Skip to main content

flodl_cli/
schema.rs

1//! `fdl schema` — inspect, clear, and refresh cached `--fdl-schema`
2//! outputs across the project.
3//!
4//! Caches live at `<cmd_dir>/.fdl/schema-cache/<cmd-name>.json` (see
5//! [`crate::schema_cache`] for the per-command mechanics). This module
6//! walks the project tree to find every cache, reports staleness, and
7//! exposes clear / refresh operations. It's intentionally a filesystem
8//! scan rather than a command-graph walk: any layout that ends up
9//! writing a cache file gets discovered, regardless of how the
10//! `commands:` tree is shaped.
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use crate::schema_cache;
16
17/// Directories that never contain valid schema caches — skip them to
18/// keep scans fast on large repos.
19const SKIP_DIRS: &[&str] = &[
20    ".git", "target", "node_modules", "libtorch", "runs",
21    ".cargo", "site", "docs", ".claude",
22];
23
24/// One cached schema discovered on disk.
25pub struct CacheEntry {
26    /// Command name (filename stem).
27    pub cmd_name: String,
28    /// Directory that holds the command's `fdl.yml` and `.fdl/`.
29    pub cmd_dir: PathBuf,
30    /// Full path to the cache JSON file.
31    pub cache_path: PathBuf,
32    /// Path to the command's primary config file (the mtime anchor).
33    /// `None` when no `fdl.yml` / `fdl.yaml` / `fdl.json` was found — the
34    /// cache exists but has no reference to compare against, which is
35    /// reported as a dedicated status.
36    pub source_config: Option<PathBuf>,
37}
38
39/// Freshness of a cache file relative to its source `fdl.yml`.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CacheStatus {
42    /// Cache file's mtime is newer than the source config.
43    Fresh,
44    /// Source config has been modified since the cache was written.
45    Stale,
46    /// Cache exists but no source config was found alongside it.
47    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
65/// Scan `project_root` recursively for `.fdl/schema-cache/*.json` files.
66/// Skips common noise dirs (`SKIP_DIRS`). Results are sorted by cache
67/// path for stable `fdl schema list` output.
68pub 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
113/// Pick the primary config file (`fdl.yml` > `fdl.yaml` > `fdl.json`)
114/// that sits next to a cache's command dir. Matches the preference
115/// order used by `crate::overlay::EXTENSIONS` via
116/// `crate::config::CONFIG_NAMES`.
117fn 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
127/// Delete cache files. `filter` restricts the operation to a single
128/// command name; `None` clears all discovered caches. Empty parent
129/// `.fdl/schema-cache/` and `.fdl/` dirs are removed when nothing else
130/// lives inside them. Returns the list of removed cache paths.
131pub 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    // Prune now-empty parent dirs. Best-effort: ignore errors, since
149    // other processes could have written files in the meantime.
150    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
173/// Probe each cached command's entry and rewrite its cache file.
174/// `filter` scopes to a single command name. Returns per-cache results
175/// so the caller can print a summary.
176///
177/// Cargo entries that haven't been built will surface their probe
178/// failure — the user is expected to build first, same contract as the
179/// per-command `fdl <cmd> --refresh-schema` flag.
180pub 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"]); // sorted by path
282    }
283
284    #[test]
285    fn discover_skips_target_and_git() {
286        let tmp = TempDir::new();
287        // Decoy caches under skipped dirs.
288        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        // Real cache.
294        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        // Sleep briefly then write cache so its mtime is strictly newer.
311        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        // Empty `.fdl/` parents cleaned up too.
352        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}