1use std::fs;
13use std::path::{Path, PathBuf};
14
15use crate::schema_cache;
16
17const SKIP_DIRS: &[&str] = &[
20 ".git", "target", "node_modules", "libtorch", "runs",
21 ".cargo", "site", "docs", ".claude",
22];
23
24pub struct CacheEntry {
26 pub cmd_name: String,
28 pub cmd_dir: PathBuf,
30 pub cache_path: PathBuf,
32 pub source_config: Option<PathBuf>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CacheStatus {
42 Fresh,
44 Stale,
46 Orphan,
48}
49
50impl CacheEntry {
51 pub fn status(&self) -> CacheStatus {
52 match &self.source_config {
53 Some(src) => {
54 if schema_cache::is_stale(&self.cache_path, std::slice::from_ref(src)) {
55 CacheStatus::Stale
56 } else {
57 CacheStatus::Fresh
58 }
59 }
60 None => CacheStatus::Orphan,
61 }
62 }
63}
64
65pub fn discover_caches(project_root: &Path) -> Vec<CacheEntry> {
69 let mut out = Vec::new();
70 walk(project_root, &mut out);
71 out.sort_by(|a, b| a.cache_path.cmp(&b.cache_path));
72 out
73}
74
75fn walk(dir: &Path, out: &mut Vec<CacheEntry>) {
76 if let Some(name) = dir.file_name().and_then(|n| n.to_str()) {
77 if SKIP_DIRS.contains(&name) {
78 return;
79 }
80 }
81
82 let cache_dir = dir.join(".fdl").join("schema-cache");
83 if cache_dir.is_dir() {
84 if let Ok(entries) = fs::read_dir(&cache_dir) {
85 for entry in entries.flatten() {
86 let path = entry.path();
87 if path.extension().and_then(|e| e.to_str()) != Some("json") {
88 continue;
89 }
90 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
91 continue;
92 };
93 out.push(CacheEntry {
94 cmd_name: stem.to_string(),
95 cmd_dir: dir.to_path_buf(),
96 cache_path: path,
97 source_config: find_source_config(dir),
98 });
99 }
100 }
101 }
102
103 if let Ok(entries) = fs::read_dir(dir) {
104 for entry in entries.flatten() {
105 let path = entry.path();
106 if path.is_dir() {
107 walk(&path, out);
108 }
109 }
110 }
111}
112
113fn find_source_config(cmd_dir: &Path) -> Option<PathBuf> {
118 for name in &["fdl.yml", "fdl.yaml", "fdl.json"] {
119 let p = cmd_dir.join(name);
120 if p.is_file() {
121 return Some(p);
122 }
123 }
124 None
125}
126
127pub fn clear_caches(project_root: &Path, filter: Option<&str>) -> Result<Vec<PathBuf>, String> {
132 let caches = discover_caches(project_root);
133 let mut removed = Vec::new();
134 let mut touched_dirs: Vec<PathBuf> = Vec::new();
135
136 for entry in &caches {
137 if let Some(name) = filter {
138 if entry.cmd_name != name {
139 continue;
140 }
141 }
142 fs::remove_file(&entry.cache_path)
143 .map_err(|e| format!("cannot remove {}: {e}", entry.cache_path.display()))?;
144 removed.push(entry.cache_path.clone());
145 touched_dirs.push(entry.cmd_dir.clone());
146 }
147
148 touched_dirs.sort();
151 touched_dirs.dedup();
152 for d in touched_dirs {
153 let cache_dir = d.join(".fdl").join("schema-cache");
154 if is_empty_dir(&cache_dir) {
155 let _ = fs::remove_dir(&cache_dir);
156 }
157 let fdl_dir = d.join(".fdl");
158 if is_empty_dir(&fdl_dir) {
159 let _ = fs::remove_dir(&fdl_dir);
160 }
161 }
162
163 Ok(removed)
164}
165
166fn is_empty_dir(p: &Path) -> bool {
167 p.is_dir()
168 && fs::read_dir(p)
169 .map(|mut it| it.next().is_none())
170 .unwrap_or(false)
171}
172
173pub fn refresh_caches(
181 project_root: &Path,
182 filter: Option<&str>,
183) -> Result<Vec<RefreshResult>, String> {
184 let caches = discover_caches(project_root);
185 let mut results = Vec::new();
186
187 for entry in &caches {
188 if let Some(name) = filter {
189 if entry.cmd_name != name {
190 continue;
191 }
192 }
193
194 let outcome = refresh_one(entry);
195 results.push(RefreshResult {
196 cmd_name: entry.cmd_name.clone(),
197 cache_path: entry.cache_path.clone(),
198 outcome,
199 });
200 }
201
202 Ok(results)
203}
204
205pub struct RefreshResult {
206 pub cmd_name: String,
207 pub cache_path: PathBuf,
208 pub outcome: Result<(), String>,
209}
210
211fn refresh_one(entry: &CacheEntry) -> Result<(), String> {
212 let config = crate::config::load_command(&entry.cmd_dir)?;
213 let entry_cmd = config
214 .entry
215 .as_deref()
216 .ok_or_else(|| format!("no `entry:` declared in {}/fdl.yml", entry.cmd_dir.display()))?;
217 let schema = schema_cache::probe(entry_cmd, &entry.cmd_dir)?;
218 schema_cache::write_cache(&entry.cache_path, &schema)
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use std::sync::atomic::{AtomicU64, Ordering};
225
226 struct TempDir(PathBuf);
227 impl TempDir {
228 fn new() -> Self {
229 static N: AtomicU64 = AtomicU64::new(0);
230 let dir = std::env::temp_dir().join(format!(
231 "fdl-schema-test-{}-{}",
232 std::process::id(),
233 N.fetch_add(1, Ordering::Relaxed)
234 ));
235 fs::create_dir_all(&dir).unwrap();
236 Self(dir)
237 }
238 }
239 impl Drop for TempDir {
240 fn drop(&mut self) {
241 let _ = fs::remove_dir_all(&self.0);
242 }
243 }
244
245 fn write_cache(dir: &Path, cmd_name: &str, json: &str) -> PathBuf {
246 let cache_dir = dir.join(".fdl").join("schema-cache");
247 fs::create_dir_all(&cache_dir).unwrap();
248 let path = cache_dir.join(format!("{cmd_name}.json"));
249 fs::write(&path, json).unwrap();
250 path
251 }
252
253 const VALID_SCHEMA_JSON: &str = r#"{"options":{},"args":[]}"#;
254
255 #[test]
256 fn discover_finds_single_cache() {
257 let tmp = TempDir::new();
258 let train = tmp.0.join("train");
259 fs::create_dir_all(&train).unwrap();
260 fs::write(train.join("fdl.yml"), "entry: echo\n").unwrap();
261 write_cache(&train, "train", VALID_SCHEMA_JSON);
262
263 let caches = discover_caches(&tmp.0);
264 assert_eq!(caches.len(), 1);
265 assert_eq!(caches[0].cmd_name, "train");
266 assert_eq!(caches[0].cmd_dir, train);
267 assert!(caches[0].source_config.is_some());
268 }
269
270 #[test]
271 fn discover_finds_multiple_nested_caches() {
272 let tmp = TempDir::new();
273 for name in &["train", "bench", "eval"] {
274 let d = tmp.0.join(name);
275 fs::create_dir_all(&d).unwrap();
276 fs::write(d.join("fdl.yml"), "entry: echo\n").unwrap();
277 write_cache(&d, name, VALID_SCHEMA_JSON);
278 }
279 let caches = discover_caches(&tmp.0);
280 let names: Vec<_> = caches.iter().map(|c| c.cmd_name.as_str()).collect();
281 assert_eq!(names, vec!["bench", "eval", "train"]); }
283
284 #[test]
285 fn discover_skips_target_and_git() {
286 let tmp = TempDir::new();
287 for noise in &["target", ".git", "node_modules"] {
289 let d = tmp.0.join(noise);
290 fs::create_dir_all(&d).unwrap();
291 write_cache(&d, "decoy", VALID_SCHEMA_JSON);
292 }
293 let train = tmp.0.join("train");
295 fs::create_dir_all(&train).unwrap();
296 fs::write(train.join("fdl.yml"), "entry: echo\n").unwrap();
297 write_cache(&train, "train", VALID_SCHEMA_JSON);
298
299 let caches = discover_caches(&tmp.0);
300 assert_eq!(caches.len(), 1);
301 assert_eq!(caches[0].cmd_name, "train");
302 }
303
304 #[test]
305 fn status_fresh_when_cache_newer_than_source() {
306 let tmp = TempDir::new();
307 let train = tmp.0.join("train");
308 fs::create_dir_all(&train).unwrap();
309 fs::write(train.join("fdl.yml"), "entry: echo\n").unwrap();
310 std::thread::sleep(std::time::Duration::from_millis(10));
312 write_cache(&train, "train", VALID_SCHEMA_JSON);
313 let caches = discover_caches(&tmp.0);
314 assert_eq!(caches[0].status(), CacheStatus::Fresh);
315 }
316
317 #[test]
318 fn status_stale_when_source_newer_than_cache() {
319 let tmp = TempDir::new();
320 let train = tmp.0.join("train");
321 fs::create_dir_all(&train).unwrap();
322 write_cache(&train, "train", VALID_SCHEMA_JSON);
323 std::thread::sleep(std::time::Duration::from_millis(10));
324 fs::write(train.join("fdl.yml"), "entry: echo\n").unwrap();
325 let caches = discover_caches(&tmp.0);
326 assert_eq!(caches[0].status(), CacheStatus::Stale);
327 }
328
329 #[test]
330 fn status_orphan_when_no_source_config() {
331 let tmp = TempDir::new();
332 let dir = tmp.0.join("lonely");
333 fs::create_dir_all(&dir).unwrap();
334 write_cache(&dir, "lonely", VALID_SCHEMA_JSON);
335 let caches = discover_caches(&tmp.0);
336 assert_eq!(caches[0].status(), CacheStatus::Orphan);
337 }
338
339 #[test]
340 fn clear_removes_all_caches_when_no_filter() {
341 let tmp = TempDir::new();
342 for name in &["a", "b"] {
343 let d = tmp.0.join(name);
344 fs::create_dir_all(&d).unwrap();
345 fs::write(d.join("fdl.yml"), "entry: echo\n").unwrap();
346 write_cache(&d, name, VALID_SCHEMA_JSON);
347 }
348 let removed = clear_caches(&tmp.0, None).unwrap();
349 assert_eq!(removed.len(), 2);
350 assert!(discover_caches(&tmp.0).is_empty());
351 assert!(!tmp.0.join("a").join(".fdl").exists());
353 assert!(!tmp.0.join("b").join(".fdl").exists());
354 }
355
356 #[test]
357 fn clear_respects_filter() {
358 let tmp = TempDir::new();
359 for name in &["keep", "drop"] {
360 let d = tmp.0.join(name);
361 fs::create_dir_all(&d).unwrap();
362 fs::write(d.join("fdl.yml"), "entry: echo\n").unwrap();
363 write_cache(&d, name, VALID_SCHEMA_JSON);
364 }
365 let removed = clear_caches(&tmp.0, Some("drop")).unwrap();
366 assert_eq!(removed.len(), 1);
367 assert!(removed[0].to_string_lossy().contains("drop"));
368 let remaining: Vec<_> = discover_caches(&tmp.0)
369 .into_iter()
370 .map(|c| c.cmd_name)
371 .collect();
372 assert_eq!(remaining, vec!["keep".to_string()]);
373 }
374
375 #[test]
376 fn clear_filter_matching_nothing_is_a_noop() {
377 let tmp = TempDir::new();
378 let d = tmp.0.join("a");
379 fs::create_dir_all(&d).unwrap();
380 fs::write(d.join("fdl.yml"), "entry: echo\n").unwrap();
381 write_cache(&d, "a", VALID_SCHEMA_JSON);
382 let removed = clear_caches(&tmp.0, Some("nonexistent")).unwrap();
383 assert!(removed.is_empty());
384 assert_eq!(discover_caches(&tmp.0).len(), 1);
385 }
386}