1use crate::xml::{
7 DecomposeRule, DisassembleXmlFileHandler, MultiLevelRule, ReassembleXmlFileHandler,
8};
9
10pub 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 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
28pub 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
64pub 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
85pub 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
99pub 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
197pub 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
214pub 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
235pub 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 assert!(parse_decompose_spec("only:two").is_empty());
357 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 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 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 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}