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