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 convert_path(
100 input: impl AsRef<Path>,
101 output: Option<impl AsRef<Path>>,
102 from: Option<Format>,
103 to: Option<Format>,
104 overwrite: bool,
105) -> Result<String> {
106 let input = input.as_ref();
107 let input_format = match from {
108 Some(format) => format,
109 None => Format::detect_path(input)?,
110 };
111 let output_format = match (to, output.as_ref().map(|path| path.as_ref())) {
112 (Some(format), _) => format,
113 (None, Some(path)) => Format::detect_path(path)?,
114 (None, None) => bail!("output format is required when writing to stdout"),
115 };
116
117 if let Some(output) = output.as_ref().map(|path| path.as_ref()) {
118 if output.exists() && !overwrite {
119 bail!(
120 "output file `{}` already exists; pass --overwrite to replace it",
121 output.display()
122 );
123 }
124 }
125
126 let content = fs::read_to_string(input)
127 .with_context(|| format!("failed to read input file `{}`", input.display()))?;
128 let value = parse_str(&content, input_format)?;
129 let rendered = render_string(&value, output_format)?;
130
131 if let Some(output) = output {
132 fs::write(output.as_ref(), &rendered).with_context(|| {
133 format!(
134 "failed to write output file `{}`",
135 output.as_ref().display()
136 )
137 })?;
138 }
139
140 Ok(rendered)
141}
142
143pub fn check_convert_path(
144 input: impl AsRef<Path>,
145 from: Option<Format>,
146 to: Option<Format>,
147) -> Result<Format> {
148 let input = input.as_ref();
149 let input_format = match from {
150 Some(format) => format,
151 None => Format::detect_path(input)?,
152 };
153 let output_format = to.context("output format is required for --check")?;
154
155 let content = fs::read_to_string(input)
156 .with_context(|| format!("failed to read input file `{}`", input.display()))?;
157 let value = parse_str(&content, input_format)?;
158 render_string(&value, output_format)?;
159
160 Ok(output_format)
161}
162
163pub fn parse_str(content: &str, format: Format) -> Result<ConfigValue> {
164 match format {
165 Format::Json => {
166 let value: serde_json::Value =
167 serde_json::from_str(content).context("failed to parse JSON")?;
168 json_to_config(value)
169 }
170 Format::Toml => {
171 let value: toml::Value = toml::from_str(content).context("failed to parse TOML")?;
172 toml_to_config(value)
173 }
174 Format::Yaml => serde_yaml::from_str(content).context("failed to parse YAML"),
175 }
176}
177
178pub fn render_string(value: &ConfigValue, format: Format) -> Result<String> {
179 match format {
180 Format::Json => {
181 let mut rendered =
182 serde_json::to_string_pretty(value).context("failed to render JSON")?;
183 rendered.push('\n');
184 Ok(rendered)
185 }
186 Format::Toml => {
187 if !matches!(value, ConfigValue::Object(_)) {
188 bail!("TOML output requires an object at the document root");
189 }
190 let rendered = toml::to_string_pretty(value).context("failed to render TOML")?;
191 Ok(rendered)
192 }
193 Format::Yaml => serde_yaml::to_string(value).context("failed to render YAML"),
194 }
195}
196
197impl ConfigValue {
198 pub fn kind(&self) -> &'static str {
199 match self {
200 Self::Null => "null",
201 Self::Bool(_) => "bool",
202 Self::Integer(_) => "integer",
203 Self::Float(_) => "float",
204 Self::String(_) => "string",
205 Self::Array(_) => "array",
206 Self::Object(_) => "object",
207 }
208 }
209}
210
211fn json_to_config(value: serde_json::Value) -> Result<ConfigValue> {
212 match value {
213 serde_json::Value::Null => Ok(ConfigValue::Null),
214 serde_json::Value::Bool(value) => Ok(ConfigValue::Bool(value)),
215 serde_json::Value::Number(value) => {
216 if let Some(value) = value.as_i64() {
217 Ok(ConfigValue::Integer(value))
218 } else if value.as_u64().is_some() {
219 bail!("JSON unsigned integer exceeds the supported i64 range")
220 } else if let Some(value) = value.as_f64() {
221 Ok(ConfigValue::Float(value))
222 } else {
223 bail!("unsupported JSON number `{value}`")
224 }
225 }
226 serde_json::Value::String(value) => Ok(ConfigValue::String(value)),
227 serde_json::Value::Array(values) => values
228 .into_iter()
229 .map(json_to_config)
230 .collect::<Result<Vec<_>>>()
231 .map(ConfigValue::Array),
232 serde_json::Value::Object(values) => values
233 .into_iter()
234 .map(|(key, value)| json_to_config(value).map(|value| (key, value)))
235 .collect::<Result<IndexMap<_, _>>>()
236 .map(ConfigValue::Object),
237 }
238}
239
240fn toml_to_config(value: toml::Value) -> Result<ConfigValue> {
241 match value {
242 toml::Value::String(value) => Ok(ConfigValue::String(value)),
243 toml::Value::Integer(value) => Ok(ConfigValue::Integer(value)),
244 toml::Value::Float(value) => Ok(ConfigValue::Float(value)),
245 toml::Value::Boolean(value) => Ok(ConfigValue::Bool(value)),
246 toml::Value::Datetime(value) => Ok(ConfigValue::String(value.to_string())),
247 toml::Value::Array(values) => values
248 .into_iter()
249 .map(toml_to_config)
250 .collect::<Result<Vec<_>>>()
251 .map(ConfigValue::Array),
252 toml::Value::Table(values) => values
253 .into_iter()
254 .map(|(key, value)| toml_to_config(value).map(|value| (key, value)))
255 .collect::<Result<IndexMap<_, _>>>()
256 .map(ConfigValue::Object),
257 }
258}