use std::path::{Path, PathBuf}; use std::process::Command; /// Discovered project context sent to Sol on session start. pub struct ProjectContext { pub name: String, pub path: String, pub prompt_md: String, pub config_toml: String, pub git_branch: Option, pub git_status: Option, pub file_tree: Vec, } /// Discover project context from the working directory. pub fn discover_project(dir: &str) -> anyhow::Result { let path = std::fs::canonicalize(dir)?; let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); let prompt_md = read_optional(&path.join(".sunbeam").join("prompt.md")); let config_toml = read_optional(&path.join(".sunbeam").join("config.toml")); let git_branch = run_git(&path, &["rev-parse", "--abbrev-ref", "HEAD"]); let git_status = run_git(&path, &["status", "--short"]); let file_tree = list_tree(&path, 2); Ok(ProjectContext { name, path: path.to_string_lossy().into(), prompt_md, config_toml, git_branch, git_status, file_tree, }) } fn read_optional(path: &Path) -> String { std::fs::read_to_string(path).unwrap_or_default() } fn run_git(dir: &Path, args: &[&str]) -> Option { Command::new("git") .args(args) .current_dir(dir) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) } fn list_tree(dir: &Path, max_depth: usize) -> Vec { let mut entries = Vec::new(); list_tree_inner(dir, dir, 0, max_depth, &mut entries); entries } fn list_tree_inner( base: &Path, dir: &Path, depth: usize, max_depth: usize, entries: &mut Vec, ) { if depth > max_depth { return; } let Ok(read_dir) = std::fs::read_dir(dir) else { return; }; let mut items: Vec<_> = read_dir.filter_map(|e| e.ok()).collect(); items.sort_by_key(|e| e.file_name()); for entry in items { let name = entry.file_name().to_string_lossy().to_string(); // Skip hidden dirs, target, node_modules, vendor if name.starts_with('.') || name == "target" || name == "node_modules" || name == "vendor" { continue; } let relative = entry .path() .strip_prefix(base) .unwrap_or(&entry.path()) .to_string_lossy() .to_string(); entries.push(relative); if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { list_tree_inner(base, &entry.path(), depth + 1, max_depth, entries); } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_discover_current_dir() { // Should work in any directory let ctx = discover_project(".").unwrap(); assert!(!ctx.name.is_empty()); assert!(!ctx.path.is_empty()); } #[test] fn test_list_tree_excludes_hidden() { let dir = std::env::temp_dir().join("sunbeam-test-tree"); let _ = std::fs::create_dir_all(dir.join(".hidden")); let _ = std::fs::create_dir_all(dir.join("visible")); let _ = std::fs::write(dir.join("file.txt"), "test"); let tree = list_tree(&dir, 1); assert!(tree.iter().any(|e| e == "visible")); assert!(tree.iter().any(|e| e == "file.txt")); assert!(!tree.iter().any(|e| e.contains(".hidden"))); let _ = std::fs::remove_dir_all(&dir); } }