debtmap 0.22.0

Code complexity and technical debt analyzer
Documentation
use std::fs;
use std::path::Path;
use std::process::{Command, Output};

use serde_json::Value;

fn debtmap_command(current_dir: &Path) -> Command {
    let mut command = Command::new(env!("CARGO_BIN_EXE_debtmap"));
    command
        .current_dir(current_dir)
        .env("HOME", current_dir.join(".test-home"))
        .env("XDG_CONFIG_HOME", current_dir.join(".test-config"))
        .env_remove("DEBTMAP_CONFIG");
    command
}

fn output_text(output: &Output) -> String {
    format!(
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    )
}

fn run_json_analysis(directory: &Path, config: Option<&Path>, output_name: &str) -> Output {
    let mut command = debtmap_command(directory);
    if let Some(path) = config {
        command.arg("--config").arg(path);
    }
    command.args([
        "analyze",
        ".",
        "--format",
        "json",
        "--quiet",
        "--no-tui",
        "--no-context-aware",
        "--no-parallel",
        "--min-score",
        "0",
        "--output",
        output_name,
    ]);
    command.output().unwrap()
}

fn report_total_loc(directory: &Path, output_name: &str) -> u64 {
    let report: Value =
        serde_json::from_str(&fs::read_to_string(directory.join(output_name)).unwrap()).unwrap();
    report["summary"]["total_loc"].as_u64().unwrap()
}

#[test]
fn init_template_passes_strict_config_check() {
    let directory = tempfile::tempdir().unwrap();
    let init = debtmap_command(directory.path())
        .arg("init")
        .output()
        .unwrap();
    assert!(init.status.success(), "{}", output_text(&init));

    let check = debtmap_command(directory.path())
        .args(["config", "check"])
        .output()
        .unwrap();
    assert!(check.status.success(), "{}", output_text(&check));
    assert!(
        String::from_utf8_lossy(&check.stdout).contains("Configuration is valid:"),
        "{}",
        output_text(&check)
    );

    let generated = fs::read_to_string(directory.path().join(".debtmap.toml")).unwrap();
    let parsed = debtmap::config::parse_and_validate_config(&generated).unwrap();
    let validation = parsed.thresholds.unwrap().validation.unwrap();
    assert_eq!(validation.max_debt_density, 50.0);
    assert_eq!(validation.max_codebase_risk_score, 7.0);
}

#[test]
fn config_check_reports_all_unknown_keys_with_suggestions() {
    let directory = tempfile::tempdir().unwrap();
    let path = directory.path().join("custom.toml");
    fs::write(
        &path,
        "threshold = 10\n[thresholds]\nmax_function_lenght = 40\n",
    )
    .unwrap();

    let output = debtmap_command(directory.path())
        .arg("--config")
        .arg(&path)
        .args(["config", "check"])
        .output()
        .unwrap();
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(!output.status.success(), "{}", output_text(&output));
    assert!(stderr.contains("unknown configuration key `threshold`"));
    assert!(stderr.contains("did you mean `thresholds`?"));
    assert!(stderr.contains("`thresholds.max_function_lenght`"));
    assert!(stderr.contains("`thresholds.max_function_length`"));
}

#[test]
fn config_check_requires_an_explicit_or_discovered_file() {
    let directory = tempfile::tempdir().unwrap();
    let output = debtmap_command(directory.path())
        .args(["config", "check"])
        .output()
        .unwrap();

    assert!(!output.status.success(), "{}", output_text(&output));
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("debtmap init"),
        "{}",
        output_text(&output)
    );
}

#[test]
fn legacy_parser_remains_permissive_for_unknown_keys() {
    assert!(debtmap::config::parse_and_validate_config("threshold = 10").is_ok());
}

#[test]
fn shipped_configs_pass_the_executable_contract() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR"));
    let paths = [
        ".debtmap.toml",
        ".debtmap.example.toml",
        ".debtmap-improved.toml",
        "examples/config-permissive.toml",
        "examples/config-strict.toml",
        "examples/library_config.toml",
        "examples/orchestration-config.toml",
    ];
    let failures: Vec<_> = paths
        .iter()
        .filter_map(|relative| {
            let output = debtmap_command(root)
                .arg("--config")
                .arg(root.join(relative))
                .args(["config", "check"])
                .output()
                .unwrap();
            (!output.status.success()).then(|| format!("{relative}: {}", output_text(&output)))
        })
        .collect();

    assert!(failures.is_empty(), "{}", failures.join("\n"));
}

