Skip to main content

openclaw_scan/
paths.rs

1//! Framework-agnostic installation path discovery.
2//!
3//! Resolution priority:
4//! 1. Explicit `--path <dir>` argument
5//! 2. `OPENCLAW_HOME` environment variable
6//! 3. Auto-detect common locations (`~/.claude/`, `~/.openclaw/`, …)
7//! 4. Error with a clear message
8
9use std::path::{Path, PathBuf};
10
11use anyhow::{bail, Context};
12
13// ── FrameworkHint ─────────────────────────────────────────────────────────────
14
15/// A hint about which agentic framework was detected.
16/// This is informational only — scanners operate identically regardless.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum FrameworkHint {
19    /// Claude Code installation at `~/.claude/`.
20    ClaudeCode,
21    /// OpenClaw installation at `~/.openclaw/` or similar.
22    Openclaw,
23    /// Explicitly supplied path whose framework couldn't be identified.
24    Unknown,
25}
26
27impl std::fmt::Display for FrameworkHint {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            FrameworkHint::ClaudeCode => write!(f, "Claude Code"),
31            FrameworkHint::Openclaw => write!(f, "OpenClaw"),
32            FrameworkHint::Unknown => write!(f, "agentic framework"),
33        }
34    }
35}
36
37// ── InstallRoot ───────────────────────────────────────────────────────────────
38
39/// A resolved installation root — the directory the scanners will walk.
40#[derive(Debug, Clone)]
41pub struct InstallRoot {
42    /// The canonical, absolute path to the installation directory.
43    pub path: PathBuf,
44    /// Which framework (best-guess) this root belongs to.
45    pub framework: FrameworkHint,
46}
47
48impl InstallRoot {
49    /// Build from an explicit path, detecting the framework from the path shape.
50    pub fn from_explicit(path: PathBuf) -> anyhow::Result<Self> {
51        let canonical = path
52            .canonicalize()
53            .with_context(|| format!("cannot access path: {}", path.display()))?;
54
55        if !canonical.is_dir() {
56            bail!("path is not a directory: {}", canonical.display());
57        }
58
59        let framework = detect_framework(&canonical);
60        Ok(InstallRoot {
61            path: canonical,
62            framework,
63        })
64    }
65}
66
67// ── resolve ───────────────────────────────────────────────────────────────────
68
69/// Resolve the installation root(s) to scan.
70///
71/// Returns one `InstallRoot` per unique directory.
72/// If `explicit` is non-empty every entry is used as-is and auto-detection
73/// is skipped.  Otherwise auto-detection runs.
74pub fn resolve(explicit: Vec<PathBuf>) -> anyhow::Result<Vec<InstallRoot>> {
75    if !explicit.is_empty() {
76        return explicit
77            .into_iter()
78            .map(InstallRoot::from_explicit)
79            .collect();
80    }
81
82    // Try $OPENCLAW_HOME first.
83    if let Ok(val) = std::env::var("OPENCLAW_HOME") {
84        let p = PathBuf::from(&val);
85        if p.is_dir() {
86            // Canonicalize to resolve symlinks and relative segments (H-4).
87            let canonical = p.canonicalize().unwrap_or(p);
88            let framework = detect_framework(&canonical);
89            return Ok(vec![InstallRoot {
90                path: canonical,
91                framework,
92            }]);
93        }
94    }
95
96    // Auto-detect from well-known locations.
97    let candidates = candidate_paths();
98    for (path, framework) in candidates {
99        if path.is_dir() && looks_like_install(&path) {
100            return Ok(vec![InstallRoot { path, framework }]);
101        }
102    }
103
104    bail!(
105        "No agentic framework installation found.\n\
106         Tried: ~/.claude, ~/.openclaw, ~/.config/openclaw, $OPENCLAW_HOME\n\
107         Use `ocls --path <dir>` to specify the installation directory."
108    )
109}
110
111// ── Internal helpers ──────────────────────────────────────────────────────────
112
113/// Ordered list of candidate directories and their expected framework.
114fn candidate_paths() -> Vec<(PathBuf, FrameworkHint)> {
115    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
116    vec![
117        (home.join(".claude"), FrameworkHint::ClaudeCode),
118        (home.join(".openclaw"), FrameworkHint::Openclaw),
119        (
120            home.join(".config").join("openclaw"),
121            FrameworkHint::Openclaw,
122        ),
123        // Also check the current directory for project-local .claude folders
124        (PathBuf::from(".claude"), FrameworkHint::ClaudeCode),
125        (PathBuf::from(".openclaw"), FrameworkHint::Openclaw),
126    ]
127}
128
129/// Heuristically determine which framework lives at `path`.
130fn detect_framework(path: &Path) -> FrameworkHint {
131    // L-2: collapsed dead if/else — both branches previously returned Unknown.
132    match path.file_name().and_then(|n| n.to_str()).unwrap_or("") {
133        ".claude" => FrameworkHint::ClaudeCode,
134        ".openclaw" => FrameworkHint::Openclaw,
135        _ => FrameworkHint::Unknown,
136    }
137}
138
139/// A directory is a plausible install root if it contains at least one of the
140/// well-known marker files that agentic frameworks store.
141fn looks_like_install(path: &Path) -> bool {
142    let markers = [
143        "settings.json",
144        "history.jsonl",
145        ".credentials.json",
146        "credentials.json",
147        "installed_plugins.json",
148        "mcp-needs-auth-cache.json",
149    ];
150    markers.iter().any(|m| path.join(m).exists())
151}
152
153// ── Tests ─────────────────────────────────────────────────────────────────────
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use tempfile::TempDir;
159
160    fn make_install_dir() -> TempDir {
161        let dir = tempfile::tempdir().unwrap();
162        std::fs::write(dir.path().join("settings.json"), b"{}").unwrap();
163        dir
164    }
165
166    #[test]
167    fn explicit_path_must_be_directory() {
168        let tmp = tempfile::NamedTempFile::new().unwrap();
169        let result = InstallRoot::from_explicit(tmp.path().to_path_buf());
170        assert!(result.is_err());
171        assert!(result.unwrap_err().to_string().contains("not a directory"));
172    }
173
174    #[test]
175    fn explicit_path_nonexistent() {
176        let result = InstallRoot::from_explicit(PathBuf::from("/nonexistent/path/xyz"));
177        assert!(result.is_err());
178    }
179
180    #[test]
181    fn explicit_path_valid_dir() {
182        let dir = make_install_dir();
183        let result = InstallRoot::from_explicit(dir.path().to_path_buf());
184        assert!(result.is_ok());
185    }
186
187    #[test]
188    fn looks_like_install_with_marker() {
189        let dir = make_install_dir();
190        assert!(looks_like_install(dir.path()));
191    }
192
193    #[test]
194    fn looks_like_install_empty_dir() {
195        let dir = tempfile::tempdir().unwrap();
196        assert!(!looks_like_install(dir.path()));
197    }
198
199    #[test]
200    fn detect_framework_claude() {
201        let path = PathBuf::from("/home/user/.claude");
202        assert_eq!(detect_framework(&path), FrameworkHint::ClaudeCode);
203    }
204
205    #[test]
206    fn detect_framework_openclaw() {
207        let path = PathBuf::from("/home/user/.openclaw");
208        assert_eq!(detect_framework(&path), FrameworkHint::Openclaw);
209    }
210
211    #[test]
212    fn resolve_returns_error_when_nothing_found() {
213        // Override home so no real ~/.claude exists in CI
214        // We test by passing no explicit paths and checking the error message.
215        // (We can't easily mock dirs::home_dir so we just verify the error type.)
216        // This test is intentionally light — the heavy path logic is tested above.
217        let result = resolve(vec![PathBuf::from("/tmp/__nonexistent_ocls_test__")]);
218        assert!(result.is_err());
219    }
220
221    #[test]
222    fn framework_hint_display() {
223        assert_eq!(FrameworkHint::ClaudeCode.to_string(), "Claude Code");
224        assert_eq!(FrameworkHint::Openclaw.to_string(), "OpenClaw");
225        assert_eq!(FrameworkHint::Unknown.to_string(), "agentic framework");
226    }
227}