Skip to main content

wipe_core/
registry.rs

1//! A machine-wide registry of known wipe projects, plus a filesystem scanner.
2//!
3//! Because collaboration is git-only, there's no server that "knows" your
4//! projects. The daemon records every board it serves here so the UI can list and
5//! switch between them - but a board cloned onto a fresh machine has never been
6//! served, so it wouldn't appear. [`scan`] closes that gap by walking the disk for
7//! `.wipe` directories and registering whatever it finds, so serving from anywhere
8//! surfaces every board you have locally.
9
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::Store;
15
16/// One registered project.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct ProjectEntry {
19    /// Absolute path to the project root (the parent of `.wipe`).
20    pub path: String,
21    /// Board name, resolved when listed (best-effort).
22    #[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
32/// Directory names never worth descending into while scanning for boards.
33const 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", // Unity's generated folder (huge)
54    "Temp",
55];
56
57/// Path to the registry JSON. Honors `$WIPE_CONFIG_DIR` (for test isolation and
58/// pinning), else the user's platform config dir.
59fn 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            // Write-then-rename: a concurrent reader never sees a half-written file.
84            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
92/// Canonical registry key for a project root.
93fn 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
100/// Record a project root in the registry (idempotent). Best-effort: persistence
101/// failures are ignored so serving never breaks over a registry issue. Returns
102/// true if it was newly added.
103pub 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(&reg);
112    true
113}
114
115/// Remove any registered projects whose `.wipe` no longer exists on disk.
116pub 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(&reg);
122    }
123}
124
125/// List all registered projects that still have a `.wipe` board, annotating each
126/// with its current board name.
127pub 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
139/// Default roots to scan when none are configured: the user's home directory.
140pub 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
146/// Walk `roots` (to `max_depth` levels) for `.wipe` boards and register each one.
147/// Returns the newly-registered roots. Heavy/generated directories are skipped and
148/// a board directory is never descended into (boards don't nest). A visit cap
149/// bounds the worst case on very large trees.
150pub fn scan(roots: &[PathBuf], max_depth: usize) -> Vec<String> {
151    let mut found = Vec::new();
152    for root in roots {
153        // Give each root its own visit budget so an earlier, huge root can't
154        // starve later ones (e.g. the cwd appended after the home dir).
155        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    // A board here: register and stop descending (boards don't contain boards).
168    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            // Skip dotfolders and known-heavy generated dirs (but a `.wipe` at this
189            // level was already handled above).
190            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        // Isolate the registry to this test.
204        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        // A board buried under a skipped dir must NOT be found.
213        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}