1use std::path::PathBuf;
19
20use crate::disassemble::{self, DisassembleOptions};
21use crate::error::{Error, Result};
22use crate::format::Format;
23use crate::reassemble::{self, ReassembleOptions};
24use crate::xml_cmd;
25
26pub async fn dispatch(args: Vec<String>) -> Result<()> {
28 let mut iter = args.into_iter();
29 let _program = iter.next();
30 let subcommand = match iter.next() {
31 Some(s) => s,
32 None => {
33 print_help();
34 return Ok(());
35 }
36 };
37 let rest: Vec<String> = iter.collect();
38
39 match subcommand.as_str() {
40 "help" | "-h" | "--help" => {
41 print_help();
42 Ok(())
43 }
44 "xml" => xml_cmd::run(rest).await,
45 "json" => run_format(Format::Json, rest),
46 "json5" => run_format(Format::Json5, rest),
47 "jsonc" => run_format(Format::Jsonc, rest),
48 "yaml" | "yml" => run_format(Format::Yaml, rest),
49 "toon" => run_format(Format::Toon, rest),
50 "toml" => run_format(Format::Toml, rest),
51 "ini" => run_format(Format::Ini, rest),
52 other => Err(Error::Usage(format!(
53 "unknown subcommand `{other}`. run `config-disassembler help` for usage."
54 ))),
55 }
56}
57
58fn run_format(default_format: Format, args: Vec<String>) -> Result<()> {
59 let mut iter = args.into_iter();
60 let action = iter.next().ok_or_else(|| {
61 Error::Usage(format!(
62 "{default_format} subcommand requires `disassemble` or `reassemble`"
63 ))
64 })?;
65 let rest: Vec<String> = iter.collect();
66
67 match action.as_str() {
68 "disassemble" => run_disassemble(default_format, rest),
69 "reassemble" => run_reassemble(default_format, rest),
70 "help" | "-h" | "--help" => {
71 print_format_help(default_format);
72 Ok(())
73 }
74 other => Err(Error::Usage(format!(
75 "unknown action `{other}` for `{default_format}`; expected `disassemble` or `reassemble`"
76 ))),
77 }
78}
79
80fn run_disassemble(default_format: Format, args: Vec<String>) -> Result<()> {
81 let allows_format_overrides = default_format.allows_format_overrides();
82 let mut input: Option<PathBuf> = None;
83 let mut output_dir: Option<PathBuf> = None;
84 let mut output_format: Option<Format> = None;
85 let mut input_format: Option<Format> = None;
86 let mut unique_id: Option<String> = None;
87 let mut pre_purge = false;
88 let mut post_purge = false;
89 let mut ignore_path: Option<PathBuf> = None;
90
91 let mut iter = args.into_iter();
92 while let Some(arg) = iter.next() {
93 match arg.as_str() {
94 "--output-dir" | "-o" => {
95 output_dir = Some(PathBuf::from(expect_value(&mut iter, "--output-dir")?));
96 }
97 "--output-format" if !allows_format_overrides => {
98 return Err(Error::Usage(format!(
99 "--output-format is not supported for `{default_format}`; {} can only be converted to {}",
100 default_format.display_name(),
101 default_format.display_name()
102 )));
103 }
104 "--output-format" => {
105 output_format = Some(expect_value(&mut iter, "--output-format")?.parse()?);
106 }
107 "--input-format" if !allows_format_overrides => {
108 return Err(Error::Usage(format!(
109 "--input-format is not supported for `{default_format}`; {} can only be converted from {}",
110 default_format.display_name(),
111 default_format.display_name()
112 )));
113 }
114 "--input-format" => {
115 input_format = Some(expect_value(&mut iter, "--input-format")?.parse()?);
116 }
117 "--unique-id" => {
118 unique_id = Some(expect_value(&mut iter, "--unique-id")?);
119 }
120 "--ignore-path" => {
121 ignore_path = Some(PathBuf::from(expect_value(&mut iter, "--ignore-path")?));
122 }
123 "--pre-purge" => pre_purge = true,
124 "--post-purge" => post_purge = true,
125 "-h" | "--help" => {
126 print_format_help(default_format);
127 return Ok(());
128 }
129 other if other.starts_with('-') => {
130 return Err(Error::Usage(format!("unknown option `{other}`")));
131 }
132 _ if input.is_none() => input = Some(PathBuf::from(arg)),
133 _ => {
134 return Err(Error::Usage(format!("unexpected argument `{arg}`")));
135 }
136 }
137 }
138
139 let input = input.ok_or_else(|| Error::Usage("missing <input> file path".into()))?;
140 let opts = DisassembleOptions {
141 input,
142 input_format: input_format.or(Some(default_format)),
143 output_dir,
144 output_format,
145 unique_id,
146 pre_purge,
147 post_purge,
148 ignore_path,
149 };
150 let dir = disassemble::disassemble(opts)?;
151 println!("disassembled into {}", dir.display());
152 Ok(())
153}
154
155fn run_reassemble(default_format: Format, args: Vec<String>) -> Result<()> {
156 let allows_format_overrides = default_format.allows_format_overrides();
157 let mut input_dir: Option<PathBuf> = None;
158 let mut output: Option<PathBuf> = None;
159 let mut output_format: Option<Format> = None;
160 let mut post_purge = false;
161
162 let mut iter = args.into_iter();
163 while let Some(arg) = iter.next() {
164 match arg.as_str() {
165 "--output" | "-o" => {
166 output = Some(PathBuf::from(expect_value(&mut iter, "--output")?));
167 }
168 "--output-format" if !allows_format_overrides => {
169 return Err(Error::Usage(format!(
170 "--output-format is not supported for `{default_format}`; {} can only be reassembled to {}",
171 default_format.display_name(),
172 default_format.display_name()
173 )));
174 }
175 "--output-format" => {
176 output_format = Some(expect_value(&mut iter, "--output-format")?.parse()?);
177 }
178 "--post-purge" => post_purge = true,
179 "-h" | "--help" => {
180 print_format_help(default_format);
181 return Ok(());
182 }
183 other if other.starts_with('-') => {
184 return Err(Error::Usage(format!("unknown option `{other}`")));
185 }
186 _ if input_dir.is_none() => input_dir = Some(PathBuf::from(arg)),
187 _ => {
188 return Err(Error::Usage(format!("unexpected argument `{arg}`")));
189 }
190 }
191 }
192
193 let input_dir = input_dir.ok_or_else(|| Error::Usage("missing <input-dir> path".into()))?;
194 let opts = ReassembleOptions {
195 input_dir,
196 output,
197 output_format: output_format.or(Some(default_format)),
198 post_purge,
199 };
200 let path = reassemble::reassemble(opts)?;
201 println!("reassembled to {}", path.display());
202 Ok(())
203}
204
205fn expect_value<I: Iterator<Item = String>>(iter: &mut I, flag: &str) -> Result<String> {
206 iter.next()
207 .ok_or_else(|| Error::Usage(format!("`{flag}` expects a value")))
208}
209
210fn print_help() {
211 eprintln!(
212 "config-disassembler {ver}\n\
213\n\
214Disassemble configuration files (XML, JSON, JSON5, JSONC, YAML, TOON, TOML, INI) into smaller\n\
215files and reassemble the original on demand.\n\
216\n\
217USAGE:\n\
218 config-disassembler <subcommand> [args...]\n\
219\n\
220SUBCOMMANDS:\n\
221 xml Disassemble or reassemble an XML file.\n\
222 json Disassemble or reassemble a JSON file.\n\
223 json5 Disassemble or reassemble a JSON5 file.\n\
224 jsonc Disassemble or reassemble a JSONC file.\n\
225 yaml Disassemble or reassemble a YAML file.\n\
226 toon Disassemble or reassemble a TOON file.\n\
227 toml Disassemble or reassemble a TOML file (TOML <-> TOML only).\n\
228 ini Disassemble or reassemble an INI file (INI <-> INI only).\n\
229 help Show this help text.\n\
230\n\
231Run `config-disassembler <subcommand> --help` for subcommand details.\n",
232 ver = env!("CARGO_PKG_VERSION")
233 );
234}
235
236fn print_format_help(format: Format) {
237 match format {
241 Format::Toml => {
242 print_same_format_help(
243 format,
244 "TOML can only be converted to and from TOML. Cross-format conversion with\n\
245JSON, JSON5, JSONC, YAML, TOON, or INI is rejected because TOML cannot represent `null`,\n\
246forbids array roots, and forces bare keys to precede tables (which would\n\
247reorder values on round-trip).",
248 " (TOML disallows array roots, so this only applies to nested arrays.)",
249 );
250 }
251 Format::Ini => {
252 print_same_format_help(
253 format,
254 "INI can only be converted to and from INI. Cross-format conversion with\n\
255JSON, JSON5, JSONC, YAML, TOON, or TOML is rejected because INI stores section\n\
256values as strings or valueless keys and cannot represent arrays or deeper nesting.",
257 " (INI cannot represent arrays, so this normally does not apply.)",
258 );
259 }
260 Format::Json | Format::Json5 | Format::Jsonc | Format::Yaml | Format::Toon => {
261 print_cross_format_help(format);
262 }
263 }
264}
265
266fn print_same_format_help(format: Format, explanation: &str, unique_id_note: &str) {
267 eprintln!(
268 "config-disassembler {format} <action> [options]\n\
269\n\
270{explanation}\n\
271\n\
272ACTIONS:\n\
273 disassemble <input> Split <input>.{extension} into a directory of {display_name} files.\n\
274 <input> may also be a directory; every .{extension} file\n\
275 beneath it is disassembled in place.\n\
276 reassemble <dir> Rebuild the original {display_name} file from <dir>.\n\
277\n\
278DISASSEMBLE OPTIONS:\n\
279 -o, --output-dir <dir> Output directory (default: <input-stem> next to input).\n\
280 Not allowed when <input> is a directory.\n\
281 --unique-id <field> For array roots, name files by this field on each element.\n\
282{unique_id_note}\n\
283 --ignore-path <path> Path to a .gitignore-style file used when <input> is a\n\
284 directory (default: .cdignore in the input directory).\n\
285 --pre-purge Remove the output directory before writing.\n\
286 --post-purge Delete the input file after disassembly.\n\
287\n\
288REASSEMBLE OPTIONS:\n\
289 -o, --output <file> Output file (default: derived from metadata next to input dir).\n\
290 --post-purge Remove the input directory after reassembly.\n",
291 extension = format.extension(),
292 display_name = format.display_name()
293 );
294}
295
296fn print_cross_format_help(format: Format) {
297 let compatible_formats = format_list(format.compatible_formats());
298 eprintln!(
299 "config-disassembler {format} <action> [options]\n\
300\n\
301ACTIONS:\n\
302 disassemble <input> Split <input> into a directory of smaller files.\n\
303 <input> may also be a directory; every matching\n\
304 file beneath it is disassembled in place.\n\
305 reassemble <dir> Rebuild the original file from <dir>.\n\
306\n\
307DISASSEMBLE OPTIONS:\n\
308 -o, --output-dir <dir> Output directory (default: <input-stem> next to input).\n\
309 Not allowed when <input> is a directory.\n\
310 --input-format <fmt> Override the input format (default: inferred from extension or `{format}`).\n\
311 --output-format <fmt> Format used for the split files (default: same as input).\n\
312 --unique-id <field> For array roots, name files by this field on each element.\n\
313 --ignore-path <path> Path to a .gitignore-style file used when <input> is a\n\
314 directory (default: .cdignore in the input directory).\n\
315 --pre-purge Remove the output directory before writing.\n\
316 --post-purge Delete the input file after disassembly.\n\
317\n\
318REASSEMBLE OPTIONS:\n\
319 -o, --output <file> Output file (default: derived from metadata next to input dir).\n\
320 --output-format <fmt> Format to write the reassembled file in (default: original source format).\n\
321 --post-purge Remove the input directory after reassembly.\n\
322\n\
323<fmt> is one of: {compatible_formats}. (TOML and INI are excluded -- use their dedicated subcommands.)\n"
324 );
325}
326
327fn format_list(formats: &[Format]) -> String {
328 formats
329 .iter()
330 .map(|format| format.canonical_name())
331 .collect::<Vec<_>>()
332 .join(", ")
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn run_reassemble_rejects_second_positional_argument() {
341 let args = vec!["dir1".to_string(), "dir2".to_string()];
352 let err = run_reassemble(Format::Json, args)
353 .expect_err("two positional dirs must be rejected as a usage error");
354 assert!(
355 matches!(&err, Error::Usage(msg) if msg.contains("unexpected argument `dir2`")),
356 "expected `Error::Usage` mentioning the second positional arg, got: {err:?}"
357 );
358 }
359}