#[test]
fn explicit_config_controls_ordinary_analysis() {
    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("sample.rs"), "fn sample() {}\n").unwrap();
    let config = directory.path().join("custom.toml");
    fs::write(&config, "[ignore]\npatterns = [\"*.rs\"]\n").unwrap();

    let baseline = run_json_analysis(directory.path(), None, "baseline.json");
    assert!(baseline.status.success(), "{}", output_text(&baseline));
    assert_eq!(report_total_loc(directory.path(), "baseline.json"), 1);

    let configured = run_json_analysis(directory.path(), Some(&config), "configured.json");
    assert!(configured.status.success(), "{}", output_text(&configured));
    assert_eq!(report_total_loc(directory.path(), "configured.json"), 0);
}

#[test]
fn environment_config_controls_ordinary_analysis() {
    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("sample.rs"), "fn sample() {}\n").unwrap();
    let config = directory.path().join("custom.toml");
    fs::write(&config, "[ignore]\npatterns = [\"*.rs\"]\n").unwrap();

    let output = debtmap_command(directory.path())
        .env("DEBTMAP_CONFIG", &config)
        .args([
            "analyze",
            ".",
            "--format",
            "json",
            "--quiet",
            "--no-tui",
            "--no-context-aware",
            "--no-parallel",
            "--min-score",
            "0",
            "--output",
            "environment.json",
        ])
        .output()
        .unwrap();

    assert!(output.status.success(), "{}", output_text(&output));
    assert_eq!(report_total_loc(directory.path(), "environment.json"), 0);
}

#[test]
fn explicit_config_inherits_reported_lower_precedence_sources() {
    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("sample.rs"), "fn sample() {}\n").unwrap();
    fs::write(
        directory.path().join(".debtmap.toml"),
        "[ignore]\npatterns = [\"*.rs\"]\n",
    )
    .unwrap();
    let config = directory.path().join("custom.toml");
    fs::write(&config, "[display]\nitems_per_tier = 7\n").unwrap();

    let output = run_json_analysis(directory.path(), Some(&config), "layered.json");

    assert!(output.status.success(), "{}", output_text(&output));
    assert_eq!(report_total_loc(directory.path(), "layered.json"), 0);
}

#[test]
fn explicit_config_is_used_when_showing_sources() {
    let directory = tempfile::tempdir().unwrap();
    fs::write(
        directory.path().join(".debtmap.toml"),
        "[thresholds]\ncomplexity = 11\n",
    )
    .unwrap();
    let config = directory.path().join("custom.toml");
    fs::write(&config, "[display]\nitems_per_tier = 7\n").unwrap();

    let output = debtmap_command(directory.path())
        .arg("--config")
        .arg(&config)
        .args(["--show-config-sources", "analyze", "."])
        .env("DEBTMAP_COMPLEXITY_THRESHOLD", "33")
        .output()
        .unwrap();

    assert!(output.status.success(), "{}", output_text(&output));
    assert!(
        String::from_utf8_lossy(&output.stdout)
            .contains(&format!("custom config: {}", config.display())),
        "{}",
        output_text(&output)
    );

    let first = String::from_utf8_lossy(&output.stdout);
    let field_names: Vec<_> = first
        .lines()
        .filter(|line| line.starts_with("  ") && line.ends_with(" = <value>"))
        .map(|line| line.trim().trim_end_matches(" = <value>"))
        .collect();
    let mut sorted_names = field_names.clone();
    sorted_names.sort_unstable();
    assert_eq!(field_names, sorted_names);

    let default_index = first.find("  1. built-in defaults").unwrap();
    let project_index = first.find("  2. project config:").unwrap();
    let custom_index = first.find("  3. custom config:").unwrap();
    let environment_index = first.find("  4. environment variable:").unwrap();
    assert!(default_index < project_index);
    assert!(project_index < custom_index);
    assert!(custom_index < environment_index);
}

