Skip to main content

boost/tools/
docs.rs

1//! `search-docs` — grep `docs/` for matching markdown files, return matches in
2//! context.
3
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use walkdir::WalkDir;
7
8use crate::protocol::CallToolResult;
9use crate::tool::{Context, Tool};
10
11pub struct SearchDocs;
12
13#[async_trait]
14impl Tool for SearchDocs {
15    fn name(&self) -> &'static str {
16        "search-docs"
17    }
18    fn description(&self) -> &'static str {
19        "Search the project's `docs/` directory for a query string. Returns matching file paths plus a short snippet around each match."
20    }
21    fn input_schema(&self) -> Value {
22        json!({
23            "type": "object",
24            "required": ["query"],
25            "properties": {
26                "query": { "type": "string", "description": "Plain substring (case-insensitive)." },
27                "limit": { "type": "integer", "description": "Max matches to return.", "default": 20 }
28            }
29        })
30    }
31
32    async fn call(&self, ctx: &Context, args: Value) -> CallToolResult {
33        let query = match args.get("query").and_then(|v| v.as_str()) {
34            Some(q) if !q.is_empty() => q.to_string(),
35            _ => return CallToolResult::error("`query` is required"),
36        };
37        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
38        let lower = query.to_ascii_lowercase();
39
40        let docs_dir = ctx.project_root.join("docs");
41        if !docs_dir.exists() {
42            return CallToolResult::json(&json!({
43                "matches": [],
44                "note": format!("docs directory not found at {}", docs_dir.display()),
45            }));
46        }
47
48        let mut matches = Vec::new();
49        'outer: for entry in WalkDir::new(&docs_dir).into_iter().filter_map(|e| e.ok()) {
50            if !entry.file_type().is_file() {
51                continue;
52            }
53            let path = entry.path();
54            if path.extension().and_then(|e| e.to_str()) != Some("md") {
55                continue;
56            }
57            let Ok(content) = std::fs::read_to_string(path) else {
58                continue;
59            };
60            let content_lower = content.to_ascii_lowercase();
61            for (line_idx, line) in content.lines().enumerate() {
62                if line.to_ascii_lowercase().contains(&lower) {
63                    matches.push(json!({
64                        "file": path.strip_prefix(&ctx.project_root).unwrap_or(path).display().to_string(),
65                        "line": line_idx + 1,
66                        "snippet": line.trim().chars().take(200).collect::<String>(),
67                    }));
68                    if matches.len() >= limit {
69                        break 'outer;
70                    }
71                }
72            }
73            let _ = content_lower;
74        }
75        CallToolResult::json(&json!({
76            "query": query,
77            "count": matches.len(),
78            "matches": matches,
79        }))
80    }
81}