use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::config::{self, CommandConfig, CommandKind, CommandSpec};
pub enum PathOutcome {
LoadFailed(String),
Descend {
child: Box<CommandConfig>,
new_dir: PathBuf,
new_name: String,
},
ShowHelp { child: Box<CommandConfig> },
RefreshSchema {
child: Box<CommandConfig>,
child_dir: PathBuf,
},
Exec {
child: Box<CommandConfig>,
child_dir: PathBuf,
},
}
pub fn classify_path_step(
spec: &CommandSpec,
name: &str,
current_dir: &Path,
tail: &[String],
env: Option<&str>,
) -> PathOutcome {
let child_dir = spec.resolve_path(name, current_dir);
let child_cfg = match config::load_command_with_env(&child_dir, env) {
Ok(c) => c,
Err(e) => return PathOutcome::LoadFailed(e),
};
if let Some(next) = tail.first() {
if child_cfg.commands.contains_key(next) {
return PathOutcome::Descend {
child: Box::new(child_cfg),
new_dir: child_dir,
new_name: next.clone(),
};
}
}
if tail.iter().any(|a| a == "--help" || a == "-h") {
return PathOutcome::ShowHelp {
child: Box::new(child_cfg),
};
}
if tail.iter().any(|a| a == "--refresh-schema") {
return PathOutcome::RefreshSchema {
child: Box::new(child_cfg),
child_dir,
};
}
PathOutcome::Exec {
child: Box::new(child_cfg),
child_dir,
}
}
pub enum WalkOutcome {
RunScript {
command: String,
docker: Option<String>,
cwd: PathBuf,
},
ExecCommand {
config: Box<CommandConfig>,
preset: Option<String>,
tail: Vec<String>,
cmd_dir: PathBuf,
},
RefreshSchema {
config: Box<CommandConfig>,
cmd_dir: PathBuf,
cmd_name: String,
},
PrintCommandHelp {
config: Box<CommandConfig>,
name: String,
},
PrintPresetHelp {
config: Box<CommandConfig>,
parent_label: String,
preset_name: String,
},
PrintRunHelp {
name: String,
description: Option<String>,
run: String,
docker: Option<String>,
},
UnknownCommand { name: String },
PresetAtTopLevel { name: String },
Error(String),
}
pub fn walk_commands(
cmd_name: &str,
tail: &[String],
top_commands: &BTreeMap<String, CommandSpec>,
project_root: &Path,
env: Option<&str>,
) -> WalkOutcome {
let mut commands: BTreeMap<String, CommandSpec> = top_commands.clone();
let mut enclosing: Option<CommandConfig> = None;
let mut current_dir: PathBuf = project_root.to_path_buf();
let mut name: String = cmd_name.to_string();
let mut current_tail: Vec<String> = tail.to_vec();
loop {
let spec = match commands.get(&name) {
Some(s) => s.clone(),
None => return WalkOutcome::UnknownCommand { name },
};
let kind = match spec.kind() {
Ok(k) => k,
Err(e) => return WalkOutcome::Error(format!("command `{name}`: {e}")),
};
match kind {
CommandKind::Run => {
let command = spec
.run
.expect("Run kind guarantees `run` is set");
if current_tail.iter().any(|a| a == "--help" || a == "-h") {
return WalkOutcome::PrintRunHelp {
name,
description: spec.description,
run: command,
docker: spec.docker,
};
}
return WalkOutcome::RunScript {
command,
docker: spec.docker,
cwd: current_dir,
};
}
CommandKind::Path => {
match classify_path_step(&spec, &name, ¤t_dir, ¤t_tail, env) {
PathOutcome::LoadFailed(msg) => return WalkOutcome::Error(msg),
PathOutcome::Descend {
child,
new_dir,
new_name,
} => {
commands = child.commands.clone();
enclosing = Some(*child);
current_dir = new_dir;
name = new_name;
if !current_tail.is_empty() {
current_tail.remove(0);
}
}
PathOutcome::ShowHelp { child } => {
return WalkOutcome::PrintCommandHelp {
config: child,
name,
};
}
PathOutcome::RefreshSchema { child, child_dir } => {
return WalkOutcome::RefreshSchema {
config: child,
cmd_dir: child_dir,
cmd_name: name,
};
}
PathOutcome::Exec { child, child_dir } => {
return WalkOutcome::ExecCommand {
config: child,
preset: None,
tail: current_tail,
cmd_dir: child_dir,
};
}
}
}
CommandKind::Preset => {
let Some(encl) = enclosing.take() else {
return WalkOutcome::PresetAtTopLevel { name };
};
if current_tail.iter().any(|a| a == "--help" || a == "-h") {
let parent_label = current_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
return WalkOutcome::PrintPresetHelp {
config: Box::new(encl),
parent_label,
preset_name: name,
};
}
return WalkOutcome::ExecCommand {
config: Box::new(encl),
preset: Some(name),
tail: current_tail,
cmd_dir: current_dir,
};
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let base = std::env::temp_dir();
let unique = format!(
"flodl-dispatch-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let dir = base.join(unique);
std::fs::create_dir_all(&dir).expect("tempdir creation");
Self(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn mkcmd(base: &Path, sub: &str, body: &str) -> PathBuf {
let dir = base.join(sub);
std::fs::create_dir_all(&dir).expect("mkcmd dir");
std::fs::write(dir.join("fdl.yml"), body).expect("mkcmd write");
dir
}
fn path_spec() -> CommandSpec {
CommandSpec::default()
}
#[test]
fn classify_descends_when_tail_names_nested_command() {
let tmp = TempDir::new();
mkcmd(
tmp.path(),
"ddp-bench",
"entry: echo\ncommands:\n quick:\n options: { model: linear }\n",
);
let spec = path_spec();
let tail = vec!["quick".to_string()];
let out = classify_path_step(&spec, "ddp-bench", tmp.path(), &tail, None);
match out {
PathOutcome::Descend { new_name, .. } => assert_eq!(new_name, "quick"),
_ => panic!("expected Descend, got something else"),
}
}
#[test]
fn classify_show_help_when_tail_has_flag() {
let tmp = TempDir::new();
mkcmd(tmp.path(), "sub", "entry: echo\n");
let spec = path_spec();
let tail = vec!["--help".to_string()];
let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
assert!(matches!(out, PathOutcome::ShowHelp { .. }));
}
#[test]
fn classify_show_help_short_flag() {
let tmp = TempDir::new();
mkcmd(tmp.path(), "sub", "entry: echo\n");
let spec = path_spec();
let tail = vec!["-h".to_string()];
let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
assert!(matches!(out, PathOutcome::ShowHelp { .. }));
}
#[test]
fn classify_refresh_schema() {
let tmp = TempDir::new();
mkcmd(tmp.path(), "sub", "entry: echo\n");
let spec = path_spec();
let tail = vec!["--refresh-schema".to_string()];
let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
assert!(matches!(out, PathOutcome::RefreshSchema { .. }));
}
#[test]
fn classify_exec_when_tail_has_no_known_token() {
let tmp = TempDir::new();
mkcmd(tmp.path(), "sub", "entry: echo\n");
let spec = path_spec();
let tail = vec!["--model".to_string(), "linear".to_string()];
let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
assert!(matches!(out, PathOutcome::Exec { .. }));
}
#[test]
fn classify_exec_when_tail_is_empty() {
let tmp = TempDir::new();
mkcmd(tmp.path(), "sub", "entry: echo\n");
let spec = path_spec();
let tail: Vec<String> = vec![];
let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
assert!(matches!(out, PathOutcome::Exec { .. }));
}
#[test]
fn classify_descend_wins_over_help_at_same_level() {
let tmp = TempDir::new();
mkcmd(
tmp.path(),
"sub",
"entry: echo\ncommands:\n quick:\n options: { x: 1 }\n",
);
let spec = path_spec();
let tail = vec!["quick".to_string(), "--help".to_string()];
let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
assert!(matches!(out, PathOutcome::Descend { .. }));
}
#[test]
fn classify_load_failed_when_no_child_fdl_yml() {
let tmp = TempDir::new();
let spec = path_spec();
let tail: Vec<String> = vec![];
let out = classify_path_step(&spec, "missing", tmp.path(), &tail, None);
match out {
PathOutcome::LoadFailed(msg) => assert!(msg.contains("no fdl.yml")),
_ => panic!("expected LoadFailed, got something else"),
}
}
#[test]
fn classify_uses_explicit_path() {
let tmp = TempDir::new();
mkcmd(tmp.path(), "actual", "entry: echo\n");
let spec = CommandSpec {
path: Some("actual".into()),
..Default::default()
};
let tail: Vec<String> = vec![];
let out = classify_path_step(&spec, "label", tmp.path(), &tail, None);
assert!(matches!(out, PathOutcome::Exec { .. }));
}
fn top_commands(yaml: &str) -> BTreeMap<String, CommandSpec> {
#[derive(serde::Deserialize)]
struct Root {
#[serde(default)]
commands: BTreeMap<String, CommandSpec>,
}
serde_yaml::from_str::<Root>(yaml)
.expect("parse top-level commands")
.commands
}
fn args(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn walk_top_level_run_returns_run_script() {
let tmp = TempDir::new();
let commands = top_commands("commands:\n greet:\n run: echo hello\n");
let out = walk_commands("greet", &[], &commands, tmp.path(), None);
match out {
WalkOutcome::RunScript { command, docker, cwd } => {
assert_eq!(command, "echo hello");
assert!(docker.is_none());
assert_eq!(cwd, tmp.path());
}
_ => panic!("expected RunScript"),
}
}
#[test]
fn walk_top_level_run_with_docker_preserves_service() {
let tmp = TempDir::new();
let commands = top_commands(
"commands:\n dev:\n run: cargo test\n docker: dev\n",
);
let out = walk_commands("dev", &[], &commands, tmp.path(), None);
match out {
WalkOutcome::RunScript { docker, .. } => {
assert_eq!(docker.as_deref(), Some("dev"));
}
_ => panic!("expected RunScript with docker"),
}
}
#[test]
fn walk_run_with_help_prints_help_not_script() {
let tmp = TempDir::new();
let commands = top_commands(
"commands:\n test:\n description: Run all CPU tests\n run: cargo test\n docker: dev\n",
);
let tail = args(&["--help"]);
let out = walk_commands("test", &tail, &commands, tmp.path(), None);
match out {
WalkOutcome::PrintRunHelp {
name,
description,
run,
docker,
} => {
assert_eq!(name, "test");
assert_eq!(description.as_deref(), Some("Run all CPU tests"));
assert_eq!(run, "cargo test");
assert_eq!(docker.as_deref(), Some("dev"));
}
_ => panic!("expected PrintRunHelp"),
}
}
#[test]
fn walk_run_with_short_help_prints_help() {
let tmp = TempDir::new();
let commands = top_commands("commands:\n test:\n run: cargo test\n");
let tail = args(&["-h"]);
let out = walk_commands("test", &tail, &commands, tmp.path(), None);
assert!(matches!(out, WalkOutcome::PrintRunHelp { .. }));
}
#[test]
fn walk_unknown_top_level_returns_unknown() {
let tmp = TempDir::new();
let commands = top_commands("commands:\n greet:\n run: echo hello\n");
let out = walk_commands("nope", &args(&["arg"]), &commands, tmp.path(), None);
match out {
WalkOutcome::UnknownCommand { name } => assert_eq!(name, "nope"),
_ => panic!("expected UnknownCommand"),
}
}
#[test]
fn walk_top_level_preset_errors_without_enclosing() {
let tmp = TempDir::new();
let commands = top_commands(
"commands:\n orphan:\n options: { model: linear }\n",
);
let out = walk_commands("orphan", &[], &commands, tmp.path(), None);
match out {
WalkOutcome::PresetAtTopLevel { name } => assert_eq!(name, "orphan"),
_ => panic!("expected PresetAtTopLevel"),
}
}
#[test]
fn walk_run_and_path_both_set_is_error() {
let tmp = TempDir::new();
let commands = top_commands(
"commands:\n bad:\n run: echo hi\n path: ./sub\n",
);
let out = walk_commands("bad", &[], &commands, tmp.path(), None);
match out {
WalkOutcome::Error(msg) => {
assert!(msg.contains("bad"), "got: {msg}");
assert!(msg.contains("both `run:` and `path:`"), "got: {msg}");
}
_ => panic!("expected Error"),
}
}
#[test]
fn walk_path_exec_at_one_level() {
let tmp = TempDir::new();
mkcmd(tmp.path(), "ddp-bench", "entry: cargo run -p ddp-bench\n");
let commands = top_commands("commands:\n ddp-bench: {}\n");
let tail = args(&["--seed", "42"]);
let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
match out {
WalkOutcome::ExecCommand {
preset,
tail: returned_tail,
cmd_dir,
..
} => {
assert!(preset.is_none());
assert_eq!(returned_tail, args(&["--seed", "42"]));
assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
}
_ => panic!("expected ExecCommand"),
}
}
#[test]
fn walk_path_then_preset_at_two_levels() {
let tmp = TempDir::new();
mkcmd(
tmp.path(),
"ddp-bench",
"entry: cargo run -p ddp-bench\n\
commands:\n quick:\n options: { model: linear }\n",
);
let commands = top_commands("commands:\n ddp-bench: {}\n");
let tail = args(&["quick", "--epochs", "5"]);
let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
match out {
WalkOutcome::ExecCommand {
preset,
tail: returned_tail,
cmd_dir,
..
} => {
assert_eq!(preset.as_deref(), Some("quick"));
assert_eq!(returned_tail, args(&["--epochs", "5"]));
assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
}
_ => panic!("expected ExecCommand with preset"),
}
}
#[test]
fn walk_path_then_path_then_preset_at_three_levels() {
let tmp = TempDir::new();
mkcmd(
tmp.path(),
"a",
"entry: echo a\ncommands:\n b: {}\n",
);
let b_dir = tmp.path().join("a").join("b");
std::fs::create_dir_all(&b_dir).unwrap();
std::fs::write(
b_dir.join("fdl.yml"),
"entry: echo b\ncommands:\n quick:\n options: { x: 1 }\n",
)
.unwrap();
let commands = top_commands("commands:\n a: {}\n");
let tail = args(&["b", "quick"]);
let out = walk_commands("a", &tail, &commands, tmp.path(), None);
match out {
WalkOutcome::ExecCommand {
preset, cmd_dir, ..
} => {
assert_eq!(preset.as_deref(), Some("quick"));
assert_eq!(cmd_dir, b_dir);
}
_ => panic!("expected ExecCommand with preset at depth 3"),
}
}
#[test]
fn walk_path_child_missing_returns_error() {
let tmp = TempDir::new();
let commands = top_commands("commands:\n ghost: {}\n");
let out = walk_commands("ghost", &[], &commands, tmp.path(), None);
match out {
WalkOutcome::Error(msg) => assert!(msg.contains("no fdl.yml"), "got: {msg}"),
_ => panic!("expected Error(LoadFailed)"),
}
}
#[test]
fn walk_path_help_prints_command_help() {
let tmp = TempDir::new();
mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
let commands = top_commands("commands:\n ddp-bench: {}\n");
let tail = args(&["--help"]);
let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
match out {
WalkOutcome::PrintCommandHelp { name, .. } => assert_eq!(name, "ddp-bench"),
_ => panic!("expected PrintCommandHelp"),
}
}
#[test]
fn walk_preset_help_prints_preset_help() {
let tmp = TempDir::new();
mkcmd(
tmp.path(),
"ddp-bench",
"entry: echo\ncommands:\n quick:\n options: { x: 1 }\n",
);
let commands = top_commands("commands:\n ddp-bench: {}\n");
let tail = args(&["quick", "--help"]);
let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
match out {
WalkOutcome::PrintPresetHelp {
parent_label,
preset_name,
..
} => {
assert_eq!(preset_name, "quick");
assert_eq!(parent_label, "ddp-bench");
}
_ => panic!("expected PrintPresetHelp"),
}
}
#[test]
fn walk_path_refresh_schema() {
let tmp = TempDir::new();
mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
let commands = top_commands("commands:\n ddp-bench: {}\n");
let tail = args(&["--refresh-schema"]);
let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
match out {
WalkOutcome::RefreshSchema { cmd_name, .. } => {
assert_eq!(cmd_name, "ddp-bench");
}
_ => panic!("expected RefreshSchema"),
}
}
#[test]
fn walk_env_propagates_to_child_overlay() {
let tmp = TempDir::new();
let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
let commands = top_commands("commands:\n ddp-bench: {}\n");
let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), Some("ci"));
match out {
WalkOutcome::ExecCommand { config, .. } => {
assert_eq!(config.entry.as_deref(), Some("echo-ci"));
}
_ => panic!("expected ExecCommand with env-overlaid entry"),
}
}
#[test]
fn walk_env_none_ignores_overlay() {
let tmp = TempDir::new();
let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
let commands = top_commands("commands:\n ddp-bench: {}\n");
let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), None);
match out {
WalkOutcome::ExecCommand { config, .. } => {
assert_eq!(config.entry.as_deref(), Some("echo-base"));
}
_ => panic!("expected ExecCommand with base entry"),
}
}
}