//! Disassemble XML file handler.

use crate::xml::builders::build_disassembled_files_unified;
use crate::xml::multi_level::{
    capture_xmlns_from_root, path_segment_from_file_pattern, save_multi_level_config,
    strip_root_and_build_xml,
};
use crate::xml::parsers::parse_xml;
use crate::xml::types::{BuildDisassembledFilesOptions, DecomposeRule, MultiLevelRule};
use crate::xml::utils::normalize_path_unix;
use ignore::gitignore::GitignoreBuilder;
use std::path::Path;
use tokio::fs;

pub struct DisassembleXmlFileHandler {
    ign: Option<ignore::gitignore::Gitignore>,
}

impl DisassembleXmlFileHandler {
    pub fn new() -> Self {
        Self { ign: None }
    }

    async fn load_ignore_rules(&mut self, ignore_path: &str) {
        let path = Path::new(ignore_path);
        let content = match fs::read_to_string(path).await {
            Ok(c) => c,
            Err(_) => return,
        };
        let root = path.parent().unwrap_or(Path::new("."));
        let mut builder = GitignoreBuilder::new(root);
        for line in content.lines() {
            let _ = builder.add_line(None, line);
        }
        // `GitignoreBuilder::build` only fails on unlikely I/O errors; treat as absent rules.
        self.ign = builder.build().ok();
    }

    fn posix_path(path: &str) -> String {
        path.replace('\\', "/")
    }

    fn is_xml_file(file_path: &str) -> bool {
        file_path.to_lowercase().ends_with(".xml")
    }

    /// True when a directory entry is both a regular file and an `.xml`.
    /// Pure helper extracted from `handle_directory` so the
    /// `is_file && is_xml_file` predicate can be exercised without a
    /// real filesystem entry.
    fn is_processable_xml_entry(is_file: bool, file_name: &str) -> bool {
        is_file && Self::is_xml_file(file_name)
    }

    /// True when the unified-build output directory should be purged
    /// before re-disassembling. Both the flag *and* the existence check
    /// must hold; `pre_purge=true` against a missing directory is a
    /// no-op rather than an error.
    fn should_pre_purge_output(pre_purge: bool, output_exists: bool) -> bool {
        pre_purge && output_exists
    }

    /// True when a file inside the disassembly tree should be
    /// considered by a multi-level rule: it must be `.xml` and either
    /// its bare name or its full path must contain the rule's pattern.
    fn file_matches_multi_level_rule(file_name: &str, full_path: &str, file_pattern: &str) -> bool {
        file_name.ends_with(".xml")
            && (file_name.contains(file_pattern) || full_path.contains(file_pattern))
    }

    /// True when the parsed XML document has the multi-level rule's
    /// `root_to_strip` either as its root element or as a direct child
    /// of its root element.
    fn has_element_to_strip(parsed: &serde_json::Value, root_to_strip: &str) -> bool {
        parsed
            .as_object()
            .and_then(|o| {
                let root_key = o.keys().find(|k| *k != "?xml")?;
                let root_val = o.get(root_key)?.as_object()?;
                Some(root_key == root_to_strip || root_val.contains_key(root_to_strip))
            })
            .unwrap_or(false)
    }

    /// Two multi-level rules share an "identity" — i.e. should be
    /// deduplicated in `.multi_level.json` — when both their
    /// `file_pattern` and their `root_to_strip` match. The other
    /// fields (`unique_id_elements`, `path_segment`, …) are derived
    /// per-file and may legitimately drift.
    fn rules_have_same_identity(a: &MultiLevelRule, b: &MultiLevelRule) -> bool {
        a.file_pattern == b.file_pattern && a.root_to_strip == b.root_to_strip
    }

    /// First non-`?xml` key of the parsed document, used as the
    /// `wrap_root_element` for a multi-level rule. Falls back to
    /// `fallback` when the parsed value is not an object or contains
    /// only the declaration.
    fn root_element_name_from_parsed(parsed: &serde_json::Value, fallback: &str) -> String {
        parsed
            .as_object()
            .and_then(|o| o.keys().find(|k| *k != "?xml").cloned())
            .unwrap_or_else(|| fallback.to_string())
    }

