flodl_cli/
schema_cache.rs1use std::fs;
17use std::path::{Path, PathBuf};
18use std::process::{Command, Stdio};
19use std::time::SystemTime;
20
21use crate::config::{self, Schema};
22
23const CACHE_DIR: &str = ".fdl/schema-cache";
25
26pub fn cache_path(cmd_dir: &Path, cmd_name: &str) -> PathBuf {
28 cmd_dir.join(CACHE_DIR).join(format!("{cmd_name}.json"))
29}
30
31pub 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
41pub 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
60pub 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
71pub 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 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
147pub fn is_cargo_entry(entry: &str) -> bool {
150 entry.trim_start().starts_with("cargo ")
151}
152
153fn inside_docker() -> bool {
156 Path::new("/.dockerenv").exists()
157}
158
159fn 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 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 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 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 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 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 #[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 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}