1use std::path::{Path, PathBuf};
4
5use crate::consent::ConsentStore;
6use crate::types::{FoundSource, Product, SourceState};
7
8#[derive(Debug, Clone, Copy)]
10pub struct KnownSource {
11 pub product: Product,
12 pub rel_path: &'static str,
13}
14
15pub 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
54pub 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 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
138pub 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 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}