projd-core 0.6.0

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

use projd_core::{
    HealthSignalKind, HealthSignalStatus, ScanOptions, VcsKind, scan_path, scan_path_with,
};
use tempfile::tempdir;

fn git_available() -> bool {
    Command::new("git")
        .arg("--version")
        .output()
        .is_ok_and(|output| output.status.success())
}

fn run_git(cwd: &Path, args: &[&str]) {
    let output = Command::new("git")
        .args(args)
        .current_dir(cwd)
        .env("GIT_AUTHOR_NAME", "Test User")
        .env("GIT_AUTHOR_EMAIL", "test@example.com")
        .env("GIT_COMMITTER_NAME", "Test User")
        .env("GIT_COMMITTER_EMAIL", "test@example.com")
        .output()
        .expect("run git");
    assert!(
        output.status.success(),
        "git {:?} failed:\nstdout: {}\nstderr: {}",
        args,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

fn make_repo_with_one_commit(dir: &Path) {
    run_git(dir, &["init", "-q", "--initial-branch=main"]);
    run_git(dir, &["config", "user.email", "test@example.com"]);
    run_git(dir, &["config", "user.name", "Test User"]);
    fs::write(dir.join("file.txt"), "hello\n").unwrap();
    run_git(dir, &["add", "file.txt"]);
    run_git(dir, &["commit", "-q", "-m", "initial commit"]);
}

#[test]
fn activity_filled_for_real_git_repo() {
    if !git_available() {
        return;
    }
    let tmp = tempdir().unwrap();
    make_repo_with_one_commit(tmp.path());

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

    assert_eq!(scan.vcs.kind, VcsKind::Git);
    assert!(
        scan.vcs.activity.days_since_last_commit.is_some(),
        "days_since_last_commit should be filled"
    );
    assert_eq!(
        scan.vcs.activity.days_since_last_commit,
        Some(0),
        "fresh commit should be 0 days old"
    );
    assert!(
        scan.vcs.activity.commits_last_90d.unwrap_or(0) >= 1,
        "should see at least 1 commit in 90d window"
    );
    assert_eq!(
        scan.vcs.activity.contributors_count,
        Some(1),
        "single author should be counted"
    );
    assert!(
        scan.vcs.activity.first_commit_date.is_some(),
        "first_commit_date should be set"
    );
}

#[test]
fn activity_empty_for_non_git_dir() {
    let tmp = tempdir().unwrap();
    let scan = scan_path(tmp.path()).expect("scan succeeds");
    assert_eq!(scan.vcs.kind, VcsKind::None);
    assert!(scan.vcs.activity.days_since_last_commit.is_none());
    assert!(scan.vcs.activity.commits_last_90d.is_none());
    assert!(scan.vcs.activity.contributors_count.is_none());
    assert!(scan.vcs.activity.first_commit_date.is_none());
    assert!(scan.vcs.activity.contributors.is_empty());
}

#[test]
fn detailed_contributors_off_by_default() {
    if !git_available() {
        return;
    }
    let tmp = tempdir().unwrap();
    make_repo_with_one_commit(tmp.path());
    let scan = scan_path(tmp.path()).expect("scan succeeds");
    assert!(
        scan.vcs.activity.contributors.is_empty(),
        "contributors should be empty by default"
    );
}

#[test]
fn detailed_contributors_on_yields_at_least_one() {
    if !git_available() {
        return;
    }
    let tmp = tempdir().unwrap();
    make_repo_with_one_commit(tmp.path());

    let opts = ScanOptions {
        detailed_contributors: true,
    };
    let scan = scan_path_with(tmp.path(), &opts).expect("scan succeeds");
    assert!(
        !scan.vcs.activity.contributors.is_empty(),
        "contributors should be populated when opt-in"
    );
    let contrib = &scan.vcs.activity.contributors[0];
    assert!(!contrib.name.is_empty(), "name should be filled");
    assert!(
        contrib.email.contains('@'),
        "email should look like an address"
    );
    assert!(contrib.commit_count >= 1);
}

#[test]
fn health_signals_include_activity_pass_for_fresh_repo() {
    if !git_available() {
        return;
    }
    let tmp = tempdir().unwrap();
    make_repo_with_one_commit(tmp.path());
    let scan = scan_path(tmp.path()).expect("scan succeeds");

    let activity_signal = scan
        .health
        .signals
        .iter()
        .find(|signal| signal.kind == HealthSignalKind::Activity)
        .expect("Activity signal present");
    assert_eq!(activity_signal.status, HealthSignalStatus::Pass);
    assert!(activity_signal.evidence.contains("active"));
}

#[test]
fn health_signals_include_activity_info_for_no_vcs() {
    let tmp = tempdir().unwrap();
    let scan = scan_path(tmp.path()).expect("scan succeeds");
    let activity_signal = scan
        .health
        .signals
        .iter()
        .find(|signal| signal.kind == HealthSignalKind::Activity)
        .expect("Activity signal present");
    assert_eq!(activity_signal.status, HealthSignalStatus::Info);
    assert!(activity_signal.evidence.contains("unknown"));
}