1use 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
26const GRAPH_DB_RELATIVE: &str = ".deagle/graph.db";
28
29fn graph_db_path(workspace_root: &Path) -> PathBuf {
31 workspace_root.join(GRAPH_DB_RELATIVE)
32}
33
34fn 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
51fn 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
74fn 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
97pub 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
207pub 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
289pub 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 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
434fn 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
452pub 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 !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
520pub 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 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
592fn 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 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 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 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 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#[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 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 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 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 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}