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    Env,
34    Ini,
35    Properties,
36}
37
38impl Format {
39    pub fn detect_path(path: &Path) -> Result<Self> {
40        if path
41            .file_name()
42            .and_then(|value| value.to_str())
43            .is_some_and(|name| name.eq_ignore_ascii_case(".env"))
44        {
45            return Ok(Self::Env);
46        }
47
48        let extension = path
49            .extension()
50            .and_then(|value| value.to_str())
51            .map(str::to_ascii_lowercase)
52            .with_context(|| format!("cannot detect format for `{}`", path.display()))?;
53
54        match extension.as_str() {
55            "json" => Ok(Self::Json),
56            "toml" => Ok(Self::Toml),
57            "yaml" | "yml" => Ok(Self::Yaml),
58            "env" => Ok(Self::Env),
59            "ini" => Ok(Self::Ini),
60            "properties" => Ok(Self::Properties),
61            _ => bail!("unsupported file extension `{extension}`"),
62        }
63    }
64
65    pub fn name(self) -> &'static str {
66        match self {
67            Self::Json => "json",
68            Self::Toml => "toml",
69            Self::Yaml => "yaml",
70            Self::Env => "env",
71            Self::Ini => "ini",
72            Self::Properties => "properties",
73        }
74    }
75}
76
77impl FromStr for Format {
78    type Err = anyhow::Error;
79
80    fn from_str(value: &str) -> Result<Self> {
81        match value.to_ascii_lowercase().as_str() {
82            "json" => Ok(Self::Json),
83            "toml" => Ok(Self::Toml),
84            "yaml" | "yml" => Ok(Self::Yaml),
85            "env" | "dotenv" => Ok(Self::Env),
86            "ini" => Ok(Self::Ini),
87            "properties" | "props" => Ok(Self::Properties),
88            _ => bail!("unsupported format `{value}`"),
89        }
90    }
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94pub enum ValueFormat {
95    String,
96    Json,
97}
98
99impl FromStr for ValueFormat {
100    type Err = anyhow::Error;
101
102    fn from_str(value: &str) -> Result<Self> {
103        match value.to_ascii_lowercase().as_str() {
104            "string" => Ok(Self::String),
105            "json" => Ok(Self::Json),
106            _ => bail!("unsupported value format `{value}`"),
107        }
108    }
109}
110
111#[derive(Debug)]
112pub struct DocumentInfo {
113    pub format: Format,
114    pub root_kind: &'static str,
115    pub size_bytes: u64,
116}
117
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub enum DiffKind {
120    Added,
121    Removed,
122    Changed,
123}
124
125impl DiffKind {
126    pub fn name(&self) -> &'static str {
127        match self {
128            Self::Added => "added",
129            Self::Removed => "removed",
130            Self::Changed => "changed",
131        }
132    }
133}
134
135#[derive(Clone, Debug, Eq, PartialEq)]
136pub struct DiffEntry {
137    pub kind: DiffKind,
138    pub path: String,
139}
140
141pub fn inspect_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<DocumentInfo> {
142    let path = path.as_ref();
143    let format = match format {
144        Some(format) => format,
145        None => Format::detect_path(path)?,
146    };
147    let content = fs::read_to_string(path)
148        .with_context(|| format!("failed to read input file `{}`", path.display()))?;
149    let value = parse_str(&content, format)?;
150    let metadata = fs::metadata(path)
151        .with_context(|| format!("failed to read metadata for `{}`", path.display()))?;
152
153    Ok(DocumentInfo {
154        format,
155        root_kind: value.kind(),
156        size_bytes: metadata.len(),
157    })
158}
159
160pub fn validate_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<Format> {
161    let path = path.as_ref();
162    let format = match format {
163        Some(format) => format,
164        None => Format::detect_path(path)?,
165    };
166
167    let content = fs::read_to_string(path)
168        .with_context(|| format!("failed to read input file `{}`", path.display()))?;
169    parse_str(&content, format)?;
170
171    Ok(format)
172}
173
174pub fn convert_path(
175    input: impl AsRef<Path>,
176    output: Option<impl AsRef<Path>>,
177    from: Option<Format>,
178    to: Option<Format>,
179    overwrite: bool,
180) -> Result<String> {
181    let input = input.as_ref();
182    let input_format = match from {
183        Some(format) => format,
184        None => Format::detect_path(input)?,
185    };
186    let output_format = match (to, output.as_ref().map(|path| path.as_ref())) {
187        (Some(format), _) => format,
188        (None, Some(path)) => Format::detect_path(path)?,
189        (None, None) => bail!("output format is required when writing to stdout"),
190    };
191
192    if let Some(output) = output.as_ref().map(|path| path.as_ref()) {
193        ensure_output_can_be_written(output, overwrite)?;
194    }
195
196    let content = fs::read_to_string(input)
197        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
198    let value = parse_str(&content, input_format)?;
199    let rendered = render_string(&value, output_format)?;
200
201    if let Some(output) = output {
202        fs::write(output.as_ref(), &rendered).with_context(|| {
203            format!(
204                "failed to write output file `{}`",
205                output.as_ref().display()
206            )
207        })?;
208    }
209
210    Ok(rendered)
211}
212
213pub fn set_path_value(
214    input: impl AsRef<Path>,
215    output: impl AsRef<Path>,
216    query: &str,
217    raw_value: &str,
218    value_format: ValueFormat,
219    from: Option<Format>,
220    to: Option<Format>,
221    overwrite: bool,
222) -> Result<String> {
223    let input = input.as_ref();
224    let output = output.as_ref();
225    let input_format = match from {
226        Some(format) => format,
227        None => Format::detect_path(input)?,
228    };
229    let output_format = match to {
230        Some(format) => format,
231        None => Format::detect_path(output)?,
232    };
233    ensure_output_can_be_written(output, overwrite)?;
234
235    let content = fs::read_to_string(input)
236        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
237    let mut value = parse_str(&content, input_format)?;
238    let replacement = parse_value_literal(raw_value, value_format)?;
239    set_query_value(&mut value, query, replacement)?;
240    let rendered = render_string(&value, output_format)?;
241    write_output(output, &rendered)?;
242
243    Ok(rendered)
244}
245
246pub fn delete_path_value(
247    input: impl AsRef<Path>,
248    output: impl AsRef<Path>,
249    query: &str,
250    from: Option<Format>,
251    to: Option<Format>,
252    overwrite: bool,
253) -> Result<String> {
254    let input = input.as_ref();
255    let output = output.as_ref();
256    let input_format = match from {
257        Some(format) => format,
258        None => Format::detect_path(input)?,
259    };
260    let output_format = match to {
261        Some(format) => format,
262        None => Format::detect_path(output)?,
263    };
264    ensure_output_can_be_written(output, overwrite)?;
265
266    let content = fs::read_to_string(input)
267        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
268    let mut value = parse_str(&content, input_format)?;
269    delete_query_value(&mut value, query)?;
270    let rendered = render_string(&value, output_format)?;
271    write_output(output, &rendered)?;
272
273    Ok(rendered)
274}
275
276pub fn merge_paths(
277    base: impl AsRef<Path>,
278    override_file: impl AsRef<Path>,
279    output: impl AsRef<Path>,
280    base_format: Option<Format>,
281    override_format: Option<Format>,
282    output_format: Option<Format>,
283    overwrite: bool,
284) -> Result<String> {
285    let base = base.as_ref();
286    let override_file = override_file.as_ref();
287    let output = output.as_ref();
288    let base_format = match base_format {
289        Some(format) => format,
290        None => Format::detect_path(base)?,
291    };
292    let override_format = match override_format {
293        Some(format) => format,
294        None => Format::detect_path(override_file)?,
295    };
296    let output_format = match output_format {
297        Some(format) => format,
298        None => Format::detect_path(output)?,
299    };
300    ensure_output_can_be_written(output, overwrite)?;
301
302    let base_content = fs::read_to_string(base)
303        .with_context(|| format!("failed to read base file `{}`", base.display()))?;
304    let override_content = fs::read_to_string(override_file)
305        .with_context(|| format!("failed to read override file `{}`", override_file.display()))?;
306    let mut value = parse_str(&base_content, base_format)?;
307    let override_value = parse_str(&override_content, override_format)?;
308    merge_values(&mut value, override_value);
309    let rendered = render_string(&value, output_format)?;
310    write_output(output, &rendered)?;
311
312    Ok(rendered)
313}
314
315pub fn diff_paths(
316    old: impl AsRef<Path>,
317    new: impl AsRef<Path>,
318    old_format: Option<Format>,
319    new_format: Option<Format>,
320) -> Result<Vec<DiffEntry>> {
321    let old = old.as_ref();
322    let new = new.as_ref();
323    let old_format = match old_format {
324        Some(format) => format,
325        None => Format::detect_path(old)?,
326    };
327    let new_format = match new_format {
328        Some(format) => format,
329        None => Format::detect_path(new)?,
330    };
331
332    let old_content = fs::read_to_string(old)
333        .with_context(|| format!("failed to read old file `{}`", old.display()))?;
334    let new_content = fs::read_to_string(new)
335        .with_context(|| format!("failed to read new file `{}`", new.display()))?;
336    let old_value = parse_str(&old_content, old_format)?;
337    let new_value = parse_str(&new_content, new_format)?;
338    let mut entries = Vec::new();
339    diff_values(&old_value, &new_value, "", &mut entries);
340
341    Ok(entries)
342}
343
344pub fn render_diff(entries: &[DiffEntry]) -> String {
345    if entries.is_empty() {
346        return "no changes\n".to_string();
347    }
348
349    let mut rendered = String::new();
350    for entry in entries {
351        rendered.push_str(entry.kind.name());
352        rendered.push(' ');
353        rendered.push_str(&entry.path);
354        rendered.push('\n');
355    }
356    rendered
357}
358
359pub fn check_convert_path(
360    input: impl AsRef<Path>,
361    from: Option<Format>,
362    to: Option<Format>,
363) -> Result<Format> {
364    let input = input.as_ref();
365    let input_format = match from {
366        Some(format) => format,
367        None => Format::detect_path(input)?,
368    };
369    let output_format = to.context("output format is required for --check")?;
370
371    let content = fs::read_to_string(input)
372        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
373    let value = parse_str(&content, input_format)?;
374    render_string(&value, output_format)?;
375
376    Ok(output_format)
377}
378
379pub fn get_path_value(
380    input: impl AsRef<Path>,
381    query: &str,
382    from: Option<Format>,
383) -> Result<ConfigValue> {
384    let input = input.as_ref();
385    let input_format = match from {
386        Some(format) => format,
387        None => Format::detect_path(input)?,
388    };
389
390    let content = fs::read_to_string(input)
391        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
392    let value = parse_str(&content, input_format)?;
393    query_value(&value, query).cloned()
394}
395
396pub fn render_query_value(value: &ConfigValue, format: Option<Format>) -> Result<String> {
397    match format {
398        Some(format) => render_string(value, format),
399        None => match value {
400            ConfigValue::Null => Ok("null\n".to_string()),
401            ConfigValue::Bool(value) => Ok(format!("{value}\n")),
402            ConfigValue::Integer(value) => Ok(format!("{value}\n")),
403            ConfigValue::Float(value) => Ok(format!("{value}\n")),
404            ConfigValue::String(value) => Ok(format!("{value}\n")),
405            ConfigValue::Array(_) | ConfigValue::Object(_) => render_string(value, Format::Json),
406        },
407    }
408}
409
410pub fn parse_str(content: &str, format: Format) -> Result<ConfigValue> {
411    match format {
412        Format::Json => {
413            let value: serde_json::Value =
414                serde_json::from_str(content).context("failed to parse JSON")?;
415            json_to_config(value)
416        }
417        Format::Toml => {
418            let value: toml::Value = toml::from_str(content).context("failed to parse TOML")?;
419            toml_to_config(value)
420        }
421        Format::Yaml => serde_yaml::from_str(content).context("failed to parse YAML"),
422        Format::Env => parse_env(content),
423        Format::Ini => parse_ini(content),
424        Format::Properties => parse_properties(content),
425    }
426}
427
428pub fn render_string(value: &ConfigValue, format: Format) -> Result<String> {
429    match format {
430        Format::Json => {
431            let mut rendered =
432                serde_json::to_string_pretty(value).context("failed to render JSON")?;
433            rendered.push('\n');
434            Ok(rendered)
435        }
436        Format::Toml => {
437            if !matches!(value, ConfigValue::Object(_)) {
438                bail!("TOML output requires an object at the document root");
439            }
440            let rendered = toml::to_string_pretty(value).context("failed to render TOML")?;
441            Ok(rendered)
442        }
443        Format::Yaml => serde_yaml::to_string(value).context("failed to render YAML"),
444        Format::Env => render_env(value),
445        Format::Ini => render_ini(value),
446        Format::Properties => render_properties(value),
447    }
448}
449
450impl ConfigValue {
451    pub fn kind(&self) -> &'static str {
452        match self {
453            Self::Null => "null",
454            Self::Bool(_) => "bool",
455            Self::Integer(_) => "integer",
456            Self::Float(_) => "float",
457            Self::String(_) => "string",
458            Self::Array(_) => "array",
459            Self::Object(_) => "object",
460        }
461    }
462}
463
464fn query_value<'a>(value: &'a ConfigValue, query: &str) -> Result<&'a ConfigValue> {
465    let segments = path_segments(query)?;
466
467    let mut current = value;
468    let mut visited: Vec<&str> = Vec::new();
469
470    for segment in segments {
471        match current {
472            ConfigValue::Object(values) => {
473                visited.push(segment);
474                current = values
475                    .get(segment)
476                    .with_context(|| format!("path `{}` not found", visited.join(".")))?;
477            }
478            ConfigValue::Array(values) => {
479                let index = segment.parse::<usize>().with_context(|| {
480                    format!("path segment `{segment}` cannot be applied to array")
481                })?;
482                visited.push(segment);
483                current = values
484                    .get(index)
485                    .with_context(|| format!("path `{}` index out of bounds", visited.join(".")))?;
486            }
487            scalar => {
488                bail!(
489                    "path segment `{segment}` cannot be applied to {}",
490                    scalar.kind()
491                );
492            }
493        }
494    }
495
496    Ok(current)
497}
498
499fn set_query_value(value: &mut ConfigValue, query: &str, replacement: ConfigValue) -> Result<()> {
500    let (parent, segment) = query_parent_mut(value, query)?;
501
502    match parent {
503        ConfigValue::Object(values) => {
504            let slot = values
505                .get_mut(segment)
506                .with_context(|| format!("path `{query}` not found"))?;
507            *slot = replacement;
508            Ok(())
509        }
510        ConfigValue::Array(values) => {
511            let index = segment
512                .parse::<usize>()
513                .with_context(|| format!("path segment `{segment}` cannot be applied to array"))?;
514            let slot = values
515                .get_mut(index)
516                .with_context(|| format!("path `{query}` index out of bounds"))?;
517            *slot = replacement;
518            Ok(())
519        }
520        scalar => {
521            bail!(
522                "path segment `{segment}` cannot be applied to {}",
523                scalar.kind()
524            );
525        }
526    }
527}
528
529fn delete_query_value(value: &mut ConfigValue, query: &str) -> Result<()> {
530    let (parent, segment) = query_parent_mut(value, query)?;
531
532    match parent {
533        ConfigValue::Object(values) => {
534            if values.shift_remove(segment).is_none() {
535                bail!("path `{query}` not found");
536            }
537            Ok(())
538        }
539        ConfigValue::Array(values) => {
540            let index = segment
541                .parse::<usize>()
542                .with_context(|| format!("path segment `{segment}` cannot be applied to array"))?;
543            if index >= values.len() {
544                bail!("path `{query}` index out of bounds");
545            }
546            values.remove(index);
547            Ok(())
548        }
549        scalar => {
550            bail!(
551                "path segment `{segment}` cannot be applied to {}",
552                scalar.kind()
553            );
554        }
555    }
556}
557
558fn query_parent_mut<'a, 'q>(
559    value: &'a mut ConfigValue,
560    query: &'q str,
561) -> Result<(&'a mut ConfigValue, &'q str)> {
562    let segments = path_segments(query)?;
563    let (segment, parents) = segments
564        .split_last()
565        .expect("path_segments rejects empty paths");
566    let mut current = value;
567    let mut visited: Vec<&str> = Vec::new();
568
569    for parent_segment in parents {
570        match current {
571            ConfigValue::Object(values) => {
572                visited.push(parent_segment);
573                current = values
574                    .get_mut(*parent_segment)
575                    .with_context(|| format!("path `{}` not found", visited.join(".")))?;
576            }
577            ConfigValue::Array(values) => {
578                let index = parent_segment.parse::<usize>().with_context(|| {
579                    format!("path segment `{parent_segment}` cannot be applied to array")
580                })?;
581                visited.push(parent_segment);
582                current = values
583                    .get_mut(index)
584                    .with_context(|| format!("path `{}` index out of bounds", visited.join(".")))?;
585            }
586            scalar => {
587                bail!(
588                    "path segment `{parent_segment}` cannot be applied to {}",
589                    scalar.kind()
590                );
591            }
592        }
593    }
594
595    Ok((current, *segment))
596}
597
598fn path_segments(query: &str) -> Result<Vec<&str>> {
599    if query.is_empty() {
600        bail!("empty path is not supported");
601    }
602
603    let segments = query.split('.').collect::<Vec<_>>();
604    if segments.iter().any(|segment| segment.is_empty()) {
605        bail!("empty path segment is not supported in `{query}`");
606    }
607
608    Ok(segments)
609}
610
611fn parse_env(content: &str) -> Result<ConfigValue> {
612    let mut values = IndexMap::new();
613
614    for (line_index, raw_line) in content.lines().enumerate() {
615        let line_number = line_index + 1;
616        let mut line = raw_line.trim();
617        if line.is_empty() || line.starts_with('#') {
618            continue;
619        }
620
621        if let Some(rest) = line.strip_prefix("export ") {
622            line = rest.trim_start();
623        }
624
625        let (key, raw_value) = line
626            .split_once('=')
627            .with_context(|| format!("invalid env assignment on line {line_number}"))?;
628        let key = key.trim();
629        if key.is_empty() {
630            bail!("empty env key on line {line_number}");
631        }
632
633        values.insert(
634            key.to_string(),
635            ConfigValue::String(unquote_key_value(raw_value.trim()).to_string()),
636        );
637    }
638
639    Ok(ConfigValue::Object(values))
640}
641
642fn parse_ini(content: &str) -> Result<ConfigValue> {
643    let mut values = IndexMap::new();
644    let mut current_section: Option<String> = None;
645
646    for (line_index, raw_line) in content.lines().enumerate() {
647        let line_number = line_index + 1;
648        let line = raw_line.trim();
649        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
650            continue;
651        }
652
653        if line.starts_with('[') && line.ends_with(']') {
654            let section = line[1..line.len() - 1].trim();
655            if section.is_empty() {
656                bail!("empty INI section on line {line_number}");
657            }
658            values
659                .entry(section.to_string())
660                .or_insert_with(|| ConfigValue::Object(IndexMap::new()));
661            current_section = Some(section.to_string());
662            continue;
663        }
664
665        let (key, raw_value) = line
666            .split_once('=')
667            .with_context(|| format!("invalid INI assignment on line {line_number}"))?;
668        let key = key.trim();
669        if key.is_empty() {
670            bail!("empty INI key on line {line_number}");
671        }
672
673        let value = ConfigValue::String(unquote_key_value(raw_value.trim()).to_string());
674        match current_section.as_deref() {
675            Some(section) => {
676                let section_value = values
677                    .entry(section.to_string())
678                    .or_insert_with(|| ConfigValue::Object(IndexMap::new()));
679                match section_value {
680                    ConfigValue::Object(section_values) => {
681                        section_values.insert(key.to_string(), value);
682                    }
683                    existing => {
684                        bail!(
685                            "INI section `{section}` conflicts with existing {} value",
686                            existing.kind()
687                        );
688                    }
689                }
690            }
691            None => {
692                values.insert(key.to_string(), value);
693            }
694        }
695    }
696
697    Ok(ConfigValue::Object(values))
698}
699
700fn parse_properties(content: &str) -> Result<ConfigValue> {
701    let mut values = IndexMap::new();
702
703    for (line_index, raw_line) in content.lines().enumerate() {
704        let line_number = line_index + 1;
705        let line = raw_line.trim();
706        if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
707            continue;
708        }
709
710        let (key, raw_value) = split_properties_assignment(line)
711            .with_context(|| format!("invalid properties assignment on line {line_number}"))?;
712        let key = key.trim();
713        if key.is_empty() {
714            bail!("empty properties key on line {line_number}");
715        }
716
717        insert_dotted_value(
718            &mut values,
719            key,
720            ConfigValue::String(unquote_key_value(raw_value.trim()).to_string()),
721        )?;
722    }
723
724    Ok(ConfigValue::Object(values))
725}
726
727fn split_properties_assignment(line: &str) -> Option<(&str, &str)> {
728    line.find(['=', ':'])
729        .map(|index| (&line[..index], &line[index + 1..]))
730}
731
732fn insert_dotted_value(
733    values: &mut IndexMap<String, ConfigValue>,
734    key: &str,
735    value: ConfigValue,
736) -> Result<()> {
737    let segments = path_segments(key)?;
738    insert_dotted_segments(values, key, &segments, value)
739}
740
741fn insert_dotted_segments(
742    values: &mut IndexMap<String, ConfigValue>,
743    key: &str,
744    segments: &[&str],
745    value: ConfigValue,
746) -> Result<()> {
747    let (segment, remaining) = segments
748        .split_first()
749        .expect("path_segments rejects empty paths");
750    if remaining.is_empty() {
751        if matches!(values.get(*segment), Some(ConfigValue::Object(_))) {
752            bail!("properties path `{key}` conflicts with existing object value");
753        }
754        values.insert((*segment).to_string(), value);
755        return Ok(());
756    }
757
758    let child = values
759        .entry((*segment).to_string())
760        .or_insert_with(|| ConfigValue::Object(IndexMap::new()));
761    match child {
762        ConfigValue::Object(child_values) => {
763            insert_dotted_segments(child_values, key, remaining, value)
764        }
765        existing => bail!(
766            "properties path `{key}` conflicts with existing {} value",
767            existing.kind()
768        ),
769    }
770}
771
772fn unquote_key_value(value: &str) -> &str {
773    if value.len() >= 2 {
774        let bytes = value.as_bytes();
775        if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
776            || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
777        {
778            return &value[1..value.len() - 1];
779        }
780    }
781
782    value
783}
784
785fn render_env(value: &ConfigValue) -> Result<String> {
786    let values = object_root(value, "env")?;
787    let mut rendered = String::new();
788
789    for (key, value) in values {
790        match value {
791            ConfigValue::String(value) => {
792                rendered.push_str(key);
793                rendered.push('=');
794                rendered.push_str(value);
795                rendered.push('\n');
796            }
797            _ => bail!("env output only supports top-level string values"),
798        }
799    }
800
801    Ok(rendered)
802}
803
804fn render_ini(value: &ConfigValue) -> Result<String> {
805    let values = object_root(value, "INI")?;
806    let mut rendered = String::new();
807
808    for (key, value) in values {
809        match value {
810            ConfigValue::String(value) => push_key_value_line(&mut rendered, key, value),
811            ConfigValue::Object(_) => {}
812            _ => bail!("INI output only supports top-level string values or sections"),
813        }
814    }
815
816    for (section, value) in values {
817        let ConfigValue::Object(section_values) = value else {
818            continue;
819        };
820
821        if !rendered.is_empty() && !rendered.ends_with("\n\n") {
822            rendered.push('\n');
823        }
824        rendered.push('[');
825        rendered.push_str(section);
826        rendered.push_str("]\n");
827
828        for (key, value) in section_values {
829            match value {
830                ConfigValue::String(value) => push_key_value_line(&mut rendered, key, value),
831                _ => bail!("INI output only supports string values in sections"),
832            }
833        }
834    }
835
836    Ok(rendered)
837}
838
839fn render_properties(value: &ConfigValue) -> Result<String> {
840    object_root(value, "properties")?;
841    let mut rendered = String::new();
842    render_properties_value(value, "", &mut rendered)?;
843    Ok(rendered)
844}
845
846fn render_properties_value(value: &ConfigValue, path: &str, rendered: &mut String) -> Result<()> {
847    match value {
848        ConfigValue::Object(values) => {
849            for (key, value) in values {
850                let child_path = join_path(path, key);
851                render_properties_value(value, &child_path, rendered)?;
852            }
853        }
854        ConfigValue::String(value) => {
855            if path.is_empty() {
856                bail!("properties output requires object keys");
857            }
858            push_key_value_line(rendered, path, value);
859        }
860        _ => bail!("properties output only supports string leaf values"),
861    }
862
863    Ok(())
864}
865
866fn object_root<'a>(
867    value: &'a ConfigValue,
868    format_name: &str,
869) -> Result<&'a IndexMap<String, ConfigValue>> {
870    match value {
871        ConfigValue::Object(values) => Ok(values),
872        _ => bail!("{format_name} output requires an object at the document root"),
873    }
874}
875
876fn push_key_value_line(rendered: &mut String, key: &str, value: &str) {
877    rendered.push_str(key);
878    rendered.push('=');
879    rendered.push_str(value);
880    rendered.push('\n');
881}
882
883fn parse_value_literal(raw_value: &str, value_format: ValueFormat) -> Result<ConfigValue> {
884    match value_format {
885        ValueFormat::String => Ok(ConfigValue::String(raw_value.to_string())),
886        ValueFormat::Json => {
887            let value: serde_json::Value =
888                serde_json::from_str(raw_value).context("failed to parse JSON value")?;
889            json_to_config(value)
890        }
891    }
892}
893
894fn ensure_output_can_be_written(output: &Path, overwrite: bool) -> Result<()> {
895    if output.exists() && !overwrite {
896        bail!(
897            "output file `{}` already exists; pass --overwrite to replace it",
898            output.display()
899        );
900    }
901
902    Ok(())
903}
904
905fn write_output(output: &Path, rendered: &str) -> Result<()> {
906    fs::write(output, rendered)
907        .with_context(|| format!("failed to write output file `{}`", output.display()))
908}
909
910fn merge_values(base: &mut ConfigValue, override_value: ConfigValue) {
911    match (base, override_value) {
912        (ConfigValue::Object(base_values), ConfigValue::Object(override_values)) => {
913            for (key, override_value) in override_values {
914                match base_values.get_mut(&key) {
915                    Some(base_value) => merge_values(base_value, override_value),
916                    None => {
917                        base_values.insert(key, override_value);
918                    }
919                }
920            }
921        }
922        (base_value, override_value) => {
923            *base_value = override_value;
924        }
925    }
926}
927
928fn diff_values(old: &ConfigValue, new: &ConfigValue, path: &str, entries: &mut Vec<DiffEntry>) {
929    match (old, new) {
930        (ConfigValue::Object(old_values), ConfigValue::Object(new_values)) => {
931            for (key, old_value) in old_values {
932                let child_path = join_path(path, key);
933                match new_values.get(key) {
934                    Some(new_value) => diff_values(old_value, new_value, &child_path, entries),
935                    None => entries.push(DiffEntry {
936                        kind: DiffKind::Removed,
937                        path: child_path,
938                    }),
939                }
940            }
941
942            for key in new_values.keys() {
943                if !old_values.contains_key(key) {
944                    entries.push(DiffEntry {
945                        kind: DiffKind::Added,
946                        path: join_path(path, key),
947                    });
948                }
949            }
950        }
951        (ConfigValue::Array(old_values), ConfigValue::Array(new_values)) => {
952            let shared_len = old_values.len().min(new_values.len());
953            for index in 0..shared_len {
954                let child_path = join_path(path, &index.to_string());
955                diff_values(&old_values[index], &new_values[index], &child_path, entries);
956            }
957
958            for index in shared_len..old_values.len() {
959                entries.push(DiffEntry {
960                    kind: DiffKind::Removed,
961                    path: join_path(path, &index.to_string()),
962                });
963            }
964
965            for index in shared_len..new_values.len() {
966                entries.push(DiffEntry {
967                    kind: DiffKind::Added,
968                    path: join_path(path, &index.to_string()),
969                });
970            }
971        }
972        _ => {
973            if old != new {
974                entries.push(DiffEntry {
975                    kind: DiffKind::Changed,
976                    path: display_path(path),
977                });
978            }
979        }
980    }
981}
982
983fn join_path(path: &str, segment: &str) -> String {
984    if path.is_empty() {
985        segment.to_string()
986    } else {
987        format!("{path}.{segment}")
988    }
989}
990
991fn display_path(path: &str) -> String {
992    if path.is_empty() {
993        "<root>".to_string()
994    } else {
995        path.to_string()
996    }
997}
998
999fn json_to_config(value: serde_json::Value) -> Result<ConfigValue> {
1000    match value {
1001        serde_json::Value::Null => Ok(ConfigValue::Null),
1002        serde_json::Value::Bool(value) => Ok(ConfigValue::Bool(value)),
1003        serde_json::Value::Number(value) => {
1004            if let Some(value) = value.as_i64() {
1005                Ok(ConfigValue::Integer(value))
1006            } else if value.as_u64().is_some() {
1007                bail!("JSON unsigned integer exceeds the supported i64 range")
1008            } else if let Some(value) = value.as_f64() {
1009                Ok(ConfigValue::Float(value))
1010            } else {
1011                bail!("unsupported JSON number `{value}`")
1012            }
1013        }
1014        serde_json::Value::String(value) => Ok(ConfigValue::String(value)),
1015        serde_json::Value::Array(values) => values
1016            .into_iter()
1017            .map(json_to_config)
1018            .collect::<Result<Vec<_>>>()
1019            .map(ConfigValue::Array),
1020        serde_json::Value::Object(values) => values
1021            .into_iter()
1022            .map(|(key, value)| json_to_config(value).map(|value| (key, value)))
1023            .collect::<Result<IndexMap<_, _>>>()
1024            .map(ConfigValue::Object),
1025    }
1026}
1027
1028fn toml_to_config(value: toml::Value) -> Result<ConfigValue> {
1029    match value {
1030        toml::Value::String(value) => Ok(ConfigValue::String(value)),
1031        toml::Value::Integer(value) => Ok(ConfigValue::Integer(value)),
1032        toml::Value::Float(value) => Ok(ConfigValue::Float(value)),
1033        toml::Value::Boolean(value) => Ok(ConfigValue::Bool(value)),
1034        toml::Value::Datetime(value) => Ok(ConfigValue::String(value.to_string())),
1035        toml::Value::Array(values) => values
1036            .into_iter()
1037            .map(toml_to_config)
1038            .collect::<Result<Vec<_>>>()
1039            .map(ConfigValue::Array),
1040        toml::Value::Table(values) => values
1041            .into_iter()
1042            .map(|(key, value)| toml_to_config(value).map(|value| (key, value)))
1043            .collect::<Result<IndexMap<_, _>>>()
1044            .map(ConfigValue::Object),
1045    }
1046}