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::{
    BuildSystemKind, CiProvider, HealthSignalKind, LanguageKind, LicenseKind, ProjectHealth,
    scan_path,
};
use tempfile::TempDir;

#[test]
fn detects_common_project_markers_from_local_directory() {
    let fixture = ProjectFixture::new();
    fixture.write("Cargo.toml", "[workspace]\nmembers = [\"app\"]\n");
    fixture.write("Cargo.lock", "# lock\n");
    fixture.write(
        "package.json",
        "{\"scripts\":{\"test\":\"node test.js\"}}\n",
    );
    fixture.write("pyproject.toml", "[project]\nname = \"demo\"\n");
    fixture.write("requirements.txt", "pytest\n");
    fixture.write("CMakeLists.txt", "cmake_minimum_required(VERSION 3.20)\n");
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT\n");
    fixture.write("docs/guide.md", "# Guide\n");
    fixture.write(".github/workflows/ci.yml", "name: ci\n");
    fixture.write(".workflow/rust-ci.yml", "version: '1.0'\n");
    fixture.write(".gitlab-ci.yml", "stages: []\n");
    fixture.write(".circleci/config.yml", "version: 2.1\n");
    fixture.write("Jenkinsfile", "pipeline {}\n");
    fixture.write("Dockerfile", "FROM scratch\n");
    fixture.write("docker-compose.yml", "services: {}\n");
    fixture.write("src/main.rs", "fn main() {}\n");
    fixture.write("web/app.ts", "export const value = 1;\n");
    fixture.write("scripts/build.py", "print('build')\n");
    fixture.write("native/main.cpp", "int main() { return 0; }\n");

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

    assert_eq!(scan.project_name, fixture.name());
    assert!(scan.has_language(LanguageKind::Rust));
    assert!(scan.has_language(LanguageKind::TypeScript));
    assert!(scan.has_language(LanguageKind::Python));
    assert!(scan.has_language(LanguageKind::Cpp));
    assert!(scan.has_build_system(BuildSystemKind::Cargo));
    assert!(scan.has_build_system(BuildSystemKind::NodePackage));
    assert!(scan.has_build_system(BuildSystemKind::PythonProject));
    assert!(scan.has_build_system(BuildSystemKind::CMake));
    assert!(scan.documentation.has_readme);
    assert!(scan.documentation.has_license);
    assert_eq!(scan.license.kind, LicenseKind::Mit);
    assert!(scan.documentation.has_docs_dir);
    assert!(scan.ci.has_github_actions);
    assert!(scan.ci.has_gitee_go);
    assert!(scan.ci.has_gitlab_ci);
    assert!(scan.ci.has_circle_ci);
    assert!(scan.ci.has_jenkins);
    assert!(scan.ci.has_provider(CiProvider::GithubActions));
    assert!(scan.ci.has_provider(CiProvider::GiteeGo));
    assert!(scan.ci.has_provider(CiProvider::GitlabCi));
    assert!(scan.ci.has_provider(CiProvider::CircleCi));
    assert!(scan.ci.has_provider(CiProvider::Jenkins));
    assert_eq!(scan.ci.providers.len(), 5);
    assert!(scan.ci.providers.iter().any(|provider| {
        provider.provider == CiProvider::GithubActions && provider.path.ends_with("ci.yml")
    }));
    assert!(scan.ci.providers.iter().any(|provider| {
        provider.provider == CiProvider::GiteeGo && provider.path.ends_with("rust-ci.yml")
    }));
    assert!(scan.ci.providers.iter().any(|provider| {
        provider.provider == CiProvider::GitlabCi && provider.path.ends_with(".gitlab-ci.yml")
    }));
    assert!(scan.ci.providers.iter().any(|provider| {
        provider.provider == CiProvider::CircleCi && provider.path.ends_with("config.yml")
    }));
    assert!(scan.ci.providers.iter().any(|provider| {
        provider.provider == CiProvider::Jenkins && provider.path.ends_with("Jenkinsfile")
    }));
    assert!(scan.containers.has_dockerfile);
    assert!(scan.containers.has_compose_file);
    assert!(scan.files_scanned >= 16);
    assert_eq!(scan.health.grade, ProjectHealth::NeedsAttention);
    assert!(scan.health.score >= 70);
    assert!(
        scan.health
            .signals
            .iter()
            .any(|signal| signal.kind == HealthSignalKind::Ci)
    );
}

