use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::Store;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectEntry {
pub path: String,
#[serde(default)]
pub name: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct RegistryFile {
#[serde(default)]
projects: Vec<String>,
}
const SKIP_DIRS: &[&str] = &[
"node_modules",
"target",
".git",
".hg",
".svn",
".plastic",
".jj",
".cache",
"dist",
"build",
"out",
".svelte-kit",
".next",
".venv",
"venv",
"vendor",
".gradle",
".idea",
".vs",
"Library", "Temp",
];
fn registry_path() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("WIPE_CONFIG_DIR") {
if !dir.trim().is_empty() {
return Some(PathBuf::from(dir).join("projects.json"));
}
}
directories::ProjectDirs::from("dev", "wipe", "wipe")
.map(|d| d.config_dir().join("projects.json"))
}
fn load() -> RegistryFile {
registry_path()
.and_then(|p| std::fs::read(p).ok())
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save(reg: &RegistryFile) {
if let Some(path) = registry_path() {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(mut s) = serde_json::to_string_pretty(reg) {
s.push('\n');
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, s).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
}
fn key_for(root: &Path) -> String {
std::fs::canonicalize(root)
.unwrap_or_else(|_| root.to_path_buf())
.display()
.to_string()
}
pub fn register(root: &Path) -> bool {
let key = key_for(root);
let mut reg = load();
if reg.projects.iter().any(|p| p == &key) {
return false;
}
reg.projects.push(key);
reg.projects.sort();
save(®);
true
}
pub fn prune() {
let mut reg = load();
let before = reg.projects.len();
reg.projects.retain(|p| Store::open(p).is_ok());
if reg.projects.len() != before {
save(®);
}
}
pub fn list() -> Vec<ProjectEntry> {
load()
.projects
.into_iter()
.filter_map(|path| {
let store = Store::open(&path).ok()?;
let name = store.load_board().map(|b| b.name).unwrap_or_default();
Some(ProjectEntry { path, name })
})
.collect()
}
pub fn default_scan_roots() -> Vec<PathBuf> {
directories::UserDirs::new()
.map(|d| vec![d.home_dir().to_path_buf()])
.unwrap_or_default()
}
pub fn scan(roots: &[PathBuf], max_depth: usize) -> Vec<String> {
let mut found = Vec::new();
for root in roots {
let mut budget: usize = 40_000;
scan_dir(root, max_depth, &mut budget, &mut found);
}
found
}
fn scan_dir(dir: &Path, depth_left: usize, budget: &mut usize, found: &mut Vec<String>) {
if *budget == 0 {
return;
}
*budget -= 1;
if dir.join(crate::WIPE_DIR).is_dir() {
if register(dir) {
found.push(key_for(dir));
}
return;
}
if depth_left == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
if !ft.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') && name != "." || SKIP_DIRS.iter().any(|s| s == &name) {
continue;
}
scan_dir(&entry.path(), depth_left - 1, budget, found);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scan_finds_nested_boards() {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("WIPE_CONFIG_DIR", tmp.path().join("cfg"));
let a = tmp.path().join("proj-a");
let b = tmp.path().join("nested").join("deep").join("proj-b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
Store::init(&a, "A", chrono::Utc::now()).unwrap();
Store::init(&b, "B", chrono::Utc::now()).unwrap();
let hidden = tmp.path().join("node_modules").join("proj-c");
std::fs::create_dir_all(&hidden).unwrap();
Store::init(&hidden, "C", chrono::Utc::now()).unwrap();
let found = scan(&[tmp.path().to_path_buf()], 8);
let names: Vec<String> = list().into_iter().map(|p| p.name).collect();
assert!(names.contains(&"A".to_string()), "found A");
assert!(names.contains(&"B".to_string()), "found B (nested)");
assert!(
!names.contains(&"C".to_string()),
"C under node_modules skipped"
);
assert_eq!(found.len(), 2);
std::env::remove_var("WIPE_CONFIG_DIR");
}
}