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(Debug)]
74pub struct DocumentInfo {
75    pub format: Format,
76    pub root_kind: &'static str,
77    pub size_bytes: u64,
78}
79
80pub fn inspect_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<DocumentInfo> {
81    let path = path.as_ref();
82    let format = match format {
83        Some(format) => format,
84        None => Format::detect_path(path)?,
85    };
86    let content = fs::read_to_string(path)
87        .with_context(|| format!("failed to read input file `{}`", path.display()))?;
88    let value = parse_str(&content, format)?;
89    let metadata = fs::metadata(path)
90        .with_context(|| format!("failed to read metadata for `{}`", path.display()))?;
91
92    Ok(DocumentInfo {
93        format,
94        root_kind: value.kind(),
95        size_bytes: metadata.len(),
96    })
97}
98
99pub fn validate_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<Format> {
100    let path = path.as_ref();
101    let format = match format {
102        Some(format) => format,
103        None => Format::detect_path(path)?,
104    };
105
106    let content = fs::read_to_string(path)
107        .with_context(|| format!("failed to read input file `{}`", path.display()))?;
108    parse_str(&content, format)?;
109
110    Ok(format)
111}
112
113pub fn convert_path(
114    input: impl AsRef<Path>,
115    output: Option<impl AsRef<Path>>,
116    from: Option<Format>,
117    to: Option<Format>,
118    overwrite: bool,
119) -> Result<String> {
120    let input = input.as_ref();
121    let input_format = match from {
122        Some(format) => format,
123        None => Format::detect_path(input)?,
124    };
125    let output_format = match (to, output.as_ref().map(|path| path.as_ref())) {
126        (Some(format), _) => format,
127        (None, Some(path)) => Format::detect_path(path)?,
128        (None, None) => bail!("output format is required when writing to stdout"),
129    };
130
131    if let Some(output) = output.as_ref().map(|path| path.as_ref()) {
132        if output.exists() && !overwrite {
133            bail!(
134                "output file `{}` already exists; pass --overwrite to replace it",
135                output.display()
136            );
137        }
138    }
139
140    let content = fs::read_to_string(input)
141        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
142    let value = parse_str(&content, input_format)?;
143    let rendered = render_string(&value, output_format)?;
144
145    if let Some(output) = output {
146        fs::write(output.as_ref(), &rendered).with_context(|| {
147            format!(
148                "failed to write output file `{}`",
149                output.as_ref().display()
150            )
151        })?;
152    }
153
154    Ok(rendered)
155}
156
157pub fn check_convert_path(
158    input: impl AsRef<Path>,
159    from: Option<Format>,
160    to: Option<Format>,
161) -> Result<Format> {
162    let input = input.as_ref();
163    let input_format = match from {
164        Some(format) => format,
165        None => Format::detect_path(input)?,
166    };
167    let output_format = to.context("output format is required for --check")?;
168
169    let content = fs::read_to_string(input)
170        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
171    let value = parse_str(&content, input_format)?;
172    render_string(&value, output_format)?;
173
174    Ok(output_format)
175}
176
177pub fn get_path_value(
178    input: impl AsRef<Path>,
179    query: &str,
180    from: Option<Format>,
181) -> Result<ConfigValue> {
182    let input = input.as_ref();
183    let input_format = match from {
184        Some(format) => format,
185        None => Format::detect_path(input)?,
186    };
187
188    let content = fs::read_to_string(input)
189        .with_context(|| format!("failed to read input file `{}`", input.display()))?;
190    let value = parse_str(&content, input_format)?;
191    query_value(&value, query).cloned()
192}
193
194pub fn render_query_value(value: &ConfigValue, format: Option<Format>) -> Result<String> {
195    match format {
196        Some(format) => render_string(value, format),
197        None => match value {
198            ConfigValue::Null => Ok("null\n".to_string()),
199            ConfigValue::Bool(value) => Ok(format!("{value}\n")),
200            ConfigValue::Integer(value) => Ok(format!("{value}\n")),
201            ConfigValue::Float(value) => Ok(format!("{value}\n")),
202            ConfigValue::String(value) => Ok(format!("{value}\n")),
203            ConfigValue::Array(_) | ConfigValue::Object(_) => render_string(value, Format::Json),
204        },
205    }
206}
207
208pub fn parse_str(content: &str, format: Format) -> Result<ConfigValue> {
209    match format {
210        Format::Json => {
211            let value: serde_json::Value =
212                serde_json::from_str(content).context("failed to parse JSON")?;
213            json_to_config(value)
214        }
215        Format::Toml => {
216            let value: toml::Value = toml::from_str(content).context("failed to parse TOML")?;
217            toml_to_config(value)
218        }
219        Format::Yaml => serde_yaml::from_str(content).context("failed to parse YAML"),
220    }
221}
222
223pub fn render_string(value: &ConfigValue, format: Format) -> Result<String> {
224    match format {
225        Format::Json => {
226            let mut rendered =
227                serde_json::to_string_pretty(value).context("failed to render JSON")?;
228            rendered.push('\n');
229            Ok(rendered)
230        }
231        Format::Toml => {
232            if !matches!(value, ConfigValue::Object(_)) {
233                bail!("TOML output requires an object at the document root");
234            }
235            let rendered = toml::to_string_pretty(value).context("failed to render TOML")?;
236            Ok(rendered)
237        }
238        Format::Yaml => serde_yaml::to_string(value).context("failed to render YAML"),
239    }
240}
241
242impl ConfigValue {
243    pub fn kind(&self) -> &'static str {
244        match self {
245            Self::Null => "null",
246            Self::Bool(_) => "bool",
247            Self::Integer(_) => "integer",
248            Self::Float(_) => "float",
249            Self::String(_) => "string",
250            Self::Array(_) => "array",
251            Self::Object(_) => "object",
252        }
253    }
254}
255
256fn query_value<'a>(value: &'a ConfigValue, query: &str) -> Result<&'a ConfigValue> {
257    if query.is_empty() {
258        bail!("empty path is not supported");
259    }
260
261    let mut current = value;
262    let mut visited: Vec<&str> = Vec::new();
263
264    for segment in query.split('.') {
265        if segment.is_empty() {
266            bail!("empty path segment is not supported in `{query}`");
267        }
268
269        match current {
270            ConfigValue::Object(values) => {
271                visited.push(segment);
272                current = values
273                    .get(segment)
274                    .with_context(|| format!("path `{}` not found", visited.join(".")))?;
275            }
276            ConfigValue::Array(values) => {
277                let index = segment.parse::<usize>().with_context(|| {
278                    format!("path segment `{segment}` cannot be applied to array")
279                })?;
280                visited.push(segment);
281                current = values
282                    .get(index)
283                    .with_context(|| format!("path `{}` index out of bounds", visited.join(".")))?;
284            }
285            scalar => {
286                bail!(
287                    "path segment `{segment}` cannot be applied to {}",
288                    scalar.kind()
289                );
290            }
291        }
292    }
293
294    Ok(current)
295}
296
297fn json_to_config(value: serde_json::Value) -> Result<ConfigValue> {
298    match value {
299        serde_json::Value::Null => Ok(ConfigValue::Null),
300        serde_json::Value::Bool(value) => Ok(ConfigValue::Bool(value)),
301        serde_json::Value::Number(value) => {
302            if let Some(value) = value.as_i64() {
303                Ok(ConfigValue::Integer(value))
304            } else if value.as_u64().is_some() {
305                bail!("JSON unsigned integer exceeds the supported i64 range")
306            } else if let Some(value) = value.as_f64() {
307                Ok(ConfigValue::Float(value))
308            } else {
309                bail!("unsupported JSON number `{value}`")
310            }
311        }
312        serde_json::Value::String(value) => Ok(ConfigValue::String(value)),
313        serde_json::Value::Array(values) => values
314            .into_iter()
315            .map(json_to_config)
316            .collect::<Result<Vec<_>>>()
317            .map(ConfigValue::Array),
318        serde_json::Value::Object(values) => values
319            .into_iter()
320            .map(|(key, value)| json_to_config(value).map(|value| (key, value)))
321            .collect::<Result<IndexMap<_, _>>>()
322            .map(ConfigValue::Object),
323    }
324}
325
326fn toml_to_config(value: toml::Value) -> Result<ConfigValue> {
327    match value {
328        toml::Value::String(value) => Ok(ConfigValue::String(value)),
329        toml::Value::Integer(value) => Ok(ConfigValue::Integer(value)),
330        toml::Value::Float(value) => Ok(ConfigValue::Float(value)),
331        toml::Value::Boolean(value) => Ok(ConfigValue::Bool(value)),
332        toml::Value::Datetime(value) => Ok(ConfigValue::String(value.to_string())),
333        toml::Value::Array(values) => values
334            .into_iter()
335            .map(toml_to_config)
336            .collect::<Result<Vec<_>>>()
337            .map(ConfigValue::Array),
338        toml::Value::Table(values) => values
339            .into_iter()
340            .map(|(key, value)| toml_to_config(value).map(|value| (key, value)))
341            .collect::<Result<IndexMap<_, _>>>()
342            .map(ConfigValue::Object),
343    }
344}