projd-core 0.6.0

Core project scanning data model and analysis helpers for Projd.
Documentation
use std::fs;
use std::path::Path;

use projd_core::{DiscoverOptions, ProjectHealth, RiskCode, scan_path, scan_paths_recursive};
use tempfile::tempdir;

fn write_file(dir: &Path, name: &str, content: &str) {
    let path = dir.join(name);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).unwrap();
    }
    fs::write(path, content).unwrap();
}

fn write_minimal_cargo_project(dir: &Path) {
    write_file(
        dir,
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );
    write_file(dir, "src/lib.rs", "pub fn hello() {}\n");
    write_file(dir, "README.md", "# demo\n");
    write_file(dir, "LICENSE", "MIT\n");
}

#[test]
fn recursive_scan_collects_each_root() {
    let tmp = tempdir().unwrap();
    let root = tmp.path();
    for name in ["alpha", "beta"] {
        write_minimal_cargo_project(&root.join(name));
    }

    let multi =
        scan_paths_recursive(root, &DiscoverOptions::default()).expect("recursive scan succeeds");

    assert_eq!(multi.roots.len(), 2);
    assert_eq!(multi.summary.total, 2);
    assert_eq!(multi.skipped.len(), 0);
    let path_tails: Vec<String> = multi
        .roots
        .iter()
        .map(|scan| {
            scan.root
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or_default()
                .to_owned()
        })
        .collect();
    assert!(path_tails.iter().any(|name| name == "alpha"));
    assert!(path_tails.iter().any(|name| name == "beta"));
}

#[test]
fn recursive_summary_aggregates_grade_and_kind() {
    let tmp = tempdir().unwrap();
    let root = tmp.path();
    write_minimal_cargo_project(&root.join("alpha"));
    write_minimal_cargo_project(&root.join("beta"));

    let multi = scan_paths_recursive(root, &DiscoverOptions::default()).unwrap();
    let cargo_count = multi
        .summary
        .by_kind
        .iter()
        .filter(|(kind, _)| matches!(kind, projd_core::RootKind::CargoPackage))
        .map(|(_, count)| *count)
        .sum::<usize>();
    assert!(cargo_count >= 2);
    assert!(multi.summary.by_grade.values().sum::<usize>() == multi.roots.len());
    assert!(multi.summary.files_scanned >= 2);
}

#[test]
fn recursive_zero_roots_returns_empty() {
    let tmp = tempdir().unwrap();
    let multi = scan_paths_recursive(tmp.path(), &DiscoverOptions::default()).unwrap();
    assert_eq!(multi.roots.len(), 0);
    assert_eq!(multi.summary.total, 0);
    assert_eq!(multi.skipped.len(), 0);
}

#[test]
fn recursive_respects_include_kind_filter() {
    let tmp = tempdir().unwrap();
    let root = tmp.path();
    write_minimal_cargo_project(&root.join("rust_one"));
    let npm = root.join("node_one");
    fs::create_dir_all(&npm).unwrap();
    write_file(&npm, "package.json", "{\"name\":\"node\"}\n");

    let mut filter = std::collections::BTreeSet::new();
    filter.insert(projd_core::RootKind::NpmPackage);
    let opts = DiscoverOptions {
        include_kinds: Some(filter),
        ..DiscoverOptions::default()
    };

    let multi = scan_paths_recursive(root, &opts).unwrap();
    assert_eq!(multi.roots.len(), 1);
    assert!(multi.roots[0].root.ends_with("node_one"));
}

#[test]
fn single_scan_flags_multiple_sibling_vcs_roots() {
    let tmp = tempdir().unwrap();
    let root = tmp.path();
    for name in ["alpha", "beta"] {
        let dir = root.join(name);
        fs::create_dir_all(&dir).unwrap();
        fs::create_dir(dir.join(".git")).unwrap();
        write_file(
            &dir,
            "Cargo.toml",
            "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
        );
    }

    let scan = scan_path(root).expect("scan succeeds");
    let multi_root_finding = scan
        .risks
        .findings
        .iter()
        .find(|finding| finding.code == RiskCode::MultipleVcsRootsFound);
    assert!(
        multi_root_finding.is_some(),
        "expected MultipleVcsRootsFound risk, got: {:#?}",
        scan.risks.findings
    );
}

#[test]
fn single_scan_flags_nested_vcs_root() {
    let tmp = tempdir().unwrap();
    let outer = tmp.path();
    fs::create_dir(outer.join(".git")).unwrap();
    write_file(
        outer,
        "Cargo.toml",
        "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
    );
    let inner = outer.join("third_party/dep");
    fs::create_dir_all(&inner).unwrap();
    fs::create_dir(inner.join(".git")).unwrap();
    write_file(
        &inner,
        "Cargo.toml",
        "[package]\nname=\"d\"\nversion=\"0.1.0\"\n",
    );

    let scan = scan_path(outer).expect("scan succeeds");
    let nested_finding = scan
        .risks
        .findings
        .iter()
        .find(|finding| finding.code == RiskCode::NestedVcsRoot);
    assert!(
        nested_finding.is_some(),
        "expected NestedVcsRoot risk, got: {:#?}",
        scan.risks.findings
    );
}

#[test]
fn single_project_does_not_trigger_multi_root_risk() {
    let tmp = tempdir().unwrap();
    write_minimal_cargo_project(tmp.path());
    let scan = scan_path(tmp.path()).expect("scan succeeds");
    assert!(
        !scan.has_risk(RiskCode::MultipleVcsRootsFound),
        "single project should not trigger MultipleVcsRootsFound"
    );
}

#[test]
fn recursive_scan_health_grade_matches_individual_scans() {
    let tmp = tempdir().unwrap();
    let root = tmp.path();
    write_minimal_cargo_project(&root.join("alpha"));

    let multi = scan_paths_recursive(root, &DiscoverOptions::default()).unwrap();
    assert_eq!(multi.roots.len(), 1);
    let single = scan_path(&multi.roots[0].root).unwrap();
    assert_eq!(multi.roots[0].health.grade, single.health.grade);
    // sanity: grade should be one of the known values
    assert!(matches!(
        multi.roots[0].health.grade,
        ProjectHealth::Healthy | ProjectHealth::NeedsAttention | ProjectHealth::Risky
    ));
}