projd-core 0.6.1

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

use projd_core::{ProjectHealth, RiskCode, RiskSeverity, render_markdown, scan_path};
use tempfile::TempDir;

#[test]
fn reports_missing_readme_and_license() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );

    let scan = scan_path(fixture.path()).expect("scan succeeds");

    assert!(scan.has_risk(RiskCode::MissingReadme));
    assert!(scan.has_risk(RiskCode::MissingLicense));
    assert_eq!(
        scan.risk(RiskCode::MissingReadme).map(|risk| risk.severity),
        Some(RiskSeverity::Medium)
    );
    assert_eq!(
        scan.risk(RiskCode::MissingLicense)
            .map(|risk| risk.severity),
        Some(RiskSeverity::Medium)
    );
    assert_eq!(scan.health.grade, ProjectHealth::Risky);
}

#[test]
fn does_not_report_readme_or_license_when_present() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT\n");

    let scan = scan_path(fixture.path()).expect("scan succeeds");

    assert!(!scan.has_risk(RiskCode::MissingReadme));
    assert!(!scan.has_risk(RiskCode::MissingLicense));
}

#[test]
fn reports_unknown_license_when_license_file_is_not_recognized() {
    let fixture = ProjectFixture::new();
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "custom internal license text\n");
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );

    let scan = scan_path(fixture.path()).expect("scan succeeds");

    assert!(!scan.has_risk(RiskCode::MissingLicense));
    assert!(scan.has_risk(RiskCode::UnknownLicense));
    assert_eq!(
        scan.risk(RiskCode::UnknownLicense)
            .map(|risk| risk.severity),
        Some(RiskSeverity::Low)
    );
}

#[test]
fn reports_missing_ci_and_no_tests_detected() {
    let fixture = ProjectFixture::new();
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT\n");

    let scan = scan_path(fixture.path()).expect("scan succeeds");

    assert!(scan.has_risk(RiskCode::MissingCi));
    assert!(scan.has_risk(RiskCode::NoTestsDetected));
    assert_eq!(
        scan.risk(RiskCode::MissingCi).map(|risk| risk.severity),
        Some(RiskSeverity::Low)
    );
    assert_eq!(
        scan.risk(RiskCode::NoTestsDetected)
            .map(|risk| risk.severity),
        Some(RiskSeverity::Medium)
    );
}

#[test]
fn does_not_report_no_tests_when_test_summary_exists() {
    let fixture = ProjectFixture::new();
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT\n");
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );
    fixture.write("tests/integration.rs", "#[test]\nfn integration() {}\n");

    let scan = scan_path(fixture.path()).expect("scan succeeds");

    assert!(!scan.has_risk(RiskCode::NoTestsDetected));
}

#[test]
fn reports_manifest_without_lockfile() {
    let fixture = ProjectFixture::new();
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT\n");
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n[dependencies]\nanyhow = \"1\"\n",
    );

    let scan = scan_path(fixture.path()).expect("scan succeeds");
    let risk = scan
        .risk(RiskCode::ManifestWithoutLockfile)
        .expect("lockfile risk exists");

    assert_eq!(risk.severity, RiskSeverity::Low);
    assert!(
        risk.path
            .as_ref()
            .is_some_and(|path| path.ends_with("Cargo.toml"))
    );
}

#[test]
fn does_not_report_manifest_without_lockfile_when_lockfile_exists() {
    let fixture = ProjectFixture::new();
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT\n");
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n[dependencies]\nanyhow = \"1\"\n",
    );
    fixture.write("Cargo.lock", "# lock\n");

    let scan = scan_path(fixture.path()).expect("scan succeeds");

    assert!(!scan.has_risk(RiskCode::ManifestWithoutLockfile));
}

#[test]
fn reports_large_project_without_ignore_rules() {
    let fixture = ProjectFixture::new();
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT\n");
    fixture.write(".github/workflows/ci.yml", "name: ci\n");
    fixture.write("tests/integration.rs", "#[test]\nfn integration() {}\n");
    for index in 0..1000 {
        fixture.write(&format!("src/file_{index}.rs"), "fn example() {}\n");
    }

    let scan = scan_path(fixture.path()).expect("scan succeeds");

    assert!(scan.has_risk(RiskCode::LargeProjectWithoutIgnoreRules));
    assert_eq!(
        scan.risk(RiskCode::LargeProjectWithoutIgnoreRules)
            .map(|risk| risk.severity),
        Some(RiskSeverity::Info)
    );
    assert!(!scan.hygiene.has_gitignore);
    assert!(!scan.hygiene.has_ignore);
}

#[test]
fn renders_risks_in_markdown_report() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );

    let scan = scan_path(fixture.path()).expect("scan succeeds");
    let report = render_markdown(&scan);

    assert!(report.contains("## Risks"));
    assert!(report.contains("missing-license"));
}

#[test]
fn renders_no_risks_message() {
    let fixture = ProjectFixture::new();
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT License\nPermission is hereby granted\n");
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n[dependencies]\nanyhow = \"1\"\n",
    );
    fixture.write("Cargo.lock", "# lock\n");
    fixture.write(".gitignore", "target/\n");
    fixture.write(".github/workflows/ci.yml", "name: ci\n");
    fixture.write("tests/integration.rs", "#[test]\nfn integration() {}\n");

    let scan = scan_path(fixture.path()).expect("scan succeeds");
    let report = render_markdown(&scan);

    assert_eq!(scan.risks.total, 0);
    assert_eq!(scan.health.grade, ProjectHealth::Healthy);
    assert!(report.contains("No risks detected by current rules."));
}

#[test]
fn renders_health_in_markdown_report() {
    let fixture = ProjectFixture::new();
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT License\nPermission is hereby granted\n");
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );
    fixture.write("Cargo.lock", "# lock\n");
    fixture.write(".github/workflows/ci.yml", "name: ci\n");
    fixture.write("tests/integration.rs", "#[test]\nfn integration() {}\n");

    let scan = scan_path(fixture.path()).expect("scan succeeds");
    let report = render_markdown(&scan);

    assert!(report.contains("## Health"));
    assert!(report.contains("- Grade: `healthy`"));
    assert!(report.contains("## Scan Observations"));
    assert!(report.contains("- Files scanned:"));
}

struct ProjectFixture {
    temp_dir: TempDir,
}

impl ProjectFixture {
    fn new() -> Self {
        Self {
            temp_dir: tempfile::tempdir().expect("create temp dir"),
        }
    }

    fn path(&self) -> &Path {
        self.temp_dir.path()
    }

    fn write(&self, relative: &str, content: &str) {
        let path = self.path().join(relative);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create fixture parent");
        }
        fs::write(path, content).expect("write fixture file");
    }
}