Skip to main content

pawan/tools/
deagle.rs

1//! Deagle code intelligence tools — graph-backed symbol search.
2//!
3//! As of the Option B rewrite, pawan embeds `deagle-core` and
4//! `deagle-parse` as library dependencies instead of shelling out to
5//! the `deagle` binary. Users no longer need `cargo install deagle`;
6//! all five tools work out of the box after `cargo install pawan`.
7//!
8//! Structure:
9//! - `DeagleSearchTool` — `GraphDb::search_nodes` / `fuzzy_search_nodes` with kind filter
10//! - `DeagleKeywordTool` — `GraphDb::keyword_search` (FTS5 BM25 ranked)
11//! - `DeagleSgTool` — `deagle_parse::pattern::search_pattern` (ast-grep structural)
12//! - `DeagleStatsTool` — `GraphDb::node_count` / `edge_count`
13//! - `DeagleMapTool` — walks the workspace, parses with tree-sitter,
14//!   inserts into the graph (mirrors `deagle map` in deagle-cli)
15//!
16//! The graph database lives at `<workspace_root>/.deagle/graph.db` by
17//! default — same as the deagle CLI, so indexes built by the binary
18//! remain usable when users upgrade.
19
20use super::Tool;
21use async_trait::async_trait;
22use deagle_core::{Edge, EdgeKind, GraphDb, Language, Node, NodeKind};
23use serde_json::{json, Value};
24use std::path::{Path, PathBuf};
25
26/// Default graph database path relative to the workspace root.
27const GRAPH_DB_RELATIVE: &str = ".deagle/graph.db";
28
29/// Resolve the graph database path inside a workspace.
30fn graph_db_path(workspace_root: &Path) -> PathBuf {
31    workspace_root.join(GRAPH_DB_RELATIVE)
32}
33
34/// Open the graph DB, creating the parent dir if needed. Returns a
35/// friendly error when the DB doesn't exist yet.
36fn open_graph(workspace_root: &Path) -> crate::Result<GraphDb> {
37    let db_path = graph_db_path(workspace_root);
38    if let Some(parent) = db_path.parent() {
39        std::fs::create_dir_all(parent)
40            .map_err(|e| crate::PawanError::Tool(format!("create .deagle dir: {}", e)))?;
41    }
42    GraphDb::open(&db_path).map_err(|e| {
43        crate::PawanError::Tool(format!(
44            "failed to open deagle graph at {}: {}. Run deagle_map first.",
45            db_path.display(),
46            e
47        ))
48    })
49}
50
51/// Format a `Vec<Node>` as a deagle-cli-style text table so the LLM
52/// output matches what the subprocess version produced.
53fn format_nodes_table(nodes: &[Node]) -> String {
54    if nodes.is_empty() {
55        return String::from("No results.");
56    }
57    let mut out = String::new();
58    out.push_str(&format!(
59        "{:<30} {:<12} {:<10} LOCATION\n",
60        "NAME", "KIND", "LANG"
61    ));
62    out.push_str(&"-".repeat(80));
63    out.push('\n');
64    for node in nodes {
65        out.push_str(&format!(
66            "{:<30} {:<12} {:<10} {}:{}\n",
67            node.name, node.kind, node.language, node.file_path, node.line_start,
68        ));
69    }
70    out.push_str(&format!("\n{} result(s)\n", nodes.len()));
71    out
72}
73
74/// Parse a user-provided kind string into a NodeKind filter. Returns
75/// `None` if the string doesn't match any known kind (callers should
76/// treat `None` as "no filter" rather than erroring — keeps parity with
77/// the deagle CLI's permissive behavior).
78fn parse_kind_filter(s: &str) -> Option<NodeKind> {
79    match s.to_lowercase().as_str() {
80        "file" => Some(NodeKind::File),
81        "module" => Some(NodeKind::Module),
82        "function" => Some(NodeKind::Function),
83        "method" => Some(NodeKind::Method),
84        "class" => Some(NodeKind::Class),
85        "struct" => Some(NodeKind::Struct),
86        "enum" => Some(NodeKind::Enum),
87        "trait" => Some(NodeKind::Trait),
88        "interface" => Some(NodeKind::Interface),
89        "constant" => Some(NodeKind::Constant),
90        "variable" => Some(NodeKind::Variable),
91        "type_alias" | "typealias" => Some(NodeKind::TypeAlias),
92        "import" => Some(NodeKind::Import),
93        _ => None,
94    }
95}
96
97// ─── deagle search — graph symbol search ────────────────────────────────────
98
99/// Graph-backed symbol search by name with optional kind filter.
100pub struct DeagleSearchTool {
101    workspace_root: PathBuf,
102}
103
104impl DeagleSearchTool {
105    pub fn new(workspace_root: PathBuf) -> Self {
106        Self { workspace_root }
107    }
108}
109
110#[async_trait]
111impl Tool for DeagleSearchTool {
112    fn name(&self) -> &str {
113        "deagle_search"
114    }
115
116    fn description(&self) -> &str {
117        "Graph-backed symbol search via embedded deagle. Finds functions, structs, traits, \
118         classes, imports by name. Returns symbol kind, language, file path, and line number. \
119         Much more structured than grep — use when you need to find a specific symbol \
120         definition or check what kind of entity a name refers to. \
121         Supports fuzzy matching and kind filtering (function, struct, trait, class, import)."
122    }
123
124    fn parameters_schema(&self) -> Value {
125        json!({
126            "type": "object",
127            "properties": {
128                "query": { "type": "string", "description": "Symbol name to search for (empty string lists all)" },
129                "kind": { "type": "string", "description": "Filter by kind: function, struct, trait, class, import, file" },
130                "fuzzy": { "type": "boolean", "description": "Use fuzzy matching (default: false, exact substring)" },
131                "limit": { "type": "integer", "description": "Max results to return (default: 50)" }
132            },
133            "required": ["query"]
134        })
135    }
136
137    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
138        use thulp_core::{Parameter, ParameterType};
139        thulp_core::ToolDefinition::builder(self.name())
140            .description(self.description())
141            .parameter(
142                Parameter::builder("query")
143                    .param_type(ParameterType::String)
144                    .required(true)
145                    .description("Symbol name to search for (empty string lists all)")
146                    .build(),
147            )
148            .parameter(
149                Parameter::builder("kind")
150                    .param_type(ParameterType::String)
151                    .required(false)
152                    .description("Filter by kind: function, struct, trait, class, import, file")
153                    .build(),
154            )
155            .parameter(
156                Parameter::builder("fuzzy")
157                    .param_type(ParameterType::Boolean)
158                    .required(false)
159                    .description("Use fuzzy matching (default: false)")
160                    .build(),
161            )
162            .parameter(
163                Parameter::builder("limit")
164                    .param_type(ParameterType::Integer)
165                    .required(false)
166                    .description("Max results (default: 50)")
167                    .build(),
168            )
169            .build()
170    }
171
172    async fn execute(&self, args: Value) -> crate::Result<Value> {
173        let query = args["query"]
174            .as_str()
175            .ok_or_else(|| crate::PawanError::Tool("query required".into()))?;
176        let limit = args["limit"].as_u64().unwrap_or(50) as usize;
177        let fuzzy = args["fuzzy"].as_bool().unwrap_or(false);
178        let kind_filter = args["kind"].as_str().and_then(parse_kind_filter);
179
180        let db = open_graph(&self.workspace_root)?;
181        let mut nodes = if fuzzy {
182            db.fuzzy_search_nodes(query)
183                .map_err(|e| crate::PawanError::Tool(format!("deagle search: {}", e)))?
184        } else {
185            db.search_nodes(query)
186                .map_err(|e| crate::PawanError::Tool(format!("deagle search: {}", e)))?
187        };
188
189        if let Some(k) = kind_filter {
190            nodes.retain(|n| n.kind == k);
191        }
192        if nodes.len() > limit {
193            nodes.truncate(limit);
194        }
195
196        let match_count = nodes.len();
197        let results = format_nodes_table(&nodes);
198
199        Ok(json!({
200            "results": results,
201            "match_count": match_count,
202            "success": true,
203        }))
204    }
205}
206
207// ─── deagle keyword — FTS5 BM25 ranked search ──────────────────────────────
208
209/// Full-text keyword search with BM25 relevance ranking.
210pub struct DeagleKeywordTool {
211    workspace_root: PathBuf,
212}
213
214impl DeagleKeywordTool {
215    pub fn new(workspace_root: PathBuf) -> Self {
216        Self { workspace_root }
217    }
218}
219
220#[async_trait]
221impl Tool for DeagleKeywordTool {
222    fn name(&self) -> &str {
223        "deagle_keyword"
224    }
225
226    fn description(&self) -> &str {
227        "Full-text keyword search via embedded deagle with BM25 ranking (SQLite FTS5). \
228         Returns entities ranked by relevance to the query. \
229         Use when you need to find code related to a concept rather than a specific name — \
230         e.g. 'authentication logic' or 'error handling patterns'. \
231         More semantic than grep because it ranks by term frequency and inverse document frequency."
232    }
233
234    fn parameters_schema(&self) -> Value {
235        json!({
236            "type": "object",
237            "properties": {
238                "query": { "type": "string", "description": "Keyword query (supports phrases in quotes)" },
239                "limit": { "type": "integer", "description": "Max results (default: 20)" }
240            },
241            "required": ["query"]
242        })
243    }
244
245    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
246        use thulp_core::{Parameter, ParameterType};
247        thulp_core::ToolDefinition::builder(self.name())
248            .description(self.description())
249            .parameter(
250                Parameter::builder("query")
251                    .param_type(ParameterType::String)
252                    .required(true)
253                    .description("Keyword query")
254                    .build(),
255            )
256            .parameter(
257                Parameter::builder("limit")
258                    .param_type(ParameterType::Integer)
259                    .required(false)
260                    .description("Max results (default: 20)")
261                    .build(),
262            )
263            .build()
264    }
265
266    async fn execute(&self, args: Value) -> crate::Result<Value> {
267        let query = args["query"]
268            .as_str()
269            .ok_or_else(|| crate::PawanError::Tool("query required".into()))?;
270        let limit = args["limit"].as_u64().unwrap_or(20) as usize;
271
272        let db = open_graph(&self.workspace_root)?;
273        let mut nodes = db
274            .keyword_search(query)
275            .map_err(|e| crate::PawanError::Tool(format!("deagle keyword: {}", e)))?;
276        if nodes.len() > limit {
277            nodes.truncate(limit);
278        }
279
280        let results = format_nodes_table(&nodes);
281
282        Ok(json!({
283            "results": results,
284            "success": true,
285        }))
286    }
287}
288
289// ─── deagle sg — structural AST pattern search ─────────────────────────────
290
291/// Structural AST pattern search powered by ast-grep.
292pub struct DeagleSgTool {
293    workspace_root: PathBuf,
294}
295
296impl DeagleSgTool {
297    pub fn new(workspace_root: PathBuf) -> Self {
298        Self { workspace_root }
299    }
300}
301
302#[async_trait]
303impl Tool for DeagleSgTool {
304    fn name(&self) -> &str {
305        "deagle_sg"
306    }
307
308    fn description(&self) -> &str {
309        "AST-based structural pattern search via embedded deagle (ast-grep). \
310         Find code by structure, not by text. Use patterns like \
311         `impl $TYPE { $$$ }` to find all impl blocks, or \
312         `pub fn $NAME($$$) { $$$ }` to find all public functions. \
313         $VAR matches one node, $$$VAR matches multiple. \
314         Much more precise than regex for refactoring and code audits."
315    }
316
317    fn parameters_schema(&self) -> Value {
318        json!({
319            "type": "object",
320            "properties": {
321                "pattern": { "type": "string", "description": "AST pattern with $VAR metavariables" },
322                "lang": { "type": "string", "description": "Language: rust, python, go, typescript, javascript, java, c, cpp, ruby" },
323                "path": { "type": "string", "description": "Path to search (default: workspace root)" }
324            },
325            "required": ["pattern"]
326        })
327    }
328
329    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
330        use thulp_core::{Parameter, ParameterType};
331        thulp_core::ToolDefinition::builder(self.name())
332            .description(self.description())
333            .parameter(
334                Parameter::builder("pattern")
335                    .param_type(ParameterType::String)
336                    .required(true)
337                    .description("AST pattern with $VAR metavariables")
338                    .build(),
339            )
340            .parameter(
341                Parameter::builder("lang")
342                    .param_type(ParameterType::String)
343                    .required(false)
344                    .description("Language filter")
345                    .build(),
346            )
347            .parameter(
348                Parameter::builder("path")
349                    .param_type(ParameterType::String)
350                    .required(false)
351                    .description("Path to search")
352                    .build(),
353            )
354            .build()
355    }
356
357    async fn execute(&self, args: Value) -> crate::Result<Value> {
358        let pattern = args["pattern"]
359            .as_str()
360            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
361        let rel_dir = args["path"].as_str().unwrap_or(".");
362        let lang_filter = args["lang"]
363            .as_str()
364            .map(parse_language)
365            .filter(|l| *l != Language::Unknown);
366
367        let search_root = if rel_dir == "." {
368            self.workspace_root.clone()
369        } else {
370            self.workspace_root.join(rel_dir)
371        };
372
373        // Walk the tree, parse each source file, run the pattern.
374        // Mirrors cmd_grep in deagle-cli.
375        let walker = ignore::WalkBuilder::new(&search_root)
376            .hidden(true)
377            .git_ignore(true)
378            .git_exclude(true)
379            .build();
380
381        let mut output = String::new();
382        let mut total_matches = 0usize;
383
384        for entry in walker.flatten() {
385            let path = entry.path();
386            if !path.is_file() {
387                continue;
388            }
389            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
390            let file_lang = Language::from_extension(ext);
391            if file_lang == Language::Unknown {
392                continue;
393            }
394            if let Some(l) = lang_filter {
395                if file_lang != l {
396                    continue;
397                }
398            }
399
400            let content = match std::fs::read_to_string(path) {
401                Ok(c) if !c.is_empty() => c,
402                _ => continue,
403            };
404            let rel_path = path.strip_prefix(&self.workspace_root).unwrap_or(path);
405
406            if let Ok(matches) =
407                deagle_parse::pattern::search_pattern(rel_path, &content, pattern, file_lang)
408            {
409                for m in &matches {
410                    let first_line = m.text.lines().next().unwrap_or("");
411                    output.push_str(&format!(
412                        "{}:{}: {}\n",
413                        m.file_path, m.line_start, first_line
414                    ));
415                    total_matches += 1;
416                }
417            }
418        }
419
420        if total_matches == 0 {
421            output.push_str("No matches found.\n");
422        } else {
423            output.push_str(&format!("\n{} match(es)\n", total_matches));
424        }
425
426        Ok(json!({
427            "matches": output,
428            "match_count": total_matches,
429            "success": true,
430        }))
431    }
432}
433
434/// Parse a user-provided language string into a `deagle_core::Language`.
435/// Returns `Language::Unknown` for anything it can't resolve.
436/// deagle-core 0.1.5 supports 9 languages (added Ruby in this release).
437fn parse_language(s: &str) -> Language {
438    match s.to_lowercase().as_str() {
439        "rust" | "rs" => Language::Rust,
440        "python" | "py" => Language::Python,
441        "go" => Language::Go,
442        "typescript" | "ts" => Language::TypeScript,
443        "javascript" | "js" => Language::JavaScript,
444        "java" => Language::Java,
445        "cpp" | "c++" => Language::Cpp,
446        "c" => Language::C,
447        "ruby" | "rb" => Language::Ruby,
448        _ => Language::Unknown,
449    }
450}
451
452// ─── deagle stats — graph statistics ────────────────────────────────────────
453
454/// Graph database statistics — node/edge counts, size.
455pub struct DeagleStatsTool {
456    workspace_root: PathBuf,
457}
458
459impl DeagleStatsTool {
460    pub fn new(workspace_root: PathBuf) -> Self {
461        Self { workspace_root }
462    }
463}
464
465#[async_trait]
466impl Tool for DeagleStatsTool {
467    fn name(&self) -> &str {
468        "deagle_stats"
469    }
470
471    fn description(&self) -> &str {
472        "Show embedded deagle graph database statistics: total nodes, edges, database path. \
473         Use this to verify the codebase has been indexed, or to gauge codebase size \
474         before deeper analysis. Run `deagle_map` first if stats show empty graph."
475    }
476
477    fn parameters_schema(&self) -> Value {
478        json!({ "type": "object", "properties": {} })
479    }
480
481    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
482        thulp_core::ToolDefinition::builder(self.name())
483            .description(self.description())
484            .build()
485    }
486
487    async fn execute(&self, _args: Value) -> crate::Result<Value> {
488        let db_path = graph_db_path(&self.workspace_root);
489        // If the DB doesn't exist yet, return an empty-but-successful
490        // report rather than erroring — mirrors deagle stats behavior
491        // on unindexed dirs.
492        if !db_path.exists() {
493            return Ok(json!({
494                "stats": format!(
495                    "Database: {}\nNodes:    0\nEdges:    0\n(not yet indexed — run deagle_map first)",
496                    db_path.display()
497                ),
498                "success": true,
499            }));
500        }
501
502        let db = open_graph(&self.workspace_root)?;
503        let nodes = db
504            .node_count()
505            .map_err(|e| crate::PawanError::Tool(format!("deagle stats: {}", e)))?;
506        let edges = db
507            .edge_count()
508            .map_err(|e| crate::PawanError::Tool(format!("deagle stats: {}", e)))?;
509
510        Ok(json!({
511            "stats": format!(
512                "Database: {}\nNodes:    {}\nEdges:    {}",
513                db_path.display(), nodes, edges
514            ),
515            "success": true,
516        }))
517    }
518}
519
520// ─── deagle map — index/reindex codebase ────────────────────────────────────
521
522/// Index a codebase into the deagle graph database.
523pub struct DeagleMapTool {
524    workspace_root: PathBuf,
525}
526
527impl DeagleMapTool {
528    pub fn new(workspace_root: PathBuf) -> Self {
529        Self { workspace_root }
530    }
531}
532
533#[async_trait]
534impl Tool for DeagleMapTool {
535    fn name(&self) -> &str {
536        "deagle_map"
537    }
538
539    fn description(&self) -> &str {
540        "Index or re-index a codebase into the embedded deagle graph database. \
541         Uses tree-sitter parsers for 9 languages (Rust, Python, Go, TypeScript, JavaScript, Java, C, C++, Ruby). \
542         Incremental — only re-parses changed files (SHA-256 hash detection). \
543         Run once to bootstrap, then again after significant code changes. \
544         Required before `deagle_search`, `deagle_keyword`, `deagle_sg` work."
545    }
546
547    fn parameters_schema(&self) -> Value {
548        json!({
549            "type": "object",
550            "properties": {
551                "path": { "type": "string", "description": "Path to index (default: workspace root)" }
552            }
553        })
554    }
555
556    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
557        use thulp_core::{Parameter, ParameterType};
558        thulp_core::ToolDefinition::builder(self.name())
559            .description(self.description())
560            .parameter(
561                Parameter::builder("path")
562                    .param_type(ParameterType::String)
563                    .required(false)
564                    .description("Path to index (default: workspace root)")
565                    .build(),
566            )
567            .build()
568    }
569
570    async fn execute(&self, args: Value) -> crate::Result<Value> {
571        let rel_dir = args["path"].as_str().unwrap_or(".");
572        let index_root = if rel_dir == "." {
573            self.workspace_root.clone()
574        } else {
575            self.workspace_root.join(rel_dir)
576        };
577
578        let workspace_root = self.workspace_root.clone();
579        // Run the CPU-bound walk+parse+insert on a blocking thread so it
580        // doesn't stall the tokio runtime.
581        let result = tokio::task::spawn_blocking(move || map_directory(&workspace_root, &index_root))
582            .await
583            .map_err(|e| crate::PawanError::Tool(format!("deagle map join: {}", e)))??;
584
585        Ok(json!({
586            "output": result,
587            "success": true,
588        }))
589    }
590}
591
592/// Perform the full incremental map: walk, parse, insert. Returns a
593/// human-readable summary string. Called from a blocking tokio thread.
594fn map_directory(workspace_root: &Path, dir: &Path) -> crate::Result<String> {
595    use rayon::prelude::*;
596
597    let db_path = graph_db_path(workspace_root);
598    if let Some(parent) = db_path.parent() {
599        std::fs::create_dir_all(parent)
600            .map_err(|e| crate::PawanError::Tool(format!("create .deagle: {}", e)))?;
601    }
602    let db = GraphDb::open(&db_path)
603        .map_err(|e| crate::PawanError::Tool(format!("open graph: {}", e)))?;
604
605    // Collect files (ignore-aware)
606    let files: Vec<_> = ignore::WalkBuilder::new(dir)
607        .hidden(true)
608        .git_ignore(true)
609        .git_global(true)
610        .git_exclude(true)
611        .build()
612        .flatten()
613        .filter(|e| e.path().is_file())
614        .filter(|e| {
615            let ext = e.path().extension().and_then(|x| x.to_str()).unwrap_or("");
616            Language::from_extension(ext) != Language::Unknown
617        })
618        .collect();
619
620    // Check hashes sequentially (SQLite not thread-safe)
621    let files_to_parse: Vec<_> = files
622        .iter()
623        .filter(|entry| {
624            let path = entry.path();
625            let rel_path = path.strip_prefix(dir).unwrap_or(path);
626            let rel_str = rel_path.to_string_lossy();
627            let content = match std::fs::read_to_string(path) {
628                Ok(c) if !c.is_empty() => c,
629                _ => return false,
630            };
631            db.needs_reindex(&rel_str, &content).unwrap_or(true)
632        })
633        .collect();
634
635    // Parse in parallel
636    let results: Vec<_> = files_to_parse
637        .par_iter()
638        .filter_map(|entry| {
639            let path = entry.path();
640            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
641            let lang = Language::from_extension(ext);
642            let content = std::fs::read_to_string(path).ok()?;
643            if content.is_empty() {
644                return None;
645            }
646            let rel_path = path.strip_prefix(dir).unwrap_or(path);
647            let rel_str = rel_path.to_string_lossy().to_string();
648            deagle_parse::parse_file_with_edges(rel_path, &content, lang)
649                .ok()
650                .map(|r| (rel_str, content, r))
651        })
652        .collect();
653
654    // Insert (single thread — SQLite constraint)
655    let mut file_count = 0usize;
656    let mut node_count = 0usize;
657    let mut edge_count = 0usize;
658
659    for (rel_path, content, result) in &results {
660        if result.nodes.is_empty() {
661            continue;
662        }
663        let _ = db.remove_file(rel_path);
664        file_count += 1;
665        node_count += result.nodes.len();
666
667        let db_ids = match db.insert_batch(&result.nodes, &[]) {
668            Ok(ids) => ids,
669            Err(_) => continue,
670        };
671        let _ = db.store_file_hash(rel_path, content);
672
673        let resolved_edges: Vec<(i64, i64, EdgeKind)> = result
674            .edges
675            .iter()
676            .filter(|(from_idx, to_idx, _)| {
677                *from_idx < db_ids.len()
678                    && *to_idx < db_ids.len()
679                    && db_ids[*from_idx] > 0
680                    && db_ids[*to_idx] > 0
681            })
682            .map(|(from_idx, to_idx, kind)| (db_ids[*from_idx], db_ids[*to_idx], *kind))
683            .collect();
684        edge_count += resolved_edges.len();
685
686        for (from_id, to_id, kind) in &resolved_edges {
687            let _ = db.insert_edge(&Edge {
688                from_id: *from_id,
689                to_id: *to_id,
690                kind: *kind,
691                confidence: 1.0,
692            });
693        }
694    }
695
696    let total_files = files.len();
697    let skipped = total_files.saturating_sub(file_count);
698    let summary = if skipped > 0 {
699        format!(
700            "Indexed {} files ({} unchanged), {} entities, {} edges\nDatabase: {}",
701            file_count,
702            skipped,
703            node_count,
704            edge_count,
705            db_path.display()
706        )
707    } else {
708        format!(
709            "Indexed {} files, {} entities, {} edges\nDatabase: {}",
710            file_count,
711            node_count,
712            edge_count,
713            db_path.display()
714        )
715    };
716    Ok(summary)
717}
718
719// ─── Tests ──────────────────────────────────────────────────────────────────
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn test_deagle_search_tool_metadata() {
727        let tool = DeagleSearchTool::new(PathBuf::from("."));
728        assert_eq!(tool.name(), "deagle_search");
729        assert!(tool.description().contains("symbol search"));
730        let schema = tool.parameters_schema();
731        assert!(schema["properties"]["query"].is_object());
732    }
733
734    #[test]
735    fn test_deagle_keyword_tool_metadata() {
736        let tool = DeagleKeywordTool::new(PathBuf::from("."));
737        assert_eq!(tool.name(), "deagle_keyword");
738        assert!(tool.description().contains("BM25"));
739    }
740
741    #[test]
742    fn test_deagle_sg_tool_metadata() {
743        let tool = DeagleSgTool::new(PathBuf::from("."));
744        assert_eq!(tool.name(), "deagle_sg");
745        assert!(tool.description().contains("AST"));
746    }
747
748    #[test]
749    fn test_deagle_stats_tool_metadata() {
750        let tool = DeagleStatsTool::new(PathBuf::from("."));
751        assert_eq!(tool.name(), "deagle_stats");
752    }
753
754    #[test]
755    fn test_deagle_map_tool_metadata() {
756        let tool = DeagleMapTool::new(PathBuf::from("."));
757        assert_eq!(tool.name(), "deagle_map");
758        assert!(tool.description().contains("tree-sitter"));
759    }
760
761    #[test]
762    fn test_thulp_definitions() {
763        let tools: Vec<Box<dyn Tool>> = vec![
764            Box::new(DeagleSearchTool::new(PathBuf::from("."))),
765            Box::new(DeagleKeywordTool::new(PathBuf::from("."))),
766            Box::new(DeagleSgTool::new(PathBuf::from("."))),
767            Box::new(DeagleStatsTool::new(PathBuf::from("."))),
768            Box::new(DeagleMapTool::new(PathBuf::from("."))),
769        ];
770        for tool in tools {
771            let def = tool.thulp_definition();
772            assert_eq!(def.name, tool.name());
773            assert!(!def.description.is_empty());
774        }
775    }
776
777    #[test]
778    fn test_deagle_tool_names_are_unique() {
779        let tools: Vec<Box<dyn Tool>> = vec![
780            Box::new(DeagleSearchTool::new(PathBuf::from("."))),
781            Box::new(DeagleKeywordTool::new(PathBuf::from("."))),
782            Box::new(DeagleSgTool::new(PathBuf::from("."))),
783            Box::new(DeagleStatsTool::new(PathBuf::from("."))),
784            Box::new(DeagleMapTool::new(PathBuf::from("."))),
785        ];
786        let names: std::collections::HashSet<String> =
787            tools.iter().map(|t| t.name().to_string()).collect();
788        assert_eq!(names.len(), 5);
789        for expected in &["deagle_search", "deagle_keyword", "deagle_sg", "deagle_stats", "deagle_map"] {
790            assert!(names.contains(*expected), "missing {}", expected);
791        }
792    }
793
794    #[test]
795    fn test_deagle_search_schema_required_query() {
796        let tool = DeagleSearchTool::new(PathBuf::from("."));
797        let schema = tool.parameters_schema();
798        let required = schema["required"].as_array().unwrap();
799        assert!(required.iter().any(|v| v == "query"));
800        let props = schema["properties"].as_object().unwrap();
801        assert!(props.contains_key("query"));
802        assert!(props.contains_key("kind"));
803        assert!(props.contains_key("fuzzy"));
804        assert!(props.contains_key("limit"));
805    }
806
807    #[test]
808    fn test_deagle_sg_schema_required_pattern() {
809        let tool = DeagleSgTool::new(PathBuf::from("."));
810        let schema = tool.parameters_schema();
811        let required = schema["required"].as_array().unwrap();
812        assert!(required.iter().any(|v| v == "pattern"));
813        let props = schema["properties"].as_object().unwrap();
814        assert!(props.contains_key("pattern"));
815        assert!(props.contains_key("lang"));
816        assert!(props.contains_key("path"));
817    }
818
819    #[tokio::test]
820    async fn test_deagle_search_missing_query_errors() {
821        let tool = DeagleSearchTool::new(PathBuf::from("."));
822        let result = tool.execute(serde_json::json!({})).await;
823        assert!(result.is_err());
824        let err = format!("{}", result.unwrap_err());
825        assert!(err.contains("query"));
826    }
827
828    #[tokio::test]
829    async fn test_deagle_keyword_missing_query_errors() {
830        let tool = DeagleKeywordTool::new(PathBuf::from("."));
831        let result = tool.execute(serde_json::json!({})).await;
832        assert!(result.is_err());
833        let err = format!("{}", result.unwrap_err());
834        assert!(err.contains("query"));
835    }
836
837    #[tokio::test]
838    async fn test_deagle_sg_missing_pattern_errors() {
839        let tool = DeagleSgTool::new(PathBuf::from("."));
840        let result = tool.execute(serde_json::json!({})).await;
841        assert!(result.is_err());
842        let err = format!("{}", result.unwrap_err());
843        assert!(err.contains("pattern"));
844    }
845
846    #[tokio::test]
847    async fn test_deagle_search_query_wrong_type_errors() {
848        let tool = DeagleSearchTool::new(PathBuf::from("."));
849        let result = tool.execute(serde_json::json!({"query": 42})).await;
850        assert!(result.is_err());
851    }
852
853    #[test]
854    fn test_deagle_keyword_schema_required_query() {
855        let tool = DeagleKeywordTool::new(PathBuf::from("."));
856        let schema = tool.parameters_schema();
857        let required = schema["required"].as_array().unwrap();
858        assert!(required.iter().any(|v| v == "query"));
859    }
860
861    #[test]
862    fn test_deagle_stats_schema_has_no_properties() {
863        let tool = DeagleStatsTool::new(PathBuf::from("."));
864        let schema = tool.parameters_schema();
865        let props = schema["properties"].as_object().unwrap();
866        assert!(props.is_empty());
867    }
868
869    #[test]
870    fn test_deagle_map_schema_has_no_required() {
871        let tool = DeagleMapTool::new(PathBuf::from("."));
872        let schema = tool.parameters_schema();
873        let has_required = schema.get("required").is_some_and(|r| {
874            r.as_array().map(|a| !a.is_empty()).unwrap_or(false)
875        });
876        assert!(!has_required);
877    }
878
879    #[test]
880    fn test_parse_kind_filter_known_kinds() {
881        // These must cover every variant of NodeKind so user queries
882        // like --kind function actually filter. Case-insensitive.
883        assert_eq!(parse_kind_filter("function"), Some(NodeKind::Function));
884        assert_eq!(parse_kind_filter("FUNCTION"), Some(NodeKind::Function));
885        assert_eq!(parse_kind_filter("struct"), Some(NodeKind::Struct));
886        assert_eq!(parse_kind_filter("trait"), Some(NodeKind::Trait));
887        assert_eq!(parse_kind_filter("class"), Some(NodeKind::Class));
888        assert_eq!(parse_kind_filter("import"), Some(NodeKind::Import));
889        assert_eq!(parse_kind_filter("file"), Some(NodeKind::File));
890        assert_eq!(parse_kind_filter("type_alias"), Some(NodeKind::TypeAlias));
891        assert_eq!(parse_kind_filter("typealias"), Some(NodeKind::TypeAlias));
892    }
893
894    #[test]
895    fn test_parse_kind_filter_unknown_returns_none() {
896        // Unknown kinds must return None (= no filter), not error —
897        // matches the permissive behavior of deagle-cli.
898        assert_eq!(parse_kind_filter("garbage"), None);
899        assert_eq!(parse_kind_filter(""), None);
900    }
901
902    #[test]
903    fn test_parse_language_covers_all_supported() {
904        // deagle-core 0.1.5 supports 9 languages + Unknown fallback
905        assert_eq!(parse_language("rust"), Language::Rust);
906        assert_eq!(parse_language("rs"), Language::Rust);
907        assert_eq!(parse_language("python"), Language::Python);
908        assert_eq!(parse_language("py"), Language::Python);
909        assert_eq!(parse_language("go"), Language::Go);
910        assert_eq!(parse_language("typescript"), Language::TypeScript);
911        assert_eq!(parse_language("ts"), Language::TypeScript);
912        assert_eq!(parse_language("javascript"), Language::JavaScript);
913        assert_eq!(parse_language("java"), Language::Java);
914        assert_eq!(parse_language("cpp"), Language::Cpp);
915        assert_eq!(parse_language("c++"), Language::Cpp);
916        assert_eq!(parse_language("c"), Language::C);
917        assert_eq!(parse_language("ruby"), Language::Ruby);
918        assert_eq!(parse_language("rb"), Language::Ruby);
919        assert_eq!(parse_language("unknown-lang"), Language::Unknown);
920    }
921
922    #[test]
923    fn test_format_nodes_table_empty() {
924        assert_eq!(format_nodes_table(&[]), "No results.");
925    }
926
927    #[test]
928    fn test_format_nodes_table_includes_headers_and_counts() {
929        let nodes = vec![Node {
930            id: 1,
931            name: "my_fn".into(),
932            kind: NodeKind::Function,
933            language: Language::Rust,
934            file_path: "src/lib.rs".into(),
935            line_start: 42,
936            line_end: 50,
937            content: None,
938        }];
939        let formatted = format_nodes_table(&nodes);
940        assert!(formatted.contains("NAME"));
941        assert!(formatted.contains("KIND"));
942        assert!(formatted.contains("LOCATION"));
943        assert!(formatted.contains("my_fn"));
944        assert!(formatted.contains("function"));
945        assert!(formatted.contains("src/lib.rs:42"));
946        assert!(formatted.contains("1 result(s)"));
947    }
948
949    #[test]
950    fn test_graph_db_path_is_under_workspace() {
951        let root = PathBuf::from("/tmp/test-workspace");
952        let path = graph_db_path(&root);
953        assert_eq!(path, PathBuf::from("/tmp/test-workspace/.deagle/graph.db"));
954    }
955
956    #[tokio::test]
957    async fn test_deagle_stats_on_empty_workspace_is_non_fatal() {
958        // Unindexed workspace → stats must NOT error. It should return a
959        // "0 nodes, 0 edges" placeholder with a hint.
960        let tmp = tempfile::TempDir::new().unwrap();
961        let tool = DeagleStatsTool::new(tmp.path().to_path_buf());
962        let result = tool.execute(serde_json::json!({})).await.unwrap();
963        let stats = result["stats"].as_str().unwrap();
964        assert!(stats.contains("Nodes:"));
965        assert!(stats.contains("0"));
966    }
967}