Files
agent-manager/src/context.rs
T

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