Skip to main content

opi_coding_agent/tool/
mod.rs

1mod 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/// Path boundary policy for file tools.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PathPolicy {
24    WorkspaceOnly,
25    AllowOutsideWorkspace,
26}
27
28/// Resolved path metadata shared by file tools.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ResolvedToolPath {
31    pub path: PathBuf,
32    pub inside_workspace: bool,
33}
34
35/// Resolve a user-supplied file path for tool execution.
36///
37/// Relative paths are based on `workspace_root`; absolute paths are preserved.
38/// A leading `@` is ignored for editor-style path mentions, and `~` expands
39/// from HOME/USERPROFILE when available.
40pub 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    // Path doesn't exist, so canonicalize the nearest existing ancestor.
99    // Preserve the original lexical suffix relative to that ancestor, then
100    // normalize after joining so `..` segments are applied consistently.
101    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
113/// Resolve `.` and `..` components without touching the filesystem.
114fn 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}