//! Project context detection: git root + branch and the technology //! stack of a directory (marker files). Cached per process so repeated //! calls (event emission, sessions) never re-run subprocesses. use serde::Serialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; #[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct ProjectContext { /// Root of the enclosing git repository (or None). pub root: Option, /// Current branch (or None). pub branch: Option, /// Detected technology stack, e.g. ["rust", "node"]. pub stack: Vec, } /// Marker file -> stack label. Checked walking up from the directory to the /// filesystem root; the first directory containing a marker wins. const MARKERS: &[(&str, &str)] = &[ ("Cargo.toml", "rust"), ("package.json", "node"), ("pyproject.toml", "python"), ("requirements.txt", "python"), ("setup.py", "python"), ("go.mod", "go"), ("bun.lockb", "bun"), ("bun.lock", "bun"), ("Gemfile", "ruby"), ]; static CACHE: OnceLock>> = OnceLock::new(); fn cache() -> &'static Mutex> { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } /// Detect the project context of a directory (cached per process). pub fn detect(cwd: &Path) -> ProjectContext { if let Some(hit) = cache().lock().unwrap().get(cwd).cloned() { return hit; } let ctx = detect_uncached(cwd); cache().lock().unwrap().insert(cwd.to_path_buf(), ctx.clone()); ctx } fn detect_uncached(cwd: &Path) -> ProjectContext { let (root, branch) = git_info(cwd); let stack = detect_stack(cwd); ProjectContext { root, branch, stack, } } /// Git root and branch via git subprocesses (best effort, silent). fn git_info(cwd: &Path) -> (Option, Option) { let root = std::process::Command::new("git") .args(["rev-parse", "--show-toplevel"]) .current_dir(cwd) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); let branch = std::process::Command::new("git") .args(["branch", "--show-current"]) .current_dir(cwd) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|b| !b.is_empty()); (root, branch) } /// Walk up from cwd collecting stack labels from marker files. pub fn detect_stack(cwd: &Path) -> Vec { let mut dir = Some(cwd); let mut out: Vec = Vec::new(); while let Some(d) = dir { for (marker, label) in MARKERS { if d.join(marker).is_file() && !out.iter().any(|s| s == label) { out.push(label.to_string()); } } dir = d.parent(); } out.sort(); out } /// Agents recommended for a detected stack (catalog slugs). pub fn recommended_agents(stack: &[String]) -> Vec<&'static str> { let mut out: Vec<&'static str> = Vec::new(); if stack.contains(&"rust".to_string()) { out.push("smelt"); } if stack.contains(&"python".to_string()) { out.push("aider"); } if stack.contains(&"node".to_string()) { out.push("claude-code"); } if out.is_empty() { out.push("claude-code"); } out } #[cfg(test)] mod tests { use super::*; #[test] fn detects_stack_from_markers() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); std::fs::write(dir.path().join("package.json"), "{}").unwrap(); let stack = detect_stack(dir.path()); assert_eq!(stack, vec!["node".to_string(), "rust".to_string()]); assert_eq!(recommended_agents(&stack), vec!["smelt", "claude-code"]); } #[test] fn walks_up_to_parent_directories() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("go.mod"), "").unwrap(); let sub = dir.path().join("src/deep"); std::fs::create_dir_all(&sub).unwrap(); let stack = detect_stack(&sub); assert_eq!(stack, vec!["go".to_string()]); } #[test] fn empty_dir_recommends_default() { let dir = tempfile::tempdir().unwrap(); let stack = detect_stack(dir.path()); assert!(stack.is_empty()); assert_eq!(recommended_agents(&stack), vec!["claude-code"]); } #[test] fn cache_returns_same_context() { let dir = tempfile::tempdir().unwrap(); let a = detect(dir.path()); let b = detect(dir.path()); assert_eq!(a, b); } }