use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use serde_json::json;
use crate::error::SynwireError;
use crate::tools::{StructuredTool, Tool, ToolOutput, ToolSchema};
use crate::vfs::grep_options::GrepOptions;
use crate::vfs::output::{OutputFormat, format_output};
use crate::vfs::protocol::Vfs;
use crate::vfs::types::{
CommunitySearchOptions, CpOptions, DiffOptions, FindOptions, HeadTailOptions,
HybridSearchOptions, IndexOptions, LsOptions, MkdirOptions, ReadRange, RmOptions,
SemanticSearchOptions, TreeOptions, VfsCapabilities,
};
#[derive(Debug, Clone, Default)]
struct ReadGuard {
read_paths: Arc<RwLock<HashSet<String>>>,
}
impl ReadGuard {
fn new() -> Self {
Self {
read_paths: Arc::new(RwLock::new(HashSet::new())),
}
}
fn record_read(&self, path: &str) {
if let Ok(mut set) = self.read_paths.write() {
let _ = set.insert(path.to_string());
}
}
fn require_read(&self, path: &str) -> Result<(), crate::vfs::error::VfsError> {
let read = self.read_paths.read().is_ok_and(|set| set.contains(path));
if read {
Ok(())
} else {
Err(crate::vfs::error::VfsError::NotRead {
path: path.to_string(),
})
}
}
}
#[allow(clippy::too_many_lines)]
pub fn vfs_tools(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> Vec<Box<dyn Tool>> {
let caps = vfs.capabilities();
let guard = ReadGuard::new();
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
tools.push(Box::new(build_mount(Arc::clone(&vfs), fmt)));
if caps.contains(VfsCapabilities::PWD) {
tools.push(Box::new(build_pwd(Arc::clone(&vfs))));
}
if caps.contains(VfsCapabilities::CD) {
tools.push(Box::new(build_cd(Arc::clone(&vfs))));
}
if caps.contains(VfsCapabilities::LS) {
tools.push(Box::new(build_ls(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::TREE) {
tools.push(Box::new(build_tree(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::READ) {
tools.push(Box::new(build_read(Arc::clone(&vfs), guard.clone(), fmt)));
}
if caps.contains(VfsCapabilities::HEAD) {
tools.push(Box::new(build_head(Arc::clone(&vfs), guard.clone())));
}
if caps.contains(VfsCapabilities::TAIL) {
tools.push(Box::new(build_tail(Arc::clone(&vfs), guard.clone())));
}
if caps.contains(VfsCapabilities::STAT) {
tools.push(Box::new(build_stat(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::WC) {
tools.push(Box::new(build_wc(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::WRITE) {
tools.push(Box::new(build_write(Arc::clone(&vfs), guard.clone())));
}
if caps.contains(VfsCapabilities::APPEND) {
tools.push(Box::new(build_append(Arc::clone(&vfs), guard.clone())));
}
if caps.contains(VfsCapabilities::MKDIR) {
tools.push(Box::new(build_mkdir(Arc::clone(&vfs))));
}
if caps.contains(VfsCapabilities::TOUCH) {
tools.push(Box::new(build_touch(Arc::clone(&vfs))));
}
if caps.contains(VfsCapabilities::EDIT) {
tools.push(Box::new(build_edit(Arc::clone(&vfs), guard)));
}
if caps.contains(VfsCapabilities::DIFF) {
tools.push(Box::new(build_diff(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::RM) {
tools.push(Box::new(build_rm(Arc::clone(&vfs))));
}
if caps.contains(VfsCapabilities::CP) {
tools.push(Box::new(build_cp(Arc::clone(&vfs))));
}
if caps.contains(VfsCapabilities::MV) {
tools.push(Box::new(build_mv(Arc::clone(&vfs))));
}
if caps.contains(VfsCapabilities::GREP) {
tools.push(Box::new(build_grep(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::GLOB) {
tools.push(Box::new(build_glob(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::FIND) {
tools.push(Box::new(build_find(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::INDEX) {
tools.push(Box::new(build_index(Arc::clone(&vfs), fmt)));
tools.push(Box::new(build_index_status(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::SEMANTIC_SEARCH) {
tools.push(Box::new(build_semantic_search(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::HYBRID_SEARCH) {
tools.push(Box::new(build_hybrid_search(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::SKELETON) {
tools.push(Box::new(build_skeleton(Arc::clone(&vfs))));
}
if caps.contains(VfsCapabilities::LIST_COMMUNITIES) {
tools.push(Box::new(build_list_communities(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::COMMUNITY_MEMBERS) {
tools.push(Box::new(build_community_members(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::COMMUNITY_SEARCH) {
tools.push(Box::new(build_community_search(Arc::clone(&vfs), fmt)));
}
if caps.contains(VfsCapabilities::COMMUNITY_SUMMARY) {
tools.push(Box::new(build_community_summary(Arc::clone(&vfs))));
}
tools
}
pub fn clone_repo_stub() -> &'static str {
"clone_repo tool registered"
}
fn vfs_err(e: crate::vfs::error::VfsError) -> SynwireError {
SynwireError::Tool(crate::error::ToolError::InvocationFailed {
message: e.to_string(),
})
}
fn fmt_err(e: String) -> SynwireError {
SynwireError::Tool(crate::error::ToolError::InvocationFailed { message: e })
}
const fn ok(content: String) -> ToolOutput {
ToolOutput {
content,
artifact: None,
binary_results: Vec::new(),
status: crate::tools::ToolResultStatus::Success,
telemetry: None,
content_type: None,
}
}
fn build_mount(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.mount")
.description(
"List all mounted VFS providers, their mount paths, and the operations each supports.\n\
When to use: Call at the start of a session to discover available data sources, \
mount paths, and supported operations per mount.\n\
Returns: A list of providers with their mount points and capability flags.\n\
Errors: None expected — this always succeeds.",
)
.schema(ToolSchema {
name: "fs.mount".into(),
description: "List mounted VFS providers, mount paths, and supported operations."
.into(),
parameters: json!({"type": "object", "properties": {}}),
})
.func(move |_| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let info = vfs.mount_info();
Ok(ok(format_output(&info, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("mount tool")
}
fn build_pwd(vfs: Arc<dyn Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("fs.pwd")
.description(
"Print the absolute path of the current working directory.\n\
When to use: To determine where relative paths will resolve from.\n\
Returns: A single absolute path string.",
)
.schema(ToolSchema {
name: "fs.pwd".into(),
description: "Print the absolute path of the current working directory.".into(),
parameters: json!({"type": "object", "properties": {}}),
})
.func(move |_| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let cwd = vfs.pwd().await.map_err(vfs_err)?;
Ok(ok(cwd))
})
})
.build()
.expect("pwd tool")
}
fn build_cd(vfs: Arc<dyn Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("fs.cd")
.description(
"Change the current working directory to the given path.\n\
Returns: Confirmation of the new directory.\n\
Errors: Fails if the path does not exist or is not a directory.",
)
.schema(ToolSchema {
name: "fs.cd".into(),
description: "Change the current working directory.".into(),
parameters: json!({
"type": "object",
"properties": { "path": { "type": "string", "description": "Target directory path. Relative paths resolve from cwd. Example: \"src\" or \"/workspace/src\"" } },
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"].as_str().unwrap_or("/");
vfs.cd(path).await.map_err(vfs_err)?;
Ok(ok(format!("Changed to {path}")))
})
})
.build()
.expect("cd tool")
}
fn build_ls(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.ls")
.description(
"List directory contents, returning name, size, and type for each entry.\n\
When to use: For a flat listing of a single directory. For recursive nested \
structure, use fs.tree instead.\n\
Returns: A list of entries with name, size in bytes, and type (file/directory/symlink).\n\
Errors: Fails if path does not exist or is not a directory.",
)
.schema(ToolSchema {
name: "fs.ls".into(),
description: "List directory entries with name, size, and type.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "default": ".", "description": "Directory path. Relative paths resolve from cwd. Example: \"src\" or \"/workspace/src\"" },
"all": { "type": "boolean", "description": "Include hidden files (names starting with '.'). Default: false." },
"recursive": { "type": "boolean", "description": "Recurse into subdirectories. For structured recursive output, prefer tree instead. Default: false." }
}
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"].as_str().unwrap_or(".");
let opts = LsOptions {
all: input["all"].as_bool().unwrap_or(false),
recursive: input["recursive"].as_bool().unwrap_or(false),
long: true,
..Default::default()
};
let entries = vfs.ls(path, opts).await.map_err(vfs_err)?;
Ok(ok(format_output(&entries, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("ls tool")
}
fn build_tree(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.tree")
.description(
"Show recursive directory tree with nested children.\n\
When to use: To understand project structure or explore nested directories. \
For a flat listing of a single directory, use fs.ls instead.\n\
Returns: Nested tree structure showing directories and files with indentation.\n\
Errors: Fails if path does not exist or is not a directory.",
)
.schema(ToolSchema {
name: "fs.tree".into(),
description: "Show recursive directory tree with nested children.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "default": ".", "description": "Root directory path. Relative paths resolve from cwd. Example: \"src\" or \"/workspace\"" },
"max_depth": { "type": "integer", "description": "Depth limit. 1 = immediate children only. Omit for unlimited. Example: 3" },
"dirs_only": { "type": "boolean", "description": "Show directories only, omitting files. Default: false." }
}
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"].as_str().unwrap_or(".");
let opts = TreeOptions {
max_depth: input["max_depth"].as_u64().map(|n| n as usize),
dirs_only: input["dirs_only"].as_bool().unwrap_or(false),
..Default::default()
};
let tree = vfs.tree(path, opts).await.map_err(vfs_err)?;
Ok(ok(format_output(&tree, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("tree tool")
}
fn build_read(vfs: Arc<dyn Vfs>, guard: ReadGuard, _fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.read")
.description(
"Read text content of a file, optionally by line or byte range.\n\
When to use: When you need file content. Use line_start/line_end for partial reads, \
or omit for the full file. For only the first N lines, use fs.head. For the last N, use fs.tail.\n\
IMPORTANT: You must read a file before you can edit or write to it.\n\
Returns: File content as a string (full or partial).\n\
Errors: WILL FAIL on binary files (non-UTF-8). Fails if the file does not exist.",
)
.schema(ToolSchema {
name: "fs.read".into(),
description: "Read file content (full or by line/byte range). Must read before edit/write.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file to read. Example: \"src/main.rs\"" },
"line_start": { "type": "integer", "description": "Start line (1-indexed, inclusive). Omit to start from beginning. Example: 10" },
"line_end": { "type": "integer", "description": "End line (1-indexed, inclusive). Omit to read to end. Example: 50" },
"byte_start": { "type": "integer", "description": "Start byte offset (0-indexed). Takes precedence over line range. Only provide for binary-adjacent work." },
"byte_end": { "type": "integer", "description": "End byte offset (0-indexed, exclusive). Takes precedence over line range." }
},
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
let guard = guard.clone();
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let has_range = input["line_start"].is_u64()
|| input["line_end"].is_u64()
|| input["byte_start"].is_u64()
|| input["byte_end"].is_u64();
let text = if has_range {
let range = ReadRange {
line_start: input["line_start"].as_u64().map(|n| n as usize),
line_end: input["line_end"].as_u64().map(|n| n as usize),
byte_start: input["byte_start"].as_u64().map(|n| n as usize),
byte_end: input["byte_end"].as_u64().map(|n| n as usize),
};
vfs.read_range(path, range).await.map_err(vfs_err)?
} else {
let content = vfs.read(path).await.map_err(vfs_err)?;
String::from_utf8(content.content)
.map_err(|_| fmt_err("binary file".into()))?
};
guard.record_read(path);
let _ = vfs.watch(path).await;
Ok(ok(text))
})
})
.build()
.expect("read tool")
}
fn build_head(vfs: Arc<dyn Vfs>, guard: ReadGuard) -> StructuredTool {
StructuredTool::builder()
.name("fs.head")
.description(
"Read the first N lines of a file (default 10).\n\
When to use: To preview the beginning of a file without reading it all. \
For the full file, use fs.read instead. For the end of a file, use fs.tail.\n\
Counts as a read — enables subsequent edit/write on this file.\n\
Returns: The first N lines as a string.\n\
Errors: Fails if the file does not exist.",
)
.schema(ToolSchema {
name: "fs.head".into(),
description: "Read the first N lines of a file (default 10). Counts as a read.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file. Example: \"src/main.rs\"" },
"lines": { "type": "integer", "default": 10, "description": "Number of lines to return from the start. Defaults to 10. Example: 20" }
},
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
let guard = guard.clone();
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let opts = HeadTailOptions {
lines: input["lines"].as_u64().map(|n| n as usize),
..Default::default()
};
let text = vfs.head(path, opts).await.map_err(vfs_err)?;
guard.record_read(path);
let _ = vfs.watch(path).await;
Ok(ok(text))
})
})
.build()
.expect("head tool")
}
fn build_tail(vfs: Arc<dyn Vfs>, guard: ReadGuard) -> StructuredTool {
StructuredTool::builder()
.name("fs.tail")
.description(
"Read the last N lines of a file (default 10).\n\
When to use: To see recent output, log entries, or the end of a file. \
For the full file, use fs.read instead. For the beginning of a file, use fs.head.\n\
Counts as a read — enables subsequent edit/write on this file.\n\
Returns: The last N lines as a string.\n\
Errors: Fails if the file does not exist.",
)
.schema(ToolSchema {
name: "fs.tail".into(),
description: "Read the last N lines of a file (default 10). Counts as a read.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file. Example: \"logs/app.log\"" },
"lines": { "type": "integer", "default": 10, "description": "Number of lines to return from the end. Defaults to 10. Example: 20" }
},
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
let guard = guard.clone();
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let opts = HeadTailOptions {
lines: input["lines"].as_u64().map(|n| n as usize),
..Default::default()
};
let text = vfs.tail(path, opts).await.map_err(vfs_err)?;
guard.record_read(path);
let _ = vfs.watch(path).await;
Ok(ok(text))
})
})
.build()
.expect("tail tool")
}
fn build_stat(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.stat")
.description(
"Return metadata for a file or directory including size, type (file/directory/symlink), \
permissions, and modification time.\n\
Returns: Structured metadata object.\n\
Errors: Fails if the path does not exist.",
)
.schema(ToolSchema {
name: "fs.stat".into(),
description: "Return size, type, permissions, and modification time for a path.".into(),
parameters: json!({
"type": "object",
"properties": { "path": { "type": "string", "description": "Path to the file or directory. Example: \"src/main.rs\"" } },
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let info = vfs.stat(path).await.map_err(vfs_err)?;
Ok(ok(format_output(&info, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("stat tool")
}
fn build_wc(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.wc")
.description(
"Count lines, words, bytes, and characters in a file.\n\
Returns: An object with line_count, word_count, byte_count, and char_count.\n\
Errors: Fails if the file does not exist.",
)
.schema(ToolSchema {
name: "fs.wc".into(),
description: "Count lines, words, bytes, and characters in a file.".into(),
parameters: json!({
"type": "object",
"properties": { "path": { "type": "string", "description": "Path to the file. Example: \"src/main.rs\"" } },
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let counts = vfs.wc(path).await.map_err(vfs_err)?;
Ok(ok(format_output(&counts, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("wc tool")
}
fn build_write(vfs: Arc<dyn Vfs>, guard: ReadGuard) -> StructuredTool {
StructuredTool::builder()
.name("fs.write")
.description(
"Write content to a file, creating it if it does not exist or OVERWRITING it if it does.\n\
IMPORTANT: For existing files, you must read the file first. WILL FAIL if the file \
exists but has not been read, or if it was modified externally since your last read.\n\
When to use: To create a new file or completely replace an existing file's content. \
To add content to the end of an existing file, use fs.append instead. To replace \
specific text within a file, use fs.edit instead.\n\
Returns: Confirmation with bytes written and path.\n\
Errors: NotRead if file exists but was not read. StaleRead if file changed externally.",
)
.schema(ToolSchema {
name: "fs.write".into(),
description: "Create or overwrite a file. Existing files must be read first.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file to write. Example: \"src/main.rs\"" },
"content": { "type": "string", "description": "The full text content to write to the file." }
},
"required": ["path", "content"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
let guard = guard.clone();
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let content = input["content"]
.as_str()
.ok_or_else(|| fmt_err("content required".into()))?;
if vfs.stat(path).await.is_ok() {
guard.require_read(path).map_err(vfs_err)?;
vfs.check_stale(path).await.map_err(vfs_err)?;
}
let result = vfs.write(path, content.as_bytes()).await.map_err(vfs_err)?;
guard.record_read(path);
let _ = vfs.watch(path).await;
Ok(ok(format!(
"Wrote {} bytes to {}",
result.bytes_written, result.path
)))
})
})
.build()
.expect("write tool")
}
fn build_append(vfs: Arc<dyn Vfs>, guard: ReadGuard) -> StructuredTool {
StructuredTool::builder()
.name("fs.append")
.description(
"Add content to the END of a file, creating the file if it does not exist.\n\
IMPORTANT: For existing files, you must read the file first. WILL FAIL if the file \
exists but has not been read, or if it was modified externally since your last read.\n\
When to use: To add lines to a log, config, or any file without replacing existing content. \
To overwrite the entire file, use fs.write instead. To replace specific text, use fs.edit instead.\n\
Returns: Confirmation with bytes appended and path.\n\
Errors: NotRead if file exists but was not read. StaleRead if file changed externally.",
)
.schema(ToolSchema {
name: "fs.append".into(),
description: "Append content to file. Existing files must be read first.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file. Creates the file if it does not exist. Example: \"logs/output.log\"" },
"content": { "type": "string", "description": "Text to append to the end of the file." }
},
"required": ["path", "content"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
let guard = guard.clone();
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let content = input["content"]
.as_str()
.ok_or_else(|| fmt_err("content required".into()))?;
if vfs.stat(path).await.is_ok() {
guard.require_read(path).map_err(vfs_err)?;
vfs.check_stale(path).await.map_err(vfs_err)?;
}
let result = vfs
.append(path, content.as_bytes())
.await
.map_err(vfs_err)?;
let _ = vfs.watch(path).await;
Ok(ok(format!(
"Appended {} bytes to {}",
result.bytes_written, result.path
)))
})
})
.build()
.expect("append tool")
}
fn build_mkdir(vfs: Arc<dyn Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("fs.mkdir")
.description(
"Create a directory at the given path.\n\
Parent directories are created by default (parents=true). Set parents=false to fail \
if the parent directory does not exist.\n\
Returns: Confirmation of the created path.\n\
Errors: Fails if the path already exists or (with parents=false) if the parent is missing.",
)
.schema(ToolSchema {
name: "fs.mkdir".into(),
description: "Create a directory, with parent directories by default.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory path to create. Example: \"src/modules/auth\"" },
"parents": { "type": "boolean", "description": "Create intermediate parent directories automatically. Defaults to true. Set false to fail if parent does not exist." }
},
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"].as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let opts = MkdirOptions {
parents: input["parents"].as_bool().unwrap_or(true),
..Default::default()
};
vfs.mkdir(path, opts).await.map_err(vfs_err)?;
Ok(ok(format!("Created {path}")))
})
})
.build()
.expect("mkdir tool")
}
fn build_touch(vfs: Arc<dyn Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("fs.touch")
.description(
"Create an empty file if it does not exist, or update its modification timestamp if it does.\n\
Returns: Confirmation with the touched path.",
)
.schema(ToolSchema {
name: "fs.touch".into(),
description: "Create an empty file or update its modification timestamp.".into(),
parameters: json!({
"type": "object",
"properties": { "path": { "type": "string", "description": "Path to the file to create or touch. Example: \"src/placeholder.rs\"" } },
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
vfs.touch(path).await.map_err(vfs_err)?;
Ok(ok(format!("Touched {path}")))
})
})
.build()
.expect("touch tool")
}
fn build_edit(vfs: Arc<dyn Vfs>, guard: ReadGuard) -> StructuredTool {
StructuredTool::builder()
.name("fs.edit")
.description(
"Replace the first occurrence of an exact string in a file with new text.\n\
CRITICAL: The edit WILL FAIL if the old string is not found verbatim in the file. \
Read the file first to verify the exact text, including whitespace and newlines. \
Only replaces the FIRST occurrence.\n\
When to use: To make targeted changes to a file. To overwrite the entire file, use \
fs.write instead. To add content to the end, use fs.append instead.\n\
Returns: Number of edits applied and the file path.\n\
Errors: Fails if old text is not found or file does not exist.",
)
.schema(ToolSchema {
name: "fs.edit".into(),
description: "Replace the first occurrence of exact text in a file. Fails if text not found.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file to edit. Example: \"src/main.rs\"" },
"old": { "type": "string", "description": "Exact text to find. Must match verbatim including whitespace and newlines. Read the file first to verify." },
"new": { "type": "string", "description": "Replacement text. Can be empty to delete the matched text." }
},
"required": ["path", "old", "new"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
let guard = guard.clone();
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
guard.require_read(path).map_err(vfs_err)?;
vfs.check_stale(path).await.map_err(vfs_err)?;
let old = input["old"]
.as_str()
.ok_or_else(|| fmt_err("old required".into()))?;
let new = input["new"]
.as_str()
.ok_or_else(|| fmt_err("new required".into()))?;
let result = vfs.edit(path, old, new).await.map_err(vfs_err)?;
let _ = vfs.watch(path).await;
Ok(ok(format!(
"{} edit(s) applied to {}",
result.edits_applied, result.path
)))
})
})
.build()
.expect("edit tool")
}
fn build_diff(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.diff")
.description(
"Compare two files and show a unified diff of their differences.\n\
Returns: Unified diff output with context lines around each change.\n\
Errors: Fails if either file does not exist or is binary.",
)
.schema(ToolSchema {
name: "fs.diff".into(),
description: "Show unified diff between two files.".into(),
parameters: json!({
"type": "object",
"properties": {
"a": { "type": "string", "description": "Path to the original file. Example: \"src/main.rs\"" },
"b": { "type": "string", "description": "Path to the modified file. Example: \"src/main.rs.new\"" },
"context_lines": { "type": "integer", "default": 3, "description": "Number of unchanged lines shown around each diff hunk. Defaults to 3." }
},
"required": ["a", "b"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let a = input["a"]
.as_str()
.ok_or_else(|| fmt_err("a required".into()))?;
let b = input["b"]
.as_str()
.ok_or_else(|| fmt_err("b required".into()))?;
let opts = DiffOptions {
context_lines: input["context_lines"].as_u64().unwrap_or(3) as u32,
};
let result = vfs.diff(a, b, opts).await.map_err(vfs_err)?;
Ok(ok(format_output(&result, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("diff tool")
}
fn build_rm(vfs: Arc<dyn Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("fs.rm")
.description(
"Permanently delete a file or directory. IRREVERSIBLE: deletion cannot be undone.\n\
For non-empty directories, set recursive=true or the operation WILL FAIL.\n\
Returns: Confirmation of the removed path.\n\
Errors: Fails on non-empty directories without recursive=true. Fails on nonexistent \
paths unless force=true.",
)
.schema(ToolSchema {
name: "fs.rm".into(),
description: "Permanently delete a file or directory. IRREVERSIBLE.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file or directory to delete. Example: \"tmp/scratch.txt\"" },
"recursive": { "type": "boolean", "description": "Delete directories and all their contents recursively. Required for non-empty directories. Default: false." },
"force": { "type": "boolean", "description": "Suppress errors for nonexistent paths. Default: false." }
},
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"].as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let opts = RmOptions {
recursive: input["recursive"].as_bool().unwrap_or(false),
force: input["force"].as_bool().unwrap_or(false),
};
vfs.rm(path, opts).await.map_err(vfs_err)?;
Ok(ok(format!("Removed {path}")))
})
})
.build()
.expect("rm tool")
}
fn build_cp(vfs: Arc<dyn Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("fs.cp")
.description(
"Copy a file or directory to a new location, keeping the original intact.\n\
When to use: To duplicate a file or directory. The original remains unchanged. \
To move (delete the original), use fs.mv instead. Cross-mount copies are supported.\n\
Returns: Bytes transferred and destination path.\n\
Errors: Fails if source does not exist or destination parent does not exist.",
)
.schema(ToolSchema {
name: "fs.cp".into(),
description: "Copy a file or directory. Original remains intact.".into(),
parameters: json!({
"type": "object",
"properties": {
"from": { "type": "string", "description": "Source path to copy from. Example: \"src/config.toml\"" },
"to": { "type": "string", "description": "Destination path to copy to. Example: \"backup/config.toml\"" },
"recursive": { "type": "boolean", "description": "Copy directories and all their contents recursively. Required for directories. Default: false." }
},
"required": ["from", "to"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let from = input["from"].as_str()
.ok_or_else(|| fmt_err("from required".into()))?;
let to = input["to"].as_str()
.ok_or_else(|| fmt_err("to required".into()))?;
let opts = CpOptions {
recursive: input["recursive"].as_bool().unwrap_or(false),
..Default::default()
};
let result = vfs.cp(from, to, opts).await.map_err(vfs_err)?;
Ok(ok(format!("Copied {} bytes to {}", result.bytes_transferred, result.path)))
})
})
.build()
.expect("cp tool")
}
fn build_mv(vfs: Arc<dyn Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("fs.mv")
.description(
"Move or rename a file or directory. The source is deleted after the move.\n\
When to use: To relocate or rename a file or directory. The original is removed. \
To keep the original, use fs.cp instead.\n\
Returns: The new path after the move.\n\
Errors: Fails if source does not exist or destination parent does not exist.",
)
.schema(ToolSchema {
name: "fs.mv".into(),
description: "Move or rename a file or directory. Source is deleted after move.".into(),
parameters: json!({
"type": "object",
"properties": {
"from": { "type": "string", "description": "Source path to move from. Example: \"old_name.rs\"" },
"to": { "type": "string", "description": "Destination path to move to. Example: \"new_name.rs\"" }
},
"required": ["from", "to"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let from = input["from"]
.as_str()
.ok_or_else(|| fmt_err("from required".into()))?;
let to = input["to"]
.as_str()
.ok_or_else(|| fmt_err("to required".into()))?;
let result = vfs.mv_file(from, to).await.map_err(vfs_err)?;
Ok(ok(format!("Moved to {}", result.path)))
})
})
.build()
.expect("mv tool")
}
fn build_grep(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.grep")
.description(
"Search file CONTENTS by regex pattern, returning matching lines with file paths and line numbers.\n\
When to use: To find text or patterns inside files. fs.grep searches file CONTENTS. \
For finding files by NAME, use fs.find or fs.glob instead.\n\
Returns: List of matches with file path, line number, and matching line text.\n\
Errors: Fails if the regex pattern is invalid.",
)
.schema(ToolSchema {
name: "fs.grep".into(),
description: "Search file contents by regex pattern. Use find/glob for file names.".into(),
parameters: json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Regular expression to search for. Example: \"fn\\s+main\" or \"TODO\"" },
"path": { "type": "string", "description": "Directory to search in. Defaults to cwd. Example: \"src\"" },
"file_type": { "type": "string", "description": "Filter by language/file type. Values: \"rust\", \"python\", \"typescript\", \"go\", \"json\", \"yaml\", \"toml\", \"markdown\". Example: \"rust\"" },
"case_insensitive": { "type": "boolean", "description": "Match regardless of case. Default: false." }
},
"required": ["pattern"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let pattern = input["pattern"].as_str()
.ok_or_else(|| fmt_err("pattern required".into()))?;
let opts = GrepOptions {
path: input["path"].as_str().map(String::from),
file_type: input["file_type"].as_str().map(String::from),
case_insensitive: input["case_insensitive"].as_bool().unwrap_or(false),
line_numbers: true,
..Default::default()
};
let matches = vfs.grep(pattern, opts).await.map_err(vfs_err)?;
Ok(ok(format_output(&matches, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("grep tool")
}
fn build_glob(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.glob")
.description(
"Find files whose names match a glob pattern.\n\
When to use: Quick file name matching when you know the extension or name pattern. \
For richer search criteria (type, size, depth), use fs.find instead. \
For searching file CONTENTS, use fs.grep instead.\n\
Returns: List of matching file paths.\n\
Errors: Fails if the glob pattern is invalid.",
)
.schema(ToolSchema {
name: "fs.glob".into(),
description: "Find files by glob pattern. Use find for richer criteria, grep for contents.".into(),
parameters: json!({
"type": "object",
"properties": { "pattern": { "type": "string", "description": "Glob pattern. * matches any characters within a path segment, ** matches across directories. Example: \"*.rs\" or \"**/*.toml\"" } },
"required": ["pattern"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let pattern = input["pattern"]
.as_str()
.ok_or_else(|| fmt_err("pattern required".into()))?;
let entries = vfs.glob(pattern).await.map_err(vfs_err)?;
Ok(ok(format_output(&entries, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("glob tool")
}
fn build_find(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("fs.find")
.description(
"Search for files and directories by METADATA: name pattern, entry type, size, and depth.\n\
When to use: When you need to filter by type, size, or depth in addition to name. \
For searching file CONTENTS, use fs.grep instead. For simple name-only matching, \
fs.glob may be faster.\n\
Returns: List of matching paths.\n\
Errors: Fails if the search path does not exist.",
)
.schema(ToolSchema {
name: "fs.find".into(),
description: "Search for files by metadata (name, type, depth). Use grep for contents.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "default": ".", "description": "Root directory to search from. Defaults to cwd. Example: \"src\"" },
"name": { "type": "string", "description": "Glob pattern matched against file name only (not full path). Example: \"*.rs\" or \"Cargo.*\"" },
"type": { "type": "string", "enum": ["file", "directory", "symlink"], "description": "Filter by entry type. \"file\" = regular files only. \"directory\" = directories only. \"symlink\" = symbolic links only." },
"max_depth": { "type": "integer", "description": "Maximum directory depth to search. 1 = immediate children only. Omit for unlimited. Example: 3" }
}
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"].as_str().unwrap_or(".");
let entry_type = input["type"].as_str().and_then(|t| match t {
"file" => Some(crate::vfs::types::FindType::File),
"directory" => Some(crate::vfs::types::FindType::Directory),
"symlink" => Some(crate::vfs::types::FindType::Symlink),
_ => None,
});
let opts = FindOptions {
name: input["name"].as_str().map(String::from),
entry_type,
max_depth: input["max_depth"].as_u64().map(|n| n as usize),
..Default::default()
};
let entries = vfs.find(path, opts).await.map_err(vfs_err)?;
Ok(ok(format_output(&entries, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("find tool")
}
fn build_index(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("index.build")
.description(
"Start building a semantic index of source files for code.search_semantic.\n\
Returns immediately with an index_id — indexing runs in the background.\n\
DENIED on root (/) for safety. Use index.status to check progress.\n\
After completion, a file watcher keeps the index updated automatically.\n\
When to use: Before code.search_semantic on local providers. Providers with \
server-side search may no-op this.\n\
When NOT to use: For text pattern matching — use fs.grep instead.\n\
Returns: An index_id for polling status.\n\
Errors: IndexDenied if path is /. Fails if path does not exist.",
)
.schema(ToolSchema {
name: "index.build".into(),
description: "Start semantic indexing of a directory. Returns immediately.".into(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory to index. Must not be /. Example: \".\" or \"/workspace/src\"" },
"force": { "type": "boolean", "description": "Force re-index even if cache is fresh. Default: false." }
},
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let opts = IndexOptions {
force: input["force"].as_bool().unwrap_or(false),
..Default::default()
};
let handle = vfs.index(path, opts).await.map_err(vfs_err)?;
Ok(ok(format_output(&handle, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("index tool")
}
fn build_index_status(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("index.status")
.description(
"Check the status of an indexing operation by index_id.\n\
Returns: Pending, Indexing (with progress 0.0-1.0), Ready (with file/chunk counts), \
or Failed (with error message).\n\
When to use: After calling index.build, poll this to know when code.search_semantic is available.\n\
Errors: Fails if index_id is unknown.",
)
.schema(ToolSchema {
name: "index.status".into(),
description: "Check indexing progress. Poll after calling index.".into(),
parameters: json!({
"type": "object",
"properties": {
"index_id": { "type": "string", "description": "The index_id returned by the index tool." }
},
"required": ["index_id"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let index_id = input["index_id"]
.as_str()
.ok_or_else(|| fmt_err("index_id required".into()))?;
let status = vfs.index_status(index_id).await.map_err(vfs_err)?;
Ok(ok(format_output(&status, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("index_status tool")
}
fn build_semantic_search(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("code.search_semantic")
.description(
"Search code and documents by meaning, returning ranked results with file paths, \
line ranges, and matched content.\n\
When to use: For conceptual queries like 'error handling logic', 'authentication \
flow', or 'database connection setup'. Use fs.grep for exact text or regex matching.\n\
WILL FAIL if index is not Ready — call index.build first and wait for completion.\n\
Returns: Ranked list of matches with file, lines, content, score, and symbol name.\n\
Errors: IndexNotReady if index is still building. Unsupported if provider lacks capability.",
)
.schema(ToolSchema {
name: "code.search_semantic".into(),
description:
"Search by meaning. Use grep for exact text. Requires index to be built first."
.into(),
parameters: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Natural language search query. Example: \"error handling\" or \"authentication flow\"" },
"top_k": { "type": "integer", "description": "Maximum results to return. Default: 10. Example: 5" },
"file_filter": { "type": "string", "description": "Glob pattern to restrict results to matching files. Example: \"*.rs\" or \"src/**/*.py\"" },
"rerank": { "type": "boolean", "description": "Rerank results with a cross-encoder for better relevance. Default: true." }
},
"required": ["query"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let query = input["query"]
.as_str()
.ok_or_else(|| fmt_err("query required".into()))?;
let opts = SemanticSearchOptions {
top_k: input["top_k"].as_u64().map(|n| n as usize),
file_filter: input["file_filter"]
.as_str()
.map(|s| vec![s.to_string()])
.unwrap_or_default(),
rerank: input["rerank"].as_bool(),
..Default::default()
};
let results = vfs.semantic_search(query, opts).await.map_err(vfs_err)?;
Ok(ok(format_output(&results, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("semantic_search tool")
}
fn build_hybrid_search(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("code.search_hybrid")
.description(
"Hybrid BM25 + vector search combining keyword recall with semantic similarity.\n\
Produces higher-quality results than either fs.grep or code.search_semantic alone by blending \
both signals.\n\
When to use: When you want accurate conceptual matches AND keyword precision — \
for example searching for a specific function name in a semantically relevant context.\n\
Requires index to be built first (call index.build and wait for Ready status).\n\
alpha=1.0 is pure BM25 (keyword), alpha=0.0 is pure vector (semantic), \
alpha=0.5 (default) is balanced.\n\
Returns: Ranked list of matches with id, file, content, score, and optional symbol.",
)
.schema(ToolSchema {
name: "code.search_hybrid".into(),
description:
"Hybrid keyword+semantic search. Requires index to be built first.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (natural language or keyword). Example: \"authentication login\""
},
"alpha": {
"type": "number",
"description": "BM25/vector blend (0.0=pure vector, 1.0=pure BM25). Default: 0.5"
},
"top_k": {
"type": "integer",
"description": "Maximum results to return. Default: 10."
}
},
"required": ["query"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let query = input["query"]
.as_str()
.ok_or_else(|| fmt_err("query required".into()))?;
let opts = HybridSearchOptions {
alpha: input["alpha"].as_f64().map_or(0.5, |v| v as f32),
top_k: input["top_k"].as_u64().map_or(10, |n| n as usize),
};
let results = vfs.hybrid_search(query, opts).await.map_err(vfs_err)?;
Ok(ok(format_output(&results, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("hybrid_search tool")
}
fn build_skeleton(vfs: Arc<dyn Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("fs.skeleton")
.description(
"Return the function and method signatures of a source file without their bodies.\n\
Each signature is prefixed with its 1-indexed line number.\n\
When to use: To understand a file's API surface without reading the full implementation. \
Dramatically reduces token usage for large files — use this before fs.read when you only \
need to know what functions/methods exist and their signatures.\n\
After identifying the relevant function, use fs.read with line_start/line_end to fetch \
its body.\n\
Returns: Signature lines with line numbers, or the full file content for unsupported \
file types (binary-safe fallback).\n\
Errors: Fails if the file does not exist.",
)
.schema(ToolSchema {
name: "fs.skeleton".into(),
description:
"Show function/method signatures without bodies for efficient code navigation."
.into(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the source file. Example: \"src/main.rs\""
}
},
"required": ["path"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let path = input["path"]
.as_str()
.ok_or_else(|| fmt_err("path required".into()))?;
let content = vfs.skeleton(path).await.map_err(vfs_err)?;
Ok(ok(content))
})
})
.build()
.expect("skeleton tool")
}
fn build_list_communities(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("code.list_communities")
.description(
"List all detected code communities with their member counts.\n\
Communities are clusters of related symbols discovered by the community-detection \
pipeline. Use this to explore the high-level structure of a codebase.\n\
Returns: A list of community IDs and the number of member symbols in each.\n\
Errors: Unsupported if the provider has no community-detection data.",
)
.schema(ToolSchema {
name: "code.list_communities".into(),
description: "List detected code communities with member counts.".into(),
parameters: json!({"type": "object", "properties": {}}),
})
.func(move |_| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let entries = vfs.list_communities().await.map_err(vfs_err)?;
Ok(ok(format_output(&entries, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("list_communities tool")
}
fn build_community_members(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("code.community_members")
.description(
"List the symbol names that belong to a specific code community.\n\
When to use: After code.list_communities to drill into which symbols are grouped together.\n\
Returns: The community ID and its member symbol names.\n\
Errors: Unsupported if the provider has no community-detection data.",
)
.schema(ToolSchema {
name: "code.community_members".into(),
description: "List symbols belonging to a community.".into(),
parameters: json!({
"type": "object",
"properties": {
"community_id": {
"type": "integer",
"description": "The community ID to inspect. Example: 7"
}
},
"required": ["community_id"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let id = input["community_id"]
.as_u64()
.ok_or_else(|| fmt_err("community_id required".into()))?;
let result = vfs.community_members(id).await.map_err(vfs_err)?;
Ok(ok(format_output(&result, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("community_members tool")
}
fn build_community_search(vfs: Arc<dyn Vfs>, fmt: OutputFormat) -> StructuredTool {
StructuredTool::builder()
.name("code.search_by_community")
.description(
"Search for code communities whose member symbols match a keyword query.\n\
Performs substring matching against member symbol names. Useful for finding \
which community contains a particular function or type.\n\
Returns: Matched communities with their IDs, member counts, and matched member names.\n\
Errors: Unsupported if the provider has no community-detection data.",
)
.schema(ToolSchema {
name: "code.search_by_community".into(),
description: "Find communities by keyword match against member symbol names.".into(),
parameters: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keyword to match against member symbol names. Example: \"parse\" or \"auth\""
},
"top_k": {
"type": "integer",
"description": "Maximum number of communities to return. Default: 10."
}
},
"required": ["query"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let query = input["query"]
.as_str()
.ok_or_else(|| fmt_err("query required".into()))?;
let opts = CommunitySearchOptions {
top_k: input["top_k"].as_u64().map_or(10, |n| n as usize),
};
let results = vfs.community_search(query, opts).await.map_err(vfs_err)?;
Ok(ok(format_output(&results, fmt).map_err(fmt_err)?))
})
})
.build()
.expect("community_search tool")
}
fn build_community_summary(vfs: Arc<dyn Vfs>) -> StructuredTool {
StructuredTool::builder()
.name("code.community_summary")
.description(
"Get a natural-language summary for a code community.\n\
If a cached summary exists and is not stale it is returned immediately. Otherwise a \
summary is generated (via an LLM if available, or a member-list fallback).\n\
Returns: The community ID, a summary string, and whether the summary is stale.\n\
Errors: Unsupported if the provider has no community-detection data.",
)
.schema(ToolSchema {
name: "code.community_summary".into(),
description: "Get or generate a natural-language summary for a community.".into(),
parameters: json!({
"type": "object",
"properties": {
"community_id": {
"type": "integer",
"description": "The community ID to summarise. Example: 3"
}
},
"required": ["community_id"]
}),
})
.func(move |input| {
let vfs = Arc::clone(&vfs);
Box::pin(async move {
let id = input["community_id"]
.as_u64()
.ok_or_else(|| fmt_err("community_id required".into()))?;
let result = vfs.community_summary(id).await.map_err(vfs_err)?;
Ok(ok(format!(
"Community {}: {}{}\n",
result.community_id,
result.summary,
if result.is_stale { " [stale]" } else { "" }
)))
})
})
.build()
.expect("community_summary tool")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::error::VfsError;
use crate::vfs::grep_options::GrepOptions;
use crate::vfs::types::{
EditResult, FileContent, GlobEntry, GrepMatch, TransferResult, WriteResult,
};
struct FakeVfs {
path: String,
content: String,
}
impl FakeVfs {
fn new(path: impl Into<String>, content: impl Into<String>) -> Self {
Self {
path: path.into(),
content: content.into(),
}
}
}
impl Vfs for FakeVfs {
fn capabilities(&self) -> VfsCapabilities {
VfsCapabilities::READ | VfsCapabilities::SKELETON
}
fn pwd(&self) -> crate::BoxFuture<'_, Result<String, VfsError>> {
Box::pin(async { Ok("/".into()) })
}
fn cd(&self, _path: &str) -> crate::BoxFuture<'_, Result<(), VfsError>> {
Box::pin(async { Ok(()) })
}
fn ls(
&self,
_path: &str,
_opts: LsOptions,
) -> crate::BoxFuture<'_, Result<Vec<crate::vfs::types::DirEntry>, VfsError>> {
Box::pin(async { Ok(vec![]) })
}
fn read(&self, path: &str) -> crate::BoxFuture<'_, Result<FileContent, VfsError>> {
if path == self.path {
let content = self.content.clone().into_bytes();
Box::pin(async move {
Ok(FileContent {
content,
mime_type: None,
})
})
} else {
let path = path.to_owned();
Box::pin(async move { Err(VfsError::NotFound(path)) })
}
}
fn write(
&self,
_path: &str,
_content: &[u8],
) -> crate::BoxFuture<'_, Result<WriteResult, VfsError>> {
Box::pin(async { Err(VfsError::Unsupported("write".into())) })
}
fn edit(
&self,
_path: &str,
_old: &str,
_new: &str,
) -> crate::BoxFuture<'_, Result<EditResult, VfsError>> {
Box::pin(async { Err(VfsError::Unsupported("edit".into())) })
}
fn rm(&self, _path: &str, _opts: RmOptions) -> crate::BoxFuture<'_, Result<(), VfsError>> {
Box::pin(async { Err(VfsError::Unsupported("rm".into())) })
}
fn cp(
&self,
_from: &str,
_to: &str,
_opts: CpOptions,
) -> crate::BoxFuture<'_, Result<TransferResult, VfsError>> {
Box::pin(async { Err(VfsError::Unsupported("cp".into())) })
}
fn mv_file(
&self,
_from: &str,
_to: &str,
) -> crate::BoxFuture<'_, Result<TransferResult, VfsError>> {
Box::pin(async { Err(VfsError::Unsupported("mv".into())) })
}
fn grep(
&self,
_pattern: &str,
_opts: GrepOptions,
) -> crate::BoxFuture<'_, Result<Vec<GrepMatch>, VfsError>> {
Box::pin(async { Err(VfsError::Unsupported("grep".into())) })
}
fn glob(&self, _pattern: &str) -> crate::BoxFuture<'_, Result<Vec<GlobEntry>, VfsError>> {
Box::pin(async { Err(VfsError::Unsupported("glob".into())) })
}
fn upload(
&self,
_from: &str,
_to: &str,
) -> crate::BoxFuture<'_, Result<TransferResult, VfsError>> {
Box::pin(async { Err(VfsError::Unsupported("upload".into())) })
}
fn download(
&self,
_from: &str,
_to: &str,
) -> crate::BoxFuture<'_, Result<TransferResult, VfsError>> {
Box::pin(async { Err(VfsError::Unsupported("download".into())) })
}
}
const MANY_FUNCS_RS: &str = r"
fn alpha() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
fn beta() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
fn gamma() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
fn delta() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
fn epsilon() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
fn zeta() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
fn eta() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
fn theta() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
fn iota() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
fn kappa() -> i32 { 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 }
";
#[tokio::test]
async fn skeleton_default_returns_full_content_for_unsupported() {
let vfs = FakeVfs::new("lib.rs", MANY_FUNCS_RS);
let result = vfs.skeleton("lib.rs").await;
assert!(result.is_ok(), "skeleton should succeed: {result:?}");
let content = result.expect("ok");
assert!(
content.contains("fn alpha"),
"expected 'fn alpha' in output"
);
assert!(
content.contains("fn kappa"),
"expected 'fn kappa' in output"
);
}
#[tokio::test]
async fn skeleton_fallback_length_equals_full_file() {
let vfs = FakeVfs::new("lib.rs", MANY_FUNCS_RS);
let skeleton = vfs.skeleton("lib.rs").await.expect("skeleton ok");
assert_eq!(
skeleton.len(),
MANY_FUNCS_RS.len(),
"default skeleton should return full file length"
);
}
#[tokio::test]
async fn skeleton_binary_returns_unsupported() {
let bad_bytes = vec![0xFF_u8, 0xFE, 0x00, 0x01];
let bad_str = String::from_utf8_lossy(&bad_bytes).into_owned();
let vfs = FakeVfs::new("data.bin", bad_str);
let result = vfs.skeleton("data.bin").await;
assert!(result.is_ok() || matches!(result, Err(VfsError::Unsupported(_))));
}
}