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]`.
100pub fn parse_disassemble_args(args: &[String]) -> DisassembleOpts<'_> {
101    let mut path = None;
102    let mut unique_id_elements = None;
103    let mut pre_purge = false;
104    let mut post_purge = false;
105    let mut ignore_path: Option<&str> = None;
106    let mut format = "xml";
107    let mut strategy = None;
108    let mut multi_level = None;
109    let mut split_tags = None;
110
111    let mut i = 0;
112    while i < args.len() {
113        let arg = &args[i];
114        if arg == "--postpurge" {
115            post_purge = true;
116            i += 1;
117        } else if arg == "--prepurge" {
118            pre_purge = true;
119            i += 1;
120        } else if let Some(rest) = arg.strip_prefix("--unique-id-elements=") {
121            unique_id_elements = Some(rest);
122            i += 1;
123        } else if arg == "--unique-id-elements" {
124            i += 1;
125            if i < args.len() {
126                unique_id_elements = Some(args[i].as_str());
127                i += 1;
128            }
129        } else if let Some(rest) = arg.strip_prefix("--ignore-path=") {
130            ignore_path = Some(rest);
131            i += 1;
132        } else if arg == "--ignore-path" {
133            i += 1;
134            if i < args.len() {
135                ignore_path = Some(args[i].as_str());
136                i += 1;
137            }
138        } else if let Some(rest) = arg.strip_prefix("--format=") {
139            format = rest;
140            i += 1;
141        } else if arg == "--format" {
142            i += 1;
143            if i < args.len() {
144                format = args[i].as_str();
145                i += 1;
146            }
147        } else if let Some(rest) = arg.strip_prefix("--strategy=") {
148            strategy = Some(rest);
149            i += 1;
150        } else if arg == "--strategy" {
151            i += 1;
152            if i < args.len() {
153                strategy = Some(args[i].as_str());
154                i += 1;
155            }
156        } else if let Some(rest) = arg.strip_prefix("--multi-level=") {
157            multi_level = Some(rest.to_string());
158            i += 1;
159        } else if arg == "--multi-level" {
160            i += 1;
161            if i < args.len() {
162                multi_level = Some(args[i].clone());
163                i += 1;
164            }
165        } else if let Some(rest) = arg.strip_prefix("--split-tags=") {
166            split_tags = Some(rest.to_string());
167            i += 1;
168        } else if arg == "--split-tags" || arg == "-p" {
169            i += 1;
170            if i < args.len() {
171                split_tags = Some(args[i].clone());
172                i += 1;
173            }
174        } else if arg.starts_with("--") {
175            i += 1;
176        } else if path.is_none() {
177            path = Some(arg.as_str());
178            i += 1;
179        } else {
180            i += 1;
181        }
182    }
183
184    DisassembleOpts {
185        path,
186        unique_id_elements,
187        pre_purge,
188        post_purge,
189        ignore_path,
190        format,
191        strategy,
192        multi_level,
193        split_tags,
194    }
195}
196
197/// Parse reassemble args: `<path> [extension] [--postpurge]`.
198pub fn parse_reassemble_args(args: &[String]) -> (Option<&str>, Option<&str>, bool) {
199    let mut path = None;
200    let mut extension = None;
201    let mut post_purge = false;
202    for arg in args {
203        if arg == "--postpurge" {
204            post_purge = true;
205        } else if path.is_none() {
206            path = Some(arg.as_str());
207        } else if extension.is_none() {
208            extension = Some(arg.as_str());
209        }
210    }
211    (path, extension, post_purge)
212}
213
214/// Print CLI usage to stderr.
215pub fn print_usage() {
216    eprintln!("Usage: xml-disassembler <command> [options]");
217    eprintln!("  disassemble <path> [options]     - Disassemble XML file or directory");
218    eprintln!("    --postpurge                    - Delete original file/dir after disassembling (default: false)");
219    eprintln!("    --prepurge                     - Remove existing disassembly output before running (default: false)");
220    eprintln!(
221        "    --unique-id-elements <list>    - Comma-separated element names for nested filenames"
222    );
223    eprintln!("    --ignore-path <path>           - Path to ignore file (default: .cdignore; falls back to .xmldisassemblerignore for backward compatibility)");
224    eprintln!(
225        "    --format <fmt>                 - Output format: xml, json, json5, yaml (default: xml)"
226    );
227    eprintln!(
228        "    --strategy <name>              - unique-id or grouped-by-tag (default: unique-id)"
229    );
230    eprintln!("    --multi-level <spec>          - Further disassemble matching files: file_pattern:root_to_strip:unique_id_elements (multiple rules separated by ';')");
231    eprintln!("    -p, --split-tags <spec>       - With grouped-by-tag: split/group nested tags (e.g. objectPermissions:split:object,fieldPermissions:group:field)");
232    eprintln!("  reassemble <path> [extension] [--postpurge]  - Reassemble directory (default extension: xml)");
233}
234
235/// Run the CLI with the given args. `args[0]` is expected to be the program name.
236pub async fn run(args: Vec<String>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
237    if args.len() < 2 {
238        print_usage();
239        return Ok(());
240    }
241
242    let command = &args[1];
243    match command.as_str() {
244        "disassemble" => run_disassemble(&args[2..]).await?,
245        "reassemble" => run_reassemble(&args[2..]).await?,
246        _ => {
247            eprintln!("Unknown command: {}", command);
248        }
249    }
250
251    Ok(())
252}
253
254async fn run_disassemble(args: &[String]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
255    let opts = parse_disassemble_args(args);
256    let path = opts.path.unwrap_or(".");
257    let strategy = opts.strategy.unwrap_or("unique-id");
258    let multi_level_rules: Vec<MultiLevelRule> = opts
259        .multi_level
260        .as_deref()
261        .map(parse_multi_level_specs)
262        .unwrap_or_default();
263    if opts.multi_level.is_some() && multi_level_rules.is_empty() {
264        eprintln!("Invalid --multi-level spec; use file_pattern:root_to_strip:unique_id_elements (multiple rules separated by ';')");
265    }
266    let decompose_rules: Vec<DecomposeRule> = if strategy == "grouped-by-tag" {
267        opts.split_tags
268            .as_ref()
269            .map(|s| parse_decompose_spec(s))
270            .unwrap_or_default()
271    } else {
272        Vec::new()
273    };
274    let decompose_rules_ref = if decompose_rules.is_empty() {
275        None
276    } else {
277        Some(decompose_rules.as_slice())
278    };
279    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::Path::new(".").to_path_buf());
280    let resolved_ignore = crate::ignore_file::resolve_xml_ignore_path(opts.ignore_path, &cwd);
281    let multi_level_rules_ref = if multi_level_rules.is_empty() {
282        None
283    } else {
284        Some(multi_level_rules.as_slice())
285    };
286    let mut handler = DisassembleXmlFileHandler::new();
287    handler
288        .disassemble(
289            path,
290            opts.unique_id_elements,
291            Some(strategy),
292            opts.pre_purge,
293            opts.post_purge,
294            &resolved_ignore,
295            opts.format,
296            multi_level_rules_ref,
297            decompose_rules_ref,
298        )
299        .await?;
300    Ok(())
301}
302
303async fn run_reassemble(args: &[String]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
304    let (path, extension, post_purge) = parse_reassemble_args(args);
305    let path = path.unwrap_or(".");
306    let handler = ReassembleXmlFileHandler::new();
307    handler
308        .reassemble(path, extension.or(Some("xml")), post_purge)
309        .await?;
310    Ok(())
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn sv(s: &str) -> String {
318        s.to_string()
319    }
320
321    #[test]
322    fn parse_decompose_spec_three_segments_defaults_path_segment_to_tag() {
323        let rules = parse_decompose_spec("objectPermissions:split:object");
324        assert_eq!(rules.len(), 1);
325        let r = &rules[0];
326        assert_eq!(r.tag, "objectPermissions");
327        assert_eq!(r.path_segment, "objectPermissions");
328        assert_eq!(r.mode, "split");
329        assert_eq!(r.field, "object");
330    }
331
332    #[test]
333    fn parse_decompose_spec_four_segments_uses_explicit_path_segment() {
334        let rules = parse_decompose_spec("fieldPermissions:fieldPerms:group:field");
335        assert_eq!(rules.len(), 1);
336        let r = &rules[0];
337        assert_eq!(r.tag, "fieldPermissions");
338        assert_eq!(r.path_segment, "fieldPerms");
339        assert_eq!(r.mode, "group");
340        assert_eq!(r.field, "field");
341    }
342
343    #[test]
344    fn parse_decompose_spec_comma_separated_rules_trims_whitespace() {
345        let rules = parse_decompose_spec("a:split:f, b:group:g , c:x:split:y");
346        assert_eq!(rules.len(), 3);
347        assert_eq!(rules[0].tag, "a");
348        assert_eq!(rules[1].tag, "b");
349        assert_eq!(rules[2].tag, "c");
350        assert_eq!(rules[2].path_segment, "x");
351    }
352
353    #[test]
354    fn parse_decompose_spec_rejects_empty_segments() {
355        // Too few segments
356        assert!(parse_decompose_spec("only:two").is_empty());
357        // Empty tag, mode, or field are filtered
358        assert!(parse_decompose_spec(":split:field").is_empty());
359        assert!(parse_decompose_spec("tag::field").is_empty());
360        assert!(parse_decompose_spec("tag:split:").is_empty());
361    }
362
363    #[test]
364    fn parse_multi_level_spec_valid_returns_rule() {
365        let rule = parse_multi_level_spec(
366            "programProcesses-meta:LoyaltyProgramSetup:parameterName,ruleName",
367        )
368        .unwrap();
369        assert_eq!(rule.file_pattern, "programProcesses-meta");
370        assert_eq!(rule.root_to_strip, "LoyaltyProgramSetup");
371        assert_eq!(rule.unique_id_elements, "parameterName,ruleName");
372        assert_eq!(rule.path_segment, "programProcesses");
373        assert_eq!(rule.wrap_root_element, "LoyaltyProgramSetup");
374        assert!(rule.wrap_xmlns.is_empty());
375    }
376
377    #[test]
378    fn parse_multi_level_spec_rejects_wrong_parts() {
379        assert!(parse_multi_level_spec("only:two").is_none());
380        assert!(parse_multi_level_spec(":Root:ids").is_none());
381        assert!(parse_multi_level_spec("file::ids").is_none());
382        assert!(parse_multi_level_spec("file:Root:").is_none());
383    }
384
385    #[test]
386    fn parse_multi_level_specs_single_rule_returns_one() {
387        let rules = parse_multi_level_specs("a-meta:Root:id");
388        assert_eq!(rules.len(), 1);
389        assert_eq!(rules[0].file_pattern, "a-meta");
390    }
391
392    #[test]
393    fn parse_multi_level_specs_semicolon_separates_rules() {
394        let rules =
395            parse_multi_level_specs("a-meta:RootA:id1,id2; b-meta:RootB:other ; c-meta:RootC:k");
396        assert_eq!(rules.len(), 3);
397        assert_eq!(rules[0].file_pattern, "a-meta");
398        assert_eq!(rules[0].unique_id_elements, "id1,id2");
399        assert_eq!(rules[1].file_pattern, "b-meta");
400        assert_eq!(rules[1].root_to_strip, "RootB");
401        assert_eq!(rules[2].file_pattern, "c-meta");
402    }
403
404    #[test]
405    fn parse_multi_level_specs_skips_empty_and_malformed() {
406        // Trailing semicolons and malformed rules are dropped without aborting the rest.
407        let rules = parse_multi_level_specs("a:R:id; ; bad ; b:R:id;");
408        assert_eq!(rules.len(), 2);
409        assert_eq!(rules[0].file_pattern, "a");
410        assert_eq!(rules[1].file_pattern, "b");
411    }
412
413    #[test]
414    fn parse_multi_level_specs_empty_string_returns_empty() {
415        assert!(parse_multi_level_specs("").is_empty());
416        assert!(parse_multi_level_specs(" ; ;").is_empty());
417    }
418
419    #[test]
420    fn parse_disassemble_args_handles_flags_and_eq_forms() {
421        let args = [
422            "path/to/file.xml",
423            "--postpurge",
424            "--prepurge",
425            "--unique-id-elements=name,id",
426            "--ignore-path=.foo",
427            "--format=json",
428            "--strategy=grouped-by-tag",
429            "--multi-level=pattern:Root:ids",
430            "--split-tags=a:split:b",
431        ]
432        .iter()
433        .map(|s| sv(s))
434        .collect::<Vec<_>>();
435        let opts = parse_disassemble_args(&args);
436        assert_eq!(opts.path, Some("path/to/file.xml"));
437        assert!(opts.pre_purge);
438        assert!(opts.post_purge);
439        assert_eq!(opts.unique_id_elements, Some("name,id"));
440        assert_eq!(opts.ignore_path, Some(".foo"));
441        assert_eq!(opts.format, "json");
442        assert_eq!(opts.strategy, Some("grouped-by-tag"));
443        assert_eq!(opts.multi_level.as_deref(), Some("pattern:Root:ids"));
444        assert_eq!(opts.split_tags.as_deref(), Some("a:split:b"));
445    }
446
447    #[test]
448    fn parse_disassemble_args_handles_space_separated_forms() {
449        let args = [
450            "file.xml",
451            "--unique-id-elements",
452            "name",
453            "--ignore-path",
454            ".gitignore",
455            "--format",
456            "yaml",
457            "--strategy",
458            "unique-id",
459            "--multi-level",
460            "p:R:ids",
461            "--split-tags",
462            "t:split:f",
463        ]
464        .iter()
465        .map(|s| sv(s))
466        .collect::<Vec<_>>();
467        let opts = parse_disassemble_args(&args);
468        assert_eq!(opts.path, Some("file.xml"));
469        assert_eq!(opts.unique_id_elements, Some("name"));
470        assert_eq!(opts.ignore_path, Some(".gitignore"));
471        assert_eq!(opts.format, "yaml");
472        assert_eq!(opts.strategy, Some("unique-id"));
473        assert_eq!(opts.multi_level.as_deref(), Some("p:R:ids"));
474        assert_eq!(opts.split_tags.as_deref(), Some("t:split:f"));
475    }
476
477    #[test]
478    fn parse_disassemble_args_p_alias_for_split_tags() {
479        let args = ["file.xml", "-p", "a:split:b"]
480            .iter()
481            .map(|s| sv(s))
482            .collect::<Vec<_>>();
483        let opts = parse_disassemble_args(&args);
484        assert_eq!(opts.split_tags.as_deref(), Some("a:split:b"));
485    }
486
487    #[test]
488    fn parse_disassemble_args_unknown_long_flag_is_skipped() {
489        let args = ["file.xml", "--unknown"]
490            .iter()
491            .map(|s| sv(s))
492            .collect::<Vec<_>>();
493        let opts = parse_disassemble_args(&args);
494        assert_eq!(opts.path, Some("file.xml"));
495    }
496
497    #[test]
498    fn parse_disassemble_args_defaults_when_empty() {
499        let opts = parse_disassemble_args(&[]);
500        assert!(opts.path.is_none());
501        assert!(opts.strategy.is_none());
502        assert!(opts.unique_id_elements.is_none());
503        assert!(!opts.pre_purge);
504        assert!(!opts.post_purge);
505        assert!(
506            opts.ignore_path.is_none(),
507            "default is `None` so the runner can pick the right filename"
508        );
509        assert_eq!(opts.format, "xml");
510    }
511
512    #[test]
513    fn parse_disassemble_args_space_forms_without_value_leave_default() {
514        let args = ["--unique-id-elements"]
515            .iter()
516            .map(|s| sv(s))
517            .collect::<Vec<_>>();
518        let opts = parse_disassemble_args(&args);
519        assert!(opts.unique_id_elements.is_none());
520    }
521
522    #[test]
523    fn parse_disassemble_args_trailing_extra_positional_ignored() {
524        let args = ["first.xml", "second.xml"]
525            .iter()
526            .map(|s| sv(s))
527            .collect::<Vec<_>>();
528        let opts = parse_disassemble_args(&args);
529        assert_eq!(opts.path, Some("first.xml"));
530    }
531
532    #[test]
533    fn parse_reassemble_args_picks_path_extension_and_flag() {
534        let args = ["some/dir", "json", "--postpurge"]
535            .iter()
536            .map(|s| sv(s))
537            .collect::<Vec<_>>();
538        let (path, ext, purge) = parse_reassemble_args(&args);
539        assert_eq!(path, Some("some/dir"));
540        assert_eq!(ext, Some("json"));
541        assert!(purge);
542    }
543
544    #[test]
545    fn parse_reassemble_args_defaults_and_extra_args_ignored() {
546        let (p, e, purge) = parse_reassemble_args(&[]);
547        assert!(p.is_none());
548        assert!(e.is_none());
549        assert!(!purge);
550
551        let args = ["dir", "xml", "extra"]
552            .iter()
553            .map(|s| sv(s))
554            .collect::<Vec<_>>();
555        let (p, e, _) = parse_reassemble_args(&args);
556        assert_eq!(p, Some("dir"));
557        assert_eq!(e, Some("xml"));
558    }
559
560    #[tokio::test]
561    async fn run_no_args_prints_usage_and_succeeds() {
562        run(vec![sv("xml-disassembler")]).await.unwrap();
563    }
564
565    #[tokio::test]
566    async fn run_unknown_command_is_not_an_error() {
567        run(vec![sv("xml-disassembler"), sv("unknown")])
568            .await
569            .unwrap();
570    }
571
572    #[tokio::test]
573    async fn run_reassemble_missing_path_returns_err() {
574        // Missing directory path propagates an error from fs::metadata.
575        let err = run(vec![
576            sv("xml-disassembler"),
577            sv("reassemble"),
578            sv("/definitely/not/here/xyz"),
579        ])
580        .await;
581        assert!(err.is_err());
582    }
583
584    #[tokio::test]
585    async fn run_disassemble_writes_expected_output() {
586        let dir = tempfile::tempdir().unwrap();
587        let xml_path = dir.path().join("sample.xml");
588        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
589<Root xmlns="http://example.com">
590  <child><name>one</name></child>
591  <child><name>two</name></child>
592</Root>"#;
593        std::fs::write(&xml_path, xml).unwrap();
594        run(vec![
595            sv("xml-disassembler"),
596            sv("disassemble"),
597            xml_path.to_string_lossy().to_string(),
598        ])
599        .await
600        .unwrap();
601        assert!(dir.path().join("sample").exists());
602    }
603
604    #[tokio::test]
605    async fn run_disassemble_with_invalid_multi_level_spec_warns_and_continues() {
606        let dir = tempfile::tempdir().unwrap();
607        let xml_path = dir.path().join("sample.xml");
608        let xml =
609            r#"<?xml version="1.0" encoding="UTF-8"?><Root><child><name>a</name></child></Root>"#;
610        std::fs::write(&xml_path, xml).unwrap();
611        run(vec![
612            sv("xml-disassembler"),
613            sv("disassemble"),
614            xml_path.to_string_lossy().to_string(),
615            sv("--multi-level=bad-spec"),
616        ])
617        .await
618        .unwrap();
619    }
620
621    #[tokio::test]
622    async fn run_reassemble_on_existing_directory_succeeds() {
623        // Disassemble then reassemble via the CLI to cover the success path end-to-end.
624        let dir = tempfile::tempdir().unwrap();
625        let xml_path = dir.path().join("reasm.xml");
626        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
627<Root><child><name>one</name></child><child><name>two</name></child></Root>"#;
628        std::fs::write(&xml_path, xml).unwrap();
629        run(vec![
630            sv("xml-disassembler"),
631            sv("disassemble"),
632            xml_path.to_string_lossy().to_string(),
633        ])
634        .await
635        .unwrap();
636        let disassembled_dir = dir.path().join("reasm");
637        assert!(disassembled_dir.exists());
638        run(vec![
639            sv("xml-disassembler"),
640            sv("reassemble"),
641            disassembled_dir.to_string_lossy().to_string(),
642        ])
643        .await
644        .unwrap();
645    }
646
647    #[tokio::test]
648    async fn run_disassemble_with_grouped_by_tag_split_tags_runs() {
649        let dir = tempfile::tempdir().unwrap();
650        let xml_path = dir.path().join("perms.xml");
651        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
652<Root>
653  <objectPermissions><object>A</object><allowRead>true</allowRead></objectPermissions>
654  <objectPermissions><object>B</object><allowRead>false</allowRead></objectPermissions>
655</Root>"#;
656        std::fs::write(&xml_path, xml).unwrap();
657        run(vec![
658            sv("xml-disassembler"),
659            sv("disassemble"),
660            xml_path.to_string_lossy().to_string(),
661            sv("--strategy=grouped-by-tag"),
662            sv("-p"),
663            sv("objectPermissions:split:object"),
664        ])
665        .await
666        .unwrap();
667    }
668}