Skip to main content

config_forge/
lib.rs

1use std::fs;
2use std::path::Path;
3use std::str::FromStr;
4
5use anyhow::{Context, Result, bail};
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8
9pub const NAME: &str = "ConfigForge";
10pub const VERSION: &str = env!("CARGO_PKG_VERSION");
11
12pub fn describe() -> &'static str {
13    "A CLI tool for converting, inspecting, and validating configuration files."
14}
15
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
17#[serde(untagged)]
18pub enum ConfigValue {
19    Null,
20    Bool(bool),
21    Integer(i64),
22    Float(f64),
23    String(String),
24    Array(Vec<ConfigValue>),
25    Object(IndexMap<String, ConfigValue>),
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum Format {
30    Json,
31    Toml,
32    Yaml,
33}
34
35impl Format {
36    pub fn detect_path(path: &Path) -> Result<Self> {
37        let extension = path
38            .extension()
39            .and_then(|value| value.to_str())
40            .map(str::to_ascii_lowercase)
41            .with_context(|| format!("cannot detect format for `{}`", path.display()))?;
42
43        match extension.as_str() {
44            "json" => Ok(Self::Json),
45            "toml" => Ok(Self::Toml),
46            "yaml" | "yml" => Ok(Self::Yaml),
47            _ => bail!("unsupported file extension `{extension}`"),
48        }
49    }
50
51    pub fn name(self) -> &'static str {
52        match self {
53            Self::Json => "json",
54            Self::Toml => "toml",
55            Self::Yaml => "yaml",
56        }
57    }
58}
59
60impl FromStr for Format {
61    type Err = anyhow::Error;
62
63    fn from_str(value: &str) -> Result<Self> {
64        match value.to_ascii_lowercase().as_str() {
65            "json" => Ok(Self::Json),
66            "toml" => Ok(Self::Toml),
67            "yaml" | "yml" => Ok(Self::Yaml),
68            _ => bail!("unsupported format `{value}`"),
69        }
70    }
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum ValueFormat {
75    String,
76    Json,
77}
78
79impl FromStr for ValueFormat {
80    type Err = anyhow::Error;
81
82    fn from_str(value: &str) -> Result<Self> {
83        match value.to_ascii_lowercase().as_str() {
84            "string" => Ok(Self::String),
85            "json" => Ok(Self::Json),
86            _ => bail!("unsupported value format `{value}`"),
87        }
88    }
89}
90
91#[derive(Debug)]
92pub struct DocumentInfo {
93    pub format: Format,
94    pub root_kind: &'static str,
95    pub size_bytes: u64,
96}
97
98pub fn inspect_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<DocumentInfo> {
99    let path = path.as_ref();
100    let format = match format {
101        Some(format) => format,
102        None => Format::detect_path(path)?,
103    };
104    let content = fs::read_to_string(path)
105        .with_context(|| format!("failed to read input file `{}`", path.display()))?;
106    let value = parse_str(&content, format)?;
107    let metadata = fs::metadata(path)
108        .with_context(|| format!("failed to read metadata for `{}`", path.display()))?;
109
110    Ok(DocumentInfo {
111        format,
112        root_kind: value.kind(),
113        size_bytes: metadata.len(),
114    })
115}
116
117pub fn validate_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<Format> {
118    let path = path.as_ref();
119    let format = match format {
120        Some(format) => format,
121        None => Format::detect_path(path)?,
122    };
123
124    let content = fs::read_to_string(path)
125        .with_context(|| format!("failed to read input file `{}`", path.display()))?;
126    parse_str(&content, format)?;
127
128    Ok(format)
129}
130
131pub fn convert_path(
132    input: impl AsRef<Path>,
133    output: Option<impl AsRef<Path>>,
134    from: Option<Format>,
135    to: Option<Format>,
136    overwrite: bool,
137) -> Result<String> {
138    let input = input.as_ref();
139    let input_format = match from {
140        Some(format) => format,
141        None => Format::detect_path(input)?,
142    };
143    let output_format = match (to, output.as_ref().map(|path| path.as_ref())) {
144        (Some(format), _) => format,
145        (None, Some(path)) => Format::detect_path(path)?,
146        (None, None) => bail!("output format is required when writing to stdout"),
147    };
148
149    if let Some(output) = output.as_ref().map(|path| path.as_ref()) {
150        ensure_output_can_be_written(output, overwrite)?;
151    }
152
153    let content = fs::read_to_string(input)
154        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
155    let value = parse_str(&content, input_format)?;
156    let rendered = render_string(&value, output_format)?;
157
158    if let Some(output) = output {
159        fs::write(output.as_ref(), &rendered).with_context(|| {
160            format!(
161                "failed to write output file `{}`",
162                output.as_ref().display()
163            )
164        })?;
165    }
166
167    Ok(rendered)
168}
169
170pub fn set_path_value(
171    input: impl AsRef<Path>,
172    output: impl AsRef<Path>,
173    query: &str,
174    raw_value: &str,
175    value_format: ValueFormat,
176    from: Option<Format>,
177    to: Option<Format>,
178    overwrite: bool,
179) -> Result<String> {
180    let input = input.as_ref();
181    let output = output.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 {
187        Some(format) => format,
188        None => Format::detect_path(output)?,
189    };
190    ensure_output_can_be_written(output, overwrite)?;
191
192    let content = fs::read_to_string(input)
193        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
194    let mut value = parse_str(&content, input_format)?;
195    let replacement = parse_value_literal(raw_value, value_format)?;
196    set_query_value(&mut value, query, replacement)?;
197    let rendered = render_string(&value, output_format)?;
198    write_output(output, &rendered)?;
199
200    Ok(rendered)
201}
202
203pub fn delete_path_value(
204    input: impl AsRef<Path>,
205    output: impl AsRef<Path>,
206    query: &str,
207    from: Option<Format>,
208    to: Option<Format>,
209    overwrite: bool,
210) -> Result<String> {
211    let input = input.as_ref();
212    let output = output.as_ref();
213    let input_format = match from {
214        Some(format) => format,
215        None => Format::detect_path(input)?,
216    };
217    let output_format = match to {
218        Some(format) => format,
219        None => Format::detect_path(output)?,
220    };
221    ensure_output_can_be_written(output, overwrite)?;
222
223    let content = fs::read_to_string(input)
224        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
225    let mut value = parse_str(&content, input_format)?;
226    delete_query_value(&mut value, query)?;
227    let rendered = render_string(&value, output_format)?;
228    write_output(output, &rendered)?;
229
230    Ok(rendered)
231}
232
233pub fn check_convert_path(
234    input: impl AsRef<Path>,
235    from: Option<Format>,
236    to: Option<Format>,
237) -> Result<Format> {
238    let input = input.as_ref();
239    let input_format = match from {
240        Some(format) => format,
241        None => Format::detect_path(input)?,
242    };
243    let output_format = to.context("output format is required for --check")?;
244
245    let content = fs::read_to_string(input)
246        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
247    let value = parse_str(&content, input_format)?;
248    render_string(&value, output_format)?;
249
250    Ok(output_format)
251}
252
253pub fn get_path_value(
254    input: impl AsRef<Path>,
255    query: &str,
256    from: Option<Format>,
257) -> Result<ConfigValue> {
258    let input = input.as_ref();
259    let input_format = match from {
260        Some(format) => format,
261        None => Format::detect_path(input)?,
262    };
263
264    let content = fs::read_to_string(input)
265        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
266    let value = parse_str(&content, input_format)?;
267    query_value(&value, query).cloned()
268}
269
270pub fn render_query_value(value: &ConfigValue, format: Option<Format>) -> Result<String> {
271    match format {
272        Some(format) => render_string(value, format),
273        None => match value {
274            ConfigValue::Null => Ok("null\n".to_string()),
275            ConfigValue::Bool(value) => Ok(format!("{value}\n")),
276            ConfigValue::Integer(value) => Ok(format!("{value}\n")),
277            ConfigValue::Float(value) => Ok(format!("{value}\n")),
278            ConfigValue::String(value) => Ok(format!("{value}\n")),
279            ConfigValue::Array(_) | ConfigValue::Object(_) => render_string(value, Format::Json),
280        },
281    }
282}
283
284pub fn parse_str(content: &str, format: Format) -> Result<ConfigValue> {
285    match format {
286        Format::Json => {
287            let value: serde_json::Value =
288                serde_json::from_str(content).context("failed to parse JSON")?;
289            json_to_config(value)
290        }
291        Format::Toml => {
292            let value: toml::Value = toml::from_str(content).context("failed to parse TOML")?;
293            toml_to_config(value)
294        }
295        Format::Yaml => serde_yaml::from_str(content).context("failed to parse YAML"),
296    }
297}
298
299pub fn render_string(value: &ConfigValue, format: Format) -> Result<String> {
300    match format {
301        Format::Json => {
302            let mut rendered =
303                serde_json::to_string_pretty(value).context("failed to render JSON")?;
304            rendered.push('\n');
305            Ok(rendered)
306        }
307        Format::Toml => {
308            if !matches!(value, ConfigValue::Object(_)) {
309                bail!("TOML output requires an object at the document root");
310            }
311            let rendered = toml::to_string_pretty(value).context("failed to render TOML")?;
312            Ok(rendered)
313        }
314        Format::Yaml => serde_yaml::to_string(value).context("failed to render YAML"),
315    }
316}
317
318impl ConfigValue {
319    pub fn kind(&self) -> &'static str {
320        match self {
321            Self::Null => "null",
322            Self::Bool(_) => "bool",
323            Self::Integer(_) => "integer",
324            Self::Float(_) => "float",
325            Self::String(_) => "string",
326            Self::Array(_) => "array",
327            Self::Object(_) => "object",
328        }
329    }
330}
331
332fn query_value<'a>(value: &'a ConfigValue, query: &str) -> Result<&'a ConfigValue> {
333    let segments = path_segments(query)?;
334
335    let mut current = value;
336    let mut visited: Vec<&str> = Vec::new();
337
338    for segment in segments {
339        match current {
340            ConfigValue::Object(values) => {
341                visited.push(segment);
342                current = values
343                    .get(segment)
344                    .with_context(|| format!("path `{}` not found", visited.join(".")))?;
345            }
346            ConfigValue::Array(values) => {
347                let index = segment.parse::<usize>().with_context(|| {
348                    format!("path segment `{segment}` cannot be applied to array")
349                })?;
350                visited.push(segment);
351                current = values
352                    .get(index)
353                    .with_context(|| format!("path `{}` index out of bounds", visited.join(".")))?;
354            }
355            scalar => {
356                bail!(
357                    "path segment `{segment}` cannot be applied to {}",
358                    scalar.kind()
359                );
360            }
361        }
362    }
363
364    Ok(current)
365}
366
367fn set_query_value(value: &mut ConfigValue, query: &str, replacement: ConfigValue) -> Result<()> {
368    let (parent, segment) = query_parent_mut(value, query)?;
369
370    match parent {
371        ConfigValue::Object(values) => {
372            let slot = values
373                .get_mut(segment)
374                .with_context(|| format!("path `{query}` not found"))?;
375            *slot = replacement;
376            Ok(())
377        }
378        ConfigValue::Array(values) => {
379            let index = segment
380                .parse::<usize>()
381                .with_context(|| format!("path segment `{segment}` cannot be applied to array"))?;
382            let slot = values
383                .get_mut(index)
384                .with_context(|| format!("path `{query}` index out of bounds"))?;
385            *slot = replacement;
386            Ok(())
387        }
388        scalar => {
389            bail!(
390                "path segment `{segment}` cannot be applied to {}",
391                scalar.kind()
392            );
393        }
394    }
395}
396
397fn delete_query_value(value: &mut ConfigValue, query: &str) -> Result<()> {
398    let (parent, segment) = query_parent_mut(value, query)?;
399
400    match parent {
401        ConfigValue::Object(values) => {
402            if values.shift_remove(segment).is_none() {
403                bail!("path `{query}` not found");
404            }
405            Ok(())
406        }
407        ConfigValue::Array(values) => {
408            let index = segment
409                .parse::<usize>()
410                .with_context(|| format!("path segment `{segment}` cannot be applied to array"))?;
411            if index >= values.len() {
412                bail!("path `{query}` index out of bounds");
413            }
414            values.remove(index);
415            Ok(())
416        }
417        scalar => {
418            bail!(
419                "path segment `{segment}` cannot be applied to {}",
420                scalar.kind()
421            );
422        }
423    }
424}
425
426fn query_parent_mut<'a, 'q>(
427    value: &'a mut ConfigValue,
428    query: &'q str,
429) -> Result<(&'a mut ConfigValue, &'q str)> {
430    let segments = path_segments(query)?;
431    let (segment, parents) = segments
432        .split_last()
433        .expect("path_segments rejects empty paths");
434    let mut current = value;
435    let mut visited: Vec<&str> = Vec::new();
436
437    for parent_segment in parents {
438        match current {
439            ConfigValue::Object(values) => {
440                visited.push(parent_segment);
441                current = values
442                    .get_mut(*parent_segment)
443                    .with_context(|| format!("path `{}` not found", visited.join(".")))?;
444            }
445            ConfigValue::Array(values) => {
446                let index = parent_segment.parse::<usize>().with_context(|| {
447                    format!("path segment `{parent_segment}` cannot be applied to array")
448                })?;
449                visited.push(parent_segment);
450                current = values
451                    .get_mut(index)
452                    .with_context(|| format!("path `{}` index out of bounds", visited.join(".")))?;
453            }
454            scalar => {
455                bail!(
456                    "path segment `{parent_segment}` cannot be applied to {}",
457                    scalar.kind()
458                );
459            }
460        }
461    }
462
463    Ok((current, *segment))
464}
465
466fn path_segments(query: &str) -> Result<Vec<&str>> {
467    if query.is_empty() {
468        bail!("empty path is not supported");
469    }
470
471    let segments = query.split('.').collect::<Vec<_>>();
472    if segments.iter().any(|segment| segment.is_empty()) {
473        bail!("empty path segment is not supported in `{query}`");
474    }
475
476    Ok(segments)
477}
478
479fn parse_value_literal(raw_value: &str, value_format: ValueFormat) -> Result<ConfigValue> {
480    match value_format {
481        ValueFormat::String => Ok(ConfigValue::String(raw_value.to_string())),
482        ValueFormat::Json => {
483            let value: serde_json::Value =
484                serde_json::from_str(raw_value).context("failed to parse JSON value")?;
485            json_to_config(value)
486        }
487    }
488}
489
490fn ensure_output_can_be_written(output: &Path, overwrite: bool) -> Result<()> {
491    if output.exists() && !overwrite {
492        bail!(
493            "output file `{}` already exists; pass --overwrite to replace it",
494            output.display()
495        );
496    }
497
498    Ok(())
499}
500
501fn write_output(output: &Path, rendered: &str) -> Result<()> {
502    fs::write(output, rendered)
503        .with_context(|| format!("failed to write output file `{}`", output.display()))
504}
505
506fn json_to_config(value: serde_json::Value) -> Result<ConfigValue> {
507    match value {
508        serde_json::Value::Null => Ok(ConfigValue::Null),
509        serde_json::Value::Bool(value) => Ok(ConfigValue::Bool(value)),
510        serde_json::Value::Number(value) => {
511            if let Some(value) = value.as_i64() {
512                Ok(ConfigValue::Integer(value))
513            } else if value.as_u64().is_some() {
514                bail!("JSON unsigned integer exceeds the supported i64 range")
515            } else if let Some(value) = value.as_f64() {
516                Ok(ConfigValue::Float(value))
517            } else {
518                bail!("unsupported JSON number `{value}`")
519            }
520        }
521        serde_json::Value::String(value) => Ok(ConfigValue::String(value)),
522        serde_json::Value::Array(values) => values
523            .into_iter()
524            .map(json_to_config)
525            .collect::<Result<Vec<_>>>()
526            .map(ConfigValue::Array),
527        serde_json::Value::Object(values) => values
528            .into_iter()
529            .map(|(key, value)| json_to_config(value).map(|value| (key, value)))
530            .collect::<Result<IndexMap<_, _>>>()
531            .map(ConfigValue::Object),
532    }
533}
534
535fn toml_to_config(value: toml::Value) -> Result<ConfigValue> {
536    match value {
537        toml::Value::String(value) => Ok(ConfigValue::String(value)),
538        toml::Value::Integer(value) => Ok(ConfigValue::Integer(value)),
539        toml::Value::Float(value) => Ok(ConfigValue::Float(value)),
540        toml::Value::Boolean(value) => Ok(ConfigValue::Bool(value)),
541        toml::Value::Datetime(value) => Ok(ConfigValue::String(value.to_string())),
542        toml::Value::Array(values) => values
543            .into_iter()
544            .map(toml_to_config)
545            .collect::<Result<Vec<_>>>()
546            .map(ConfigValue::Array),
547        toml::Value::Table(values) => values
548            .into_iter()
549            .map(|(key, value)| toml_to_config(value).map(|value| (key, value)))
550            .collect::<Result<IndexMap<_, _>>>()
551            .map(ConfigValue::Object),
552    }
553}