dot-ai 0.1.0

A minimal AI agent that lives in your terminal
Documentation
use anyhow::{Context, Result};
use serde_json::Value;
use std::fs;
use std::path::Path;

use super::Tool;

pub struct ReadFileTool;

impl Tool for ReadFileTool {
    fn name(&self) -> &str {
        "read_file"
    }

    fn description(&self) -> &str {
        "Read the contents of a file at the given path. Use this to examine existing files."
    }

    fn input_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "The file path to read"
                }
            },
            "required": ["path"]
        })
    }

    fn execute(&self, input: Value) -> Result<String> {
        let path = input["path"]
            .as_str()
            .context("Missing required parameter 'path'")?;
        tracing::debug!("read_file: {}", path);
        let content =
            fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))?;
        Ok(content)
    }
}

pub struct WriteFileTool;

impl Tool for WriteFileTool {
    fn name(&self) -> &str {
        "write_file"
    }

    fn description(&self) -> &str {
        "Write content to a file at the given path. Creates the file if it doesn't exist, overwrites if it does."
    }

    fn input_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "The file path to write to"
                },
                "content": {
                    "type": "string",
                    "description": "The content to write"
                }
            },
            "required": ["path", "content"]
        })
    }

    fn execute(&self, input: Value) -> Result<String> {
        let path = input["path"]
            .as_str()
            .context("Missing required parameter 'path'")?;
        let content = input["content"]
            .as_str()
            .context("Missing required parameter 'content'")?;
        tracing::debug!("write_file: {}", path);

        if let Some(parent) = Path::new(path).parent()
            && !parent.as_os_str().is_empty()
        {
            fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create parent directories for: {}", path))?;
        }

        fs::write(path, content).with_context(|| format!("Failed to write file: {}", path))?;

        Ok(format!(
            "Successfully wrote {} bytes to {}",
            content.len(),
            path
        ))
    }
}

pub struct ListDirectoryTool;

impl Tool for ListDirectoryTool {
    fn name(&self) -> &str {
        "list_directory"
    }

    fn description(&self) -> &str {
        "List the contents of a directory."
    }

    fn input_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "The directory path to list"
                }
            },
            "required": ["path"]
        })
    }

    fn execute(&self, input: Value) -> Result<String> {
        let path = input["path"]
            .as_str()
            .context("Missing required parameter 'path'")?;
        tracing::debug!("list_directory: {}", path);

        let read_dir =
            fs::read_dir(path).with_context(|| format!("Failed to read directory: {}", path))?;

        let mut entries: Vec<String> = Vec::new();
        for entry in read_dir {
            let entry = entry.context("Failed to read directory entry")?;
            let metadata = entry.metadata().context("Failed to read entry metadata")?;
            let kind = if metadata.is_dir() { "dir" } else { "file" };
            let size = if metadata.is_file() {
                metadata.len()
            } else {
                0
            };
            let name = entry.file_name().to_string_lossy().to_string();

            #[cfg(unix)]
            let perms = {
                use std::os::unix::fs::PermissionsExt;
                format!("{:o}", metadata.permissions().mode() & 0o777)
            };
            #[cfg(not(unix))]
            let perms = String::from("---");

            entries.push(format!("{:<5}  {:>10}  {}  {}", kind, size, perms, name));
        }

        entries.sort();

        if entries.is_empty() {
            Ok(format!("Directory '{}' is empty.", path))
        } else {
            Ok(format!("Contents of '{}':\n{}", path, entries.join("\n")))
        }
    }
}

pub struct SearchFilesTool;

impl Tool for SearchFilesTool {
    fn name(&self) -> &str {
        "search_files"
    }

    fn description(&self) -> &str {
        "Search for a pattern in files within a directory. Returns matching lines with file paths and line numbers."
    }

    fn input_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "The directory to search in"
                },
                "pattern": {
                    "type": "string",
                    "description": "The text pattern to search for"
                },
                "file_pattern": {
                    "type": "string",
                    "description": "Optional glob pattern to filter files (e.g., '*.rs')"
                }
            },
            "required": ["path", "pattern"]
        })
    }

    fn execute(&self, input: Value) -> Result<String> {
        let path = input["path"]
            .as_str()
            .context("Missing required parameter 'path'")?;
        let pattern = input["pattern"]
            .as_str()
            .context("Missing required parameter 'pattern'")?;
        let file_pattern = input["file_pattern"].as_str().unwrap_or("");
        tracing::debug!("search_files: {} for '{}'", path, pattern);

        let mut results: Vec<String> = Vec::new();
        search_recursive(Path::new(path), pattern, file_pattern, &mut results, 50)?;

        if results.is_empty() {
            Ok(format!("No matches found for '{}' in '{}'.", pattern, path))
        } else {
            let truncated = results.len() >= 50;
            let mut output = results.join("\n");
            if truncated {
                output.push_str("\n... (output truncated at 50 matches)");
            }
            Ok(output)
        }
    }
}

fn search_recursive(
    dir: &Path,
    pattern: &str,
    file_pattern: &str,
    results: &mut Vec<String>,
    max: usize,
) -> Result<()> {
    if results.len() >= max {
        return Ok(());
    }

    let entries = match fs::read_dir(dir) {
        Ok(e) => e,
        Err(_) => return Ok(()),
    };

    for entry in entries {
        if results.len() >= max {
            return Ok(());
        }

        let entry = match entry {
            Ok(e) => e,
            Err(_) => continue,
        };

        let metadata = match entry.metadata() {
            Ok(m) => m,
            Err(_) => continue,
        };

        let path = entry.path();

        if metadata.is_dir() {
            let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
            if dir_name.starts_with('.') || dir_name == "target" || dir_name == "node_modules" {
                continue;
            }
            search_recursive(&path, pattern, file_pattern, results, max)?;
        } else if metadata.is_file() {
            let file_name = path
                .file_name()
                .unwrap_or_default()
                .to_string_lossy()
                .to_string();

            if !file_pattern.is_empty() && !matches_file_pattern(&file_name, file_pattern) {
                continue;
            }

            let content = match fs::read_to_string(&path) {
                Ok(c) => c,
                Err(_) => continue,
            };

            for (line_num, line) in content.lines().enumerate() {
                if results.len() >= max {
                    return Ok(());
                }
                if line.contains(pattern) {
                    results.push(format!(
                        "{}:{}: {}",
                        path.display(),
                        line_num + 1,
                        line.trim()
                    ));
                }
            }
        }
    }

    Ok(())
}

fn matches_file_pattern(filename: &str, pattern: &str) -> bool {
    if let Some(ext_pattern) = pattern.strip_prefix("*.") {
        filename.ends_with(&format!(".{}", ext_pattern))
    } else {
        filename == pattern
    }
}