Skip to main content

agentlint/
project.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use serde_json::Value;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum PackageManager {
10    Npm,
11    Pnpm,
12    Yarn,
13    Bun,
14}
15
16impl PackageManager {
17    pub const fn command(self) -> &'static str {
18        match self {
19            Self::Npm => "npm",
20            Self::Pnpm => "pnpm",
21            Self::Yarn => "yarn",
22            Self::Bun => "bun",
23        }
24    }
25}
26
27#[derive(Debug, Clone, Default)]
28pub struct ProjectFacts {
29    pub package_manager: Option<PackageManager>,
30    pub package_scripts: BTreeSet<String>,
31    pub has_python: bool,
32    pub has_rust: bool,
33    pub has_go: bool,
34}
35
36impl ProjectFacts {
37    /// Infer conservative tooling facts from repository metadata.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if present package metadata cannot be read.
42    pub fn inspect(root: &Path) -> Result<Self> {
43        let mut facts = Self {
44            package_manager: detect_package_manager(root),
45            has_python: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"]
46                .iter()
47                .any(|name| root.join(name).exists()),
48            has_rust: root.join("Cargo.toml").is_file(),
49            has_go: root.join("go.mod").is_file(),
50            ..Self::default()
51        };
52        let package_json = root.join("package.json");
53        if package_json.is_file() {
54            let text = fs::read_to_string(&package_json).context("failed to read package.json")?;
55            if let Ok(value) = serde_json::from_str::<Value>(&text) {
56                if facts.package_manager.is_none() {
57                    facts.package_manager = value
58                        .get("packageManager")
59                        .and_then(Value::as_str)
60                        .and_then(parse_package_manager);
61                }
62                if let Some(scripts) = value.get("scripts").and_then(Value::as_object) {
63                    facts.package_scripts.extend(scripts.keys().cloned());
64                }
65            }
66        }
67        Ok(facts)
68    }
69}
70
71fn detect_package_manager(root: &Path) -> Option<PackageManager> {
72    [
73        ("pnpm-lock.yaml", PackageManager::Pnpm),
74        ("yarn.lock", PackageManager::Yarn),
75        ("bun.lock", PackageManager::Bun),
76        ("bun.lockb", PackageManager::Bun),
77        ("package-lock.json", PackageManager::Npm),
78    ]
79    .into_iter()
80    .find_map(|(name, manager)| root.join(name).is_file().then_some(manager))
81}
82
83fn parse_package_manager(value: &str) -> Option<PackageManager> {
84    match value.split('@').next()? {
85        "npm" => Some(PackageManager::Npm),
86        "pnpm" => Some(PackageManager::Pnpm),
87        "yarn" => Some(PackageManager::Yarn),
88        "bun" => Some(PackageManager::Bun),
89        _ => None,
90    }
91}