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",
21    "target",
22    "node_modules",
23    "libtorch",
24    "runs",
25    ".cargo",
26    "site",
27    "docs",
28    ".claude",
29];
30
31/// One cached schema discovered on disk.
32pub struct CacheEntry {
33    /// Command name (filename stem).
34    pub cmd_name: String,
35    /// Directory that holds the command's `fdl.yml` and `.fdl/`.
36    pub cmd_dir: PathBuf,
37    /// Full path to the cache JSON file.
38    pub cache_path: PathBuf,
39    /// Path to the command's primary config file (the mtime anchor).
40    /// `None` when no `fdl.yml` / `fdl.yaml` / `fdl.json` was found — the
41    /// cache exists but has no reference to compare against, which is
42    /// reported as a dedicated status.
43    pub source_config: Option<PathBuf>,
44}
45
46/// Freshness of a cache file relative to its source `fdl.yml`.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum CacheStatus {
49    /// Cache file's mtime is newer than the source config.
50    Fresh,
51    /// Source config has been modified since the cache was written.
52    Stale,
53    /// Cache exists but no source config was found alongside it.
54    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
72/// Scan `project_root` recursively for `.fdl/schema-cache/*.json` files.
73/// Skips common noise dirs (`SKIP_DIRS`). Results are sorted by cache
74/// path for stable `fdl schema list` output.
75pub 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
120/// Pick the primary config file (`fdl.yml` > `fdl.yaml` > `fdl.json`)
121/// that sits next to a cache's command dir. Matches the preference
122/// order used by `crate::overlay::EXTENSIONS` via
123/// `crate::config::CONFIG_NAMES`.
124fn 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
134/// Delete cache files. `filter` restricts the operation to a single
135/// command name; `None` clears all discovered caches. Empty parent
136/// `.fdl/schema-cache/` and `.fdl/` dirs are removed when nothing else
137/// lives inside them. Returns the list of removed cache paths.
138pub 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    // Prune now-empty parent dirs. Best-effort: ignore errors, since
156    // other processes could have written files in the meantime.
157    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
180/// Probe each cached command's entry and rewrite its cache file.
181/// `filter` scopes to a single command name. Returns per-cache results
182/// so the caller can print a summary.
183///
184/// Cargo entries that haven't been built will surface their probe
185/// failure — the user is expected to build first, same contract as the
186/// per-command `fdl <cmd> --refresh-schema` flag.
187pub 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"]); // sorted by path
291    }
292
293    #[test]
294    fn discover_skips_target_and_git() {
295        let tmp = TempDir::new();
296        // Decoy caches under skipped dirs.
297        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        // Real cache.
303        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        // Sleep briefly then write cache so its mtime is strictly newer.
320        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        // Empty `.fdl/` parents cleaned up too.
361        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}