1use 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}