use std::collections::BTreeMap;
use std::path::Path;
use std::process::{ExitCode, Stdio};
use crate::builtins;
use crate::cli_error;
use crate::config::{self, ArgSpec, CommandConfig, OptionSpec, ResolvedConfig, Schema};
use crate::libtorch;
use crate::style;
pub fn config_to_args(resolved: &ResolvedConfig) -> Vec<String> {
let mut args = Vec::new();
let d = &resolved.ddp;
push_opt(&mut args, "--mode", &d.mode);
push_opt(&mut args, "--policy", &d.policy);
push_opt(&mut args, "--backend", &d.backend);
push_value(&mut args, "--anchor", &d.anchor);
push_num(&mut args, "--max-anchor", &d.max_anchor);
push_float(&mut args, "--overhead-target", &d.overhead_target);
push_float(&mut args, "--divergence-threshold", &d.divergence_threshold);
push_value(&mut args, "--max-batch-diff", &d.max_batch_diff);
push_float(&mut args, "--max-grad-norm", &d.max_grad_norm);
push_num(&mut args, "--snapshot-timeout", &d.snapshot_timeout);
push_num(&mut args, "--checkpoint-every", &d.checkpoint_every);
push_value(&mut args, "--progressive", &d.progressive);
if let Some(hint) = &d.speed_hint {
args.push("--speed-hint".into());
args.push(format!("{}:{}", hint.slow_rank, hint.ratio));
}
if let Some(ratios) = &d.partition_ratios {
let s: Vec<String> = ratios.iter().map(|r| format!("{r}")).collect();
args.push("--partition-ratios".into());
args.push(s.join(","));
}
if let Some(ratio) = d.lr_scale_ratio {
args.push("--lr-scale-ratio".into());
args.push(format!("{ratio}"));
}
if d.timeline == Some(true) {
args.push("--timeline".into());
}
let t = &resolved.training;
push_num(&mut args, "--epochs", &t.epochs);
push_num(&mut args, "--batch-size", &t.batch_size);
push_num(&mut args, "--batches", &t.batches_per_epoch);
push_float(&mut args, "--lr", &t.lr);
push_num(&mut args, "--seed", &t.seed);
let o = &resolved.output;
push_opt(&mut args, "--output", &o.dir);
push_num(&mut args, "--monitor", &o.monitor);
for (key, val) in &resolved.options {
let flag = format!("--{}", key.replace('_', "-"));
match val {
serde_json::Value::Bool(true) => args.push(flag),
serde_json::Value::Bool(false) => {}
serde_json::Value::Null => {}
other => {
args.push(flag);
args.push(value_to_string(other));
}
}
}
args
}
fn push_opt(args: &mut Vec<String>, flag: &str, val: &Option<String>) {
if let Some(v) = val {
args.push(flag.into());
args.push(v.clone());
}
}
fn push_num<T: std::fmt::Display>(args: &mut Vec<String>, flag: &str, val: &Option<T>) {
if let Some(v) = val {
args.push(flag.into());
args.push(v.to_string());
}
}
fn push_float(args: &mut Vec<String>, flag: &str, val: &Option<f64>) {
if let Some(v) = val {
args.push(flag.into());
args.push(format!("{v}"));
}
}
fn push_value(args: &mut Vec<String>, flag: &str, val: &Option<serde_json::Value>) {
if let Some(v) = val {
match v {
serde_json::Value::Null => {}
other => {
args.push(flag.into());
args.push(value_to_string(other));
}
}
}
}
fn value_to_string(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
other => other.to_string(),
}
}
fn inside_docker() -> bool {
Path::new("/.dockerenv").exists()
}
const DEFAULT_CONTAINER_PROJECT_ROOT: &str = "/workspace";
static COMPOSE_MOUNT_CACHE: std::sync::OnceLock<
std::collections::HashMap<String, String>,
> = std::sync::OnceLock::new();
fn container_project_root(project_root: &Path, service: &str) -> String {
let cache = COMPOSE_MOUNT_CACHE
.get_or_init(|| parse_compose_project_mounts(project_root));
cache
.get(service)
.cloned()
.unwrap_or_else(|| DEFAULT_CONTAINER_PROJECT_ROOT.to_string())
}
fn parse_compose_project_mounts(
project_root: &Path,
) -> std::collections::HashMap<String, String> {
let compose_path = project_root.join("docker-compose.yml");
let text = match std::fs::read_to_string(&compose_path) {
Ok(t) => t,
Err(_) => return std::collections::HashMap::new(),
};
let doc: serde_yaml_ng::Value = match serde_yaml_ng::from_str(&text) {
Ok(d) => d,
Err(_) => return std::collections::HashMap::new(),
};
let mut out = std::collections::HashMap::new();
let services = match doc.get("services").and_then(|v| v.as_mapping()) {
Some(s) => s,
None => return out,
};
for (name, svc) in services {
let svc_name = match name.as_str() {
Some(s) => s,
None => continue,
};
let volumes = match svc.get("volumes").and_then(|v| v.as_sequence()) {
Some(v) => v,
None => continue,
};
if let Some(container_path) = find_project_mount(volumes) {
let cleaned = container_path.trim_end_matches('/').to_string();
let cleaned = if cleaned.is_empty() {
"/".to_string()
} else {
cleaned
};
out.insert(svc_name.to_string(), cleaned);
}
}
out
}
fn find_project_mount(volumes: &[serde_yaml_ng::Value]) -> Option<String> {
for entry in volumes {
if let Some(s) = entry.as_str() {
let mut parts = s.splitn(3, ':');
let host = parts.next()?;
let container = parts.next()?;
if host == "." || host == "./" {
return Some(container.to_string());
}
} else if let Some(m) = entry.as_mapping() {
let source = m
.get(serde_yaml_ng::Value::String("source".into()))
.and_then(|v| v.as_str());
let target = m
.get(serde_yaml_ng::Value::String("target".into()))
.and_then(|v| v.as_str());
if matches!(source, Some(".") | Some("./")) {
if let Some(t) = target {
return Some(t.to_string());
}
}
}
}
None
}
fn libtorch_env(project_root: &Path) -> Result<Vec<(String, String)>, String> {
let mut env = Vec::new();
env.push((
"LIBTORCH_CPU_PATH".into(),
"./libtorch/precompiled/cpu".into(),
));
if let Some((info, host_path)) = resolve_libtorch(project_root)? {
env.push(("LIBTORCH_HOST_PATH".into(), host_path));
if let Some(cuda) = &info.cuda_version {
if cuda != "none" {
let cuda_version = if cuda.matches('.').count() < 2 {
format!("{cuda}.0")
} else {
cuda.clone()
};
let cuda_tag = cuda_version
.splitn(3, '.')
.take(2)
.collect::<Vec<_>>()
.join(".");
env.push(("CUDA_VERSION".into(), cuda_version));
env.push(("CUDA_TAG".into(), cuda_tag));
}
}
}
Ok(env)
}
fn resolve_libtorch(
project_root: &Path,
) -> Result<Option<(libtorch::detect::LibtorchInfo, String)>, String> {
if let Some(resolved) = resolve_libtorch_from_overlay(project_root)? {
return Ok(Some(resolved));
}
let Some(info) = libtorch::detect::read_active(project_root) else {
return Ok(None);
};
let host_path = format!("./libtorch/{}", info.path);
Ok(Some((info, host_path)))
}
fn resolve_libtorch_from_overlay(
project_root: &Path,
) -> Result<Option<(libtorch::detect::LibtorchInfo, String)>, String> {
let Ok(env_name) = std::env::var("FDL_ENV") else {
return Ok(None);
};
let env_name = env_name.trim();
if env_name.is_empty() {
return Ok(None);
}
let base_path = config::find_config_in(project_root).ok_or_else(|| {
format!(
"FDL_ENV={env_name} is set but no fdl config file exists in {}",
project_root.display()
)
})?;
let cfg = config::load_project_with_env(&base_path, Some(env_name))
.map_err(|e| format!("FDL_ENV={env_name}: cannot resolve the overlay: {e}"))?;
let Some(cluster) = cfg.cluster else {
return Ok(None);
};
let host_name = crate::cluster::resolve_local_hostname();
let Some(entry) = cluster.workers.iter().find(|w| w.host == host_name) else {
return Ok(None);
};
let Some(arch) = entry.arch.as_ref() else {
return Ok(None);
};
let variant_dir = std::path::PathBuf::from(&entry.path)
.join("libtorch")
.join(arch);
Ok(resolve_libtorch_at(&variant_dir))
}
pub(crate) fn resolve_libtorch_at(
path: &Path,
) -> Option<(libtorch::detect::LibtorchInfo, String)> {
if path.is_file()
&& path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(".active"))
{
let libtorch_root = path.parent()?;
let info = libtorch::detect::read_active_from(path, libtorch_root)?;
let host_path = libtorch_root.join(&info.path).display().to_string();
return Some((info, host_path));
}
if path.join(".active").exists() {
let info = libtorch::detect::read_active_from(
&path.join(".active"),
path,
)?;
let host_path = path.join(&info.path).display().to_string();
return Some((info, host_path));
}
if path.join("lib").is_dir() {
let info = libtorch::detect::libtorch_info_from_dir(
path.display().to_string(),
path,
);
let host_path = path.display().to_string();
return Some((info, host_path));
}
None
}
fn spawn_docker_shell(command: &str, project_root: &Path) -> ExitCode {
let env_vars = match libtorch_env(project_root) {
Ok(v) => v,
Err(e) => {
eprintln!("fdl: {e}");
return ExitCode::FAILURE;
}
};
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", command])
.current_dir(project_root)
.env(
"HOSTNAME",
crate::cluster::resolve_local_hostname(),
)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::inherit());
for (key, val) in &env_vars {
cmd.env(key, val);
}
match cmd.status() {
Ok(s) if s.success() => ExitCode::SUCCESS,
Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
Err(e) => {
cli_error!("{e}");
ExitCode::FAILURE
}
}
}
pub(crate) use crate::util::shell::posix_quote;
fn split_append_dashdash(s: &str) -> (String, String) {
let s = s.trim();
if s == "--" {
return (String::new(), String::new());
}
if let Some(rest) = s.strip_prefix("-- ") {
return (String::new(), rest.trim().to_string());
}
if let Some(prefix) = s.strip_suffix(" --") {
return (prefix.trim().to_string(), String::new());
}
if let Some(idx) = s.find(" -- ") {
let before = &s[..idx];
let after = &s[idx + 4..];
return (before.trim().to_string(), after.trim().to_string());
}
(s.to_string(), String::new())
}
fn split_user_args_dashdash(args: &[String]) -> (&[String], Option<&[String]>) {
match args.iter().position(|a| a == "--") {
Some(idx) => (&args[..idx], Some(&args[idx + 1..])),
None => (args, None),
}
}
pub(crate) fn compose_run_command(
run: &str,
user_args: &[String],
append: Option<&str>,
) -> String {
let suffix = append.map(str::trim).filter(|s| !s.is_empty());
let (append_pre, append_post, append_has_dashdash) = match suffix {
Some(s) => {
let (pre, post) = split_append_dashdash(s);
let has = s == "--"
|| s.starts_with("-- ")
|| s.ends_with(" --")
|| s.contains(" -- ");
(pre, post, has)
}
None => (String::new(), String::new(), false),
};
let (user_pre, user_post_opt) = split_user_args_dashdash(user_args);
let mut out = String::from(run.trim());
if !append_pre.is_empty() {
out.push(' ');
out.push_str(&append_pre);
}
for a in user_pre {
out.push(' ');
out.push_str(&posix_quote(a));
}
let needs_separator = append_has_dashdash
|| !append_post.is_empty()
|| user_post_opt.is_some_and(|p| !p.is_empty());
if needs_separator {
out.push_str(" --");
if !append_post.is_empty() {
out.push(' ');
out.push_str(&append_post);
}
if let Some(post) = user_post_opt {
for a in post {
out.push(' ');
out.push_str(&posix_quote(a));
}
}
}
out
}
fn testing_cluster_env_arg() -> String {
match std::env::var("FLODL_TESTING_CLUSTER_JSON") {
Ok(_) => " -e FLODL_TESTING_CLUSTER_JSON".to_string(),
Err(_) => String::new(),
}
}
pub fn exec_script(
command: &str,
append: Option<&str>,
user_args: &[String],
docker_service: Option<&str>,
cwd: &Path,
) -> ExitCode {
let inner_cmd = compose_run_command(command, user_args, append);
match docker_service {
Some(service) if !inside_docker() => {
let overlay = crate::cluster::cluster_compose_overlay_arg(cwd);
let testing_env_arg = testing_cluster_env_arg();
let docker_cmd = format!(
"docker compose{overlay} run --rm{testing_env_arg} {service} bash -c {}",
posix_quote(&inner_cmd)
);
spawn_docker_shell(&docker_cmd, cwd)
}
_ => {
let (shell, flag) = if cfg!(target_os = "windows") {
("cmd", "/C")
} else {
("sh", "-c")
};
match std::process::Command::new(shell)
.args([flag, inner_cmd.as_str()])
.current_dir(cwd)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::inherit())
.status()
{
Ok(s) if s.success() => ExitCode::SUCCESS,
Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
Err(e) => {
cli_error!("{e}");
ExitCode::FAILURE
}
}
}
}
}
pub fn exec_command(
cmd_config: &CommandConfig,
preset_name: Option<&str>,
extra_args: &[String],
cmd_dir: &Path,
project_root: &Path,
) -> ExitCode {
let entry = match &cmd_config.entry {
Some(e) => e.as_str(),
None => {
eprintln!(
"error: no entry point defined in {}/fdl.yaml",
cmd_dir.display()
);
return ExitCode::FAILURE;
}
};
if let Some(schema) = &cmd_config.schema {
if let Err(e) = config::validate_tail(extra_args, schema) {
cli_error!("{e}");
return ExitCode::FAILURE;
}
}
let resolved = match preset_name {
Some(name) => match cmd_config.commands.get(name) {
Some(preset) => {
if let Some(schema) = &cmd_config.schema {
if let Err(e) = config::validate_preset_for_exec(name, preset, schema) {
cli_error!("{e}");
return ExitCode::FAILURE;
}
}
config::merge_preset(cmd_config, preset)
}
None => {
cli_error!("unknown command '{name}'");
eprintln!();
print_command_help(cmd_config, "");
return ExitCode::FAILURE;
}
},
None => config::defaults_only(cmd_config),
};
let mut args = config_to_args(&resolved);
args.extend(extra_args.iter().cloned());
let use_docker = cmd_config.docker.is_some() && !inside_docker();
if use_docker {
let service = cmd_config.docker.as_deref().unwrap();
let workdir = cmd_dir
.strip_prefix(project_root)
.unwrap_or(cmd_dir)
.to_string_lossy();
let container_root = container_project_root(project_root, service);
let args_str = shell_join(&args);
let inner = if workdir.is_empty() || workdir == "." {
format!("{entry} {args_str}")
} else {
format!(
"cd {} && {entry} {args_str}",
posix_quote(&format!("{container_root}/{workdir}"))
)
};
if preset_name.is_some() {
eprintln!("fdl: [{service}] {inner}");
}
let overlay = crate::cluster::cluster_compose_overlay_arg(project_root);
let testing_env_arg = testing_cluster_env_arg();
let docker_cmd = format!(
"docker compose{overlay} run --rm -e {}{testing_env_arg} {service} bash -c {}",
posix_quote(&format!("FDL_PROJECT_ROOT={container_root}")),
posix_quote(&inner),
);
spawn_docker_shell(&docker_cmd, project_root)
} else {
let parts: Vec<&str> = entry.split_whitespace().collect();
if parts.is_empty() {
cli_error!("empty entry point");
return ExitCode::FAILURE;
}
let program = parts[0];
let entry_args = &parts[1..];
if preset_name.is_some() {
let preview: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
eprintln!("fdl: {entry} {}", preview.join(" "));
}
match std::process::Command::new(program)
.args(entry_args)
.args(&args)
.current_dir(cmd_dir)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::inherit())
.status()
{
Ok(s) if s.success() => ExitCode::SUCCESS,
Ok(s) => ExitCode::from(s.code().unwrap_or(1) as u8),
Err(e) => {
cli_error!("failed to execute '{program}': {e}");
ExitCode::FAILURE
}
}
}
}
fn shell_join(args: &[String]) -> String {
args.iter()
.map(|a| posix_quote(a))
.collect::<Vec<_>>()
.join(" ")
}
pub fn print_run_help(
name: &str,
description: Option<&str>,
run: &str,
append: Option<&str>,
docker: Option<&str>,
) {
if let Some(desc) = description {
eprintln!("{} {desc}", style::bold(name));
} else {
eprintln!("{}", style::bold(name));
}
eprintln!();
eprintln!("{}:", style::yellow("Usage"));
eprintln!(" fdl {name} [-- <args>... [-- <runner-args>...]]");
eprintln!();
eprintln!("{}:", style::yellow("Runs"));
let composed = match append.map(str::trim).filter(|s| !s.is_empty()) {
Some(suffix) => {
let (pre, post) = split_append_dashdash(suffix);
let left = if pre.is_empty() {
"[<args>]".to_string()
} else {
format!("{pre} [<args>]")
};
let right = if post.is_empty() {
"[<runner-args>]".to_string()
} else {
format!("{post} [<runner-args>]")
};
format!("{run} {left} -- {right}")
}
None => format!("{run} [<args>] [-- <runner-args>]"),
};
if let Some(svc) = docker {
eprintln!(
" {} {svc} -c {composed:?}",
style::dim("docker compose run --rm")
);
} else {
eprintln!(" {composed}");
}
eprintln!();
eprintln!(
"{} the first `--` separates fdl args from the run script; a second `--` splits cargo-side args from runner-side args.",
style::dim("Note:"),
);
eprintln!(
"{} `append:` is split on its own `--` and merged half-and-half; pass `--no-append` to drop it entirely.",
style::dim("Note:"),
);
}
pub fn print_command_help(cmd_config: &CommandConfig, name: &str) {
let (presets, sub_cmds) = split_commands_by_kind(&cmd_config.commands);
let preset_slot = cmd_config.arg_name.as_deref().unwrap_or("preset");
let width = help_width();
print_title(cmd_config, name);
print_usage_line(cmd_config, name, &presets, &sub_cmds, preset_slot);
print_arguments_section(cmd_config, &presets, preset_slot, width);
print_sub_commands_section(&sub_cmds);
print_schema_commands_section(cmd_config, name);
print_options_section(cmd_config, width);
print_entry_section(cmd_config);
print_defaults_section(cmd_config);
}
fn print_title(cmd_config: &CommandConfig, name: &str) {
if let Some(desc) = &cmd_config.description {
eprintln!("{} {desc}", style::bold(name));
} else {
eprintln!("{}", style::bold(name));
}
}
fn print_usage_line(
cmd_config: &CommandConfig,
name: &str,
presets: &CommandGroup,
sub_cmds: &CommandGroup,
preset_slot: &str,
) {
let usage_tail = build_usage_tail(
cmd_config.schema.as_ref(),
!presets.is_empty(),
!sub_cmds.is_empty(),
preset_slot,
);
eprintln!();
eprintln!("{}:", style::yellow("Usage"));
eprintln!(" fdl {name}{usage_tail}");
}
fn print_arguments_section(
cmd_config: &CommandConfig,
presets: &CommandGroup,
preset_slot: &str,
width: usize,
) {
let has_schema_args = cmd_config
.schema
.as_ref()
.is_some_and(|s| !s.args.is_empty());
if !has_schema_args && presets.is_empty() {
return;
}
eprintln!();
eprintln!("{}:", style::yellow("Arguments"));
let avail = width.saturating_sub(4);
if let Some(schema) = &cmd_config.schema {
for a in &schema.args {
for line in format_arg(a, avail) {
eprintln!(" {line}");
}
}
}
if !presets.is_empty() {
let slot_label = format!("[<{preset_slot}>]");
eprintln!(
" {} Named preset, one of:",
style::green(&format!("{:<20}", slot_label))
);
for (pname, spec) in presets {
let desc = spec.description.as_deref().unwrap_or("-");
eprintln!(
" {} {}",
style::green(&format!("{:<18}", pname)),
desc
);
}
}
}
fn print_sub_commands_section(sub_cmds: &CommandGroup) {
if sub_cmds.is_empty() {
return;
}
eprintln!();
eprintln!("{}:", style::yellow("Commands"));
for (sub_name, sub_spec) in sub_cmds {
let desc = sub_spec.description.as_deref().unwrap_or("-");
eprintln!(
" {} {}",
style::green(&format!("{:<20}", sub_name)),
desc
);
}
}
fn print_schema_commands_section(cmd_config: &CommandConfig, name: &str) {
let Some(schema) = &cmd_config.schema else {
return;
};
if schema.commands.is_empty() {
return;
}
eprintln!();
eprintln!("{}:", style::yellow("Commands"));
for (sub_name, sub_schema) in &schema.commands {
let desc = sub_schema.description.as_deref().unwrap_or("-");
eprintln!(
" {} {}",
style::green(&format!("{:<20}", sub_name)),
desc
);
}
eprintln!();
eprintln!(
" Run {} for a subcommand's options.",
style::dim(&format!("fdl {name} <command> --help"))
);
}
fn print_options_section(cmd_config: &CommandConfig, width: usize) {
let Some(schema) = &cmd_config.schema else {
return;
};
if schema.options.is_empty() {
return;
}
eprintln!();
eprintln!("{}:", style::yellow("Options"));
let avail = width.saturating_sub(4);
for (long, spec) in &schema.options {
for line in format_option(long, spec, avail) {
eprintln!(" {line}");
}
}
}
fn print_entry_section(cmd_config: &CommandConfig) {
let Some(entry) = &cmd_config.entry else {
return;
};
eprintln!();
eprintln!("{}:", style::yellow("Entry"));
eprintln!(" {entry}");
if let Some(service) = &cmd_config.docker {
eprintln!(
" {}",
style::dim(&format!("[docker: {service}]"))
);
}
eprintln!();
eprintln!(
" Any extra {} are forwarded to the entry point.",
style::dim("[options]")
);
}
fn print_defaults_section(cmd_config: &CommandConfig) {
if cmd_config.ddp.is_none() && cmd_config.training.is_none() {
return;
}
eprintln!();
eprintln!("{}:", style::yellow("Defaults"));
if let Some(d) = &cmd_config.ddp {
if let Some(mode) = &d.mode {
eprintln!(" {} {mode}", style::dim("ddp.mode"));
}
if let Some(anchor) = &d.anchor {
eprintln!(" {} {}", style::dim("ddp.anchor"), value_to_string(anchor));
}
}
if let Some(t) = &cmd_config.training {
if let Some(e) = t.epochs {
eprintln!(" {} {e}", style::dim("training.epochs"));
}
if let Some(bs) = t.batch_size {
eprintln!(" {} {bs}", style::dim("training.batch_size"));
}
if let Some(lr) = t.lr {
eprintln!(" {} {lr}", style::dim("training.lr"));
}
if let Some(seed) = t.seed {
eprintln!(" {} {seed}", style::dim("training.seed"));
}
}
}
pub fn print_preset_help(cmd_config: &CommandConfig, cmd_name: &str, preset_name: &str) {
let preset = match cmd_config.commands.get(preset_name) {
Some(s) => s,
None => {
eprintln!("unknown command: {preset_name}");
return;
}
};
let desc = preset.description.as_deref().unwrap_or("(no description)");
eprintln!(
"{} {} {}",
style::bold(cmd_name),
style::green(preset_name),
desc
);
eprintln!();
eprintln!("{}:", style::yellow("Usage"));
eprintln!(
" fdl {cmd_name} {preset_name} {}",
style::dim("[extra options]")
);
let resolved = config::merge_preset(cmd_config, preset);
eprintln!();
eprintln!("{}:", style::yellow("Effective config"));
let d = &resolved.ddp;
print_config_field("ddp.mode", &d.mode);
print_config_value("ddp.anchor", &d.anchor);
print_config_field("ddp.max_anchor", &d.max_anchor);
print_config_field("ddp.overhead_target", &d.overhead_target);
print_config_field("ddp.divergence_threshold", &d.divergence_threshold);
print_config_value("ddp.max_batch_diff", &d.max_batch_diff);
print_config_field("ddp.max_grad_norm", &d.max_grad_norm);
if d.timeline == Some(true) {
eprintln!(" {} true", style::dim("ddp.timeline"));
}
let t = &resolved.training;
print_config_field("training.epochs", &t.epochs);
print_config_field("training.batch_size", &t.batch_size);
print_config_field("training.batches_per_epoch", &t.batches_per_epoch);
print_config_field("training.lr", &t.lr);
print_config_field("training.seed", &t.seed);
let o = &resolved.output;
print_config_field("output.dir", &o.dir);
print_config_field("output.monitor", &o.monitor);
if !resolved.options.is_empty() {
eprintln!();
eprintln!("{}:", style::yellow("Options"));
for (key, val) in &resolved.options {
eprintln!(
" {} {}",
style::green(&format!("--{}", key.replace('_', "-"))),
value_to_string(val)
);
}
}
if let Some(entry) = &cmd_config.entry {
let args = config_to_args(&resolved);
let args_str = args.join(" ");
let docker_info = cmd_config
.docker
.as_ref()
.map(|s| format!("[{s}] ", ))
.unwrap_or_default();
eprintln!();
eprintln!("{}:", style::yellow("Effective command"));
eprintln!(
" {}{}{}",
style::dim(&docker_info),
entry,
if args_str.is_empty() {
String::new()
} else {
format!(" {args_str}")
}
);
}
eprintln!();
eprintln!(
"Extra {} after the command name are appended to the entry.",
style::dim("[options]")
);
}
fn print_config_field<T: std::fmt::Display>(label: &str, val: &Option<T>) {
if let Some(v) = val {
eprintln!(" {} {v}", style::dim(label));
}
}
fn print_config_value(label: &str, val: &Option<serde_json::Value>) {
if let Some(v) = val {
if !v.is_null() {
eprintln!(" {} {}", style::dim(label), value_to_string(v));
}
}
}
pub fn print_project_help(
project: &config::ProjectConfig,
project_root: &Path,
active_env: Option<&str>,
) {
let visible_builtins = builtins::visible_top_level();
if let Some(desc) = &project.description {
eprintln!("{} {}", style::bold("fdl"), desc);
} else {
eprintln!("{} {}", style::bold("fdl"), env!("CARGO_PKG_VERSION"));
}
eprintln!();
eprintln!("{}:", style::yellow("Usage"));
eprintln!(
" fdl {} {}",
style::dim("<command>"),
style::dim("[options]")
);
eprintln!();
eprintln!("{}:", style::yellow("Options"));
eprintln!(
" {} Show this help",
style::green(&format!("{:<18}", "-h, --help"))
);
eprintln!(
" {} Show version",
style::green(&format!("{:<18}", "-V, --version"))
);
eprintln!(
" {} Use fdl.<name>.yml overlay (also: --env <name>, FDL_ENV=<name>)",
style::green(&format!("{:<18}", "@<name>"))
);
eprintln!(
" {} Scope visible GPUs, e.g. 0,1 or all (any position)",
style::green(&format!("{:<18}", "--gpus <spec>"))
);
eprintln!(
" {} Verbose output",
style::green(&format!("{:<18}", "-v"))
);
eprintln!(
" {} Debug output",
style::green(&format!("{:<18}", "-vv"))
);
eprintln!(
" {} Trace output (maximum detail)",
style::green(&format!("{:<18}", "-vvv"))
);
eprintln!(
" {} Suppress non-error output",
style::green(&format!("{:<18}", "-q, --quiet"))
);
eprintln!(
" {} Force ANSI color (bypass TTY / NO_COLOR detection)",
style::green(&format!("{:<18}", "--ansi"))
);
eprintln!(
" {} Disable ANSI color output",
style::green(&format!("{:<18}", "--no-ansi"))
);
eprintln!(
" {} Drop a run command's `append:` suffix",
style::green(&format!("{:<18}", "--no-append"))
);
eprintln!(
" {} Skip the cluster pre-flight build",
style::green(&format!("{:<18}", "--no-prebuild"))
);
eprintln!();
eprintln!("{}:", style::yellow("Built-in"));
for (name, desc) in &visible_builtins {
eprintln!(" {} {desc}", style::green(&format!("{:<18}", name)));
}
if !project.commands.is_empty() {
eprintln!();
eprintln!("{}:", style::yellow("Commands"));
for (name, spec) in &project.commands {
let desc: String = match spec.description.clone() {
Some(d) => d,
None => {
let is_path_kind = spec.run.is_none();
if is_path_kind {
let child_dir = spec.resolve_path(name, project_root);
config::load_command_with_env(&child_dir, active_env)
.ok()
.and_then(|c| c.description)
.unwrap_or_else(|| "(sub-command)".into())
} else {
spec.run
.as_deref()
.unwrap_or("(command)")
.to_string()
}
}
};
eprintln!(" {} {desc}", style::green(&format!("{:<18}", name)));
}
}
if let Some(base_config) = config::find_config(project_root) {
let envs = crate::overlay::list_envs(&base_config);
if !envs.is_empty() {
eprintln!();
eprintln!("{}:", style::yellow("Environments"));
for e in &envs {
let active_marker = if Some(e.as_str()) == active_env {
style::green(" (active)")
} else {
String::new()
};
eprintln!(
" {} Overlay from fdl.{}.yml{active_marker}",
style::green(&format!("{:<18}", format!("@{e}"))),
e
);
}
eprintln!();
eprintln!(
"Use {} to run a command with an environment overlay.",
style::dim("fdl @<env> <command>")
);
}
}
eprintln!();
eprintln!(
"Use {} for more information on a command.",
style::dim("fdl <command> -h")
);
}
fn build_usage_tail(
schema: Option<&Schema>,
has_presets: bool,
has_sub_commands: bool,
preset_slot: &str,
) -> String {
let mut parts = String::new();
let slot = match (has_presets, has_sub_commands) {
(true, false) => Some(format!("[<{preset_slot}>]")),
(false, true) => Some("[<command>]".to_string()),
(true, true) => Some(format!("[<{preset_slot}>|<command>]")),
(false, false) => None,
};
if let Some(s) = slot {
parts.push(' ');
parts.push_str(&style::dim(&s));
}
if let Some(s) = schema {
for a in &s.args {
parts.push(' ');
parts.push_str(&format_arg_usage(a));
}
}
parts.push(' ');
parts.push_str(&style::dim("[options]"));
parts
}
type CommandGroup = Vec<(String, crate::config::CommandSpec)>;
fn split_commands_by_kind(
commands: &BTreeMap<String, crate::config::CommandSpec>,
) -> (CommandGroup, CommandGroup) {
use crate::config::CommandKind;
let mut presets = Vec::new();
let mut sub_cmds = Vec::new();
for (k, v) in commands {
match v.kind() {
Ok(CommandKind::Preset) => presets.push((k.clone(), v.clone())),
_ => sub_cmds.push((k.clone(), v.clone())),
}
}
(presets, sub_cmds)
}
fn format_arg_usage(a: &ArgSpec) -> String {
let suffix = if a.variadic { "..." } else { "" };
let core = format!("<{}>{suffix}", a.name);
if a.required && a.default.is_none() {
style::green(&core)
} else {
style::dim(&format!("[{core}]"))
}
}
const ARG_COL: usize = 22;
const OPT_COL: usize = 30;
fn format_arg(a: &ArgSpec, avail_width: usize) -> Vec<String> {
let left = format_arg_usage(a);
let visible = visible_width(&left);
let segs = desc_segments(a.description.as_deref(), &a.default, &a.choices, &a.ty);
format_row(&left, visible, ARG_COL, &segs, avail_width)
}
fn format_option(long: &str, spec: &OptionSpec, avail_width: usize) -> Vec<String> {
let flag = match &spec.short {
Some(s) => format!("-{s}, --{long}"),
None => format!(" --{long}"),
};
let placeholder = option_placeholder(&spec.ty);
let (left, visible) = if placeholder.is_empty() {
(style::green(&flag), flag.chars().count())
} else {
(
style::green(&format!("{flag} {placeholder}")),
flag.chars().count() + 1 + placeholder.chars().count(),
)
};
let segs = desc_segments(spec.description.as_deref(), &spec.default, &spec.choices, &spec.ty);
let mut out = format_row(&left, visible, OPT_COL, &segs, avail_width);
if let Some(env) = &spec.env {
out.push(format!(
"{}{}",
" ".repeat(OPT_COL),
style::dim(&format!("[env: {env}]"))
));
}
out
}
struct Seg {
text: String,
styled: String,
}
impl Seg {
fn plain(s: &str) -> Seg {
Seg { text: s.to_string(), styled: s.to_string() }
}
fn dim(s: &str) -> Seg {
Seg { text: s.to_string(), styled: style::dim(s) }
}
}
fn desc_segments(
description: Option<&str>,
default: &Option<serde_json::Value>,
choices: &Option<Vec<serde_json::Value>>,
ty: &str,
) -> Vec<Seg> {
let mut segs: Vec<Seg> = description
.unwrap_or("-")
.split_whitespace()
.map(Seg::plain)
.collect();
if let Some(d) = default {
let is_empty_list = matches!(d, serde_json::Value::Array(a) if a.is_empty());
let is_false = matches!(d, serde_json::Value::Bool(false));
if !d.is_null() && !is_false && !is_empty_list {
segs.push(Seg::dim(&format!("[default: {}]", format_value(d))));
}
}
if let Some(choices) = choices {
if !choices.is_empty() {
let list = choices.iter().map(format_value).collect::<Vec<_>>().join(", ");
segs.push(Seg::dim(&format!("[possible: {list}]")));
}
}
if ty.starts_with("list[") {
segs.push(Seg::dim("(repeat or comma-separate)"));
}
segs
}
fn wrap_segments(segs: &[Seg], width: usize) -> Vec<String> {
let width = width.max(1);
let mut lines: Vec<String> = Vec::new();
let mut cur = String::new();
let mut cur_w = 0usize;
for seg in segs {
let w = seg.text.chars().count();
if cur_w == 0 {
cur.push_str(&seg.styled);
cur_w = w;
} else if cur_w + 1 + w <= width {
cur.push(' ');
cur.push_str(&seg.styled);
cur_w += 1 + w;
} else {
lines.push(std::mem::take(&mut cur));
cur.push_str(&seg.styled);
cur_w = w;
}
}
if !cur.is_empty() {
lines.push(cur);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn format_row(
label: &str,
label_visible: usize,
desc_col: usize,
segs: &[Seg],
avail_width: usize,
) -> Vec<String> {
const MIN_DESC: usize = 20;
let desc_width = avail_width.saturating_sub(desc_col).max(MIN_DESC);
let desc_lines = wrap_segments(segs, desc_width);
let pad = " ".repeat(desc_col);
let mut out: Vec<String> = Vec::with_capacity(desc_lines.len() + 1);
if label_visible < desc_col {
let gap = " ".repeat(desc_col - label_visible);
out.push(format!("{label}{gap}{}", desc_lines[0]));
} else {
out.push(label.to_string());
out.push(format!("{pad}{}", desc_lines[0]));
}
for line in &desc_lines[1..] {
out.push(format!("{pad}{line}"));
}
for line in &mut out {
while line.ends_with(' ') {
line.pop();
}
}
out
}
fn option_placeholder(ty: &str) -> &'static str {
match ty {
"bool" => "",
"int" => "<N>",
"float" => "<F>",
"path" => "<PATH>",
"list[path]" => "<PATH>...",
t if t.starts_with("list[") => "<VALUE>...",
_ => "<VALUE>",
}
}
fn format_value(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn visible_width(s: &str) -> usize {
strip_ansi(s).chars().count()
}
fn help_width() -> usize {
const DEFAULT: usize = 100;
const MIN: usize = 60;
const MAX: usize = 120;
let raw = std::env::var("COLUMNS")
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.filter(|&c| c > 0)
.or_else(term_cols)
.unwrap_or(DEFAULT);
raw.clamp(MIN, MAX)
}
#[cfg(unix)]
fn term_cols() -> Option<usize> {
use std::io::IsTerminal;
use std::os::unix::io::AsRawFd;
let stderr = std::io::stderr();
if !stderr.is_terminal() {
return None;
}
#[repr(C)]
struct Winsize {
row: u16,
col: u16,
xpixel: u16,
ypixel: u16,
}
#[cfg(any(target_os = "linux", target_os = "android"))]
const TIOCGWINSZ: std::os::raw::c_ulong = 0x5413;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
const TIOCGWINSZ: std::os::raw::c_ulong = 0x4008_7468;
unsafe extern "C" {
fn ioctl(fd: std::os::raw::c_int, request: std::os::raw::c_ulong, ...) -> std::os::raw::c_int;
}
let mut ws = Winsize { row: 0, col: 0, xpixel: 0, ypixel: 0 };
let rc = unsafe { ioctl(stderr.as_raw_fd(), TIOCGWINSZ, &mut ws as *mut Winsize) };
(rc == 0 && ws.col > 0).then_some(ws.col as usize)
}
#[cfg(not(unix))]
fn term_cols() -> Option<usize> {
None
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::test_env::env_lock;
fn plain_segs(words: &[&str]) -> Vec<Seg> {
words.iter().map(|w| Seg::plain(w)).collect()
}
#[test]
fn wrap_segments_packs_words_within_width() {
let segs = plain_segs(&["alpha", "beta", "gamma", "delta"]);
let lines = wrap_segments(&segs, 12);
assert_eq!(lines, vec!["alpha beta", "gamma delta"]);
for line in &lines {
assert!(line.chars().count() <= 12);
}
}
#[test]
fn wrap_segments_oversized_segment_gets_its_own_line() {
let segs = plain_segs(&["short", "supercalifragilistic", "tail"]);
let lines = wrap_segments(&segs, 10);
assert_eq!(lines, vec!["short", "supercalifragilistic", "tail"]);
}
#[test]
fn format_row_aligns_continuation_under_description_column() {
let segs = plain_segs(&["aaaa", "bbbb", "cccc", "dddd", "eeee"]);
let rows = format_row("--x", 3, 8, &segs, 28);
assert_eq!(rows[0], "--x aaaa bbbb cccc dddd"); assert_eq!(rows[1], format!("{}eeee", " ".repeat(8)));
for row in &rows[1..] {
assert!(row.starts_with(&" ".repeat(8)));
assert!(!row.starts_with(&" ".repeat(9)));
}
}
#[test]
fn format_row_overflowing_label_drops_description_below() {
let segs = plain_segs(&["desc"]);
let rows = format_row("--a-very-long-flag", 18, 8, &segs, 40);
assert_eq!(rows[0], "--a-very-long-flag");
assert_eq!(rows[1], format!("{}desc", " ".repeat(8)));
}
#[test]
fn help_width_honors_columns_env_within_clamp() {
let _lock = env_lock();
let prev = std::env::var("COLUMNS").ok();
unsafe { std::env::set_var("COLUMNS", "90") };
assert_eq!(help_width(), 90);
unsafe { std::env::set_var("COLUMNS", "9999") };
assert_eq!(help_width(), 120); unsafe { std::env::set_var("COLUMNS", "10") };
assert_eq!(help_width(), 60); match prev {
Some(v) => unsafe { std::env::set_var("COLUMNS", v) },
None => unsafe { std::env::remove_var("COLUMNS") },
}
}
fn unique_tmp_dir(tag: &str) -> std::path::PathBuf {
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let d = std::env::temp_dir().join(format!(
"fdl-run-test-{tag}-{}-{}",
std::process::id(),
SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn overlay_libtorch_finds_fdl_yaml_spelling() {
let _guard = env_lock();
let dir = unique_tmp_dir("yaml-spelling");
std::fs::write(dir.join("fdl.yaml"), "description: base\n").unwrap();
std::fs::write(
dir.join("fdl.testenv.yaml"),
"cluster:\n controller:\n host: 127.0.0.1\n port: 29500\n path: /opt/flodl\n workers:\n - host: not-this-host\n local_devices: [0]\n nccl_socket_ifname: lo\n path: /opt/flodl\n",
)
.unwrap();
unsafe { std::env::set_var("FDL_ENV", "testenv") };
let resolved = resolve_libtorch_from_overlay(&dir);
unsafe { std::env::remove_var("FDL_ENV") };
std::fs::remove_dir_all(&dir).ok();
assert!(matches!(resolved, Ok(None)), "{resolved:?}");
}
#[test]
fn overlay_libtorch_load_failure_is_loud() {
let _guard = env_lock();
let dir = unique_tmp_dir("broken-overlay");
std::fs::write(dir.join("fdl.yml"), "description: base\n").unwrap();
unsafe { std::env::set_var("FDL_ENV", "missing-env") };
let resolved = resolve_libtorch_from_overlay(&dir);
unsafe { std::env::remove_var("FDL_ENV") };
std::fs::remove_dir_all(&dir).ok();
let err = match resolved {
Ok(v) => panic!("expected Err, got Ok({v:?})"),
Err(e) => e,
};
assert!(err.contains("missing-env"), "{err}");
}
#[test]
fn posix_quote_passes_safe_strings_through() {
assert_eq!(posix_quote("hello"), "hello");
assert_eq!(posix_quote("-p"), "-p");
assert_eq!(posix_quote("flodl-hf"), "flodl-hf");
assert_eq!(posix_quote("a/b.c"), "a/b.c");
assert_eq!(posix_quote("KEY=val"), "KEY=val");
}
#[test]
fn posix_quote_wraps_unsafe_strings() {
assert_eq!(posix_quote(""), "''");
assert_eq!(posix_quote("foo bar"), "'foo bar'");
assert_eq!(posix_quote("a$b"), "'a$b'");
assert_eq!(posix_quote("a\"b"), "'a\"b'");
}
#[test]
fn posix_quote_escapes_embedded_single_quotes() {
assert_eq!(posix_quote("it's"), "'it'\\''s'");
assert_eq!(posix_quote("'"), "''\\'''");
}
#[test]
fn posix_quote_round_trips_shell_join_output() {
let args: Vec<String> = ["--tag", "$HOME", "a\"b"]
.iter()
.map(|s| s.to_string())
.collect();
let inner = format!("cd /workspace/bench && train {}", shell_join(&args));
assert_eq!(
posix_quote(&inner),
"'cd /workspace/bench && train --tag '\\''$HOME'\\'' '\\''a\"b'\\'''"
);
}
#[test]
fn shell_join_quotes_shell_metacharacters() {
let args: Vec<String> = ["--model", "mlp", "--tag", "$HOME", "*.py", "a;b"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(shell_join(&args), "--model mlp --tag '$HOME' '*.py' 'a;b'");
}
#[test]
fn compose_run_command_no_extras_passes_run_through() {
assert_eq!(compose_run_command("echo hello", &[], None), "echo hello");
}
#[test]
fn compose_run_command_inserts_user_args_between_run_and_append() {
let user = vec!["-p".to_string(), "flodl-hf".to_string()];
let out = compose_run_command("cargo test live", &user, Some("-- --nocapture --ignored"));
assert_eq!(out, "cargo test live -p flodl-hf -- --nocapture --ignored");
}
#[test]
fn compose_run_command_quotes_user_args_with_spaces() {
let user = vec!["--name".to_string(), "with space".to_string()];
let out = compose_run_command("cmd", &user, None);
assert_eq!(out, "cmd --name 'with space'");
}
#[test]
fn compose_run_command_omits_empty_append() {
let out = compose_run_command("cmd", &["arg".to_string()], Some(""));
assert_eq!(out, "cmd arg");
let out2 = compose_run_command("cmd", &["arg".to_string()], Some(" "));
assert_eq!(out2, "cmd arg");
}
#[test]
fn compose_run_command_user_double_dash_threads_runner_args() {
let user = vec![
"-p".to_string(),
"foo".to_string(),
"--".to_string(),
"--ignored".to_string(),
];
let out = compose_run_command("cargo test", &user, Some("-- --nocapture"));
assert_eq!(out, "cargo test -p foo -- --nocapture --ignored");
}
#[test]
fn compose_run_command_user_double_dash_without_append() {
let user = vec![
"-p".to_string(),
"foo".to_string(),
"--".to_string(),
"--ignored".to_string(),
];
let out = compose_run_command("cargo test", &user, None);
assert_eq!(out, "cargo test -p foo -- --ignored");
}
#[test]
fn compose_run_command_append_with_pre_and_post_halves() {
let out = compose_run_command("cmd", &[], Some("--foo -- --bar"));
assert_eq!(out, "cmd --foo -- --bar");
}
#[test]
fn compose_run_command_append_pre_only_no_separator() {
let user = vec!["--ansi".to_string()];
let out = compose_run_command("cmd", &user, Some("--no-ansi"));
assert_eq!(out, "cmd --no-ansi --ansi");
}
#[test]
fn compose_run_command_user_only_double_dash_emits_separator() {
let user = vec!["--".to_string(), "--list".to_string()];
let out = compose_run_command("cargo test", &user, None);
assert_eq!(out, "cargo test -- --list");
}
#[test]
fn compose_run_command_append_full_split_with_user_both_sides() {
let user = vec![
"-p".to_string(),
"foo".to_string(),
"--".to_string(),
"--ignored".to_string(),
];
let out = compose_run_command(
"cargo test",
&user,
Some("--release -- --nocapture"),
);
assert_eq!(
out,
"cargo test --release -p foo -- --nocapture --ignored"
);
}
#[test]
fn split_append_dashdash_handles_edges() {
assert_eq!(
split_append_dashdash("-- --nocapture"),
(String::new(), "--nocapture".to_string())
);
assert_eq!(
split_append_dashdash("--foo -- --bar"),
("--foo".to_string(), "--bar".to_string())
);
assert_eq!(
split_append_dashdash("--foo --"),
("--foo".to_string(), String::new())
);
assert_eq!(
split_append_dashdash("--"),
(String::new(), String::new())
);
assert_eq!(
split_append_dashdash("--foo"),
("--foo".to_string(), String::new())
);
assert_eq!(
split_append_dashdash(""),
(String::new(), String::new())
);
}
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new() -> Self {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos()).unwrap_or(0);
let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("fdl-resolve-libtorch-{}-{}", nanos, seq));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn path(&self) -> &std::path::Path { &self.0 }
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn populate_lt_root(root: &std::path::Path) {
for (sub, torch, archs) in [
("precompiled/v1", "1.0", "0.0"),
("builds/v2", "2.0", "1.0"),
] {
let d = root.join(sub);
std::fs::create_dir_all(d.join("lib")).unwrap();
std::fs::write(
d.join(".arch"),
format!("torch={torch}\ncuda=1.0\narchs={archs}\nsource=test\n"),
).unwrap();
}
}
#[test]
fn resolve_libtorch_at_pointer_file() {
let s = Scratch::new();
let lt = s.path().join("libtorch");
populate_lt_root(<);
let pointer = lt.join(".active.alt");
std::fs::write(&pointer, "precompiled/v1\n").unwrap();
let (info, host_path) = resolve_libtorch_at(&pointer)
.expect("pointer file resolves");
assert_eq!(info.path, "precompiled/v1");
assert_eq!(info.torch_version.as_deref(), Some("1.0"));
assert_eq!(host_path, lt.join("precompiled/v1").display().to_string());
}
#[test]
fn resolve_libtorch_at_libtorch_root_dir() {
let s = Scratch::new();
let lt = s.path().join("libtorch");
populate_lt_root(<);
std::fs::write(lt.join(".active"), "builds/v2\n").unwrap();
let (info, host_path) = resolve_libtorch_at(<)
.expect("libtorch-root dir resolves");
assert_eq!(info.path, "builds/v2");
assert_eq!(info.torch_version.as_deref(), Some("2.0"));
assert_eq!(host_path, lt.join("builds/v2").display().to_string());
}
#[test]
fn resolve_libtorch_at_direct_variant_dir() {
let s = Scratch::new();
let variant = s.path().join("standalone-libtorch");
std::fs::create_dir_all(variant.join("lib")).unwrap();
std::fs::write(
variant.join(".arch"),
"torch=3.0\ncuda=2.0\narchs=1.0\nsource=test\n",
).unwrap();
let (info, host_path) = resolve_libtorch_at(&variant)
.expect("direct variant dir resolves");
assert_eq!(info.path, variant.display().to_string());
assert_eq!(info.torch_version.as_deref(), Some("3.0"));
assert_eq!(host_path, variant.display().to_string());
}
#[test]
fn resolve_libtorch_at_bogus_path_returns_none() {
let s = Scratch::new();
let bogus = s.path().join("no-lib-no-active-no-pointer");
std::fs::create_dir_all(&bogus).unwrap();
assert!(resolve_libtorch_at(&bogus).is_none(),
"dir without lib/, .active, or pointer-shape filename → None");
}
}