use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, OnceLock};
use clap::CommandFactory;
use clap_complete::engine::{ArgValueCandidates, ArgValueCompleter, CompletionCandidate};
use crate::cli::Cli;
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct EnvGuard {
cwd: PathBuf,
home: Option<OsString>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.cwd);
match &self.home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
}
}
struct Fixture {
_guard: EnvGuard,
dir: tempfile::TempDir,
_lock: MutexGuard<'static, ()>,
}
impl Fixture {
fn root(&self) -> PathBuf {
std::fs::canonicalize(self.dir.path()).expect("canonicalize tempdir")
}
}
fn write(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(path, contents).expect("write fixture");
}
fn enter_workspace() -> Fixture {
let lock = env_lock();
let guard = EnvGuard {
cwd: std::env::current_dir().expect("read cwd"),
home: std::env::var_os("HOME"),
};
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write(&root.join("Cargo.toml"), "[workspace]\nmembers = []\n");
write(&root.join("graphs").join("perception.yaml"), "nodes: {}\n");
write(
&root.join("graphs").join("control_loop.yaml"),
"nodes: {}\n",
);
write(
&root
.join("nodes")
.join("lidar_filter")
.join("src")
.join("lib.rs"),
"// node\n",
);
write(
&root
.join("nodes")
.join("imu_fusion")
.join("src")
.join("lib.rs"),
"// node\n",
);
write(
&root.join("schemas").join("my_detection.yaml"),
"schemas: {}\n",
);
write(
&root
.join("schemas")
.join("go2_msgs")
.join("msg")
.join("LowState.msg"),
"uint8 level\n",
);
write(&root.join("run1.mcap"), "");
write(&root.join("run2.mcap"), "");
write(&root.join("notes.txt"), "");
write(
&root.join("home").join(".cerulion").join("robots.toml"),
"[robots]\nspot_lab = \"aabbcc\"\n",
);
std::env::set_var("HOME", root.join("home"));
std::env::set_current_dir(root).expect("enter workspace");
Fixture {
dir,
_guard: guard,
_lock: lock,
}
}
fn complete(words: &[&str]) -> Vec<String> {
let mut cmd = Cli::command();
let args: Vec<OsString> = words.iter().map(OsString::from).collect();
let index = args.len() - 1;
let cwd = std::env::current_dir().ok();
let candidates: Vec<CompletionCandidate> =
clap_complete::engine::complete(&mut cmd, args, index, cwd.as_deref())
.expect("completion engine");
candidates
.into_iter()
.filter(|c| !c.is_hide_set())
.map(|c| c.get_value().to_string_lossy().into_owned())
.collect()
}
fn values(words: &[&str]) -> Vec<String> {
let mut out: Vec<String> = complete(words)
.into_iter()
.filter(|c| !c.starts_with('-'))
.collect();
out.sort();
out
}
#[test]
fn graph_run_completes_the_scaffolded_graph_names() {
let _fx = enter_workspace();
assert_eq!(
values(&["cerulion", "graph", "run", ""]),
["control_loop", "perception"]
);
assert_eq!(values(&["cerulion", "graph", "run", "per"]), ["perception"]);
}
#[test]
fn node_build_completes_the_scaffolded_node_types() {
let _fx = enter_workspace();
assert_eq!(
values(&["cerulion", "node", "build", ""]),
["imu_fusion", "lidar_filter"]
);
}
#[test]
fn ros2_offers_attach_and_the_removed_ros_family_is_not_offered() {
let _fx = enter_workspace();
let ros2_verbs = values(&["cerulion", "ros2", ""]);
for verb in ["attach", "run", "launch"] {
assert!(
ros2_verbs.contains(&verb.to_string()),
"`cerulion ros2` must offer `{verb}`, got {ros2_verbs:?}"
);
}
let flags = complete(&["cerulion", "ros2", "attach", "--"]);
assert!(
flags.contains(&"--iface".to_string()),
"`cerulion ros2 attach` must complete `--iface`, got {flags:?}"
);
let top = values(&["cerulion", ""]);
assert!(
top.contains(&"ros2".to_string()),
"top level must offer `ros2`, got {top:?}"
);
assert!(
!top.contains(&"ros".to_string()),
"the removed `ros` family must not be offered at top level, got {top:?}"
);
}
#[test]
fn schema_info_completes_workspace_msg_store_and_builtin_names() {
let _fx = enter_workspace();
let got = values(&["cerulion", "schema", "info", ""]);
assert!(
got.contains(&"my_detection".to_string()),
"workspace schema missing"
);
assert!(
got.contains(&"go2_msgs/LowState".to_string()),
"msg store missing"
);
assert!(
got.contains(&"sensor_msgs/Image".to_string()),
"built-in missing"
);
}
#[test]
fn viz_robot_completes_the_paired_robot_from_the_isolated_home() {
let _fx = enter_workspace();
assert_eq!(values(&["cerulion", "viz", "--robot", ""]), ["spot_lab"]);
}
#[test]
fn create_verbs_offer_no_value_candidates_while_their_siblings_do() {
let _fx = enter_workspace();
for verb in [
["cerulion", "node", "create", ""],
["cerulion", "graph", "create", ""],
["cerulion", "schema", "create", ""],
] {
assert!(
values(&verb).is_empty(),
"`{}` must offer no value candidates, got {:?}",
verb.join(" "),
values(&verb)
);
}
assert_eq!(
values(&["cerulion", "node", "delete", ""]),
["imu_fusion", "lidar_filter"]
);
assert_eq!(
values(&["cerulion", "graph", "validate", ""]),
["control_loop", "perception"]
);
assert!(values(&["cerulion", "schema", "delete", ""]).contains(&"my_detection".to_string()));
}
#[test]
fn bag_play_completes_mcap_bags_and_directories_but_not_other_files() {
let _fx = enter_workspace();
let got = values(&["cerulion", "bag", "play", ""]);
assert!(
got.contains(&"run1.mcap".to_string()),
"bag missing from {got:?}"
);
assert!(
got.contains(&"run2.mcap".to_string()),
"bag missing from {got:?}"
);
assert!(
!got.contains(&"notes.txt".to_string()),
"the .mcap filter is not applied: {got:?}"
);
assert!(
got.iter().any(|c| c == "graphs/"),
"directories must stay traversable: {got:?}"
);
}
#[test]
fn bag_migrate_completes_mcap_bags_on_both_the_input_and_the_output() {
let _fx = enter_workspace();
for argv in [
vec!["cerulion", "bag", "migrate", ""],
vec!["cerulion", "bag", "migrate", "run1.mcap", "-o", ""],
] {
let got = values(&argv);
assert!(
got.contains(&"run1.mcap".to_string()) && got.contains(&"run2.mcap".to_string()),
"bags missing from {argv:?}: {got:?}"
);
assert!(
!got.contains(&"notes.txt".to_string()),
"the .mcap filter is not applied on {argv:?}: {got:?}"
);
assert!(
got.iter().any(|c| c == "graphs/"),
"directories must stay traversable on {argv:?}: {got:?}"
);
}
}
#[test]
fn hinted_path_args_still_fall_back_to_file_completion() {
let fx = enter_workspace();
let root = fx.root();
let tolerance = values(&["cerulion", "bag", "play", "x.mcap", "--tolerance", ""]);
assert!(
tolerance.contains(&"notes.txt".to_string()),
"an input-file arg must complete files: {tolerance:?}"
);
let schemas_dir = values(&["cerulion", "connect", "--schemas-dir", ""]);
assert!(
schemas_dir.contains(&"schemas/".to_string()),
"a directory arg must complete directories: {schemas_dir:?}"
);
assert!(
!schemas_dir.contains(&"notes.txt".to_string()),
"a directory arg must not complete plain files: {schemas_dir:?}"
);
assert!(root.join("notes.txt").is_file());
}
fn wired_args() -> Vec<String> {
fn walk(cmd: &clap::Command, path: &str, out: &mut Vec<String>) {
for arg in cmd.get_arguments() {
let wired = arg.get::<ArgValueCandidates>().is_some()
|| arg.get::<ArgValueCompleter>().is_some();
if wired {
out.push(format!("{path}::{}", arg.get_id()));
}
}
for sub in cmd.get_subcommands() {
walk(sub, &format!("{path} {}", sub.get_name()), out);
}
}
let mut cmd = Cli::command();
cmd.build();
let mut out = Vec::new();
walk(&cmd, "cerulion", &mut out);
out.sort();
out
}
#[test]
fn the_wired_completer_inventory_is_exactly_the_declared_set() {
let expected = [
"cerulion bag info::bag",
"cerulion bag migrate::bag",
"cerulion bag migrate::out",
"cerulion bag play::bag",
"cerulion bag play::resim",
"cerulion bag record::topics",
"cerulion connect::robot",
"cerulion graph levels::name",
"cerulion graph partition::name",
"cerulion graph profile::name",
"cerulion graph run::name",
"cerulion graph validate::name",
"cerulion node build::node_type",
"cerulion node delete::node_type",
"cerulion node info::node_type",
"cerulion node modify::node_type",
"cerulion node run::node_type",
"cerulion node stage::graph",
"cerulion node stage::node_type",
"cerulion pair::robot",
"cerulion topic echo::topic",
"cerulion topic hz::topic",
"cerulion topic info::topic",
"cerulion schema delete::name",
"cerulion schema info::name",
"cerulion viz::robot",
"cerulion viz::topics",
];
let mut expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
expected.sort();
assert_eq!(wired_args(), expected);
for forbidden in [
"cerulion node create::node_type",
"cerulion graph create::name",
"cerulion schema create::name",
] {
assert!(
!wired_args().iter().any(|w| w == forbidden),
"`{forbidden}` completes names the verb REJECTS — see CER-925 R1 finding 3"
);
}
}
#[test]
fn the_help_text_quotes_the_same_install_line_the_hint_prints() {
let mut cmd = crate::cli::Cli::command();
cmd.build();
let long_about = cmd
.get_subcommands()
.find(|c| c.get_name() == "completions")
.and_then(|c| c.get_long_about())
.expect("completions long help")
.to_string();
for shell in <crate::completion::CompletionShell as clap::ValueEnum>::value_variants() {
for line in shell.install_hint().lines() {
assert!(
long_about.contains(line),
"`cerulion completions --help` does not quote the {shell:?} \
install line it would print:\n {line}\n\
The two surfaces have drifted — fix whichever is wrong."
);
}
}
}
fn args_completing_nothing() -> Vec<String> {
fn walk(cmd: &clap::Command, path: &str, out: &mut Vec<String>) {
for arg in cmd.get_arguments() {
if matches!(
arg.get_action(),
clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
) || arg.get_num_args().is_some_and(|r| r.max_values() == 0)
{
continue;
}
let covered = arg.get_value_hint() != clap::ValueHint::Unknown
|| arg.get::<ArgValueCompleter>().is_some()
|| arg.get::<ArgValueCandidates>().is_some()
|| !arg.get_possible_values().is_empty();
if !covered {
out.push(format!("{path}::{}", arg.get_id()));
}
}
for sub in cmd.get_subcommands() {
walk(sub, &format!("{path} {}", sub.get_name()), out);
}
}
let mut cmd = Cli::command();
cmd.build();
let mut out = Vec::new();
walk(&cmd, "cerulion", &mut out);
out.sort();
out.dedup();
out
}
#[test]
fn every_arg_that_completes_nothing_is_a_declared_free_form_value() {
let declared_free_form = [
"cerulion graph create::name",
"cerulion node create::node_type",
"cerulion ros2 attach::graph_name",
"cerulion schema create::name",
"cerulion workspace create::name",
"cerulion flashback::note",
"cerulion graph create::prefix",
"cerulion node run::id",
"cerulion node run::prefix",
"cerulion node stage::id",
"cerulion node stage::prefix",
"cerulion pair::label",
"cerulion ros2 attach::robot_name",
"cerulion ros2 attach::topic_prefix",
"cerulion node create::input",
"cerulion node create::output",
"cerulion node create::trigger_input",
"cerulion node modify::input",
"cerulion node modify::output",
"cerulion node modify::trigger_input",
"cerulion node stage::input_binding",
"cerulion account devices revoke::device_id",
"cerulion connect::eid",
"cerulion pair::account",
"cerulion pair::code",
"cerulion pair::eid",
"cerulion connect::addrs",
"cerulion connect::relay_url",
"cerulion pair::addrs",
"cerulion pair::relay_url",
"cerulion ros2 attach::iface",
"cerulion topic list::connect",
"cerulion topic list::listen",
"cerulion viz::connect",
"cerulion viz::listen",
"cerulion connect::network",
"cerulion graph run::record_cpu",
"cerulion node create::policy",
"cerulion node modify::policy",
"cerulion trace inspect::filter",
"cerulion ros2 launch::args",
"cerulion ros2 run::args",
"cerulion ros::args",
"cerulion connect::topics",
"cerulion bag play::topics",
"cerulion bag record::exclude",
"cerulion bag record::regex",
"cerulion bag record::run",
"cerulion bag play::duration",
"cerulion bag play::rate",
"cerulion bag play::start_offset",
"cerulion bag record::duration",
"cerulion bag record::schema_wait_ms",
"cerulion graph partition::budget_ns",
"cerulion graph profile::duration",
"cerulion graph profile::fires",
"cerulion graph run::trace_limit",
"cerulion ros2 attach::domain",
"cerulion ros2 attach::timeout",
"cerulion topic echo::truncate_length",
"cerulion trace inspect::limit",
"cerulion bagd::attach", "cerulion bagd::discovery_settle_ms",
"cerulion bagd::flashback_label",
"cerulion bagd::flush_interval_ms",
"cerulion bagd::rings",
"cerulion bagd::run_id",
"cerulion bagd::schema_demand_timeout_ms",
"cerulion bagd::schema_wait_timeout_ms",
"cerulion bagd::status_period_ms",
"cerulion bagd::state_rings",
"cerulion bagd::state_tag",
"cerulion bagd::topics",
];
let mut expected: Vec<String> = declared_free_form.iter().map(|s| s.to_string()).collect();
expected.sort();
assert_eq!(
args_completing_nothing(),
expected,
"\nAn argument's completion coverage changed.\n\
If a NEW argument appears: is its value a PATH? Give it a \
`value_hint` (FilePath / DirPath / AnyPath) — do NOT add it to the \
declared list, or it will silently complete nothing. Add it only if \
the value is genuinely free-form (an invented name, an opaque id, a \
number, a locator).\n\
If one DISAPPEARED: it grew a hint or a completer — drop it from the \
list."
);
}