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}