1use std::collections::BTreeMap;
8use std::path::Path;
9use std::process::{ExitCode, Stdio};
10
11use crate::builtins;
12use crate::cli_error;
13use crate::config::{self, ArgSpec, CommandConfig, OptionSpec, ResolvedConfig, Schema};
14use crate::libtorch;
15use crate::style;
16
17pub fn config_to_args(resolved: &ResolvedConfig) -> Vec<String> {
21 let mut args = Vec::new();
22
23 let d = &resolved.ddp;
25 push_opt(&mut args, "--mode", &d.mode);
26 push_opt(&mut args, "--policy", &d.policy);
27 push_opt(&mut args, "--backend", &d.backend);
28 push_value(&mut args, "--anchor", &d.anchor);
29 push_num(&mut args, "--max-anchor", &d.max_anchor);
30 push_float(&mut args, "--overhead-target", &d.overhead_target);
31 push_float(&mut args, "--divergence-threshold", &d.divergence_threshold);
32 push_value(&mut args, "--max-batch-diff", &d.max_batch_diff);
33 push_float(&mut args, "--max-grad-norm", &d.max_grad_norm);
34 push_num(&mut args, "--snapshot-timeout", &d.snapshot_timeout);
35 push_num(&mut args, "--checkpoint-every", &d.checkpoint_every);
36 push_value(&mut args, "--progressive", &d.progressive);
37 if let Some(hint) = &d.speed_hint {
38 args.push("--speed-hint".into());
39 args.push(format!("{}:{}", hint.slow_rank, hint.ratio));
40 }
41 if let Some(ratios) = &d.partition_ratios {
42 let s: Vec<String> = ratios.iter().map(|r| format!("{r}")).collect();
43 args.push("--partition-ratios".into());
44 args.push(s.join(","));
45 }
46 if let Some(ratio) = d.lr_scale_ratio {
47 args.push("--lr-scale-ratio".into());
48 args.push(format!("{ratio}"));
49 }
50 if d.timeline == Some(true) {
51 args.push("--timeline".into());
52 }
53
54 let t = &resolved.training;
56 push_num(&mut args, "--epochs", &t.epochs);
57 push_num(&mut args, "--batch-size", &t.batch_size);
58 push_num(&mut args, "--batches", &t.batches_per_epoch);
59 push_float(&mut args, "--lr", &t.lr);
60 push_num(&mut args, "--seed", &t.seed);
61
62 let o = &resolved.output;
64 push_opt(&mut args, "--output", &o.dir);
65 push_num(&mut args, "--monitor", &o.monitor);
66
67 for (key, val) in &resolved.options {
69 let flag = format!("--{}", key.replace('_', "-"));
70 match val {
71 serde_json::Value::Bool(true) => args.push(flag),
72 serde_json::Value::Bool(false) => {}
73 serde_json::Value::Null => {}
74 other => {
75 args.push(flag);
76 args.push(value_to_string(other));
77 }
78 }
79 }
80
81 args
82}
83
84fn push_opt(args: &mut Vec<String>, flag: &str, val: &Option<String>) {
85 if let Some(v) = val {
86 args.push(flag.into());
87 args.push(v.clone());
88 }
89}
90
91fn push_num<T: std::fmt::Display>(args: &mut Vec<String>, flag: &str, val: &Option<T>) {
92 if let Some(v) = val {
93 args.push(flag.into());
94 args.push(v.to_string());
95 }
96}
97
98fn push_float(args: &mut Vec<String>, flag: &str, val: &Option<f64>) {
99 if let Some(v) = val {
100 args.push(flag.into());
101 args.push(format!("{v}"));
102 }
103}
104
105fn push_value(args: &mut Vec<String>, flag: &str, val: &Option<serde_json::Value>) {
106 if let Some(v) = val {
107 match v {
108 serde_json::Value::Null => {}
109 other => {
110 args.push(flag.into());
111 args.push(value_to_string(other));
112 }
113 }
114 }
115}
116
117fn value_to_string(v: &serde_json::Value) -> String {
118 match v {
119 serde_json::Value::String(s) => s.clone(),
120 serde_json::Value::Number(n) => n.to_string(),
121 serde_json::Value::Bool(b) => b.to_string(),
122 other => other.to_string(),
123 }
124}
125
126fn inside_docker() -> bool {
130 Path::new("/.dockerenv").exists()
131}
132
133fn libtorch_env(project_root: &Path) -> Vec<(String, String)> {
138 let mut env = Vec::new();
139
140 env.push((
142 "LIBTORCH_CPU_PATH".into(),
143 "./libtorch/precompiled/cpu".into(),
144 ));
145
146 if let Some(info) = libtorch::detect::read_active(project_root) {
148 let host_path = format!("./libtorch/{}", info.path);
149 env.push(("LIBTORCH_HOST_PATH".into(), host_path));
150
151 if let Some(cuda) = &info.cuda_version {
153 if cuda != "none" {
154 let cuda_version = if cuda.matches('.').count() < 2 {
155 format!("{cuda}.0")
156 } else {
157 cuda.clone()
158 };
159 let cuda_tag = cuda_version
160 .splitn(3, '.')
161 .take(2)
162 .collect::<Vec<_>>()
163 .join(".");
164 env.push(("CUDA_VERSION".into(), cuda_version));
165 env.push(("CUDA_TAG".into(), cuda_tag));
166 }
167 }
168 }
169
170 env
171}
172
173fn spawn_docker_shell(command: &str, project_root: &Path) -> ExitCode {
179 let env_vars = libtorch_env(project_root);
180
181 let mut cmd = std::process::Command::new("sh");
182 cmd.args(["-c", command])
183 .current_dir(project_root)
184 .stdout(Stdio::inherit())
185 .stderr(Stdio::inherit())
186 .stdin(Stdio::inherit());
187
188 for (key, val) in &env_vars {
189 cmd.env(key, val);
190 }
191
192 match cmd.status() {
193 Ok(s) if s.success() => ExitCode::SUCCESS,
194 Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
195 Err(e) => {
196 cli_error!("{e}");
197 ExitCode::FAILURE
198 }
199 }
200}
201
202pub fn exec_script(command: &str, docker_service: Option<&str>, cwd: &Path) -> ExitCode {
206 match docker_service {
207 Some(service) if !inside_docker() => {
208 let docker_cmd =
209 format!("docker compose run --rm {service} bash -c \"{command}\"");
210 spawn_docker_shell(&docker_cmd, cwd)
211 }
212 _ => {
213 let (shell, flag) = if cfg!(target_os = "windows") {
214 ("cmd", "/C")
215 } else {
216 ("sh", "-c")
217 };
218
219 match std::process::Command::new(shell)
220 .args([flag, command])
221 .current_dir(cwd)
222 .stdout(Stdio::inherit())
223 .stderr(Stdio::inherit())
224 .stdin(Stdio::inherit())
225 .status()
226 {
227 Ok(s) if s.success() => ExitCode::SUCCESS,
228 Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
229 Err(e) => {
230 cli_error!("{e}");
231 ExitCode::FAILURE
232 }
233 }
234 }
235 }
236}
237
238pub fn exec_command(
245 cmd_config: &CommandConfig,
246 preset_name: Option<&str>,
247 extra_args: &[String],
248 cmd_dir: &Path,
249 project_root: &Path,
250) -> ExitCode {
251 let entry = match &cmd_config.entry {
252 Some(e) => e.as_str(),
253 None => {
254 eprintln!(
255 "error: no entry point defined in {}/fdl.yaml",
256 cmd_dir.display()
257 );
258 return ExitCode::FAILURE;
259 }
260 };
261
262 if let Some(schema) = &cmd_config.schema {
271 if let Err(e) = config::validate_tail(extra_args, schema) {
272 cli_error!("{e}");
273 return ExitCode::FAILURE;
274 }
275 }
276
277 let resolved = match preset_name {
279 Some(name) => match cmd_config.commands.get(name) {
280 Some(preset) => {
281 if let Some(schema) = &cmd_config.schema {
285 if let Err(e) = config::validate_preset_for_exec(name, preset, schema) {
286 cli_error!("{e}");
287 return ExitCode::FAILURE;
288 }
289 }
290 config::merge_preset(cmd_config, preset)
291 }
292 None => {
293 cli_error!("unknown command '{name}'");
294 eprintln!();
295 print_command_help(cmd_config, "");
296 return ExitCode::FAILURE;
297 }
298 },
299 None => config::defaults_only(cmd_config),
300 };
301
302 let mut args = config_to_args(&resolved);
304
305 args.extend(extra_args.iter().cloned());
307
308 let use_docker = cmd_config.docker.is_some() && !inside_docker();
310
311 if use_docker {
312 let service = cmd_config.docker.as_deref().unwrap();
313 let workdir = cmd_dir
314 .strip_prefix(project_root)
315 .unwrap_or(cmd_dir)
316 .to_string_lossy();
317
318 let args_str = shell_join(&args);
320 let inner = if workdir.is_empty() || workdir == "." {
321 format!("{entry} {args_str}")
322 } else {
323 format!("cd {workdir} && {entry} {args_str}")
324 };
325
326 if preset_name.is_some() {
327 eprintln!("fdl: [{service}] {inner}");
328 }
329
330 let docker_cmd = format!("docker compose run --rm {service} bash -c \"{inner}\"");
332 spawn_docker_shell(&docker_cmd, project_root)
333 } else {
334 let parts: Vec<&str> = entry.split_whitespace().collect();
336 if parts.is_empty() {
337 cli_error!("empty entry point");
338 return ExitCode::FAILURE;
339 }
340 let program = parts[0];
341 let entry_args = &parts[1..];
342
343 if preset_name.is_some() {
344 let preview: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
345 eprintln!("fdl: {entry} {}", preview.join(" "));
346 }
347
348 match std::process::Command::new(program)
349 .args(entry_args)
350 .args(&args)
351 .current_dir(cmd_dir)
352 .stdout(Stdio::inherit())
353 .stderr(Stdio::inherit())
354 .stdin(Stdio::inherit())
355 .status()
356 {
357 Ok(s) if s.success() => ExitCode::SUCCESS,
358 Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
359 Err(e) => {
360 cli_error!("failed to execute '{program}': {e}");
361 ExitCode::FAILURE
362 }
363 }
364 }
365}
366
367fn shell_join(args: &[String]) -> String {
369 args.iter()
370 .map(|a| {
371 if a.contains(' ') || a.contains('"') || a.is_empty() {
372 format!("'{}'", a.replace('\'', "'\\''"))
373 } else {
374 a.clone()
375 }
376 })
377 .collect::<Vec<_>>()
378 .join(" ")
379}
380
381pub fn print_run_help(name: &str, description: Option<&str>, run: &str, docker: Option<&str>) {
387 if let Some(desc) = description {
388 eprintln!("{} {desc}", style::bold(name));
389 } else {
390 eprintln!("{}", style::bold(name));
391 }
392 eprintln!();
393 eprintln!("{}:", style::yellow("Usage"));
394 eprintln!(" fdl {name}");
395 eprintln!();
396 eprintln!("{}:", style::yellow("Runs"));
397 if let Some(svc) = docker {
398 eprintln!(" {} {svc} -c {run:?}", style::dim("docker compose run --rm"));
399 } else {
400 eprintln!(" {run}");
401 }
402 eprintln!();
403 eprintln!(
404 "{} run:-kind commands do not forward argv; the script runs as declared.",
405 style::dim("Note:"),
406 );
407}
408
409pub fn print_command_help(cmd_config: &CommandConfig, name: &str) {
412 let (presets, sub_cmds) = split_commands_by_kind(&cmd_config.commands);
413 let preset_slot = cmd_config.arg_name.as_deref().unwrap_or("preset");
414
415 print_title(cmd_config, name);
416 print_usage_line(cmd_config, name, &presets, &sub_cmds, preset_slot);
417 print_arguments_section(cmd_config, &presets, preset_slot);
418 print_sub_commands_section(&sub_cmds);
419 print_options_section(cmd_config);
420 print_entry_section(cmd_config);
421 print_defaults_section(cmd_config);
422}
423
424fn print_title(cmd_config: &CommandConfig, name: &str) {
425 if let Some(desc) = &cmd_config.description {
426 eprintln!("{} {desc}", style::bold(name));
427 } else {
428 eprintln!("{}", style::bold(name));
429 }
430}
431
432fn print_usage_line(
433 cmd_config: &CommandConfig,
434 name: &str,
435 presets: &CommandGroup,
436 sub_cmds: &CommandGroup,
437 preset_slot: &str,
438) {
439 let usage_tail = build_usage_tail(
442 cmd_config.schema.as_ref(),
443 !presets.is_empty(),
444 !sub_cmds.is_empty(),
445 preset_slot,
446 );
447 eprintln!();
448 eprintln!("{}:", style::yellow("Usage"));
449 eprintln!(" fdl {name}{usage_tail}");
450}
451
452fn print_arguments_section(
453 cmd_config: &CommandConfig,
454 presets: &CommandGroup,
455 preset_slot: &str,
456) {
457 let has_schema_args = cmd_config
463 .schema
464 .as_ref()
465 .is_some_and(|s| !s.args.is_empty());
466 if !has_schema_args && presets.is_empty() {
467 return;
468 }
469 eprintln!();
470 eprintln!("{}:", style::yellow("Arguments"));
471 if let Some(schema) = &cmd_config.schema {
472 for a in &schema.args {
473 eprintln!(" {}", format_arg(a));
474 }
475 }
476 if !presets.is_empty() {
477 let slot_label = format!("[<{preset_slot}>]");
478 eprintln!(
479 " {} Named preset, one of:",
480 style::green(&format!("{:<20}", slot_label))
481 );
482 for (pname, spec) in presets {
483 let desc = spec.description.as_deref().unwrap_or("-");
484 eprintln!(
485 " {} {}",
486 style::green(&format!("{:<18}", pname)),
487 desc
488 );
489 }
490 }
491}
492
493fn print_sub_commands_section(sub_cmds: &CommandGroup) {
494 if sub_cmds.is_empty() {
497 return;
498 }
499 eprintln!();
500 eprintln!("{}:", style::yellow("Commands"));
501 for (sub_name, sub_spec) in sub_cmds {
502 let desc = sub_spec.description.as_deref().unwrap_or("-");
503 eprintln!(
504 " {} {}",
505 style::green(&format!("{:<20}", sub_name)),
506 desc
507 );
508 }
509}
510
511fn print_options_section(cmd_config: &CommandConfig) {
512 let Some(schema) = &cmd_config.schema else {
515 return;
516 };
517 if schema.options.is_empty() {
518 return;
519 }
520 eprintln!();
521 eprintln!("{}:", style::yellow("Options"));
522 for (long, spec) in &schema.options {
523 for line in format_option(long, spec) {
524 eprintln!(" {line}");
525 }
526 }
527}
528
529fn print_entry_section(cmd_config: &CommandConfig) {
530 let Some(entry) = &cmd_config.entry else {
531 return;
532 };
533 eprintln!();
534 eprintln!("{}:", style::yellow("Entry"));
535 eprintln!(" {entry}");
536 if let Some(service) = &cmd_config.docker {
537 eprintln!(
538 " {}",
539 style::dim(&format!("[docker: {service}]"))
540 );
541 }
542 eprintln!();
543 eprintln!(
544 " Any extra {} are forwarded to the entry point.",
545 style::dim("[options]")
546 );
547}
548
549fn print_defaults_section(cmd_config: &CommandConfig) {
550 if cmd_config.ddp.is_none() && cmd_config.training.is_none() {
551 return;
552 }
553 eprintln!();
554 eprintln!("{}:", style::yellow("Defaults"));
555 if let Some(d) = &cmd_config.ddp {
556 if let Some(mode) = &d.mode {
557 eprintln!(" {} {mode}", style::dim("ddp.mode"));
558 }
559 if let Some(anchor) = &d.anchor {
560 eprintln!(" {} {}", style::dim("ddp.anchor"), value_to_string(anchor));
561 }
562 }
563 if let Some(t) = &cmd_config.training {
564 if let Some(e) = t.epochs {
565 eprintln!(" {} {e}", style::dim("training.epochs"));
566 }
567 if let Some(bs) = t.batch_size {
568 eprintln!(" {} {bs}", style::dim("training.batch_size"));
569 }
570 if let Some(lr) = t.lr {
571 eprintln!(" {} {lr}", style::dim("training.lr"));
572 }
573 if let Some(seed) = t.seed {
574 eprintln!(" {} {seed}", style::dim("training.seed"));
575 }
576 }
577}
578
579pub fn print_preset_help(cmd_config: &CommandConfig, cmd_name: &str, preset_name: &str) {
581 let preset = match cmd_config.commands.get(preset_name) {
582 Some(s) => s,
583 None => {
584 eprintln!("unknown command: {preset_name}");
585 return;
586 }
587 };
588
589 let desc = preset.description.as_deref().unwrap_or("(no description)");
591 eprintln!(
592 "{} {} {}",
593 style::bold(cmd_name),
594 style::green(preset_name),
595 desc
596 );
597
598 eprintln!();
599 eprintln!("{}:", style::yellow("Usage"));
600 eprintln!(
601 " fdl {cmd_name} {preset_name} {}",
602 style::dim("[extra options]")
603 );
604
605 let resolved = config::merge_preset(cmd_config, preset);
607
608 eprintln!();
609 eprintln!("{}:", style::yellow("Effective config"));
610
611 let d = &resolved.ddp;
613 print_config_field("ddp.mode", &d.mode);
614 print_config_value("ddp.anchor", &d.anchor);
615 print_config_field("ddp.max_anchor", &d.max_anchor);
616 print_config_field("ddp.overhead_target", &d.overhead_target);
617 print_config_field("ddp.divergence_threshold", &d.divergence_threshold);
618 print_config_value("ddp.max_batch_diff", &d.max_batch_diff);
619 print_config_field("ddp.max_grad_norm", &d.max_grad_norm);
620 if d.timeline == Some(true) {
621 eprintln!(" {} true", style::dim("ddp.timeline"));
622 }
623
624 let t = &resolved.training;
626 print_config_field("training.epochs", &t.epochs);
627 print_config_field("training.batch_size", &t.batch_size);
628 print_config_field("training.batches_per_epoch", &t.batches_per_epoch);
629 print_config_field("training.lr", &t.lr);
630 print_config_field("training.seed", &t.seed);
631
632 let o = &resolved.output;
634 print_config_field("output.dir", &o.dir);
635 print_config_field("output.monitor", &o.monitor);
636
637 if !resolved.options.is_empty() {
639 eprintln!();
640 eprintln!("{}:", style::yellow("Options"));
641 for (key, val) in &resolved.options {
642 eprintln!(
643 " {} {}",
644 style::green(&format!("--{}", key.replace('_', "-"))),
645 value_to_string(val)
646 );
647 }
648 }
649
650 if let Some(entry) = &cmd_config.entry {
652 let args = config_to_args(&resolved);
653 let args_str = args.join(" ");
654 let docker_info = cmd_config
655 .docker
656 .as_ref()
657 .map(|s| format!("[{s}] ", ))
658 .unwrap_or_default();
659
660 eprintln!();
661 eprintln!("{}:", style::yellow("Effective command"));
662 eprintln!(
663 " {}{}{}",
664 style::dim(&docker_info),
665 entry,
666 if args_str.is_empty() {
667 String::new()
668 } else {
669 format!(" {args_str}")
670 }
671 );
672 }
673
674 eprintln!();
675 eprintln!(
676 "Extra {} after the command name are appended to the entry.",
677 style::dim("[options]")
678 );
679}
680
681fn print_config_field<T: std::fmt::Display>(label: &str, val: &Option<T>) {
682 if let Some(v) = val {
683 eprintln!(" {} {v}", style::dim(label));
684 }
685}
686
687fn print_config_value(label: &str, val: &Option<serde_json::Value>) {
688 if let Some(v) = val {
689 if !v.is_null() {
690 eprintln!(" {} {}", style::dim(label), value_to_string(v));
691 }
692 }
693}
694
695pub fn print_project_help(
697 project: &config::ProjectConfig,
698 project_root: &Path,
699 active_env: Option<&str>,
700) {
701 let visible_builtins = builtins::visible_top_level();
702 if let Some(desc) = &project.description {
703 eprintln!("{} {}", style::bold("fdl"), desc);
704 } else {
705 eprintln!("{} {}", style::bold("fdl"), env!("CARGO_PKG_VERSION"));
706 }
707
708 eprintln!();
709 eprintln!("{}:", style::yellow("Usage"));
710 eprintln!(
711 " fdl {} {}",
712 style::dim("<command>"),
713 style::dim("[options]")
714 );
715
716 eprintln!();
717 eprintln!("{}:", style::yellow("Options"));
718 eprintln!(
719 " {} Show this help",
720 style::green(&format!("{:<18}", "-h, --help"))
721 );
722 eprintln!(
723 " {} Show version",
724 style::green(&format!("{:<18}", "-V, --version"))
725 );
726 eprintln!(
727 " {} Use fdl.<name>.yml overlay (also: FDL_ENV=<name>)",
728 style::green(&format!("{:<18}", "--env <name>"))
729 );
730 eprintln!(
731 " {} Verbose output",
732 style::green(&format!("{:<18}", "-v"))
733 );
734 eprintln!(
735 " {} Debug output",
736 style::green(&format!("{:<18}", "-vv"))
737 );
738 eprintln!(
739 " {} Trace output (maximum detail)",
740 style::green(&format!("{:<18}", "-vvv"))
741 );
742 eprintln!(
743 " {} Suppress non-error output",
744 style::green(&format!("{:<18}", "-q, --quiet"))
745 );
746 eprintln!(
747 " {} Force ANSI color (bypass TTY / NO_COLOR detection)",
748 style::green(&format!("{:<18}", "--ansi"))
749 );
750 eprintln!(
751 " {} Disable ANSI color output",
752 style::green(&format!("{:<18}", "--no-ansi"))
753 );
754
755 eprintln!();
757 eprintln!("{}:", style::yellow("Built-in"));
758 for (name, desc) in &visible_builtins {
759 eprintln!(" {} {desc}", style::green(&format!("{:<18}", name)));
760 }
761
762 if !project.commands.is_empty() {
769 eprintln!();
770 eprintln!("{}:", style::yellow("Commands"));
771 for (name, spec) in &project.commands {
772 let desc: String = match spec.description.clone() {
773 Some(d) => d,
774 None => {
775 let is_path_kind = spec.run.is_none();
779 if is_path_kind {
780 let child_dir = spec.resolve_path(name, project_root);
781 config::load_command_with_env(&child_dir, active_env)
782 .ok()
783 .and_then(|c| c.description)
784 .unwrap_or_else(|| "(sub-command)".into())
785 } else {
786 spec.run
787 .as_deref()
788 .unwrap_or("(command)")
789 .to_string()
790 }
791 }
792 };
793 eprintln!(" {} {desc}", style::green(&format!("{:<18}", name)));
794 }
795 }
796
797 if let Some(base_config) = config::find_config(project_root) {
799 let envs = crate::overlay::list_envs(&base_config);
800 if !envs.is_empty() {
801 eprintln!();
802 eprintln!("{}:", style::yellow("Environments"));
803 for e in &envs {
804 let active_marker = if Some(e.as_str()) == active_env {
805 style::green(" (active)")
806 } else {
807 String::new()
808 };
809 eprintln!(
810 " {} Overlay from fdl.{}.yml{active_marker}",
811 style::green(&format!("{:<18}", e)),
812 e
813 );
814 }
815 eprintln!();
816 eprintln!(
817 "Use {} to run a command with an environment overlay.",
818 style::dim("fdl <env> <command>")
819 );
820 }
821 }
822
823 eprintln!();
824 eprintln!(
825 "Use {} for more information on a command.",
826 style::dim("fdl <command> -h")
827 );
828}
829
830fn build_usage_tail(
839 schema: Option<&Schema>,
840 has_presets: bool,
841 has_sub_commands: bool,
842 preset_slot: &str,
843) -> String {
844 let mut parts = String::new();
845 let slot = match (has_presets, has_sub_commands) {
846 (true, false) => Some(format!("[<{preset_slot}>]")),
847 (false, true) => Some("[<command>]".to_string()),
848 (true, true) => Some(format!("[<{preset_slot}>|<command>]")),
849 (false, false) => None,
850 };
851 if let Some(s) = slot {
852 parts.push(' ');
853 parts.push_str(&style::dim(&s));
854 }
855 if let Some(s) = schema {
856 for a in &s.args {
857 parts.push(' ');
858 parts.push_str(&format_arg_usage(a));
859 }
860 }
861 parts.push(' ');
862 parts.push_str(&style::dim("[options]"));
863 parts
864}
865
866type CommandGroup = Vec<(String, crate::config::CommandSpec)>;
867
868fn split_commands_by_kind(
873 commands: &BTreeMap<String, crate::config::CommandSpec>,
874) -> (CommandGroup, CommandGroup) {
875 use crate::config::CommandKind;
876 let mut presets = Vec::new();
877 let mut sub_cmds = Vec::new();
878 for (k, v) in commands {
879 match v.kind() {
880 Ok(CommandKind::Preset) => presets.push((k.clone(), v.clone())),
881 _ => sub_cmds.push((k.clone(), v.clone())),
882 }
883 }
884 (presets, sub_cmds)
885}
886
887fn format_arg_usage(a: &ArgSpec) -> String {
888 let suffix = if a.variadic { "..." } else { "" };
889 let core = format!("<{}>{suffix}", a.name);
890 if a.required && a.default.is_none() {
891 style::green(&core)
892 } else {
893 style::dim(&format!("[{core}]"))
894 }
895}
896
897fn format_arg(a: &ArgSpec) -> String {
898 let mut left = format_arg_usage(a);
899 let visible = visible_width(&left);
901 if visible < 22 {
902 for _ in 0..(22 - visible) {
903 left.push(' ');
904 }
905 } else {
906 left.push(' ');
907 }
908 let mut line = left;
909 line.push_str(a.description.as_deref().unwrap_or("-"));
910 append_default_and_choices(&mut line, &a.default, &a.choices, &a.ty);
911 line
912}
913
914fn format_option(long: &str, spec: &OptionSpec) -> Vec<String> {
917 let flag = match &spec.short {
918 Some(s) => format!("-{s}, --{long}"),
919 None => format!(" --{long}"),
920 };
921 let placeholder = option_placeholder(&spec.ty);
922 let left = if placeholder.is_empty() {
923 style::green(&flag)
924 } else {
925 style::green(&format!("{flag} {placeholder}"))
926 };
927 let visible = visible_width_for(&flag, placeholder);
928
929 let pad = if visible < 30 { 30 - visible } else { 1 };
931 let mut line = format!("{left}{}", " ".repeat(pad));
932 line.push_str(spec.description.as_deref().unwrap_or("-"));
933 append_default_and_choices(&mut line, &spec.default, &spec.choices, &spec.ty);
934
935 let mut out = vec![line];
936 if let Some(env) = &spec.env {
937 out.push(format!("{} {}", " ".repeat(32), style::dim(&format!("[env: {env}]"))));
938 }
939 out
940}
941
942fn option_placeholder(ty: &str) -> &'static str {
943 match ty {
944 "bool" => "",
945 "int" => "<N>",
946 "float" => "<F>",
947 "path" => "<PATH>",
948 "list[path]" => "<PATH>...",
949 t if t.starts_with("list[") => "<VALUE>...",
950 _ => "<VALUE>",
951 }
952}
953
954fn append_default_and_choices(
955 line: &mut String,
956 default: &Option<serde_json::Value>,
957 choices: &Option<Vec<serde_json::Value>>,
958 ty: &str,
959) {
960 if let Some(d) = default {
961 let is_empty_list = matches!(d, serde_json::Value::Array(a) if a.is_empty());
963 let is_false = matches!(d, serde_json::Value::Bool(false));
964 if !d.is_null() && !is_false && !is_empty_list {
965 line.push_str(&format!(" {}", style::dim(&format!("[default: {}]", format_value(d)))));
966 }
967 }
968 if let Some(choices) = choices {
969 if !choices.is_empty() {
970 let list = choices
971 .iter()
972 .map(format_value)
973 .collect::<Vec<_>>()
974 .join(", ");
975 line.push_str(&format!(" {}", style::dim(&format!("[possible: {list}]"))));
976 }
977 }
978 if ty.starts_with("list[") {
980 line.push_str(&format!(" {}", style::dim("(repeat or comma-separate)")));
981 }
982}
983
984fn format_value(v: &serde_json::Value) -> String {
985 match v {
986 serde_json::Value::String(s) => s.clone(),
987 other => other.to_string(),
988 }
989}
990
991fn visible_width(s: &str) -> usize {
994 strip_ansi(s).chars().count()
997}
998
999fn visible_width_for(flag: &str, placeholder: &str) -> usize {
1000 if placeholder.is_empty() {
1001 flag.chars().count()
1002 } else {
1003 flag.chars().count() + 1 + placeholder.chars().count()
1004 }
1005}
1006
1007fn strip_ansi(s: &str) -> String {
1008 let mut out = String::with_capacity(s.len());
1009 let mut chars = s.chars().peekable();
1010 while let Some(c) = chars.next() {
1011 if c == '\x1b' && chars.peek() == Some(&'[') {
1012 chars.next();
1013 for c in chars.by_ref() {
1014 if c.is_ascii_alphabetic() {
1015 break;
1016 }
1017 }
1018 } else {
1019 out.push(c);
1020 }
1021 }
1022 out
1023}