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::{DependencyEcosystem, DependencySource, render_markdown, scan_path};
use tempfile::TempDir;

#[test]
fn counts_rust_dependencies_from_cargo_manifest() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "Cargo.toml",
        r#"
[package]
name = "demo"
version = "0.1.0"

[dependencies]
anyhow = "1"
serde = { version = "1", optional = true }

[dev-dependencies]
tempfile = "3"

[build-dependencies]
cc = "1"
"#,
    );
    fixture.write("Cargo.lock", "# lock\n");

    let scan = scan_path(fixture.path()).expect("scan succeeds");
    let summary = scan
        .dependencies
        .ecosystems
        .iter()
        .find(|summary| summary.ecosystem == DependencyEcosystem::Rust)
        .expect("rust dependency summary exists");

    assert_eq!(summary.source, DependencySource::CargoToml);
    assert!(summary.manifest.ends_with("Cargo.toml"));
    assert!(
        summary
            .lockfile
            .as_ref()
            .is_some_and(|path| path.ends_with("Cargo.lock"))
    );
    assert_eq!(summary.normal, 2);
    assert_eq!(summary.development, 1);
    assert_eq!(summary.build, 1);
    assert_eq!(summary.optional, 1);
    assert_eq!(summary.total, 4);
    assert_eq!(scan.dependencies.total_manifests, 1);
    assert_eq!(scan.dependencies.total_dependencies, 4);
}

#[test]
fn counts_node_dependencies_from_package_json() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "package.json",
        r#"{
  "name": "demo",
  "version": "0.1.0",
  "dependencies": {"react": "19"},
  "devDependencies": {"vitest": "latest"},
  "optionalDependencies": {"fsevents": "latest"}
}
"#,
    );
    fixture.write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n");

    let scan = scan_path(fixture.path()).expect("scan succeeds");
    let summary = scan
        .dependencies
        .ecosystems
        .iter()
        .find(|summary| summary.ecosystem == DependencyEcosystem::Node)
        .expect("node dependency summary exists");

    assert_eq!(summary.source, DependencySource::PackageJson);
    assert!(summary.manifest.ends_with("package.json"));
    assert!(
        summary
            .lockfile
            .as_ref()
            .is_some_and(|path| path.ends_with("pnpm-lock.yaml"))
    );
    assert_eq!(summary.normal, 1);
    assert_eq!(summary.development, 1);
    assert_eq!(summary.build, 0);
    assert_eq!(summary.optional, 1);
    assert_eq!(summary.total, 3);
}

#[test]
fn counts_python_pyproject_dependencies() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "pyproject.toml",
        r#"
[project]
name = "demo"
version = "0.1.0"
dependencies = ["requests", "pydantic"]

[project.optional-dependencies]
dev = ["pytest", "ruff"]
docs = ["mkdocs"]
"#,
    );
    fixture.write("uv.lock", "# lock\n");

    let scan = scan_path(fixture.path()).expect("scan succeeds");
    let summary = scan
        .dependencies
        .ecosystems
        .iter()
        .find(|summary| summary.source == DependencySource::PyprojectToml)
        .expect("pyproject dependency summary exists");

    assert_eq!(summary.ecosystem, DependencyEcosystem::Python);
    assert!(summary.manifest.ends_with("pyproject.toml"));
    assert!(
        summary
            .lockfile
            .as_ref()
            .is_some_and(|path| path.ends_with("uv.lock"))
    );
    assert_eq!(summary.normal, 2);
    assert_eq!(summary.development, 2);
    assert_eq!(summary.build, 0);
    assert_eq!(summary.optional, 3);
    assert_eq!(summary.total, 5);
}

#[test]
fn counts_requirements_txt_dependencies() {
    let fixture = ProjectFixture::new();
    fixture.write(
        "requirements.txt",
        "requests\n# comment\n\npytest>=8\n-r extra.txt\n--index-url https://example.invalid/simple\n",
    );

    let scan = scan_path(fixture.path()).expect("scan succeeds");
    let summary = scan
        .dependencies
        .ecosystems
        .iter()
        .find(|summary| summary.source == DependencySource::RequirementsTxt)
        .expect("requirements dependency summary exists");

    assert_eq!(summary.ecosystem, DependencyEcosystem::Python);
    assert_eq!(summary.normal, 2);
    assert_eq!(summary.development, 0);
    assert_eq!(summary.total, 2);
}

#[test]
fn ignores_dependency_manifests_excluded_by_ignore_files() {
    let fixture = ProjectFixture::new();
    fixture.write(".gitignore", "vendor/\n");
    fixture.write(
        "Cargo.toml",
        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
    );
    fixture.write(
        "vendor/package.json",
        "{\"dependencies\":{\"ignored\":\"1\"}}\n",
    );

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

    assert!(
        scan.dependencies
            .ecosystems
            .iter()
            .all(|summary| summary.ecosystem != DependencyEcosystem::Node)
    );
    assert_eq!(scan.dependencies.total_manifests, 1);
}

#[test]
fn renders_dependencies_in_markdown_report() {
    let fixture = ProjectFixture::new();
    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 report = render_markdown(&scan);

    assert!(report.contains("## Dependencies"));
    assert!(report.contains("- Total manifests: 1"));
    assert!(report.contains("- Total dependency entries: 1"));
    assert!(report.contains("Rust"));
    assert!(report.contains("normal 1"));
}

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