sgr-agent-tools 0.3.0

14 reusable file-system tools for sgr-agent based AI agents
Documentation
//! LocalFs — ready-to-use `FileBackend` for local filesystem via std::fs.
//!
//! Requires feature `local-fs`.
//!
//! ```rust,ignore
//! use sgr_agent_tools::{LocalFs, ReadTool, SearchTool};
//! let fs = Arc::new(LocalFs::new("/path/to/workspace"));
//! let read = ReadTool(fs.clone());
//! ```

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};

use crate::backend::FileBackend;

/// Local filesystem backend rooted at a workspace directory.
///
/// All paths are resolved relative to `root`. Absolute paths are rejected.
pub struct LocalFs {
    root: PathBuf,
}

impl LocalFs {
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self { root: root.into() }
    }

    /// Resolve a workspace-relative path to absolute, preventing traversal outside root.
    fn resolve(&self, path: &str) -> Result<PathBuf> {
        let path = path.trim_start_matches('/');
        if path.contains("..") {
            bail!("path traversal blocked: {path}");
        }
        Ok(self.root.join(path))
    }
}

#[async_trait::async_trait]
impl FileBackend for LocalFs {
    async fn read(&self, path: &str, number: bool, start_line: i32, end_line: i32) -> Result<String> {
        let full = self.resolve(path)?;
        let content = tokio::fs::read_to_string(&full).await
            .with_context(|| format!("read {}", full.display()))?;

        let lines: Vec<&str> = content.lines().collect();
        let start = if start_line > 0 { (start_line - 1) as usize } else { 0 };
        let end = if end_line > 0 { end_line as usize } else { lines.len() };
        let end = end.min(lines.len());

        let mut out = String::new();
        for (i, line) in lines[start..end].iter().enumerate() {
            if number {
                out.push_str(&format!("{}\t{}\n", start + i + 1, line));
            } else {
                out.push_str(line);
                out.push('\n');
            }
        }
        Ok(out)
    }

    async fn write(&self, path: &str, content: &str, start_line: i32, end_line: i32) -> Result<()> {
        let full = self.resolve(path)?;
        if let Some(parent) = full.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }

        if start_line > 0 && end_line > 0 {
            // Ranged write: replace lines
            let existing = tokio::fs::read_to_string(&full).await.unwrap_or_default();
            let mut lines: Vec<&str> = existing.lines().collect();
            let start = (start_line - 1) as usize;
            let end = (end_line as usize).min(lines.len());
            let new_lines: Vec<&str> = content.lines().collect();
            lines.splice(start..end, new_lines);
            tokio::fs::write(&full, lines.join("\n") + "\n").await?;
        } else {
            tokio::fs::write(&full, content).await?;
        }
        Ok(())
    }

    async fn delete(&self, path: &str) -> Result<()> {
        let full = self.resolve(path)?;
        tokio::fs::remove_file(&full).await
            .with_context(|| format!("delete {}", full.display()))
    }

    async fn search(&self, root: &str, pattern: &str, limit: i32) -> Result<String> {
        let dir = self.resolve(root)?;
        let re = regex::Regex::new(pattern)
            .with_context(|| format!("invalid regex: {pattern}"))?;

        let mut results = String::new();
        let mut count = 0;
        let max = if limit > 0 { limit as usize } else { 500 };

        search_dir_recursive(&dir, &self.root, &re, max, &mut count, &mut results)?;
        Ok(results)
    }

    async fn list(&self, path: &str) -> Result<String> {
        let dir = self.resolve(path)?;
        let mut entries = tokio::fs::read_dir(&dir).await
            .with_context(|| format!("list {}", dir.display()))?;

        let mut out = format!("$ ls {path}\n");
        let mut names: Vec<String> = Vec::new();
        while let Some(entry) = entries.next_entry().await? {
            let name = entry.file_name().to_string_lossy().to_string();
            let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
            if is_dir {
                names.push(format!("{name}/"));
            } else {
                names.push(name);
            }
        }
        names.sort();
        for name in names {
            out.push_str(&name);
            out.push('\n');
        }
        Ok(out)
    }

    async fn tree(&self, root: &str, level: i32) -> Result<String> {
        let dir = self.resolve(root)?;
        let mut out = String::new();
        tree_recursive(&dir, &self.root, "", level as usize, 0, &mut out)?;
        Ok(out)
    }

    async fn context(&self) -> Result<String> {
        Ok(chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string())
    }

    async fn mkdir(&self, path: &str) -> Result<()> {
        let full = self.resolve(path)?;
        tokio::fs::create_dir_all(&full).await
            .with_context(|| format!("mkdir {}", full.display()))
    }

    async fn move_file(&self, from: &str, to: &str) -> Result<()> {
        let src = self.resolve(from)?;
        let dst = self.resolve(to)?;
        if let Some(parent) = dst.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }
        tokio::fs::rename(&src, &dst).await
            .with_context(|| format!("move {} -> {}", src.display(), dst.display()))
    }

    async fn find(&self, root: &str, name: &str, file_type: &str, limit: i32) -> Result<String> {
        let dir = self.resolve(root)?;
        let max = if limit > 0 { limit as usize } else { 100 };
        let mut results = Vec::new();
        find_recursive(&dir, &self.root, name, file_type, max, &mut results)?;
        Ok(results.join("\n"))
    }
}

