Skip to main content

config_disassembler/xml/
cli.rs

1//! Command-line interface for the xml-disassembler binary.
2//!
3//! Kept in the library crate so it can be exercised by unit tests and
4//! the binary stays a thin shim.
5
6use crate::xml::{
7    DecomposeRule, DisassembleXmlFileHandler, MultiLevelRule, ReassembleXmlFileHandler,
8};
9
10/// Options parsed from disassemble CLI args.
11pub struct DisassembleOpts<'a> {
12    pub path: Option<&'a str>,
13    pub unique_id_elements: Option<&'a str>,
14    pub pre_purge: bool,
15    pub post_purge: bool,
16    /// Explicit `--ignore-path` value. `None` means the caller did not
17    /// pass the flag and the runner should resolve a default (see
18    /// [`crate::xml::ignore_file::resolve_xml_ignore_path`]).
19    ///
20    /// [`crate::xml::ignore_file::resolve_xml_ignore_path`]: crate::ignore_file::resolve_xml_ignore_path
21    pub ignore_path: Option<&'a str>,
22    pub format: &'a str,
23    pub strategy: Option<&'a str>,
24    pub multi_level: Option<String>,
25    pub split_tags: Option<String>,
26}
27
28/// Parse --split-tags spec for grouped-by-tag. Comma-separated rules; each rule:
29/// `tag:mode:field` (path_segment defaults to tag) or `tag:path:mode:field`.
30/// mode = "split" (one file per item) or "group" (group by field).
31pub fn parse_decompose_spec(spec: &str) -> Vec<DecomposeRule> {
32    let mut rules = Vec::new();
33    for part in spec.split(',') {
34        let part = part.trim();
35        let segments: Vec<&str> = part.splitn(4, ':').collect();
36        if segments.len() >= 3 {
37            let tag = segments[0].to_string();
38            let (path_segment, mode, field) = if segments.len() == 3 {
39                (
40                    tag.clone(),
41                    segments[1].to_string(),
42                    segments[2].to_string(),
43                )
44            } else {
45                (
46                    segments[1].to_string(),
47                    segments[2].to_string(),
48                    segments[3].to_string(),
49                )
50            };
51            if !tag.is_empty() && !mode.is_empty() && !field.is_empty() {
52                rules.push(DecomposeRule {
53                    tag,
54                    path_segment,
55                    mode,
56                    field,
57                });
58            }
59        }
60    }
61    rules
62}
63
64/// Parse a single --multi-level spec: `file_pattern:root_to_strip:unique_id_elements`.
65pub fn parse_multi_level_spec(spec: &str) -> Option<MultiLevelRule> {
66    let parts: Vec<&str> = spec.splitn(3, ':').collect();
67    if parts.len() != 3 {
68        return None;
69    }
70    let (file_pattern, root_to_strip, unique_id_elements) = (parts[0], parts[1], parts[2]);
71    if file_pattern.is_empty() || root_to_strip.is_empty() || unique_id_elements.is_empty() {
72        return None;
73    }
74    let path_segment = crate::xml::path_segment_from_file_pattern(file_pattern);
75    Some(MultiLevelRule {
76        file_pattern: file_pattern.to_string(),
77        root_to_strip: root_to_strip.to_string(),
78        unique_id_elements: unique_id_elements.to_string(),
79        path_segment: path_segment.clone(),
80        wrap_root_element: root_to_strip.to_string(),
81        wrap_xmlns: String::new(),
82    })
83}
84
85/// Parse one or more --multi-level specs separated by `;`.
86///
87/// Each rule is `file_pattern:root_to_strip:unique_id_elements`; rules are joined by `;`
88/// because the third part is itself a comma-separated list. Empty rules (e.g. trailing `;`)
89/// are skipped silently. Malformed rules are dropped (the caller may warn separately if it
90/// needs to surface that to the user).
91pub fn parse_multi_level_specs(spec: &str) -> Vec<MultiLevelRule> {
92    spec.split(';')
93        .map(str::trim)
94        .filter(|s| !s.is_empty())
95        .filter_map(parse_multi_level_spec)
96        .collect()
97}
98
99/// Parse disassemble args: `<path> [options]`.
100///
101/// Iteration is driven by `args.iter()` rather than a manual `let mut i =
102/// 0; while i < args.len()` cursor so that every option handler advances
103/// the cursor by consuming the iterator. The previous index-based loop
104/// scattered ~22 `i += 1` expressions across the body; every `+= -> -=`
105/// or `+= -> *=` mutation on those lines produces an infinite loop (the
106/// outer `while` never terminates because `i` either wraps around on
107/// usize underflow or stays put), which `cargo-mutants` can only
108/// classify as `timeout`. That added ~36 timeouts and ~36 minutes of
109/// wall-clock to every full sweep with no actual signal. The iterator
110/// form removes those mutation sites entirely; behavior is unchanged
111/// and covered by the existing `parse_disassemble_args_*` tests.
112pub fn parse_disassemble_args(args: &[String]) -> DisassembleOpts<'_> {
113    let mut path = None;
114    let mut unique_id_elements = None;
115    let mut pre_purge = false;
116    let mut post_purge = false;
117    let mut ignore_path: Option<&str> = None;
118    let mut format = "xml";
119    let mut strategy = None;
120    let mut multi_level = None;
121    let mut split_tags = None;
122
123    let mut iter = args.iter();
124    while let Some(arg) = iter.next() {
125        if arg == "--postpurge" {
126            post_purge = true;
127        } else if arg == "--prepurge" {
128            pre_purge = true;
129        } else if let Some(rest) = arg.strip_prefix("--unique-id-elements=") {
130            unique_id_elements = Some(rest);
131        } else if arg == "--unique-id-elements" {
132            if let Some(value) = iter.next() {
133                unique_id_elements = Some(value.as_str());
134            }
135        } else if let Some(rest) = arg.strip_prefix("--ignore-path=") {
136            ignore_path = Some(rest);
137        } else if arg == "--ignore-path" {
138            if let Some(value) = iter.next() {
139                ignore_path = Some(value.as_str());
140            }
141        } else if let Some(rest) = arg.strip_prefix("--format=") {
142            format = rest;
143        } else if arg == "--format" {
144            if let Some(value) = iter.next() {
145                format = value.as_str();
146            }
147        } else if let Some(rest) = arg.strip_prefix("--strategy=") {
148            strategy = Some(rest);
149        } else if arg == "--strategy" {
150            if let Some(value) = iter.next() {
151                strategy = Some(value.as_str());
152            }
153        } else if let Some(rest) = arg.strip_prefix("--multi-level=") {
154            multi_level = Some(rest.to_string());
155        } else if arg == "--multi-level" {
156            if let Some(value) = iter.next() {
157                multi_level = Some(value.clone());
158            }
159        } else if let Some(rest) = arg.strip_prefix("--split-tags=") {
160            split_tags = Some(rest.to_string());
161        } else if arg == "--split-tags" || arg == "-p" {
162            if let Some(value) = iter.next() {
163                split_tags = Some(value.clone());
164            }
165        } else if arg.starts_with("--") {
166            // Unknown long flag: silently skipped (matches the legacy
167            // index-based parser, whose tests pin this behavior via
168            // `parse_disassemble_args_unknown_long_flag_is_skipped`).
169        } else if path.is_none() {
170            path = Some(arg.as_str());
171        }
172        // Else: extra positional argument, dropped silently to match the
173        // legacy parser (covered by
174        // `parse_disassemble_args_trailing_extra_positional_ignored`).
175    }
176
177    DisassembleOpts {
178        path,
179        unique_id_elements,
180        pre_purge,
181        post_purge,
182        ignore_path,
183        format,
184        strategy,
185        multi_level,
186        split_tags,
187    }
188}
189
190/// Parse reassemble args: `<path> [extension] [--postpurge]`.
191pub fn parse_reassemble_args(args: &[String]) -> (Option<&str>, Option<&str>, bool) {
192    let mut path = None;
193    let mut extension = None;
194    let mut post_purge = false;
195    for arg in args {
196        if arg == "--postpurge" {
197            post_purge = true;
198        } else if path.is_none() {
199            path = Some(arg.as_str());
200        } else if extension.is_none() {
201            extension = Some(arg.as_str());
202        }
203    }
204    (path, extension, post_purge)
205}
206
207/// Print CLI usage to stderr.
208pub fn print_usage() {
209    eprintln!("Usage: xml-disassembler <command> [options]");
210    eprintln!("  disassemble <path> [options]     - Disassemble XML file or directory");
211    eprintln!("    --postpurge                    - Delete original file/dir after disassembling (default: false)");
212    eprintln!("    --prepurge                     - Remove existing disassembly output before running (default: false)");
213    eprintln!(
214        "    --unique-id-elements <list>    - Comma-separated element names for nested filenames"
215    );
216    eprintln!("    --ignore-path <path>           - Path to ignore file (default: .cdignore; falls back to .xmldisassemblerignore for backward compatibility)");
217    eprintln!(
218        "    --format <fmt>                 - Output format: xml, json, json5, yaml (default: xml)"
219    );
220    eprintln!(
221        "    --strategy <name>              - unique-id or grouped-by-tag (default: unique-id)"
222    );
223    eprintln!("    --multi-level <spec>          - Further disassemble matching files: file_pattern:root_to_strip:unique_id_elements (multiple rules separated by ';')");
224    eprintln!("    -p, --split-tags <spec>       - With grouped-by-tag: split/group nested tags (e.g. objectPermissions:split:object,fieldPermissions:group:field)");
225    eprintln!("  reassemble <path> [extension] [--postpurge]  - Reassemble directory (default extension: xml)");
226}
227
228/// True when `args` only contains the program name (or is empty).
229/// Pure helper extracted from `run` so the `args.len() < 2` guard
230/// can be exercised at every boundary without spawning a process.
231fn should_print_usage(args_len: usize) -> bool {
232    args_len < 2
233}
234
235/// True when the user supplied a `--multi-level` spec that didn't
236/// parse into any rules. Pure helper extracted from `run_disassemble`
237/// so both legs of `spec.is_some() && parsed.is_empty()` are testable.
238fn multi_level_spec_failed_to_parse(spec_present: bool, parsed_empty: bool) -> bool {
239    spec_present && parsed_empty
240}
241
242/// True when the strategy is `grouped-by-tag` and decompose-spec
243/// parsing should run. Extracting this guard avoids paying the cost
244/// of parsing `--split-tags` (and triggering its destructive
245/// behaviours under mutation testing) for any other strategy.
246fn should_parse_decompose_rules(strategy: &str) -> bool {
247    strategy == "grouped-by-tag"
248}
249
250/// Run the CLI with the given args. `args[0]` is expected to be the program name.
251pub async fn run(args: Vec<String>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
252    if should_print_usage(args.len()) {
253        print_usage();
254        return Ok(());
255    }
256
257    let command = &args[1];
258    match command.as_str() {
259        "disassemble" => run_disassemble(&args[2..]).await?,
260        "reassemble" => run_reassemble(&args[2..]).await?,
261        _ => {
262            eprintln!("Unknown command: {}", command);
263        }
264    }
265
266    Ok(())
267}
268
269async fn run_disassemble(args: &[String]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
270    let opts = parse_disassemble_args(args);
271    let path = opts.path.unwrap_or(".");
272    let strategy = opts.strategy.unwrap_or("unique-id");
273    let multi_level_rules: Vec<MultiLevelRule> = opts
274        .multi_level
275        .as_deref()
276        .map(parse_multi_level_specs)
277        .unwrap_or_default();
278    if multi_level_spec_failed_to_parse(opts.multi_level.is_some(), multi_level_rules.is_empty()) {
279        eprintln!("Invalid --multi-level spec; use file_pattern:root_to_strip:unique_id_elements (multiple rules separated by ';')");
280    }
281    let decompose_rules: Vec<DecomposeRule> = if should_parse_decompose_rules(strategy) {
282        opts.split_tags
283            .as_ref()
284            .map(|s| parse_decompose_spec(s))
285            .unwrap_or_default()
286    } else {
287        Vec::new()
288    };
289    let decompose_rules_ref = if decompose_rules.is_empty() {
290        None
291    } else {
292        Some(decompose_rules.as_slice())
293    };
294    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::Path::new(".").to_path_buf());
295    let resolved_ignore = crate::ignore_file::resolve_xml_ignore_path(opts.ignore_path, &cwd);
296    let multi_level_rules_ref = if multi_level_rules.is_empty() {
297        None
298    } else {
299        Some(multi_level_rules.as_slice())
300    };
301    let mut handler = DisassembleXmlFileHandler::new();
302    handler
303        .disassemble(
304            path,
305            opts.unique_id_elements,
306            Some(strategy),
307            opts.pre_purge,
308            opts.post_purge,
309            &resolved_ignore,
310            opts.format,
311            multi_level_rules_ref,
312            decompose_rules_ref,
313        )
314        .await?;
315    Ok(())
316}
317
318async fn run_reassemble(args: &[String]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
319    let (path, extension, post_purge) = parse_reassemble_args(args);
320    let path = path.unwrap_or(".");
321    let handler = ReassembleXmlFileHandler::new();
322    handler
323        .reassemble(path, extension.or(Some("xml")), post_purge)
324        .await?;
325    Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn sv(s: &str) -> String {
333        s.to_string()
334    }
335
336    #[test]
337    fn parse_decompose_spec_three_segments_defaults_path_segment_to_tag() {
338        let rules = parse_decompose_spec("objectPermissions:split:object");
339        assert_eq!(rules.len(), 1);
340        let r = &rules[0];
341        assert_eq!(r.tag, "objectPermissions");
342        assert_eq!(r.path_segment, "objectPermissions");
343        assert_eq!(r.mode, "split");
344        assert_eq!(r.field, "object");
345    }
346
347    #[test]
348    fn parse_decompose_spec_four_segments_uses_explicit_path_segment() {
349        let rules = parse_decompose_spec("fieldPermissions:fieldPerms:group:field");
350        assert_eq!(rules.len(), 1);
351        let r = &rules[0];
352        assert_eq!(r.tag, "fieldPermissions");
353        assert_eq!(r.path_segment, "fieldPerms");
354        assert_eq!(r.mode, "group");
355        assert_eq!(r.field, "field");
356    }
357
358    #[test]
359    fn parse_decompose_spec_comma_separated_rules_trims_whitespace() {
360        let rules = parse_decompose_spec("a:split:f, b:group:g , c:x:split:y");
361        assert_eq!(rules.len(), 3);
362        assert_eq!(rules[0].tag, "a");
363        assert_eq!(rules[1].tag, "b");
364        assert_eq!(rules[2].tag, "c");
365        assert_eq!(rules[2].path_segment, "x");
366    }
367
368    #[test]
369    fn parse_decompose_spec_rejects_empty_segments() {
370        // Too few segments
371        assert!(parse_decompose_spec("only:two").is_empty());
372        // Empty tag, mode, or field are filtered
373        assert!(parse_decompose_spec(":split:field").is_empty());
374        assert!(parse_decompose_spec("tag::field").is_empty());
375        assert!(parse_decompose_spec("tag:split:").is_empty());
376    }
377
378    #[test]
379    fn parse_multi_level_spec_valid_returns_rule() {
380        let rule = parse_multi_level_spec(
381            "programProcesses-meta:LoyaltyProgramSetup:parameterName,ruleName",
382        )
383        .unwrap();
384        assert_eq!(rule.file_pattern, "programProcesses-meta");
385        assert_eq!(rule.root_to_strip, "LoyaltyProgramSetup");
386        assert_eq!(rule.unique_id_elements, "parameterName,ruleName");
387        assert_eq!(rule.path_segment, "programProcesses");
388        assert_eq!(rule.wrap_root_element, "LoyaltyProgramSetup");
389        assert!(rule.wrap_xmlns.is_empty());
390    }
391
392    #[test]
393    fn parse_multi_level_spec_rejects_wrong_parts() {
394        assert!(parse_multi_level_spec("only:two").is_none());
395        assert!(parse_multi_level_spec(":Root:ids").is_none());
396        assert!(parse_multi_level_spec("file::ids").is_none());
397        assert!(parse_multi_level_spec("file:Root:").is_none());
398    }
399
400    #[test]
401    fn parse_multi_level_specs_single_rule_returns_one() {
402        let rules = parse_multi_level_specs("a-meta:Root:id");
403        assert_eq!(rules.len(), 1);
404        assert_eq!(rules[0].file_pattern, "a-meta");
405    }
406
407    #[test]
408    fn parse_multi_level_specs_semicolon_separates_rules() {
409        let rules =
410            parse_multi_level_specs("a-meta:RootA:id1,id2; b-meta:RootB:other ; c-meta:RootC:k");
411        assert_eq!(rules.len(), 3);
412        assert_eq!(rules[0].file_pattern, "a-meta");
413        assert_eq!(rules[0].unique_id_elements, "id1,id2");
414        assert_eq!(rules[1].file_pattern, "b-meta");
415        assert_eq!(rules[1].root_to_strip, "RootB");
416        assert_eq!(rules[2].file_pattern, "c-meta");
417    }
418
419    #[test]
420    fn parse_multi_level_specs_skips_empty_and_malformed() {
421        // Trailing semicolons and malformed rules are dropped without aborting the rest.
422        let rules = parse_multi_level_specs("a:R:id; ; bad ; b:R:id;");
423        assert_eq!(rules.len(), 2);
424        assert_eq!(rules[0].file_pattern, "a");
425        assert_eq!(rules[1].file_pattern, "b");
426    }
427
428    #[test]
429    fn parse_multi_level_specs_empty_string_returns_empty() {
430        assert!(parse_multi_level_specs("").is_empty());
431        assert!(parse_multi_level_specs(" ; ;").is_empty());
432    }
433
434    #[test]
435    fn parse_disassemble_args_handles_flags_and_eq_forms() {
436        let args = [
437            "path/to/file.xml",
438            "--postpurge",
439            "--prepurge",
440            "--unique-id-elements=name,id",
441            "--ignore-path=.foo",
442            "--format=json",
443            "--strategy=grouped-by-tag",
444            "--multi-level=pattern:Root:ids",
445            "--split-tags=a:split:b",
446        ]
447        .iter()
448        .map(|s| sv(s))
449        .collect::<Vec<_>>();
450        let opts = parse_disassemble_args(&args);
451        assert_eq!(opts.path, Some("path/to/file.xml"));
452        assert!(opts.pre_purge);
453        assert!(opts.post_purge);
454        assert_eq!(opts.unique_id_elements, Some("name,id"));
455        assert_eq!(opts.ignore_path, Some(".foo"));
456        assert_eq!(opts.format, "json");
457        assert_eq!(opts.strategy, Some("grouped-by-tag"));
458        assert_eq!(opts.multi_level.as_deref(), Some("pattern:Root:ids"));
459        assert_eq!(opts.split_tags.as_deref(), Some("a:split:b"));
460    }
461
462    #[test]
463    fn parse_disassemble_args_handles_space_separated_forms() {
464        let args = [
465            "file.xml",
466            "--unique-id-elements",
467            "name",
468            "--ignore-path",
469            ".gitignore",
470            "--format",
471            "yaml",
472            "--strategy",
473            "unique-id",
474            "--multi-level",
475            "p:R:ids",
476            "--split-tags",
477            "t:split:f",
478        ]
479        .iter()
480        .map(|s| sv(s))
481        .collect::<Vec<_>>();
482        let opts = parse_disassemble_args(&args);
483        assert_eq!(opts.path, Some("file.xml"));
484        assert_eq!(opts.unique_id_elements, Some("name"));
485        assert_eq!(opts.ignore_path, Some(".gitignore"));
486        assert_eq!(opts.format, "yaml");
487        assert_eq!(opts.strategy, Some("unique-id"));
488        assert_eq!(opts.multi_level.as_deref(), Some("p:R:ids"));
489        assert_eq!(opts.split_tags.as_deref(), Some("t:split:f"));
490    }
491
492    #[test]
493    fn parse_disassemble_args_space_form_value_is_not_misread_as_positional_path() {
494        // For every option that accepts a value in separated form, when the option
495        // is the leading argument and no positional path precedes it, the captured
496        // value must not be re-treated as the positional path. This pins down the
497        // `i += 1` advance after a value is consumed (regression: a missed advance
498        // would cause the parser to revisit the value on the next iteration and
499        // store it in `path`).
500        let cases: &[(&[&str], &str)] = &[
501            (&["--unique-id-elements", "name"], "name"),
502            (&["--ignore-path", ".foo"], ".foo"),
503            (&["--format", "yaml"], "yaml"),
504            (&["--strategy", "grouped-by-tag"], "grouped-by-tag"),
505            (&["--multi-level", "p:R:ids"], "p:R:ids"),
506            (&["--split-tags", "t:split:f"], "t:split:f"),
507            (&["-p", "t:split:f"], "t:split:f"),
508        ];
509        for (args, expected_value) in cases {
510            let owned: Vec<String> = args.iter().map(|s| sv(s)).collect();
511            let opts = parse_disassemble_args(&owned);
512            assert!(
513                opts.path.is_none(),
514                "args {args:?}: value `{expected_value}` was incorrectly captured as path"
515            );
516        }
517    }
518
519    #[test]
520    fn parse_disassemble_args_space_form_value_missing_does_not_panic() {
521        // When a value-consuming option is the last arg with no following value,
522        // the bounds check (`if i < args.len()`) must prevent an out-of-bounds
523        // read. Each of these inputs would panic if the bounds check is mutated
524        // from `<` to `<=`.
525        let options = [
526            "--unique-id-elements",
527            "--ignore-path",
528            "--format",
529            "--strategy",
530            "--multi-level",
531            "--split-tags",
532            "-p",
533        ];
534        for opt in options {
535            let args = [opt].iter().map(|s| sv(s)).collect::<Vec<_>>();
536            let opts = parse_disassemble_args(&args);
537            // The defaults assertion is incidental; the real assertion is that
538            // the call above does not panic.
539            assert!(opts.path.is_none(), "bare option `{opt}` set a path");
540        }
541    }
542
543    #[test]
544    fn parse_disassemble_args_p_alias_for_split_tags() {
545        let args = ["file.xml", "-p", "a:split:b"]
546            .iter()
547            .map(|s| sv(s))
548            .collect::<Vec<_>>();
549        let opts = parse_disassemble_args(&args);
550        assert_eq!(opts.split_tags.as_deref(), Some("a:split:b"));
551    }
552
553    #[test]
554    fn parse_disassemble_args_unknown_long_flag_is_skipped() {
555        let args = ["file.xml", "--unknown"]
556            .iter()
557            .map(|s| sv(s))
558            .collect::<Vec<_>>();
559        let opts = parse_disassemble_args(&args);
560        assert_eq!(opts.path, Some("file.xml"));
561    }
562
563    #[test]
564    fn parse_disassemble_args_defaults_when_empty() {
565        let opts = parse_disassemble_args(&[]);
566        assert!(opts.path.is_none());
567        assert!(opts.strategy.is_none());
568        assert!(opts.unique_id_elements.is_none());
569        assert!(!opts.pre_purge);
570        assert!(!opts.post_purge);
571        assert!(
572            opts.ignore_path.is_none(),
573            "default is `None` so the runner can pick the right filename"
574        );
575        assert_eq!(opts.format, "xml");
576    }
577
578    #[test]
579    fn parse_disassemble_args_space_forms_without_value_leave_default() {
580        let args = ["--unique-id-elements"]
581            .iter()
582            .map(|s| sv(s))
583            .collect::<Vec<_>>();
584        let opts = parse_disassemble_args(&args);
585        assert!(opts.unique_id_elements.is_none());
586    }
587
588    #[test]
589    fn parse_disassemble_args_trailing_extra_positional_ignored() {
590        let args = ["first.xml", "second.xml"]
591            .iter()
592            .map(|s| sv(s))
593            .collect::<Vec<_>>();
594        let opts = parse_disassemble_args(&args);
595        assert_eq!(opts.path, Some("first.xml"));
596    }
597
598    #[test]
599    fn parse_reassemble_args_picks_path_extension_and_flag() {
600        let args = ["some/dir", "json", "--postpurge"]
601            .iter()
602            .map(|s| sv(s))
603            .collect::<Vec<_>>();
604        let (path, ext, purge) = parse_reassemble_args(&args);
605        assert_eq!(path, Some("some/dir"));
606        assert_eq!(ext, Some("json"));
607        assert!(purge);
608    }
609
610    #[test]
611    fn parse_reassemble_args_defaults_and_extra_args_ignored() {
612        let (p, e, purge) = parse_reassemble_args(&[]);
613        assert!(p.is_none());
614        assert!(e.is_none());
615        assert!(!purge);
616
617        let args = ["dir", "xml", "extra"]
618            .iter()
619            .map(|s| sv(s))
620            .collect::<Vec<_>>();
621        let (p, e, _) = parse_reassemble_args(&args);
622        assert_eq!(p, Some("dir"));
623        assert_eq!(e, Some("xml"));
624    }
625
626    #[tokio::test]
627    async fn run_no_args_prints_usage_and_succeeds() {
628        run(vec![sv("xml-disassembler")]).await.unwrap();
629    }
630
631    #[tokio::test]
632    async fn run_unknown_command_is_not_an_error() {
633        run(vec![sv("xml-disassembler"), sv("unknown")])
634            .await
635            .unwrap();
636    }
637
638    #[tokio::test]
639    async fn run_reassemble_missing_path_returns_err() {
640        // Missing directory path propagates an error from fs::metadata.
641        let err = run(vec![
642            sv("xml-disassembler"),
643            sv("reassemble"),
644            sv("/definitely/not/here/xyz"),
645        ])
646        .await;
647        assert!(err.is_err());
648    }
649
650    #[tokio::test]
651    async fn run_disassemble_writes_expected_output() {
652        let dir = tempfile::tempdir().unwrap();
653        let xml_path = dir.path().join("sample.xml");
654        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
655<Root xmlns="http://example.com">
656  <child><name>one</name></child>
657  <child><name>two</name></child>
658</Root>"#;
659        std::fs::write(&xml_path, xml).unwrap();
660        run(vec![
661            sv("xml-disassembler"),
662            sv("disassemble"),
663            xml_path.to_string_lossy().to_string(),
664        ])
665        .await
666        .unwrap();
667        assert!(dir.path().join("sample").exists());
668    }
669
670    #[tokio::test]
671    async fn run_disassemble_with_invalid_multi_level_spec_warns_and_continues() {
672        let dir = tempfile::tempdir().unwrap();
673        let xml_path = dir.path().join("sample.xml");
674        let xml =
675            r#"<?xml version="1.0" encoding="UTF-8"?><Root><child><name>a</name></child></Root>"#;
676        std::fs::write(&xml_path, xml).unwrap();
677        run(vec![
678            sv("xml-disassembler"),
679            sv("disassemble"),
680            xml_path.to_string_lossy().to_string(),
681            sv("--multi-level=bad-spec"),
682        ])
683        .await
684        .unwrap();
685    }
686
687    #[tokio::test]
688    async fn run_reassemble_on_existing_directory_succeeds() {
689        // Disassemble then reassemble via the CLI to cover the success path end-to-end.
690        let dir = tempfile::tempdir().unwrap();
691        let xml_path = dir.path().join("reasm.xml");
692        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
693<Root><child><name>one</name></child><child><name>two</name></child></Root>"#;
694        std::fs::write(&xml_path, xml).unwrap();
695        run(vec![
696            sv("xml-disassembler"),
697            sv("disassemble"),
698            xml_path.to_string_lossy().to_string(),
699        ])
700        .await
701        .unwrap();
702        let disassembled_dir = dir.path().join("reasm");
703        assert!(disassembled_dir.exists());
704        run(vec![
705            sv("xml-disassembler"),
706            sv("reassemble"),
707            disassembled_dir.to_string_lossy().to_string(),
708        ])
709        .await
710        .unwrap();
711    }
712
713    #[tokio::test]
714    async fn run_disassemble_with_grouped_by_tag_split_tags_runs() {
715        let dir = tempfile::tempdir().unwrap();
716        let xml_path = dir.path().join("perms.xml");
717        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
718<Root>
719  <objectPermissions><object>A</object><allowRead>true</allowRead></objectPermissions>
720  <objectPermissions><object>B</object><allowRead>false</allowRead></objectPermissions>
721</Root>"#;
722        std::fs::write(&xml_path, xml).unwrap();
723        run(vec![
724            sv("xml-disassembler"),
725            sv("disassemble"),
726            xml_path.to_string_lossy().to_string(),
727            sv("--strategy=grouped-by-tag"),
728            sv("-p"),
729            sv("objectPermissions:split:object"),
730        ])
731        .await
732        .unwrap();
733    }
734
735    #[test]
736    fn should_print_usage_only_for_fewer_than_two_args() {
737        // Pins each `<` mutant: `<=` would also trigger on len=2,
738        // `==` would miss len=0, `>` would invert the polarity.
739        assert!(should_print_usage(0));
740        assert!(should_print_usage(1));
741        assert!(!should_print_usage(2));
742        assert!(!should_print_usage(3));
743    }
744
745    #[test]
746    fn multi_level_spec_failed_to_parse_requires_both_conditions() {
747        // The warning must fire only when a spec was *provided* and
748        // parsing returned no rules — every other quadrant is silent.
749        assert!(multi_level_spec_failed_to_parse(true, true));
750        assert!(!multi_level_spec_failed_to_parse(true, false));
751        assert!(!multi_level_spec_failed_to_parse(false, true));
752        assert!(!multi_level_spec_failed_to_parse(false, false));
753    }
754
755    #[test]
756    fn should_parse_decompose_rules_only_for_grouped_by_tag() {
757        // Decompose rules are exclusive to `grouped-by-tag`. Mutating
758        // the original `==` to `!=` would forward decompose specs to
759        // the `unique-id` strategy and trigger downstream work that
760        // times out under cargo-mutants — testing the helper directly
761        // pins the operator without involving the async pipeline.
762        assert!(should_parse_decompose_rules("grouped-by-tag"));
763        assert!(!should_parse_decompose_rules("unique-id"));
764        assert!(!should_parse_decompose_rules(""));
765        assert!(!should_parse_decompose_rules("Grouped-By-Tag"));
766    }
767}