Files
cli/sunbeam/src/code/project.rs
Sienna Meridian Satterwhite 02e4d7fb37 feat(code): CLI client with gRPC connection + local tools
phase 3 client core:
- sunbeam code subcommand with project discovery, config loading
- gRPC client connects to Sol, starts bidirectional session
- 7 client-side tool executors: file_read, file_write, search_replace,
  grep, bash, list_directory
- project context: .sunbeam/prompt.md, .sunbeam/config.toml, git info
- tool permission config (always/ask/never per tool)
- simple stdin loop (ratatui TUI in phase 4)
- aligned sunbeam-proto to tonic 0.14
2026-03-23 11:57:24 +00:00

132 lines
3.5 KiB
Rust

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<String>,
pub git_status: Option<String>,
pub file_tree: Vec<String>,
}
/// Discover project context from the working directory.
pub fn discover_project(dir: &str) -> anyhow::Result<ProjectContext> {
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<String> {
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<String> {
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<String>,
) {
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);
}
}