1use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::Store;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct ProjectEntry {
19 pub path: String,
21 #[serde(default)]
23 pub name: String,
24}
25
26#[derive(Debug, Default, Serialize, Deserialize)]
27struct RegistryFile {
28 #[serde(default)]
29 projects: Vec<String>,
30}
31
32const SKIP_DIRS: &[&str] = &[
34 "node_modules",
35 "target",
36 ".git",
37 ".hg",
38 ".svn",
39 ".plastic",
40 ".jj",
41 ".cache",
42 "dist",
43 "build",
44 "out",
45 ".svelte-kit",
46 ".next",
47 ".venv",
48 "venv",
49 "vendor",
50 ".gradle",
51 ".idea",
52 ".vs",
53 "Library", "Temp",
55];
56
57fn registry_path() -> Option<PathBuf> {
60 if let Ok(dir) = std::env::var("WIPE_CONFIG_DIR") {
61 if !dir.trim().is_empty() {
62 return Some(PathBuf::from(dir).join("projects.json"));
63 }
64 }
65 directories::ProjectDirs::from("dev", "wipe", "wipe")
66 .map(|d| d.config_dir().join("projects.json"))
67}
68
69fn load() -> RegistryFile {
70 registry_path()
71 .and_then(|p| std::fs::read(p).ok())
72 .and_then(|b| serde_json::from_slice(&b).ok())
73 .unwrap_or_default()
74}
75
76fn save(reg: &RegistryFile) {
77 if let Some(path) = registry_path() {
78 if let Some(dir) = path.parent() {
79 let _ = std::fs::create_dir_all(dir);
80 }
81 if let Ok(mut s) = serde_json::to_string_pretty(reg) {
82 s.push('\n');
83 let tmp = path.with_extension("json.tmp");
85 if std::fs::write(&tmp, s).is_ok() {
86 let _ = std::fs::rename(&tmp, &path);
87 }
88 }
89 }
90}
91
92fn key_for(root: &Path) -> String {
94 std::fs::canonicalize(root)
95 .unwrap_or_else(|_| root.to_path_buf())
96 .display()
97 .to_string()
98}
99
100pub fn register(root: &Path) -> bool {
104 let key = key_for(root);
105 let mut reg = load();
106 if reg.projects.iter().any(|p| p == &key) {
107 return false;
108 }
109 reg.projects.push(key);
110 reg.projects.sort();
111 save(®);
112 true
113}
114
115pub fn prune() {
117 let mut reg = load();
118 let before = reg.projects.len();
119 reg.projects.retain(|p| Store::open(p).is_ok());
120 if reg.projects.len() != before {
121 save(®);
122 }
123}
124
125pub fn list() -> Vec<ProjectEntry> {
128 load()
129 .projects
130 .into_iter()
131 .filter_map(|path| {
132 let store = Store::open(&path).ok()?;
133 let name = store.load_board().map(|b| b.name).unwrap_or_default();
134 Some(ProjectEntry { path, name })
135 })
136 .collect()
137}
138
139pub fn default_scan_roots() -> Vec<PathBuf> {
141 directories::UserDirs::new()
142 .map(|d| vec![d.home_dir().to_path_buf()])
143 .unwrap_or_default()
144}
145
146pub fn scan(roots: &[PathBuf], max_depth: usize) -> Vec<String> {
151 let mut found = Vec::new();
152 for root in roots {
153 let mut budget: usize = 40_000;
156 scan_dir(root, max_depth, &mut budget, &mut found);
157 }
158 found
159}
160
161fn scan_dir(dir: &Path, depth_left: usize, budget: &mut usize, found: &mut Vec<String>) {
162 if *budget == 0 {
163 return;
164 }
165 *budget -= 1;
166
167 if dir.join(crate::WIPE_DIR).is_dir() {
169 if register(dir) {
170 found.push(key_for(dir));
171 }
172 return;
173 }
174 if depth_left == 0 {
175 return;
176 }
177 let Ok(entries) = std::fs::read_dir(dir) else {
178 return;
179 };
180 for entry in entries.flatten() {
181 let Ok(ft) = entry.file_type() else { continue };
182 if !ft.is_dir() {
183 continue;
184 }
185 let name = entry.file_name();
186 let name = name.to_string_lossy();
187 if name.starts_with('.') && name != "." || SKIP_DIRS.iter().any(|s| s == &name) {
188 continue;
191 }
192 scan_dir(&entry.path(), depth_left - 1, budget, found);
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn scan_finds_nested_boards() {
202 let tmp = tempfile::tempdir().unwrap();
203 std::env::set_var("WIPE_CONFIG_DIR", tmp.path().join("cfg"));
205
206 let a = tmp.path().join("proj-a");
207 let b = tmp.path().join("nested").join("deep").join("proj-b");
208 std::fs::create_dir_all(&a).unwrap();
209 std::fs::create_dir_all(&b).unwrap();
210 Store::init(&a, "A", chrono::Utc::now()).unwrap();
211 Store::init(&b, "B", chrono::Utc::now()).unwrap();
212 let hidden = tmp.path().join("node_modules").join("proj-c");
214 std::fs::create_dir_all(&hidden).unwrap();
215 Store::init(&hidden, "C", chrono::Utc::now()).unwrap();
216
217 let found = scan(&[tmp.path().to_path_buf()], 8);
218 let names: Vec<String> = list().into_iter().map(|p| p.name).collect();
219 assert!(names.contains(&"A".to_string()), "found A");
220 assert!(names.contains(&"B".to_string()), "found B (nested)");
221 assert!(
222 !names.contains(&"C".to_string()),
223 "C under node_modules skipped"
224 );
225 assert_eq!(found.len(), 2);
226
227 std::env::remove_var("WIPE_CONFIG_DIR");
228 }
229}