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 `fdl.yml` in the command dir. A cache older than its fdl.yml is
14//! considered stale. Users can also force-refresh.
15
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::process::{Command, Stdio};
19use std::time::SystemTime;
20
21use crate::config::{self, Schema};
22
23/// Directory where all schema caches live, relative to the command dir.
24const CACHE_DIR: &str = ".fdl/schema-cache";
25
26/// Resolve the cache file path for a given command dir and name.
27pub fn cache_path(cmd_dir: &Path, cmd_name: &str) -> PathBuf {
28    cmd_dir.join(CACHE_DIR).join(format!("{cmd_name}.json"))
29}
30
31/// Read a schema cache file, returning `Some` only if it parses cleanly
32/// and survives validation. Parse or validation errors are treated as
33/// "no cache" (the caller falls through to the inline/YAML schema).
34pub fn read_cache(path: &Path) -> Option<Schema> {
35    let content = fs::read_to_string(path).ok()?;
36    let schema: Schema = serde_json::from_str(&content).ok()?;
37    config::validate_schema(&schema).ok()?;
38    Some(schema)
39}
40
41/// Consider a cache "stale" if it is older than the command's fdl.yml
42/// (config changes), or older than a sentinel binary path when supplied.
43///
44/// Missing cache ⇒ stale (return true). Missing reference mtime ⇒ treat
45/// the cache as fresh (conservative: don't refresh what we can't justify).
46pub fn is_stale(cache: &Path, reference_mtimes: &[PathBuf]) -> bool {
47    let Some(cache_mtime) = mtime(cache) else {
48        return true;
49    };
50    reference_mtimes
51        .iter()
52        .filter_map(|p| mtime(p))
53        .any(|ref_m| ref_m > cache_mtime)
54}
55
56fn mtime(path: &Path) -> Option<SystemTime> {
57    fs::metadata(path).ok()?.modified().ok()
58}
59
60/// Serialize a schema to the cache file, creating parent dirs as needed.
61pub fn write_cache(path: &Path, schema: &Schema) -> Result<(), String> {
62    if let Some(parent) = path.parent() {
63        fs::create_dir_all(parent)
64            .map_err(|e| format!("cannot create {}: {}", parent.display(), e))?;
65    }
66    let json = serde_json::to_string_pretty(schema)
67        .map_err(|e| format!("schema serialize: {e}"))?;
68    fs::write(path, json).map_err(|e| format!("cannot write {}: {}", path.display(), e))
69}
70
71/// Probe a binary for its schema by running `<entry> --fdl-schema` via the
72/// shell and parsing stdout as JSON.
73///
74/// `cmd_dir` is the directory containing the `fdl.yml` that declared the
75/// entry — it serves as the cwd for the shell unless the entry is wrapped
76/// through docker (then the wrap walks up to the nearest
77/// `docker-compose.yml` for compose's cwd).
78///
79/// `docker_service` carries the `docker:` field from the resolved
80/// command config. When set AND we're not already inside a container,
81/// the invocation is wrapped as
82/// `docker compose run --rm <service> bash -c '<entry> --fdl-schema'`
83/// so cargo entries that need libtorch get probed inside the dev
84/// container instead of failing silently on the host. When unset, the
85/// entry runs directly on the host.
86///
87/// On failure returns a string error rather than panicking — callers
88/// almost always want to fall back to the inline schema (or none).
89pub fn probe(entry: &str, cmd_dir: &Path, docker_service: Option<&str>) -> Result<Schema, String> {
90    if entry.trim().is_empty() {
91        return Err("entry is empty".into());
92    }
93
94    let inner = format!("{entry} --fdl-schema");
95    let (invocation, run_cwd) = match docker_service {
96        Some(svc) if !inside_docker() => {
97            let compose_root = find_docker_compose_root(cmd_dir).ok_or_else(|| {
98                format!(
99                    "cannot probe schema: docker:{svc} declared but no \
100                     docker-compose.yml found above {}",
101                    cmd_dir.display()
102                )
103            })?;
104            let wrapped = format!(
105                "docker compose run --rm {svc} bash -c {}",
106                posix_quote(&inner)
107            );
108            (wrapped, compose_root)
109        }
110        _ => (inner, cmd_dir.to_path_buf()),
111    };
112
113    let (shell, flag) = if cfg!(target_os = "windows") {
114        ("cmd", "/C")
115    } else {
116        ("sh", "-c")
117    };
118    let output = Command::new(shell)
119        .args([flag, &invocation])
120        .current_dir(&run_cwd)
121        .stdout(Stdio::piped())
122        .stderr(Stdio::piped())
123        .output()
124        .map_err(|e| format!("spawn `{invocation}`: {e}"))?;
125
126    if !output.status.success() {
127        let stderr = String::from_utf8_lossy(&output.stderr);
128        return Err(format!(
129            "`{invocation}` exited with {}: {}",
130            output.status,
131            stderr.trim()
132        ));
133    }
134
135    // Tolerate leading lines of cargo chatter by locating the first `{`.
136    let stdout = String::from_utf8_lossy(&output.stdout);
137    let start = stdout
138        .find('{')
139        .ok_or_else(|| "no JSON object in --fdl-schema output".to_string())?;
140    let schema: Schema = serde_json::from_str(&stdout[start..])
141        .map_err(|e| format!("--fdl-schema did not emit valid JSON: {e}"))?;
142    config::validate_schema(&schema)
143        .map_err(|e| format!("--fdl-schema output failed validation: {e}"))?;
144    Ok(schema)
145}
146
147/// Heuristic: cargo entries compile-on-run, so they are never auto-probed.
148/// Probing must be explicit (`fdl <cmd> --refresh-schema`) for those.
149pub fn is_cargo_entry(entry: &str) -> bool {
150    entry.trim_start().starts_with("cargo ")
151}
152
153/// True when this process is running inside a Docker container. Mirrors
154/// the `/.dockerenv` heuristic used elsewhere in the crate.
155fn inside_docker() -> bool {
156    Path::new("/.dockerenv").exists()
157}
158
159/// Climb from `start` looking for a directory containing
160/// `docker-compose.yml` (the compose root used as cwd for `docker
161/// compose` invocations). Returns `None` if none is found before
162/// hitting the filesystem root.
163fn find_docker_compose_root(start: &Path) -> Option<PathBuf> {
164    let mut dir = start.to_path_buf();
165    loop {
166        if dir.join("docker-compose.yml").exists() {
167            return Some(dir);
168        }
169        if !dir.pop() {
170            return None;
171        }
172    }
173}
174
175use crate::util::shell::posix_quote;
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::collections::BTreeMap;
181    use std::io::Write;
182
183    /// Scoped test directory under `std::env::temp_dir()` that cleans up on drop.
184    /// Zero-external-dep replacement for `tempfile::tempdir()`.
185    struct TestDir {
186        path: PathBuf,
187    }
188
189    impl TestDir {
190        fn new(tag: &str) -> Self {
191            let nanos = std::time::SystemTime::now()
192                .duration_since(std::time::UNIX_EPOCH)
193                .map(|d| d.as_nanos())
194                .unwrap_or(0);
195            let pid = std::process::id();
196            let path = std::env::temp_dir().join(format!("fdl-test-{tag}-{pid}-{nanos}"));
197            fs::create_dir_all(&path).expect("create test dir");
198            Self { path }
199        }
200
201        fn path(&self) -> &Path {
202            &self.path
203        }
204    }
205
206    impl Drop for TestDir {
207        fn drop(&mut self) {
208            let _ = fs::remove_dir_all(&self.path);
209        }
210    }
211
212    fn minimal_schema() -> Schema {
213        let mut options = BTreeMap::new();
214        options.insert(
215            "model".into(),
216            config::OptionSpec {
217                ty: "string".into(),
218                description: Some("pick a model".into()),
219                default: Some(serde_json::json!("mlp")),
220                choices: Some(vec![
221                    serde_json::json!("mlp"),
222                    serde_json::json!("resnet"),
223                ]),
224                short: Some("m".into()),
225                env: None,
226                completer: None,
227            },
228        );
229        Schema {
230            args: Vec::new(),
231            options,
232            strict: false,
233            ..Schema::default()
234        }
235    }
236
237    #[test]
238    fn cache_roundtrip_preserves_schema() {
239        let tmp = TestDir::new("sc");
240        let path = cache_path(tmp.path(), "ddp-bench");
241        let schema = minimal_schema();
242        write_cache(&path, &schema).expect("write cache");
243
244        let read = read_cache(&path).expect("round-trip parses");
245        let orig_model = schema.options.get("model").unwrap();
246        let round_model = read.options.get("model").unwrap();
247        assert_eq!(orig_model.ty, round_model.ty);
248        assert_eq!(orig_model.short, round_model.short);
249        assert_eq!(orig_model.choices, round_model.choices);
250    }
251
252    #[test]
253    fn read_cache_rejects_invalid_json() {
254        let tmp = TestDir::new("sc");
255        let path = tmp.path().join("bad.json");
256        fs::write(&path, "not json at all").unwrap();
257        assert!(read_cache(&path).is_none());
258    }
259
260    #[test]
261    fn read_cache_unknown_field_falls_back_to_none() {
262        // Version-skew guard: a schema emitted by a NEWER
263        // flodl-cli-macros than this fdl knows (extra field) must not
264        // parse partially (deny_unknown_fields) — and the probe layer
265        // degrades to "no cache" so help still renders from the inline
266        // yml schema or none.
267        let tmp = TestDir::new("sc");
268        let path = tmp.path().join("newer.json");
269        let body = r#"{
270            "options": {
271                "model": { "type": "string" }
272            },
273            "field_from_the_future": true
274        }"#;
275        fs::write(&path, body).unwrap();
276        assert!(read_cache(&path).is_none());
277    }
278
279    #[test]
280    fn read_cache_rejects_validation_failure() {
281        // A schema that clears validation at struct level but fails
282        // semantic validation: shadowed fdl-level flag `--help`.
283        let tmp = TestDir::new("sc");
284        let path = tmp.path().join("bad_sem.json");
285        let body = r#"{
286            "options": {
287                "help": { "type": "bool" }
288            }
289        }"#;
290        fs::write(&path, body).unwrap();
291        assert!(read_cache(&path).is_none(),
292            "cache must not return a schema that fails validate_schema");
293    }
294
295    #[test]
296    fn is_stale_missing_cache_is_stale() {
297        let tmp = TestDir::new("sc");
298        let path = tmp.path().join("missing.json");
299        assert!(is_stale(&path, &[]));
300    }
301
302    #[test]
303    fn is_stale_compares_mtimes() {
304        let tmp = TestDir::new("sc");
305        let cache = tmp.path().join("cache.json");
306        let source = tmp.path().join("fdl.yml");
307        fs::write(&cache, "{}").unwrap();
308        // Sleep a moment then touch source so its mtime is newer.
309        std::thread::sleep(std::time::Duration::from_millis(20));
310        let mut f = fs::File::create(&source).unwrap();
311        writeln!(f, "newer").unwrap();
312        assert!(
313            is_stale(&cache, std::slice::from_ref(&source)),
314            "source newer than cache ⇒ stale"
315        );
316    }
317
318    #[test]
319    fn is_cargo_entry_detects_common_shapes() {
320        assert!(is_cargo_entry("cargo run --release --features cuda --"));
321        assert!(is_cargo_entry("  cargo run -- "));
322        assert!(!is_cargo_entry("./target/release/ddp-bench"));
323        assert!(!is_cargo_entry("python ./train.py"));
324        assert!(!is_cargo_entry(""));
325    }
326
327    #[test]
328    fn probe_round_trips_with_mock_binary() {
329        // Build a tiny shell script that emits the schema JSON and use it
330        // as the "entry". This tests the full probe path end-to-end
331        // without pulling in cargo.
332        let tmp = TestDir::new("sc");
333        let script = tmp.path().join("mock-bin.sh");
334        let body = r#"#!/bin/sh
335cat <<'JSON'
336{
337  "options": {
338    "model": {
339      "type": "string",
340      "short": "m",
341      "description": "pick a model",
342      "default": "mlp",
343      "choices": ["mlp", "resnet"]
344    }
345  }
346}
347JSON
348"#;
349        fs::write(&script, body).unwrap();
350        // chmod +x
351        #[cfg(unix)]
352        {
353            use std::os::unix::fs::PermissionsExt;
354            let perm = fs::Permissions::from_mode(0o755);
355            fs::set_permissions(&script, perm).unwrap();
356        }
357
358        let entry = script.to_string_lossy();
359        let schema = probe(&entry, tmp.path(), None).expect("probe should succeed");
360        let model = schema.options.get("model").expect("model opt");
361        assert_eq!(model.ty, "string");
362        assert_eq!(model.short.as_deref(), Some("m"));
363    }
364
365    #[test]
366    fn probe_rejects_non_json_output() {
367        let tmp = TestDir::new("sc");
368        let script = tmp.path().join("junk.sh");
369        fs::write(&script, "#!/bin/sh\necho not json\n").unwrap();
370        #[cfg(unix)]
371        {
372            use std::os::unix::fs::PermissionsExt;
373            let perm = fs::Permissions::from_mode(0o755);
374            fs::set_permissions(&script, perm).unwrap();
375        }
376        let err = probe(&script.to_string_lossy(), tmp.path(), None)
377            .expect_err("non-json must fail");
378        assert!(err.contains("no JSON") || err.contains("valid JSON"),
379            "err was: {err}");
380    }
381
382    #[test]
383    fn probe_rejects_semantically_invalid_schema() {
384        let tmp = TestDir::new("sc");
385        let script = tmp.path().join("bad.sh");
386        // Emits JSON that parses but declares a reserved flag.
387        let body = r#"#!/bin/sh
388cat <<'JSON'
389{ "options": { "help": { "type": "bool" } } }
390JSON
391"#;
392        fs::write(&script, body).unwrap();
393        #[cfg(unix)]
394        {
395            use std::os::unix::fs::PermissionsExt;
396            let perm = fs::Permissions::from_mode(0o755);
397            fs::set_permissions(&script, perm).unwrap();
398        }
399        let err = probe(&script.to_string_lossy(), tmp.path(), None)
400            .expect_err("semantic fail must propagate");
401        assert!(err.contains("validation") || err.contains("reserved"),
402            "err was: {err}");
403    }
404}