1#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51#[serde(rename_all = "PascalCase")]
52pub enum ToolEffect {
53 ReadOnly,
55 RemoteAction,
57 LocalMutation,
59 Destructive,
61}
62
63pub fn classify_tool(name: &str) -> ToolEffect {
75 match name {
76 "Read" | "List" | "Grep" | "Glob" | "MemoryRead" | "ListAgents" | "ListSkills"
78 | "ActivateSkill" | "RecallContext" | "AskUser" | "TodoRead" => ToolEffect::ReadOnly,
79
80 "WebFetch" => ToolEffect::ReadOnly, "WebSearch" => ToolEffect::ReadOnly, "InvokeAgent" => ToolEffect::ReadOnly, "Write" | "Edit" | "MemoryWrite" | "TodoWrite" => ToolEffect::LocalMutation,
87
88 "Bash" => ToolEffect::LocalMutation,
90
91 "Delete" => ToolEffect::Destructive,
93
94 name if crate::mcp::is_mcp_tool_name(name) => ToolEffect::RemoteAction,
96
97 _ => ToolEffect::LocalMutation,
99 }
100}
101
102pub fn is_mutating_tool(name: &str) -> bool {
115 !matches!(classify_tool(name), ToolEffect::ReadOnly)
116}
117
118pub mod agent;
120pub mod ask_user;
121pub mod bg_process;
122pub mod file_tools;
124pub mod fuzzy;
125pub mod glob_tool;
127pub mod grep;
129pub mod memory;
131pub mod recall;
133pub mod shell;
135pub mod skill_tools;
137pub mod todo;
139pub mod validate;
141pub mod web_fetch;
143pub mod web_search;
145
146use anyhow::Result;
147use path_clean::PathClean;
148use serde_json::Value;
149use std::collections::HashMap;
150use std::path::{Path, PathBuf};
151use std::sync::Arc;
152use std::time::SystemTime;
153
154use crate::output_caps::OutputCaps;
155
156use crate::providers::ToolDefinition;
157
158pub type FileReadCache = Arc<std::sync::Mutex<HashMap<String, (u64, SystemTime, String)>>>;
170
171pub type LastWriterCache = Arc<std::sync::Mutex<HashMap<PathBuf, (String, std::time::Instant)>>>;
178
179pub type LastBashCache = Arc<std::sync::Mutex<Option<(String, std::time::Instant)>>>;
184
185#[derive(Debug, Clone)]
198pub struct ToolResult {
199 pub output: String,
201 pub success: bool,
207 pub full_output: Option<String>,
213}
214
215pub struct ToolRegistry {
217 project_root: PathBuf,
218 definitions: HashMap<String, ToolDefinition>,
219 read_cache: FileReadCache,
220 last_writer: LastWriterCache,
222 last_bash: LastBashCache,
224 pub undo: std::sync::Mutex<crate::undo::UndoStack>,
226 pub skill_registry: crate::skills::SkillRegistry,
228 db: std::sync::RwLock<Option<std::sync::Arc<crate::db::Database>>>,
230 session_id: std::sync::RwLock<Option<String>>,
232 pub caps: OutputCaps,
234 pub bg_registry: bg_process::BgRegistry,
237 trust: crate::trust::TrustMode,
239 mcp_manager: std::sync::RwLock<Option<Arc<tokio::sync::RwLock<crate::mcp::McpManager>>>>,
242}
243
244impl ToolRegistry {
245 pub fn new(project_root: PathBuf, max_context_tokens: usize) -> Self {
249 Self::with_trust(
250 project_root,
251 max_context_tokens,
252 crate::trust::TrustMode::Safe,
253 )
254 }
255
256 pub fn with_trust(
258 project_root: PathBuf,
259 max_context_tokens: usize,
260 trust: crate::trust::TrustMode,
261 ) -> Self {
262 let mut definitions = HashMap::new();
263
264 for def in file_tools::definitions() {
266 definitions.insert(def.name.clone(), def);
267 }
268
269 for def in grep::definitions() {
270 definitions.insert(def.name.clone(), def);
271 }
272 for def in shell::definitions() {
273 definitions.insert(def.name.clone(), def);
274 }
275 for def in agent::definitions() {
276 definitions.insert(def.name.clone(), def);
277 }
278 for def in ask_user::definitions() {
279 definitions.insert(def.name.clone(), def);
280 }
281 for def in glob_tool::definitions() {
282 definitions.insert(def.name.clone(), def);
283 }
284 for def in web_fetch::definitions() {
285 definitions.insert(def.name.clone(), def);
286 }
287 for def in web_search::definitions() {
288 definitions.insert(def.name.clone(), def);
289 }
290 for def in todo::definitions() {
291 definitions.insert(def.name.clone(), def);
292 }
293 for def in memory::definitions() {
294 definitions.insert(def.name.clone(), def);
295 }
296 for def in skill_tools::definitions() {
297 definitions.insert(def.name.clone(), def);
298 }
299 let recall_def = recall::definition();
301 definitions.insert(recall_def.name.clone(), recall_def);
302 let skill_registry = crate::skills::SkillRegistry::discover(&project_root);
303
304 Self {
305 project_root,
306 definitions,
307 read_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
308 last_writer: Arc::new(std::sync::Mutex::new(HashMap::new())),
309 last_bash: Arc::new(std::sync::Mutex::new(None)),
310 undo: std::sync::Mutex::new(crate::undo::UndoStack::new()),
311 skill_registry,
312 db: std::sync::RwLock::new(None),
313 session_id: std::sync::RwLock::new(None),
314 caps: OutputCaps::for_context(max_context_tokens),
315 bg_registry: bg_process::BgRegistry::new(),
316 trust,
317 mcp_manager: std::sync::RwLock::new(None),
318 }
319 }
320
321 pub fn with_shared_cache(mut self, cache: FileReadCache) -> Self {
326 self.read_cache = cache;
327 self
328 }
329
330 pub fn file_read_cache(&self) -> FileReadCache {
332 Arc::clone(&self.read_cache)
333 }
334
335 pub fn last_writer_cache(&self) -> LastWriterCache {
337 Arc::clone(&self.last_writer)
338 }
339
340 pub fn last_bash_cache(&self) -> LastBashCache {
342 Arc::clone(&self.last_bash)
343 }
344
345 pub fn set_session(&self, db: std::sync::Arc<crate::db::Database>, session_id: String) {
347 if let Ok(mut guard) = self.db.write() {
348 *guard = Some(db);
349 }
350 if let Ok(mut guard) = self.session_id.write() {
351 *guard = Some(session_id);
352 }
353 }
354
355 pub fn set_mcp_manager(&self, manager: Arc<tokio::sync::RwLock<crate::mcp::McpManager>>) {
360 if let Ok(mut guard) = self.mcp_manager.write() {
361 *guard = Some(manager);
362 }
363 }
364
365 pub fn mcp_manager(&self) -> Option<Arc<tokio::sync::RwLock<crate::mcp::McpManager>>> {
367 self.mcp_manager.read().ok().and_then(|guard| guard.clone())
368 }
369
370 pub fn classify_tool_with_mcp(&self, name: &str) -> ToolEffect {
375 if crate::mcp::is_mcp_tool_name(name) {
376 if let Some(mgr) = self.mcp_manager()
377 && let Ok(mgr) = mgr.try_read()
378 {
379 return mgr.classify_tool(name);
380 }
381 return ToolEffect::RemoteAction;
383 }
384 classify_tool(name)
385 }
386
387 pub fn all_builtin_tool_names(&self) -> Vec<String> {
390 let mut names: Vec<String> = self.definitions.keys().cloned().collect();
391 names.sort();
392 names
393 }
394
395 pub fn has_tool(&self, name: &str) -> bool {
397 self.definitions.contains_key(name)
398 }
399
400 pub fn list_skills(&self) -> Vec<(String, String, String)> {
402 self.skill_registry
403 .list()
404 .into_iter()
405 .map(|m| {
406 let source = match m.source {
407 crate::skills::SkillSource::BuiltIn => "built-in",
408 crate::skills::SkillSource::User => "user",
409 crate::skills::SkillSource::Project => "project",
410 };
411 (m.name.clone(), m.description.clone(), source.to_string())
412 })
413 .collect()
414 }
415
416 pub fn search_skills(&self, query: &str) -> Vec<(String, String, String)> {
418 self.skill_registry
419 .search(query)
420 .into_iter()
421 .map(|m| {
422 let source = match m.source {
423 crate::skills::SkillSource::BuiltIn => "built-in",
424 crate::skills::SkillSource::User => "user",
425 crate::skills::SkillSource::Project => "project",
426 };
427 (m.name.clone(), m.description.clone(), source.to_string())
428 })
429 .collect()
430 }
431
432 pub fn get_definitions(&self, allowed: &[String], denied: &[String]) -> Vec<ToolDefinition> {
441 let mut defs: Vec<ToolDefinition> = if !allowed.is_empty() {
442 allowed
443 .iter()
444 .filter_map(|name| self.definitions.get(name).cloned())
445 .collect()
446 } else if !denied.is_empty() {
447 self.definitions
448 .values()
449 .filter(|d| !denied.contains(&d.name))
450 .cloned()
451 .collect()
452 } else {
453 self.definitions.values().cloned().collect()
454 };
455
456 if let Some(mgr) = self.mcp_manager()
458 && let Ok(mgr) = mgr.try_read()
459 {
460 let mcp_defs = mgr.all_tool_definitions();
461 if !allowed.is_empty() {
462 for def in mcp_defs {
464 if allowed.contains(&def.name) {
465 defs.push(def);
466 }
467 }
468 } else if !denied.is_empty() {
469 for def in mcp_defs {
471 if !denied.contains(&def.name) {
472 defs.push(def);
473 }
474 }
475 } else {
476 defs.extend(mcp_defs);
478 }
479 }
480
481 defs
482 }
483
484 pub async fn execute(
494 &self,
495 name: &str,
496 arguments: &str,
497 sink_for_streaming: Option<(&dyn crate::engine::EngineSink, &str)>,
498 ) -> ToolResult {
499 let raw = arguments.trim();
500 let raw = if raw.is_empty() { "{}" } else { raw };
501 let args: Value = match serde_json::from_str(raw) {
502 Ok(v) => v,
503 Err(e) => {
504 return ToolResult {
505 output: format!("Invalid JSON arguments: {e}"),
506 success: false,
507 full_output: None,
508 };
509 }
510 };
511
512 tracing::info!(
513 "Executing tool: {name} with args: [{} chars]",
514 arguments.len()
515 );
516
517 if let Some(file_path) = crate::undo::is_mutating_tool(name)
519 .then(|| crate::undo::extract_file_path(name, &args))
520 .flatten()
521 {
522 let resolved = self.project_root.join(&file_path);
523 if let Ok(mut undo) = self.undo.lock() {
524 undo.snapshot(&resolved);
525 }
526 }
527
528 let result = match name {
529 "Read" => file_tools::read_file(&self.project_root, &args, &self.read_cache).await,
531 "Write" => file_tools::write_file(&self.project_root, &args).await,
532 "Edit" => file_tools::edit_file(&self.project_root, &args, &self.read_cache).await,
533 "Delete" => file_tools::delete_file(&self.project_root, &args).await,
534 "List" => {
535 file_tools::list_files(&self.project_root, &args, self.caps.list_entries).await
536 }
537
538 "Grep" => grep::grep(&self.project_root, &args, self.caps.grep_matches).await,
540 "Glob" => {
541 glob_tool::glob_search(&self.project_root, &args, self.caps.glob_results).await
542 }
543
544 "Bash" => {
547 let shell_result = shell::run_shell_command(
548 &self.project_root,
549 &args,
550 self.caps.shell_output_lines,
551 &self.bg_registry,
552 sink_for_streaming,
553 &self.trust,
554 )
555 .await;
556 return match shell_result {
557 Ok(so) => {
558 let snippet = args["command"]
561 .as_str()
562 .unwrap_or("")
563 .chars()
564 .take(72)
565 .collect::<String>();
566 if !snippet.is_empty()
567 && let Ok(mut guard) = self.last_bash.lock()
568 {
569 *guard = Some((snippet, std::time::Instant::now()));
570 }
571 ToolResult {
572 output: so.summary,
573 success: true,
574 full_output: so.full_output,
575 }
576 }
577 Err(e) => ToolResult {
578 output: format!("Error: {e}"),
579 success: false,
580 full_output: None,
581 },
582 };
583 }
584
585 "WebFetch" => web_fetch::web_fetch(&args, self.caps.web_body_chars).await,
587 "WebSearch" => web_search::web_search(&args).await,
588 "TodoWrite" => {
589 let db_opt = self.db.read().ok().and_then(|g| g.clone());
590 let sid_opt = self.session_id.read().ok().and_then(|g| g.clone());
591 match (db_opt, sid_opt) {
592 (Some(db), Some(sid)) => todo::todo_write(&db, &sid, &args).await,
593 _ => Ok("TodoWrite requires an active session.".to_string()),
594 }
595 }
596
597 "MemoryRead" => memory::memory_read(&self.project_root).await,
599 "MemoryWrite" => memory::memory_write(&self.project_root, &args).await,
600
601 "ListAgents" => {
603 let detail = args["detail"].as_bool().unwrap_or(false);
604 if detail {
605 Ok(agent::list_agents_detail(&self.project_root))
606 } else {
607 let agents = agent::list_agents(&self.project_root);
608 if agents.is_empty() {
609 Ok("No sub-agents configured.".to_string())
610 } else {
611 let lines: Vec<String> = agents
612 .iter()
613 .map(|(name, desc, source)| {
614 if source == "built-in" {
615 format!(" {name} — {desc}")
616 } else {
617 format!(" {name} — {desc} [{source}]")
618 }
619 })
620 .collect();
621 Ok(lines.join("\n"))
622 }
623 }
624 }
625 "ListSkills" => Ok(skill_tools::list_skills(&self.skill_registry, &args)),
627 "ActivateSkill" => Ok(skill_tools::activate_skill(&self.skill_registry, &args)),
628
629 "RecallContext" => {
631 let db_opt = self.db.read().ok().and_then(|g| g.clone());
632 let sid_opt = self.session_id.read().ok().and_then(|g| g.clone());
633 if let (Some(db), Some(sid)) = (db_opt, sid_opt) {
634 Ok(recall::recall_context(&db, &sid, &args).await)
635 } else {
636 Ok("RecallContext requires an active session.".to_string())
637 }
638 }
639
640 "InvokeAgent" => {
641 return ToolResult {
644 output: "InvokeAgent is handled by the inference loop.".to_string(),
645 success: false,
646 full_output: None,
647 };
648 }
649
650 "AskUser" => {
651 return ToolResult {
654 output: "AskUser is handled by the inference loop.".to_string(),
655 success: false,
656 full_output: None,
657 };
658 }
659
660 other => {
661 if crate::mcp::is_mcp_tool_name(other) {
664 if let Some(mgr) = self.mcp_manager() {
665 let result = {
666 let mgr = mgr.read().await;
667 mgr.call_tool(other, args.clone()).await
668 };
669 return match result {
670 Ok(output) => ToolResult {
671 output,
672 success: true,
673 full_output: None,
674 },
675 Err(e) => ToolResult {
676 output: format!("Error: {e}"),
677 success: false,
678 full_output: None,
679 },
680 };
681 }
682 return ToolResult {
683 output: format!(
684 "MCP tool '{other}' not available — \
685 no MCP servers connected."
686 ),
687 success: false,
688 full_output: None,
689 };
690 }
691
692 let warning = if other.contains('{') || other.len() > 64 {
695 format!(
696 "Unknown tool: {other}. \
697 This model appears to struggle with tool calling. \
698 Consider switching to a model with native function-call support."
699 )
700 } else {
701 format!("Unknown tool: {other}")
702 };
703 Err(anyhow::anyhow!(warning))
704 }
705 };
706
707 match result {
708 Ok(output) => {
709 if matches!(name, "Write" | "Edit")
712 && let Some(path) =
713 crate::file_tracker::resolve_file_path_from_args(&args, &self.project_root)
714 && let Ok(mut guard) = self.last_writer.lock()
715 {
716 guard.insert(path, (name.to_string(), std::time::Instant::now()));
717 }
718 ToolResult {
719 output,
720 success: true,
721 full_output: None,
722 }
723 }
724 Err(e) => ToolResult {
725 output: format!("Error: {e}"),
726 success: false,
727 full_output: None,
728 },
729 }
730 }
731}
732
733pub fn safe_resolve_path(project_root: &Path, requested: &str) -> Result<PathBuf> {
755 let requested_path = Path::new(requested);
758
759 let resolved = if requested_path.is_absolute() {
761 requested_path.to_path_buf().clean()
762 } else {
763 project_root.join(requested_path).clean()
764 };
765
766 if !resolved.starts_with(project_root) {
770 anyhow::bail!(
771 "Path {requested:?} is outside the project root ({project_root:?}). \
772 Write, Edit, and Delete are restricted to the project directory to \
773 prevent accidental modification of files outside the project. \
774 Tell the user: to write outside the project, restart koda from a \
775 parent directory that contains both paths."
776 );
777 }
778
779 Ok(resolved)
780}
781
782pub(crate) fn resolve_path_unrestricted(project_root: &Path, requested: &str) -> PathBuf {
791 let path = Path::new(requested);
792 if path.is_absolute() {
793 path.to_path_buf().clean()
794 } else {
795 project_root.join(path).clean()
796 }
797}
798
799pub fn resolve_read_path(project_root: &Path, requested: &str) -> Result<PathBuf> {
813 let resolved = resolve_path_unrestricted(project_root, requested);
814 if crate::sandbox::is_fully_denied(&resolved) {
815 anyhow::bail!(
816 "Access to {requested:?} is denied: this path contains koda's \
817 internal secrets and cannot be read by model tool calls."
818 );
819 }
820 Ok(resolved)
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use std::path::PathBuf;
827
828 fn root() -> PathBuf {
829 PathBuf::from("/home/user/project")
830 }
831
832 #[test]
833 fn test_relative_path_resolves_inside_root() {
834 let result = safe_resolve_path(&root(), "src/main.rs").unwrap();
835 assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
836 }
837
838 #[test]
839 fn test_dot_path_resolves_to_root() {
840 let result = safe_resolve_path(&root(), ".").unwrap();
841 assert_eq!(result, PathBuf::from("/home/user/project"));
842 }
843
844 #[test]
845 fn test_new_file_in_new_dir_resolves() {
846 let result = safe_resolve_path(&root(), "src/brand_new/feature.rs").unwrap();
847 assert_eq!(
848 result,
849 PathBuf::from("/home/user/project/src/brand_new/feature.rs")
850 );
851 }
852
853 #[test]
854 fn test_dotdot_traversal_blocked() {
855 let result = safe_resolve_path(&root(), "../../etc/passwd");
856 assert!(result.is_err());
857 }
858
859 #[test]
860 fn test_dotdot_sneaky_traversal_blocked() {
861 let result = safe_resolve_path(&root(), "src/../../etc/passwd");
862 assert!(result.is_err());
863 }
864
865 #[test]
866 fn test_absolute_path_inside_root_allowed() {
867 let result = safe_resolve_path(&root(), "/home/user/project/src/lib.rs").unwrap();
868 assert_eq!(result, PathBuf::from("/home/user/project/src/lib.rs"));
869 }
870
871 #[test]
872 fn test_absolute_path_outside_root_blocked() {
873 let result = safe_resolve_path(&root(), "/etc/shadow");
874 assert!(result.is_err());
875 }
876
877 #[test]
878 fn test_outside_root_error_is_actionable_for_user() {
879 let err = safe_resolve_path(&root(), "../../etc/passwd").unwrap_err();
880 let msg = err.to_string();
881 assert!(
882 msg.contains("outside the project root"),
883 "error must say 'outside the project root'; got: {msg}"
884 );
885 assert!(
886 msg.contains("Tell the user"),
887 "error must direct model to surface this to the user; got: {msg}"
888 );
889 assert!(
891 !msg.contains("Bash"),
892 "error must not suggest Bash as a workaround; got: {msg}"
893 );
894 }
895
896 #[test]
897 fn test_empty_path_resolves_to_root() {
898 let result = safe_resolve_path(&root(), "").unwrap();
899 assert_eq!(result, PathBuf::from("/home/user/project"));
900 }
901
902 #[test]
905 fn read_path_allows_project_file() {
906 let p = resolve_read_path(&root(), "src/lib.rs").unwrap();
907 assert_eq!(p, PathBuf::from("/home/user/project/src/lib.rs"));
908 }
909
910 #[test]
911 fn read_path_allows_outside_project() {
912 let p = resolve_read_path(&root(), "/etc/hosts").unwrap();
914 assert_eq!(p, PathBuf::from("/etc/hosts"));
915 }
916
917 #[test]
918 fn read_path_blocks_koda_db() {
919 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
920 let koda_db = format!("{home}/.config/koda/db/koda.db");
921 let err = resolve_read_path(&root(), &koda_db).unwrap_err();
922 assert!(
923 err.to_string().contains("denied"),
924 "expected 'denied' in error, got: {err}"
925 );
926 }
927}
928
929pub fn describe_action(tool_name: &str, args: &serde_json::Value) -> String {
933 match tool_name {
934 "Bash" => {
935 let cmd = args
936 .get("command")
937 .or(args.get("cmd"))
938 .and_then(|v| v.as_str())
939 .unwrap_or("?");
940 let bg = args
941 .get("background")
942 .and_then(|v| v.as_bool())
943 .unwrap_or(false);
944 if bg {
945 format!("[bg] {cmd}")
946 } else {
947 cmd.to_string()
948 }
949 }
950 "Delete" => {
951 let path = args
952 .get("file_path")
953 .or(args.get("path"))
954 .and_then(|v| v.as_str())
955 .unwrap_or("?");
956 let recursive = args
957 .get("recursive")
958 .and_then(|v| v.as_bool())
959 .unwrap_or(false);
960 if recursive {
961 format!("Delete directory (recursive): {path}")
962 } else {
963 format!("Delete: {path}")
964 }
965 }
966 "Write" => {
967 let path = args
968 .get("path")
969 .or(args.get("file_path"))
970 .and_then(|v| v.as_str())
971 .unwrap_or("?");
972 let overwrite = args
973 .get("overwrite")
974 .and_then(|v| v.as_bool())
975 .unwrap_or(false);
976 if overwrite {
977 format!("Overwrite file: {path}")
978 } else {
979 format!("Create file: {path}")
980 }
981 }
982 "Edit" => {
983 let path = if let Some(payload) = args.get("payload") {
984 payload
985 .get("file_path")
986 .or(payload.get("path"))
987 .and_then(|v| v.as_str())
988 .unwrap_or("?")
989 } else {
990 args.get("file_path")
991 .or(args.get("path"))
992 .and_then(|v| v.as_str())
993 .unwrap_or("?")
994 };
995 format!("Edit file: {path}")
996 }
997 "WebFetch" => {
998 let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("?");
999 format!("Fetch URL: {url}")
1000 }
1001 "WebSearch" => {
1002 let q = args.get("query").and_then(|v| v.as_str()).unwrap_or("?");
1003 format!("Web search: {q}")
1004 }
1005 "TodoWrite" => {
1006 let n = args
1007 .get("todos")
1008 .and_then(|v| v.as_array())
1009 .map(|a| a.len())
1010 .unwrap_or(0);
1011 format!("Update todo list ({n} tasks)")
1012 }
1013 "MemoryWrite" => {
1014 let fact = args.get("fact").and_then(|v| v.as_str()).unwrap_or("?");
1015 let preview = if fact.len() > 60 {
1016 format!("{}…", &fact[..57])
1017 } else {
1018 fact.to_string()
1019 };
1020 format!("Save to memory: {preview}")
1021 }
1022 _ => format!("Execute: {tool_name}"),
1023 }
1024}
1025
1026#[cfg(test)]
1027mod describe_action_tests {
1028 use super::*;
1029 use serde_json::json;
1030
1031 #[test]
1032 fn test_describe_bash() {
1033 let desc = describe_action("Bash", &json!({"command": "cargo build"}));
1034 assert!(desc.contains("cargo build"));
1035 }
1036
1037 #[test]
1038 fn test_describe_delete() {
1039 let desc = describe_action("Delete", &json!({"file_path": "old.rs"}));
1040 assert!(desc.contains("old.rs"));
1041 }
1042
1043 #[test]
1044 fn test_describe_edit() {
1045 let desc = describe_action("Edit", &json!({"payload": {"file_path": "src/main.rs"}}));
1046 assert!(desc.contains("src/main.rs"));
1047 }
1048
1049 #[test]
1050 fn test_describe_write() {
1051 let desc = describe_action("Write", &json!({"path": "new.rs"}));
1052 assert!(desc.contains("Create file"));
1053 assert!(desc.contains("new.rs"));
1054 }
1055
1056 #[test]
1057 fn test_describe_write_overwrite() {
1058 let desc = describe_action("Write", &json!({"path": "x.rs", "overwrite": true}));
1059 assert!(desc.contains("Overwrite"));
1060 }
1061
1062 #[test]
1063 fn test_get_definitions_deny_list() {
1064 let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
1065 let denied = vec![
1066 "Write".to_string(),
1067 "Edit".to_string(),
1068 "Delete".to_string(),
1069 ];
1070 let defs = registry.get_definitions(&[], &denied);
1071 let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
1072 assert!(!names.contains(&"Write"));
1073 assert!(!names.contains(&"Edit"));
1074 assert!(!names.contains(&"Delete"));
1075 assert!(names.contains(&"Read"));
1076 assert!(names.contains(&"Grep"));
1077 }
1078
1079 #[test]
1080 fn test_get_definitions_allow_list_wins_over_deny() {
1081 let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
1082 let allowed = vec!["Read".to_string(), "Write".to_string()];
1083 let denied = vec!["Write".to_string()];
1084 let defs = registry.get_definitions(&allowed, &denied);
1086 let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
1087 assert_eq!(names.len(), 2);
1088 assert!(names.contains(&"Read"));
1089 assert!(names.contains(&"Write"));
1090 }
1091
1092 #[test]
1093 fn test_get_definitions_both_empty_returns_all() {
1094 let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
1095 let all = registry.get_definitions(&[], &[]);
1096 assert!(all.len() > 10, "Should have many tools");
1097 }
1098}