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