opi_coding_agent/tool/
mod.rs1mod bash;
2mod edit;
3mod find;
4mod glob;
5mod grep;
6mod ls;
7mod read;
8mod write;
9
10pub use bash::BashTool;
11pub use edit::EditTool;
12pub use find::FindTool;
13pub use glob::GlobTool;
14pub use grep::GrepTool;
15pub use ls::LsTool;
16pub use read::ReadTool;
17pub use write::WriteTool;
18
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PathPolicy {
24 WorkspaceOnly,
25 AllowOutsideWorkspace,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ResolvedToolPath {
31 pub path: PathBuf,
32 pub inside_workspace: bool,
33}
34
35pub fn resolve_tool_path(
41 workspace_root: &Path,
42 user_path: &str,
43 policy: PathPolicy,
44) -> Result<ResolvedToolPath, String> {
45 let expanded = expand_user_path(user_path);
46 let resolved = if expanded.is_absolute() {
47 expanded
48 } else {
49 workspace_root.join(expanded)
50 };
51 let canonical_root = std::fs::canonicalize(workspace_root)
52 .map_err(|e| format!("cannot canonicalize workspace root: {e}"))?;
53 let canonical = canonicalize_existing_or_nearest(&resolved)?;
54 let inside_workspace = canonical.starts_with(&canonical_root);
55
56 if policy == PathPolicy::WorkspaceOnly && !inside_workspace {
57 return Err(format!(
58 "path '{}' resolves outside the workspace",
59 user_path
60 ));
61 }
62
63 Ok(ResolvedToolPath {
64 path: canonical,
65 inside_workspace,
66 })
67}
68
69pub fn validate_workspace_path(workspace_root: &Path, user_path: &str) -> Result<PathBuf, String> {
70 resolve_tool_path(workspace_root, user_path, PathPolicy::WorkspaceOnly)
71 .map(|resolved| resolved.path)
72}
73
74fn expand_user_path(user_path: &str) -> PathBuf {
75 let path = user_path.strip_prefix('@').unwrap_or(user_path);
76 if path == "~" {
77 return home_dir().unwrap_or_else(|| PathBuf::from(path));
78 }
79 if let Some(rest) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\"))
80 && let Some(home) = home_dir()
81 {
82 return home.join(rest);
83 }
84 PathBuf::from(path)
85}
86
87fn home_dir() -> Option<PathBuf> {
88 std::env::var_os("HOME")
89 .or_else(|| std::env::var_os("USERPROFILE"))
90 .map(PathBuf::from)
91}
92
93fn canonicalize_existing_or_nearest(path: &Path) -> Result<PathBuf, String> {
94 if let Ok(canonical) = std::fs::canonicalize(path) {
95 return Ok(canonical);
96 }
97
98 let mut ancestor = path;
102 while let Some(parent) = ancestor.parent() {
103 if let Ok(canonical_ancestor) = std::fs::canonicalize(parent) {
104 let suffix = path.strip_prefix(parent).unwrap_or_else(|_| Path::new(""));
105 return Ok(normalize_path_components(&canonical_ancestor.join(suffix)));
106 }
107 ancestor = parent;
108 }
109
110 Ok(normalize_path_components(path))
111}
112
113fn normalize_path_components(path: &Path) -> PathBuf {
115 let mut normalized = PathBuf::new();
116 for component in path.components() {
117 match component {
118 std::path::Component::ParentDir => {
119 normalized.pop();
120 }
121 std::path::Component::CurDir => {}
122 c => normalized.push(c.as_os_str()),
123 }
124 }
125 normalized
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn expand_user_path_strips_at_prefix() {
134 let path = expand_user_path("@Cargo.toml");
135 assert_eq!(path, PathBuf::from("Cargo.toml"));
136 }
137
138 #[test]
139 fn normalize_path_components_removes_parent_segments() {
140 let path = normalize_path_components(Path::new("/tmp/a/../b"));
141 assert!(path.ends_with(Path::new("tmp").join("b")));
142 }
143
144 #[test]
145 fn canonicalize_existing_or_nearest_normalizes_missing_suffix_parent_segments() {
146 let workspace = tempfile::tempdir().unwrap();
147 let path = workspace.path().join("missing/child/../../target.txt");
148 let resolved = canonicalize_existing_or_nearest(&path).unwrap();
149
150 assert_eq!(
151 resolved,
152 std::fs::canonicalize(workspace.path())
153 .unwrap()
154 .join("target.txt")
155 );
156 }
157}