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_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
183pub 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
200pub 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
221pub 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 assert!(parse_decompose_spec("only:two").is_empty());
337 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 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 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}