1use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Default, Deserialize)]
15pub struct ProjectConfig {
16 #[serde(default)]
17 pub description: Option<String>,
18 #[serde(default)]
22 pub commands: BTreeMap<String, CommandSpec>,
23}
24
25#[derive(Debug, Default, Deserialize)]
33pub struct CommandConfig {
34 #[serde(default)]
35 pub description: Option<String>,
36 #[serde(default)]
37 pub entry: Option<String>,
38 #[serde(default)]
41 pub docker: Option<String>,
42 #[serde(default)]
43 pub ddp: Option<DdpConfig>,
44 #[serde(default)]
45 pub training: Option<TrainingConfig>,
46 #[serde(default)]
47 pub output: Option<OutputConfig>,
48 #[serde(default)]
51 pub commands: BTreeMap<String, CommandSpec>,
52 #[serde(default, rename = "arg-name")]
58 pub arg_name: Option<String>,
59 #[serde(default)]
62 pub schema: Option<Schema>,
63 #[serde(default)]
72 pub compile: Option<bool>,
73}
74
75#[derive(Debug, Default, Clone)]
90pub struct CommandSpec {
91 pub description: Option<String>,
92 pub run: Option<String>,
94 pub append: Option<String>,
103 pub path: Option<String>,
108 pub docker: Option<String>,
110 pub ddp: Option<DdpConfig>,
112 pub training: Option<TrainingConfig>,
113 pub output: Option<OutputConfig>,
114 pub options: BTreeMap<String, serde_json::Value>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum CommandKind {
120 Run,
122 Path,
125 Preset,
128}
129
130impl CommandSpec {
131 pub fn kind(&self) -> Result<CommandKind, String> {
137 if self.docker.is_some() && self.run.is_none() {
138 return Err(
139 "command declares `docker:` without `run:`; \
140 `docker:` only wraps inline run-scripts"
141 .to_string(),
142 );
143 }
144 if self.append.is_some() && self.run.is_none() {
145 return Err(
146 "command declares `append:` without `run:`; \
147 `append:` only forwards trailing tokens for inline run-scripts"
148 .to_string(),
149 );
150 }
151 match (self.run.as_deref(), self.path.as_deref()) {
152 (Some(_), Some(_)) => Err(
153 "command declares both `run:` and `path:`; \
154 only one is allowed"
155 .to_string(),
156 ),
157 (Some(_), None) => Ok(CommandKind::Run),
158 (None, Some(_)) => Ok(CommandKind::Path),
159 (None, None) => {
160 if self.ddp.is_some()
164 || self.training.is_some()
165 || self.output.is_some()
166 || !self.options.is_empty()
167 {
168 Ok(CommandKind::Preset)
169 } else {
170 Ok(CommandKind::Path)
171 }
172 }
173 }
174 }
175
176 pub fn resolve_path(&self, name: &str, parent_dir: &Path) -> PathBuf {
180 match &self.path {
181 Some(p) => parent_dir.join(p),
182 None => parent_dir.join(name),
183 }
184 }
185}
186
187impl<'de> Deserialize<'de> for CommandSpec {
192 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
193 where
194 D: serde::Deserializer<'de>,
195 {
196 #[derive(Deserialize)]
197 struct Inner {
198 #[serde(default)]
199 description: Option<String>,
200 #[serde(default)]
201 run: Option<String>,
202 #[serde(default)]
203 append: Option<String>,
204 #[serde(default)]
205 path: Option<String>,
206 #[serde(default)]
207 docker: Option<String>,
208 #[serde(default)]
209 ddp: Option<DdpConfig>,
210 #[serde(default)]
211 training: Option<TrainingConfig>,
212 #[serde(default)]
213 output: Option<OutputConfig>,
214 #[serde(default)]
215 options: BTreeMap<String, serde_json::Value>,
216 }
217
218 let raw = serde_yaml::Value::deserialize(deserializer)?;
219 if matches!(raw, serde_yaml::Value::Null) {
220 return Ok(Self::default());
221 }
222 let inner: Inner =
223 serde_yaml::from_value(raw).map_err(serde::de::Error::custom)?;
224 Ok(Self {
225 description: inner.description,
226 run: inner.run,
227 append: inner.append,
228 path: inner.path,
229 docker: inner.docker,
230 ddp: inner.ddp,
231 training: inner.training,
232 output: inner.output,
233 options: inner.options,
234 })
235 }
236}
237
238#[derive(Debug, Clone, Default, Deserialize, Serialize)]
243pub struct Schema {
244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
245 pub args: Vec<ArgSpec>,
246 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
247 pub options: BTreeMap<String, OptionSpec>,
248 #[serde(default, skip_serializing_if = "is_false")]
273 pub strict: bool,
274}
275
276#[derive(Debug, Clone, Deserialize, Serialize)]
278pub struct OptionSpec {
279 #[serde(rename = "type")]
280 pub ty: String,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub description: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub default: Option<serde_json::Value>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub choices: Option<Vec<serde_json::Value>>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub short: Option<String>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub env: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
295 #[allow(dead_code)]
296 pub completer: Option<String>,
297}
298
299#[derive(Debug, Clone, Deserialize, Serialize)]
301pub struct ArgSpec {
302 pub name: String,
303 #[serde(rename = "type")]
304 pub ty: String,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub description: Option<String>,
307 #[serde(default = "default_required")]
308 pub required: bool,
309 #[serde(default, skip_serializing_if = "is_false")]
310 pub variadic: bool,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub default: Option<serde_json::Value>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub choices: Option<Vec<serde_json::Value>>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
318 #[allow(dead_code)]
319 pub completer: Option<String>,
320}
321
322fn is_false(b: &bool) -> bool {
323 !*b
324}
325
326fn default_required() -> bool {
327 true
328}
329
330const RESERVED_LONGS: &[&str] = &[
333 "help", "version", "quiet", "env",
334];
335const RESERVED_SHORTS: &[&str] = &[
336 "h", "V", "q", "v", "e",
337];
338const VALID_TYPES: &[&str] = &[
339 "string", "int", "float", "bool", "path",
340 "list[string]", "list[int]", "list[float]", "list[path]",
341];
342
343pub fn validate_schema(schema: &Schema) -> Result<(), String> {
348 let mut short_seen: BTreeMap<String, String> = BTreeMap::new();
350 for (long, spec) in &schema.options {
351 if !VALID_TYPES.contains(&spec.ty.as_str()) {
352 return Err(format!(
353 "option --{}: unknown type '{}' (valid: {})",
354 long,
355 spec.ty,
356 VALID_TYPES.join(", ")
357 ));
358 }
359 if RESERVED_LONGS.contains(&long.as_str()) {
360 return Err(format!(
361 "option --{long} shadows a reserved fdl-level flag"
362 ));
363 }
364 if let Some(s) = &spec.short {
365 if s.chars().count() != 1 {
366 return Err(format!(
367 "option --{long}: `short: \"{s}\"` must be a single character"
368 ));
369 }
370 if RESERVED_SHORTS.contains(&s.as_str()) {
371 return Err(format!(
372 "option --{long}: short -{s} shadows a reserved fdl-level flag"
373 ));
374 }
375 if let Some(prev) = short_seen.insert(s.clone(), long.clone()) {
376 return Err(format!(
377 "options --{prev} and --{long} both declare short -{s}"
378 ));
379 }
380 }
381 }
382
383 let mut seen_optional = false;
385 let mut name_seen: BTreeMap<String, ()> = BTreeMap::new();
386 for (i, arg) in schema.args.iter().enumerate() {
387 if !VALID_TYPES.contains(&arg.ty.as_str()) {
388 return Err(format!(
389 "arg <{}>: unknown type '{}' (valid: {})",
390 arg.name,
391 arg.ty,
392 VALID_TYPES.join(", ")
393 ));
394 }
395 if name_seen.insert(arg.name.clone(), ()).is_some() {
396 return Err(format!("duplicate positional name <{}>", arg.name));
397 }
398 if arg.variadic && i != schema.args.len() - 1 {
399 return Err(format!(
400 "arg <{}>: variadic positional must be the last one",
401 arg.name
402 ));
403 }
404 let is_optional = !arg.required || arg.default.is_some();
405 if arg.required && arg.default.is_some() {
406 return Err(format!(
407 "arg <{}>: `required: true` with a default is a contradiction",
408 arg.name
409 ));
410 }
411 if seen_optional && arg.required && arg.default.is_none() {
412 return Err(format!(
413 "arg <{}>: required positional cannot follow an optional one",
414 arg.name
415 ));
416 }
417 if is_optional {
418 seen_optional = true;
419 }
420 }
421
422 Ok(())
423}
424
425#[derive(Debug, Clone, Default, Deserialize)]
429pub struct DdpConfig {
430 pub mode: Option<String>,
431 pub policy: Option<String>,
432 pub backend: Option<String>,
433 pub anchor: Option<serde_json::Value>,
435 pub max_anchor: Option<u32>,
436 pub overhead_target: Option<f64>,
437 pub divergence_threshold: Option<f64>,
438 pub max_batch_diff: Option<serde_json::Value>,
440 pub speed_hint: Option<SpeedHint>,
441 pub partition_ratios: Option<Vec<f64>>,
442 pub progressive: Option<serde_json::Value>,
444 pub max_grad_norm: Option<f64>,
445 pub lr_scale_ratio: Option<f64>,
446 pub snapshot_timeout: Option<u32>,
447 pub checkpoint_every: Option<u32>,
448 pub timeline: Option<bool>,
449}
450
451#[derive(Debug, Clone, Default, Deserialize)]
452pub struct SpeedHint {
453 pub slow_rank: usize,
454 pub ratio: f64,
455}
456
457#[derive(Debug, Clone, Default, Deserialize)]
459pub struct TrainingConfig {
460 pub epochs: Option<u32>,
461 pub batch_size: Option<u32>,
462 pub batches_per_epoch: Option<u32>,
463 pub lr: Option<f64>,
464 pub seed: Option<u64>,
465}
466
467#[derive(Debug, Clone, Default, Deserialize)]
469pub struct OutputConfig {
470 pub dir: Option<String>,
471 pub timeline: Option<bool>,
472 pub monitor: Option<u16>,
473}
474
475
476const CONFIG_NAMES: &[&str] = &["fdl.yaml", "fdl.yml", "fdl.json"];
479const EXAMPLE_SUFFIXES: &[&str] = &[".example", ".dist"];
480
481pub fn find_config(start: &Path) -> Option<PathBuf> {
487 let mut dir = start.to_path_buf();
488 loop {
489 for name in CONFIG_NAMES {
491 let candidate = dir.join(name);
492 if candidate.is_file() {
493 return Some(candidate);
494 }
495 }
496 for name in CONFIG_NAMES {
498 for suffix in EXAMPLE_SUFFIXES {
499 let example = dir.join(format!("{name}{suffix}"));
500 if example.is_file() {
501 let target = dir.join(name);
502 if try_copy_example(&example, &target) {
503 return Some(target);
504 }
505 return Some(example);
507 }
508 }
509 }
510 if !dir.pop() {
511 return None;
512 }
513 }
514}
515
516fn try_copy_example(example: &Path, target: &Path) -> bool {
519 let example_name = example.file_name().unwrap_or_default().to_string_lossy();
520 let target_name = target.file_name().unwrap_or_default().to_string_lossy();
521 eprintln!(
522 "fdl: found {example_name} but no {target_name}. \
523 Copy it to create your local config? [Y/n] "
524 );
525 let mut input = String::new();
526 if std::io::stdin().read_line(&mut input).is_err() {
527 return false;
528 }
529 let answer = input.trim().to_lowercase();
530 if answer.is_empty() || answer == "y" || answer == "yes" {
531 match std::fs::copy(example, target) {
532 Ok(_) => {
533 eprintln!("fdl: created {target_name} (edit to customize)");
534 true
535 }
536 Err(e) => {
537 eprintln!("fdl: failed to copy: {e}");
538 false
539 }
540 }
541 } else {
542 false
543 }
544}
545
546pub fn load_project(path: &Path) -> Result<ProjectConfig, String> {
548 load_project_with_env(path, None)
549}
550
551pub fn load_project_with_env(
558 base_path: &Path,
559 env: Option<&str>,
560) -> Result<ProjectConfig, String> {
561 let merged = load_merged_value(base_path, env)?;
562 serde_yaml::from_value::<ProjectConfig>(merged)
563 .map_err(|e| format!("{}: {}", base_path.display(), e))
564}
565
566pub fn load_merged_value(
570 base_path: &Path,
571 env: Option<&str>,
572) -> Result<serde_yaml::Value, String> {
573 let layers = resolve_config_layers(base_path, env)?;
574 Ok(crate::overlay::merge_layers(
575 layers.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
576 ))
577}
578
579pub fn resolve_config_layers(
588 base_path: &Path,
589 env: Option<&str>,
590) -> Result<Vec<(PathBuf, serde_yaml::Value)>, String> {
591 let mut layers = crate::overlay::resolve_chain(base_path)?;
592 if let Some(name) = env {
593 match crate::overlay::find_env_file(base_path, name) {
594 Some(p) => {
595 let env_chain = crate::overlay::resolve_chain(&p)?;
596 layers.extend(env_chain);
597 }
598 None => {
599 return Err(format!(
600 "environment `{name}` not found (expected fdl.{name}.yml next to {})",
601 base_path.display()
602 ));
603 }
604 }
605 }
606 let mut seen = std::collections::HashSet::new();
610 layers.retain(|(path, _)| seen.insert(path.clone()));
611 Ok(layers)
612}
613
614pub fn config_layer_sources(base_path: &Path, env: Option<&str>) -> Vec<PathBuf> {
617 resolve_config_layers(base_path, env)
618 .map(|ls| ls.into_iter().map(|(p, _)| p).collect())
619 .unwrap_or_else(|_| vec![base_path.to_path_buf()])
620}
621
622pub fn load_command(dir: &Path) -> Result<CommandConfig, String> {
627 load_command_with_env(dir, None)
628}
629
630pub fn load_command_with_env(dir: &Path, env: Option<&str>) -> Result<CommandConfig, String> {
638 let mut base_path: Option<PathBuf> = None;
640 for name in CONFIG_NAMES {
641 let path = dir.join(name);
642 if path.is_file() {
643 base_path = Some(path);
644 break;
645 }
646 }
647 if base_path.is_none() {
648 for name in CONFIG_NAMES {
649 for suffix in EXAMPLE_SUFFIXES {
650 let example = dir.join(format!("{name}{suffix}"));
651 if example.is_file() {
652 let target = dir.join(name);
653 let src = if try_copy_example(&example, &target) {
654 target
655 } else {
656 example
657 };
658 base_path = Some(src);
659 break;
660 }
661 }
662 if base_path.is_some() {
663 break;
664 }
665 }
666 }
667 let base_path = base_path
668 .ok_or_else(|| format!("no fdl.yml found in {}", dir.display()))?;
669
670 let mut layers = crate::overlay::resolve_chain(&base_path)?;
674 if let Some(name) = env {
675 if let Some(p) = crate::overlay::find_env_file(&base_path, name) {
676 layers.extend(crate::overlay::resolve_chain(&p)?);
677 }
678 }
679 let mut seen = std::collections::HashSet::new();
680 layers.retain(|(path, _)| seen.insert(path.clone()));
681 let merged = crate::overlay::merge_layers(
682 layers.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
683 );
684 let mut cfg: CommandConfig = serde_yaml::from_value(merged)
685 .map_err(|e| format!("{}: {}", base_path.display(), e))?;
686
687 if let Some(schema) = &cfg.schema {
688 validate_schema(schema)
689 .map_err(|e| format!("schema error in {}/fdl.yml: {e}", dir.display()))?;
690 }
696
697 let cmd_name = dir
704 .file_name()
705 .and_then(|n| n.to_str())
706 .unwrap_or("_");
707 let cache = crate::schema_cache::cache_path(dir, cmd_name);
708 let refs: Vec<std::path::PathBuf> = CONFIG_NAMES
711 .iter()
712 .map(|n| dir.join(n))
713 .filter(|p| p.exists())
714 .collect();
715 if !crate::schema_cache::is_stale(&cache, &refs) {
716 if let Some(cached) = crate::schema_cache::read_cache(&cache) {
717 cfg.schema = Some(cached);
718 }
719 } else if let Some(entry) = cfg.entry.as_deref() {
720 let opts_into_compile = cfg.compile.unwrap_or(false);
730 let should_probe =
731 !crate::schema_cache::is_cargo_entry(entry) || opts_into_compile;
732 if should_probe {
733 if let Ok(probed) =
734 crate::schema_cache::probe(entry, dir, cfg.docker.as_deref())
735 {
736 let _ = crate::schema_cache::write_cache(&cache, &probed);
740 cfg.schema = Some(probed);
741 }
742 }
743 }
744
745 Ok(cfg)
746}
747
748const STRICT_UNIVERSAL_LONGS: &[(&str, Option<char>, bool)] = &[
756 ("help", Some('h'), false),
758 ("version", Some('V'), false),
759 ("fdl-schema", None, false),
760 ("refresh-schema", None, false),
761];
762
763pub fn schema_to_args_spec(schema: &Schema) -> crate::args::parser::ArgsSpec {
769 use crate::args::parser::{ArgsSpec, OptionDecl, PositionalDecl};
770
771 let mut options: Vec<OptionDecl> = schema
772 .options
773 .iter()
774 .map(|(long, spec)| OptionDecl {
775 long: long.clone(),
776 short: spec
777 .short
778 .as_deref()
779 .and_then(|s| s.chars().next()),
780 takes_value: spec.ty != "bool",
781 allows_bare: true,
786 repeatable: spec.ty.starts_with("list["),
787 choices: spec
788 .choices
789 .as_ref()
790 .map(|cs| strict_choices_to_strings(cs)),
791 })
792 .collect();
793
794 for (long, short, takes_value) in STRICT_UNIVERSAL_LONGS {
797 options.push(OptionDecl {
798 long: (*long).to_string(),
799 short: *short,
800 takes_value: *takes_value,
801 allows_bare: true,
802 repeatable: false,
803 choices: None,
804 });
805 }
806
807 let positionals: Vec<PositionalDecl> = schema
810 .args
811 .iter()
812 .map(|a| PositionalDecl {
813 name: a.name.clone(),
814 required: false,
815 variadic: a.variadic,
816 choices: a
817 .choices
818 .as_ref()
819 .map(|cs| strict_choices_to_strings(cs)),
820 })
821 .collect();
822
823 ArgsSpec {
824 options,
825 positionals,
826 lenient_unknowns: !schema.strict,
830 }
831}
832
833fn strict_choices_to_strings(cs: &[serde_json::Value]) -> Vec<String> {
834 cs.iter()
835 .map(|v| match v {
836 serde_json::Value::String(s) => s.clone(),
837 other => other.to_string(),
838 })
839 .collect()
840}
841
842pub fn validate_tail(tail: &[String], schema: &Schema) -> Result<(), String> {
850 let spec = schema_to_args_spec(schema);
851 let mut argv = Vec::with_capacity(tail.len() + 1);
852 argv.push("fdl".to_string());
853 argv.extend(tail.iter().cloned());
854 crate::args::parser::parse(&spec, &argv).map(|_| ())
855}
856
857pub fn validate_preset_for_exec(
863 preset_name: &str,
864 spec: &CommandSpec,
865 schema: &Schema,
866) -> Result<(), String> {
867 for (key, value) in &spec.options {
868 let Some(opt) = schema.options.get(key) else {
869 if schema.strict {
870 return Err(format!(
871 "preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
872 ));
873 }
874 continue;
875 };
876 let Some(choices) = &opt.choices else {
877 continue;
878 };
879 if !choices.iter().any(|c| values_equal(c, value)) {
880 let allowed: Vec<String> = choices
881 .iter()
882 .map(|c| match c {
883 serde_json::Value::String(s) => s.clone(),
884 other => other.to_string(),
885 })
886 .collect();
887 return Err(format!(
888 "preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
889 display_json(value),
890 allowed.join(", "),
891 ));
892 }
893 }
894 Ok(())
895}
896
897pub fn validate_preset_values(
907 commands: &BTreeMap<String, CommandSpec>,
908 schema: &Schema,
909) -> Result<(), String> {
910 for (preset_name, spec) in commands {
911 match spec.kind() {
912 Ok(CommandKind::Preset) => {}
913 _ => continue,
914 }
915 for (key, value) in &spec.options {
916 let Some(opt) = schema.options.get(key) else {
917 continue; };
919 let Some(choices) = &opt.choices else {
920 continue; };
922 if !choices.iter().any(|c| values_equal(c, value)) {
923 let allowed: Vec<String> = choices
924 .iter()
925 .map(|c| match c {
926 serde_json::Value::String(s) => s.clone(),
927 other => other.to_string(),
928 })
929 .collect();
930 return Err(format!(
931 "preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
932 display_json(value),
933 allowed.join(", "),
934 ));
935 }
936 }
937 }
938 Ok(())
939}
940
941fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
945 if a == b {
946 return true;
947 }
948 match (a, b) {
950 (serde_json::Value::String(s), other) | (other, serde_json::Value::String(s)) => {
951 s == &other.to_string()
952 }
953 _ => false,
954 }
955}
956
957fn display_json(v: &serde_json::Value) -> String {
958 match v {
959 serde_json::Value::String(s) => s.clone(),
960 other => other.to_string(),
961 }
962}
963
964pub fn validate_presets_strict(
969 commands: &BTreeMap<String, CommandSpec>,
970 schema: &Schema,
971) -> Result<(), String> {
972 for (preset_name, spec) in commands {
973 match spec.kind() {
974 Ok(CommandKind::Preset) => {}
975 _ => continue,
976 }
977 for key in spec.options.keys() {
978 if !schema.options.contains_key(key) {
979 return Err(format!(
980 "preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
981 ));
982 }
983 }
984 }
985 Ok(())
986}
987
988pub fn merge_preset(root: &CommandConfig, preset: &CommandSpec) -> ResolvedConfig {
994 ResolvedConfig {
995 ddp: merge_ddp(&root.ddp, &preset.ddp),
996 training: merge_training(&root.training, &preset.training),
997 output: merge_output(&root.output, &preset.output),
998 options: preset.options.clone(),
999 }
1000}
1001
1002pub fn defaults_only(root: &CommandConfig) -> ResolvedConfig {
1004 ResolvedConfig {
1005 ddp: root.ddp.clone().unwrap_or_default(),
1006 training: root.training.clone().unwrap_or_default(),
1007 output: root.output.clone().unwrap_or_default(),
1008 options: BTreeMap::new(),
1009 }
1010}
1011
1012pub struct ResolvedConfig {
1014 pub ddp: DdpConfig,
1015 pub training: TrainingConfig,
1016 pub output: OutputConfig,
1017 pub options: BTreeMap<String, serde_json::Value>,
1018}
1019
1020macro_rules! merge_field {
1021 ($base:expr, $over:expr, $field:ident) => {
1022 $over
1023 .as_ref()
1024 .and_then(|o| o.$field.clone())
1025 .or_else(|| $base.as_ref().and_then(|b| b.$field.clone()))
1026 };
1027}
1028
1029fn merge_ddp(base: &Option<DdpConfig>, over: &Option<DdpConfig>) -> DdpConfig {
1030 DdpConfig {
1031 mode: merge_field!(base, over, mode),
1032 policy: merge_field!(base, over, policy),
1033 backend: merge_field!(base, over, backend),
1034 anchor: merge_field!(base, over, anchor),
1035 max_anchor: merge_field!(base, over, max_anchor),
1036 overhead_target: merge_field!(base, over, overhead_target),
1037 divergence_threshold: merge_field!(base, over, divergence_threshold),
1038 max_batch_diff: merge_field!(base, over, max_batch_diff),
1039 speed_hint: merge_field!(base, over, speed_hint),
1040 partition_ratios: merge_field!(base, over, partition_ratios),
1041 progressive: merge_field!(base, over, progressive),
1042 max_grad_norm: merge_field!(base, over, max_grad_norm),
1043 lr_scale_ratio: merge_field!(base, over, lr_scale_ratio),
1044 snapshot_timeout: merge_field!(base, over, snapshot_timeout),
1045 checkpoint_every: merge_field!(base, over, checkpoint_every),
1046 timeline: merge_field!(base, over, timeline),
1047 }
1048}
1049
1050fn merge_training(base: &Option<TrainingConfig>, over: &Option<TrainingConfig>) -> TrainingConfig {
1051 TrainingConfig {
1052 epochs: merge_field!(base, over, epochs),
1053 batch_size: merge_field!(base, over, batch_size),
1054 batches_per_epoch: merge_field!(base, over, batches_per_epoch),
1055 lr: merge_field!(base, over, lr),
1056 seed: merge_field!(base, over, seed),
1057 }
1058}
1059
1060fn merge_output(base: &Option<OutputConfig>, over: &Option<OutputConfig>) -> OutputConfig {
1061 OutputConfig {
1062 dir: merge_field!(base, over, dir),
1063 timeline: merge_field!(base, over, timeline),
1064 monitor: merge_field!(base, over, monitor),
1065 }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 fn project_root() -> PathBuf {
1075 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1076 .parent()
1077 .expect("flodl-cli parent must be project root")
1078 .to_path_buf()
1079 }
1080
1081 fn load_example() -> ProjectConfig {
1082 let path = project_root().join("fdl.yml.example");
1083 assert!(
1084 path.is_file(),
1085 "fdl.yml.example missing at {} -- the CLI depends on it as the canonical config template",
1086 path.display()
1087 );
1088 load_project(&path).expect("fdl.yml.example must parse as a valid ProjectConfig")
1089 }
1090
1091 fn opt(ty: &str) -> OptionSpec {
1092 OptionSpec {
1093 ty: ty.into(),
1094 description: None,
1095 default: None,
1096 choices: None,
1097 short: None,
1098 env: None,
1099 completer: None,
1100 }
1101 }
1102
1103 fn arg(name: &str, ty: &str) -> ArgSpec {
1104 ArgSpec {
1105 name: name.into(),
1106 ty: ty.into(),
1107 description: None,
1108 required: true,
1109 variadic: false,
1110 default: None,
1111 choices: None,
1112 completer: None,
1113 }
1114 }
1115
1116 #[test]
1117 fn validate_schema_accepts_minimal_valid() {
1118 let mut s = Schema::default();
1119 s.options.insert("model".into(), opt("string"));
1120 s.options.insert("epochs".into(), opt("int"));
1121 s.args.push(arg("run-id", "string"));
1122 validate_schema(&s).expect("minimal valid schema must pass");
1123 }
1124
1125 #[test]
1126 fn validate_schema_rejects_unknown_option_type() {
1127 let mut s = Schema::default();
1128 s.options.insert("bad".into(), opt("integer"));
1129 let err = validate_schema(&s).expect_err("unknown type should fail");
1130 assert!(err.contains("unknown type"), "err was: {err}");
1131 }
1132
1133 #[test]
1134 fn validate_schema_rejects_reserved_long() {
1135 let mut s = Schema::default();
1136 s.options.insert("help".into(), opt("bool"));
1137 let err = validate_schema(&s).expect_err("reserved --help must fail");
1138 assert!(err.contains("reserved"), "err was: {err}");
1139 }
1140
1141 #[test]
1142 fn validate_schema_rejects_reserved_short() {
1143 let mut s = Schema::default();
1144 let mut o = opt("string");
1145 o.short = Some("h".into());
1146 s.options.insert("host".into(), o);
1147 let err = validate_schema(&s).expect_err("short -h must fail");
1148 assert!(err.contains("reserved"), "err was: {err}");
1149 }
1150
1151 #[test]
1152 fn validate_schema_rejects_duplicate_short() {
1153 let mut s = Schema::default();
1154 let mut a = opt("string");
1155 a.short = Some("m".into());
1156 let mut b = opt("string");
1157 b.short = Some("m".into());
1158 s.options.insert("model".into(), a);
1159 s.options.insert("mode".into(), b);
1160 let err = validate_schema(&s).expect_err("duplicate -m must fail");
1161 assert!(err.contains("both declare short"), "err was: {err}");
1162 }
1163
1164 #[test]
1165 fn validate_schema_rejects_non_last_variadic() {
1166 let mut s = Schema::default();
1167 let mut first = arg("files", "string");
1168 first.variadic = true;
1169 s.args.push(first);
1170 s.args.push(arg("trailer", "string"));
1171 let err = validate_schema(&s).expect_err("variadic-not-last must fail");
1172 assert!(err.contains("variadic"), "err was: {err}");
1173 }
1174
1175 #[test]
1176 fn validate_schema_rejects_required_after_optional() {
1177 let mut s = Schema::default();
1178 let mut first = arg("maybe", "string");
1179 first.required = false;
1180 s.args.push(first);
1181 s.args.push(arg("need", "string"));
1182 let err = validate_schema(&s).expect_err("required-after-optional must fail");
1183 assert!(err.contains("cannot follow"), "err was: {err}");
1184 }
1185
1186 fn schema_with_model_option(strict: bool) -> Schema {
1189 let mut s = Schema {
1190 strict,
1191 ..Schema::default()
1192 };
1193 let mut model = opt("string");
1194 model.short = Some("m".into());
1195 model.choices = Some(vec![
1196 serde_json::json!("mlp"),
1197 serde_json::json!("resnet"),
1198 ]);
1199 s.options.insert("model".into(), model);
1200 s.options.insert("epochs".into(), opt("int"));
1201 s.options.insert("validate".into(), opt("bool"));
1203 s
1204 }
1205
1206 fn strict_schema_with_model_option() -> Schema {
1207 schema_with_model_option(true)
1208 }
1209
1210 #[test]
1211 fn validate_tail_accepts_known_long_flag() {
1212 let schema = strict_schema_with_model_option();
1213 let tail = vec!["--epochs".into(), "3".into()];
1214 validate_tail(&tail, &schema).expect("known flag must pass");
1215 }
1216
1217 #[test]
1218 fn validate_tail_accepts_known_short_flag() {
1219 let schema = strict_schema_with_model_option();
1220 let tail = vec!["-m".into(), "mlp".into()];
1221 validate_tail(&tail, &schema).expect("known short must pass");
1222 }
1223
1224 #[test]
1225 fn validate_tail_accepts_bool_flag() {
1226 let schema = strict_schema_with_model_option();
1227 let tail = vec!["--validate".into()];
1228 validate_tail(&tail, &schema).expect("bool flag must pass");
1229 }
1230
1231 #[test]
1232 fn validate_tail_strict_rejects_unknown_long_flag() {
1233 let schema = strict_schema_with_model_option();
1234 let tail = vec!["--nope".into()];
1235 let err = validate_tail(&tail, &schema)
1236 .expect_err("unknown long flag must error in strict mode");
1237 assert!(err.contains("--nope"), "err was: {err}");
1238 }
1239
1240 #[test]
1241 fn validate_tail_strict_suggests_did_you_mean() {
1242 let schema = strict_schema_with_model_option();
1244 let tail = vec!["--epoch".into(), "3".into()];
1245 let err = validate_tail(&tail, &schema).expect_err("typo must error");
1246 assert!(err.contains("did you mean"), "err was: {err}");
1247 assert!(err.contains("--epochs"), "suggestion missing: {err}");
1248 }
1249
1250 #[test]
1251 fn validate_tail_strict_rejects_unknown_short_flag() {
1252 let schema = strict_schema_with_model_option();
1253 let tail = vec!["-z".into()];
1254 let err = validate_tail(&tail, &schema)
1255 .expect_err("unknown short must error in strict mode");
1256 assert!(err.contains("-z"), "err was: {err}");
1257 }
1258
1259 #[test]
1260 fn validate_tail_rejects_bad_choice_always_strict() {
1261 let schema = strict_schema_with_model_option();
1262 let tail = vec!["--model".into(), "lenet".into()];
1263 let err = validate_tail(&tail, &schema)
1264 .expect_err("out-of-set choice must error");
1265 assert!(err.contains("lenet"), "err was: {err}");
1266 assert!(err.contains("allowed"), "err should list allowed values: {err}");
1267 }
1268
1269 #[test]
1270 fn validate_tail_rejects_bad_choice_even_when_not_strict() {
1271 let schema = schema_with_model_option(false);
1276 let tail = vec!["--model".into(), "lenet".into()];
1277 let err = validate_tail(&tail, &schema)
1278 .expect_err("out-of-set choice must error without strict");
1279 assert!(err.contains("lenet"), "err was: {err}");
1280 assert!(err.contains("allowed"), "err should list allowed values: {err}");
1281 }
1282
1283 #[test]
1284 fn validate_tail_non_strict_tolerates_unknown_flag() {
1285 let schema = schema_with_model_option(false);
1288 let tail = vec!["--fancy-passthrough".into(), "value".into()];
1289 validate_tail(&tail, &schema)
1290 .expect("unknown flag must be tolerated when strict is off");
1291 }
1292
1293 #[test]
1294 fn validate_tail_non_strict_still_checks_known_short_choices() {
1295 let schema = schema_with_model_option(false);
1299 let tail = vec!["-m".into(), "lenet".into()];
1300 let err = validate_tail(&tail, &schema)
1301 .expect_err("out-of-set choice via short must error");
1302 assert!(err.contains("lenet"), "err was: {err}");
1303 }
1304
1305 #[test]
1306 fn validate_tail_allows_reserved_help() {
1307 let schema = strict_schema_with_model_option();
1311 let tail = vec!["--help".into()];
1312 validate_tail(&tail, &schema).expect("--help must be allowed");
1313 }
1314
1315 #[test]
1316 fn validate_tail_allows_reserved_fdl_schema() {
1317 let schema = strict_schema_with_model_option();
1319 let tail = vec!["--fdl-schema".into()];
1320 validate_tail(&tail, &schema).expect("--fdl-schema must be allowed");
1321 }
1322
1323 #[test]
1324 fn validate_tail_passthrough_after_double_dash() {
1325 let schema = strict_schema_with_model_option();
1328 let tail = vec!["--".into(), "--arbitrary".into(), "anything".into()];
1329 validate_tail(&tail, &schema).expect("passthrough must work");
1330 }
1331
1332 #[test]
1333 fn validate_presets_strict_rejects_unknown_option() {
1334 let schema = strict_schema_with_model_option();
1335 let mut commands = BTreeMap::new();
1336 let mut bad_options = BTreeMap::new();
1337 bad_options.insert("batchsize".into(), serde_json::json!(32));
1338 commands.insert(
1339 "quick".into(),
1340 CommandSpec {
1341 options: bad_options,
1342 ..Default::default()
1343 },
1344 );
1345 let err = validate_presets_strict(&commands, &schema)
1346 .expect_err("preset pinning undeclared option must error");
1347 assert!(err.contains("quick"), "err should name the preset: {err}");
1348 assert!(err.contains("batchsize"), "err should name the key: {err}");
1349 }
1350
1351 #[test]
1352 fn validate_presets_strict_accepts_known_options() {
1353 let schema = strict_schema_with_model_option();
1354 let mut commands = BTreeMap::new();
1355 let mut good_options = BTreeMap::new();
1356 good_options.insert("model".into(), serde_json::json!("mlp"));
1357 good_options.insert("epochs".into(), serde_json::json!(5));
1358 commands.insert(
1359 "quick".into(),
1360 CommandSpec {
1361 options: good_options,
1362 ..Default::default()
1363 },
1364 );
1365 validate_presets_strict(&commands, &schema)
1366 .expect("presets with declared options must pass");
1367 }
1368
1369 #[test]
1370 fn validate_presets_strict_ignores_run_and_path_kinds() {
1371 let schema = strict_schema_with_model_option();
1374 let mut commands = BTreeMap::new();
1375 commands.insert(
1376 "helper".into(),
1377 CommandSpec {
1378 run: Some("echo hi".into()),
1379 ..Default::default()
1380 },
1381 );
1382 commands.insert(
1383 "nested".into(),
1384 CommandSpec {
1385 path: Some("./nested/".into()),
1386 ..Default::default()
1387 },
1388 );
1389 validate_presets_strict(&commands, &schema)
1390 .expect("run/path siblings must be ignored by preset strict check");
1391 }
1392
1393 #[test]
1396 fn validate_preset_values_rejects_bad_choice_even_without_strict() {
1397 let schema = schema_with_model_option(false);
1400 let mut commands = BTreeMap::new();
1401 let mut opts = BTreeMap::new();
1402 opts.insert("model".into(), serde_json::json!("lenet"));
1403 commands.insert(
1404 "quick".into(),
1405 CommandSpec {
1406 options: opts,
1407 ..Default::default()
1408 },
1409 );
1410 let err = validate_preset_values(&commands, &schema)
1411 .expect_err("out-of-choices preset must error");
1412 assert!(err.contains("quick"), "preset name missing: {err}");
1413 assert!(err.contains("model"), "option name missing: {err}");
1414 assert!(err.contains("lenet"), "bad value missing: {err}");
1415 assert!(err.contains("allowed"), "allowed list missing: {err}");
1416 }
1417
1418 #[test]
1419 fn validate_preset_values_accepts_in_choices_preset() {
1420 let schema = schema_with_model_option(false);
1421 let mut commands = BTreeMap::new();
1422 let mut opts = BTreeMap::new();
1423 opts.insert("model".into(), serde_json::json!("mlp"));
1424 commands.insert(
1425 "quick".into(),
1426 CommandSpec {
1427 options: opts,
1428 ..Default::default()
1429 },
1430 );
1431 validate_preset_values(&commands, &schema)
1432 .expect("in-choices preset must pass");
1433 }
1434
1435 #[test]
1436 fn validate_preset_values_ignores_undeclared_keys() {
1437 let schema = schema_with_model_option(false);
1440 let mut commands = BTreeMap::new();
1441 let mut opts = BTreeMap::new();
1442 opts.insert("extra".into(), serde_json::json!("whatever"));
1443 commands.insert(
1444 "quick".into(),
1445 CommandSpec {
1446 options: opts,
1447 ..Default::default()
1448 },
1449 );
1450 validate_preset_values(&commands, &schema)
1451 .expect("undeclared key must be ignored by value validator");
1452 }
1453
1454 #[test]
1455 fn validate_preset_values_ignores_options_without_choices() {
1456 let schema = schema_with_model_option(false);
1459 let mut commands = BTreeMap::new();
1460 let mut opts = BTreeMap::new();
1461 opts.insert("epochs".into(), serde_json::json!(999));
1462 commands.insert(
1463 "quick".into(),
1464 CommandSpec {
1465 options: opts,
1466 ..Default::default()
1467 },
1468 );
1469 validate_preset_values(&commands, &schema)
1470 .expect("no-choices option must accept any value");
1471 }
1472
1473 #[test]
1474 fn validate_schema_rejects_required_with_default() {
1475 let mut s = Schema::default();
1476 let mut a = arg("x", "string");
1477 a.default = Some(serde_json::json!("foo"));
1478 s.args.push(a);
1479 let err = validate_schema(&s).expect_err("required+default must fail");
1480 assert!(err.contains("contradiction"), "err was: {err}");
1481 }
1482
1483 #[test]
1487 fn fdl_yml_example_has_doc_script() {
1488 let cfg = load_example();
1489 let doc = cfg.commands.get("doc").unwrap_or_else(|| {
1490 panic!(
1491 "fdl.yml.example is missing a `doc` command; the rustdoc pipeline \
1492 depends on `fdl doc` being defined"
1493 )
1494 });
1495 let cmd = doc
1496 .run
1497 .as_deref()
1498 .expect("fdl.yml.example `doc` command must be a `run:` entry");
1499 assert!(
1500 !cmd.trim().is_empty(),
1501 "fdl.yml.example `doc` command has an empty `run:` command"
1502 );
1503 assert!(
1504 cmd.contains("cargo doc"),
1505 "fdl.yml.example `doc` command must invoke `cargo doc`, got: {cmd}"
1506 );
1507 assert!(
1512 cmd.contains("target/doc"),
1513 "fdl.yml.example `doc` command must verify output was produced \
1514 (expected a `test -f target/doc/...` check), got: {cmd}"
1515 );
1516 }
1517
1518 #[test]
1519 fn command_spec_kind_mutex_run_and_path() {
1520 let spec = CommandSpec {
1521 run: Some("echo".into()),
1522 path: Some("x/".into()),
1523 ..Default::default()
1524 };
1525 let err = spec.kind().expect_err("run + path must fail");
1526 assert!(err.contains("both"), "err was: {err}");
1527 }
1528
1529 #[test]
1530 fn command_spec_kind_path_convention() {
1531 let spec = CommandSpec::default();
1532 assert_eq!(spec.kind().unwrap(), CommandKind::Path);
1533 }
1534
1535 #[test]
1536 fn command_spec_kind_preset_when_preset_fields_set() {
1537 let spec = CommandSpec {
1538 training: Some(TrainingConfig {
1539 epochs: Some(1),
1540 ..Default::default()
1541 }),
1542 ..Default::default()
1543 };
1544 assert_eq!(spec.kind().unwrap(), CommandKind::Preset);
1545 }
1546
1547 #[test]
1548 fn command_spec_kind_preset_when_only_options_set() {
1549 let mut options = BTreeMap::new();
1552 options.insert("model".into(), serde_json::json!("linear"));
1553 let spec = CommandSpec {
1554 options,
1555 ..Default::default()
1556 };
1557 assert_eq!(spec.kind().unwrap(), CommandKind::Preset);
1558 }
1559
1560 #[test]
1561 fn command_spec_kind_path_explicit() {
1562 let spec = CommandSpec {
1565 path: Some("./sub/".into()),
1566 ..Default::default()
1567 };
1568 assert_eq!(spec.kind().unwrap(), CommandKind::Path);
1569 }
1570
1571 #[test]
1572 fn command_spec_kind_rejects_docker_without_run() {
1573 let spec = CommandSpec {
1577 docker: Some("cuda".into()),
1578 ..Default::default()
1579 };
1580 let err = spec
1581 .kind()
1582 .expect_err("docker without run must fail");
1583 assert!(err.contains("docker"), "err was: {err}");
1584 }
1585
1586 #[test]
1587 fn command_spec_kind_allows_docker_with_run() {
1588 let spec = CommandSpec {
1589 run: Some("cargo test".into()),
1590 docker: Some("dev".into()),
1591 ..Default::default()
1592 };
1593 assert_eq!(spec.kind().unwrap(), CommandKind::Run);
1594 }
1595
1596 #[test]
1597 fn command_spec_deserialize_from_null() {
1598 let yaml = "cmd: ~";
1599 let map: BTreeMap<String, CommandSpec> =
1600 serde_yaml::from_str(yaml).expect("null must deserialize to default");
1601 let spec = map.get("cmd").expect("cmd missing");
1602 assert!(spec.run.is_none() && spec.path.is_none());
1603 assert_eq!(spec.kind().unwrap(), CommandKind::Path);
1604 }
1605
1606 #[test]
1607 fn command_config_arg_name_deserializes_kebab_case() {
1608 let yaml = "arg-name: recipe\nentry: echo\n";
1610 let cfg: CommandConfig =
1611 serde_yaml::from_str(yaml).expect("arg-name must parse");
1612 assert_eq!(cfg.arg_name.as_deref(), Some("recipe"));
1613 }
1614
1615 #[test]
1616 fn command_config_arg_name_defaults_to_none() {
1617 let cfg: CommandConfig =
1618 serde_yaml::from_str("entry: echo\n").expect("minimal cfg must parse");
1619 assert!(cfg.arg_name.is_none());
1620 }
1621
1622 struct TempDir(PathBuf);
1631 impl TempDir {
1632 fn new() -> Self {
1633 use std::sync::atomic::{AtomicU64, Ordering};
1634 static N: AtomicU64 = AtomicU64::new(0);
1635 let dir = std::env::temp_dir().join(format!(
1636 "fdl-cfg-test-{}-{}",
1637 std::process::id(),
1638 N.fetch_add(1, Ordering::Relaxed)
1639 ));
1640 std::fs::create_dir_all(&dir).unwrap();
1641 Self(dir)
1642 }
1643 }
1644 impl Drop for TempDir {
1645 fn drop(&mut self) {
1646 let _ = std::fs::remove_dir_all(&self.0);
1647 }
1648 }
1649
1650 fn filenames(layers: &[(PathBuf, serde_yaml::Value)]) -> Vec<String> {
1651 layers
1652 .iter()
1653 .map(|(p, _)| {
1654 p.file_name()
1655 .and_then(|n| n.to_str())
1656 .unwrap_or("?")
1657 .to_string()
1658 })
1659 .collect()
1660 }
1661
1662 #[test]
1663 fn resolve_config_layers_base_only() {
1664 let tmp = TempDir::new();
1665 let base = tmp.0.join("fdl.yml");
1666 std::fs::write(&base, "a: 1\n").unwrap();
1667 let layers = resolve_config_layers(&base, None).unwrap();
1668 assert_eq!(filenames(&layers), vec!["fdl.yml"]);
1669 }
1670
1671 #[test]
1672 fn resolve_config_layers_base_with_env_overlay() {
1673 let tmp = TempDir::new();
1674 let base = tmp.0.join("fdl.yml");
1675 let env = tmp.0.join("fdl.ci.yml");
1676 std::fs::write(&base, "a: 1\n").unwrap();
1677 std::fs::write(&env, "b: 2\n").unwrap();
1678 let layers = resolve_config_layers(&base, Some("ci")).unwrap();
1679 assert_eq!(filenames(&layers), vec!["fdl.yml", "fdl.ci.yml"]);
1680 }
1681
1682 #[test]
1683 fn resolve_config_layers_env_inherits_from_mixin() {
1684 let tmp = TempDir::new();
1687 let base = tmp.0.join("fdl.yml");
1688 let cloud = tmp.0.join("fdl.cloud.yml");
1689 let ci = tmp.0.join("fdl.ci.yml");
1690 std::fs::write(&base, "a: 1\n").unwrap();
1691 std::fs::write(&cloud, "b: 2\n").unwrap();
1692 std::fs::write(&ci, "inherit-from: fdl.cloud.yml\nc: 3\n").unwrap();
1693 let layers = resolve_config_layers(&base, Some("ci")).unwrap();
1694 assert_eq!(
1695 filenames(&layers),
1696 vec!["fdl.yml", "fdl.cloud.yml", "fdl.ci.yml"]
1697 );
1698 }
1699
1700 #[test]
1701 fn resolve_config_layers_dedups_when_env_inherits_from_base() {
1702 let tmp = TempDir::new();
1706 let base = tmp.0.join("fdl.yml");
1707 let ci = tmp.0.join("fdl.ci.yml");
1708 std::fs::write(&base, "a: 1\n").unwrap();
1709 std::fs::write(&ci, "inherit-from: fdl.yml\nb: 2\n").unwrap();
1710 let layers = resolve_config_layers(&base, Some("ci")).unwrap();
1711 assert_eq!(filenames(&layers), vec!["fdl.yml", "fdl.ci.yml"]);
1712 }
1713
1714 #[test]
1715 fn resolve_config_layers_merged_value_matches_chain() {
1716 let tmp = TempDir::new();
1719 let base = tmp.0.join("fdl.yml");
1720 let cloud = tmp.0.join("fdl.cloud.yml");
1721 let ci = tmp.0.join("fdl.ci.yml");
1722 std::fs::write(&base, "value: base\nkeep_base: yes\n").unwrap();
1723 std::fs::write(&cloud, "value: cloud\nkeep_cloud: yes\n").unwrap();
1724 std::fs::write(
1725 &ci,
1726 "inherit-from: fdl.cloud.yml\nvalue: ci\nkeep_ci: yes\n",
1727 )
1728 .unwrap();
1729 let merged = load_merged_value(&base, Some("ci")).unwrap();
1730 let m = merged.as_mapping().unwrap();
1731 assert_eq!(
1733 m.get(serde_yaml::Value::String("value".into())).unwrap(),
1734 &serde_yaml::Value::String("ci".into())
1735 );
1736 assert!(m.contains_key(serde_yaml::Value::String("keep_base".into())));
1738 assert!(m.contains_key(serde_yaml::Value::String("keep_cloud".into())));
1739 assert!(m.contains_key(serde_yaml::Value::String("keep_ci".into())));
1740 }
1741
1742 #[test]
1743 fn resolve_config_layers_missing_env_errors() {
1744 let tmp = TempDir::new();
1745 let base = tmp.0.join("fdl.yml");
1746 std::fs::write(&base, "a: 1\n").unwrap();
1747 let err = resolve_config_layers(&base, Some("nope")).unwrap_err();
1748 assert!(err.contains("nope"));
1749 assert!(err.contains("not found"));
1750 }
1751
1752 #[test]
1753 fn resolve_config_layers_base_inherit_from_chain() {
1754 let tmp = TempDir::new();
1757 let defaults = tmp.0.join("shared.yml");
1758 let base = tmp.0.join("fdl.yml");
1759 std::fs::write(&defaults, "policy: default\n").unwrap();
1760 std::fs::write(&base, "inherit-from: shared.yml\npolicy: override\n").unwrap();
1761 let layers = resolve_config_layers(&base, None).unwrap();
1762 assert_eq!(filenames(&layers), vec!["shared.yml", "fdl.yml"]);
1763 }
1764
1765 #[test]
1766 fn load_command_auto_probes_non_cargo_entry_and_writes_cache() {
1767 let tmp = TempDir::new();
1771 let cmd_dir = tmp.0.join("mybench");
1772 std::fs::create_dir_all(&cmd_dir).unwrap();
1773
1774 let script = cmd_dir.join("emit.sh");
1775 let body = "#!/bin/sh\n\
1776 if [ \"$1\" = \"--fdl-schema\" ]; then\n\
1777 cat <<'JSON'\n\
1778 { \"options\": { \"rounds\": { \"type\": \"int\", \"description\": \"N\" } } }\n\
1779 JSON\n\
1780 exit 0\n\
1781 fi\n";
1782 std::fs::write(&script, body).unwrap();
1783 #[cfg(unix)]
1784 {
1785 use std::os::unix::fs::PermissionsExt;
1786 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1787 }
1788
1789 std::fs::write(cmd_dir.join("fdl.yml"), "entry: sh emit.sh\n").unwrap();
1790
1791 let cfg = load_command(&cmd_dir).expect("load ok");
1792 let schema = cfg.schema.expect("auto-probe must populate schema");
1793 assert!(schema.options.contains_key("rounds"));
1794
1795 let cached_path = crate::schema_cache::cache_path(&cmd_dir, "mybench");
1797 assert!(cached_path.is_file(), "cache file should exist");
1798 }
1799
1800 #[test]
1801 fn load_command_skips_auto_probe_for_cargo_entries() {
1802 let tmp = TempDir::new();
1807 let cmd_dir = tmp.0.join("cargo-cmd");
1808 std::fs::create_dir_all(&cmd_dir).unwrap();
1809 std::fs::write(cmd_dir.join("fdl.yml"), "entry: cargo run --\n").unwrap();
1810
1811 let cfg = load_command(&cmd_dir).expect("load ok");
1812 assert!(
1813 cfg.schema.is_none(),
1814 "cargo entry must not be auto-probed (compile latency would ruin --help)"
1815 );
1816 let cached = crate::schema_cache::cache_path(&cmd_dir, "cargo-cmd");
1817 assert!(!cached.exists(), "no cache should be written for cargo entries");
1818 }
1819
1820 #[test]
1821 fn load_command_compile_true_overrides_cargo_skip() {
1822 let tmp = TempDir::new();
1835 let cmd_dir = tmp.0.join("cargo-compile-true");
1836 std::fs::create_dir_all(&cmd_dir).unwrap();
1837 std::fs::write(
1838 cmd_dir.join("fdl.yml"),
1839 "entry: cargo run --\ncompile: true\n",
1840 )
1841 .unwrap();
1842
1843 let cfg = load_command(&cmd_dir).expect("load ok");
1847 assert_eq!(cfg.compile, Some(true), "compile field round-trips");
1848 assert!(cfg.schema.is_none());
1849 }
1850
1851 #[test]
1852 fn load_command_compile_false_keeps_cargo_skip() {
1853 let tmp = TempDir::new();
1856 let cmd_dir = tmp.0.join("cargo-compile-false");
1857 std::fs::create_dir_all(&cmd_dir).unwrap();
1858 std::fs::write(
1859 cmd_dir.join("fdl.yml"),
1860 "entry: cargo run --\ncompile: false\n",
1861 )
1862 .unwrap();
1863
1864 let cfg = load_command(&cmd_dir).expect("load ok");
1865 assert_eq!(cfg.compile, Some(false));
1866 assert!(cfg.schema.is_none(), "cargo skip honored when compile: false");
1867 let cached = crate::schema_cache::cache_path(&cmd_dir, "cargo-compile-false");
1868 assert!(!cached.exists());
1869 }
1870
1871 #[test]
1872 fn load_command_auto_probe_failure_falls_through_silently() {
1873 let tmp = TempDir::new();
1876 let cmd_dir = tmp.0.join("silent");
1877 std::fs::create_dir_all(&cmd_dir).unwrap();
1878 std::fs::write(cmd_dir.join("fdl.yml"), "entry: \"/bin/true\"\n").unwrap();
1882
1883 let cfg = load_command(&cmd_dir).expect("load must succeed despite probe error");
1884 assert!(cfg.schema.is_none());
1885 }
1886}