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}