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 fn exec_script(command: &str, docker_service: Option<&str>, cwd: &Path) -> ExitCode {
329 match docker_service {
330 Some(service) if !inside_docker() => {
331 let docker_cmd =
332 format!("docker compose run --rm {service} bash -c \"{command}\"");
333 spawn_docker_shell(&docker_cmd, cwd)
334 }
335 _ => {
336 let (shell, flag) = if cfg!(target_os = "windows") {
337 ("cmd", "/C")
338 } else {
339 ("sh", "-c")
340 };
341
342 match std::process::Command::new(shell)
343 .args([flag, command])
344 .current_dir(cwd)
345 .stdout(Stdio::inherit())
346 .stderr(Stdio::inherit())
347 .stdin(Stdio::inherit())
348 .status()
349 {
350 Ok(s) if s.success() => ExitCode::SUCCESS,
351 Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
352 Err(e) => {
353 cli_error!("{e}");
354 ExitCode::FAILURE
355 }
356 }
357 }
358 }
359}
360
361pub fn exec_command(
368 cmd_config: &CommandConfig,
369 preset_name: Option<&str>,
370 extra_args: &[String],
371 cmd_dir: &Path,
372 project_root: &Path,
373) -> ExitCode {
374 let entry = match &cmd_config.entry {
375 Some(e) => e.as_str(),
376 None => {
377 eprintln!(
378 "error: no entry point defined in {}/fdl.yaml",
379 cmd_dir.display()
380 );
381 return ExitCode::FAILURE;
382 }
383 };
384
385 if let Some(schema) = &cmd_config.schema {
394 if let Err(e) = config::validate_tail(extra_args, schema) {
395 cli_error!("{e}");
396 return ExitCode::FAILURE;
397 }
398 }
399
400 let resolved = match preset_name {
402 Some(name) => match cmd_config.commands.get(name) {
403 Some(preset) => {
404 if let Some(schema) = &cmd_config.schema {
408 if let Err(e) = config::validate_preset_for_exec(name, preset, schema) {
409 cli_error!("{e}");
410 return ExitCode::FAILURE;
411 }
412 }
413 config::merge_preset(cmd_config, preset)
414 }
415 None => {
416 cli_error!("unknown command '{name}'");
417 eprintln!();
418 print_command_help(cmd_config, "");
419 return ExitCode::FAILURE;
420 }
421 },
422 None => config::defaults_only(cmd_config),
423 };
424
425 let mut args = config_to_args(&resolved);
427
428 args.extend(extra_args.iter().cloned());
430
431 let use_docker = cmd_config.docker.is_some() && !inside_docker();
433
434 if use_docker {
435 let service = cmd_config.docker.as_deref().unwrap();
436 let workdir = cmd_dir
437 .strip_prefix(project_root)
438 .unwrap_or(cmd_dir)
439 .to_string_lossy();
440
441 let container_root = container_project_root(project_root, service);
450 let args_str = shell_join(&args);
451 let inner = if workdir.is_empty() || workdir == "." {
452 format!("{entry} {args_str}")
453 } else {
454 format!("cd {container_root}/{workdir} && {entry} {args_str}")
455 };
456
457 if preset_name.is_some() {
458 eprintln!("fdl: [{service}] {inner}");
459 }
460
461 let docker_cmd = format!("docker compose run --rm {service} bash -c \"{inner}\"");
463 spawn_docker_shell(&docker_cmd, project_root)
464 } else {
465 let parts: Vec<&str> = entry.split_whitespace().collect();
467 if parts.is_empty() {
468 cli_error!("empty entry point");
469 return ExitCode::FAILURE;
470 }
471 let program = parts[0];
472 let entry_args = &parts[1..];
473
474 if preset_name.is_some() {
475 let preview: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
476 eprintln!("fdl: {entry} {}", preview.join(" "));
477 }
478
479 match std::process::Command::new(program)
480 .args(entry_args)
481 .args(&args)
482 .current_dir(cmd_dir)
483 .stdout(Stdio::inherit())
484 .stderr(Stdio::inherit())
485 .stdin(Stdio::inherit())
486 .status()
487 {
488 Ok(s) if s.success() => ExitCode::SUCCESS,
489 Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
490 Err(e) => {
491 cli_error!("failed to execute '{program}': {e}");
492 ExitCode::FAILURE
493 }
494 }
495 }
496}
497
498fn shell_join(args: &[String]) -> String {
500 args.iter()
501 .map(|a| {
502 if a.contains(' ') || a.contains('"') || a.is_empty() {
503 format!("'{}'", a.replace('\'', "'\\''"))
504 } else {
505 a.clone()
506 }
507 })
508 .collect::<Vec<_>>()
509 .join(" ")
510}
511
512pub fn print_run_help(name: &str, description: Option<&str>, run: &str, docker: Option<&str>) {
518 if let Some(desc) = description {
519 eprintln!("{} {desc}", style::bold(name));
520 } else {
521 eprintln!("{}", style::bold(name));
522 }
523 eprintln!();
524 eprintln!("{}:", style::yellow("Usage"));
525 eprintln!(" fdl {name}");
526 eprintln!();
527 eprintln!("{}:", style::yellow("Runs"));
528 if let Some(svc) = docker {
529 eprintln!(" {} {svc} -c {run:?}", style::dim("docker compose run --rm"));
530 } else {
531 eprintln!(" {run}");
532 }
533 eprintln!();
534 eprintln!(
535 "{} run:-kind commands do not forward argv; the script runs as declared.",
536 style::dim("Note:"),
537 );
538}
539
540pub fn print_command_help(cmd_config: &CommandConfig, name: &str) {
543 let (presets, sub_cmds) = split_commands_by_kind(&cmd_config.commands);
544 let preset_slot = cmd_config.arg_name.as_deref().unwrap_or("preset");
545
546 print_title(cmd_config, name);
547 print_usage_line(cmd_config, name, &presets, &sub_cmds, preset_slot);
548 print_arguments_section(cmd_config, &presets, preset_slot);
549 print_sub_commands_section(&sub_cmds);
550 print_options_section(cmd_config);
551 print_entry_section(cmd_config);
552 print_defaults_section(cmd_config);
553}
554
555fn print_title(cmd_config: &CommandConfig, name: &str) {
556 if let Some(desc) = &cmd_config.description {
557 eprintln!("{} {desc}", style::bold(name));
558 } else {
559 eprintln!("{}", style::bold(name));
560 }
561}
562
563fn print_usage_line(
564 cmd_config: &CommandConfig,
565 name: &str,
566 presets: &CommandGroup,
567 sub_cmds: &CommandGroup,
568 preset_slot: &str,
569) {
570 let usage_tail = build_usage_tail(
573 cmd_config.schema.as_ref(),
574 !presets.is_empty(),
575 !sub_cmds.is_empty(),
576 preset_slot,
577 );
578 eprintln!();
579 eprintln!("{}:", style::yellow("Usage"));
580 eprintln!(" fdl {name}{usage_tail}");
581}
582
583fn print_arguments_section(
584 cmd_config: &CommandConfig,
585 presets: &CommandGroup,
586 preset_slot: &str,
587) {
588 let has_schema_args = cmd_config
594 .schema
595 .as_ref()
596 .is_some_and(|s| !s.args.is_empty());
597 if !has_schema_args && presets.is_empty() {
598 return;
599 }
600 eprintln!();
601 eprintln!("{}:", style::yellow("Arguments"));
602 if let Some(schema) = &cmd_config.schema {
603 for a in &schema.args {
604 eprintln!(" {}", format_arg(a));
605 }
606 }
607 if !presets.is_empty() {
608 let slot_label = format!("[<{preset_slot}>]");
609 eprintln!(
610 " {} Named preset, one of:",
611 style::green(&format!("{:<20}", slot_label))
612 );
613 for (pname, spec) in presets {
614 let desc = spec.description.as_deref().unwrap_or("-");
615 eprintln!(
616 " {} {}",
617 style::green(&format!("{:<18}", pname)),
618 desc
619 );
620 }
621 }
622}
623
624fn print_sub_commands_section(sub_cmds: &CommandGroup) {
625 if sub_cmds.is_empty() {
628 return;
629 }
630 eprintln!();
631 eprintln!("{}:", style::yellow("Commands"));
632 for (sub_name, sub_spec) in sub_cmds {
633 let desc = sub_spec.description.as_deref().unwrap_or("-");
634 eprintln!(
635 " {} {}",
636 style::green(&format!("{:<20}", sub_name)),
637 desc
638 );
639 }
640}
641
642fn print_options_section(cmd_config: &CommandConfig) {
643 let Some(schema) = &cmd_config.schema else {
646 return;
647 };
648 if schema.options.is_empty() {
649 return;
650 }
651 eprintln!();
652 eprintln!("{}:", style::yellow("Options"));
653 for (long, spec) in &schema.options {
654 for line in format_option(long, spec) {
655 eprintln!(" {line}");
656 }
657 }
658}
659
660fn print_entry_section(cmd_config: &CommandConfig) {
661 let Some(entry) = &cmd_config.entry else {
662 return;
663 };
664 eprintln!();
665 eprintln!("{}:", style::yellow("Entry"));
666 eprintln!(" {entry}");
667 if let Some(service) = &cmd_config.docker {
668 eprintln!(
669 " {}",
670 style::dim(&format!("[docker: {service}]"))
671 );
672 }
673 eprintln!();
674 eprintln!(
675 " Any extra {} are forwarded to the entry point.",
676 style::dim("[options]")
677 );
678}
679
680fn print_defaults_section(cmd_config: &CommandConfig) {
681 if cmd_config.ddp.is_none() && cmd_config.training.is_none() {
682 return;
683 }
684 eprintln!();
685 eprintln!("{}:", style::yellow("Defaults"));
686 if let Some(d) = &cmd_config.ddp {
687 if let Some(mode) = &d.mode {
688 eprintln!(" {} {mode}", style::dim("ddp.mode"));
689 }
690 if let Some(anchor) = &d.anchor {
691 eprintln!(" {} {}", style::dim("ddp.anchor"), value_to_string(anchor));
692 }
693 }
694 if let Some(t) = &cmd_config.training {
695 if let Some(e) = t.epochs {
696 eprintln!(" {} {e}", style::dim("training.epochs"));
697 }
698 if let Some(bs) = t.batch_size {
699 eprintln!(" {} {bs}", style::dim("training.batch_size"));
700 }
701 if let Some(lr) = t.lr {
702 eprintln!(" {} {lr}", style::dim("training.lr"));
703 }
704 if let Some(seed) = t.seed {
705 eprintln!(" {} {seed}", style::dim("training.seed"));
706 }
707 }
708}
709
710pub fn print_preset_help(cmd_config: &CommandConfig, cmd_name: &str, preset_name: &str) {
712 let preset = match cmd_config.commands.get(preset_name) {
713 Some(s) => s,
714 None => {
715 eprintln!("unknown command: {preset_name}");
716 return;
717 }
718 };
719
720 let desc = preset.description.as_deref().unwrap_or("(no description)");
722 eprintln!(
723 "{} {} {}",
724 style::bold(cmd_name),
725 style::green(preset_name),
726 desc
727 );
728
729 eprintln!();
730 eprintln!("{}:", style::yellow("Usage"));
731 eprintln!(
732 " fdl {cmd_name} {preset_name} {}",
733 style::dim("[extra options]")
734 );
735
736 let resolved = config::merge_preset(cmd_config, preset);
738
739 eprintln!();
740 eprintln!("{}:", style::yellow("Effective config"));
741
742 let d = &resolved.ddp;
744 print_config_field("ddp.mode", &d.mode);
745 print_config_value("ddp.anchor", &d.anchor);
746 print_config_field("ddp.max_anchor", &d.max_anchor);
747 print_config_field("ddp.overhead_target", &d.overhead_target);
748 print_config_field("ddp.divergence_threshold", &d.divergence_threshold);
749 print_config_value("ddp.max_batch_diff", &d.max_batch_diff);
750 print_config_field("ddp.max_grad_norm", &d.max_grad_norm);
751 if d.timeline == Some(true) {
752 eprintln!(" {} true", style::dim("ddp.timeline"));
753 }
754
755 let t = &resolved.training;
757 print_config_field("training.epochs", &t.epochs);
758 print_config_field("training.batch_size", &t.batch_size);
759 print_config_field("training.batches_per_epoch", &t.batches_per_epoch);
760 print_config_field("training.lr", &t.lr);
761 print_config_field("training.seed", &t.seed);
762
763 let o = &resolved.output;
765 print_config_field("output.dir", &o.dir);
766 print_config_field("output.monitor", &o.monitor);
767
768 if !resolved.options.is_empty() {
770 eprintln!();
771 eprintln!("{}:", style::yellow("Options"));
772 for (key, val) in &resolved.options {
773 eprintln!(
774 " {} {}",
775 style::green(&format!("--{}", key.replace('_', "-"))),
776 value_to_string(val)
777 );
778 }
779 }
780
781 if let Some(entry) = &cmd_config.entry {
783 let args = config_to_args(&resolved);
784 let args_str = args.join(" ");
785 let docker_info = cmd_config
786 .docker
787 .as_ref()
788 .map(|s| format!("[{s}] ", ))
789 .unwrap_or_default();
790
791 eprintln!();
792 eprintln!("{}:", style::yellow("Effective command"));
793 eprintln!(
794 " {}{}{}",
795 style::dim(&docker_info),
796 entry,
797 if args_str.is_empty() {
798 String::new()
799 } else {
800 format!(" {args_str}")
801 }
802 );
803 }
804
805 eprintln!();
806 eprintln!(
807 "Extra {} after the command name are appended to the entry.",
808 style::dim("[options]")
809 );
810}
811
812fn print_config_field<T: std::fmt::Display>(label: &str, val: &Option<T>) {
813 if let Some(v) = val {
814 eprintln!(" {} {v}", style::dim(label));
815 }
816}
817
818fn print_config_value(label: &str, val: &Option<serde_json::Value>) {
819 if let Some(v) = val {
820 if !v.is_null() {
821 eprintln!(" {} {}", style::dim(label), value_to_string(v));
822 }
823 }
824}
825
826pub fn print_project_help(
828 project: &config::ProjectConfig,
829 project_root: &Path,
830 active_env: Option<&str>,
831) {
832 let visible_builtins = builtins::visible_top_level();
833 if let Some(desc) = &project.description {
834 eprintln!("{} {}", style::bold("fdl"), desc);
835 } else {
836 eprintln!("{} {}", style::bold("fdl"), env!("CARGO_PKG_VERSION"));
837 }
838
839 eprintln!();
840 eprintln!("{}:", style::yellow("Usage"));
841 eprintln!(
842 " fdl {} {}",
843 style::dim("<command>"),
844 style::dim("[options]")
845 );
846
847 eprintln!();
848 eprintln!("{}:", style::yellow("Options"));
849 eprintln!(
850 " {} Show this help",
851 style::green(&format!("{:<18}", "-h, --help"))
852 );
853 eprintln!(
854 " {} Show version",
855 style::green(&format!("{:<18}", "-V, --version"))
856 );
857 eprintln!(
858 " {} Use fdl.<name>.yml overlay (also: FDL_ENV=<name>)",
859 style::green(&format!("{:<18}", "--env <name>"))
860 );
861 eprintln!(
862 " {} Verbose output",
863 style::green(&format!("{:<18}", "-v"))
864 );
865 eprintln!(
866 " {} Debug output",
867 style::green(&format!("{:<18}", "-vv"))
868 );
869 eprintln!(
870 " {} Trace output (maximum detail)",
871 style::green(&format!("{:<18}", "-vvv"))
872 );
873 eprintln!(
874 " {} Suppress non-error output",
875 style::green(&format!("{:<18}", "-q, --quiet"))
876 );
877 eprintln!(
878 " {} Force ANSI color (bypass TTY / NO_COLOR detection)",
879 style::green(&format!("{:<18}", "--ansi"))
880 );
881 eprintln!(
882 " {} Disable ANSI color output",
883 style::green(&format!("{:<18}", "--no-ansi"))
884 );
885
886 eprintln!();
888 eprintln!("{}:", style::yellow("Built-in"));
889 for (name, desc) in &visible_builtins {
890 eprintln!(" {} {desc}", style::green(&format!("{:<18}", name)));
891 }
892
893 if !project.commands.is_empty() {
900 eprintln!();
901 eprintln!("{}:", style::yellow("Commands"));
902 for (name, spec) in &project.commands {
903 let desc: String = match spec.description.clone() {
904 Some(d) => d,
905 None => {
906 let is_path_kind = spec.run.is_none();
910 if is_path_kind {
911 let child_dir = spec.resolve_path(name, project_root);
912 config::load_command_with_env(&child_dir, active_env)
913 .ok()
914 .and_then(|c| c.description)
915 .unwrap_or_else(|| "(sub-command)".into())
916 } else {
917 spec.run
918 .as_deref()
919 .unwrap_or("(command)")
920 .to_string()
921 }
922 }
923 };
924 eprintln!(" {} {desc}", style::green(&format!("{:<18}", name)));
925 }
926 }
927
928 if let Some(base_config) = config::find_config(project_root) {
930 let envs = crate::overlay::list_envs(&base_config);
931 if !envs.is_empty() {
932 eprintln!();
933 eprintln!("{}:", style::yellow("Environments"));
934 for e in &envs {
935 let active_marker = if Some(e.as_str()) == active_env {
936 style::green(" (active)")
937 } else {
938 String::new()
939 };
940 eprintln!(
941 " {} Overlay from fdl.{}.yml{active_marker}",
942 style::green(&format!("{:<18}", e)),
943 e
944 );
945 }
946 eprintln!();
947 eprintln!(
948 "Use {} to run a command with an environment overlay.",
949 style::dim("fdl <env> <command>")
950 );
951 }
952 }
953
954 eprintln!();
955 eprintln!(
956 "Use {} for more information on a command.",
957 style::dim("fdl <command> -h")
958 );
959}
960
961fn build_usage_tail(
970 schema: Option<&Schema>,
971 has_presets: bool,
972 has_sub_commands: bool,
973 preset_slot: &str,
974) -> String {
975 let mut parts = String::new();
976 let slot = match (has_presets, has_sub_commands) {
977 (true, false) => Some(format!("[<{preset_slot}>]")),
978 (false, true) => Some("[<command>]".to_string()),
979 (true, true) => Some(format!("[<{preset_slot}>|<command>]")),
980 (false, false) => None,
981 };
982 if let Some(s) = slot {
983 parts.push(' ');
984 parts.push_str(&style::dim(&s));
985 }
986 if let Some(s) = schema {
987 for a in &s.args {
988 parts.push(' ');
989 parts.push_str(&format_arg_usage(a));
990 }
991 }
992 parts.push(' ');
993 parts.push_str(&style::dim("[options]"));
994 parts
995}
996
997type CommandGroup = Vec<(String, crate::config::CommandSpec)>;
998
999fn split_commands_by_kind(
1004 commands: &BTreeMap<String, crate::config::CommandSpec>,
1005) -> (CommandGroup, CommandGroup) {
1006 use crate::config::CommandKind;
1007 let mut presets = Vec::new();
1008 let mut sub_cmds = Vec::new();
1009 for (k, v) in commands {
1010 match v.kind() {
1011 Ok(CommandKind::Preset) => presets.push((k.clone(), v.clone())),
1012 _ => sub_cmds.push((k.clone(), v.clone())),
1013 }
1014 }
1015 (presets, sub_cmds)
1016}
1017
1018fn format_arg_usage(a: &ArgSpec) -> String {
1019 let suffix = if a.variadic { "..." } else { "" };
1020 let core = format!("<{}>{suffix}", a.name);
1021 if a.required && a.default.is_none() {
1022 style::green(&core)
1023 } else {
1024 style::dim(&format!("[{core}]"))
1025 }
1026}
1027
1028fn format_arg(a: &ArgSpec) -> String {
1029 let mut left = format_arg_usage(a);
1030 let visible = visible_width(&left);
1032 if visible < 22 {
1033 for _ in 0..(22 - visible) {
1034 left.push(' ');
1035 }
1036 } else {
1037 left.push(' ');
1038 }
1039 let mut line = left;
1040 line.push_str(a.description.as_deref().unwrap_or("-"));
1041 append_default_and_choices(&mut line, &a.default, &a.choices, &a.ty);
1042 line
1043}
1044
1045fn format_option(long: &str, spec: &OptionSpec) -> Vec<String> {
1048 let flag = match &spec.short {
1049 Some(s) => format!("-{s}, --{long}"),
1050 None => format!(" --{long}"),
1051 };
1052 let placeholder = option_placeholder(&spec.ty);
1053 let left = if placeholder.is_empty() {
1054 style::green(&flag)
1055 } else {
1056 style::green(&format!("{flag} {placeholder}"))
1057 };
1058 let visible = visible_width_for(&flag, placeholder);
1059
1060 let pad = if visible < 30 { 30 - visible } else { 1 };
1062 let mut line = format!("{left}{}", " ".repeat(pad));
1063 line.push_str(spec.description.as_deref().unwrap_or("-"));
1064 append_default_and_choices(&mut line, &spec.default, &spec.choices, &spec.ty);
1065
1066 let mut out = vec![line];
1067 if let Some(env) = &spec.env {
1068 out.push(format!("{} {}", " ".repeat(32), style::dim(&format!("[env: {env}]"))));
1069 }
1070 out
1071}
1072
1073fn option_placeholder(ty: &str) -> &'static str {
1074 match ty {
1075 "bool" => "",
1076 "int" => "<N>",
1077 "float" => "<F>",
1078 "path" => "<PATH>",
1079 "list[path]" => "<PATH>...",
1080 t if t.starts_with("list[") => "<VALUE>...",
1081 _ => "<VALUE>",
1082 }
1083}
1084
1085fn append_default_and_choices(
1086 line: &mut String,
1087 default: &Option<serde_json::Value>,
1088 choices: &Option<Vec<serde_json::Value>>,
1089 ty: &str,
1090) {
1091 if let Some(d) = default {
1092 let is_empty_list = matches!(d, serde_json::Value::Array(a) if a.is_empty());
1094 let is_false = matches!(d, serde_json::Value::Bool(false));
1095 if !d.is_null() && !is_false && !is_empty_list {
1096 line.push_str(&format!(" {}", style::dim(&format!("[default: {}]", format_value(d)))));
1097 }
1098 }
1099 if let Some(choices) = choices {
1100 if !choices.is_empty() {
1101 let list = choices
1102 .iter()
1103 .map(format_value)
1104 .collect::<Vec<_>>()
1105 .join(", ");
1106 line.push_str(&format!(" {}", style::dim(&format!("[possible: {list}]"))));
1107 }
1108 }
1109 if ty.starts_with("list[") {
1111 line.push_str(&format!(" {}", style::dim("(repeat or comma-separate)")));
1112 }
1113}
1114
1115fn format_value(v: &serde_json::Value) -> String {
1116 match v {
1117 serde_json::Value::String(s) => s.clone(),
1118 other => other.to_string(),
1119 }
1120}
1121
1122fn visible_width(s: &str) -> usize {
1125 strip_ansi(s).chars().count()
1128}
1129
1130fn visible_width_for(flag: &str, placeholder: &str) -> usize {
1131 if placeholder.is_empty() {
1132 flag.chars().count()
1133 } else {
1134 flag.chars().count() + 1 + placeholder.chars().count()
1135 }
1136}
1137
1138fn strip_ansi(s: &str) -> String {
1139 let mut out = String::with_capacity(s.len());
1140 let mut chars = s.chars().peekable();
1141 while let Some(c) = chars.next() {
1142 if c == '\x1b' && chars.peek() == Some(&'[') {
1143 chars.next();
1144 for c in chars.by_ref() {
1145 if c.is_ascii_alphabetic() {
1146 break;
1147 }
1148 }
1149 } else {
1150 out.push(c);
1151 }
1152 }
1153 out
1154}