albatross-cli 2.1.0

Open source, terminal-first AI coding agent with fully transparent multi-model routing. Local (Ollama, LM Studio, MLX, llama.cpp) or cloud, your keys, one TUI.
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use std::path::Path;

use crate::config::AgentConfig;
use crate::project_memory::{add_snippets, load_project_index, search_index};

use super::Tool;

pub struct RepoSearchTool {
    pub config: AgentConfig,
}

#[derive(Deserialize)]
struct Args {
    query: String,
    #[serde(default)]
    limit: Option<usize>,
}

#[async_trait]
impl Tool for RepoSearchTool {
    fn name(&self) -> &'static str {
        "repo_search"
    }

    fn description(&self) -> &'static str {
        "Search the local project memory index for relevant files, symbols, headings, imports, and short snippets. Run /index first if the index is missing."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "query": { "type": "string", "description": "Repo/code question or keywords to search for" },
                "limit": { "type": "integer", "minimum": 1, "maximum": 20, "description": "Maximum hits to return" }
            },
            "required": ["query"]
        })
    }

    async fn execute(&self, args: Value) -> Value {
        if !self.config.project_memory.enabled {
            return json!({ "error": "project memory is disabled. Run /memory on to enable it." });
        }
        let args: Args = match serde_json::from_value(args) {
            Ok(args) => args,
            Err(e) => return json!({ "error": format!("invalid args: {e}") }),
        };
        let index = match load_project_index(&self.config) {
            Ok(Some(index)) => index,
            Ok(None) => {
                return json!({ "error": "project memory index not found. Run /index first." })
            }
            Err(e) => return json!({ "error": e.to_string() }),
        };
        let limit = args.limit.unwrap_or(8).clamp(1, 20);
        let mut hits = search_index(&index, &args.query, limit);
        add_snippets(Path::new(&index.workspace_root), &args.query, &mut hits);
        json!({
            "query": args.query,
            "count": hits.len(),
            "workspaceRoot": index.workspace_root,
            "hits": hits,
        })
    }
}

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

    #[tokio::test]
    async fn searches_project_memory_index_with_snippets() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join("src")).unwrap();
        std::fs::write(
            dir.path().join("src/commands.rs"),
            "pub fn dispatch() {\n    println!(\"slash command\");\n}\n",
        )
        .unwrap();
        let config = AgentConfig {
            workspace_root: dir.path().display().to_string(),
            session_dir: dir.path().join(".sessions").display().to_string(),
            ..Default::default()
        };
        build_project_index(&config).unwrap();

        let result = RepoSearchTool { config }
            .execute(json!({ "query": "dispatch command", "limit": 3 }))
            .await;

        assert_eq!(result["count"].as_u64(), Some(1));
        assert_eq!(result["hits"][0]["path"].as_str(), Some("src/commands.rs"));
        assert!(result["hits"][0]["snippet"]
            .as_str()
            .unwrap()
            .contains("dispatch"));
    }
}