init+contexte : am init genere un agent-manager.yaml selon la stack detectee + contexte projet (git root, branche, stack) attache aux evenements et sessions (issue #8)
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user