    fn is_ignored(&self, path: &str) -> bool {
        self.ign
            .as_ref()
            .map(|ign| ign.matched(path, false).is_ignore())
            .unwrap_or(false)
    }

    /// Derive the disassembled-output directory name from a file stem.
    ///
    /// We strip only the trailing extension-like segment (everything after the **last** `.`),
    /// so `HR_Admin.permissionset-meta` collapses to `HR_Admin` while
    /// `Account.MyApprovalProcess.approvalProcess-meta` collapses to `Account.MyApprovalProcess`.
    /// Splitting at the *first* dot — the previous behaviour — was lossy for metadata types
    /// whose fullName itself contains a dot (e.g. Salesforce approval processes, quick actions,
    /// custom-metadata records) because two files like `A.X.foo-meta.xml` and `A.Y.foo-meta.xml`
    /// both resolved to `A/`, silently merging unrelated components.
    fn output_dir_basename(file_stem: &str) -> &str {
        file_stem
            .rsplit_once('.')
            .map(|(prefix, _)| prefix)
            .unwrap_or(file_stem)
    }

    #[allow(clippy::too_many_arguments)]
    pub async fn disassemble(
        &mut self,
        file_path: &str,
        unique_id_elements: Option<&str>,
        strategy: Option<&str>,
        pre_purge: bool,
        post_purge: bool,
        ignore_path: &str,
        format: &str,
        multi_level_rules: Option<&[MultiLevelRule]>,
        decompose_rules: Option<&[DecomposeRule]>,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let strategy = strategy.unwrap_or("unique-id");
        let strategy = if ["unique-id", "grouped-by-tag"].contains(&strategy) {
            strategy
        } else {
            log::warn!(
                "Unsupported strategy \"{}\", defaulting to \"unique-id\".",
                strategy
            );
            "unique-id"
        };

        self.load_ignore_rules(ignore_path).await;

        let path = Path::new(file_path);
        let meta = fs::metadata(path).await?;
        let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
        let relative_path = path.strip_prefix(&cwd).unwrap_or(path).to_string_lossy();
        let relative_path = Self::posix_path(&relative_path);

        // Treat an empty rules slice as "no multi-level".
        let multi_level_rules = multi_level_rules.filter(|rules| !rules.is_empty());

        if meta.is_file() {
            self.handle_file(
                file_path,
                &relative_path,
                unique_id_elements,
                strategy,
                pre_purge,
                post_purge,
                format,
                multi_level_rules,
                decompose_rules,
            )
            .await?;
        } else {
            // Anything that isn't a regular file is treated as a directory; fs::metadata on
            // the caller already errored out if the path didn't exist.
            self.handle_directory(
                file_path,
                unique_id_elements,
                strategy,
                pre_purge,
                post_purge,
                format,
                multi_level_rules,
                decompose_rules,
            )
            .await?;
        }

        Ok(())
    }

    #[allow(clippy::too_many_arguments)]
    async fn handle_file(
        &self,
        file_path: &str,
        relative_path: &str,
        unique_id_elements: Option<&str>,
        strategy: &str,
        pre_purge: bool,
        post_purge: bool,
        format: &str,
        multi_level_rules: Option<&[MultiLevelRule]>,
        decompose_rules: Option<&[DecomposeRule]>,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let resolved = Path::new(file_path)
            .canonicalize()
            .unwrap_or_else(|_| Path::new(file_path).to_path_buf());
        let resolved_str = normalize_path_unix(&resolved.to_string_lossy());

        if !Self::is_xml_file(&resolved_str) {
            log::error!(
                "The file path provided is not an XML file: {}",
                resolved_str
            );
            return Ok(());
        }

        if self.is_ignored(relative_path) {
            log::warn!("File ignored by ignore rules: {}", resolved_str);
            return Ok(());
        }

        let dir_path = resolved.parent().unwrap_or(Path::new("."));
        let dir_path_str = normalize_path_unix(&dir_path.to_string_lossy());
        self.process_file(
            &dir_path_str,
            strategy,
            &resolved_str,
            unique_id_elements,
            pre_purge,
            post_purge,
            format,
            multi_level_rules,
            decompose_rules,
        )
        .await
    }

