1use std::fs;
2use std::path::Path;
3use std::str::FromStr;
4
5use anyhow::{Context, Result, bail};
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8
9pub const NAME: &str = "ConfigForge";
10pub const VERSION: &str = env!("CARGO_PKG_VERSION");
11
12pub fn describe() -> &'static str {
13 "A CLI tool for converting, inspecting, and validating configuration files."
14}
15
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
17#[serde(untagged)]
18pub enum ConfigValue {
19 Null,
20 Bool(bool),
21 Integer(i64),
22 Float(f64),
23 String(String),
24 Array(Vec<ConfigValue>),
25 Object(IndexMap<String, ConfigValue>),
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum Format {
30 Json,
31 Toml,
32 Yaml,
33}
34
35impl Format {
36 pub fn detect_path(path: &Path) -> Result<Self> {
37 let extension = path
38 .extension()
39 .and_then(|value| value.to_str())
40 .map(str::to_ascii_lowercase)
41 .with_context(|| format!("cannot detect format for `{}`", path.display()))?;
42
43 match extension.as_str() {
44 "json" => Ok(Self::Json),
45 "toml" => Ok(Self::Toml),
46 "yaml" | "yml" => Ok(Self::Yaml),
47 _ => bail!("unsupported file extension `{extension}`"),
48 }
49 }
50
51 pub fn name(self) -> &'static str {
52 match self {
53 Self::Json => "json",
54 Self::Toml => "toml",
55 Self::Yaml => "yaml",
56 }
57 }
58}
59
60impl FromStr for Format {
61 type Err = anyhow::Error;
62
63 fn from_str(value: &str) -> Result<Self> {
64 match value.to_ascii_lowercase().as_str() {
65 "json" => Ok(Self::Json),
66 "toml" => Ok(Self::Toml),
67 "yaml" | "yml" => Ok(Self::Yaml),
68 _ => bail!("unsupported format `{value}`"),
69 }
70 }
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum ValueFormat {
75 String,
76 Json,
77}
78
79impl FromStr for ValueFormat {
80 type Err = anyhow::Error;
81
82 fn from_str(value: &str) -> Result<Self> {
83 match value.to_ascii_lowercase().as_str() {
84 "string" => Ok(Self::String),
85 "json" => Ok(Self::Json),
86 _ => bail!("unsupported value format `{value}`"),
87 }
88 }
89}
90
91#[derive(Debug)]
92pub struct DocumentInfo {
93 pub format: Format,
94 pub root_kind: &'static str,
95 pub size_bytes: u64,
96}
97
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub enum DiffKind {
100 Added,
101 Removed,
102 Changed,
103}
104
105impl DiffKind {
106 pub fn name(&self) -> &'static str {
107 match self {
108 Self::Added => "added",
109 Self::Removed => "removed",
110 Self::Changed => "changed",
111 }
112 }
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct DiffEntry {
117 pub kind: DiffKind,
118 pub path: String,
119}
120
121pub fn inspect_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<DocumentInfo> {
122 let path = path.as_ref();
123 let format = match format {
124 Some(format) => format,
125 None => Format::detect_path(path)?,
126 };
127 let content = fs::read_to_string(path)
128 .with_context(|| format!("failed to read input file `{}`", path.display()))?;
129 let value = parse_str(&content, format)?;
130 let metadata = fs::metadata(path)
131 .with_context(|| format!("failed to read metadata for `{}`", path.display()))?;
132
133 Ok(DocumentInfo {
134 format,
135 root_kind: value.kind(),
136 size_bytes: metadata.len(),
137 })
138}
139
140pub fn validate_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<Format> {
141 let path = path.as_ref();
142 let format = match format {
143 Some(format) => format,
144 None => Format::detect_path(path)?,
145 };
146
147 let content = fs::read_to_string(path)
148 .with_context(|| format!("failed to read input file `{}`", path.display()))?;
149 parse_str(&content, format)?;
150
151 Ok(format)
152}
153
154pub fn convert_path(
155 input: impl AsRef<Path>,
156 output: Option<impl AsRef<Path>>,
157 from: Option<Format>,
158 to: Option<Format>,
159 overwrite: bool,
160) -> Result<String> {
161 let input = input.as_ref();
162 let input_format = match from {
163 Some(format) => format,
164 None => Format::detect_path(input)?,
165 };
166 let output_format = match (to, output.as_ref().map(|path| path.as_ref())) {
167 (Some(format), _) => format,
168 (None, Some(path)) => Format::detect_path(path)?,
169 (None, None) => bail!("output format is required when writing to stdout"),
170 };
171
172 if let Some(output) = output.as_ref().map(|path| path.as_ref()) {
173 ensure_output_can_be_written(output, overwrite)?;
174 }
175
176 let content = fs::read_to_string(input)
177 .with_context(|| format!("failed to read input file `{}`", input.display()))?;
178 let value = parse_str(&content, input_format)?;
179 let rendered = render_string(&value, output_format)?;
180
181 if let Some(output) = output {
182 fs::write(output.as_ref(), &rendered).with_context(|| {
183 format!(
184 "failed to write output file `{}`",
185 output.as_ref().display()
186 )
187 })?;
188 }
189
190 Ok(rendered)
191}
192
193pub fn set_path_value(
194 input: impl AsRef<Path>,
195 output: impl AsRef<Path>,
196 query: &str,
197 raw_value: &str,
198 value_format: ValueFormat,
199 from: Option<Format>,
200 to: Option<Format>,
201 overwrite: bool,
202) -> Result<String> {
203 let input = input.as_ref();
204 let output = output.as_ref();
205 let input_format = match from {
206 Some(format) => format,
207 None => Format::detect_path(input)?,
208 };
209 let output_format = match to {
210 Some(format) => format,
211 None => Format::detect_path(output)?,
212 };
213 ensure_output_can_be_written(output, overwrite)?;
214
215 let content = fs::read_to_string(input)
216 .with_context(|| format!("failed to read input file `{}`", input.display()))?;
217 let mut value = parse_str(&content, input_format)?;
218 let replacement = parse_value_literal(raw_value, value_format)?;
219 set_query_value(&mut value, query, replacement)?;
220 let rendered = render_string(&value, output_format)?;
221 write_output(output, &rendered)?;
222
223 Ok(rendered)
224}
225
226pub fn delete_path_value(
227 input: impl AsRef<Path>,
228 output: impl AsRef<Path>,
229 query: &str,
230 from: Option<Format>,
231 to: Option<Format>,
232 overwrite: bool,
233) -> Result<String> {
234 let input = input.as_ref();
235 let output = output.as_ref();
236 let input_format = match from {
237 Some(format) => format,
238 None => Format::detect_path(input)?,
239 };
240 let output_format = match to {
241 Some(format) => format,
242 None => Format::detect_path(output)?,
243 };
244 ensure_output_can_be_written(output, overwrite)?;
245
246 let content = fs::read_to_string(input)
247 .with_context(|| format!("failed to read input file `{}`", input.display()))?;
248 let mut value = parse_str(&content, input_format)?;
249 delete_query_value(&mut value, query)?;
250 let rendered = render_string(&value, output_format)?;
251 write_output(output, &rendered)?;
252
253 Ok(rendered)
254}
255
256pub fn merge_paths(
257 base: impl AsRef<Path>,
258 override_file: impl AsRef<Path>,
259 output: impl AsRef<Path>,
260 base_format: Option<Format>,
261 override_format: Option<Format>,
262 output_format: Option<Format>,
263 overwrite: bool,
264) -> Result<String> {
265 let base = base.as_ref();
266 let override_file = override_file.as_ref();
267 let output = output.as_ref();
268 let base_format = match base_format {
269 Some(format) => format,
270 None => Format::detect_path(base)?,
271 };
272 let override_format = match override_format {
273 Some(format) => format,
274 None => Format::detect_path(override_file)?,
275 };
276 let output_format = match output_format {
277 Some(format) => format,
278 None => Format::detect_path(output)?,
279 };
280 ensure_output_can_be_written(output, overwrite)?;
281
282 let base_content = fs::read_to_string(base)
283 .with_context(|| format!("failed to read base file `{}`", base.display()))?;
284 let override_content = fs::read_to_string(override_file)
285 .with_context(|| format!("failed to read override file `{}`", override_file.display()))?;
286 let mut value = parse_str(&base_content, base_format)?;
287 let override_value = parse_str(&override_content, override_format)?;
288 merge_values(&mut value, override_value);
289 let rendered = render_string(&value, output_format)?;
290 write_output(output, &rendered)?;
291
292 Ok(rendered)
293}
294
295pub fn diff_paths(
296 old: impl AsRef<Path>,
297 new: impl AsRef<Path>,
298 old_format: Option<Format>,
299 new_format: Option<Format>,
300) -> Result<Vec<DiffEntry>> {
301 let old = old.as_ref();
302 let new = new.as_ref();
303 let old_format = match old_format {
304 Some(format) => format,
305 None => Format::detect_path(old)?,
306 };
307 let new_format = match new_format {
308 Some(format) => format,
309 None => Format::detect_path(new)?,
310 };
311
312 let old_content = fs::read_to_string(old)
313 .with_context(|| format!("failed to read old file `{}`", old.display()))?;
314 let new_content = fs::read_to_string(new)
315 .with_context(|| format!("failed to read new file `{}`", new.display()))?;
316 let old_value = parse_str(&old_content, old_format)?;
317 let new_value = parse_str(&new_content, new_format)?;
318 let mut entries = Vec::new();
319 diff_values(&old_value, &new_value, "", &mut entries);
320
321 Ok(entries)
322}
323
324pub fn render_diff(entries: &[DiffEntry]) -> String {
325 if entries.is_empty() {
326 return "no changes\n".to_string();
327 }
328
329 let mut rendered = String::new();
330 for entry in entries {
331 rendered.push_str(entry.kind.name());
332 rendered.push(' ');
333 rendered.push_str(&entry.path);
334 rendered.push('\n');
335 }
336 rendered
337}
338
339pub fn check_convert_path(
340 input: impl AsRef<Path>,
341 from: Option<Format>,
342 to: Option<Format>,
343) -> Result<Format> {
344 let input = input.as_ref();
345 let input_format = match from {
346 Some(format) => format,
347 None => Format::detect_path(input)?,
348 };
349 let output_format = to.context("output format is required for --check")?;
350
351 let content = fs::read_to_string(input)
352 .with_context(|| format!("failed to read input file `{}`", input.display()))?;
353 let value = parse_str(&content, input_format)?;
354 render_string(&value, output_format)?;
355
356 Ok(output_format)
357}
358
359pub fn get_path_value(
360 input: impl AsRef<Path>,
361 query: &str,
362 from: Option<Format>,
363) -> Result<ConfigValue> {
364 let input = input.as_ref();
365 let input_format = match from {
366 Some(format) => format,
367 None => Format::detect_path(input)?,
368 };
369
370 let content = fs::read_to_string(input)
371 .with_context(|| format!("failed to read input file `{}`", input.display()))?;
372 let value = parse_str(&content, input_format)?;
373 query_value(&value, query).cloned()
374}
375
376pub fn render_query_value(value: &ConfigValue, format: Option<Format>) -> Result<String> {
377 match format {
378 Some(format) => render_string(value, format),
379 None => match value {
380 ConfigValue::Null => Ok("null\n".to_string()),
381 ConfigValue::Bool(value) => Ok(format!("{value}\n")),
382 ConfigValue::Integer(value) => Ok(format!("{value}\n")),
383 ConfigValue::Float(value) => Ok(format!("{value}\n")),
384 ConfigValue::String(value) => Ok(format!("{value}\n")),
385 ConfigValue::Array(_) | ConfigValue::Object(_) => render_string(value, Format::Json),
386 },
387 }
388}
389
390pub fn parse_str(content: &str, format: Format) -> Result<ConfigValue> {
391 match format {
392 Format::Json => {
393 let value: serde_json::Value =
394 serde_json::from_str(content).context("failed to parse JSON")?;
395 json_to_config(value)
396 }
397 Format::Toml => {
398 let value: toml::Value = toml::from_str(content).context("failed to parse TOML")?;
399 toml_to_config(value)
400 }
401 Format::Yaml => serde_yaml::from_str(content).context("failed to parse YAML"),
402 }
403}
404
405pub fn render_string(value: &ConfigValue, format: Format) -> Result<String> {
406 match format {
407 Format::Json => {
408 let mut rendered =
409 serde_json::to_string_pretty(value).context("failed to render JSON")?;
410 rendered.push('\n');
411 Ok(rendered)
412 }
413 Format::Toml => {
414 if !matches!(value, ConfigValue::Object(_)) {
415 bail!("TOML output requires an object at the document root");
416 }
417 let rendered = toml::to_string_pretty(value).context("failed to render TOML")?;
418 Ok(rendered)
419 }
420 Format::Yaml => serde_yaml::to_string(value).context("failed to render YAML"),
421 }
422}
423
424impl ConfigValue {
425 pub fn kind(&self) -> &'static str {
426 match self {
427 Self::Null => "null",
428 Self::Bool(_) => "bool",
429 Self::Integer(_) => "integer",
430 Self::Float(_) => "float",
431 Self::String(_) => "string",
432 Self::Array(_) => "array",
433 Self::Object(_) => "object",
434 }
435 }
436}
437
438fn query_value<'a>(value: &'a ConfigValue, query: &str) -> Result<&'a ConfigValue> {
439 let segments = path_segments(query)?;
440
441 let mut current = value;
442 let mut visited: Vec<&str> = Vec::new();
443
444 for segment in segments {
445 match current {
446 ConfigValue::Object(values) => {
447 visited.push(segment);
448 current = values
449 .get(segment)
450 .with_context(|| format!("path `{}` not found", visited.join(".")))?;
451 }
452 ConfigValue::Array(values) => {
453 let index = segment.parse::<usize>().with_context(|| {
454 format!("path segment `{segment}` cannot be applied to array")
455 })?;
456 visited.push(segment);
457 current = values
458 .get(index)
459 .with_context(|| format!("path `{}` index out of bounds", visited.join(".")))?;
460 }
461 scalar => {
462 bail!(
463 "path segment `{segment}` cannot be applied to {}",
464 scalar.kind()
465 );
466 }
467 }
468 }
469
470 Ok(current)
471}
472
473fn set_query_value(value: &mut ConfigValue, query: &str, replacement: ConfigValue) -> Result<()> {
474 let (parent, segment) = query_parent_mut(value, query)?;
475
476 match parent {
477 ConfigValue::Object(values) => {
478 let slot = values
479 .get_mut(segment)
480 .with_context(|| format!("path `{query}` not found"))?;
481 *slot = replacement;
482 Ok(())
483 }
484 ConfigValue::Array(values) => {
485 let index = segment
486 .parse::<usize>()
487 .with_context(|| format!("path segment `{segment}` cannot be applied to array"))?;
488 let slot = values
489 .get_mut(index)
490 .with_context(|| format!("path `{query}` index out of bounds"))?;
491 *slot = replacement;
492 Ok(())
493 }
494 scalar => {
495 bail!(
496 "path segment `{segment}` cannot be applied to {}",
497 scalar.kind()
498 );
499 }
500 }
501}
502
503fn delete_query_value(value: &mut ConfigValue, query: &str) -> Result<()> {
504 let (parent, segment) = query_parent_mut(value, query)?;
505
506 match parent {
507 ConfigValue::Object(values) => {
508 if values.shift_remove(segment).is_none() {
509 bail!("path `{query}` not found");
510 }
511 Ok(())
512 }
513 ConfigValue::Array(values) => {
514 let index = segment
515 .parse::<usize>()
516 .with_context(|| format!("path segment `{segment}` cannot be applied to array"))?;
517 if index >= values.len() {
518 bail!("path `{query}` index out of bounds");
519 }
520 values.remove(index);
521 Ok(())
522 }
523 scalar => {
524 bail!(
525 "path segment `{segment}` cannot be applied to {}",
526 scalar.kind()
527 );
528 }
529 }
530}
531
532fn query_parent_mut<'a, 'q>(
533 value: &'a mut ConfigValue,
534 query: &'q str,
535) -> Result<(&'a mut ConfigValue, &'q str)> {
536 let segments = path_segments(query)?;
537 let (segment, parents) = segments
538 .split_last()
539 .expect("path_segments rejects empty paths");
540 let mut current = value;
541 let mut visited: Vec<&str> = Vec::new();
542
543 for parent_segment in parents {
544 match current {
545 ConfigValue::Object(values) => {
546 visited.push(parent_segment);
547 current = values
548 .get_mut(*parent_segment)
549 .with_context(|| format!("path `{}` not found", visited.join(".")))?;
550 }
551 ConfigValue::Array(values) => {
552 let index = parent_segment.parse::<usize>().with_context(|| {
553 format!("path segment `{parent_segment}` cannot be applied to array")
554 })?;
555 visited.push(parent_segment);
556 current = values
557 .get_mut(index)
558 .with_context(|| format!("path `{}` index out of bounds", visited.join(".")))?;
559 }
560 scalar => {
561 bail!(
562 "path segment `{parent_segment}` cannot be applied to {}",
563 scalar.kind()
564 );
565 }
566 }
567 }
568
569 Ok((current, *segment))
570}
571
572fn path_segments(query: &str) -> Result<Vec<&str>> {
573 if query.is_empty() {
574 bail!("empty path is not supported");
575 }
576
577 let segments = query.split('.').collect::<Vec<_>>();
578 if segments.iter().any(|segment| segment.is_empty()) {
579 bail!("empty path segment is not supported in `{query}`");
580 }
581
582 Ok(segments)
583}
584
585fn parse_value_literal(raw_value: &str, value_format: ValueFormat) -> Result<ConfigValue> {
586 match value_format {
587 ValueFormat::String => Ok(ConfigValue::String(raw_value.to_string())),
588 ValueFormat::Json => {
589 let value: serde_json::Value =
590 serde_json::from_str(raw_value).context("failed to parse JSON value")?;
591 json_to_config(value)
592 }
593 }
594}
595
596fn ensure_output_can_be_written(output: &Path, overwrite: bool) -> Result<()> {
597 if output.exists() && !overwrite {
598 bail!(
599 "output file `{}` already exists; pass --overwrite to replace it",
600 output.display()
601 );
602 }
603
604 Ok(())
605}
606
607fn write_output(output: &Path, rendered: &str) -> Result<()> {
608 fs::write(output, rendered)
609 .with_context(|| format!("failed to write output file `{}`", output.display()))
610}
611
612fn merge_values(base: &mut ConfigValue, override_value: ConfigValue) {
613 match (base, override_value) {
614 (ConfigValue::Object(base_values), ConfigValue::Object(override_values)) => {
615 for (key, override_value) in override_values {
616 match base_values.get_mut(&key) {
617 Some(base_value) => merge_values(base_value, override_value),
618 None => {
619 base_values.insert(key, override_value);
620 }
621 }
622 }
623 }
624 (base_value, override_value) => {
625 *base_value = override_value;
626 }
627 }
628}
629
630fn diff_values(old: &ConfigValue, new: &ConfigValue, path: &str, entries: &mut Vec<DiffEntry>) {
631 match (old, new) {
632 (ConfigValue::Object(old_values), ConfigValue::Object(new_values)) => {
633 for (key, old_value) in old_values {
634 let child_path = join_path(path, key);
635 match new_values.get(key) {
636 Some(new_value) => diff_values(old_value, new_value, &child_path, entries),
637 None => entries.push(DiffEntry {
638 kind: DiffKind::Removed,
639 path: child_path,
640 }),
641 }
642 }
643
644 for key in new_values.keys() {
645 if !old_values.contains_key(key) {
646 entries.push(DiffEntry {
647 kind: DiffKind::Added,
648 path: join_path(path, key),
649 });
650 }
651 }
652 }
653 (ConfigValue::Array(old_values), ConfigValue::Array(new_values)) => {
654 let shared_len = old_values.len().min(new_values.len());
655 for index in 0..shared_len {
656 let child_path = join_path(path, &index.to_string());
657 diff_values(&old_values[index], &new_values[index], &child_path, entries);
658 }
659
660 for index in shared_len..old_values.len() {
661 entries.push(DiffEntry {
662 kind: DiffKind::Removed,
663 path: join_path(path, &index.to_string()),
664 });
665 }
666
667 for index in shared_len..new_values.len() {
668 entries.push(DiffEntry {
669 kind: DiffKind::Added,
670 path: join_path(path, &index.to_string()),
671 });
672 }
673 }
674 _ => {
675 if old != new {
676 entries.push(DiffEntry {
677 kind: DiffKind::Changed,
678 path: display_path(path),
679 });
680 }
681 }
682 }
683}
684
685fn join_path(path: &str, segment: &str) -> String {
686 if path.is_empty() {
687 segment.to_string()
688 } else {
689 format!("{path}.{segment}")
690 }
691}
692
693fn display_path(path: &str) -> String {
694 if path.is_empty() {
695 "<root>".to_string()
696 } else {
697 path.to_string()
698 }
699}
700
701fn json_to_config(value: serde_json::Value) -> Result<ConfigValue> {
702 match value {
703 serde_json::Value::Null => Ok(ConfigValue::Null),
704 serde_json::Value::Bool(value) => Ok(ConfigValue::Bool(value)),
705 serde_json::Value::Number(value) => {
706 if let Some(value) = value.as_i64() {
707 Ok(ConfigValue::Integer(value))
708 } else if value.as_u64().is_some() {
709 bail!("JSON unsigned integer exceeds the supported i64 range")
710 } else if let Some(value) = value.as_f64() {
711 Ok(ConfigValue::Float(value))
712 } else {
713 bail!("unsupported JSON number `{value}`")
714 }
715 }
716 serde_json::Value::String(value) => Ok(ConfigValue::String(value)),
717 serde_json::Value::Array(values) => values
718 .into_iter()
719 .map(json_to_config)
720 .collect::<Result<Vec<_>>>()
721 .map(ConfigValue::Array),
722 serde_json::Value::Object(values) => values
723 .into_iter()
724 .map(|(key, value)| json_to_config(value).map(|value| (key, value)))
725 .collect::<Result<IndexMap<_, _>>>()
726 .map(ConfigValue::Object),
727 }
728}
729
730fn toml_to_config(value: toml::Value) -> Result<ConfigValue> {
731 match value {
732 toml::Value::String(value) => Ok(ConfigValue::String(value)),
733 toml::Value::Integer(value) => Ok(ConfigValue::Integer(value)),
734 toml::Value::Float(value) => Ok(ConfigValue::Float(value)),
735 toml::Value::Boolean(value) => Ok(ConfigValue::Bool(value)),
736 toml::Value::Datetime(value) => Ok(ConfigValue::String(value.to_string())),
737 toml::Value::Array(values) => values
738 .into_iter()
739 .map(toml_to_config)
740 .collect::<Result<Vec<_>>>()
741 .map(ConfigValue::Array),
742 toml::Value::Table(values) => values
743 .into_iter()
744 .map(|(key, value)| toml_to_config(value).map(|value| (key, value)))
745 .collect::<Result<IndexMap<_, _>>>()
746 .map(ConfigValue::Object),
747 }
748}