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 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}