Skip to main content

config_forge/
lib.rs

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}