#[test]
fn detects_git_summary_from_local_repository() {
    let fixture = ProjectFixture::new();
    if !git_available() {
        return;
    }

    run_git(fixture.path(), &["init"]);
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );
    fixture.write("README.md", "# Demo\n");
    fixture.write("LICENSE", "MIT\n");
    fixture.write("src/main.rs", "fn main() {}\n");

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

    assert!(scan.vcs.is_repository);
    assert!(
        scan.vcs
            .root
            .as_ref()
            .is_some_and(|path| path == fixture.path())
    );
    assert!(scan.vcs.branch.is_some());
    assert!(scan.vcs.is_dirty);
    assert!(scan.vcs.untracked_files > 0);
}

#[test]
fn skips_heavy_and_internal_directories() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );
    fixture.write("src/main.rs", "fn main() {}\n");
    fixture.write("target/generated.py", "print('ignore me')\n");
    fixture.write(
        "node_modules/pkg/index.ts",
        "export const ignored = true;\n",
    );
    fixture.write(".git/hooks/pre-commit.cpp", "int ignored() { return 0; }\n");

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

    assert!(scan.has_language(LanguageKind::Rust));
    assert!(!scan.has_language(LanguageKind::Python));
    assert!(!scan.has_language(LanguageKind::TypeScript));
    assert!(!scan.has_language(LanguageKind::Cpp));
    assert!(
        scan.skipped_dirs
            .iter()
            .any(|path| path.ends_with("target"))
    );
    assert!(
        scan.skipped_dirs
            .iter()
            .any(|path| path.ends_with("node_modules"))
    );
    assert!(scan.skipped_dirs.iter().any(|path| path.ends_with(".git")));
}

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

fn run_git(cwd: &Path, args: &[&str]) {
    let output = std::process::Command::new("git")
        .args(args)
        .current_dir(cwd)
        .output()
        .expect("run git");

    assert!(
        output.status.success(),
        "git {:?} failed: {}{}",
        args,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn respects_gitignore_rules() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );
    fixture.write(".gitignore", "generated/\nignored.py\n");
    fixture.write("src/main.rs", "fn main() {}\n");
    fixture.write("generated/app.ts", "export const ignored = true;\n");
    fixture.write("ignored.py", "print('ignore me')\n");
    fixture.write("native/main.cpp", "int main() { return 0; }\n");

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

    assert!(scan.has_language(LanguageKind::Rust));
    assert!(scan.has_language(LanguageKind::Cpp));
    assert!(!scan.has_language(LanguageKind::TypeScript));
    assert!(!scan.has_language(LanguageKind::Python));
    assert!(
        scan.skipped_dirs
            .iter()
            .any(|path| path.ends_with("generated"))
    );
}

#[test]
fn respects_ignore_file_rules() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );
    fixture.write(".ignore", "scratch/\nignored.cpp\n");
    fixture.write("src/main.rs", "fn main() {}\n");
    fixture.write("scratch/script.py", "print('ignore me')\n");
    fixture.write("ignored.cpp", "int ignored() { return 0; }\n");

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

    assert!(scan.has_language(LanguageKind::Rust));
    assert!(!scan.has_language(LanguageKind::Python));
    assert!(!scan.has_language(LanguageKind::Cpp));
    assert!(
        scan.skipped_dirs
            .iter()
            .any(|path| path.ends_with("scratch"))
    );
}

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 name(&self) -> String {
        self.path()
            .file_name()
            .and_then(|value| value.to_str())
            .expect("temp dir has name")
            .to_owned()
    }

    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");
    }
}