1use std::collections::BTreeMap;
8use std::path::Path;
9use std::process::{ExitCode, Stdio};
10
11use crate::builtins;
12use crate::cli_error;
13use crate::config::{self, ArgSpec, CommandConfig, OptionSpec, ResolvedConfig, Schema};
14use crate::libtorch;
15use crate::style;
16
17pub fn config_to_args(resolved: &ResolvedConfig) -> Vec<String> {
21 let mut args = Vec::new();
22
23 let d = &resolved.ddp;
25 push_opt(&mut args, "--mode", &d.mode);
26 push_opt(&mut args, "--policy", &d.policy);
27 push_opt(&mut args, "--backend", &d.backend);
28 push_value(&mut args, "--anchor", &d.anchor);
29 push_num(&mut args, "--max-anchor", &d.max_anchor);
30 push_float(&mut args, "--overhead-target", &d.overhead_target);
31 push_float(&mut args, "--divergence-threshold", &d.divergence_threshold);
32 push_value(&mut args, "--max-batch-diff", &d.max_batch_diff);
33 push_float(&mut args, "--max-grad-norm", &d.max_grad_norm);
34 push_num(&mut args, "--snapshot-timeout", &d.snapshot_timeout);
35 push_num(&mut args, "--checkpoint-every", &d.checkpoint_every);
36 push_value(&mut args, "--progressive", &d.progressive);
37 if let Some(hint) = &d.speed_hint {
38 args.push("--speed-hint".into());
39 args.push(format!("{}:{}", hint.slow_rank, hint.ratio));
40 }
41 if let Some(ratios) = &d.partition_ratios {
42 let s: Vec<String> = ratios.iter().map(|r| format!("{r}")).collect();
43 args.push("--partition-ratios".into());
44 args.push(s.join(","));
45 }
46 if let Some(ratio) = d.lr_scale_ratio {
47 args.push("--lr-scale-ratio".into());
48 args.push(format!("{ratio}"));
49 }
50 if d.timeline == Some(true) {
51 args.push("--timeline".into());
52 }
53
54 let t = &resolved.training;
56 push_num(&mut args, "--epochs", &t.epochs);
57 push_num(&mut args, "--batch-size", &t.batch_size);
58 push_num(&mut args, "--batches", &t.batches_per_epoch);
59 push_float(&mut args, "--lr", &t.lr);
60 push_num(&mut args, "--seed", &t.seed);
61
62 let o = &resolved.output;
64 push_opt(&mut args, "--output", &o.dir);
65 push_num(&mut args, "--monitor", &o.monitor);
66
67 for (key, val) in &resolved.options {
69 let flag = format!("--{}", key.replace('_', "-"));
70 match val {
71 serde_json::Value::Bool(true) => args.push(flag),
72 serde_json::Value::Bool(false) => {}
73 serde_json::Value::Null => {}
74 other => {
75 args.push(flag);
76 args.push(value_to_string(other));
77 }
78 }
79 }
80
81 args
82}
83
84fn push_opt(args: &mut Vec<String>, flag: &str, val: &Option<String>) {
85 if let Some(v) = val {
86 args.push(flag.into());
87 args.push(v.clone());
88 }
89}
90
91fn push_num<T: std::fmt::Display>(args: &mut Vec<String>, flag: &str, val: &Option<T>) {
92 if let Some(v) = val {
93 args.push(flag.into());
94 args.push(v.to_string());
95 }
96}
97
98fn push_float(args: &mut Vec<String>, flag: &str, val: &Option<f64>) {
99 if let Some(v) = val {
100 args.push(flag.into());
101 args.push(format!("{v}"));
102 }
103}
104
105fn push_value(args: &mut Vec<String>, flag: &str, val: &Option<serde_json::Value>) {
106 if let Some(v) = val {
107 match v {
108 serde_json::Value::Null => {}
109 other => {
110 args.push(flag.into());
111 args.push(value_to_string(other));
112 }
113 }
114 }
115}
116
117fn value_to_string(v: &serde_json::Value) -> String {
118 match v {
119 serde_json::Value::String(s) => s.clone(),
120 serde_json::Value::Number(n) => n.to_string(),
121 serde_json::Value::Bool(b) => b.to_string(),
122 other => other.to_string(),
123 }
124}
125
126fn inside_docker() -> bool {
130 Path::new("/.dockerenv").exists()
131}
132
133const DEFAULT_CONTAINER_PROJECT_ROOT: &str = "/workspace";
138
139static COMPOSE_MOUNT_CACHE: std::sync::OnceLock<
146 std::collections::HashMap<String, String>,
147> = std::sync::OnceLock::new();
148
149fn container_project_root(project_root: &Path, service: &str) -> String {
165 let cache = COMPOSE_MOUNT_CACHE
166 .get_or_init(|| parse_compose_project_mounts(project_root));
167 cache
168 .get(service)
169 .cloned()
170 .unwrap_or_else(|| DEFAULT_CONTAINER_PROJECT_ROOT.to_string())
171}
172
173fn parse_compose_project_mounts(
182 project_root: &Path,
183) -> std::collections::HashMap<String, String> {
184 let compose_path = project_root.join("docker-compose.yml");
185 let text = match std::fs::read_to_string(&compose_path) {
186 Ok(t) => t,
187 Err(_) => return std::collections::HashMap::new(),
188 };
189 let doc: serde_yaml::Value = match serde_yaml::from_str(&text) {
190 Ok(d) => d,
191 Err(_) => return std::collections::HashMap::new(),
192 };
193 let mut out = std::collections::HashMap::new();
194 let services = match doc.get("services").and_then(|v| v.as_mapping()) {
195 Some(s) => s,
196 None => return out,
197 };
198 for (name, svc) in services {
199 let svc_name = match name.as_str() {
200 Some(s) => s,
201 None => continue,
202 };
203 let volumes = match svc.get("volumes").and_then(|v| v.as_sequence()) {
204 Some(v) => v,
205 None => continue,
206 };
207 if let Some(container_path) = find_project_mount(volumes) {
208 let cleaned = container_path.trim_end_matches('/').to_string();
211 let cleaned = if cleaned.is_empty() {
212 "/".to_string()
213 } else {
214 cleaned
215 };
216 out.insert(svc_name.to_string(), cleaned);
217 }
218 }
219 out
220}
221
222fn find_project_mount(volumes: &[serde_yaml::Value]) -> Option<String> {
226 for entry in volumes {
227 if let Some(s) = entry.as_str() {
228 let mut parts = s.splitn(3, ':');
233 let host = parts.next()?;
234 let container = parts.next()?;
235 if host == "." || host == "./" {
236 return Some(container.to_string());
237 }
238 } else if let Some(m) = entry.as_mapping() {
239 let source = m
241 .get(serde_yaml::Value::String("source".into()))
242 .and_then(|v| v.as_str());
243 let target = m
244 .get(serde_yaml::Value::String("target".into()))
245 .and_then(|v| v.as_str());
246 if matches!(source, Some(".") | Some("./")) {
247 if let Some(t) = target {
248 return Some(t.to_string());
249 }
250 }
251 }
252 }
253 None
254}
255
256fn libtorch_env(project_root: &Path) -> Vec<(String, String)> {
261 let mut env = Vec::new();
262
263 env.push((
265 "LIBTORCH_CPU_PATH".into(),
266 "./libtorch/precompiled/cpu".into(),
267 ));
268
269 if let Some(info) = libtorch::detect::read_active(project_root) {
271 let host_path = format!("./libtorch/{}", info.path);
272 env.push(("LIBTORCH_HOST_PATH".into(), host_path));
273
274 if let Some(cuda) = &info.cuda_version {
276 if cuda != "none" {
277 let cuda_version = if cuda.matches('.').count() < 2 {
278 format!("{cuda}.0")
279 } else {
280 cuda.clone()
281 };
282 let cuda_tag = cuda_version
283 .splitn(3, '.')
284 .take(2)
285 .collect::<Vec<_>>()
286 .join(".");
287 env.push(("CUDA_VERSION".into(), cuda_version));
288 env.push(("CUDA_TAG".into(), cuda_tag));
289 }
290 }
291 }
292
293 env
294}
295
296fn spawn_docker_shell(command: &str, project_root: &Path) -> ExitCode {
302 let env_vars = libtorch_env(project_root);
303
304 let mut cmd = std::process::Command::new("sh");
305 cmd.args(["-c", command])
306 .current_dir(project_root)
307 .stdout(Stdio::inherit())
308 .stderr(Stdio::inherit())
309 .stdin(Stdio::inherit());
310
311 for (key, val) in &env_vars {
312 cmd.env(key, val);
313 }
314
315 match cmd.status() {
316 Ok(s) if s.success() => ExitCode::SUCCESS,
317 Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
318 Err(e) => {
319 cli_error!("{e}");
320 ExitCode::FAILURE
321 }
322 }
323}
324
325pub(crate) fn posix_quote(s: &str) -> String {
332 if s.is_empty() {
333 return "''".to_string();
334 }
335 let safe = s.chars().all(|c| {
336 c.is_ascii_alphanumeric()
337 || matches!(c, '_' | '-' | '.' | '/' | ':' | '=' | '+' | '@' | ',')
338 });
339 if safe {
340 return s.to_string();
341 }
342 let mut out = String::with_capacity(s.len() + 2);
343 out.push('\'');
344 for c in s.chars() {
345 if c == '\'' {
346 out.push_str("'\\''");
347 } else {
348 out.push(c);
349 }
350 }
351 out.push('\'');
352 out
353}
354
355fn split_append_dashdash(s: &str) -> (String, String) {
366 let s = s.trim();
367 if s == "--" {
368 return (String::new(), String::new());
369 }
370 if let Some(rest) = s.strip_prefix("-- ") {
371 return (String::new(), rest.trim().to_string());
372 }
373 if let Some(prefix) = s.strip_suffix(" --") {
374 return (prefix.trim().to_string(), String::new());
375 }
376 if let Some(idx) = s.find(" -- ") {
377 let before = &s[..idx];
378 let after = &s[idx + 4..];
379 return (before.trim().to_string(), after.trim().to_string());
380 }
381 (s.to_string(), String::new())
382}
383
384fn split_user_args_dashdash(args: &[String]) -> (&[String], Option<&[String]>) {
390 match args.iter().position(|a| a == "--") {
391 Some(idx) => (&args[..idx], Some(&args[idx + 1..])),
392 None => (args, None),
393 }
394}
395
396pub(crate) fn compose_run_command(
411 run: &str,
412 user_args: &[String],
413 append: Option<&str>,
414) -> String {
415 let suffix = append.map(str::trim).filter(|s| !s.is_empty());
416 let (append_pre, append_post, append_has_dashdash) = match suffix {
417 Some(s) => {
418 let (pre, post) = split_append_dashdash(s);
419 let has = s == "--"
424 || s.starts_with("-- ")
425 || s.ends_with(" --")
426 || s.contains(" -- ");
427 (pre, post, has)
428 }
429 None => (String::new(), String::new(), false),
430 };
431 let (user_pre, user_post_opt) = split_user_args_dashdash(user_args);
432
433 let mut out = String::from(run.trim());
434 if !append_pre.is_empty() {
435 out.push(' ');
436 out.push_str(&append_pre);
437 }
438 for a in user_pre {
439 out.push(' ');
440 out.push_str(&posix_quote(a));
441 }
442
443 let needs_separator = append_has_dashdash
444 || !append_post.is_empty()
445 || user_post_opt.is_some_and(|p| !p.is_empty());
446 if needs_separator {
447 out.push_str(" --");
448 if !append_post.is_empty() {
449 out.push(' ');
450 out.push_str(&append_post);
451 }
452 if let Some(post) = user_post_opt {
453 for a in post {
454 out.push(' ');
455 out.push_str(&posix_quote(a));
456 }
457 }
458 }
459 out
460}
461
462pub fn exec_script(
469 command: &str,
470 append: Option<&str>,
471 user_args: &[String],
472 docker_service: Option<&str>,
473 cwd: &Path,
474) -> ExitCode {
475 let inner_cmd = compose_run_command(command, user_args, append);
476
477 match docker_service {
478 Some(service) if !inside_docker() => {
479 let docker_cmd = format!(
483 "docker compose run --rm {service} bash -c {}",
484 posix_quote(&inner_cmd)
485 );
486 spawn_docker_shell(&docker_cmd, cwd)
487 }
488 _ => {
489 let (shell, flag) = if cfg!(target_os = "windows") {
490 ("cmd", "/C")
491 } else {
492 ("sh", "-c")
493 };
494
495 match std::process::Command::new(shell)
496 .args([flag, inner_cmd.as_str()])
497 .current_dir(cwd)
498 .stdout(Stdio::inherit())
499 .stderr(Stdio::inherit())
500 .stdin(Stdio::inherit())
501 .status()
502 {
503 Ok(s) if s.success() => ExitCode::SUCCESS,
504 Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
505 Err(e) => {
506 cli_error!("{e}");
507 ExitCode::FAILURE
508 }
509 }
510 }
511 }
512}
513
514pub fn exec_command(
521 cmd_config: &CommandConfig,
522 preset_name: Option<&str>,
523 extra_args: &[String],
524 cmd_dir: &Path,
525 project_root: &Path,
526) -> ExitCode {
527 let entry = match &cmd_config.entry {
528 Some(e) => e.as_str(),
529 None => {
530 eprintln!(
531 "error: no entry point defined in {}/fdl.yaml",
532 cmd_dir.display()
533 );
534 return ExitCode::FAILURE;
535 }
536 };
537
538 if let Some(schema) = &cmd_config.schema {
547 if let Err(e) = config::validate_tail(extra_args, schema) {
548 cli_error!("{e}");
549 return ExitCode::FAILURE;
550 }
551 }
552
553 let resolved = match preset_name {
555 Some(name) => match cmd_config.commands.get(name) {
556 Some(preset) => {
557 if let Some(schema) = &cmd_config.schema {
561 if let Err(e) = config::validate_preset_for_exec(name, preset, schema) {
562 cli_error!("{e}");
563 return ExitCode::FAILURE;
564 }
565 }
566 config::merge_preset(cmd_config, preset)
567 }
568 None => {
569 cli_error!("unknown command '{name}'");
570 eprintln!();
571 print_command_help(cmd_config, "");
572 return ExitCode::FAILURE;
573 }
574 },
575 None => config::defaults_only(cmd_config),
576 };
577
578 let mut args = config_to_args(&resolved);
580
581 args.extend(extra_args.iter().cloned());
583
584 let use_docker = cmd_config.docker.is_some() && !inside_docker();
586
587 if use_docker {
588 let service = cmd_config.docker.as_deref().unwrap();
589 let workdir = cmd_dir
590 .strip_prefix(project_root)
591 .unwrap_or(cmd_dir)
592 .to_string_lossy();
593
594 let container_root = container_project_root(project_root, service);
603 let args_str = shell_join(&args);
604 let inner = if workdir.is_empty() || workdir == "." {
605 format!("{entry} {args_str}")
606 } else {
607 format!("cd {container_root}/{workdir} && {entry} {args_str}")
608 };
609
610 if preset_name.is_some() {
611 eprintln!("fdl: [{service}] {inner}");
612 }
613
614 let docker_cmd = format!(
622 "docker compose run --rm -e FDL_PROJECT_ROOT={container_root} {service} bash -c \"{inner}\"",
623 );
624 spawn_docker_shell(&docker_cmd, project_root)
625 } else {
626 let parts: Vec<&str> = entry.split_whitespace().collect();
628 if parts.is_empty() {
629 cli_error!("empty entry point");
630 return ExitCode::FAILURE;
631 }
632 let program = parts[0];
633 let entry_args = &parts[1..];
634
635 if preset_name.is_some() {
636 let preview: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
637 eprintln!("fdl: {entry} {}", preview.join(" "));
638 }
639
640 match std::process::Command::new(program)
641 .args(entry_args)
642 .args(&args)
643 .current_dir(cmd_dir)
644 .stdout(Stdio::inherit())
645 .stderr(Stdio::inherit())
646 .stdin(Stdio::inherit())
647 .status()
648 {
649 Ok(s) if s.success() => ExitCode::SUCCESS,
650 Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
651 Err(e) => {
652 cli_error!("failed to execute '{program}': {e}");
653 ExitCode::FAILURE
654 }
655 }
656 }
657}
658
659fn shell_join(args: &[String]) -> String {
661 args.iter()
662 .map(|a| {
663 if a.contains(' ') || a.contains('"') || a.is_empty() {
664 format!("'{}'", a.replace('\'', "'\\''"))
665 } else {
666 a.clone()
667 }
668 })
669 .collect::<Vec<_>>()
670 .join(" ")
671}
672
673pub fn print_run_help(
679 name: &str,
680 description: Option<&str>,
681 run: &str,
682 append: Option<&str>,
683 docker: Option<&str>,
684) {
685 if let Some(desc) = description {
686 eprintln!("{} {desc}", style::bold(name));
687 } else {
688 eprintln!("{}", style::bold(name));
689 }
690 eprintln!();
691 eprintln!("{}:", style::yellow("Usage"));
692 eprintln!(" fdl {name} [-- <args>... [-- <runner-args>...]]");
693 eprintln!();
694 eprintln!("{}:", style::yellow("Runs"));
695 let composed = match append.map(str::trim).filter(|s| !s.is_empty()) {
696 Some(suffix) => {
697 let (pre, post) = split_append_dashdash(suffix);
698 let left = if pre.is_empty() {
702 "[<args>]".to_string()
703 } else {
704 format!("{pre} [<args>]")
705 };
706 let right = if post.is_empty() {
707 "[<runner-args>]".to_string()
708 } else {
709 format!("{post} [<runner-args>]")
710 };
711 format!("{run} {left} -- {right}")
712 }
713 None => format!("{run} [<args>] [-- <runner-args>]"),
714 };
715 if let Some(svc) = docker {
716 eprintln!(
717 " {} {svc} -c {composed:?}",
718 style::dim("docker compose run --rm")
719 );
720 } else {
721 eprintln!(" {composed}");
722 }
723 eprintln!();
724 eprintln!(
725 "{} the first `--` separates fdl args from the run script; a second `--` splits cargo-side args from runner-side args.",
726 style::dim("Note:"),
727 );
728 eprintln!(
729 "{} `append:` is split on its own `--` and merged half-and-half; pass `--no-append` to drop it entirely.",
730 style::dim("Note:"),
731 );
732}
733
734pub fn print_command_help(cmd_config: &CommandConfig, name: &str) {
737 let (presets, sub_cmds) = split_commands_by_kind(&cmd_config.commands);
738 let preset_slot = cmd_config.arg_name.as_deref().unwrap_or("preset");
739
740 print_title(cmd_config, name);
741 print_usage_line(cmd_config, name, &presets, &sub_cmds, preset_slot);
742 print_arguments_section(cmd_config, &presets, preset_slot);
743 print_sub_commands_section(&sub_cmds);
744 print_options_section(cmd_config);
745 print_entry_section(cmd_config);
746 print_defaults_section(cmd_config);
747}
748
749fn print_title(cmd_config: &CommandConfig, name: &str) {
750 if let Some(desc) = &cmd_config.description {
751 eprintln!("{} {desc}", style::bold(name));
752 } else {
753 eprintln!("{}", style::bold(name));
754 }
755}
756
757fn print_usage_line(
758 cmd_config: &CommandConfig,
759 name: &str,
760 presets: &CommandGroup,
761 sub_cmds: &CommandGroup,
762 preset_slot: &str,
763) {
764 let usage_tail = build_usage_tail(
767 cmd_config.schema.as_ref(),
768 !presets.is_empty(),
769 !sub_cmds.is_empty(),
770 preset_slot,
771 );
772 eprintln!();
773 eprintln!("{}:", style::yellow("Usage"));
774 eprintln!(" fdl {name}{usage_tail}");
775}
776
777fn print_arguments_section(
778 cmd_config: &CommandConfig,
779 presets: &CommandGroup,
780 preset_slot: &str,
781) {
782 let has_schema_args = cmd_config
788 .schema
789 .as_ref()
790 .is_some_and(|s| !s.args.is_empty());
791 if !has_schema_args && presets.is_empty() {
792 return;
793 }
794 eprintln!();
795 eprintln!("{}:", style::yellow("Arguments"));
796 if let Some(schema) = &cmd_config.schema {
797 for a in &schema.args {
798 eprintln!(" {}", format_arg(a));
799 }
800 }
801 if !presets.is_empty() {
802 let slot_label = format!("[<{preset_slot}>]");
803 eprintln!(
804 " {} Named preset, one of:",
805 style::green(&format!("{:<20}", slot_label))
806 );
807 for (pname, spec) in presets {
808 let desc = spec.description.as_deref().unwrap_or("-");
809 eprintln!(
810 " {} {}",
811 style::green(&format!("{:<18}", pname)),
812 desc
813 );
814 }
815 }
816}
817
818fn print_sub_commands_section(sub_cmds: &CommandGroup) {
819 if sub_cmds.is_empty() {
822 return;
823 }
824 eprintln!();
825 eprintln!("{}:", style::yellow("Commands"));
826 for (sub_name, sub_spec) in sub_cmds {
827 let desc = sub_spec.description.as_deref().unwrap_or("-");
828 eprintln!(
829 " {} {}",
830 style::green(&format!("{:<20}", sub_name)),
831 desc
832 );
833 }
834}
835
836fn print_options_section(cmd_config: &CommandConfig) {
837 let Some(schema) = &cmd_config.schema else {
840 return;
841 };
842 if schema.options.is_empty() {
843 return;
844 }
845 eprintln!();
846 eprintln!("{}:", style::yellow("Options"));
847 for (long, spec) in &schema.options {
848 for line in format_option(long, spec) {
849 eprintln!(" {line}");
850 }
851 }
852}
853
854fn print_entry_section(cmd_config: &CommandConfig) {
855 let Some(entry) = &cmd_config.entry else {
856 return;
857 };
858 eprintln!();
859 eprintln!("{}:", style::yellow("Entry"));
860 eprintln!(" {entry}");
861 if let Some(service) = &cmd_config.docker {
862 eprintln!(
863 " {}",
864 style::dim(&format!("[docker: {service}]"))
865 );
866 }
867 eprintln!();
868 eprintln!(
869 " Any extra {} are forwarded to the entry point.",
870 style::dim("[options]")
871 );
872}
873
874fn print_defaults_section(cmd_config: &CommandConfig) {
875 if cmd_config.ddp.is_none() && cmd_config.training.is_none() {
876 return;
877 }
878 eprintln!();
879 eprintln!("{}:", style::yellow("Defaults"));
880 if let Some(d) = &cmd_config.ddp {
881 if let Some(mode) = &d.mode {
882 eprintln!(" {} {mode}", style::dim("ddp.mode"));
883 }
884 if let Some(anchor) = &d.anchor {
885 eprintln!(" {} {}", style::dim("ddp.anchor"), value_to_string(anchor));
886 }
887 }
888 if let Some(t) = &cmd_config.training {
889 if let Some(e) = t.epochs {
890 eprintln!(" {} {e}", style::dim("training.epochs"));
891 }
892 if let Some(bs) = t.batch_size {
893 eprintln!(" {} {bs}", style::dim("training.batch_size"));
894 }
895 if let Some(lr) = t.lr {
896 eprintln!(" {} {lr}", style::dim("training.lr"));
897 }
898 if let Some(seed) = t.seed {
899 eprintln!(" {} {seed}", style::dim("training.seed"));
900 }
901 }
902}
903
904pub fn print_preset_help(cmd_config: &CommandConfig, cmd_name: &str, preset_name: &str) {
906 let preset = match cmd_config.commands.get(preset_name) {
907 Some(s) => s,
908 None => {
909 eprintln!("unknown command: {preset_name}");
910 return;
911 }
912 };
913
914 let desc = preset.description.as_deref().unwrap_or("(no description)");
916 eprintln!(
917 "{} {} {}",
918 style::bold(cmd_name),
919 style::green(preset_name),
920 desc
921 );
922
923 eprintln!();
924 eprintln!("{}:", style::yellow("Usage"));
925 eprintln!(
926 " fdl {cmd_name} {preset_name} {}",
927 style::dim("[extra options]")
928 );
929
930 let resolved = config::merge_preset(cmd_config, preset);
932
933 eprintln!();
934 eprintln!("{}:", style::yellow("Effective config"));
935
936 let d = &resolved.ddp;
938 print_config_field("ddp.mode", &d.mode);
939 print_config_value("ddp.anchor", &d.anchor);
940 print_config_field("ddp.max_anchor", &d.max_anchor);
941 print_config_field("ddp.overhead_target", &d.overhead_target);
942 print_config_field("ddp.divergence_threshold", &d.divergence_threshold);
943 print_config_value("ddp.max_batch_diff", &d.max_batch_diff);
944 print_config_field("ddp.max_grad_norm", &d.max_grad_norm);
945 if d.timeline == Some(true) {
946 eprintln!(" {} true", style::dim("ddp.timeline"));
947 }
948
949 let t = &resolved.training;
951 print_config_field("training.epochs", &t.epochs);
952 print_config_field("training.batch_size", &t.batch_size);
953 print_config_field("training.batches_per_epoch", &t.batches_per_epoch);
954 print_config_field("training.lr", &t.lr);
955 print_config_field("training.seed", &t.seed);
956
957 let o = &resolved.output;
959 print_config_field("output.dir", &o.dir);
960 print_config_field("output.monitor", &o.monitor);
961
962 if !resolved.options.is_empty() {
964 eprintln!();
965 eprintln!("{}:", style::yellow("Options"));
966 for (key, val) in &resolved.options {
967 eprintln!(
968 " {} {}",
969 style::green(&format!("--{}", key.replace('_', "-"))),
970 value_to_string(val)
971 );
972 }
973 }
974
975 if let Some(entry) = &cmd_config.entry {
977 let args = config_to_args(&resolved);
978 let args_str = args.join(" ");
979 let docker_info = cmd_config
980 .docker
981 .as_ref()
982 .map(|s| format!("[{s}] ", ))
983 .unwrap_or_default();
984
985 eprintln!();
986 eprintln!("{}:", style::yellow("Effective command"));
987 eprintln!(
988 " {}{}{}",
989 style::dim(&docker_info),
990 entry,
991 if args_str.is_empty() {
992 String::new()
993 } else {
994 format!(" {args_str}")
995 }
996 );
997 }
998
999 eprintln!();
1000 eprintln!(
1001 "Extra {} after the command name are appended to the entry.",
1002 style::dim("[options]")
1003 );
1004}
1005
1006fn print_config_field<T: std::fmt::Display>(label: &str, val: &Option<T>) {
1007 if let Some(v) = val {
1008 eprintln!(" {} {v}", style::dim(label));
1009 }
1010}
1011
1012fn print_config_value(label: &str, val: &Option<serde_json::Value>) {
1013 if let Some(v) = val {
1014 if !v.is_null() {
1015 eprintln!(" {} {}", style::dim(label), value_to_string(v));
1016 }
1017 }
1018}
1019
1020pub fn print_project_help(
1022 project: &config::ProjectConfig,
1023 project_root: &Path,
1024 active_env: Option<&str>,
1025) {
1026 let visible_builtins = builtins::visible_top_level();
1027 if let Some(desc) = &project.description {
1028 eprintln!("{} {}", style::bold("fdl"), desc);
1029 } else {
1030 eprintln!("{} {}", style::bold("fdl"), env!("CARGO_PKG_VERSION"));
1031 }
1032
1033 eprintln!();
1034 eprintln!("{}:", style::yellow("Usage"));
1035 eprintln!(
1036 " fdl {} {}",
1037 style::dim("<command>"),
1038 style::dim("[options]")
1039 );
1040
1041 eprintln!();
1042 eprintln!("{}:", style::yellow("Options"));
1043 eprintln!(
1044 " {} Show this help",
1045 style::green(&format!("{:<18}", "-h, --help"))
1046 );
1047 eprintln!(
1048 " {} Show version",
1049 style::green(&format!("{:<18}", "-V, --version"))
1050 );
1051 eprintln!(
1052 " {} Use fdl.<name>.yml overlay (also: FDL_ENV=<name>)",
1053 style::green(&format!("{:<18}", "--env <name>"))
1054 );
1055 eprintln!(
1056 " {} Verbose output",
1057 style::green(&format!("{:<18}", "-v"))
1058 );
1059 eprintln!(
1060 " {} Debug output",
1061 style::green(&format!("{:<18}", "-vv"))
1062 );
1063 eprintln!(
1064 " {} Trace output (maximum detail)",
1065 style::green(&format!("{:<18}", "-vvv"))
1066 );
1067 eprintln!(
1068 " {} Suppress non-error output",
1069 style::green(&format!("{:<18}", "-q, --quiet"))
1070 );
1071 eprintln!(
1072 " {} Force ANSI color (bypass TTY / NO_COLOR detection)",
1073 style::green(&format!("{:<18}", "--ansi"))
1074 );
1075 eprintln!(
1076 " {} Disable ANSI color output",
1077 style::green(&format!("{:<18}", "--no-ansi"))
1078 );
1079 eprintln!(
1080 " {} Drop a run command's `append:` suffix",
1081 style::green(&format!("{:<18}", "--no-append"))
1082 );
1083
1084 eprintln!();
1086 eprintln!("{}:", style::yellow("Built-in"));
1087 for (name, desc) in &visible_builtins {
1088 eprintln!(" {} {desc}", style::green(&format!("{:<18}", name)));
1089 }
1090
1091 if !project.commands.is_empty() {
1098 eprintln!();
1099 eprintln!("{}:", style::yellow("Commands"));
1100 for (name, spec) in &project.commands {
1101 let desc: String = match spec.description.clone() {
1102 Some(d) => d,
1103 None => {
1104 let is_path_kind = spec.run.is_none();
1108 if is_path_kind {
1109 let child_dir = spec.resolve_path(name, project_root);
1110 config::load_command_with_env(&child_dir, active_env)
1111 .ok()
1112 .and_then(|c| c.description)
1113 .unwrap_or_else(|| "(sub-command)".into())
1114 } else {
1115 spec.run
1116 .as_deref()
1117 .unwrap_or("(command)")
1118 .to_string()
1119 }
1120 }
1121 };
1122 eprintln!(" {} {desc}", style::green(&format!("{:<18}", name)));
1123 }
1124 }
1125
1126 if let Some(base_config) = config::find_config(project_root) {
1128 let envs = crate::overlay::list_envs(&base_config);
1129 if !envs.is_empty() {
1130 eprintln!();
1131 eprintln!("{}:", style::yellow("Environments"));
1132 for e in &envs {
1133 let active_marker = if Some(e.as_str()) == active_env {
1134 style::green(" (active)")
1135 } else {
1136 String::new()
1137 };
1138 eprintln!(
1139 " {} Overlay from fdl.{}.yml{active_marker}",
1140 style::green(&format!("{:<18}", e)),
1141 e
1142 );
1143 }
1144 eprintln!();
1145 eprintln!(
1146 "Use {} to run a command with an environment overlay.",
1147 style::dim("fdl <env> <command>")
1148 );
1149 }
1150 }
1151
1152 eprintln!();
1153 eprintln!(
1154 "Use {} for more information on a command.",
1155 style::dim("fdl <command> -h")
1156 );
1157}
1158
1159fn build_usage_tail(
1168 schema: Option<&Schema>,
1169 has_presets: bool,
1170 has_sub_commands: bool,
1171 preset_slot: &str,
1172) -> String {
1173 let mut parts = String::new();
1174 let slot = match (has_presets, has_sub_commands) {
1175 (true, false) => Some(format!("[<{preset_slot}>]")),
1176 (false, true) => Some("[<command>]".to_string()),
1177 (true, true) => Some(format!("[<{preset_slot}>|<command>]")),
1178 (false, false) => None,
1179 };
1180 if let Some(s) = slot {
1181 parts.push(' ');
1182 parts.push_str(&style::dim(&s));
1183 }
1184 if let Some(s) = schema {
1185 for a in &s.args {
1186 parts.push(' ');
1187 parts.push_str(&format_arg_usage(a));
1188 }
1189 }
1190 parts.push(' ');
1191 parts.push_str(&style::dim("[options]"));
1192 parts
1193}
1194
1195type CommandGroup = Vec<(String, crate::config::CommandSpec)>;
1196
1197fn split_commands_by_kind(
1202 commands: &BTreeMap<String, crate::config::CommandSpec>,
1203) -> (CommandGroup, CommandGroup) {
1204 use crate::config::CommandKind;
1205 let mut presets = Vec::new();
1206 let mut sub_cmds = Vec::new();
1207 for (k, v) in commands {
1208 match v.kind() {
1209 Ok(CommandKind::Preset) => presets.push((k.clone(), v.clone())),
1210 _ => sub_cmds.push((k.clone(), v.clone())),
1211 }
1212 }
1213 (presets, sub_cmds)
1214}
1215
1216fn format_arg_usage(a: &ArgSpec) -> String {
1217 let suffix = if a.variadic { "..." } else { "" };
1218 let core = format!("<{}>{suffix}", a.name);
1219 if a.required && a.default.is_none() {
1220 style::green(&core)
1221 } else {
1222 style::dim(&format!("[{core}]"))
1223 }
1224}
1225
1226fn format_arg(a: &ArgSpec) -> String {
1227 let mut left = format_arg_usage(a);
1228 let visible = visible_width(&left);
1230 if visible < 22 {
1231 for _ in 0..(22 - visible) {
1232 left.push(' ');
1233 }
1234 } else {
1235 left.push(' ');
1236 }
1237 let mut line = left;
1238 line.push_str(a.description.as_deref().unwrap_or("-"));
1239 append_default_and_choices(&mut line, &a.default, &a.choices, &a.ty);
1240 line
1241}
1242
1243fn format_option(long: &str, spec: &OptionSpec) -> Vec<String> {
1246 let flag = match &spec.short {
1247 Some(s) => format!("-{s}, --{long}"),
1248 None => format!(" --{long}"),
1249 };
1250 let placeholder = option_placeholder(&spec.ty);
1251 let left = if placeholder.is_empty() {
1252 style::green(&flag)
1253 } else {
1254 style::green(&format!("{flag} {placeholder}"))
1255 };
1256 let visible = visible_width_for(&flag, placeholder);
1257
1258 let pad = if visible < 30 { 30 - visible } else { 1 };
1260 let mut line = format!("{left}{}", " ".repeat(pad));
1261 line.push_str(spec.description.as_deref().unwrap_or("-"));
1262 append_default_and_choices(&mut line, &spec.default, &spec.choices, &spec.ty);
1263
1264 let mut out = vec![line];
1265 if let Some(env) = &spec.env {
1266 out.push(format!("{} {}", " ".repeat(32), style::dim(&format!("[env: {env}]"))));
1267 }
1268 out
1269}
1270
1271fn option_placeholder(ty: &str) -> &'static str {
1272 match ty {
1273 "bool" => "",
1274 "int" => "<N>",
1275 "float" => "<F>",
1276 "path" => "<PATH>",
1277 "list[path]" => "<PATH>...",
1278 t if t.starts_with("list[") => "<VALUE>...",
1279 _ => "<VALUE>",
1280 }
1281}
1282
1283fn append_default_and_choices(
1284 line: &mut String,
1285 default: &Option<serde_json::Value>,
1286 choices: &Option<Vec<serde_json::Value>>,
1287 ty: &str,
1288) {
1289 if let Some(d) = default {
1290 let is_empty_list = matches!(d, serde_json::Value::Array(a) if a.is_empty());
1292 let is_false = matches!(d, serde_json::Value::Bool(false));
1293 if !d.is_null() && !is_false && !is_empty_list {
1294 line.push_str(&format!(" {}", style::dim(&format!("[default: {}]", format_value(d)))));
1295 }
1296 }
1297 if let Some(choices) = choices {
1298 if !choices.is_empty() {
1299 let list = choices
1300 .iter()
1301 .map(format_value)
1302 .collect::<Vec<_>>()
1303 .join(", ");
1304 line.push_str(&format!(" {}", style::dim(&format!("[possible: {list}]"))));
1305 }
1306 }
1307 if ty.starts_with("list[") {
1309 line.push_str(&format!(" {}", style::dim("(repeat or comma-separate)")));
1310 }
1311}
1312
1313fn format_value(v: &serde_json::Value) -> String {
1314 match v {
1315 serde_json::Value::String(s) => s.clone(),
1316 other => other.to_string(),
1317 }
1318}
1319
1320fn visible_width(s: &str) -> usize {
1323 strip_ansi(s).chars().count()
1326}
1327
1328fn visible_width_for(flag: &str, placeholder: &str) -> usize {
1329 if placeholder.is_empty() {
1330 flag.chars().count()
1331 } else {
1332 flag.chars().count() + 1 + placeholder.chars().count()
1333 }
1334}
1335
1336fn strip_ansi(s: &str) -> String {
1337 let mut out = String::with_capacity(s.len());
1338 let mut chars = s.chars().peekable();
1339 while let Some(c) = chars.next() {
1340 if c == '\x1b' && chars.peek() == Some(&'[') {
1341 chars.next();
1342 for c in chars.by_ref() {
1343 if c.is_ascii_alphabetic() {
1344 break;
1345 }
1346 }
1347 } else {
1348 out.push(c);
1349 }
1350 }
1351 out
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357
1358 #[test]
1359 fn posix_quote_passes_safe_strings_through() {
1360 assert_eq!(posix_quote("hello"), "hello");
1361 assert_eq!(posix_quote("-p"), "-p");
1362 assert_eq!(posix_quote("flodl-hf"), "flodl-hf");
1363 assert_eq!(posix_quote("a/b.c"), "a/b.c");
1364 assert_eq!(posix_quote("KEY=val"), "KEY=val");
1365 }
1366
1367 #[test]
1368 fn posix_quote_wraps_unsafe_strings() {
1369 assert_eq!(posix_quote(""), "''");
1370 assert_eq!(posix_quote("foo bar"), "'foo bar'");
1371 assert_eq!(posix_quote("a$b"), "'a$b'");
1372 assert_eq!(posix_quote("a\"b"), "'a\"b'");
1373 }
1374
1375 #[test]
1376 fn posix_quote_escapes_embedded_single_quotes() {
1377 assert_eq!(posix_quote("it's"), "'it'\\''s'");
1378 assert_eq!(posix_quote("'"), "''\\'''");
1379 }
1380
1381 #[test]
1382 fn compose_run_command_no_extras_passes_run_through() {
1383 assert_eq!(compose_run_command("echo hello", &[], None), "echo hello");
1384 }
1385
1386 #[test]
1387 fn compose_run_command_inserts_user_args_between_run_and_append() {
1388 let user = vec!["-p".to_string(), "flodl-hf".to_string()];
1389 let out = compose_run_command("cargo test live", &user, Some("-- --nocapture --ignored"));
1390 assert_eq!(out, "cargo test live -p flodl-hf -- --nocapture --ignored");
1391 }
1392
1393 #[test]
1394 fn compose_run_command_quotes_user_args_with_spaces() {
1395 let user = vec!["--name".to_string(), "with space".to_string()];
1396 let out = compose_run_command("cmd", &user, None);
1397 assert_eq!(out, "cmd --name 'with space'");
1398 }
1399
1400 #[test]
1401 fn compose_run_command_omits_empty_append() {
1402 let out = compose_run_command("cmd", &["arg".to_string()], Some(""));
1403 assert_eq!(out, "cmd arg");
1404 let out2 = compose_run_command("cmd", &["arg".to_string()], Some(" "));
1405 assert_eq!(out2, "cmd arg");
1406 }
1407
1408 #[test]
1409 fn compose_run_command_user_double_dash_threads_runner_args() {
1410 let user = vec![
1411 "-p".to_string(),
1412 "foo".to_string(),
1413 "--".to_string(),
1414 "--ignored".to_string(),
1415 ];
1416 let out = compose_run_command("cargo test", &user, Some("-- --nocapture"));
1417 assert_eq!(out, "cargo test -p foo -- --nocapture --ignored");
1418 }
1419
1420 #[test]
1421 fn compose_run_command_user_double_dash_without_append() {
1422 let user = vec![
1423 "-p".to_string(),
1424 "foo".to_string(),
1425 "--".to_string(),
1426 "--ignored".to_string(),
1427 ];
1428 let out = compose_run_command("cargo test", &user, None);
1429 assert_eq!(out, "cargo test -p foo -- --ignored");
1430 }
1431
1432 #[test]
1433 fn compose_run_command_append_with_pre_and_post_halves() {
1434 let out = compose_run_command("cmd", &[], Some("--foo -- --bar"));
1435 assert_eq!(out, "cmd --foo -- --bar");
1436 }
1437
1438 #[test]
1439 fn compose_run_command_append_pre_only_no_separator() {
1440 let user = vec!["--ansi".to_string()];
1443 let out = compose_run_command("cmd", &user, Some("--no-ansi"));
1444 assert_eq!(out, "cmd --no-ansi --ansi");
1445 }
1446
1447 #[test]
1448 fn compose_run_command_user_only_double_dash_emits_separator() {
1449 let user = vec!["--".to_string(), "--list".to_string()];
1450 let out = compose_run_command("cargo test", &user, None);
1451 assert_eq!(out, "cargo test -- --list");
1452 }
1453
1454 #[test]
1455 fn compose_run_command_append_full_split_with_user_both_sides() {
1456 let user = vec![
1457 "-p".to_string(),
1458 "foo".to_string(),
1459 "--".to_string(),
1460 "--ignored".to_string(),
1461 ];
1462 let out = compose_run_command(
1463 "cargo test",
1464 &user,
1465 Some("--release -- --nocapture"),
1466 );
1467 assert_eq!(
1468 out,
1469 "cargo test --release -p foo -- --nocapture --ignored"
1470 );
1471 }
1472
1473 #[test]
1474 fn split_append_dashdash_handles_edges() {
1475 assert_eq!(
1476 split_append_dashdash("-- --nocapture"),
1477 (String::new(), "--nocapture".to_string())
1478 );
1479 assert_eq!(
1480 split_append_dashdash("--foo -- --bar"),
1481 ("--foo".to_string(), "--bar".to_string())
1482 );
1483 assert_eq!(
1484 split_append_dashdash("--foo --"),
1485 ("--foo".to_string(), String::new())
1486 );
1487 assert_eq!(
1488 split_append_dashdash("--"),
1489 (String::new(), String::new())
1490 );
1491 assert_eq!(
1492 split_append_dashdash("--foo"),
1493 ("--foo".to_string(), String::new())
1494 );
1495 assert_eq!(
1496 split_append_dashdash(""),
1497 (String::new(), String::new())
1498 );
1499 }
1500}