Skip to main content

config_disassembler/
cli.rs

1//! Top-level command-line dispatcher.
2//!
3//! ```text
4//! config-disassembler <subcommand> [args...]
5//!
6//! Subcommands:
7//!   xml      Disassemble or reassemble an XML file (in-tree port of xml-disassembler).
8//!   json     Disassemble or reassemble a JSON file.
9//!   json5    Disassemble or reassemble a JSON5 file.
10//!   jsonc    Disassemble or reassemble a JSONC file.
11//!   yaml     Disassemble or reassemble a YAML file.
12//!   toon     Disassemble or reassemble a TOON file.
13//!   toml     Disassemble or reassemble a TOML file (TOML <-> TOML only).
14//!   ini      Disassemble or reassemble an INI file (INI <-> INI only).
15//!   help     Show this help text.
16//! ```
17
18use 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
26/// Dispatch a full argv (including program name at `args[0]`).
27pub 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    // Dispatch up front so every branch is reachable: the same-format-only
238    // formats (TOML, INI) get their dedicated help text, and the
239    // cross-format family shares one help body.
240    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}