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}