Multi-LLM Provider Support: - Add llm-core crate with LlmProvider trait abstraction - Implement Anthropic Claude API client with streaming - Implement OpenAI API client with streaming - Add token counting with SimpleTokenCounter and ClaudeTokenCounter - Add retry logic with exponential backoff and jitter Borderless TUI Redesign: - Rewrite theme system with terminal capability detection (Full/Unicode256/Basic) - Add provider tabs component with keybind switching [1]/[2]/[3] - Implement vim-modal input (Normal/Insert/Visual/Command modes) - Redesign chat panel with timestamps and streaming indicators - Add multi-provider status bar with cost tracking - Add Nerd Font icons with graceful ASCII fallbacks - Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark) Advanced Agent Features: - Add system prompt builder with configurable components - Enhance subagent orchestration with parallel execution - Add git integration module for safe command detection - Add streaming tool results via channels - Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell - Add WebSearch with provider abstraction Plugin System Enhancement: - Add full agent definition parsing from YAML frontmatter - Add skill system with progressive disclosure - Wire plugin hooks into HookManager 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
224 lines
7.3 KiB
Rust
224 lines
7.3 KiB
Rust
use tools_fs::{read_file, glob_list, grep, write_file, edit_file, multi_edit_file, list_directory, EditOperation};
|
|
use std::fs;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn read_and_glob_respect_gitignore() {
|
|
let dir = tempdir().unwrap();
|
|
let root = dir.path();
|
|
fs::write(root.join("a.txt"), "hello").unwrap();
|
|
fs::create_dir(root.join("secret")).unwrap();
|
|
fs::write(root.join("secret/secret.txt"), "token=123").unwrap();
|
|
fs::write(root.join(".gitignore"), "secret/\n").unwrap();
|
|
|
|
let pattern = format!("{}/**/*", root.display());
|
|
let files = glob_list(&pattern).unwrap();
|
|
assert!(files.iter().any(|p| p.ends_with("a.txt")));
|
|
assert!(!files.iter().any(|p| p.contains("secret.txt")));
|
|
assert_eq!(read_file(root.join("a.txt").to_str().unwrap()).unwrap(), "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn grep_finds_lines() {
|
|
let dir = tempdir().unwrap();
|
|
let root = dir.path();
|
|
fs::write(root.join("a.rs"), "fn main() { println!(\"hello\"); }").unwrap();
|
|
|
|
let hits = grep(root.to_str().unwrap(), "hello").unwrap();
|
|
assert!(hits.iter().any(|(_p, _ln, text)| text.contains("hello")));
|
|
}
|
|
|
|
#[test]
|
|
fn write_file_creates_new_file() {
|
|
let dir = tempdir().unwrap();
|
|
let file_path = dir.path().join("new.txt");
|
|
|
|
write_file(file_path.to_str().unwrap(), "new content").unwrap();
|
|
|
|
assert_eq!(read_file(file_path.to_str().unwrap()).unwrap(), "new content");
|
|
}
|
|
|
|
#[test]
|
|
fn write_file_overwrites_existing() {
|
|
let dir = tempdir().unwrap();
|
|
let file_path = dir.path().join("existing.txt");
|
|
fs::write(&file_path, "old content").unwrap();
|
|
|
|
write_file(file_path.to_str().unwrap(), "new content").unwrap();
|
|
|
|
assert_eq!(read_file(file_path.to_str().unwrap()).unwrap(), "new content");
|
|
}
|
|
|
|
#[test]
|
|
fn edit_file_replaces_exact_match() {
|
|
let dir = tempdir().unwrap();
|
|
let file_path = dir.path().join("test.txt");
|
|
let original = "line 1\nline 2\nline 3\n";
|
|
fs::write(&file_path, original).unwrap();
|
|
|
|
edit_file(file_path.to_str().unwrap(), "line 2", "modified line 2").unwrap();
|
|
|
|
let result = read_file(file_path.to_str().unwrap()).unwrap();
|
|
assert_eq!(result, "line 1\nmodified line 2\nline 3\n");
|
|
}
|
|
|
|
#[test]
|
|
fn edit_file_replaces_multiline() {
|
|
let dir = tempdir().unwrap();
|
|
let file_path = dir.path().join("test.txt");
|
|
let original = "line 1\nline 2\nline 3\nline 4\n";
|
|
fs::write(&file_path, original).unwrap();
|
|
|
|
edit_file(file_path.to_str().unwrap(), "line 2\nline 3", "new content").unwrap();
|
|
|
|
let result = read_file(file_path.to_str().unwrap()).unwrap();
|
|
assert_eq!(result, "line 1\nnew content\nline 4\n");
|
|
}
|
|
|
|
#[test]
|
|
fn edit_file_fails_on_ambiguous_match() {
|
|
let dir = tempdir().unwrap();
|
|
let file_path = dir.path().join("test.txt");
|
|
let original = "duplicate\nsome text\nduplicate\n";
|
|
fs::write(&file_path, original).unwrap();
|
|
|
|
let result = edit_file(file_path.to_str().unwrap(), "duplicate", "changed");
|
|
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(err_msg.contains("Ambiguous") || err_msg.contains("multiple") || err_msg.contains("occurrences"));
|
|
}
|
|
|
|
#[test]
|
|
fn edit_file_fails_on_no_match() {
|
|
let dir = tempdir().unwrap();
|
|
let file_path = dir.path().join("test.txt");
|
|
let original = "line 1\nline 2\n";
|
|
fs::write(&file_path, original).unwrap();
|
|
|
|
let result = edit_file(file_path.to_str().unwrap(), "nonexistent", "changed");
|
|
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(err_msg.contains("not found") || err_msg.contains("String to replace"));
|
|
}
|
|
|
|
#[test]
|
|
fn multi_edit_file_applies_multiple_edits() {
|
|
let dir = tempdir().unwrap();
|
|
let file_path = dir.path().join("test.txt");
|
|
let original = "line 1\nline 2\nline 3\n";
|
|
fs::write(&file_path, original).unwrap();
|
|
|
|
let edits = vec![
|
|
EditOperation {
|
|
old_string: "line 1".to_string(),
|
|
new_string: "modified 1".to_string(),
|
|
},
|
|
EditOperation {
|
|
old_string: "line 2".to_string(),
|
|
new_string: "modified 2".to_string(),
|
|
},
|
|
];
|
|
|
|
let result = multi_edit_file(file_path.to_str().unwrap(), edits).unwrap();
|
|
assert!(result.contains("Applied 2 edits"));
|
|
|
|
let content = read_file(file_path.to_str().unwrap()).unwrap();
|
|
assert_eq!(content, "modified 1\nmodified 2\nline 3\n");
|
|
|
|
// Backup file should exist
|
|
let backup_path = format!("{}.bak", file_path.display());
|
|
assert!(std::path::Path::new(&backup_path).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn multi_edit_file_fails_on_missing_string() {
|
|
let dir = tempdir().unwrap();
|
|
let file_path = dir.path().join("test.txt");
|
|
let original = "line 1\nline 2\n";
|
|
fs::write(&file_path, original).unwrap();
|
|
|
|
let edits = vec![
|
|
EditOperation {
|
|
old_string: "line 1".to_string(),
|
|
new_string: "modified 1".to_string(),
|
|
},
|
|
EditOperation {
|
|
old_string: "nonexistent".to_string(),
|
|
new_string: "modified".to_string(),
|
|
},
|
|
];
|
|
|
|
let result = multi_edit_file(file_path.to_str().unwrap(), edits);
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(err_msg.contains("String not found"));
|
|
}
|
|
|
|
#[test]
|
|
fn list_directory_shows_files_and_dirs() {
|
|
let dir = tempdir().unwrap();
|
|
let root = dir.path();
|
|
|
|
// Create test structure
|
|
fs::write(root.join("file1.txt"), "content").unwrap();
|
|
fs::write(root.join("file2.txt"), "content").unwrap();
|
|
fs::create_dir(root.join("subdir")).unwrap();
|
|
fs::write(root.join(".hidden"), "hidden content").unwrap();
|
|
|
|
let entries = list_directory(root.to_str().unwrap(), false).unwrap();
|
|
|
|
// Should find 2 files and 1 directory (hidden file excluded)
|
|
assert_eq!(entries.len(), 3);
|
|
|
|
// Verify directory appears first (sorted)
|
|
assert_eq!(entries[0].name, "subdir");
|
|
assert!(entries[0].is_dir);
|
|
|
|
// Verify files
|
|
let file_names: Vec<_> = entries.iter().skip(1).map(|e| e.name.as_str()).collect();
|
|
assert!(file_names.contains(&"file1.txt"));
|
|
assert!(file_names.contains(&"file2.txt"));
|
|
|
|
// Hidden file should not be present
|
|
assert!(!entries.iter().any(|e| e.name == ".hidden"));
|
|
}
|
|
|
|
#[test]
|
|
fn list_directory_shows_hidden_when_requested() {
|
|
let dir = tempdir().unwrap();
|
|
let root = dir.path();
|
|
|
|
fs::write(root.join("visible.txt"), "content").unwrap();
|
|
fs::write(root.join(".hidden"), "hidden content").unwrap();
|
|
|
|
let entries = list_directory(root.to_str().unwrap(), true).unwrap();
|
|
|
|
// Should find both files
|
|
assert!(entries.iter().any(|e| e.name == "visible.txt"));
|
|
assert!(entries.iter().any(|e| e.name == ".hidden"));
|
|
}
|
|
|
|
#[test]
|
|
fn list_directory_includes_metadata() {
|
|
let dir = tempdir().unwrap();
|
|
let root = dir.path();
|
|
|
|
fs::write(root.join("test.txt"), "hello world").unwrap();
|
|
fs::create_dir(root.join("testdir")).unwrap();
|
|
|
|
let entries = list_directory(root.to_str().unwrap(), false).unwrap();
|
|
|
|
// Directory entry should have no size
|
|
let dir_entry = entries.iter().find(|e| e.name == "testdir").unwrap();
|
|
assert!(dir_entry.is_dir);
|
|
assert!(dir_entry.size.is_none());
|
|
assert!(dir_entry.modified.is_some());
|
|
|
|
// File entry should have size
|
|
let file_entry = entries.iter().find(|e| e.name == "test.txt").unwrap();
|
|
assert!(!file_entry.is_dir);
|
|
assert_eq!(file_entry.size, Some(11)); // "hello world" is 11 bytes
|
|
assert!(file_entry.modified.is_some());
|
|
} |