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