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/// POSIX-quote a single token so it round-trips through `sh -c` / `bash
328/// -c` as one argument. Empty strings become `''`; tokens containing
329/// only safe characters pass through unchanged; everything else is
330/// wrapped in single quotes with embedded `'` escaped as `'\''`.
331pub(crate) fn posix_quote(s: &str) -> String {
332    if s.is_empty() {
333        return "''".to_string();
334    }
335    let safe = s.chars().all(|c| {
336        c.is_ascii_alphanumeric()
337            || matches!(c, '_' | '-' | '.' | '/' | ':' | '=' | '+' | '@' | ',')
338    });
339    if safe {
340        return s.to_string();
341    }
342    let mut out = String::with_capacity(s.len() + 2);
343    out.push('\'');
344    for c in s.chars() {
345        if c == '\'' {
346            out.push_str("'\\''");
347        } else {
348            out.push(c);
349        }
350    }
351    out.push('\'');
352    out
353}
354
355/// Split `s` on the first whitespace-bounded `--` token, returning the
356/// halves with that token removed. Trim each half. When no such token is
357/// found, the whole string is returned as the "before" half and the
358/// "after" half is empty.
359///
360/// Whitespace-bounded means the `--` must be a standalone token: a
361/// leading `--`, a trailing `--`, a sole `--`, or a ` -- ` between
362/// other tokens. A bare `--foo` (no separator) is not a match. Quoted
363/// content in `s` passes through verbatim — split scanning happens on
364/// the raw string, not its shell-tokenised form.
365fn split_append_dashdash(s: &str) -> (String, String) {
366    let s = s.trim();
367    if s == "--" {
368        return (String::new(), String::new());
369    }
370    if let Some(rest) = s.strip_prefix("-- ") {
371        return (String::new(), rest.trim().to_string());
372    }
373    if let Some(prefix) = s.strip_suffix(" --") {
374        return (prefix.trim().to_string(), String::new());
375    }
376    if let Some(idx) = s.find(" -- ") {
377        let before = &s[..idx];
378        let after = &s[idx + 4..];
379        return (before.trim().to_string(), after.trim().to_string());
380    }
381    (s.to_string(), String::new())
382}
383
384/// Split `args` on the first standalone `--` token, returning the
385/// halves with that token removed. Returns `(before, Some(after))` when
386/// a separator is present, `(args, None)` otherwise. The `None` case
387/// keeps callers from emitting a stray `--` when the user did not pass
388/// runner-side args.
389fn split_user_args_dashdash(args: &[String]) -> (&[String], Option<&[String]>) {
390    match args.iter().position(|a| a == "--") {
391        Some(idx) => (&args[..idx], Some(&args[idx + 1..])),
392        None => (args, None),
393    }
394}
395
396/// Compose the final shell command from `run` + `append` + `user_args`.
397///
398/// Layout: `run [append-pre] [user-pre] -- [append-post] [user-post]`,
399/// where `append-pre` / `append-post` are halves of the yml `append:`
400/// field split on its first standalone `--`, and `user-pre` /
401/// `user-post` are halves of the CLI tokens that followed fdl's own
402/// first `--`, split again on a second `--`. The `--` separator only
403/// emits when at least one of the post halves is non-empty (or the
404/// `append` half explicitly carries one — preserving the legacy
405/// `append: -- --nocapture` shape).
406///
407/// User args go after append on each side: `append` seeds defaults,
408/// and last-wins for cargo-style flags lets the user override without
409/// ceremony (e.g. `append: --no-ansi` + CLI `--ansi`).
410pub(crate) fn compose_run_command(
411    run: &str,
412    user_args: &[String],
413    append: Option<&str>,
414) -> String {
415    let suffix = append.map(str::trim).filter(|s| !s.is_empty());
416    let (append_pre, append_post, append_has_dashdash) = match suffix {
417        Some(s) => {
418            let (pre, post) = split_append_dashdash(s);
419            // Distinguish "append had a `--`" (legacy `-- --nocapture`
420            // with empty pre, non-empty post) from "append had no `--`"
421            // (post defaults empty). Without this we'd swallow the
422            // separator on legacy yml.
423            let has = s == "--"
424                || s.starts_with("-- ")
425                || s.ends_with(" --")
426                || s.contains(" -- ");
427            (pre, post, has)
428        }
429        None => (String::new(), String::new(), false),
430    };
431    let (user_pre, user_post_opt) = split_user_args_dashdash(user_args);
432
433    let mut out = String::from(run.trim());
434    if !append_pre.is_empty() {
435        out.push(' ');
436        out.push_str(&append_pre);
437    }
438    for a in user_pre {
439        out.push(' ');
440        out.push_str(&posix_quote(a));
441    }
442
443    let needs_separator = append_has_dashdash
444        || !append_post.is_empty()
445        || user_post_opt.is_some_and(|p| !p.is_empty());
446    if needs_separator {
447        out.push_str(" --");
448        if !append_post.is_empty() {
449            out.push(' ');
450            out.push_str(&append_post);
451        }
452        if let Some(post) = user_post_opt {
453            for a in post {
454                out.push(' ');
455                out.push_str(&posix_quote(a));
456            }
457        }
458    }
459    out
460}
461
462/// Run an inline `run:` script, optionally wrapped in Docker.
463///
464/// `user_args` (from CLI tokens after `--`) are POSIX-quoted and spliced
465/// between `command` and `append`, so a script like `cargo test live`
466/// with `append: -- --nocapture --ignored` still receives its libtest
467/// flags after a user-supplied `-p flodl-hf`.
468pub fn exec_script(
469    command: &str,
470    append: Option<&str>,
471    user_args: &[String],
472    docker_service: Option<&str>,
473    cwd: &Path,
474) -> ExitCode {
475    let inner_cmd = compose_run_command(command, user_args, append);
476
477    match docker_service {
478        Some(service) if !inside_docker() => {
479            // Quote the whole composed command for the outer
480            // `bash -c` so user args containing shell metacharacters
481            // don't escape the inner shell.
482            let docker_cmd = format!(
483                "docker compose run --rm {service} bash -c {}",
484                posix_quote(&inner_cmd)
485            );
486            spawn_docker_shell(&docker_cmd, cwd)
487        }
488        _ => {
489            let (shell, flag) = if cfg!(target_os = "windows") {
490                ("cmd", "/C")
491            } else {
492                ("sh", "-c")
493            };
494
495            match std::process::Command::new(shell)
496                .args([flag, inner_cmd.as_str()])
497                .current_dir(cwd)
498                .stdout(Stdio::inherit())
499                .stderr(Stdio::inherit())
500                .stdin(Stdio::inherit())
501                .status()
502            {
503                Ok(s) if s.success() => ExitCode::SUCCESS,
504                Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
505                Err(e) => {
506                    cli_error!("{e}");
507                    ExitCode::FAILURE
508                }
509            }
510        }
511    }
512}
513
514// ── Command execution ───────────────────────────────────────────────────
515
516/// Execute a sub-command, optionally with a named preset (inline command).
517///
518/// `project_root` is needed to resolve Docker compose context and
519/// compute the relative workdir for containerized execution.
520pub fn exec_command(
521    cmd_config: &CommandConfig,
522    preset_name: Option<&str>,
523    extra_args: &[String],
524    cmd_dir: &Path,
525    project_root: &Path,
526) -> ExitCode {
527    let entry = match &cmd_config.entry {
528        Some(e) => e.as_str(),
529        None => {
530            eprintln!(
531                "error: no entry point defined in {}/fdl.yaml",
532                cmd_dir.display()
533            );
534            return ExitCode::FAILURE;
535        }
536    };
537
538    // Tail validation pre-flight. Runs whenever a schema is present:
539    // - `choices:` on declared options → always enforced.
540    // - Unknown flags → rejected only when `schema.strict` is set
541    //   (lenient mode tolerates pass-through flags the binary may
542    //   consume directly).
543    // fdl-generated args (from the structured ddp/training/output
544    // blocks) are intentionally skipped — those are the binary's
545    // surface, not the user's.
546    if let Some(schema) = &cmd_config.schema {
547        if let Err(e) = config::validate_tail(extra_args, schema) {
548            cli_error!("{e}");
549            return ExitCode::FAILURE;
550        }
551    }
552
553    // Resolve config: preset overrides merged with root defaults.
554    let resolved = match preset_name {
555        Some(name) => match cmd_config.commands.get(name) {
556            Some(preset) => {
557                // Validate *this* preset only (choices + strict unknowns).
558                // Whole-map validation is deferred so a broken sibling
559                // preset doesn't block a correct one from running.
560                if let Some(schema) = &cmd_config.schema {
561                    if let Err(e) = config::validate_preset_for_exec(name, preset, schema) {
562                        cli_error!("{e}");
563                        return ExitCode::FAILURE;
564                    }
565                }
566                config::merge_preset(cmd_config, preset)
567            }
568            None => {
569                cli_error!("unknown command '{name}'");
570                eprintln!();
571                print_command_help(cmd_config, "");
572                return ExitCode::FAILURE;
573            }
574        },
575        None => config::defaults_only(cmd_config),
576    };
577
578    // Build argument list from config.
579    let mut args = config_to_args(&resolved);
580
581    // Append extra CLI args (these override config-derived args).
582    args.extend(extra_args.iter().cloned());
583
584    // Docker wrapping or direct execution.
585    let use_docker = cmd_config.docker.is_some() && !inside_docker();
586
587    if use_docker {
588        let service = cmd_config.docker.as_deref().unwrap();
589        let workdir = cmd_dir
590            .strip_prefix(project_root)
591            .unwrap_or(cmd_dir)
592            .to_string_lossy();
593
594        // Build the inner command: cd <container-root>/<workdir> && <entry> <args>
595        //
596        // `<container-root>` is discovered from docker-compose.yml's
597        // bind mount for the project (host `.` → container target) via
598        // [`container_project_root`], so the generated `cd` works
599        // regardless of the service's own `working_dir:`. Falls back
600        // to `/workspace` (the fdl init convention) when the compose
601        // file is missing or silent on this service.
602        let container_root = container_project_root(project_root, service);
603        let args_str = shell_join(&args);
604        let inner = if workdir.is_empty() || workdir == "." {
605            format!("{entry} {args_str}")
606        } else {
607            format!("cd {container_root}/{workdir} && {entry} {args_str}")
608        };
609
610        if preset_name.is_some() {
611            eprintln!("fdl: [{service}] {inner}");
612        }
613
614        // Surface the container-side workspace root to the inner
615        // process so entry binaries can re-anchor argv path arguments
616        // independently of the per-task `cd <root>/<workdir>` we
617        // injected above. Mirrors the `HF_HOME` pattern: `fdl` owns
618        // the env, the binary just reads it. Without this, a user
619        // typing `flodl-hf/tests/.exports/bert` from the host repo
620        // root resolves against the wrong cwd inside the container.
621        let docker_cmd = format!(
622            "docker compose run --rm -e FDL_PROJECT_ROOT={container_root} {service} bash -c \"{inner}\"",
623        );
624        spawn_docker_shell(&docker_cmd, project_root)
625    } else {
626        // Direct execution (inside container or no docker configured).
627        let parts: Vec<&str> = entry.split_whitespace().collect();
628        if parts.is_empty() {
629            cli_error!("empty entry point");
630            return ExitCode::FAILURE;
631        }
632        let program = parts[0];
633        let entry_args = &parts[1..];
634
635        if preset_name.is_some() {
636            let preview: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
637            eprintln!("fdl: {entry} {}", preview.join(" "));
638        }
639
640        match std::process::Command::new(program)
641            .args(entry_args)
642            .args(&args)
643            .current_dir(cmd_dir)
644            .stdout(Stdio::inherit())
645            .stderr(Stdio::inherit())
646            .stdin(Stdio::inherit())
647            .status()
648        {
649            Ok(s) if s.success() => ExitCode::SUCCESS,
650            Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
651            Err(e) => {
652                cli_error!("failed to execute '{program}': {e}");
653                ExitCode::FAILURE
654            }
655        }
656    }
657}
658
659/// Join args into a shell-safe string.
660fn shell_join(args: &[String]) -> String {
661    args.iter()
662        .map(|a| {
663            if a.contains(' ') || a.contains('"') || a.is_empty() {
664                format!("'{}'", a.replace('\'', "'\\''"))
665            } else {
666                a.clone()
667            }
668        })
669        .collect::<Vec<_>>()
670        .join(" ")
671}
672
673// ── Help output ─────────────────────────────────────────────────────────
674
675/// Print help for a `run:`-kind command. Shows the inline script,
676/// any `append:` suffix, the Docker service (if any), and the `--`
677/// forwarding contract.
678pub fn print_run_help(
679    name: &str,
680    description: Option<&str>,
681    run: &str,
682    append: Option<&str>,
683    docker: Option<&str>,
684) {
685    if let Some(desc) = description {
686        eprintln!("{} {desc}", style::bold(name));
687    } else {
688        eprintln!("{}", style::bold(name));
689    }
690    eprintln!();
691    eprintln!("{}:", style::yellow("Usage"));
692    eprintln!("    fdl {name} [-- <args>... [-- <runner-args>...]]");
693    eprintln!();
694    eprintln!("{}:", style::yellow("Runs"));
695    let composed = match append.map(str::trim).filter(|s| !s.is_empty()) {
696        Some(suffix) => {
697            let (pre, post) = split_append_dashdash(suffix);
698            // Show whichever halves the yml actually populated, with
699            // the user slots interleaved at the right side per the
700            // merge contract.
701            let left = if pre.is_empty() {
702                "[<args>]".to_string()
703            } else {
704                format!("{pre} [<args>]")
705            };
706            let right = if post.is_empty() {
707                "[<runner-args>]".to_string()
708            } else {
709                format!("{post} [<runner-args>]")
710            };
711            format!("{run} {left} -- {right}")
712        }
713        None => format!("{run} [<args>] [-- <runner-args>]"),
714    };
715    if let Some(svc) = docker {
716        eprintln!(
717            "    {} {svc} -c {composed:?}",
718            style::dim("docker compose run --rm")
719        );
720    } else {
721        eprintln!("    {composed}");
722    }
723    eprintln!();
724    eprintln!(
725        "{} the first `--` separates fdl args from the run script; a second `--` splits cargo-side args from runner-side args.",
726        style::dim("Note:"),
727    );
728    eprintln!(
729        "{} `append:` is split on its own `--` and merged half-and-half; pass `--no-append` to drop it entirely.",
730        style::dim("Note:"),
731    );
732}
733
734/// Print help for a sub-command (its arguments, nested commands, and
735/// entry). Orchestrates the per-section helpers below.
736pub fn print_command_help(cmd_config: &CommandConfig, name: &str) {
737    let (presets, sub_cmds) = split_commands_by_kind(&cmd_config.commands);
738    let preset_slot = cmd_config.arg_name.as_deref().unwrap_or("preset");
739
740    print_title(cmd_config, name);
741    print_usage_line(cmd_config, name, &presets, &sub_cmds, preset_slot);
742    print_arguments_section(cmd_config, &presets, preset_slot);
743    print_sub_commands_section(&sub_cmds);
744    print_options_section(cmd_config);
745    print_entry_section(cmd_config);
746    print_defaults_section(cmd_config);
747}
748
749fn print_title(cmd_config: &CommandConfig, name: &str) {
750    if let Some(desc) = &cmd_config.description {
751        eprintln!("{} {desc}", style::bold(name));
752    } else {
753        eprintln!("{}", style::bold(name));
754    }
755}
756
757fn print_usage_line(
758    cmd_config: &CommandConfig,
759    name: &str,
760    presets: &CommandGroup,
761    sub_cmds: &CommandGroup,
762    preset_slot: &str,
763) {
764    // The first-positional slot reflects what is actually accepted here:
765    // preset name, sub-command name, or either.
766    let usage_tail = build_usage_tail(
767        cmd_config.schema.as_ref(),
768        !presets.is_empty(),
769        !sub_cmds.is_empty(),
770        preset_slot,
771    );
772    eprintln!();
773    eprintln!("{}:", style::yellow("Usage"));
774    eprintln!("    fdl {name}{usage_tail}");
775}
776
777fn print_arguments_section(
778    cmd_config: &CommandConfig,
779    presets: &CommandGroup,
780    preset_slot: &str,
781) {
782    // Schema-declared positionals (typed slots on the entry binary) and
783    // the preset slot (dispatched by fdl before the binary sees argv)
784    // both land in the first-positional position, so they share one
785    // section. Schema args render first; the preset slot with its
786    // value list follows.
787    let has_schema_args = cmd_config
788        .schema
789        .as_ref()
790        .is_some_and(|s| !s.args.is_empty());
791    if !has_schema_args && presets.is_empty() {
792        return;
793    }
794    eprintln!();
795    eprintln!("{}:", style::yellow("Arguments"));
796    if let Some(schema) = &cmd_config.schema {
797        for a in &schema.args {
798            eprintln!("    {}", format_arg(a));
799        }
800    }
801    if !presets.is_empty() {
802        let slot_label = format!("[<{preset_slot}>]");
803        eprintln!(
804            "    {}  Named preset, one of:",
805            style::green(&format!("{:<20}", slot_label))
806        );
807        for (pname, spec) in presets {
808            let desc = spec.description.as_deref().unwrap_or("-");
809            eprintln!(
810                "      {}  {}",
811                style::green(&format!("{:<18}", pname)),
812                desc
813            );
814        }
815    }
816}
817
818fn print_sub_commands_section(sub_cmds: &CommandGroup) {
819    // Run/Path kinds only — true sub-commands with their own behavior
820    // (an inline script or a nested fdl.yml).
821    if sub_cmds.is_empty() {
822        return;
823    }
824    eprintln!();
825    eprintln!("{}:", style::yellow("Commands"));
826    for (sub_name, sub_spec) in sub_cmds {
827        let desc = sub_spec.description.as_deref().unwrap_or("-");
828        eprintln!(
829            "    {}  {}",
830            style::green(&format!("{:<20}", sub_name)),
831            desc
832        );
833    }
834}
835
836fn print_options_section(cmd_config: &CommandConfig) {
837    // Schema-driven options. Renders only when a schema block is present
838    // in fdl.yaml; the "Defaults" section covers ddp/training/output.
839    let Some(schema) = &cmd_config.schema else {
840        return;
841    };
842    if schema.options.is_empty() {
843        return;
844    }
845    eprintln!();
846    eprintln!("{}:", style::yellow("Options"));
847    for (long, spec) in &schema.options {
848        for line in format_option(long, spec) {
849            eprintln!("    {line}");
850        }
851    }
852}
853
854fn print_entry_section(cmd_config: &CommandConfig) {
855    let Some(entry) = &cmd_config.entry else {
856        return;
857    };
858    eprintln!();
859    eprintln!("{}:", style::yellow("Entry"));
860    eprintln!("    {entry}");
861    if let Some(service) = &cmd_config.docker {
862        eprintln!(
863            "     {}",
864            style::dim(&format!("[docker: {service}]"))
865        );
866    }
867    eprintln!();
868    eprintln!(
869        "    Any extra {} are forwarded to the entry point.",
870        style::dim("[options]")
871    );
872}
873
874fn print_defaults_section(cmd_config: &CommandConfig) {
875    if cmd_config.ddp.is_none() && cmd_config.training.is_none() {
876        return;
877    }
878    eprintln!();
879    eprintln!("{}:", style::yellow("Defaults"));
880    if let Some(d) = &cmd_config.ddp {
881        if let Some(mode) = &d.mode {
882            eprintln!("    {}  {mode}", style::dim("ddp.mode"));
883        }
884        if let Some(anchor) = &d.anchor {
885            eprintln!("    {}  {}", style::dim("ddp.anchor"), value_to_string(anchor));
886        }
887    }
888    if let Some(t) = &cmd_config.training {
889        if let Some(e) = t.epochs {
890            eprintln!("    {}  {e}", style::dim("training.epochs"));
891        }
892        if let Some(bs) = t.batch_size {
893            eprintln!("    {}  {bs}", style::dim("training.batch_size"));
894        }
895        if let Some(lr) = t.lr {
896            eprintln!("    {}  {lr}", style::dim("training.lr"));
897        }
898        if let Some(seed) = t.seed {
899            eprintln!("    {}  {seed}", style::dim("training.seed"));
900        }
901    }
902}
903
904/// Print help for a named preset command nested inside a sub-command.
905pub fn print_preset_help(cmd_config: &CommandConfig, cmd_name: &str, preset_name: &str) {
906    let preset = match cmd_config.commands.get(preset_name) {
907        Some(s) => s,
908        None => {
909            eprintln!("unknown command: {preset_name}");
910            return;
911        }
912    };
913
914    // Title.
915    let desc = preset.description.as_deref().unwrap_or("(no description)");
916    eprintln!(
917        "{} {} {}",
918        style::bold(cmd_name),
919        style::green(preset_name),
920        desc
921    );
922
923    eprintln!();
924    eprintln!("{}:", style::yellow("Usage"));
925    eprintln!(
926        "    fdl {cmd_name} {preset_name} {}",
927        style::dim("[extra options]")
928    );
929
930    // Show the merged config that this preset produces.
931    let resolved = config::merge_preset(cmd_config, preset);
932
933    eprintln!();
934    eprintln!("{}:", style::yellow("Effective config"));
935
936    // DDP fields.
937    let d = &resolved.ddp;
938    print_config_field("ddp.mode", &d.mode);
939    print_config_value("ddp.anchor", &d.anchor);
940    print_config_field("ddp.max_anchor", &d.max_anchor);
941    print_config_field("ddp.overhead_target", &d.overhead_target);
942    print_config_field("ddp.divergence_threshold", &d.divergence_threshold);
943    print_config_value("ddp.max_batch_diff", &d.max_batch_diff);
944    print_config_field("ddp.max_grad_norm", &d.max_grad_norm);
945    if d.timeline == Some(true) {
946        eprintln!("    {}  true", style::dim("ddp.timeline"));
947    }
948
949    // Training fields.
950    let t = &resolved.training;
951    print_config_field("training.epochs", &t.epochs);
952    print_config_field("training.batch_size", &t.batch_size);
953    print_config_field("training.batches_per_epoch", &t.batches_per_epoch);
954    print_config_field("training.lr", &t.lr);
955    print_config_field("training.seed", &t.seed);
956
957    // Output fields.
958    let o = &resolved.output;
959    print_config_field("output.dir", &o.dir);
960    print_config_field("output.monitor", &o.monitor);
961
962    // Pass-through options.
963    if !resolved.options.is_empty() {
964        eprintln!();
965        eprintln!("{}:", style::yellow("Options"));
966        for (key, val) in &resolved.options {
967            eprintln!(
968                "    {}  {}",
969                style::green(&format!("--{}", key.replace('_', "-"))),
970                value_to_string(val)
971            );
972        }
973    }
974
975    // Show the effective command.
976    if let Some(entry) = &cmd_config.entry {
977        let args = config_to_args(&resolved);
978        let args_str = args.join(" ");
979        let docker_info = cmd_config
980            .docker
981            .as_ref()
982            .map(|s| format!("[{s}] ", ))
983            .unwrap_or_default();
984
985        eprintln!();
986        eprintln!("{}:", style::yellow("Effective command"));
987        eprintln!(
988            "    {}{}{}",
989            style::dim(&docker_info),
990            entry,
991            if args_str.is_empty() {
992                String::new()
993            } else {
994                format!(" {args_str}")
995            }
996        );
997    }
998
999    eprintln!();
1000    eprintln!(
1001        "Extra {} after the command name are appended to the entry.",
1002        style::dim("[options]")
1003    );
1004}
1005
1006fn print_config_field<T: std::fmt::Display>(label: &str, val: &Option<T>) {
1007    if let Some(v) = val {
1008        eprintln!("    {}  {v}", style::dim(label));
1009    }
1010}
1011
1012fn print_config_value(label: &str, val: &Option<serde_json::Value>) {
1013    if let Some(v) = val {
1014        if !v.is_null() {
1015            eprintln!("    {}  {}", style::dim(label), value_to_string(v));
1016        }
1017    }
1018}
1019
1020/// Print the project help with scripts and commands.
1021pub fn print_project_help(
1022    project: &config::ProjectConfig,
1023    project_root: &Path,
1024    active_env: Option<&str>,
1025) {
1026    let visible_builtins = builtins::visible_top_level();
1027    if let Some(desc) = &project.description {
1028        eprintln!("{} {}", style::bold("fdl"), desc);
1029    } else {
1030        eprintln!("{} {}", style::bold("fdl"), env!("CARGO_PKG_VERSION"));
1031    }
1032
1033    eprintln!();
1034    eprintln!("{}:", style::yellow("Usage"));
1035    eprintln!(
1036        "    fdl {} {}",
1037        style::dim("<command>"),
1038        style::dim("[options]")
1039    );
1040
1041    eprintln!();
1042    eprintln!("{}:", style::yellow("Options"));
1043    eprintln!(
1044        "    {}  Show this help",
1045        style::green(&format!("{:<18}", "-h, --help"))
1046    );
1047    eprintln!(
1048        "    {}  Show version",
1049        style::green(&format!("{:<18}", "-V, --version"))
1050    );
1051    eprintln!(
1052        "    {}  Use fdl.<name>.yml overlay (also: FDL_ENV=<name>)",
1053        style::green(&format!("{:<18}", "--env <name>"))
1054    );
1055    eprintln!(
1056        "    {}  Verbose output",
1057        style::green(&format!("{:<18}", "-v"))
1058    );
1059    eprintln!(
1060        "    {}  Debug output",
1061        style::green(&format!("{:<18}", "-vv"))
1062    );
1063    eprintln!(
1064        "    {}  Trace output (maximum detail)",
1065        style::green(&format!("{:<18}", "-vvv"))
1066    );
1067    eprintln!(
1068        "    {}  Suppress non-error output",
1069        style::green(&format!("{:<18}", "-q, --quiet"))
1070    );
1071    eprintln!(
1072        "    {}  Force ANSI color (bypass TTY / NO_COLOR detection)",
1073        style::green(&format!("{:<18}", "--ansi"))
1074    );
1075    eprintln!(
1076        "    {}  Disable ANSI color output",
1077        style::green(&format!("{:<18}", "--no-ansi"))
1078    );
1079    eprintln!(
1080        "    {}  Drop a run command's `append:` suffix",
1081        style::green(&format!("{:<18}", "--no-append"))
1082    );
1083
1084    // Built-in commands.
1085    eprintln!();
1086    eprintln!("{}:", style::yellow("Built-in"));
1087    for (name, desc) in &visible_builtins {
1088        eprintln!("    {}  {desc}", style::green(&format!("{:<18}", name)));
1089    }
1090
1091    // Commands: unified section. Each entry in `project.commands` is one
1092    // of: an inline `run:` script, a `path:` (or convention-default)
1093    // pointer to a child fdl.yml, or — at nested levels only — an inline
1094    // preset. Descriptions come from the `CommandSpec`; for `path:`
1095    // commands missing their own description, fall back to loading the
1096    // child fdl.yml's description.
1097    if !project.commands.is_empty() {
1098        eprintln!();
1099        eprintln!("{}:", style::yellow("Commands"));
1100        for (name, spec) in &project.commands {
1101            let desc: String = match spec.description.clone() {
1102                Some(d) => d,
1103                None => {
1104                    // For path-kind entries, fall back to the child config's
1105                    // own description so `commands: { ddp-bench: }` still
1106                    // shows a useful blurb.
1107                    let is_path_kind = spec.run.is_none();
1108                    if is_path_kind {
1109                        let child_dir = spec.resolve_path(name, project_root);
1110                        config::load_command_with_env(&child_dir, active_env)
1111                            .ok()
1112                            .and_then(|c| c.description)
1113                            .unwrap_or_else(|| "(sub-command)".into())
1114                    } else {
1115                        spec.run
1116                            .as_deref()
1117                            .unwrap_or("(command)")
1118                            .to_string()
1119                    }
1120                }
1121            };
1122            eprintln!("    {}  {desc}", style::green(&format!("{:<18}", name)));
1123        }
1124    }
1125
1126    // Available environments (sibling fdl.<env>.yml files at project root).
1127    if let Some(base_config) = config::find_config(project_root) {
1128        let envs = crate::overlay::list_envs(&base_config);
1129        if !envs.is_empty() {
1130            eprintln!();
1131            eprintln!("{}:", style::yellow("Environments"));
1132            for e in &envs {
1133                let active_marker = if Some(e.as_str()) == active_env {
1134                    style::green(" (active)")
1135                } else {
1136                    String::new()
1137                };
1138                eprintln!(
1139                    "    {}  Overlay from fdl.{}.yml{active_marker}",
1140                    style::green(&format!("{:<18}", e)),
1141                    e
1142                );
1143            }
1144            eprintln!();
1145            eprintln!(
1146                "Use {} to run a command with an environment overlay.",
1147                style::dim("fdl <env> <command>")
1148            );
1149        }
1150    }
1151
1152    eprintln!();
1153    eprintln!(
1154        "Use {} for more information on a command.",
1155        style::dim("fdl <command> -h")
1156    );
1157}
1158
1159// ── Schema-driven help helpers ──────────────────────────────────────────
1160
1161/// Build the part of `fdl <cmd>...` after the command name: positionals
1162/// rendered as `<name>` (required) or `[<name>]` (optional), plus a slot
1163/// for the first-positional picker — `[<preset>]` when only presets exist,
1164/// `[<command>]` when only sub-commands exist, `[<preset>|<command>]` when
1165/// both — and `[options]`. The preset placeholder is customisable per
1166/// sub-command via `arg-name:`.
1167fn build_usage_tail(
1168    schema: Option<&Schema>,
1169    has_presets: bool,
1170    has_sub_commands: bool,
1171    preset_slot: &str,
1172) -> String {
1173    let mut parts = String::new();
1174    let slot = match (has_presets, has_sub_commands) {
1175        (true, false) => Some(format!("[<{preset_slot}>]")),
1176        (false, true) => Some("[<command>]".to_string()),
1177        (true, true) => Some(format!("[<{preset_slot}>|<command>]")),
1178        (false, false) => None,
1179    };
1180    if let Some(s) = slot {
1181        parts.push(' ');
1182        parts.push_str(&style::dim(&s));
1183    }
1184    if let Some(s) = schema {
1185        for a in &s.args {
1186            parts.push(' ');
1187            parts.push_str(&format_arg_usage(a));
1188        }
1189    }
1190    parts.push(' ');
1191    parts.push_str(&style::dim("[options]"));
1192    parts
1193}
1194
1195type CommandGroup = Vec<(String, crate::config::CommandSpec)>;
1196
1197/// Partition a `commands:` map into (presets, sub-commands) by resolved
1198/// `CommandKind`. Entries whose `kind()` errors (both run and path set)
1199/// are treated as sub-commands so they still render somewhere — the
1200/// error surfaces when the user tries to dispatch them.
1201fn split_commands_by_kind(
1202    commands: &BTreeMap<String, crate::config::CommandSpec>,
1203) -> (CommandGroup, CommandGroup) {
1204    use crate::config::CommandKind;
1205    let mut presets = Vec::new();
1206    let mut sub_cmds = Vec::new();
1207    for (k, v) in commands {
1208        match v.kind() {
1209            Ok(CommandKind::Preset) => presets.push((k.clone(), v.clone())),
1210            _ => sub_cmds.push((k.clone(), v.clone())),
1211        }
1212    }
1213    (presets, sub_cmds)
1214}
1215
1216fn format_arg_usage(a: &ArgSpec) -> String {
1217    let suffix = if a.variadic { "..." } else { "" };
1218    let core = format!("<{}>{suffix}", a.name);
1219    if a.required && a.default.is_none() {
1220        style::green(&core)
1221    } else {
1222        style::dim(&format!("[{core}]"))
1223    }
1224}
1225
1226fn format_arg(a: &ArgSpec) -> String {
1227    let mut left = format_arg_usage(a);
1228    // Target ~22-char visual width for the label column.
1229    let visible = visible_width(&left);
1230    if visible < 22 {
1231        for _ in 0..(22 - visible) {
1232            left.push(' ');
1233        }
1234    } else {
1235        left.push(' ');
1236    }
1237    let mut line = left;
1238    line.push_str(a.description.as_deref().unwrap_or("-"));
1239    append_default_and_choices(&mut line, &a.default, &a.choices, &a.ty);
1240    line
1241}
1242
1243/// Format an option row. Returns one or more lines; `choices` list wraps
1244/// onto a second indented line when present, to keep the main row readable.
1245fn format_option(long: &str, spec: &OptionSpec) -> Vec<String> {
1246    let flag = match &spec.short {
1247        Some(s) => format!("-{s}, --{long}"),
1248        None => format!("    --{long}"),
1249    };
1250    let placeholder = option_placeholder(&spec.ty);
1251    let left = if placeholder.is_empty() {
1252        style::green(&flag)
1253    } else {
1254        style::green(&format!("{flag} {placeholder}"))
1255    };
1256    let visible = visible_width_for(&flag, placeholder);
1257
1258    // Pad to 30 columns for alignment.
1259    let pad = if visible < 30 { 30 - visible } else { 1 };
1260    let mut line = format!("{left}{}", " ".repeat(pad));
1261    line.push_str(spec.description.as_deref().unwrap_or("-"));
1262    append_default_and_choices(&mut line, &spec.default, &spec.choices, &spec.ty);
1263
1264    let mut out = vec![line];
1265    if let Some(env) = &spec.env {
1266        out.push(format!("{}  {}", " ".repeat(32), style::dim(&format!("[env: {env}]"))));
1267    }
1268    out
1269}
1270
1271fn option_placeholder(ty: &str) -> &'static str {
1272    match ty {
1273        "bool" => "",
1274        "int" => "<N>",
1275        "float" => "<F>",
1276        "path" => "<PATH>",
1277        "list[path]" => "<PATH>...",
1278        t if t.starts_with("list[") => "<VALUE>...",
1279        _ => "<VALUE>",
1280    }
1281}
1282
1283fn append_default_and_choices(
1284    line: &mut String,
1285    default: &Option<serde_json::Value>,
1286    choices: &Option<Vec<serde_json::Value>>,
1287    ty: &str,
1288) {
1289    if let Some(d) = default {
1290        // Skip noisy defaults: bool false, empty list, null.
1291        let is_empty_list = matches!(d, serde_json::Value::Array(a) if a.is_empty());
1292        let is_false = matches!(d, serde_json::Value::Bool(false));
1293        if !d.is_null() && !is_false && !is_empty_list {
1294            line.push_str(&format!(" {}", style::dim(&format!("[default: {}]", format_value(d)))));
1295        }
1296    }
1297    if let Some(choices) = choices {
1298        if !choices.is_empty() {
1299            let list = choices
1300                .iter()
1301                .map(format_value)
1302                .collect::<Vec<_>>()
1303                .join(", ");
1304            line.push_str(&format!(" {}", style::dim(&format!("[possible: {list}]"))));
1305        }
1306    }
1307    // Annotate list types so users know about repeat/comma semantics.
1308    if ty.starts_with("list[") {
1309        line.push_str(&format!(" {}", style::dim("(repeat or comma-separate)")));
1310    }
1311}
1312
1313fn format_value(v: &serde_json::Value) -> String {
1314    match v {
1315        serde_json::Value::String(s) => s.clone(),
1316        other => other.to_string(),
1317    }
1318}
1319
1320/// Rough visible width helper: styled strings wrap their visible content
1321/// in ANSI escapes, so we use the unstyled inputs we started from.
1322fn visible_width(s: &str) -> usize {
1323    // The inputs we pass here come from pre-styling helpers that already
1324    // know the raw length. Strip ANSI to be safe.
1325    strip_ansi(s).chars().count()
1326}
1327
1328fn visible_width_for(flag: &str, placeholder: &str) -> usize {
1329    if placeholder.is_empty() {
1330        flag.chars().count()
1331    } else {
1332        flag.chars().count() + 1 + placeholder.chars().count()
1333    }
1334}
1335
1336fn strip_ansi(s: &str) -> String {
1337    let mut out = String::with_capacity(s.len());
1338    let mut chars = s.chars().peekable();
1339    while let Some(c) = chars.next() {
1340        if c == '\x1b' && chars.peek() == Some(&'[') {
1341            chars.next();
1342            for c in chars.by_ref() {
1343                if c.is_ascii_alphabetic() {
1344                    break;
1345                }
1346            }
1347        } else {
1348            out.push(c);
1349        }
1350    }
1351    out
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357
1358    #[test]
1359    fn posix_quote_passes_safe_strings_through() {
1360        assert_eq!(posix_quote("hello"), "hello");
1361        assert_eq!(posix_quote("-p"), "-p");
1362        assert_eq!(posix_quote("flodl-hf"), "flodl-hf");
1363        assert_eq!(posix_quote("a/b.c"), "a/b.c");
1364        assert_eq!(posix_quote("KEY=val"), "KEY=val");
1365    }
1366
1367    #[test]
1368    fn posix_quote_wraps_unsafe_strings() {
1369        assert_eq!(posix_quote(""), "''");
1370        assert_eq!(posix_quote("foo bar"), "'foo bar'");
1371        assert_eq!(posix_quote("a$b"), "'a$b'");
1372        assert_eq!(posix_quote("a\"b"), "'a\"b'");
1373    }
1374
1375    #[test]
1376    fn posix_quote_escapes_embedded_single_quotes() {
1377        assert_eq!(posix_quote("it's"), "'it'\\''s'");
1378        assert_eq!(posix_quote("'"), "''\\'''");
1379    }
1380
1381    #[test]
1382    fn compose_run_command_no_extras_passes_run_through() {
1383        assert_eq!(compose_run_command("echo hello", &[], None), "echo hello");
1384    }
1385
1386    #[test]
1387    fn compose_run_command_inserts_user_args_between_run_and_append() {
1388        let user = vec!["-p".to_string(), "flodl-hf".to_string()];
1389        let out = compose_run_command("cargo test live", &user, Some("-- --nocapture --ignored"));
1390        assert_eq!(out, "cargo test live -p flodl-hf -- --nocapture --ignored");
1391    }
1392
1393    #[test]
1394    fn compose_run_command_quotes_user_args_with_spaces() {
1395        let user = vec!["--name".to_string(), "with space".to_string()];
1396        let out = compose_run_command("cmd", &user, None);
1397        assert_eq!(out, "cmd --name 'with space'");
1398    }
1399
1400    #[test]
1401    fn compose_run_command_omits_empty_append() {
1402        let out = compose_run_command("cmd", &["arg".to_string()], Some(""));
1403        assert_eq!(out, "cmd arg");
1404        let out2 = compose_run_command("cmd", &["arg".to_string()], Some("   "));
1405        assert_eq!(out2, "cmd arg");
1406    }
1407
1408    #[test]
1409    fn compose_run_command_user_double_dash_threads_runner_args() {
1410        let user = vec![
1411            "-p".to_string(),
1412            "foo".to_string(),
1413            "--".to_string(),
1414            "--ignored".to_string(),
1415        ];
1416        let out = compose_run_command("cargo test", &user, Some("-- --nocapture"));
1417        assert_eq!(out, "cargo test -p foo -- --nocapture --ignored");
1418    }
1419
1420    #[test]
1421    fn compose_run_command_user_double_dash_without_append() {
1422        let user = vec![
1423            "-p".to_string(),
1424            "foo".to_string(),
1425            "--".to_string(),
1426            "--ignored".to_string(),
1427        ];
1428        let out = compose_run_command("cargo test", &user, None);
1429        assert_eq!(out, "cargo test -p foo -- --ignored");
1430    }
1431
1432    #[test]
1433    fn compose_run_command_append_with_pre_and_post_halves() {
1434        let out = compose_run_command("cmd", &[], Some("--foo -- --bar"));
1435        assert_eq!(out, "cmd --foo -- --bar");
1436    }
1437
1438    #[test]
1439    fn compose_run_command_append_pre_only_no_separator() {
1440        // append carries a default flag with no `--` token; user supplies
1441        // an override. Defaults seed first, user wins via last-flag-wins.
1442        let user = vec!["--ansi".to_string()];
1443        let out = compose_run_command("cmd", &user, Some("--no-ansi"));
1444        assert_eq!(out, "cmd --no-ansi --ansi");
1445    }
1446
1447    #[test]
1448    fn compose_run_command_user_only_double_dash_emits_separator() {
1449        let user = vec!["--".to_string(), "--list".to_string()];
1450        let out = compose_run_command("cargo test", &user, None);
1451        assert_eq!(out, "cargo test -- --list");
1452    }
1453
1454    #[test]
1455    fn compose_run_command_append_full_split_with_user_both_sides() {
1456        let user = vec![
1457            "-p".to_string(),
1458            "foo".to_string(),
1459            "--".to_string(),
1460            "--ignored".to_string(),
1461        ];
1462        let out = compose_run_command(
1463            "cargo test",
1464            &user,
1465            Some("--release -- --nocapture"),
1466        );
1467        assert_eq!(
1468            out,
1469            "cargo test --release -p foo -- --nocapture --ignored"
1470        );
1471    }
1472
1473    #[test]
1474    fn split_append_dashdash_handles_edges() {
1475        assert_eq!(
1476            split_append_dashdash("-- --nocapture"),
1477            (String::new(), "--nocapture".to_string())
1478        );
1479        assert_eq!(
1480            split_append_dashdash("--foo -- --bar"),
1481            ("--foo".to_string(), "--bar".to_string())
1482        );
1483        assert_eq!(
1484            split_append_dashdash("--foo --"),
1485            ("--foo".to_string(), String::new())
1486        );
1487        assert_eq!(
1488            split_append_dashdash("--"),
1489            (String::new(), String::new())
1490        );
1491        assert_eq!(
1492            split_append_dashdash("--foo"),
1493            ("--foo".to_string(), String::new())
1494        );
1495        assert_eq!(
1496            split_append_dashdash(""),
1497            (String::new(), String::new())
1498        );
1499    }
1500}