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}