Skip to main content

whycodes_import/
discover.rs

1//! Locate other agents' user-level settings files without reading them.
2
3use std::path::{Path, PathBuf};
4
5use crate::consent::ConsentStore;
6use crate::types::{FoundSource, Product, SourceState};
7
8/// A settings file another CLI is known to write.
9#[derive(Debug, Clone, Copy)]
10pub struct KnownSource {
11    pub product: Product,
12    pub rel_path: &'static str,
13}
14
15/// Fixed relative-to-home locations. OpenCode also checks `$XDG_CONFIG_HOME`.
16pub const KNOWN_SOURCES: &[KnownSource] = &[
17    KnownSource {
18        product: Product::Claude,
19        rel_path: ".claude.json",
20    },
21    KnownSource {
22        product: Product::Claude,
23        rel_path: ".claude/settings.json",
24    },
25    KnownSource {
26        product: Product::Claude,
27        rel_path: ".claude/mcp.json",
28    },
29    KnownSource {
30        product: Product::OpenCode,
31        rel_path: ".config/opencode/opencode.json",
32    },
33    KnownSource {
34        product: Product::OpenCode,
35        rel_path: ".config/opencode/opencode.jsonc",
36    },
37    KnownSource {
38        product: Product::Grok,
39        rel_path: ".grok/config.toml",
40    },
41    KnownSource {
42        product: Product::Codex,
43        rel_path: ".codex/config.toml",
44    },
45];
46
47pub fn home_dir() -> Option<PathBuf> {
48    std::env::var_os("HOME")
49        .filter(|h| !h.is_empty())
50        .or_else(|| std::env::var_os("USERPROFILE").filter(|h| !h.is_empty()))
51        .map(PathBuf::from)
52}
53
54/// Scan `$HOME` for known settings files.
55pub fn scan(consent: &ConsentStore) -> Vec<FoundSource> {
56    match home_dir() {
57        Some(home) => scan_with_home(&home, consent),
58        None => Vec::new(),
59    }
60}
61
62pub fn scan_with_home(home: &Path, consent: &ConsentStore) -> Vec<FoundSource> {
63    let mut found = Vec::new();
64    let mut seen = std::collections::BTreeSet::new();
65    for source in KNOWN_SOURCES {
66        push_if_present(
67            &mut found,
68            &mut seen,
69            home.join(source.rel_path),
70            source.product,
71            source.rel_path,
72            consent,
73        );
74    }
75    for path in extra_opencode_paths(home) {
76        push_if_present(
77            &mut found,
78            &mut seen,
79            path,
80            Product::OpenCode,
81            ".config/opencode/opencode.json",
82            consent,
83        );
84    }
85    found
86}
87
88fn extra_opencode_paths(home: &Path) -> Vec<PathBuf> {
89    extra_opencode_paths_with(home, std::env::var_os("XDG_CONFIG_HOME"))
90}
91
92fn extra_opencode_paths_with(home: &Path, xdg: Option<std::ffi::OsString>) -> Vec<PathBuf> {
93    let mut out = Vec::new();
94    if let Some(xdg) = xdg.filter(|h| !h.is_empty()) {
95        let base = PathBuf::from(xdg).join("opencode");
96        out.push(base.join("opencode.json"));
97        out.push(base.join("opencode.jsonc"));
98    }
99    // Windows-ish fallback; harmless if missing.
100    out.push(home.join("AppData/Roaming/opencode/opencode.json"));
101    out
102}
103
104fn push_if_present(
105    found: &mut Vec<FoundSource>,
106    seen: &mut std::collections::BTreeSet<PathBuf>,
107    path: PathBuf,
108    product: Product,
109    rel_path: &'static str,
110    consent: &ConsentStore,
111) {
112    if !path.exists() || !seen.insert(path.clone()) {
113        return;
114    }
115    let state = classify(is_symlink(&path), consent.state_for(&path));
116    found.push(FoundSource {
117        product,
118        rel_path,
119        path,
120        state,
121    });
122}
123
124fn classify(is_link: bool, fallback: SourceState) -> SourceState {
125    if is_link {
126        SourceState::Symlink
127    } else {
128        fallback
129    }
130}
131
132fn is_symlink(path: &Path) -> bool {
133    std::fs::symlink_metadata(path)
134        .map(|m| m.file_type().is_symlink())
135        .unwrap_or(false)
136}
137
138/// True when WhyCodes has no user `config.toml` yet (first-run candidate).
139pub fn why_config_missing() -> bool {
140    let path = whycodes_core::paths::config_file();
141    !path.exists()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
149
150    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
151        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
152    }
153
154    #[test]
155    fn lock_env_recovers_from_poison() {
156        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
157            let _g = ENV_LOCK.lock().unwrap();
158            panic!("poison");
159        }));
160        let _g = lock_env();
161    }
162
163    #[test]
164    fn scan_finds_files_skips_missing() {
165        let dir = tempfile::tempdir().unwrap();
166        let home = dir.path();
167        std::fs::create_dir_all(home.join(".claude")).unwrap();
168        std::fs::write(home.join(".claude.json"), "{}").unwrap();
169        std::fs::write(home.join(".claude/mcp.json"), "{}").unwrap();
170        std::fs::create_dir_all(home.join(".grok")).unwrap();
171        std::fs::write(home.join(".grok/config.toml"), "[mcp_servers]\n").unwrap();
172        let consent = ConsentStore::new(home.join("data"));
173        let found = scan_with_home(home, &consent);
174        assert!(
175            found
176                .iter()
177                .any(|f| f.product == Product::Claude && f.rel_path == ".claude.json")
178        );
179        assert!(found.iter().any(|f| f.product == Product::Grok));
180        assert!(found.iter().all(|f| f.state == SourceState::New));
181        assert!(!found.iter().any(|f| f.product == Product::Codex));
182    }
183
184    #[test]
185    fn scan_marks_symlink() {
186        let dir = tempfile::tempdir().unwrap();
187        let home = dir.path();
188        let real = home.join("real.json");
189        std::fs::write(&real, "{}").unwrap();
190        let link = home.join(".claude.json");
191        #[cfg(unix)]
192        {
193            std::os::unix::fs::symlink(&real, &link).unwrap();
194            let consent = ConsentStore::new(home.join("data"));
195            let found = scan_with_home(home, &consent);
196            let f = found.iter().find(|f| f.rel_path == ".claude.json").unwrap();
197            assert_eq!(f.state, SourceState::Symlink);
198        }
199        #[cfg(not(unix))]
200        {
201            std::fs::write(&link, "{}").unwrap();
202            let consent = ConsentStore::new(home.join("data"));
203            let found = scan_with_home(home, &consent);
204            assert!(found.iter().any(|f| f.rel_path == ".claude.json"));
205            let _ = real;
206        }
207        assert_eq!(classify(true, SourceState::New), SourceState::Symlink);
208        assert_eq!(classify(false, SourceState::New), SourceState::New);
209    }
210
211    #[test]
212    fn extra_opencode_from_xdg() {
213        let dir = tempfile::tempdir().unwrap();
214        let xdg = dir.path().join("xdg");
215        std::fs::create_dir_all(xdg.join("opencode")).unwrap();
216        std::fs::write(xdg.join("opencode/opencode.json"), "{}").unwrap();
217        let extra = extra_opencode_paths_with(dir.path(), Some(xdg.clone().into_os_string()));
218        assert!(extra.iter().any(|p| p.ends_with("opencode.json")));
219        let consent = ConsentStore::new(dir.path().join("data"));
220        // scan_with_home only sees XDG via env; push the extra path as if it existed under home.
221        std::fs::create_dir_all(dir.path().join(".config/opencode")).unwrap();
222        std::fs::write(dir.path().join(".config/opencode/opencode.json"), "{}").unwrap();
223        let found = scan_with_home(dir.path(), &consent);
224        assert!(
225            found
226                .iter()
227                .any(|f| f.product == Product::OpenCode && f.path.ends_with("opencode.json"))
228        );
229        let _ = extra;
230    }
231
232    #[test]
233    fn scan_and_home_dir() {
234        let _ = scan(&ConsentStore::new(tempfile::tempdir().unwrap().path()));
235        let _ = home_dir();
236        let _ = why_config_missing();
237        assert!(!KNOWN_SOURCES.is_empty());
238        assert_eq!(KNOWN_SOURCES[0].product, Product::Claude);
239        let extra = extra_opencode_paths_with(std::path::Path::new("/tmp"), None);
240        assert!(extra.iter().any(|p| p.ends_with("opencode.json")));
241        let _ = extra_opencode_paths(std::path::Path::new("/tmp"));
242        let _guard = lock_env();
243        let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
244        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/whycodes-xdg") };
245        let extra = extra_opencode_paths(std::path::Path::new("/tmp"));
246        assert!(extra.iter().any(|p| p.ends_with("opencode.jsonc")));
247        restore_os("XDG_CONFIG_HOME", Some("/tmp/whycodes-xdg-restore".into()));
248        restore_os("XDG_CONFIG_HOME", None);
249        restore_os("XDG_CONFIG_HOME", prev_xdg);
250    }
251
252    #[test]
253    fn why_config_missing_true_and_false() {
254        let _guard = lock_env();
255        let dir = tempfile::tempdir().unwrap();
256        let prev = std::env::var_os("WHYCODES_HOME");
257        unsafe { std::env::set_var("WHYCODES_HOME", dir.path()) };
258        assert!(why_config_missing());
259        std::fs::write(dir.path().join("config.toml"), "").unwrap();
260        assert!(!why_config_missing());
261        restore_os("WHYCODES_HOME", prev);
262    }
263
264    #[test]
265    fn scan_empty_without_files() {
266        let dir = tempfile::tempdir().unwrap();
267        let consent = ConsentStore::new(dir.path().join("data"));
268        assert!(scan_with_home(dir.path(), &consent).is_empty());
269    }
270
271    #[test]
272    fn scan_fake_claude_cursor_codex_trees_and_consent() {
273        let dir = tempfile::tempdir().unwrap();
274        let home = dir.path();
275        std::fs::create_dir_all(home.join(".claude")).unwrap();
276        std::fs::write(home.join(".claude.json"), r#"{"mcpServers":{}}"#).unwrap();
277        std::fs::write(home.join(".claude/settings.json"), r#"{"permissions":{}}"#).unwrap();
278        std::fs::write(home.join(".claude/mcp.json"), r#"{"fs":{"command":"npx"}}"#).unwrap();
279        std::fs::create_dir_all(home.join(".config/opencode")).unwrap();
280        std::fs::write(
281            home.join(".config/opencode/opencode.jsonc"),
282            r#"{ "mcp": {} }"#,
283        )
284        .unwrap();
285        std::fs::create_dir_all(home.join(".codex")).unwrap();
286        std::fs::write(home.join(".codex/config.toml"), "[mcp_servers]\n").unwrap();
287        std::fs::create_dir_all(home.join(".grok")).unwrap();
288        std::fs::write(home.join(".grok/config.toml"), "[permission]\n").unwrap();
289        let consent = ConsentStore::new(home.join("data"));
290        consent.approve(&home.join(".claude.json")).unwrap();
291        consent.deny(&home.join(".codex/config.toml")).unwrap();
292        let found = scan_with_home(home, &consent);
293        assert!(
294            found
295                .iter()
296                .any(|f| f.rel_path == ".claude.json" && f.state == SourceState::Approved)
297        );
298        assert!(
299            found
300                .iter()
301                .any(|f| f.product == Product::Codex && f.state == SourceState::Denied)
302        );
303        assert!(found.iter().any(|f| f.product == Product::OpenCode));
304        assert!(found.iter().any(|f| f.rel_path == ".claude/settings.json"));
305    }
306
307    #[test]
308    fn extra_opencode_empty_xdg_and_seen_dedup() {
309        let extra = extra_opencode_paths_with(std::path::Path::new("/tmp"), Some("".into()));
310        assert_eq!(extra.len(), 1);
311        let dir = tempfile::tempdir().unwrap();
312        let home = dir.path();
313        std::fs::create_dir_all(home.join(".config/opencode")).unwrap();
314        std::fs::write(home.join(".config/opencode/opencode.json"), "{}").unwrap();
315        std::fs::create_dir_all(home.join("AppData/Roaming/opencode")).unwrap();
316        std::fs::write(home.join("AppData/Roaming/opencode/opencode.json"), "{}").unwrap();
317        let consent = ConsentStore::new(home.join("data"));
318        let found = scan_with_home(home, &consent);
319        let oc: Vec<_> = found
320            .iter()
321            .filter(|f| f.product == Product::OpenCode)
322            .collect();
323        assert!(!oc.is_empty());
324        assert_eq!(
325            oc.iter()
326                .filter(|f| f.path.ends_with("opencode.json"))
327                .count(),
328            2
329        );
330    }
331
332    #[test]
333    fn is_symlink_false_for_regular_and_missing() {
334        let dir = tempfile::tempdir().unwrap();
335        let file = dir.path().join("a.json");
336        std::fs::write(&file, "{}").unwrap();
337        assert!(!is_symlink(&file));
338        assert!(!is_symlink(&dir.path().join("missing")));
339    }
340
341    fn restore_os(key: &str, prev: Option<std::ffi::OsString>) {
342        match prev {
343            Some(v) => unsafe { std::env::set_var(key, v) },
344            None => unsafe { std::env::remove_var(key) },
345        }
346    }
347
348    #[test]
349    fn home_dir_prefers_home_then_userprofile() {
350        let _guard = lock_env();
351        let real_home = std::env::var_os("HOME");
352        let real_profile = std::env::var_os("USERPROFILE");
353        unsafe {
354            std::env::remove_var("HOME");
355            std::env::remove_var("USERPROFILE");
356        }
357        restore_os("HOME", None);
358        restore_os("USERPROFILE", None);
359        unsafe {
360            std::env::set_var("HOME", "");
361            std::env::set_var("USERPROFILE", "/tmp/profile-home");
362        }
363        assert_eq!(
364            home_dir().as_deref(),
365            Some(std::path::Path::new("/tmp/profile-home"))
366        );
367        restore_os("HOME", Some("/tmp/kept-home".into()));
368        restore_os("USERPROFILE", Some("/tmp/kept-profile".into()));
369        assert_eq!(
370            std::env::var_os("HOME").as_deref(),
371            Some(std::ffi::OsStr::new("/tmp/kept-home"))
372        );
373        restore_os("HOME", real_home);
374        restore_os("USERPROFILE", real_profile);
375    }
376
377    #[test]
378    fn scan_returns_empty_without_home() {
379        let _guard = lock_env();
380        let real_home = std::env::var_os("HOME");
381        let real_profile = std::env::var_os("USERPROFILE");
382        unsafe {
383            std::env::remove_var("HOME");
384            std::env::remove_var("USERPROFILE");
385        }
386        let dir = tempfile::tempdir().unwrap();
387        let found = scan(&ConsentStore::new(dir.path()));
388        assert!(found.is_empty());
389        restore_os("HOME", real_home);
390        restore_os("USERPROFILE", real_profile);
391    }
392}