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
132 lines
3.5 KiB
Rust
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);
|
|
}
|
|
}
|