#[test]
fn invalid_explicit_config_stops_analysis() {
    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("sample.rs"), "fn sample() {}\n").unwrap();
    let config = directory.path().join("invalid.toml");
    fs::write(&config, "[thresholds\ncomplexity = 17\n").unwrap();

    let output = run_json_analysis(directory.path(), Some(&config), "report.json");

    assert!(!output.status.success(), "{}", output_text(&output));
    assert!(!directory.path().join("report.json").exists());
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("Failed to parse .debtmap.toml"),
        "{}",
        output_text(&output)
    );
}

#[test]
fn invalid_explicit_scoring_stops_analysis() {
    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("sample.rs"), "fn sample() {}\n").unwrap();
    let config = directory.path().join("invalid-scoring.toml");
    fs::write(
        &config,
        "[scoring]\ncoverage = 1.0\ncomplexity = 1.0\ndependency = 1.0\n",
    )
    .unwrap();

    let output = run_json_analysis(directory.path(), Some(&config), "scoring.json");

    assert!(!output.status.success(), "{}", output_text(&output));
    assert!(!directory.path().join("scoring.json").exists());
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("must sum to 1.0"),
        "{}",
        output_text(&output)
    );
}

#[test]
fn malformed_discovered_config_stops_analysis() {
    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("sample.rs"), "fn sample() {}\n").unwrap();
    fs::write(directory.path().join(".debtmap.toml"), "[thresholds\n").unwrap();

    let output = run_json_analysis(directory.path(), None, "discovered.json");

    assert!(!output.status.success(), "{}", output_text(&output));
    assert!(!directory.path().join("discovered.json").exists());
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("Failed to parse .debtmap.toml"),
        "{}",
        output_text(&output)
    );
}

#[test]
fn user_config_is_applied_to_ordinary_analysis() {
    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("sample.rs"), "fn sample() {}\n").unwrap();
    let user_directories = [
        directory.path().join(".test-config/debtmap"),
        directory.path().join(".test-home/.config/debtmap"),
        directory
            .path()
            .join(".test-home/Library/Application Support/debtmap"),
    ];
    for user_directory in user_directories {
        fs::create_dir_all(&user_directory).unwrap();
        fs::write(
            user_directory.join("config.toml"),
            "[ignore]\npatterns = [\"*.rs\"]\n",
        )
        .unwrap();
    }

    let output = run_json_analysis(directory.path(), None, "user.json");

    assert!(output.status.success(), "{}", output_text(&output));
    assert_eq!(report_total_loc(directory.path(), "user.json"), 0);
}

#[cfg(target_os = "linux")]
#[test]
fn explicit_non_utf8_config_path_is_honored() {
    use std::ffi::OsString;
    use std::os::unix::ffi::OsStringExt;

    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("sample.rs"), "fn sample() {}\n").unwrap();
    let config = directory
        .path()
        .join(OsString::from_vec(b"config-\xff.toml".to_vec()));
    fs::write(&config, "[ignore]\npatterns = [\"*.rs\"]\n").unwrap();

    let output = run_json_analysis(directory.path(), Some(&config), "non-utf8.json");

    assert!(output.status.success(), "{}", output_text(&output));
    assert_eq!(report_total_loc(directory.path(), "non-utf8.json"), 0);
}

#[cfg(all(unix, not(target_os = "linux")))]
#[test]
fn unsupported_non_utf8_config_path_fails_closed() {
    use std::ffi::OsString;
    use std::os::unix::ffi::OsStringExt;

    let directory = tempfile::tempdir().unwrap();
    fs::write(directory.path().join("sample.rs"), "fn sample() {}\n").unwrap();
    let config = directory
        .path()
        .join(OsString::from_vec(b"config-\xff.toml".to_vec()));

    let output = run_json_analysis(directory.path(), Some(&config), "non-utf8.json");

    assert!(!output.status.success(), "{}", output_text(&output));
    assert!(!directory.path().join("non-utf8.json").exists());
}

#[test]
fn validate_subcommand_config_is_not_ignored() {
    let directory = tempfile::tempdir().unwrap();
    let missing = directory.path().join("missing.toml");

    let output = debtmap_command(directory.path())
        .args(["validate", ".", "--config"])
        .arg(&missing)
        .output()
        .unwrap();

    assert!(!output.status.success(), "{}", output_text(&output));
    assert!(
        String::from_utf8_lossy(&output.stderr).contains("Cannot read config file"),
        "{}",
        output_text(&output)
    );
}