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