154 lines
4.7 KiB
Rust
154 lines
4.7 KiB
Rust
//! 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<String>,
|
|
/// Current branch (or None).
|
|
pub branch: Option<String>,
|
|
/// Detected technology stack, e.g. ["rust", "node"].
|
|
pub stack: Vec<String>,
|
|
}
|
|
|
|
/// 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<Mutex<HashMap<PathBuf, ProjectContext>>> = OnceLock::new();
|
|
|
|
fn cache() -> &'static Mutex<HashMap<PathBuf, ProjectContext>> {
|
|
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<String>, Option<String>) {
|
|
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<String> {
|
|
let mut dir = Some(cwd);
|
|
let mut out: Vec<String> = 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);
|
|
}
|
|
}
|