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}