- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API - Add slog! macro: drop-in for eprintln! that also captures to ring buffer - Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog! - Add get_server_logs MCP tool: accepts count (1-500) and optional filter params - 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer - 262 tests passing, clippy clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
4.7 KiB
Rust
165 lines
4.7 KiB
Rust
use crate::slog;
|
|
use crate::state::SessionState;
|
|
use ignore::WalkBuilder;
|
|
use serde::Serialize;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Serialize, Debug, poem_openapi::Object)]
|
|
pub struct SearchResult {
|
|
pub path: String,
|
|
pub matches: usize,
|
|
}
|
|
|
|
fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
|
|
state.get_project_root()
|
|
}
|
|
|
|
pub async fn search_files(
|
|
query: String,
|
|
state: &SessionState,
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
let root = get_project_root(state)?;
|
|
search_files_impl(query, root).await
|
|
}
|
|
|
|
pub async fn search_files_impl(query: String, root: PathBuf) -> Result<Vec<SearchResult>, String> {
|
|
let root_clone = root.clone();
|
|
|
|
let results = tokio::task::spawn_blocking(move || {
|
|
let mut matches = Vec::new();
|
|
let walker = WalkBuilder::new(&root_clone).git_ignore(true).build();
|
|
|
|
for result in walker {
|
|
match result {
|
|
Ok(entry) => {
|
|
if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
|
|
continue;
|
|
}
|
|
|
|
let path = entry.path();
|
|
if let Ok(content) = fs::read_to_string(path)
|
|
&& content.contains(&query)
|
|
{
|
|
let relative = path
|
|
.strip_prefix(&root_clone)
|
|
.unwrap_or(path)
|
|
.to_string_lossy()
|
|
.to_string();
|
|
|
|
matches.push(SearchResult {
|
|
path: relative,
|
|
matches: 1,
|
|
});
|
|
}
|
|
}
|
|
Err(err) => slog!("Error walking dir: {}", err),
|
|
}
|
|
}
|
|
|
|
matches
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Search task failed: {e}"))?;
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
fn setup_project(files: &[(&str, &str)]) -> TempDir {
|
|
let dir = TempDir::new().unwrap();
|
|
for (path, content) in files {
|
|
let full = dir.path().join(path);
|
|
if let Some(parent) = full.parent() {
|
|
fs::create_dir_all(parent).unwrap();
|
|
}
|
|
fs::write(full, content).unwrap();
|
|
}
|
|
dir
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn finds_files_matching_query() {
|
|
let dir = setup_project(&[
|
|
("hello.txt", "hello world"),
|
|
("goodbye.txt", "goodbye world"),
|
|
]);
|
|
|
|
let results = search_files_impl("hello".to_string(), dir.path().to_path_buf())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].path, "hello.txt");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn returns_empty_for_no_matches() {
|
|
let dir = setup_project(&[("file.txt", "some content")]);
|
|
|
|
let results = search_files_impl("nonexistent".to_string(), dir.path().to_path_buf())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(results.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn searches_nested_directories() {
|
|
let dir = setup_project(&[
|
|
("top.txt", "needle"),
|
|
("sub/deep.txt", "needle in haystack"),
|
|
("sub/other.txt", "no match here"),
|
|
]);
|
|
|
|
let results = search_files_impl("needle".to_string(), dir.path().to_path_buf())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(results.len(), 2);
|
|
let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect();
|
|
assert!(paths.contains(&"top.txt"));
|
|
assert!(paths.contains(&"sub/deep.txt"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skips_directories_only_matches_files() {
|
|
let dir = setup_project(&[("sub/file.txt", "content")]);
|
|
|
|
let results = search_files_impl("content".to_string(), dir.path().to_path_buf())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].path, "sub/file.txt");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn respects_gitignore() {
|
|
let dir = setup_project(&[
|
|
(".gitignore", "ignored/\n"),
|
|
("kept.txt", "search term"),
|
|
("ignored/hidden.txt", "search term"),
|
|
]);
|
|
|
|
// Initialize a git repo so .gitignore is respected
|
|
std::process::Command::new("git")
|
|
.args(["init"])
|
|
.current_dir(dir.path())
|
|
.output()
|
|
.unwrap();
|
|
|
|
let results = search_files_impl("search term".to_string(), dir.path().to_path_buf())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].path, "kept.txt");
|
|
}
|
|
}
|