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