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/// Default container path we assume the host project root is mounted
134/// at when `docker-compose.yml` is missing or can't be parsed.
135/// Matches the convention `fdl init` writes into every generated
136/// compose service (`.:/workspace`).
137const DEFAULT_CONTAINER_PROJECT_ROOT: &str = "/workspace";
138
139/// Per-process cache of the container-side project-root path, keyed by
140/// docker-compose service name. Populated lazily on first lookup and
141/// reused for the life of the `fdl` invocation. `docker-compose.yml` is
142/// user-edited and version-controlled, so re-parsing once per
143/// invocation is cheap — the cache only avoids re-parsing *within* a
144/// single invocation.
145static COMPOSE_MOUNT_CACHE: std::sync::OnceLock<
146    std::collections::HashMap<String, String>,
147> = std::sync::OnceLock::new();
148
149/// Resolve the absolute container path where the host project root is
150/// mounted inside `service`.
151///
152/// Reads `docker-compose.yml` at `project_root` once per process and
153/// caches the `service → container_path` mapping. Falls back to
154/// [`DEFAULT_CONTAINER_PROJECT_ROOT`] when the compose file is missing,
155/// unparseable, or doesn't declare a matching bind mount for `.`.
156///
157/// This is what lets `exec_command` generate `cd <container-path>`
158/// prefixes that work regardless of the service's `working_dir:` —
159/// e.g. flodl-hf's `hf-parity` service uses
160/// `working_dir: /workspace/flodl-hf` so Python parity scripts can
161/// `import` sibling helpers, and a naive relative `cd flodl-hf/convert`
162/// would resolve to the non-existent
163/// `/workspace/flodl-hf/flodl-hf/convert`.
164fn 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
173/// Parse `<project_root>/docker-compose.yml` and return a map of
174/// `service → container-mount-path` for every service that bind-mounts
175/// the project root (host `.` or `./`).
176///
177/// Handles both short-form (`".:/workspace"`) and long-form
178/// (`{ type: bind, source: ., target: /workspace }`) volume entries.
179/// Read errors, parse errors, and missing sections all yield an empty
180/// map — callers fall back to the convention.
181fn 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            // Strip a trailing `/` so later `format!("{root}/{workdir}")`
209            // never produces `//` in the middle of a path.
210            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
222/// Inside a service's `volumes:` sequence, find the entry that
223/// bind-mounts the project root (host path `.` or `./`) and return the
224/// container-side target path.
225fn find_project_mount(volumes: &[serde_yaml::Value]) -> Option<String> {
226    for entry in volumes {
227        if let Some(s) = entry.as_str() {
228            // Short form: "host:container[:options]". Docker-compose's
229            // short-form parser only splits on the first two `:` on
230            // POSIX, but fdl-generated hosts are always `.` so naive
231            // split-and-check works fine here.
232            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            // Long form: { type: bind, source: ., target: /workspace }.
240            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
256/// Resolve libtorch env vars from the project root, matching the Makefile logic:
257///   LIBTORCH_HOST_PATH = ./libtorch/<active_variant>
258///   LIBTORCH_CPU_PATH  = ./libtorch/precompiled/cpu
259///   CUDA_VERSION, CUDA_TAG from .arch metadata
260fn libtorch_env(project_root: &Path) -> Vec<(String, String)> {
261    let mut env = Vec::new();
262
263    // CPU path is always the same.
264    env.push((
265        "LIBTORCH_CPU_PATH".into(),
266        "./libtorch/precompiled/cpu".into(),
267    ));
268
269    // Active variant for CUDA.
270    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        // CUDA version from .arch metadata.
275        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
296/// Spawn a shell command with libtorch env vars set.
297///
298/// `FLODL_VERBOSITY` is forwarded to Docker containers via the
299/// `environment:` section in docker-compose.yml (bare variable name
300/// passes the host value through when set, ignored otherwise).
301fn 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
325// ── Run-kind execution ──────────────────────────────────────────────────
326
327/// Run an inline `run:` script, optionally wrapped in Docker.
328pub 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
361// ── Command execution ───────────────────────────────────────────────────
362
363/// Execute a sub-command, optionally with a named preset (inline command).
364///
365/// `project_root` is needed to resolve Docker compose context and
366/// compute the relative workdir for containerized execution.
367pub 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    // Tail validation pre-flight. Runs whenever a schema is present:
386    // - `choices:` on declared options → always enforced.
387    // - Unknown flags → rejected only when `schema.strict` is set
388    //   (lenient mode tolerates pass-through flags the binary may
389    //   consume directly).
390    // fdl-generated args (from the structured ddp/training/output
391    // blocks) are intentionally skipped — those are the binary's
392    // surface, not the user's.
393    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    // Resolve config: preset overrides merged with root defaults.
401    let resolved = match preset_name {
402        Some(name) => match cmd_config.commands.get(name) {
403            Some(preset) => {
404                // Validate *this* preset only (choices + strict unknowns).
405                // Whole-map validation is deferred so a broken sibling
406                // preset doesn't block a correct one from running.
407                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    // Build argument list from config.
426    let mut args = config_to_args(&resolved);
427
428    // Append extra CLI args (these override config-derived args).
429    args.extend(extra_args.iter().cloned());
430
431    // Docker wrapping or direct execution.
432    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        // Build the inner command: cd <container-root>/<workdir> && <entry> <args>
442        //
443        // `<container-root>` is discovered from docker-compose.yml's
444        // bind mount for the project (host `.` → container target) via
445        // [`container_project_root`], so the generated `cd` works
446        // regardless of the service's own `working_dir:`. Falls back
447        // to `/workspace` (the fdl init convention) when the compose
448        // file is missing or silent on this service.
449        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        // Run via docker compose from the project root (with libtorch env).
462        let docker_cmd = format!("docker compose run --rm {service} bash -c \"{inner}\"");
463        spawn_docker_shell(&docker_cmd, project_root)
464    } else {
465        // Direct execution (inside container or no docker configured).
466        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
498/// Join args into a shell-safe string.
499fn 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
512// ── Help output ─────────────────────────────────────────────────────────
513
514/// Print help for a `run:`-kind command. Shows the inline script that
515/// will execute and the Docker service (if any). `run:` commands do not
516/// forward argv, so there are no flags or positionals to document.
517pub 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
540/// Print help for a sub-command (its arguments, nested commands, and
541/// entry). Orchestrates the per-section helpers below.
542pub 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    // The first-positional slot reflects what is actually accepted here:
571    // preset name, sub-command name, or either.
572    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    // Schema-declared positionals (typed slots on the entry binary) and
589    // the preset slot (dispatched by fdl before the binary sees argv)
590    // both land in the first-positional position, so they share one
591    // section. Schema args render first; the preset slot with its
592    // value list follows.
593    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    // Run/Path kinds only — true sub-commands with their own behavior
626    // (an inline script or a nested fdl.yml).
627    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    // Schema-driven options. Renders only when a schema block is present
644    // in fdl.yaml; the "Defaults" section covers ddp/training/output.
645    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
710/// Print help for a named preset command nested inside a sub-command.
711pub 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    // Title.
721    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    // Show the merged config that this preset produces.
737    let resolved = config::merge_preset(cmd_config, preset);
738
739    eprintln!();
740    eprintln!("{}:", style::yellow("Effective config"));
741
742    // DDP fields.
743    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    // Training fields.
756    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    // Output fields.
764    let o = &resolved.output;
765    print_config_field("output.dir", &o.dir);
766    print_config_field("output.monitor", &o.monitor);
767
768    // Pass-through options.
769    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    // Show the effective command.
782    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
826/// Print the project help with scripts and commands.
827pub 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    // Built-in commands.
887    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    // Commands: unified section. Each entry in `project.commands` is one
894    // of: an inline `run:` script, a `path:` (or convention-default)
895    // pointer to a child fdl.yml, or — at nested levels only — an inline
896    // preset. Descriptions come from the `CommandSpec`; for `path:`
897    // commands missing their own description, fall back to loading the
898    // child fdl.yml's description.
899    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                    // For path-kind entries, fall back to the child config's
907                    // own description so `commands: { ddp-bench: }` still
908                    // shows a useful blurb.
909                    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    // Available environments (sibling fdl.<env>.yml files at project root).
929    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
961// ── Schema-driven help helpers ──────────────────────────────────────────
962
963/// Build the part of `fdl <cmd>...` after the command name: positionals
964/// rendered as `<name>` (required) or `[<name>]` (optional), plus a slot
965/// for the first-positional picker — `[<preset>]` when only presets exist,
966/// `[<command>]` when only sub-commands exist, `[<preset>|<command>]` when
967/// both — and `[options]`. The preset placeholder is customisable per
968/// sub-command via `arg-name:`.
969fn 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
999/// Partition a `commands:` map into (presets, sub-commands) by resolved
1000/// `CommandKind`. Entries whose `kind()` errors (both run and path set)
1001/// are treated as sub-commands so they still render somewhere — the
1002/// error surfaces when the user tries to dispatch them.
1003fn 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    // Target ~22-char visual width for the label column.
1031    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
1045/// Format an option row. Returns one or more lines; `choices` list wraps
1046/// onto a second indented line when present, to keep the main row readable.
1047fn 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    // Pad to 30 columns for alignment.
1061    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        // Skip noisy defaults: bool false, empty list, null.
1093        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    // Annotate list types so users know about repeat/comma semantics.
1110    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
1122/// Rough visible width helper: styled strings wrap their visible content
1123/// in ANSI escapes, so we use the unstyled inputs we started from.
1124fn visible_width(s: &str) -> usize {
1125    // The inputs we pass here come from pre-styling helpers that already
1126    // know the raw length. Strip ANSI to be safe.
1127    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}