    #[allow(clippy::too_many_arguments)]
    async fn handle_directory(
        &self,
        dir_path: &str,
        unique_id_elements: Option<&str>,
        strategy: &str,
        pre_purge: bool,
        post_purge: bool,
        format: &str,
        multi_level_rules: Option<&[MultiLevelRule]>,
        decompose_rules: Option<&[DecomposeRule]>,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let dir_path = normalize_path_unix(dir_path);
        let mut entries = fs::read_dir(&dir_path).await?;
        let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());

        while let Some(entry) = entries.next_entry().await? {
            let sub_path = entry.path();
            let sub_file_path = sub_path.to_string_lossy();
            let relative_sub = sub_path
                .strip_prefix(&cwd)
                .unwrap_or(&sub_path)
                .to_string_lossy();
            let relative_sub = Self::posix_path(&relative_sub);

            if !Self::is_processable_xml_entry(sub_path.is_file(), &sub_file_path) {
                continue;
            }
            if self.is_ignored(&relative_sub) {
                log::warn!("File ignored by ignore rules: {}", sub_file_path);
                continue;
            }
            let sub_file_path_norm = normalize_path_unix(&sub_file_path);
            self.process_file(
                &dir_path,
                strategy,
                &sub_file_path_norm,
                unique_id_elements,
                pre_purge,
                post_purge,
                format,
                multi_level_rules,
                decompose_rules,
            )
            .await?;
        }
        Ok(())
    }

    #[allow(clippy::too_many_arguments)]
    async fn process_file(
        &self,
        dir_path: &str,
        strategy: &str,
        file_path: &str,
        unique_id_elements: Option<&str>,
        pre_purge: bool,
        post_purge: bool,
        format: &str,
        multi_level_rules: Option<&[MultiLevelRule]>,
        decompose_rules: Option<&[DecomposeRule]>,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        log::debug!("Parsing file to disassemble: {}", file_path);

        let file_name = Path::new(file_path)
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("output");
        let base_name = Self::output_dir_basename(file_name);
        let output_path = Path::new(dir_path).join(base_name);

        if Self::should_pre_purge_output(pre_purge, output_path.exists()) {
            fs::remove_dir_all(&output_path).await.ok();
        }

        build_disassembled_files_unified(BuildDisassembledFilesOptions {
            file_path,
            disassembled_path: output_path.to_str().unwrap_or("."),
            base_name: file_name,
            post_purge,
            format,
            unique_id_elements,
            strategy,
            decompose_rules,
        })
        .await?;

        // Apply each multi-level rule in order. Each rule walks the same disassembly tree
        // independently; rules are merged into the shared `.multi_level.json` so reassembly
        // can replay them in order.
        if let Some(rules) = multi_level_rules {
            for rule in rules {
                self.recursively_disassemble_multi_level(&output_path, rule, format)
                    .await?;
            }
        }

        Ok(())
    }

    /// Recursively walk the disassembly output; for XML files matching the rule's file_pattern,
    /// strip the root and re-disassemble with the rule's unique_id_elements.
    async fn recursively_disassemble_multi_level(
        &self,
        dir_path: &Path,
        rule: &MultiLevelRule,
        format: &str,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        let mut config = crate::xml::multi_level::load_multi_level_config(dir_path)
            .await
            .unwrap_or_default();

        let mut stack = vec![dir_path.to_path_buf()];
        while let Some(current) = stack.pop() {
            let mut entries = Vec::new();
            let mut read_dir = fs::read_dir(&current).await?;
            while let Some(entry) = read_dir.next_entry().await? {
                entries.push(entry);
            }

            for entry in entries {
                let path = entry.path();
                let path_str = path.to_string_lossy().to_string();

                if path.is_dir() {
                    stack.push(path);
                    continue;
                }
                // Anything not a directory is processed as a regular file below.
                {
                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
                    let path_str_check = path.to_string_lossy();
                    if !Self::file_matches_multi_level_rule(
                        name,
                        &path_str_check,
                        &rule.file_pattern,
                    ) {
                        continue;
                    }

                    let parsed = match parse_xml(&path_str).await {
                        Some(p) => p,
                        None => continue,
                    };
                    if !Self::has_element_to_strip(&parsed, &rule.root_to_strip) {
                        continue;
                    }

                    let wrap_xmlns = capture_xmlns_from_root(&parsed).unwrap_or_default();

                    let stripped_xml = match strip_root_and_build_xml(&parsed, &rule.root_to_strip)
                    {
                        Some(xml) => xml,
                        None => continue,
                    };

                    fs::write(&path, stripped_xml).await?;

                    let file_stem = path
                        .file_stem()
                        .and_then(|s| s.to_str())
                        .unwrap_or("output");
                    let output_dir_name = Self::output_dir_basename(file_stem);
                    let parent = path.parent().unwrap_or(dir_path);
                    let second_level_output = parent.join(output_dir_name);

                    build_disassembled_files_unified(BuildDisassembledFilesOptions {
                        file_path: &path_str,
                        disassembled_path: second_level_output.to_str().unwrap_or("."),
                        base_name: output_dir_name,
                        post_purge: true,
                        format,
                        unique_id_elements: Some(&rule.unique_id_elements),
                        strategy: "unique-id",
                        decompose_rules: None,
                    })
                    .await?;

                    // Find an existing entry for this rule by (file_pattern, root_to_strip).
                    // Multiple rules may co-exist in `.multi_level.json` (one per logical
                    // segment); per-rule deduplication keeps each one a singleton.
                    let existing_idx = config
                        .rules
                        .iter()
                        .position(|r| Self::rules_have_same_identity(r, rule));
                    match existing_idx {
                        None => {
                            let wrap_root = Self::root_element_name_from_parsed(
                                &parsed,
                                &rule.wrap_root_element,
                            );
                            let path_segment = if rule.path_segment.is_empty() {
                                path_segment_from_file_pattern(&rule.file_pattern)
                            } else {
                                rule.path_segment.clone()
                            };
                            let stored_xmlns = if rule.wrap_xmlns.is_empty() {
                                wrap_xmlns
                            } else {
                                rule.wrap_xmlns.clone()
                            };
                            config.rules.push(MultiLevelRule {
                                file_pattern: rule.file_pattern.clone(),
                                root_to_strip: rule.root_to_strip.clone(),
                                unique_id_elements: rule.unique_id_elements.clone(),
                                path_segment,
                                // Persist document root (e.g. LoyaltyProgramSetup) so reassembly uses it
                                // as root with xmlns; path_segment is the inner wrapper in each file.
                                wrap_root_element: wrap_root,
                                wrap_xmlns: stored_xmlns,
                            });
                        }
                        Some(idx) => {
                            // Backfill xmlns from the source if we didn't have one yet; otherwise
                            // leave the existing entry alone (the first observed file wins).
                            if config.rules[idx].wrap_xmlns.is_empty() {
                                config.rules[idx].wrap_xmlns = wrap_xmlns;
                            }
                        }
                    }
                }
            }
        }

        if !config.rules.is_empty() {
            save_multi_level_config(dir_path, &config).await?;
        }

        Ok(())
    }
}