/// Recursive grep: search files for regex matches.
fn search_dir_recursive(
    dir: &Path,
    root: &Path,
    re: &regex::Regex,
    max: usize,
    count: &mut usize,
    out: &mut String,
) -> Result<()> {
    let entries = std::fs::read_dir(dir)?;
    for entry in entries.flatten() {
        if *count >= max { return Ok(()); }
        let path = entry.path();
        if path.is_dir() {
            // Skip hidden dirs
            if entry.file_name().to_string_lossy().starts_with('.') { continue; }
            search_dir_recursive(&path, root, re, max, count, out)?;
        } else if path.is_file() {
            if let Ok(content) = std::fs::read_to_string(&path) {
                let rel = path.strip_prefix(root).unwrap_or(&path);
                for (i, line) in content.lines().enumerate() {
                    if *count >= max { return Ok(()); }
                    if re.is_match(line) {
                        out.push_str(&format!("{}:{}:{}\n", rel.display(), i + 1, line));
                        *count += 1;
                    }
                }
            }
        }
    }
    Ok(())
}

/// Recursive tree: build directory tree string.
fn tree_recursive(
    dir: &Path,
    root: &Path,
    prefix: &str,
    max_depth: usize,
    depth: usize,
    out: &mut String,
) -> Result<()> {
    if max_depth > 0 && depth >= max_depth { return Ok(()); }

    let mut entries: Vec<_> = std::fs::read_dir(dir)?
        .filter_map(|e| e.ok())
        .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
        .collect();
    entries.sort_by_key(|e| e.file_name());

    for (i, entry) in entries.iter().enumerate() {
        let is_last = i == entries.len() - 1;
        let connector = if is_last { "└── " } else { "├── " };
        let name = entry.file_name().to_string_lossy().to_string();
        let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);

        if depth == 0 && i == 0 {
            let rel = dir.strip_prefix(root).unwrap_or(dir);
            out.push_str(&format!("{}/\n", rel.display()));
        }

        out.push_str(&format!("{prefix}{connector}{}{}\n", name, if is_dir { "/" } else { "" }));

        if is_dir {
            let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "" });
            tree_recursive(&entry.path(), root, &child_prefix, max_depth, depth + 1, out)?;
        }
    }
    Ok(())
}

