Skip to main content

flodl_cli/
schema_cache.rs

1//! `--fdl-schema` binary contract: probe, validate, and cache.
2//!
3//! A sub-command binary that opts into the contract exposes a single
4//! `--fdl-schema` flag printing a JSON schema describing its CLI surface.
5//! `flodl-cli` caches the output under `<cmd_dir>/.fdl/schema-cache/<cmd>.json`
6//! and prefers it over any inline YAML schema declared in `fdl.yaml`.
7//!
8//! **Cargo entries** (`entry: cargo run ...`) are *not* auto-probed: invoking
9//! them forces a full compile, which is unacceptable latency for `fdl --help`.
10//! For those, users run `fdl <cmd> --refresh-schema` explicitly after a build.
11//!
12//! Cache invalidation is mtime-based: the cache file's mtime is compared
13//! against every path that could change the schema — the command's config
14//! file AND, for a binary that declares its own surface, the sources that
15//! surface is compiled from (see
16//! [`schema_source_refs`](crate::schema_cache::schema_source_refs)). A cache
17//! older than any of them is stale. Users can also force-refresh.
18
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22use std::time::SystemTime;
23
24use crate::config::{self, Schema};
25
26/// Directory where all schema caches live, relative to the command dir.
27const CACHE_DIR: &str = ".fdl/schema-cache";
28
29/// Directories never worth walking when collecting schema sources: build
30/// output, caches, and data. Skipping `target` is the one that matters —
31/// it dwarfs the source tree.
32const SOURCE_SKIP_DIRS: &[&str] = &[
33    "target",
34    ".fdl",
35    ".git",
36    "node_modules",
37    "runs",
38    "data",
39    "baselines",
40    "libtorch",
41    ".cargo",
42];
43
44/// Hard ceiling on files examined. `fdl <cmd> -h` must never be slow, so a
45/// pathological tree costs a bounded scan and then gives up — degrading to
46/// today's config-only invalidation rather than stalling help.
47const MAX_SOURCE_REFS: usize = 4096;
48
49/// Every file whose edit could change a command's `--fdl-schema` output.
50///
51/// The schema of a cargo entry is *compiled from* the crate's Rust sources, so
52/// watching only `fdl.yml` (as this did originally) meant editing a CLI struct
53/// left a stale cache with no signal at all: `-h` kept rendering the previous
54/// surface until someone touched the yml or deleted the cache by hand.
55///
56/// Deliberately coarse — every `.rs` plus `Cargo.toml` under the command dir,
57/// not an attempt to find the files that *define* the schema. Over-watching
58/// costs one extra probe, exactly what editing the yml already costs.
59/// Under-watching is the bug being fixed, and a precise scan would reintroduce
60/// it the moment a `#[derive(FdlArgs)]` struct referenced a constant from
61/// another module. Measured at 23 files / 0.12 ms for `ddp-bench`, against a
62/// probe that spins a container and runs cargo — the precision is not worth
63/// buying.
64///
65/// Scoped to the command's OWN directory on purpose. Following its dependency
66/// crates would invalidate the cache on every edit anywhere in the workspace,
67/// which in a repo whose library changes constantly means compiling on nearly
68/// every `-h` — the cost this cache exists to avoid.
69pub fn schema_source_refs(cmd_dir: &Path) -> Vec<PathBuf> {
70    let mut out = Vec::new();
71    let mut stack = vec![cmd_dir.to_path_buf()];
72    while let Some(dir) = stack.pop() {
73        if out.len() >= MAX_SOURCE_REFS {
74            break;
75        }
76        let Ok(rd) = fs::read_dir(&dir) else { continue };
77        for entry in rd.flatten() {
78            let path = entry.path();
79            let Ok(ft) = entry.file_type() else { continue };
80            let name = entry.file_name();
81            let name = name.to_string_lossy();
82            if ft.is_dir() {
83                if !name.starts_with('.') && !SOURCE_SKIP_DIRS.contains(&name.as_ref()) {
84                    stack.push(path);
85                }
86            } else if name.ends_with(".rs") || name == "Cargo.toml" {
87                out.push(path);
88            }
89        }
90    }
91    out
92}
93
94/// Resolve the cache file path for a given command dir and name.
95pub fn cache_path(cmd_dir: &Path, cmd_name: &str) -> PathBuf {
96    cmd_dir.join(CACHE_DIR).join(format!("{cmd_name}.json"))
97}
98
99/// Read a schema cache file, returning `Some` only if it parses cleanly
100/// and survives validation. Parse or validation errors are treated as
101/// "no cache" (the caller falls through to the inline/YAML schema).
102pub fn read_cache(path: &Path) -> Option<Schema> {
103    let content = fs::read_to_string(path).ok()?;
104    let schema: Schema = serde_json::from_str(&content).ok()?;
105    config::validate_schema(&schema).ok()?;
106    Some(schema)
107}
108
109/// Consider a cache "stale" if it is older than the command's fdl.yml
110/// (config changes), or older than a sentinel binary path when supplied.
111///
112/// Missing cache ⇒ stale (return true). Missing reference mtime ⇒ treat
113/// the cache as fresh (conservative: don't refresh what we can't justify).
114pub fn is_stale(cache: &Path, reference_mtimes: &[PathBuf]) -> bool {
115    let Some(cache_mtime) = mtime(cache) else {
116        return true;
117    };
118    reference_mtimes
119        .iter()
120        .filter_map(|p| mtime(p))
121        .any(|ref_m| ref_m > cache_mtime)
122}
123
124fn mtime(path: &Path) -> Option<SystemTime> {
125    fs::metadata(path).ok()?.modified().ok()
126}
127
128/// Serialize a schema to the cache file, creating parent dirs as needed.
129pub fn write_cache(path: &Path, schema: &Schema) -> Result<(), String> {
130    if let Some(parent) = path.parent() {
131        fs::create_dir_all(parent)
132            .map_err(|e| format!("cannot create {}: {}", parent.display(), e))?;
133    }
134    let json =
135        serde_json::to_string_pretty(schema).map_err(|e| format!("schema serialize: {e}"))?;
136    fs::write(path, json).map_err(|e| format!("cannot write {}: {}", path.display(), e))
137}
138
139/// Probe a binary for its schema by running `<entry> --fdl-schema` via the
140/// shell and parsing stdout as JSON.
141///
142/// `cmd_dir` is the directory containing the `fdl.yml` that declared the
143/// entry — it serves as the cwd for the shell unless the entry is wrapped
144/// through docker (then the wrap walks up to the nearest
145/// `docker-compose.yml` for compose's cwd).
146///
147/// `docker_service` carries the `docker:` field from the resolved
148/// command config. When set AND we're not already inside a container,
149/// the invocation is wrapped as
150/// `docker compose run --rm <service> bash -c '<entry> --fdl-schema'`
151/// so cargo entries that need libtorch get probed inside the dev
152/// container instead of failing silently on the host. When unset, the
153/// entry runs directly on the host.
154///
155/// On failure returns a string error rather than panicking — callers
156/// almost always want to fall back to the inline schema (or none).
157pub fn probe(entry: &str, cmd_dir: &Path, docker_service: Option<&str>) -> Result<Schema, String> {
158    if entry.trim().is_empty() {
159        return Err("entry is empty".into());
160    }
161
162    let inner = format!("{entry} --fdl-schema");
163    let (invocation, run_cwd) = match docker_service {
164        Some(svc) if !inside_docker() => {
165            let compose_root = find_docker_compose_root(cmd_dir).ok_or_else(|| {
166                format!(
167                    "cannot probe schema: docker:{svc} declared but no \
168                     docker-compose.yml found above {}",
169                    cmd_dir.display()
170                )
171            })?;
172            // The container starts in its configured workdir (the
173            // compose root's mount), NOT the command dir — without a
174            // `cd`, a cargo entry builds and probes the WORKSPACE
175            // default binary instead of the command's (observed:
176            // `fdl ddp-bench --refresh-schema` probing `fdl` itself,
177            // which rejects `--fdl-schema`). The command dir's path
178            // relative to the compose root is the same on both sides
179            // of the mount, so prefix the entry with a relative cd.
180            let inner_in_container = match cmd_dir
181                .strip_prefix(&compose_root)
182                .ok()
183                .filter(|rel| !rel.as_os_str().is_empty())
184            {
185                Some(rel) => format!("cd {} && {inner}", posix_quote(&rel.to_string_lossy())),
186                None => inner,
187            };
188            let wrapped = format!(
189                "docker compose run --rm {svc} bash -c {}",
190                posix_quote(&inner_in_container)
191            );
192            (wrapped, compose_root)
193        }
194        _ => (inner, cmd_dir.to_path_buf()),
195    };
196
197    let (shell, flag) = if cfg!(target_os = "windows") {
198        ("cmd", "/C")
199    } else {
200        ("sh", "-c")
201    };
202    let output = Command::new(shell)
203        .args([flag, &invocation])
204        .current_dir(&run_cwd)
205        .stdout(Stdio::piped())
206        .stderr(Stdio::piped())
207        .output()
208        .map_err(|e| format!("spawn `{invocation}`: {e}"))?;
209
210    if !output.status.success() {
211        let stderr = String::from_utf8_lossy(&output.stderr);
212        return Err(format!(
213            "`{invocation}` exited with {}: {}",
214            output.status,
215            stderr.trim()
216        ));
217    }
218
219    // Tolerate leading lines of cargo chatter by locating the first `{`.
220    let stdout = String::from_utf8_lossy(&output.stdout);
221    let start = stdout
222        .find('{')
223        .ok_or_else(|| "no JSON object in --fdl-schema output".to_string())?;
224    let schema: Schema = serde_json::from_str(&stdout[start..])
225        .map_err(|e| format!("--fdl-schema did not emit valid JSON: {e}"))?;
226    config::validate_schema(&schema)
227        .map_err(|e| format!("--fdl-schema output failed validation: {e}"))?;
228    Ok(schema)
229}
230
231/// Heuristic: cargo entries compile-on-run, so they are never auto-probed.
232/// Probing must be explicit (`fdl <cmd> --refresh-schema`) for those.
233pub fn is_cargo_entry(entry: &str) -> bool {
234    entry.trim_start().starts_with("cargo ")
235}
236
237/// True when this process is running inside a Docker container. Mirrors
238/// the `/.dockerenv` heuristic used elsewhere in the crate.
239fn inside_docker() -> bool {
240    Path::new("/.dockerenv").exists()
241}
242
243/// Climb from `start` looking for a directory containing
244/// `docker-compose.yml` (the compose root used as cwd for `docker
245/// compose` invocations). Returns `None` if none is found before
246/// hitting the filesystem root.
247fn find_docker_compose_root(start: &Path) -> Option<PathBuf> {
248    let mut dir = start.to_path_buf();
249    loop {
250        if dir.join("docker-compose.yml").exists() {
251            return Some(dir);
252        }
253        if !dir.pop() {
254            return None;
255        }
256    }
257}
258
259use crate::util::shell::posix_quote;
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::collections::BTreeMap;
265    use std::io::Write;
266
267    /// Scoped test directory under `std::env::temp_dir()` that cleans up on drop.
268    /// Zero-external-dep replacement for `tempfile::tempdir()`.
269    struct TestDir {
270        path: PathBuf,
271    }
272
273    /// Uniqueness within the process comes from a counter, not from the
274    /// clock. `SystemTime::now` is quantised to whatever tick the platform
275    /// keeps, which `as_nanos` does not tell you, and nine of these tests
276    /// share the `sc` tag and start within the same millisecond: two that
277    /// land on one tick get one directory, and the first to finish deletes
278    /// the other's script out from under a running `sh`. Seen once on the
279    /// macOS runner as an exit-127 `bad.sh: No such file or directory`,
280    /// where the timestamp was the only thing keeping the paths apart.
281    static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
282
283    impl TestDir {
284        fn new(tag: &str) -> Self {
285            let n = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
286            let pid = std::process::id();
287            let path = std::env::temp_dir().join(format!("fdl-test-{tag}-{pid}-{n}"));
288            fs::create_dir_all(&path).expect("create test dir");
289            Self { path }
290        }
291
292        fn path(&self) -> &Path {
293            &self.path
294        }
295    }
296
297    impl Drop for TestDir {
298        fn drop(&mut self) {
299            let _ = fs::remove_dir_all(&self.path);
300        }
301    }
302
303    fn minimal_schema() -> Schema {
304        let mut options = BTreeMap::new();
305        options.insert(
306            "model".into(),
307            config::OptionSpec {
308                ty: "string".into(),
309                description: Some("pick a model".into()),
310                default: Some(serde_json::json!("mlp")),
311                choices: Some(vec![serde_json::json!("mlp"), serde_json::json!("resnet")]),
312                short: Some("m".into()),
313                env: None,
314                completer: None,
315            },
316        );
317        Schema {
318            args: Vec::new(),
319            options,
320            strict: false,
321            ..Schema::default()
322        }
323    }
324
325    #[test]
326    fn cache_roundtrip_preserves_schema() {
327        let tmp = TestDir::new("sc");
328        let path = cache_path(tmp.path(), "ddp-bench");
329        let schema = minimal_schema();
330        write_cache(&path, &schema).expect("write cache");
331
332        let read = read_cache(&path).expect("round-trip parses");
333        let orig_model = schema.options.get("model").unwrap();
334        let round_model = read.options.get("model").unwrap();
335        assert_eq!(orig_model.ty, round_model.ty);
336        assert_eq!(orig_model.short, round_model.short);
337        assert_eq!(orig_model.choices, round_model.choices);
338    }
339
340    #[test]
341    fn read_cache_rejects_invalid_json() {
342        let tmp = TestDir::new("sc");
343        let path = tmp.path().join("bad.json");
344        fs::write(&path, "not json at all").unwrap();
345        assert!(read_cache(&path).is_none());
346    }
347
348    #[test]
349    fn read_cache_unknown_field_falls_back_to_none() {
350        // Version-skew guard: a schema emitted by a NEWER
351        // flodl-cli-macros than this fdl knows (extra field) must not
352        // parse partially (deny_unknown_fields) — and the probe layer
353        // degrades to "no cache" so help still renders from the inline
354        // yml schema or none.
355        let tmp = TestDir::new("sc");
356        let path = tmp.path().join("newer.json");
357        let body = r#"{
358            "options": {
359                "model": { "type": "string" }
360            },
361            "field_from_the_future": true
362        }"#;
363        fs::write(&path, body).unwrap();
364        assert!(read_cache(&path).is_none());
365    }
366
367    #[test]
368    fn read_cache_rejects_validation_failure() {
369        // A schema that clears validation at struct level but fails
370        // semantic validation: shadowed fdl-level flag `--help`.
371        let tmp = TestDir::new("sc");
372        let path = tmp.path().join("bad_sem.json");
373        let body = r#"{
374            "options": {
375                "help": { "type": "bool" }
376            }
377        }"#;
378        fs::write(&path, body).unwrap();
379        assert!(
380            read_cache(&path).is_none(),
381            "cache must not return a schema that fails validate_schema"
382        );
383    }
384
385    #[test]
386    fn is_stale_missing_cache_is_stale() {
387        let tmp = TestDir::new("sc");
388        let path = tmp.path().join("missing.json");
389        assert!(is_stale(&path, &[]));
390    }
391
392    #[test]
393    fn is_stale_compares_mtimes() {
394        let tmp = TestDir::new("sc");
395        let cache = tmp.path().join("cache.json");
396        let source = tmp.path().join("fdl.yml");
397        fs::write(&cache, "{}").unwrap();
398        // Sleep a moment then touch source so its mtime is newer.
399        std::thread::sleep(std::time::Duration::from_millis(20));
400        let mut f = fs::File::create(&source).unwrap();
401        writeln!(f, "newer").unwrap();
402        assert!(
403            is_stale(&cache, std::slice::from_ref(&source)),
404            "source newer than cache ⇒ stale"
405        );
406    }
407
408    #[test]
409    fn is_cargo_entry_detects_common_shapes() {
410        assert!(is_cargo_entry("cargo run --release --features cuda --"));
411        assert!(is_cargo_entry("  cargo run -- "));
412        assert!(!is_cargo_entry("./target/release/ddp-bench"));
413        assert!(!is_cargo_entry("python ./train.py"));
414        assert!(!is_cargo_entry(""));
415    }
416
417    #[test]
418    fn probe_round_trips_with_mock_binary() {
419        // Build a tiny shell script that emits the schema JSON and use it
420        // as the "entry". This tests the full probe path end-to-end
421        // without pulling in cargo.
422        //
423        // The entry is `sh <name>`, not the script path on its own, and
424        // that is load-bearing on Windows: `probe` shells out through
425        // `cmd /C`, which cannot execute a `.sh` and -- worse -- returns
426        // success with empty stdout when handed one. Naming `sh`
427        // explicitly runs it under Git Bash's sh on every host, and the
428        // relative name keeps backslash paths out of sh's hands (probe
429        // runs in `cmd_dir`). Same shape as the fdl.yml entry in
430        // config::tests::command_tests, which is why that one was
431        // portable already.
432        let tmp = TestDir::new("sc");
433        let script = tmp.path().join("mock-bin.sh");
434        let body = r#"#!/bin/sh
435cat <<'JSON'
436{
437  "options": {
438    "model": {
439      "type": "string",
440      "short": "m",
441      "description": "pick a model",
442      "default": "mlp",
443      "choices": ["mlp", "resnet"]
444    }
445  }
446}
447JSON
448"#;
449        fs::write(&script, body).unwrap();
450
451        let schema = probe("sh mock-bin.sh", tmp.path(), None).expect("probe should succeed");
452        let model = schema.options.get("model").expect("model opt");
453        assert_eq!(model.ty, "string");
454        assert_eq!(model.short.as_deref(), Some("m"));
455    }
456
457    #[test]
458    fn probe_rejects_non_json_output() {
459        let tmp = TestDir::new("sc");
460        let script = tmp.path().join("junk.sh");
461        fs::write(&script, "#!/bin/sh\necho not json\n").unwrap();
462        // `sh <name>`: see probe_round_trips_with_mock_binary. This test
463        // in particular used to pass on Windows for the wrong reason --
464        // cmd /C on a .sh yields empty stdout, which trips the same "no
465        // JSON" error the test asserts, so it was green without ever
466        // running the script.
467        let err = probe("sh junk.sh", tmp.path(), None).expect_err("non-json must fail");
468        assert!(
469            err.contains("no JSON") || err.contains("valid JSON"),
470            "err was: {err}"
471        );
472    }
473
474    #[test]
475    fn probe_rejects_semantically_invalid_schema() {
476        let tmp = TestDir::new("sc");
477        let script = tmp.path().join("bad.sh");
478        // Emits JSON that parses but declares a reserved flag.
479        let body = r#"#!/bin/sh
480cat <<'JSON'
481{ "options": { "help": { "type": "bool" } } }
482JSON
483"#;
484        fs::write(&script, body).unwrap();
485        let err = probe("sh bad.sh", tmp.path(), None).expect_err("semantic fail must propagate");
486        assert!(
487            err.contains("validation") || err.contains("reserved"),
488            "err was: {err}"
489        );
490    }
491}