Skip to main content

flodl_cli/
run.rs

1//! Command resolution and execution.
2//!
3//! Merges structured config sections into CLI arguments, resolves named
4//! command presets, and spawns the target process (directly or through
5//! Docker when a `docker:` service is declared).
6
7use 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
17// ── Config to CLI args ──────────────────────────────────────────────────
18
19/// Translate a resolved config into CLI arguments for the entry point.
20pub fn config_to_args(resolved: &ResolvedConfig) -> Vec<String> {
21    let mut args = Vec::new();
22
23    // DDP section
24    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    // Training section
55    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    // Output section
63    let o = &resolved.output;
64    push_opt(&mut args, "--output", &o.dir);
65    push_num(&mut args, "--monitor", &o.monitor);
66
67    // Pass-through options
68    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
126// ── Docker detection ────────────────────────────────────────────────────
127
128/// Check if we're already running inside a Docker container.
129fn inside_docker() -> bool {
130    Path::new("/.dockerenv").exists()
131}
132
133/// Resolve libtorch env vars from the project root, matching the Makefile logic:
134///   LIBTORCH_HOST_PATH = ./libtorch/<active_variant>
135///   LIBTORCH_CPU_PATH  = ./libtorch/precompiled/cpu
136///   CUDA_VERSION, CUDA_TAG from .arch metadata
137fn libtorch_env(project_root: &Path) -> Vec<(String, String)> {
138    let mut env = Vec::new();
139
140    // CPU path is always the same.
141    env.push((
142        "LIBTORCH_CPU_PATH".into(),
143        "./libtorch/precompiled/cpu".into(),
144    ));
145
146    // Active variant for CUDA.
147    if let Some(info) = libtorch::detect::read_active(project_root) {
148        let host_path = format!("./libtorch/{}", info.path);
149        env.push(("LIBTORCH_HOST_PATH".into(), host_path));
150
151        // CUDA version from .arch metadata.
152        if let Some(cuda) = &info.cuda_version {
153            if cuda != "none" {
154                let cuda_version = if cuda.matches('.').count() < 2 {
155                    format!("{cuda}.0")
156                } else {
157                    cuda.clone()
158                };
159                let cuda_tag = cuda_version
160                    .splitn(3, '.')
161                    .take(2)
162                    .collect::<Vec<_>>()
163                    .join(".");
164                env.push(("CUDA_VERSION".into(), cuda_version));
165                env.push(("CUDA_TAG".into(), cuda_tag));
166            }
167        }
168    }
169
170    env
171}
172
173/// Spawn a shell command with libtorch env vars set.
174///
175/// `FLODL_VERBOSITY` is forwarded to Docker containers via the
176/// `environment:` section in docker-compose.yml (bare variable name
177/// passes the host value through when set, ignored otherwise).
178fn spawn_docker_shell(command: &str, project_root: &Path) -> ExitCode {
179    let env_vars = libtorch_env(project_root);
180
181    let mut cmd = std::process::Command::new("sh");
182    cmd.args(["-c", command])
183        .current_dir(project_root)
184        .stdout(Stdio::inherit())
185        .stderr(Stdio::inherit())
186        .stdin(Stdio::inherit());
187
188    for (key, val) in &env_vars {
189        cmd.env(key, val);
190    }
191
192    match cmd.status() {
193        Ok(s) if s.success() => ExitCode::SUCCESS,
194        Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
195        Err(e) => {
196            cli_error!("{e}");
197            ExitCode::FAILURE
198        }
199    }
200}
201
202// ── Run-kind execution ──────────────────────────────────────────────────
203
204/// Run an inline `run:` script, optionally wrapped in Docker.
205pub fn exec_script(command: &str, docker_service: Option<&str>, cwd: &Path) -> ExitCode {
206    match docker_service {
207        Some(service) if !inside_docker() => {
208            let docker_cmd =
209                format!("docker compose run --rm {service} bash -c \"{command}\"");
210            spawn_docker_shell(&docker_cmd, cwd)
211        }
212        _ => {
213            let (shell, flag) = if cfg!(target_os = "windows") {
214                ("cmd", "/C")
215            } else {
216                ("sh", "-c")
217            };
218
219            match std::process::Command::new(shell)
220                .args([flag, command])
221                .current_dir(cwd)
222                .stdout(Stdio::inherit())
223                .stderr(Stdio::inherit())
224                .stdin(Stdio::inherit())
225                .status()
226            {
227                Ok(s) if s.success() => ExitCode::SUCCESS,
228                Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
229                Err(e) => {
230                    cli_error!("{e}");
231                    ExitCode::FAILURE
232                }
233            }
234        }
235    }
236}
237
238// ── Command execution ───────────────────────────────────────────────────
239
240/// Execute a sub-command, optionally with a named preset (inline command).
241///
242/// `project_root` is needed to resolve Docker compose context and
243/// compute the relative workdir for containerized execution.
244pub fn exec_command(
245    cmd_config: &CommandConfig,
246    preset_name: Option<&str>,
247    extra_args: &[String],
248    cmd_dir: &Path,
249    project_root: &Path,
250) -> ExitCode {
251    let entry = match &cmd_config.entry {
252        Some(e) => e.as_str(),
253        None => {
254            eprintln!(
255                "error: no entry point defined in {}/fdl.yaml",
256                cmd_dir.display()
257            );
258            return ExitCode::FAILURE;
259        }
260    };
261
262    // Tail validation pre-flight. Runs whenever a schema is present:
263    // - `choices:` on declared options → always enforced.
264    // - Unknown flags → rejected only when `schema.strict` is set
265    //   (lenient mode tolerates pass-through flags the binary may
266    //   consume directly).
267    // fdl-generated args (from the structured ddp/training/output
268    // blocks) are intentionally skipped — those are the binary's
269    // surface, not the user's.
270    if let Some(schema) = &cmd_config.schema {
271        if let Err(e) = config::validate_tail(extra_args, schema) {
272            cli_error!("{e}");
273            return ExitCode::FAILURE;
274        }
275    }
276
277    // Resolve config: preset overrides merged with root defaults.
278    let resolved = match preset_name {
279        Some(name) => match cmd_config.commands.get(name) {
280            Some(preset) => {
281                // Validate *this* preset only (choices + strict unknowns).
282                // Whole-map validation is deferred so a broken sibling
283                // preset doesn't block a correct one from running.
284                if let Some(schema) = &cmd_config.schema {
285                    if let Err(e) = config::validate_preset_for_exec(name, preset, schema) {
286                        cli_error!("{e}");
287                        return ExitCode::FAILURE;
288                    }
289                }
290                config::merge_preset(cmd_config, preset)
291            }
292            None => {
293                cli_error!("unknown command '{name}'");
294                eprintln!();
295                print_command_help(cmd_config, "");
296                return ExitCode::FAILURE;
297            }
298        },
299        None => config::defaults_only(cmd_config),
300    };
301
302    // Build argument list from config.
303    let mut args = config_to_args(&resolved);
304
305    // Append extra CLI args (these override config-derived args).
306    args.extend(extra_args.iter().cloned());
307
308    // Docker wrapping or direct execution.
309    let use_docker = cmd_config.docker.is_some() && !inside_docker();
310
311    if use_docker {
312        let service = cmd_config.docker.as_deref().unwrap();
313        let workdir = cmd_dir
314            .strip_prefix(project_root)
315            .unwrap_or(cmd_dir)
316            .to_string_lossy();
317
318        // Build the inner command: cd <workdir> && <entry> <args>
319        let args_str = shell_join(&args);
320        let inner = if workdir.is_empty() || workdir == "." {
321            format!("{entry} {args_str}")
322        } else {
323            format!("cd {workdir} && {entry} {args_str}")
324        };
325
326        if preset_name.is_some() {
327            eprintln!("fdl: [{service}] {inner}");
328        }
329
330        // Run via docker compose from the project root (with libtorch env).
331        let docker_cmd = format!("docker compose run --rm {service} bash -c \"{inner}\"");
332        spawn_docker_shell(&docker_cmd, project_root)
333    } else {
334        // Direct execution (inside container or no docker configured).
335        let parts: Vec<&str> = entry.split_whitespace().collect();
336        if parts.is_empty() {
337            cli_error!("empty entry point");
338            return ExitCode::FAILURE;
339        }
340        let program = parts[0];
341        let entry_args = &parts[1..];
342
343        if preset_name.is_some() {
344            let preview: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
345            eprintln!("fdl: {entry} {}", preview.join(" "));
346        }
347
348        match std::process::Command::new(program)
349            .args(entry_args)
350            .args(&args)
351            .current_dir(cmd_dir)
352            .stdout(Stdio::inherit())
353            .stderr(Stdio::inherit())
354            .stdin(Stdio::inherit())
355            .status()
356        {
357            Ok(s) if s.success() => ExitCode::SUCCESS,
358            Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
359            Err(e) => {
360                cli_error!("failed to execute '{program}': {e}");
361                ExitCode::FAILURE
362            }
363        }
364    }
365}
366
367/// Join args into a shell-safe string.
368fn shell_join(args: &[String]) -> String {
369    args.iter()
370        .map(|a| {
371            if a.contains(' ') || a.contains('"') || a.is_empty() {
372                format!("'{}'", a.replace('\'', "'\\''"))
373            } else {
374                a.clone()
375            }
376        })
377        .collect::<Vec<_>>()
378        .join(" ")
379}
380
381// ── Help output ─────────────────────────────────────────────────────────
382
383/// Print help for a `run:`-kind command. Shows the inline script that
384/// will execute and the Docker service (if any). `run:` commands do not
385/// forward argv, so there are no flags or positionals to document.
386pub fn print_run_help(name: &str, description: Option<&str>, run: &str, docker: Option<&str>) {
387    if let Some(desc) = description {
388        eprintln!("{} {desc}", style::bold(name));
389    } else {
390        eprintln!("{}", style::bold(name));
391    }
392    eprintln!();
393    eprintln!("{}:", style::yellow("Usage"));
394    eprintln!("    fdl {name}");
395    eprintln!();
396    eprintln!("{}:", style::yellow("Runs"));
397    if let Some(svc) = docker {
398        eprintln!("    {} {svc} -c {run:?}", style::dim("docker compose run --rm"));
399    } else {
400        eprintln!("    {run}");
401    }
402    eprintln!();
403    eprintln!(
404        "{} run:-kind commands do not forward argv; the script runs as declared.",
405        style::dim("Note:"),
406    );
407}
408
409/// Print help for a sub-command (its arguments, nested commands, and
410/// entry). Orchestrates the per-section helpers below.
411pub fn print_command_help(cmd_config: &CommandConfig, name: &str) {
412    let (presets, sub_cmds) = split_commands_by_kind(&cmd_config.commands);
413    let preset_slot = cmd_config.arg_name.as_deref().unwrap_or("preset");
414
415    print_title(cmd_config, name);
416    print_usage_line(cmd_config, name, &presets, &sub_cmds, preset_slot);
417    print_arguments_section(cmd_config, &presets, preset_slot);
418    print_sub_commands_section(&sub_cmds);
419    print_options_section(cmd_config);
420    print_entry_section(cmd_config);
421    print_defaults_section(cmd_config);
422}
423
424fn print_title(cmd_config: &CommandConfig, name: &str) {
425    if let Some(desc) = &cmd_config.description {
426        eprintln!("{} {desc}", style::bold(name));
427    } else {
428        eprintln!("{}", style::bold(name));
429    }
430}
431
432fn print_usage_line(
433    cmd_config: &CommandConfig,
434    name: &str,
435    presets: &CommandGroup,
436    sub_cmds: &CommandGroup,
437    preset_slot: &str,
438) {
439    // The first-positional slot reflects what is actually accepted here:
440    // preset name, sub-command name, or either.
441    let usage_tail = build_usage_tail(
442        cmd_config.schema.as_ref(),
443        !presets.is_empty(),
444        !sub_cmds.is_empty(),
445        preset_slot,
446    );
447    eprintln!();
448    eprintln!("{}:", style::yellow("Usage"));
449    eprintln!("    fdl {name}{usage_tail}");
450}
451
452fn print_arguments_section(
453    cmd_config: &CommandConfig,
454    presets: &CommandGroup,
455    preset_slot: &str,
456) {
457    // Schema-declared positionals (typed slots on the entry binary) and
458    // the preset slot (dispatched by fdl before the binary sees argv)
459    // both land in the first-positional position, so they share one
460    // section. Schema args render first; the preset slot with its
461    // value list follows.
462    let has_schema_args = cmd_config
463        .schema
464        .as_ref()
465        .is_some_and(|s| !s.args.is_empty());
466    if !has_schema_args && presets.is_empty() {
467        return;
468    }
469    eprintln!();
470    eprintln!("{}:", style::yellow("Arguments"));
471    if let Some(schema) = &cmd_config.schema {
472        for a in &schema.args {
473            eprintln!("    {}", format_arg(a));
474        }
475    }
476    if !presets.is_empty() {
477        let slot_label = format!("[<{preset_slot}>]");
478        eprintln!(
479            "    {}  Named preset, one of:",
480            style::green(&format!("{:<20}", slot_label))
481        );
482        for (pname, spec) in presets {
483            let desc = spec.description.as_deref().unwrap_or("-");
484            eprintln!(
485                "      {}  {}",
486                style::green(&format!("{:<18}", pname)),
487                desc
488            );
489        }
490    }
491}
492
493fn print_sub_commands_section(sub_cmds: &CommandGroup) {
494    // Run/Path kinds only — true sub-commands with their own behavior
495    // (an inline script or a nested fdl.yml).
496    if sub_cmds.is_empty() {
497        return;
498    }
499    eprintln!();
500    eprintln!("{}:", style::yellow("Commands"));
501    for (sub_name, sub_spec) in sub_cmds {
502        let desc = sub_spec.description.as_deref().unwrap_or("-");
503        eprintln!(
504            "    {}  {}",
505            style::green(&format!("{:<20}", sub_name)),
506            desc
507        );
508    }
509}
510
511fn print_options_section(cmd_config: &CommandConfig) {
512    // Schema-driven options. Renders only when a schema block is present
513    // in fdl.yaml; the "Defaults" section covers ddp/training/output.
514    let Some(schema) = &cmd_config.schema else {
515        return;
516    };
517    if schema.options.is_empty() {
518        return;
519    }
520    eprintln!();
521    eprintln!("{}:", style::yellow("Options"));
522    for (long, spec) in &schema.options {
523        for line in format_option(long, spec) {
524            eprintln!("    {line}");
525        }
526    }
527}
528
529fn print_entry_section(cmd_config: &CommandConfig) {
530    let Some(entry) = &cmd_config.entry else {
531        return;
532    };
533    eprintln!();
534    eprintln!("{}:", style::yellow("Entry"));
535    eprintln!("    {entry}");
536    if let Some(service) = &cmd_config.docker {
537        eprintln!(
538            "     {}",
539            style::dim(&format!("[docker: {service}]"))
540        );
541    }
542    eprintln!();
543    eprintln!(
544        "    Any extra {} are forwarded to the entry point.",
545        style::dim("[options]")
546    );
547}
548
549fn print_defaults_section(cmd_config: &CommandConfig) {
550    if cmd_config.ddp.is_none() && cmd_config.training.is_none() {
551        return;
552    }
553    eprintln!();
554    eprintln!("{}:", style::yellow("Defaults"));
555    if let Some(d) = &cmd_config.ddp {
556        if let Some(mode) = &d.mode {
557            eprintln!("    {}  {mode}", style::dim("ddp.mode"));
558        }
559        if let Some(anchor) = &d.anchor {
560            eprintln!("    {}  {}", style::dim("ddp.anchor"), value_to_string(anchor));
561        }
562    }
563    if let Some(t) = &cmd_config.training {
564        if let Some(e) = t.epochs {
565            eprintln!("    {}  {e}", style::dim("training.epochs"));
566        }
567        if let Some(bs) = t.batch_size {
568            eprintln!("    {}  {bs}", style::dim("training.batch_size"));
569        }
570        if let Some(lr) = t.lr {
571            eprintln!("    {}  {lr}", style::dim("training.lr"));
572        }
573        if let Some(seed) = t.seed {
574            eprintln!("    {}  {seed}", style::dim("training.seed"));
575        }
576    }
577}
578
579/// Print help for a named preset command nested inside a sub-command.
580pub fn print_preset_help(cmd_config: &CommandConfig, cmd_name: &str, preset_name: &str) {
581    let preset = match cmd_config.commands.get(preset_name) {
582        Some(s) => s,
583        None => {
584            eprintln!("unknown command: {preset_name}");
585            return;
586        }
587    };
588
589    // Title.
590    let desc = preset.description.as_deref().unwrap_or("(no description)");
591    eprintln!(
592        "{} {} {}",
593        style::bold(cmd_name),
594        style::green(preset_name),
595        desc
596    );
597
598    eprintln!();
599    eprintln!("{}:", style::yellow("Usage"));
600    eprintln!(
601        "    fdl {cmd_name} {preset_name} {}",
602        style::dim("[extra options]")
603    );
604
605    // Show the merged config that this preset produces.
606    let resolved = config::merge_preset(cmd_config, preset);
607
608    eprintln!();
609    eprintln!("{}:", style::yellow("Effective config"));
610
611    // DDP fields.
612    let d = &resolved.ddp;
613    print_config_field("ddp.mode", &d.mode);
614    print_config_value("ddp.anchor", &d.anchor);
615    print_config_field("ddp.max_anchor", &d.max_anchor);
616    print_config_field("ddp.overhead_target", &d.overhead_target);
617    print_config_field("ddp.divergence_threshold", &d.divergence_threshold);
618    print_config_value("ddp.max_batch_diff", &d.max_batch_diff);
619    print_config_field("ddp.max_grad_norm", &d.max_grad_norm);
620    if d.timeline == Some(true) {
621        eprintln!("    {}  true", style::dim("ddp.timeline"));
622    }
623
624    // Training fields.
625    let t = &resolved.training;
626    print_config_field("training.epochs", &t.epochs);
627    print_config_field("training.batch_size", &t.batch_size);
628    print_config_field("training.batches_per_epoch", &t.batches_per_epoch);
629    print_config_field("training.lr", &t.lr);
630    print_config_field("training.seed", &t.seed);
631
632    // Output fields.
633    let o = &resolved.output;
634    print_config_field("output.dir", &o.dir);
635    print_config_field("output.monitor", &o.monitor);
636
637    // Pass-through options.
638    if !resolved.options.is_empty() {
639        eprintln!();
640        eprintln!("{}:", style::yellow("Options"));
641        for (key, val) in &resolved.options {
642            eprintln!(
643                "    {}  {}",
644                style::green(&format!("--{}", key.replace('_', "-"))),
645                value_to_string(val)
646            );
647        }
648    }
649
650    // Show the effective command.
651    if let Some(entry) = &cmd_config.entry {
652        let args = config_to_args(&resolved);
653        let args_str = args.join(" ");
654        let docker_info = cmd_config
655            .docker
656            .as_ref()
657            .map(|s| format!("[{s}] ", ))
658            .unwrap_or_default();
659
660        eprintln!();
661        eprintln!("{}:", style::yellow("Effective command"));
662        eprintln!(
663            "    {}{}{}",
664            style::dim(&docker_info),
665            entry,
666            if args_str.is_empty() {
667                String::new()
668            } else {
669                format!(" {args_str}")
670            }
671        );
672    }
673
674    eprintln!();
675    eprintln!(
676        "Extra {} after the command name are appended to the entry.",
677        style::dim("[options]")
678    );
679}
680
681fn print_config_field<T: std::fmt::Display>(label: &str, val: &Option<T>) {
682    if let Some(v) = val {
683        eprintln!("    {}  {v}", style::dim(label));
684    }
685}
686
687fn print_config_value(label: &str, val: &Option<serde_json::Value>) {
688    if let Some(v) = val {
689        if !v.is_null() {
690            eprintln!("    {}  {}", style::dim(label), value_to_string(v));
691        }
692    }
693}
694
695/// Print the project help with scripts and commands.
696pub fn print_project_help(
697    project: &config::ProjectConfig,
698    project_root: &Path,
699    active_env: Option<&str>,
700) {
701    let visible_builtins = builtins::visible_top_level();
702    if let Some(desc) = &project.description {
703        eprintln!("{} {}", style::bold("fdl"), desc);
704    } else {
705        eprintln!("{} {}", style::bold("fdl"), env!("CARGO_PKG_VERSION"));
706    }
707
708    eprintln!();
709    eprintln!("{}:", style::yellow("Usage"));
710    eprintln!(
711        "    fdl {} {}",
712        style::dim("<command>"),
713        style::dim("[options]")
714    );
715
716    eprintln!();
717    eprintln!("{}:", style::yellow("Options"));
718    eprintln!(
719        "    {}  Show this help",
720        style::green(&format!("{:<18}", "-h, --help"))
721    );
722    eprintln!(
723        "    {}  Show version",
724        style::green(&format!("{:<18}", "-V, --version"))
725    );
726    eprintln!(
727        "    {}  Use fdl.<name>.yml overlay (also: FDL_ENV=<name>)",
728        style::green(&format!("{:<18}", "--env <name>"))
729    );
730    eprintln!(
731        "    {}  Verbose output",
732        style::green(&format!("{:<18}", "-v"))
733    );
734    eprintln!(
735        "    {}  Debug output",
736        style::green(&format!("{:<18}", "-vv"))
737    );
738    eprintln!(
739        "    {}  Trace output (maximum detail)",
740        style::green(&format!("{:<18}", "-vvv"))
741    );
742    eprintln!(
743        "    {}  Suppress non-error output",
744        style::green(&format!("{:<18}", "-q, --quiet"))
745    );
746    eprintln!(
747        "    {}  Force ANSI color (bypass TTY / NO_COLOR detection)",
748        style::green(&format!("{:<18}", "--ansi"))
749    );
750    eprintln!(
751        "    {}  Disable ANSI color output",
752        style::green(&format!("{:<18}", "--no-ansi"))
753    );
754
755    // Built-in commands.
756    eprintln!();
757    eprintln!("{}:", style::yellow("Built-in"));
758    for (name, desc) in &visible_builtins {
759        eprintln!("    {}  {desc}", style::green(&format!("{:<18}", name)));
760    }
761
762    // Commands: unified section. Each entry in `project.commands` is one
763    // of: an inline `run:` script, a `path:` (or convention-default)
764    // pointer to a child fdl.yml, or — at nested levels only — an inline
765    // preset. Descriptions come from the `CommandSpec`; for `path:`
766    // commands missing their own description, fall back to loading the
767    // child fdl.yml's description.
768    if !project.commands.is_empty() {
769        eprintln!();
770        eprintln!("{}:", style::yellow("Commands"));
771        for (name, spec) in &project.commands {
772            let desc: String = match spec.description.clone() {
773                Some(d) => d,
774                None => {
775                    // For path-kind entries, fall back to the child config's
776                    // own description so `commands: { ddp-bench: }` still
777                    // shows a useful blurb.
778                    let is_path_kind = spec.run.is_none();
779                    if is_path_kind {
780                        let child_dir = spec.resolve_path(name, project_root);
781                        config::load_command_with_env(&child_dir, active_env)
782                            .ok()
783                            .and_then(|c| c.description)
784                            .unwrap_or_else(|| "(sub-command)".into())
785                    } else {
786                        spec.run
787                            .as_deref()
788                            .unwrap_or("(command)")
789                            .to_string()
790                    }
791                }
792            };
793            eprintln!("    {}  {desc}", style::green(&format!("{:<18}", name)));
794        }
795    }
796
797    // Available environments (sibling fdl.<env>.yml files at project root).
798    if let Some(base_config) = config::find_config(project_root) {
799        let envs = crate::overlay::list_envs(&base_config);
800        if !envs.is_empty() {
801            eprintln!();
802            eprintln!("{}:", style::yellow("Environments"));
803            for e in &envs {
804                let active_marker = if Some(e.as_str()) == active_env {
805                    style::green(" (active)")
806                } else {
807                    String::new()
808                };
809                eprintln!(
810                    "    {}  Overlay from fdl.{}.yml{active_marker}",
811                    style::green(&format!("{:<18}", e)),
812                    e
813                );
814            }
815            eprintln!();
816            eprintln!(
817                "Use {} to run a command with an environment overlay.",
818                style::dim("fdl <env> <command>")
819            );
820        }
821    }
822
823    eprintln!();
824    eprintln!(
825        "Use {} for more information on a command.",
826        style::dim("fdl <command> -h")
827    );
828}
829
830// ── Schema-driven help helpers ──────────────────────────────────────────
831
832/// Build the part of `fdl <cmd>...` after the command name: positionals
833/// rendered as `<name>` (required) or `[<name>]` (optional), plus a slot
834/// for the first-positional picker — `[<preset>]` when only presets exist,
835/// `[<command>]` when only sub-commands exist, `[<preset>|<command>]` when
836/// both — and `[options]`. The preset placeholder is customisable per
837/// sub-command via `arg-name:`.
838fn build_usage_tail(
839    schema: Option<&Schema>,
840    has_presets: bool,
841    has_sub_commands: bool,
842    preset_slot: &str,
843) -> String {
844    let mut parts = String::new();
845    let slot = match (has_presets, has_sub_commands) {
846        (true, false) => Some(format!("[<{preset_slot}>]")),
847        (false, true) => Some("[<command>]".to_string()),
848        (true, true) => Some(format!("[<{preset_slot}>|<command>]")),
849        (false, false) => None,
850    };
851    if let Some(s) = slot {
852        parts.push(' ');
853        parts.push_str(&style::dim(&s));
854    }
855    if let Some(s) = schema {
856        for a in &s.args {
857            parts.push(' ');
858            parts.push_str(&format_arg_usage(a));
859        }
860    }
861    parts.push(' ');
862    parts.push_str(&style::dim("[options]"));
863    parts
864}
865
866type CommandGroup = Vec<(String, crate::config::CommandSpec)>;
867
868/// Partition a `commands:` map into (presets, sub-commands) by resolved
869/// `CommandKind`. Entries whose `kind()` errors (both run and path set)
870/// are treated as sub-commands so they still render somewhere — the
871/// error surfaces when the user tries to dispatch them.
872fn split_commands_by_kind(
873    commands: &BTreeMap<String, crate::config::CommandSpec>,
874) -> (CommandGroup, CommandGroup) {
875    use crate::config::CommandKind;
876    let mut presets = Vec::new();
877    let mut sub_cmds = Vec::new();
878    for (k, v) in commands {
879        match v.kind() {
880            Ok(CommandKind::Preset) => presets.push((k.clone(), v.clone())),
881            _ => sub_cmds.push((k.clone(), v.clone())),
882        }
883    }
884    (presets, sub_cmds)
885}
886
887fn format_arg_usage(a: &ArgSpec) -> String {
888    let suffix = if a.variadic { "..." } else { "" };
889    let core = format!("<{}>{suffix}", a.name);
890    if a.required && a.default.is_none() {
891        style::green(&core)
892    } else {
893        style::dim(&format!("[{core}]"))
894    }
895}
896
897fn format_arg(a: &ArgSpec) -> String {
898    let mut left = format_arg_usage(a);
899    // Target ~22-char visual width for the label column.
900    let visible = visible_width(&left);
901    if visible < 22 {
902        for _ in 0..(22 - visible) {
903            left.push(' ');
904        }
905    } else {
906        left.push(' ');
907    }
908    let mut line = left;
909    line.push_str(a.description.as_deref().unwrap_or("-"));
910    append_default_and_choices(&mut line, &a.default, &a.choices, &a.ty);
911    line
912}
913
914/// Format an option row. Returns one or more lines; `choices` list wraps
915/// onto a second indented line when present, to keep the main row readable.
916fn format_option(long: &str, spec: &OptionSpec) -> Vec<String> {
917    let flag = match &spec.short {
918        Some(s) => format!("-{s}, --{long}"),
919        None => format!("    --{long}"),
920    };
921    let placeholder = option_placeholder(&spec.ty);
922    let left = if placeholder.is_empty() {
923        style::green(&flag)
924    } else {
925        style::green(&format!("{flag} {placeholder}"))
926    };
927    let visible = visible_width_for(&flag, placeholder);
928
929    // Pad to 30 columns for alignment.
930    let pad = if visible < 30 { 30 - visible } else { 1 };
931    let mut line = format!("{left}{}", " ".repeat(pad));
932    line.push_str(spec.description.as_deref().unwrap_or("-"));
933    append_default_and_choices(&mut line, &spec.default, &spec.choices, &spec.ty);
934
935    let mut out = vec![line];
936    if let Some(env) = &spec.env {
937        out.push(format!("{}  {}", " ".repeat(32), style::dim(&format!("[env: {env}]"))));
938    }
939    out
940}
941
942fn option_placeholder(ty: &str) -> &'static str {
943    match ty {
944        "bool" => "",
945        "int" => "<N>",
946        "float" => "<F>",
947        "path" => "<PATH>",
948        "list[path]" => "<PATH>...",
949        t if t.starts_with("list[") => "<VALUE>...",
950        _ => "<VALUE>",
951    }
952}
953
954fn append_default_and_choices(
955    line: &mut String,
956    default: &Option<serde_json::Value>,
957    choices: &Option<Vec<serde_json::Value>>,
958    ty: &str,
959) {
960    if let Some(d) = default {
961        // Skip noisy defaults: bool false, empty list, null.
962        let is_empty_list = matches!(d, serde_json::Value::Array(a) if a.is_empty());
963        let is_false = matches!(d, serde_json::Value::Bool(false));
964        if !d.is_null() && !is_false && !is_empty_list {
965            line.push_str(&format!(" {}", style::dim(&format!("[default: {}]", format_value(d)))));
966        }
967    }
968    if let Some(choices) = choices {
969        if !choices.is_empty() {
970            let list = choices
971                .iter()
972                .map(format_value)
973                .collect::<Vec<_>>()
974                .join(", ");
975            line.push_str(&format!(" {}", style::dim(&format!("[possible: {list}]"))));
976        }
977    }
978    // Annotate list types so users know about repeat/comma semantics.
979    if ty.starts_with("list[") {
980        line.push_str(&format!(" {}", style::dim("(repeat or comma-separate)")));
981    }
982}
983
984fn format_value(v: &serde_json::Value) -> String {
985    match v {
986        serde_json::Value::String(s) => s.clone(),
987        other => other.to_string(),
988    }
989}
990
991/// Rough visible width helper: styled strings wrap their visible content
992/// in ANSI escapes, so we use the unstyled inputs we started from.
993fn visible_width(s: &str) -> usize {
994    // The inputs we pass here come from pre-styling helpers that already
995    // know the raw length. Strip ANSI to be safe.
996    strip_ansi(s).chars().count()
997}
998
999fn visible_width_for(flag: &str, placeholder: &str) -> usize {
1000    if placeholder.is_empty() {
1001        flag.chars().count()
1002    } else {
1003        flag.chars().count() + 1 + placeholder.chars().count()
1004    }
1005}
1006
1007fn strip_ansi(s: &str) -> String {
1008    let mut out = String::with_capacity(s.len());
1009    let mut chars = s.chars().peekable();
1010    while let Some(c) = chars.next() {
1011        if c == '\x1b' && chars.peek() == Some(&'[') {
1012            chars.next();
1013            for c in chars.by_ref() {
1014                if c.is_ascii_alphabetic() {
1015                    break;
1016                }
1017            }
1018        } else {
1019            out.push(c);
1020        }
1021    }
1022    out
1023}