/// Recursive find: match files/dirs by name pattern.
fn find_recursive(
    dir: &Path,
    root: &Path,
    pattern: &str,
    file_type: &str,
    max: usize,
    results: &mut Vec<String>,
) -> Result<()> {
    let entries = std::fs::read_dir(dir)?;
    for entry in entries.flatten() {
        if results.len() >= max { return Ok(()); }
        let path = entry.path();
        let name = entry.file_name().to_string_lossy().to_string();
        let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);

        let type_match = match file_type {
            "files" => !is_dir,
            "dirs" => is_dir,
            _ => true,
        };

        if type_match && name.contains(pattern) {
            let rel = path.strip_prefix(root).unwrap_or(&path);
            results.push(rel.display().to_string());
        }

        if is_dir && !name.starts_with('.') {
            find_recursive(&path, root, pattern, file_type, max, results)?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn read_write_delete() {
        let tmp = std::env::temp_dir().join("sgr_localfs_test");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();

        let fs = LocalFs::new(&tmp);

        // Write
        fs.write("test.txt", "line1\nline2\nline3\n", 0, 0).await.unwrap();

        // Read full
        let content = fs.read("test.txt", false, 0, 0).await.unwrap();
        assert!(content.contains("line1"));
        assert!(content.contains("line3"));

        // Read with line numbers
        let numbered = fs.read("test.txt", true, 0, 0).await.unwrap();
        assert!(numbered.contains("1\tline1"));

        // Read range
        let range = fs.read("test.txt", false, 2, 2).await.unwrap();
        assert!(range.contains("line2"));
        assert!(!range.contains("line1"));

        // Delete
        fs.delete("test.txt").await.unwrap();
        assert!(fs.read("test.txt", false, 0, 0).await.is_err());

        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[tokio::test]
    async fn list_and_tree() {
        let tmp = std::env::temp_dir().join("sgr_localfs_test2");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(tmp.join("sub")).unwrap();
        std::fs::write(tmp.join("a.txt"), "hello").unwrap();
        std::fs::write(tmp.join("sub/b.txt"), "world").unwrap();

        let fs = LocalFs::new(&tmp);

        let listing = fs.list("/").await.unwrap();
        assert!(listing.contains("a.txt"));
        assert!(listing.contains("sub/"));

        let tree = fs.tree("/", 2).await.unwrap();
        assert!(tree.contains("a.txt"));
        assert!(tree.contains("sub/"));

        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[tokio::test]
    async fn search_files() {
        let tmp = std::env::temp_dir().join("sgr_localfs_test3");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();
        std::fs::write(tmp.join("a.txt"), "hello world\nfoo bar").unwrap();
        std::fs::write(tmp.join("b.txt"), "baz qux").unwrap();

        let fs = LocalFs::new(&tmp);
        let results = fs.search("/", "hello", 10).await.unwrap();
        assert!(results.contains("a.txt:1:hello world"));
        assert!(!results.contains("b.txt"));

        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[tokio::test]
    async fn path_traversal_blocked() {
        let tmp = std::env::temp_dir().join("sgr_localfs_test4");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();

        let fs = LocalFs::new(&tmp);
        assert!(fs.read("../etc/passwd", false, 0, 0).await.is_err());
        assert!(fs.write("../../evil.txt", "pwned", 0, 0).await.is_err());
    }

    #[tokio::test]
    async fn find_files() {
        let tmp = std::env::temp_dir().join("sgr_localfs_test5");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(tmp.join("sub")).unwrap();
        std::fs::write(tmp.join("readme.md"), "hi").unwrap();
        std::fs::write(tmp.join("sub/readme.md"), "hi").unwrap();

        let fs = LocalFs::new(&tmp);
        let found = fs.find("/", "readme", "", 10).await.unwrap();
        assert!(found.contains("readme.md"));
        assert!(found.lines().count() >= 2);

        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[tokio::test]
    async fn mkdir_and_move() {
        let tmp = std::env::temp_dir().join("sgr_localfs_test6");
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();

        let fs = LocalFs::new(&tmp);
        fs.write("orig.txt", "data", 0, 0).await.unwrap();
        fs.mkdir("newdir").await.unwrap();
        fs.move_file("orig.txt", "newdir/moved.txt").await.unwrap();

        assert!(fs.read("orig.txt", false, 0, 0).await.is_err());
        let content = fs.read("newdir/moved.txt", false, 0, 0).await.unwrap();
        assert!(content.contains("data"));

        let _ = std::fs::remove_dir_all(&tmp);
    }
}