1use std::fs;
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22use std::time::SystemTime;
23
24use crate::config::{self, Schema};
25
26const CACHE_DIR: &str = ".fdl/schema-cache";
28
29const SOURCE_SKIP_DIRS: &[&str] = &[
33 "target", ".fdl", ".git", "node_modules", "runs", "data", "baselines",
34 "libtorch", ".cargo",
35];
36
37const MAX_SOURCE_REFS: usize = 4096;
41
42pub 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
87pub fn cache_path(cmd_dir: &Path, cmd_name: &str) -> PathBuf {
89 cmd_dir.join(CACHE_DIR).join(format!("{cmd_name}.json"))
90}
91
92pub 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
102pub 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
121pub 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
132pub 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 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 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
227pub fn is_cargo_entry(entry: &str) -> bool {
230 entry.trim_start().starts_with("cargo ")
231}
232
233fn inside_docker() -> bool {
236 Path::new("/.dockerenv").exists()
237}
238
239fn 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 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 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 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 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 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 #[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 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}