@@ -441,3 +441,355 @@ pub fn execute(
441441
442442 Ok ( ( ) )
443443}
444+
445+ // ── Tests ────────────────────────────────────────────────────
446+
447+ #[ cfg( test) ]
448+ mod tests {
449+ use super :: * ;
450+
451+ // Helper to get path-value pairs for easy assertion
452+ fn flat ( entries : & [ FlatEntry ] ) -> Vec < ( & str , Option < & str > ) > {
453+ entries
454+ . iter ( )
455+ . map ( |e| ( e. path . as_str ( ) , e. value . as_deref ( ) ) )
456+ . collect ( )
457+ }
458+
459+ // ── XML: Basic elements ──────────────────────────────────
460+
461+ #[ test]
462+ fn xml_simple_elements ( ) {
463+ let xml = r#"<root><name>hello</name><version>1.0</version></root>"# ;
464+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
465+ let pairs = flat ( & entries) ;
466+ assert_eq ! ( pairs, vec![
467+ ( "root.name" , Some ( "hello" ) ) ,
468+ ( "root.version" , Some ( "1.0" ) ) ,
469+ ] ) ;
470+ }
471+
472+ #[ test]
473+ fn xml_nested_elements ( ) {
474+ let xml = r#"<a><b><c>deep</c></b></a>"# ;
475+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
476+ assert_eq ! ( entries. len( ) , 1 ) ;
477+ assert_eq ! ( entries[ 0 ] . path, "a.b.c" ) ;
478+ assert_eq ! ( entries[ 0 ] . value. as_deref( ) , Some ( "deep" ) ) ;
479+ }
480+
481+ #[ test]
482+ fn xml_empty_element ( ) {
483+ let xml = r#"<root><empty></empty><has>text</has></root>"# ;
484+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
485+ // Empty element produces no entry (no text, no attrs)
486+ assert_eq ! ( entries. len( ) , 1 ) ;
487+ assert_eq ! ( entries[ 0 ] . path, "root.has" ) ;
488+ }
489+
490+ // ── XML: Attributes ──────────────────────────────────────
491+
492+ #[ test]
493+ fn xml_attributes ( ) {
494+ let xml = r#"<file src="tools\**" target="tools" />"# ;
495+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
496+ let pairs = flat ( & entries) ;
497+ assert_eq ! ( pairs, vec![
498+ ( "file@src" , Some ( "tools\\ **" ) ) ,
499+ ( "file@target" , Some ( "tools" ) ) ,
500+ ] ) ;
501+ }
502+
503+ #[ test]
504+ fn xml_element_with_attrs_and_text ( ) {
505+ let xml = r#"<item id="42" type="widget">gadget</item>"# ;
506+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
507+ let pairs = flat ( & entries) ;
508+ assert_eq ! ( pairs, vec![
509+ ( "item@id" , Some ( "42" ) ) ,
510+ ( "item@type" , Some ( "widget" ) ) ,
511+ ( "item" , Some ( "gadget" ) ) ,
512+ ] ) ;
513+ }
514+
515+ // ── XML: Repeated siblings ───────────────────────────────
516+
517+ #[ test]
518+ fn xml_repeated_siblings_get_indexed ( ) {
519+ let xml = r#"<list><item>a</item><item>b</item><item>c</item></list>"# ;
520+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
521+ let pairs = flat ( & entries) ;
522+ assert_eq ! ( pairs, vec![
523+ ( "list.item[0]" , Some ( "a" ) ) ,
524+ ( "list.item[1]" , Some ( "b" ) ) ,
525+ ( "list.item[2]" , Some ( "c" ) ) ,
526+ ] ) ;
527+ }
528+
529+ #[ test]
530+ fn xml_single_element_not_indexed ( ) {
531+ let xml = r#"<list><item>only</item></list>"# ;
532+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
533+ assert_eq ! ( entries[ 0 ] . path, "list.item" ) ; // No [0]
534+ }
535+
536+ #[ test]
537+ fn xml_mixed_siblings_only_duplicates_indexed ( ) {
538+ let xml = r#"<root><name>foo</name><tag>a</tag><tag>b</tag></root>"# ;
539+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
540+ let pairs = flat ( & entries) ;
541+ assert_eq ! ( pairs, vec![
542+ ( "root.name" , Some ( "foo" ) ) ,
543+ ( "root.tag[0]" , Some ( "a" ) ) ,
544+ ( "root.tag[1]" , Some ( "b" ) ) ,
545+ ] ) ;
546+ }
547+
548+ // ── XML: CDATA ───────────────────────────────────────────
549+
550+ #[ test]
551+ fn xml_cdata_treated_as_text ( ) {
552+ let xml = r#"<doc><content><![CDATA[Hello <world>]]></content></doc>"# ;
553+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
554+ assert_eq ! ( entries[ 0 ] . path, "doc.content" ) ;
555+ assert_eq ! ( entries[ 0 ] . value. as_deref( ) , Some ( "Hello <world>" ) ) ;
556+ }
557+
558+ // ── XML: Whitespace handling ─────────────────────────────
559+
560+ #[ test]
561+ fn xml_whitespace_only_text_ignored ( ) {
562+ let xml = r#"<root>
563+ <child>value</child>
564+ </root>"# ;
565+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
566+ // Only the actual text content, not the formatting whitespace
567+ assert_eq ! ( entries. len( ) , 1 ) ;
568+ assert_eq ! ( entries[ 0 ] . path, "root.child" ) ;
569+ assert_eq ! ( entries[ 0 ] . value. as_deref( ) , Some ( "value" ) ) ;
570+ }
571+
572+ // ── XML: Self-closing tags ───────────────────────────────
573+
574+ #[ test]
575+ fn xml_self_closing_with_attrs ( ) {
576+ let xml = r#"<config><db host="localhost" port="5432" /></config>"# ;
577+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
578+ let pairs = flat ( & entries) ;
579+ assert_eq ! ( pairs, vec![
580+ ( "config.db@host" , Some ( "localhost" ) ) ,
581+ ( "config.db@port" , Some ( "5432" ) ) ,
582+ ] ) ;
583+ }
584+
585+ // ── XML: Custom separator ────────────────────────────────
586+
587+ #[ test]
588+ fn xml_custom_separator ( ) {
589+ let xml = r#"<a><b><c>val</c></b></a>"# ;
590+ let entries = flatten_xml ( xml, '/' , 0 ) . unwrap ( ) ;
591+ assert_eq ! ( entries[ 0 ] . path, "a/b/c" ) ;
592+ }
593+
594+ // ── XML: Max depth ───────────────────────────────────────
595+
596+ #[ test]
597+ fn xml_max_depth_limits_traversal ( ) {
598+ let xml = r#"<a><b><c><d>deep</d></c></b></a>"# ;
599+ let entries = flatten_xml ( xml, '.' , 2 ) . unwrap ( ) ;
600+ // Depth 2 means: a (depth 0) -> b (depth 1) -> c (depth 2, but children skipped)
601+ assert ! ( entries. is_empty( ) || !entries. iter( ) . any( |e| e. path. contains( "d" ) ) ) ;
602+ }
603+
604+ // ── XML: Namespaces ──────────────────────────────────────
605+
606+ #[ test]
607+ fn xml_namespace_prefix_preserved ( ) {
608+ let xml = r#"<soap:Envelope><soap:Body><ns:Data>val</ns:Data></soap:Body></soap:Envelope>"# ;
609+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
610+ assert_eq ! ( entries[ 0 ] . path, "soap:Envelope.soap:Body.ns:Data" ) ;
611+ assert_eq ! ( entries[ 0 ] . value. as_deref( ) , Some ( "val" ) ) ;
612+ }
613+
614+ // ── XML: Real-world nuspec ───────────────────────────────
615+
616+ #[ test]
617+ fn xml_nuspec_structure ( ) {
618+ let xml = r#"<?xml version="1.0" encoding="utf-8"?>
619+ <package>
620+ <metadata>
621+ <id>recur</id>
622+ <version>0.2.1</version>
623+ <title>recur</title>
624+ </metadata>
625+ <files>
626+ <file src="tools\**" target="tools" />
627+ </files>
628+ </package>"# ;
629+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
630+ let pairs = flat ( & entries) ;
631+ assert ! ( pairs. contains( & ( "package.metadata.id" , Some ( "recur" ) ) ) ) ;
632+ assert ! ( pairs. contains( & ( "package.metadata.version" , Some ( "0.2.1" ) ) ) ) ;
633+ assert ! ( pairs. contains( & ( "package.metadata.title" , Some ( "recur" ) ) ) ) ;
634+ assert ! ( pairs. contains( & ( "package.files.file@src" , Some ( "tools\\ **" ) ) ) ) ;
635+ assert ! ( pairs. contains( & ( "package.files.file@target" , Some ( "tools" ) ) ) ) ;
636+ }
637+
638+ // ── XML: Entity references ───────────────────────────────
639+
640+ #[ test]
641+ fn xml_entities_decoded ( ) {
642+ let xml = r#"<root><text>a & b < c</text></root>"# ;
643+ let entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
644+ assert_eq ! ( entries[ 0 ] . value. as_deref( ) , Some ( "a & b < c" ) ) ;
645+ }
646+
647+ // ── JSON: Basic objects ──────────────────────────────────
648+
649+ #[ test]
650+ fn json_simple_object ( ) {
651+ let json = r#"{"name":"recur","version":"0.2.5"}"# ;
652+ let entries = flatten_json ( json, '.' , 0 ) . unwrap ( ) ;
653+ let pairs = flat ( & entries) ;
654+ assert ! ( pairs. contains( & ( "name" , Some ( "recur" ) ) ) ) ;
655+ assert ! ( pairs. contains( & ( "version" , Some ( "0.2.5" ) ) ) ) ;
656+ }
657+
658+ #[ test]
659+ fn json_nested_object ( ) {
660+ let json = r#"{"config":{"database":{"host":"localhost","port":"5432"}}}"# ;
661+ let entries = flatten_json ( json, '.' , 0 ) . unwrap ( ) ;
662+ let pairs = flat ( & entries) ;
663+ assert ! ( pairs. contains( & ( "config.database.host" , Some ( "localhost" ) ) ) ) ;
664+ assert ! ( pairs. contains( & ( "config.database.port" , Some ( "5432" ) ) ) ) ;
665+ }
666+
667+ // ── JSON: Arrays ─────────────────────────────────────────
668+
669+ #[ test]
670+ fn json_arrays_indexed ( ) {
671+ let json = r#"{"tags":["search","grep","hierarchy"]}"# ;
672+ let entries = flatten_json ( json, '.' , 0 ) . unwrap ( ) ;
673+ let pairs = flat ( & entries) ;
674+ assert ! ( pairs. contains( & ( "tags[0]" , Some ( "search" ) ) ) ) ;
675+ assert ! ( pairs. contains( & ( "tags[1]" , Some ( "grep" ) ) ) ) ;
676+ assert ! ( pairs. contains( & ( "tags[2]" , Some ( "hierarchy" ) ) ) ) ;
677+ }
678+
679+ #[ test]
680+ fn json_array_of_objects ( ) {
681+ let json = r#"{"users":[{"name":"Joe"},{"name":"Skippy"}]}"# ;
682+ let entries = flatten_json ( json, '.' , 0 ) . unwrap ( ) ;
683+ let pairs = flat ( & entries) ;
684+ assert ! ( pairs. contains( & ( "users[0].name" , Some ( "Joe" ) ) ) ) ;
685+ assert ! ( pairs. contains( & ( "users[1].name" , Some ( "Skippy" ) ) ) ) ;
686+ }
687+
688+ // ── JSON: Primitive types ────────────────────────────────
689+
690+ #[ test]
691+ fn json_booleans_and_null ( ) {
692+ let json = r#"{"active":true,"deleted":false,"notes":null}"# ;
693+ let entries = flatten_json ( json, '.' , 0 ) . unwrap ( ) ;
694+ let pairs = flat ( & entries) ;
695+ assert ! ( pairs. contains( & ( "active" , Some ( "true" ) ) ) ) ;
696+ assert ! ( pairs. contains( & ( "deleted" , Some ( "false" ) ) ) ) ;
697+ assert ! ( pairs. contains( & ( "notes" , Some ( "null" ) ) ) ) ;
698+ }
699+
700+ #[ test]
701+ fn json_numbers ( ) {
702+ let json = r#"{"count":42,"ratio":3.14}"# ;
703+ let entries = flatten_json ( json, '.' , 0 ) . unwrap ( ) ;
704+ let pairs = flat ( & entries) ;
705+ assert ! ( pairs. contains( & ( "count" , Some ( "42" ) ) ) ) ;
706+ assert ! ( pairs. contains( & ( "ratio" , Some ( "3.14" ) ) ) ) ;
707+ }
708+
709+ // ── JSON: Max depth ──────────────────────────────────────
710+
711+ #[ test]
712+ fn json_max_depth_limits_traversal ( ) {
713+ let json = r#"{"a":{"b":{"c":{"d":"deep"}}}}"# ;
714+ let entries = flatten_json ( json, '.' , 2 ) . unwrap ( ) ;
715+ // At depth 2 we hit "a.b" which contains {"c":{"d":"deep"}}
716+ // That should be serialized as a raw JSON string
717+ assert ! ( !entries. iter( ) . any( |e| e. path == "a.b.c.d" ) ) ;
718+ assert ! ( entries. iter( ) . any( |e| e. path == "a.b" ) ) ;
719+ }
720+
721+ // ── JSON: Custom separator ───────────────────────────────
722+
723+ #[ test]
724+ fn json_custom_separator ( ) {
725+ let json = r#"{"a":{"b":"val"}}"# ;
726+ let entries = flatten_json ( json, '/' , 0 ) . unwrap ( ) ;
727+ assert_eq ! ( entries[ 0 ] . path, "a/b" ) ;
728+ }
729+
730+ // ── JSON: Real-world package.json style ──────────────────
731+
732+ #[ test]
733+ fn json_package_json_style ( ) {
734+ let json = r#"{
735+ "name": "my-app",
736+ "version": "1.0.0",
737+ "dependencies": {
738+ "express": "^4.18.0",
739+ "lodash": "^4.17.21"
740+ },
741+ "scripts": {
742+ "start": "node index.js",
743+ "test": "jest"
744+ }
745+ }"# ;
746+ let entries = flatten_json ( json, '.' , 0 ) . unwrap ( ) ;
747+ let pairs = flat ( & entries) ;
748+ assert ! ( pairs. contains( & ( "name" , Some ( "my-app" ) ) ) ) ;
749+ assert ! ( pairs. contains( & ( "dependencies.express" , Some ( "^4.18.0" ) ) ) ) ;
750+ assert ! ( pairs. contains( & ( "scripts.test" , Some ( "jest" ) ) ) ) ;
751+ }
752+
753+ // ── Format detection ─────────────────────────────────────
754+
755+ #[ test]
756+ fn detect_xml_extensions ( ) {
757+ assert_eq ! ( detect_format( Some ( & PathBuf :: from( "file.xml" ) ) , None ) , Format :: Xml ) ;
758+ assert_eq ! ( detect_format( Some ( & PathBuf :: from( "file.nuspec" ) ) , None ) , Format :: Xml ) ;
759+ assert_eq ! ( detect_format( Some ( & PathBuf :: from( "file.csproj" ) ) , None ) , Format :: Xml ) ;
760+ assert_eq ! ( detect_format( Some ( & PathBuf :: from( "file.svg" ) ) , None ) , Format :: Xml ) ;
761+ }
762+
763+ #[ test]
764+ fn detect_json_extensions ( ) {
765+ assert_eq ! ( detect_format( Some ( & PathBuf :: from( "file.json" ) ) , None ) , Format :: Json ) ;
766+ assert_eq ! ( detect_format( Some ( & PathBuf :: from( "data.jsonl" ) ) , None ) , Format :: Json ) ;
767+ }
768+
769+ #[ test]
770+ fn detect_format_override ( ) {
771+ // Override should win over extension
772+ assert_eq ! ( detect_format( Some ( & PathBuf :: from( "file.xml" ) ) , Some ( "json" ) ) , Format :: Json ) ;
773+ assert_eq ! ( detect_format( Some ( & PathBuf :: from( "file.json" ) ) , Some ( "xml" ) ) , Format :: Xml ) ;
774+ }
775+
776+ // ── Cross-format: same hierarchy, same output ────────────
777+
778+ #[ test]
779+ fn xml_and_json_produce_same_paths ( ) {
780+ let xml = r#"<config><db><host>localhost</host><port>5432</port></db></config>"# ;
781+ let json = r#"{"config":{"db":{"host":"localhost","port":"5432"}}}"# ;
782+
783+ let xml_entries = flatten_xml ( xml, '.' , 0 ) . unwrap ( ) ;
784+ let json_entries = flatten_json ( json, '.' , 0 ) . unwrap ( ) ;
785+
786+ let xml_pairs: Vec < _ > = xml_entries. iter ( ) . map ( |e| ( & e. path , e. value . as_deref ( ) ) ) . collect ( ) ;
787+ let json_pairs: Vec < _ > = json_entries. iter ( ) . map ( |e| ( & e. path , e. value . as_deref ( ) ) ) . collect ( ) ;
788+
789+ // Both should produce config.db.host = localhost and config.db.port = 5432
790+ assert ! ( xml_pairs. iter( ) . any( |( p, v) | p. as_str( ) == "config.db.host" && * v == Some ( "localhost" ) ) ) ;
791+ assert ! ( json_pairs. iter( ) . any( |( p, v) | p. as_str( ) == "config.db.host" && * v == Some ( "localhost" ) ) ) ;
792+ assert ! ( xml_pairs. iter( ) . any( |( p, v) | p. as_str( ) == "config.db.port" && * v == Some ( "5432" ) ) ) ;
793+ assert ! ( json_pairs. iter( ) . any( |( p, v) | p. as_str( ) == "config.db.port" && * v == Some ( "5432" ) ) ) ;
794+ }
795+ }
0 commit comments