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