impl Default for DisassembleXmlFileHandler {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[allow(clippy::default_constructed_unit_structs)]
    fn disassemble_handler_default_equals_new() {
        let _ = DisassembleXmlFileHandler::default();
    }

    #[test]
    fn is_xml_file_matches_case_insensitively() {
        assert!(DisassembleXmlFileHandler::is_xml_file("foo.xml"));
        assert!(DisassembleXmlFileHandler::is_xml_file("BAR.XML"));
        assert!(!DisassembleXmlFileHandler::is_xml_file("foo.txt"));
    }

    #[test]
    fn posix_path_converts_backslashes() {
        assert_eq!(
            DisassembleXmlFileHandler::posix_path(r"C:\Users\name\file.xml"),
            "C:/Users/name/file.xml"
        );
    }

    #[tokio::test]
    async fn load_ignore_rules_noop_when_path_missing() {
        let mut handler = DisassembleXmlFileHandler::new();
        handler
            .load_ignore_rules("/definitely/does/not/exist/.ignore")
            .await;
        assert!(handler.ign.is_none());
    }

    #[tokio::test]
    async fn load_ignore_rules_builds_matcher() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join(".ignore");
        tokio::fs::write(&path, "*.xml\n").await.unwrap();
        let mut handler = DisassembleXmlFileHandler::new();
        handler.load_ignore_rules(path.to_str().unwrap()).await;
        assert!(handler.ign.is_some());
        assert!(handler.is_ignored("file.xml"));
        assert!(!handler.is_ignored("file.txt"));
    }

    #[test]
    fn is_ignored_default_false_without_rules() {
        let handler = DisassembleXmlFileHandler::new();
        assert!(!handler.is_ignored("some/path.xml"));
    }

    #[test]
    fn output_dir_basename_strips_only_last_dot_segment() {
        // Plain Salesforce-style metadata: strip the `.<suffix>-meta` tail.
        assert_eq!(
            DisassembleXmlFileHandler::output_dir_basename("HR_Admin.permissionset-meta"),
            "HR_Admin"
        );
        assert_eq!(
            DisassembleXmlFileHandler::output_dir_basename("Get_Info.flow-meta"),
            "Get_Info"
        );
    }

    #[test]
    fn output_dir_basename_preserves_dotted_full_names() {
        // Approval processes are named `<sobject>.<process>` which yields a stem containing
        // *two* dots. The old `split('.').next()` returned just `<sobject>`, causing
        // distinct processes for the same sobject to land in the same output directory and
        // silently merge during reassembly. The new behaviour keeps the dotted fullName.
        assert_eq!(
            DisassembleXmlFileHandler::output_dir_basename(
                "Account_Merge__c.New_Account_Merges_2.approvalProcess-meta"
            ),
            "Account_Merge__c.New_Account_Merges_2"
        );
        assert_eq!(
            DisassembleXmlFileHandler::output_dir_basename(
                "Account_Merge__c.New_Account_Merges_3.approvalProcess-meta"
            ),
            "Account_Merge__c.New_Account_Merges_3"
        );
        // Quick actions follow the same `<sobject>.<action>` pattern.
        assert_eq!(
            DisassembleXmlFileHandler::output_dir_basename("Case.LogACall.quickAction-meta"),
            "Case.LogACall"
        );
    }

    #[test]
    fn is_processable_xml_entry_true_only_for_regular_xml_files() {
        // Pin both the `is_file && is_xml_file` conjunction and the
        // outer `!` at the call site. All four quadrants of
        // (is_file, is_xml) are covered.
        assert!(DisassembleXmlFileHandler::is_processable_xml_entry(
            true, "foo.xml"
        ));
        assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
            false, "foo.xml"
        ));
        assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
            true, "foo.txt"
        ));
        assert!(!DisassembleXmlFileHandler::is_processable_xml_entry(
            false, "foo.txt"
        ));
    }

    #[test]
    fn should_pre_purge_output_requires_both_flag_and_existing_dir() {
        // `pre_purge=true` alone must not delete a missing directory
        // (that's a benign no-op, not an error); an existing directory
        // alone must not be deleted unless the caller asked for purge.
        assert!(DisassembleXmlFileHandler::should_pre_purge_output(
            true, true
        ));
        assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
            true, false
        ));
        assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
            false, true
        ));
        assert!(!DisassembleXmlFileHandler::should_pre_purge_output(
            false, false
        ));
    }

    #[test]
    fn file_matches_multi_level_rule_requires_xml_extension() {
        // Non-`.xml` files are skipped regardless of pattern membership.
        assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
            "Foo.txt",
            "/dir/Foo.txt",
            "Foo"
        ));
    }

    #[test]
    fn file_matches_multi_level_rule_when_filename_contains_pattern() {
        assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
            "MyPattern.xml",
            "/dir/MyPattern.xml",
            "MyPattern"
        ));
    }

    #[test]
    fn file_matches_multi_level_rule_when_only_full_path_contains_pattern() {
        // The pattern may live in a parent directory name even if the
        // bare file name is something generic like `meta.xml`.
        assert!(DisassembleXmlFileHandler::file_matches_multi_level_rule(
            "child.xml",
            "/parentPattern/child.xml",
            "parentPattern"
        ));
    }

    #[test]
    fn file_matches_multi_level_rule_false_when_pattern_absent_everywhere() {
        assert!(!DisassembleXmlFileHandler::file_matches_multi_level_rule(
            "Foo.xml",
            "/dir/Foo.xml",
            "MissingPattern"
        ));
    }

    #[test]
    fn has_element_to_strip_when_root_key_matches() {
        let parsed = serde_json::json!({"Foo": {"a": "b"}});
        assert!(DisassembleXmlFileHandler::has_element_to_strip(
            &parsed, "Foo"
        ));
    }

    #[test]
    fn has_element_to_strip_when_root_contains_target_child() {
        let parsed = serde_json::json!({"Foo": {"Bar": {"a": "b"}}});
        assert!(DisassembleXmlFileHandler::has_element_to_strip(
            &parsed, "Bar"
        ));
    }

    #[test]
    fn has_element_to_strip_false_when_target_absent() {
        let parsed = serde_json::json!({"Foo": {"a": "b"}});
        assert!(!DisassembleXmlFileHandler::has_element_to_strip(
            &parsed, "Missing"
        ));
    }

    #[test]
    fn has_element_to_strip_false_for_non_object_or_decl_only() {
        assert!(!DisassembleXmlFileHandler::has_element_to_strip(
            &serde_json::json!("primitive"),
            "Foo"
        ));
        assert!(!DisassembleXmlFileHandler::has_element_to_strip(
            &serde_json::json!({"?xml": {}}),
            "Foo"
        ));
    }

    fn rule(pattern: &str, root: &str) -> MultiLevelRule {
        MultiLevelRule {
            file_pattern: pattern.to_string(),
            root_to_strip: root.to_string(),
            unique_id_elements: String::new(),
            path_segment: String::new(),
            wrap_root_element: String::new(),
            wrap_xmlns: String::new(),
        }
    }

    #[test]
    fn rules_share_identity_when_pattern_and_root_match() {
        assert!(DisassembleXmlFileHandler::rules_have_same_identity(
            &rule("p", "R"),
            &rule("p", "R"),
        ));
    }

    #[test]
    fn rules_differ_when_file_pattern_differs() {
        assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
            &rule("p1", "R"),
            &rule("p2", "R"),
        ));
    }

    #[test]
    fn rules_differ_when_root_to_strip_differs() {
        assert!(!DisassembleXmlFileHandler::rules_have_same_identity(
            &rule("p", "R1"),
            &rule("p", "R2"),
        ));
    }

    #[test]
    fn root_element_name_finds_first_non_declaration_key() {
        let parsed = serde_json::json!({"?xml": {}, "MyRoot": {"a": "b"}});
        assert_eq!(
            DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "fallback"),
            "MyRoot"
        );
    }

    #[test]
    fn root_element_name_falls_back_when_only_declaration_present() {
        let parsed = serde_json::json!({"?xml": {}});
        assert_eq!(
            DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "FallbackRoot"),
            "FallbackRoot"
        );
    }

    #[test]
    fn root_element_name_falls_back_for_non_object() {
        let parsed = serde_json::json!("primitive");
        assert_eq!(
            DisassembleXmlFileHandler::root_element_name_from_parsed(&parsed, "Fb"),
            "Fb"
        );
    }

    #[test]
    fn output_dir_basename_no_dot_returns_stem_unchanged() {
        // Stems without any dot are passed through verbatim (no extension to strip).
        assert_eq!(DisassembleXmlFileHandler::output_dir_basename("Foo"), "Foo");
        assert_eq!(DisassembleXmlFileHandler::output_dir_basename(""), "");
    }
}
