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 =
582 tokio::task::spawn_blocking(move || map_directory(&workspace_root, &index_root))
583 .await
584 .map_err(|e| crate::PawanError::Tool(format!("deagle map join: {}", e)))??;
585
586 Ok(json!({
587 "output": result,
588 "success": true,
589 }))
590 }
591}
592
593fn map_directory(workspace_root: &Path, dir: &Path) -> crate::Result<String> {
596 use rayon::prelude::*;
597
598 let db_path = graph_db_path(workspace_root);
599 if let Some(parent) = db_path.parent() {
600 std::fs::create_dir_all(parent)
601 .map_err(|e| crate::PawanError::Tool(format!("create .deagle: {}", e)))?;
602 }
603 let db = GraphDb::open(&db_path)
604 .map_err(|e| crate::PawanError::Tool(format!("open graph: {}", e)))?;
605
606 let files: Vec<_> = ignore::WalkBuilder::new(dir)
608 .hidden(true)
609 .git_ignore(true)
610 .git_global(true)
611 .git_exclude(true)
612 .build()
613 .flatten()
614 .filter(|e| e.path().is_file())
615 .filter(|e| {
616 let ext = e.path().extension().and_then(|x| x.to_str()).unwrap_or("");
617 Language::from_extension(ext) != Language::Unknown
618 })
619 .collect();
620
621 let files_to_parse: Vec<_> = files
623 .iter()
624 .filter(|entry| {
625 let path = entry.path();
626 let rel_path = path.strip_prefix(dir).unwrap_or(path);
627 let rel_str = rel_path.to_string_lossy();
628 let content = match std::fs::read_to_string(path) {
629 Ok(c) if !c.is_empty() => c,
630 _ => return false,
631 };
632 db.needs_reindex(&rel_str, &content).unwrap_or(true)
633 })
634 .collect();
635
636 let results: Vec<_> = files_to_parse
638 .par_iter()
639 .filter_map(|entry| {
640 let path = entry.path();
641 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
642 let lang = Language::from_extension(ext);
643 let content = std::fs::read_to_string(path).ok()?;
644 if content.is_empty() {
645 return None;
646 }
647 let rel_path = path.strip_prefix(dir).unwrap_or(path);
648 let rel_str = rel_path.to_string_lossy().to_string();
649 deagle_parse::parse_file_with_edges(rel_path, &content, lang)
650 .ok()
651 .map(|r| (rel_str, content, r))
652 })
653 .collect();
654
655 let mut file_count = 0usize;
657 let mut node_count = 0usize;
658 let mut edge_count = 0usize;
659
660 for (rel_path, content, result) in &results {
661 if result.nodes.is_empty() {
662 continue;
663 }
664 let _ = db.remove_file(rel_path);
665 file_count += 1;
666 node_count += result.nodes.len();
667
668 let db_ids = match db.insert_batch(&result.nodes, &[]) {
669 Ok(ids) => ids,
670 Err(_) => continue,
671 };
672 let _ = db.store_file_hash(rel_path, content);
673
674 let resolved_edges: Vec<(i64, i64, EdgeKind)> = result
675 .edges
676 .iter()
677 .filter(|(from_idx, to_idx, _)| {
678 *from_idx < db_ids.len()
679 && *to_idx < db_ids.len()
680 && db_ids[*from_idx] > 0
681 && db_ids[*to_idx] > 0
682 })
683 .map(|(from_idx, to_idx, kind)| (db_ids[*from_idx], db_ids[*to_idx], *kind))
684 .collect();
685 edge_count += resolved_edges.len();
686
687 for (from_id, to_id, kind) in &resolved_edges {
688 let _ = db.insert_edge(&Edge {
689 from_id: *from_id,
690 to_id: *to_id,
691 kind: *kind,
692 confidence: 1.0,
693 });
694 }
695 }
696
697 let total_files = files.len();
698 let skipped = total_files.saturating_sub(file_count);
699 let summary = if skipped > 0 {
700 format!(
701 "Indexed {} files ({} unchanged), {} entities, {} edges\nDatabase: {}",
702 file_count,
703 skipped,
704 node_count,
705 edge_count,
706 db_path.display()
707 )
708 } else {
709 format!(
710 "Indexed {} files, {} entities, {} edges\nDatabase: {}",
711 file_count,
712 node_count,
713 edge_count,
714 db_path.display()
715 )
716 };
717 Ok(summary)
718}
719
720#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
727 fn test_deagle_search_tool_metadata() {
728 let tool = DeagleSearchTool::new(PathBuf::from("."));
729 assert_eq!(tool.name(), "deagle_search");
730 assert!(tool.description().contains("symbol search"));
731 let schema = tool.parameters_schema();
732 assert!(schema["properties"]["query"].is_object());
733 }
734
735 #[test]
736 fn test_deagle_keyword_tool_metadata() {
737 let tool = DeagleKeywordTool::new(PathBuf::from("."));
738 assert_eq!(tool.name(), "deagle_keyword");
739 assert!(tool.description().contains("BM25"));
740 }
741
742 #[test]
743 fn test_deagle_sg_tool_metadata() {
744 let tool = DeagleSgTool::new(PathBuf::from("."));
745 assert_eq!(tool.name(), "deagle_sg");
746 assert!(tool.description().contains("AST"));
747 }
748
749 #[test]
750 fn test_deagle_stats_tool_metadata() {
751 let tool = DeagleStatsTool::new(PathBuf::from("."));
752 assert_eq!(tool.name(), "deagle_stats");
753 }
754
755 #[test]
756 fn test_deagle_map_tool_metadata() {
757 let tool = DeagleMapTool::new(PathBuf::from("."));
758 assert_eq!(tool.name(), "deagle_map");
759 assert!(tool.description().contains("tree-sitter"));
760 }
761
762 #[test]
763 fn test_thulp_definitions() {
764 let tools: Vec<Box<dyn Tool>> = vec![
765 Box::new(DeagleSearchTool::new(PathBuf::from("."))),
766 Box::new(DeagleKeywordTool::new(PathBuf::from("."))),
767 Box::new(DeagleSgTool::new(PathBuf::from("."))),
768 Box::new(DeagleStatsTool::new(PathBuf::from("."))),
769 Box::new(DeagleMapTool::new(PathBuf::from("."))),
770 ];
771 for tool in tools {
772 let def = tool.thulp_definition();
773 assert_eq!(def.name, tool.name());
774 assert!(!def.description.is_empty());
775 }
776 }
777
778 #[test]
779 fn test_deagle_tool_names_are_unique() {
780 let tools: Vec<Box<dyn Tool>> = vec![
781 Box::new(DeagleSearchTool::new(PathBuf::from("."))),
782 Box::new(DeagleKeywordTool::new(PathBuf::from("."))),
783 Box::new(DeagleSgTool::new(PathBuf::from("."))),
784 Box::new(DeagleStatsTool::new(PathBuf::from("."))),
785 Box::new(DeagleMapTool::new(PathBuf::from("."))),
786 ];
787 let names: std::collections::HashSet<String> =
788 tools.iter().map(|t| t.name().to_string()).collect();
789 assert_eq!(names.len(), 5);
790 for expected in &[
791 "deagle_search",
792 "deagle_keyword",
793 "deagle_sg",
794 "deagle_stats",
795 "deagle_map",
796 ] {
797 assert!(names.contains(*expected), "missing {}", expected);
798 }
799 }
800
801 #[test]
802 fn test_deagle_search_schema_required_query() {
803 let tool = DeagleSearchTool::new(PathBuf::from("."));
804 let schema = tool.parameters_schema();
805 let required = schema["required"].as_array().unwrap();
806 assert!(required.iter().any(|v| v == "query"));
807 let props = schema["properties"].as_object().unwrap();
808 assert!(props.contains_key("query"));
809 assert!(props.contains_key("kind"));
810 assert!(props.contains_key("fuzzy"));
811 assert!(props.contains_key("limit"));
812 }
813
814 #[test]
815 fn test_deagle_sg_schema_required_pattern() {
816 let tool = DeagleSgTool::new(PathBuf::from("."));
817 let schema = tool.parameters_schema();
818 let required = schema["required"].as_array().unwrap();
819 assert!(required.iter().any(|v| v == "pattern"));
820 let props = schema["properties"].as_object().unwrap();
821 assert!(props.contains_key("pattern"));
822 assert!(props.contains_key("lang"));
823 assert!(props.contains_key("path"));
824 }
825
826 #[tokio::test]
827 async fn test_deagle_search_missing_query_errors() {
828 let tool = DeagleSearchTool::new(PathBuf::from("."));
829 let result = tool.execute(serde_json::json!({})).await;
830 assert!(result.is_err());
831 let err = format!("{}", result.unwrap_err());
832 assert!(err.contains("query"));
833 }
834
835 #[tokio::test]
836 async fn test_deagle_keyword_missing_query_errors() {
837 let tool = DeagleKeywordTool::new(PathBuf::from("."));
838 let result = tool.execute(serde_json::json!({})).await;
839 assert!(result.is_err());
840 let err = format!("{}", result.unwrap_err());
841 assert!(err.contains("query"));
842 }
843
844 #[tokio::test]
845 async fn test_deagle_sg_missing_pattern_errors() {
846 let tool = DeagleSgTool::new(PathBuf::from("."));
847 let result = tool.execute(serde_json::json!({})).await;
848 assert!(result.is_err());
849 let err = format!("{}", result.unwrap_err());
850 assert!(err.contains("pattern"));
851 }
852
853 #[tokio::test]
854 async fn test_deagle_search_query_wrong_type_errors() {
855 let tool = DeagleSearchTool::new(PathBuf::from("."));
856 let result = tool.execute(serde_json::json!({"query": 42})).await;
857 assert!(result.is_err());
858 }
859
860 #[test]
861 fn test_deagle_keyword_schema_required_query() {
862 let tool = DeagleKeywordTool::new(PathBuf::from("."));
863 let schema = tool.parameters_schema();
864 let required = schema["required"].as_array().unwrap();
865 assert!(required.iter().any(|v| v == "query"));
866 }
867
868 #[test]
869 fn test_deagle_stats_schema_has_no_properties() {
870 let tool = DeagleStatsTool::new(PathBuf::from("."));
871 let schema = tool.parameters_schema();
872 let props = schema["properties"].as_object().unwrap();
873 assert!(props.is_empty());
874 }
875
876 #[test]
877 fn test_deagle_map_schema_has_no_required() {
878 let tool = DeagleMapTool::new(PathBuf::from("."));
879 let schema = tool.parameters_schema();
880 let has_required = schema
881 .get("required")
882 .is_some_and(|r| r.as_array().map(|a| !a.is_empty()).unwrap_or(false));
883 assert!(!has_required);
884 }
885
886 #[test]
887 fn test_parse_kind_filter_known_kinds() {
888 assert_eq!(parse_kind_filter("function"), Some(NodeKind::Function));
891 assert_eq!(parse_kind_filter("FUNCTION"), Some(NodeKind::Function));
892 assert_eq!(parse_kind_filter("struct"), Some(NodeKind::Struct));
893 assert_eq!(parse_kind_filter("trait"), Some(NodeKind::Trait));
894 assert_eq!(parse_kind_filter("class"), Some(NodeKind::Class));
895 assert_eq!(parse_kind_filter("import"), Some(NodeKind::Import));
896 assert_eq!(parse_kind_filter("file"), Some(NodeKind::File));
897 assert_eq!(parse_kind_filter("type_alias"), Some(NodeKind::TypeAlias));
898 assert_eq!(parse_kind_filter("typealias"), Some(NodeKind::TypeAlias));
899 }
900
901 #[test]
902 fn test_parse_kind_filter_unknown_returns_none() {
903 assert_eq!(parse_kind_filter("garbage"), None);
906 assert_eq!(parse_kind_filter(""), None);
907 }
908
909 #[test]
910 fn test_parse_language_covers_all_supported() {
911 assert_eq!(parse_language("rust"), Language::Rust);
913 assert_eq!(parse_language("rs"), Language::Rust);
914 assert_eq!(parse_language("python"), Language::Python);
915 assert_eq!(parse_language("py"), Language::Python);
916 assert_eq!(parse_language("go"), Language::Go);
917 assert_eq!(parse_language("typescript"), Language::TypeScript);
918 assert_eq!(parse_language("ts"), Language::TypeScript);
919 assert_eq!(parse_language("javascript"), Language::JavaScript);
920 assert_eq!(parse_language("java"), Language::Java);
921 assert_eq!(parse_language("cpp"), Language::Cpp);
922 assert_eq!(parse_language("c++"), Language::Cpp);
923 assert_eq!(parse_language("c"), Language::C);
924 assert_eq!(parse_language("ruby"), Language::Ruby);
925 assert_eq!(parse_language("rb"), Language::Ruby);
926 assert_eq!(parse_language("unknown-lang"), Language::Unknown);
927 }
928
929 #[test]
930 fn test_format_nodes_table_empty() {
931 assert_eq!(format_nodes_table(&[]), "No results.");
932 }
933
934 #[test]
935 fn test_format_nodes_table_includes_headers_and_counts() {
936 let nodes = vec![Node {
937 id: 1,
938 name: "my_fn".into(),
939 kind: NodeKind::Function,
940 language: Language::Rust,
941 file_path: "src/lib.rs".into(),
942 line_start: 42,
943 line_end: 50,
944 content: None,
945 }];
946 let formatted = format_nodes_table(&nodes);
947 assert!(formatted.contains("NAME"));
948 assert!(formatted.contains("KIND"));
949 assert!(formatted.contains("LOCATION"));
950 assert!(formatted.contains("my_fn"));
951 assert!(formatted.contains("function"));
952 assert!(formatted.contains("src/lib.rs:42"));
953 assert!(formatted.contains("1 result(s)"));
954 }
955
956 #[test]
957 fn test_graph_db_path_is_under_workspace() {
958 let root = PathBuf::from("/tmp/test-workspace");
959 let path = graph_db_path(&root);
960 assert_eq!(path, PathBuf::from("/tmp/test-workspace/.deagle/graph.db"));
961 }
962
963 #[tokio::test]
964 async fn test_deagle_stats_on_empty_workspace_is_non_fatal() {
965 let tmp = tempfile::TempDir::new().unwrap();
968 let tool = DeagleStatsTool::new(tmp.path().to_path_buf());
969 let result = tool.execute(serde_json::json!({})).await.unwrap();
970 let stats = result["stats"].as_str().unwrap();
971 assert!(stats.contains("Nodes:"));
972 assert!(stats.contains("0"));
973 }
974}