|
| 1 | +use std::fmt::Write as _; |
| 2 | +use std::io::Write; |
| 3 | +use std::path::{Path, PathBuf}; |
| 4 | + |
| 5 | +use anyhow::{Context, Result}; |
| 6 | +use owo_colors::OwoColorize; |
| 7 | +use prek_consts::PREK_TOML; |
| 8 | +use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Table, Value}; |
| 9 | + |
| 10 | +use crate::cli::ExitStatus; |
| 11 | +use crate::config; |
| 12 | +use crate::fs::Simplified; |
| 13 | +use crate::printer::Printer; |
| 14 | + |
| 15 | +pub(crate) fn yaml_to_toml( |
| 16 | + input: &Path, |
| 17 | + output: Option<PathBuf>, |
| 18 | + force: bool, |
| 19 | + printer: Printer, |
| 20 | +) -> Result<ExitStatus> { |
| 21 | + // Validate the input file first. |
| 22 | + let _ = config::load_config(input)?; |
| 23 | + |
| 24 | + let content = fs_err::read_to_string(input)?; |
| 25 | + let value: serde_json::Value = serde_saphyr::from_str(&content)?; |
| 26 | + |
| 27 | + let output = output.unwrap_or_else(|| input.parent().unwrap_or(Path::new(".")).join(PREK_TOML)); |
| 28 | + |
| 29 | + if output == input { |
| 30 | + anyhow::bail!( |
| 31 | + "Output path `{}` matches input; choose a different output path", |
| 32 | + output.simplified_display().cyan() |
| 33 | + ); |
| 34 | + } |
| 35 | + |
| 36 | + let mut rendered = json_to_toml(&value)?; |
| 37 | + if !rendered.ends_with('\n') { |
| 38 | + rendered.push('\n'); |
| 39 | + } |
| 40 | + |
| 41 | + if let Some(parent) = output.parent() { |
| 42 | + fs_err::create_dir_all(parent)?; |
| 43 | + } |
| 44 | + |
| 45 | + let mut options = fs_err::OpenOptions::new(); |
| 46 | + options.write(true); |
| 47 | + if force { |
| 48 | + options.create(true).truncate(true); |
| 49 | + } else { |
| 50 | + options.create_new(true); |
| 51 | + } |
| 52 | + |
| 53 | + let mut file = match options.open(&output) { |
| 54 | + Ok(file) => file, |
| 55 | + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { |
| 56 | + anyhow::bail!( |
| 57 | + "File `{}` already exists (use `--force` to overwrite)", |
| 58 | + output.simplified_display().cyan() |
| 59 | + ); |
| 60 | + } |
| 61 | + Err(err) => return Err(err.into()), |
| 62 | + }; |
| 63 | + |
| 64 | + file.write_all(rendered.as_bytes())?; |
| 65 | + |
| 66 | + writeln!( |
| 67 | + printer.stdout(), |
| 68 | + "Written to `{}`", |
| 69 | + output.simplified_display().cyan() |
| 70 | + )?; |
| 71 | + |
| 72 | + Ok(ExitStatus::Success) |
| 73 | +} |
| 74 | + |
| 75 | +fn json_to_toml(value: &serde_json::Value) -> Result<String> { |
| 76 | + let map = value |
| 77 | + .as_object() |
| 78 | + .context("Expected a top-level mapping in the config file")?; |
| 79 | + |
| 80 | + let mut doc = DocumentMut::new(); |
| 81 | + for (key, value) in map { |
| 82 | + if key == "repos" { |
| 83 | + let repos = value.as_array().context("`repos` must be an array")?; |
| 84 | + doc["repos"] = repos_to_array_of_tables(repos)?.into(); |
| 85 | + continue; |
| 86 | + } |
| 87 | + doc[key] = json_to_toml_value(value).into(); |
| 88 | + } |
| 89 | + |
| 90 | + Ok(doc.to_string()) |
| 91 | +} |
| 92 | + |
| 93 | +fn json_to_toml_value(value: &serde_json::Value) -> Value { |
| 94 | + match value { |
| 95 | + serde_json::Value::Null => Value::from(""), |
| 96 | + serde_json::Value::Bool(value) => Value::from(*value), |
| 97 | + serde_json::Value::Number(value) => { |
| 98 | + if let Some(value) = value.as_i64() { |
| 99 | + Value::from(value) |
| 100 | + } else if let Some(value) = value.as_f64() { |
| 101 | + Value::from(value) |
| 102 | + } else { |
| 103 | + Value::from(0.0) |
| 104 | + } |
| 105 | + } |
| 106 | + serde_json::Value::String(value) => Value::from(value.as_str()), |
| 107 | + serde_json::Value::Array(values) => { |
| 108 | + json_array_to_value_with_indent(values, " ", " ", false) |
| 109 | + } |
| 110 | + serde_json::Value::Object(values) => Value::InlineTable(json_object_to_inline(values)), |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +fn json_array_to_value_with_indent( |
| 115 | + values: &[serde_json::Value], |
| 116 | + item_indent: &str, |
| 117 | + closing_indent: &str, |
| 118 | + force_multiline: bool, |
| 119 | +) -> Value { |
| 120 | + let mut array = Array::new(); |
| 121 | + if values.len() == 1 && !force_multiline { |
| 122 | + let value = match &values[0] { |
| 123 | + serde_json::Value::Object(map) => Value::InlineTable(json_object_to_inline(map)), |
| 124 | + _ => json_to_toml_value(&values[0]), |
| 125 | + }; |
| 126 | + array.push(value); |
| 127 | + array.set_trailing(""); |
| 128 | + return Value::Array(array); |
| 129 | + } |
| 130 | + |
| 131 | + for value in values { |
| 132 | + let mut value = match value { |
| 133 | + serde_json::Value::Object(map) => Value::InlineTable(json_object_to_inline(map)), |
| 134 | + _ => json_to_toml_value(value), |
| 135 | + }; |
| 136 | + value.decor_mut().set_prefix(format!("\n{item_indent}")); |
| 137 | + array.push(value); |
| 138 | + } |
| 139 | + array.set_trailing(format!("\n{closing_indent}")); |
| 140 | + Value::Array(array) |
| 141 | +} |
| 142 | + |
| 143 | +fn json_object_to_inline(values: &serde_json::Map<String, serde_json::Value>) -> InlineTable { |
| 144 | + let mut table = InlineTable::new(); |
| 145 | + for (key, value) in values { |
| 146 | + let value = match value { |
| 147 | + serde_json::Value::Array(values) => { |
| 148 | + json_array_to_value_with_indent(values, " ", " ", false) |
| 149 | + } |
| 150 | + _ => json_to_toml_value(value), |
| 151 | + }; |
| 152 | + table.insert(key.as_str(), value); |
| 153 | + } |
| 154 | + format_inline_table_multiline(&mut table, " ", " "); |
| 155 | + table |
| 156 | +} |
| 157 | + |
| 158 | +fn format_inline_table_multiline(table: &mut InlineTable, base_indent: &str, closing_indent: &str) { |
| 159 | + let len = table.len(); |
| 160 | + if len <= 1 { |
| 161 | + return; |
| 162 | + } |
| 163 | + for (idx, (mut key, value)) in table.iter_mut().enumerate() { |
| 164 | + key.leaf_decor_mut().set_prefix(format!("\n{base_indent}")); |
| 165 | + key.leaf_decor_mut().set_suffix(" "); |
| 166 | + |
| 167 | + let suffix = if idx + 1 == len { |
| 168 | + format!("\n{closing_indent}") |
| 169 | + } else { |
| 170 | + String::new() |
| 171 | + }; |
| 172 | + value.decor_mut().set_prefix(" "); |
| 173 | + value.decor_mut().set_suffix(suffix); |
| 174 | + |
| 175 | + if let Value::InlineTable(inner) = value { |
| 176 | + let nested_base = format!("{base_indent} "); |
| 177 | + let nested_closing = format!("{closing_indent} "); |
| 178 | + format_inline_table_multiline(inner, &nested_base, &nested_closing); |
| 179 | + } |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +fn repos_to_array_of_tables(values: &[serde_json::Value]) -> Result<ArrayOfTables> { |
| 184 | + let mut array = ArrayOfTables::new(); |
| 185 | + for value in values { |
| 186 | + let map = value |
| 187 | + .as_object() |
| 188 | + .context("Each repo entry must be a mapping")?; |
| 189 | + let mut table = Table::new(); |
| 190 | + for (key, value) in map { |
| 191 | + if key == "hooks" { |
| 192 | + let hooks = value.as_array().context("`hooks` must be an array")?; |
| 193 | + table[key] = json_array_to_value_with_indent(hooks, " ", "", true).into(); |
| 194 | + continue; |
| 195 | + } |
| 196 | + table[key] = json_to_toml_value(value).into(); |
| 197 | + } |
| 198 | + array.push(table); |
| 199 | + } |
| 200 | + Ok(array) |
| 201 | +} |
0 commit comments