mod cli;
mod completion;
#[cfg(test)]
mod completion_wiring_tests;
#[cfg(test)]
mod cfg_symmetry_tests;
#[cfg(test)]
mod clean_diagnostic_tests;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use clap::{CommandFactory, Parser};
use cerulion_cli_engine::error::CliResult;
use cerulion_cli_engine::workspace::CerulionWorkspace;
use cerulion_cli_engine::{
account_cmd, connect_cmd, graph_cmd, login_cmd, node_cmd, pair_cmd, partition_emit, ros_cmd,
schema_cmd, topic_cmd, viz_client, viz_cmd, workspace,
};
use cli::{
AccountAction, BagAction, Cli, Commands, DevicesAction, GraphAction, NodeAction, Ros2Action,
SchemaAction, TopicAction, TraceAction, WorkspaceAction,
};
const ROS_VERB_MOVED_MSG: &str = "`cerulion ros attach` has moved: the verb is now `cerulion ros2 attach` (the `ros` family was folded into `ros2`). Re-run the same invocation with `ros2` in place of `ros`.";
fn main() -> ExitCode {
clap_complete::CompleteEnv::with_factory(Cli::command).complete();
if std::env::var_os("IOX2_LOG_LEVEL").is_none() {
std::env::set_var("IOX2_LOG_LEVEL", "error");
}
cerulion_core::iceoryx_logger::init_iceoryx_log_level_from_env();
#[cfg(unix)]
if let Some(code) = ros2_passthrough_intercept() {
return code;
}
let cli = Cli::parse();
#[cfg(unix)]
let cli = match cli {
Cli {
command: Commands::Bagd(args),
..
} => return cerulion_bagd::bagd_cli_main(*args),
other => other,
};
#[cfg(not(unix))]
if matches!(cli.command, Commands::Bagd) {
eprintln!(
"Error: `cerulion bagd` is only supported on Unix platforms (the recorder is \
driven by SIGTERM lifecycle signals over POSIX shared memory)"
);
return ExitCode::FAILURE;
}
if matches!(cli.command, Commands::Ros { .. }) {
eprintln!("Error: {ROS_VERB_MOVED_MSG}");
return ExitCode::from(2);
}
if !matches!(cli.command, Commands::Tui) {
init_logging(cli.verbose, cli.command.log_verb_class().is_quiet_default());
}
let _ = cerulion_core::iceoryx_logger::install_iceoryx2_tracing_bridge();
if let Commands::Bag { action } = &cli.command {
if let Some(refusal) = resim_usage_refusal(action) {
eprintln!("Error: {refusal}");
return ExitCode::from(cerulion_cli_engine::resim_cmd::EXIT_USAGE);
}
}
if command_needs_identity(&cli.command) {
if let Err(e) = login_cmd::ensure_login_gate(&mut std::io::stderr()) {
eprintln!("Error: {e}");
return ExitCode::from(cerulion_cli_engine::login_cmd::EXIT_AUTH_REQUIRED);
}
}
if let Commands::Bag { action } = &cli.command {
if is_resim_family(action) {
return resim_exit_code(cli.command);
}
}
if matches!(cli.command, Commands::Connect { .. }) {
return connect_exit_code(cli.command);
}
if matches!(cli.command, Commands::Pair { .. }) {
return pair_exit_code(cli.command);
}
if matches!(
cli.command,
Commands::Ros2 {
action: Ros2Action::Run { .. } | Ros2Action::Launch { .. }
}
) {
let Commands::Ros2 { action } = cli.command else {
unreachable!("guarded by the matches! above")
};
let (verb, args) = match action {
Ros2Action::Run { args } => (cerulion_cli_engine::ros2_cmd::Ros2NativeVerb::Run, args),
Ros2Action::Launch { args } => {
(cerulion_cli_engine::ros2_cmd::Ros2NativeVerb::Launch, args)
}
Ros2Action::Attach { .. } => unreachable!("guarded by the matches! above"),
};
return ros2_exit_code(verb, args);
}
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error: {}", e);
ExitCode::FAILURE
}
}
}
fn resim_usage_refusal(action: &BagAction) -> Option<String> {
cerulion_cli_engine::resim_cmd::resolve_play_mode(play_flags_of(action)?).err()
}
fn play_flags_of(action: &BagAction) -> Option<cerulion_cli_engine::resim_cmd::PlayFlags> {
let BagAction::Play {
resim,
verify,
rate,
repeat,
topics,
duration,
start_offset,
report,
tolerance,
strict_state,
..
} = action
else {
return None;
};
Some(cerulion_cli_engine::resim_cmd::PlayFlags {
resim: resim.clone(),
verify: *verify,
rate: *rate,
repeat: *repeat,
topics: topics.clone(),
duration: *duration,
start_offset: *start_offset,
report: report.clone(),
tolerance: tolerance.clone(),
strict_state: *strict_state,
})
}
fn is_resim_family(action: &BagAction) -> bool {
match action {
BagAction::Play {
resim,
verify,
report,
tolerance,
strict_state,
..
} => {
resim.is_some() || *verify || report.is_some() || tolerance.is_some() || *strict_state
}
BagAction::Info { .. } | BagAction::Record { .. } | BagAction::Migrate { .. } => false,
}
}
#[cfg(unix)]
fn resim_exit_code(command: Commands) -> ExitCode {
let Commands::Bag { action } = &command else {
unreachable!("`resim_exit_code` is reached only for a `bag play` resim-family invocation")
};
let (BagAction::Play { bag, .. }, Some(flags)) = (action, play_flags_of(action)) else {
unreachable!("`resim_exit_code` is reached only for a `bag play` resim-family invocation")
};
ExitCode::from(cerulion_cli_engine::resim_cmd::run_play_resim(bag, flags))
}
#[cfg(unix)]
fn run_bag(action: BagAction) -> CliResult<()> {
use cerulion_cli_engine::bag_cmd;
use std::io::Write as _;
match action {
BagAction::Info { bag } => {
let ws = discover_workspace().ok();
let schemas_dir = ws.as_ref().map(|w| w.schemas_dir.clone());
print!("{}", bag_cmd::bag_info(&bag, schemas_dir.as_deref())?);
std::io::stdout().flush()?;
Ok(())
}
BagAction::Play {
bag,
resim,
verify,
rate,
repeat,
topics,
duration,
start_offset,
report,
tolerance,
strict_state,
} => {
let mode = cerulion_cli_engine::resim_cmd::resolve_play_mode(
cerulion_cli_engine::resim_cmd::PlayFlags {
resim,
verify,
rate,
repeat,
topics,
duration,
start_offset,
report,
tolerance,
strict_state,
},
)
.map_err(cerulion_cli_engine::error::CliError::Validation)?;
let cerulion_cli_engine::resim_cmd::PlayMode::Playback {
rate,
repeat,
topics,
duration_bound_ns,
start_offset_ns,
} = mode
else {
unreachable!("`bag play --resim` is dispatched in main() before run()")
};
let running = setup_ctrlc_handler()?;
let mut out = std::io::stdout();
let summary = bag_cmd::bag_play(
&bag,
bag_cmd::PlayOptions {
rate,
repeat,
topics,
start_offset_ns,
duration_bound_ns,
schemas_dir: discover_workspace().ok().map(|w| w.schemas_dir),
},
running,
&mut out,
)?;
print!("{}", bag_cmd::render_play_summary(&summary));
out.flush()?;
Ok(())
}
BagAction::Record {
topics,
all,
regex,
exclude,
out: out_path,
duration,
schema_wait_ms,
run,
} => {
let running = setup_ctrlc_handler()?;
let mut out = std::io::stdout();
let summary = bag_cmd::bag_record(
bag_cmd::RecordOptions {
topics,
all,
regex,
exclude,
out: out_path,
duration: duration.map(std::time::Duration::from_secs),
max_bag_size: None,
schema_wait: std::time::Duration::from_millis(schema_wait_ms),
run: run.map(|r| {
if r.trim().is_empty() {
bag_cmd::RunTarget::Sole
} else {
bag_cmd::RunTarget::Named(r)
}
}),
schemas_dir: discover_workspace().ok().map(|w| w.schemas_dir),
},
running,
&mut out,
)?;
print!("{}", bag_cmd::render_record_summary(&summary));
out.flush()?;
Ok(())
}
BagAction::Migrate {
bag,
out: out_path,
dry_run,
yes,
} => {
use cerulion_cli_engine::bag_migrate;
use std::io::IsTerminal as _;
let opts = bag_migrate::MigrateOptions {
out: out_path,
dry_run,
assume_yes: yes,
};
let is_tty = std::io::stdin().is_terminal();
let mut confirm =
|preview: &str| prompt_yes_no(preview, "Write the migrated bag to disk?");
let report = bag_migrate::bag_migrate(
&bag,
&opts,
bag_migrate::MigrationStamp::now(),
is_tty,
&mut confirm,
)?;
if !report.preview_shown {
print!("{}", report.preview);
}
match report.outcome {
bag_migrate::MigrateOutcome::DryRun => {
println!("\ndry run — nothing written.");
}
bag_migrate::MigrateOutcome::Written => {
println!("\nMigrated bag written to {}", report.output.display());
println!("The original '{}' is unchanged.", report.input.display());
}
bag_migrate::MigrateOutcome::Declined => {
println!("\naborted — nothing written.");
}
}
std::io::stdout().flush()?;
Ok(())
}
}
}
#[cfg(not(unix))]
fn run_bag(_action: BagAction) -> CliResult<()> {
Err(cerulion_cli_engine::error::CliError::Validation(
"`cerulion bag` is only supported on Unix platforms (the bag reader/writer depends on \
Unix-only POSIX trace-ring types)"
.to_string(),
))
}
#[cfg(not(unix))]
fn resim_exit_code(_command: Commands) -> ExitCode {
eprintln!(
"Error: `cerulion bag play --resim` is only supported on Unix platforms (the bag reader \
depends on Unix-only POSIX trace-ring types)"
);
ExitCode::FAILURE
}
fn connect_exit_code(command: Commands) -> ExitCode {
let Commands::Connect {
robot,
eid,
addrs,
topics,
all,
key_file,
schemas_dir,
relay_url,
relay_disabled,
network,
} = command
else {
unreachable!("connect_exit_code is only called for Commands::Connect");
};
let args = connect_cmd::ConnectArgs {
robot,
eid,
addrs,
topics,
all,
key_file,
schemas_dir,
relay_url,
relay_disabled,
network,
};
let plan = match connect_cmd::plan(&args) {
Ok(p) => p,
Err(e) => {
eprintln!("Error: {e}");
return ExitCode::FAILURE;
}
};
let running = match setup_ctrlc_handler() {
Ok(r) => r,
Err(e) => {
eprintln!("warning: could not install a signal handler: {e}");
Arc::new(AtomicBool::new(true))
}
};
tracing::info!(bin = %plan.bin.display(), "cerulion connect: spawning cerulion-connectd");
match connect_cmd::spawn_and_wait(&plan, running) {
Ok(code) => ExitCode::from(code as u8),
Err(e) => {
eprintln!("Error: {e}");
ExitCode::FAILURE
}
}
}
fn pair_exit_code(command: Commands) -> ExitCode {
let Commands::Pair {
robot,
eid,
addrs,
code,
account,
label,
key_file,
relay_url,
relay_disabled,
} = command
else {
unreachable!("pair_exit_code is only called for Commands::Pair");
};
let args = pair_cmd::PairArgs {
robot,
eid,
addrs,
code,
account,
label,
key_file,
relay_url,
relay_disabled,
};
let plan = match pair_cmd::plan(&args) {
Ok(p) => p,
Err(e) => {
eprintln!("Error: {e}");
return ExitCode::from(1u8);
}
};
let running = match setup_ctrlc_handler() {
Ok(r) => r,
Err(e) => {
eprintln!("warning: could not install a signal handler: {e}");
Arc::new(AtomicBool::new(true))
}
};
tracing::info!(bin = %plan.bin.display(), "cerulion pair: spawning cerulion-connectd pair");
match pair_cmd::spawn_and_pair(&plan, running) {
Ok(code) => ExitCode::from(code as u8),
Err(e) => {
eprintln!("Error: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(unix)]
fn ros2_passthrough_intercept() -> Option<ExitCode> {
let argv: Vec<String> = std::env::args().collect();
let mut i = 1;
let mut verbose = false;
while i < argv.len() && (argv[i] == "-v" || argv[i] == "--verbose") {
verbose = true;
i += 1;
}
if argv.get(i).map(String::as_str) != Some("ros2") {
return None;
}
let verb = match argv.get(i + 1).map(String::as_str) {
Some("run") => cerulion_cli_engine::ros2_cmd::Ros2NativeVerb::Run,
Some("launch") => cerulion_cli_engine::ros2_cmd::Ros2NativeVerb::Launch,
_ => return None,
};
init_logging(verbose, false);
Some(ros2_exit_code(verb, argv[i + 2..].to_vec()))
}
#[cfg(unix)]
fn ros2_exit_code(
verb: cerulion_cli_engine::ros2_cmd::Ros2NativeVerb,
args: Vec<String>,
) -> ExitCode {
use cerulion_cli_engine::ros2_cmd;
let plan = match ros2_cmd::resolve_lib_dir().and_then(|lib_dir| {
let ament_prefix = ros2_cmd::stage_ament_prefix(&lib_dir)?;
ros2_cmd::build_ros2_passthrough_plan(verb, &args, &lib_dir, &ament_prefix)
}) {
Ok(plan) => plan,
Err(e) => {
eprintln!("Error: {e}");
return ExitCode::from(ros2_cmd::classify(&e));
}
};
let err = ros2_cmd::exec_ros2(&plan);
let code = ros2_cmd::classify(&err);
if code == ros2_cmd::EXIT_ROS2_NOT_FOUND {
eprintln!(
"Error: `ros2` not found on PATH — install ROS 2 (or source its setup script) so \
the real `ros2` is resolvable"
);
} else {
eprintln!("Error: {err}");
}
ExitCode::from(code)
}
#[cfg(not(unix))]
fn ros2_exit_code(
_verb: cerulion_cli_engine::ros2_cmd::Ros2NativeVerb,
_args: Vec<String>,
) -> ExitCode {
eprintln!(
"Error: `cerulion ros2` is only supported on Unix platforms (LD_PRELOAD + rmw_cerulion \
require the Unix dynamic loader and exec() semantics)"
);
ExitCode::FAILURE
}
fn run(cli: Cli) -> CliResult<()> {
match cli.command {
Commands::Workspace { action } => match action {
WorkspaceAction::Create { name } => {
let cwd = std::env::current_dir()?;
let ws = workspace::workspace_create(&cwd, &name)?;
println!("Created workspace at {}", ws.root.display());
if let Some(source) = &ws.dependency_source {
println!(" dependencies: {source}");
}
Ok(())
}
WorkspaceAction::Init { location } => {
let path = PathBuf::from(&location)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(&location));
let ws = workspace::workspace_init(&path)?;
println!("Initialized workspace at {}", ws.root.display());
if let Some(source) = &ws.dependency_source {
println!(" dependencies: {source}");
}
Ok(())
}
},
Commands::Node { action } => {
let ws = discover_workspace()?;
match action {
NodeAction::Create {
node_type,
output,
input,
trigger_input,
policy,
raw_ffi,
} => {
if trigger_input.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-T` per node; for sync over multiple inputs use \
`-i` for each plus `--policy sync_window_ms=N`"
.to_string(),
));
}
if input.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-i` per `node create` (use `node modify` to add \
more inputs after creation)"
.to_string(),
));
}
if output.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-o` per `node create` (use `node modify` to add \
more outputs after creation)"
.to_string(),
));
}
let outputs: Vec<(String, String)> = if output.is_empty() {
vec![]
} else {
let (schema, name) = parse_port_args(&output);
vec![(resolve_and_report(&ws.schemas_dir, &schema)?, name)]
};
let regular_inputs: Vec<(String, String)> = if input.is_empty() {
vec![]
} else {
let (schema, name) = parse_port_args(&input);
vec![(resolve_and_report(&ws.schemas_dir, &schema)?, name)]
};
let trigger_inputs: Vec<(String, String)> = if trigger_input.is_empty() {
vec![]
} else {
let (schema, name) = parse_port_args(&trigger_input);
vec![(resolve_and_report(&ws.schemas_dir, &schema)?, name)]
};
let explicit_policy = match policy.as_deref() {
Some(spec) => Some(parse_policy_spec(spec)?),
None => None,
};
let resolved_policy = resolve_create_policy(
explicit_policy.as_ref(),
trigger_inputs.first(),
®ular_inputs,
)?;
check_dash_t_dash_i_name_collision(&trigger_inputs, ®ular_inputs)?;
let mut combined_inputs = trigger_inputs.clone();
combined_inputs.extend(regular_inputs.iter().cloned());
let trigger_name = trigger_inputs
.first()
.map(|(_, name)| name.clone())
.or_else(|| match resolved_policy.as_ref() {
Some(cerulion_core::MacroPolicy::DataTrigger { input_name }) => {
Some(input_name.clone())
}
_ => None,
});
let options = node_cmd::NodeCreateOptions {
outputs,
inputs: combined_inputs,
trigger: trigger_name,
raw_ffi,
};
node_cmd::node_create_with_options(
&ws.nodes_dir,
&ws.root.join("Cargo.toml"),
&node_type,
resolved_policy,
&options,
)?;
println!("Created node type '{}'", node_type);
Ok(())
}
NodeAction::Delete { node_type } => {
node_cmd::node_delete(&ws.nodes_dir, &ws.root.join("Cargo.toml"), &node_type)?;
println!("Deleted node type '{}'", node_type);
Ok(())
}
NodeAction::Modify {
node_type,
input,
trigger_input,
output,
policy,
} => {
if trigger_input.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-T` per call; for sync over multiple inputs use \
`-i` for each plus `--policy sync_window_ms=N`"
.to_string(),
));
}
if input.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-i` per `node modify` call".to_string(),
));
}
if output.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-o` per `node modify` call".to_string(),
));
}
let regular_inputs: Vec<(String, String)> = if input.is_empty() {
vec![]
} else {
let (schema, name) = parse_port_args(&input);
vec![(resolve_and_report(&ws.schemas_dir, &schema)?, name)]
};
let trigger_inputs: Vec<(String, String)> = if trigger_input.is_empty() {
vec![]
} else {
let (schema, name) = parse_port_args(&trigger_input);
vec![(resolve_and_report(&ws.schemas_dir, &schema)?, name)]
};
let explicit_policy = match policy.as_deref() {
Some(spec) => Some(parse_policy_spec(spec)?),
None => None,
};
let effective_policy: Option<cerulion_core::MacroPolicy> = match (
explicit_policy,
trigger_inputs.first(),
) {
(Some(p), None) => Some(p),
(None, Some((_, name))) => Some(cerulion_core::MacroPolicy::DataTrigger {
input_name: name.clone(),
}),
(
Some(cerulion_core::MacroPolicy::DataTrigger { input_name }),
Some((_, t_name)),
) => {
if input_name != *t_name {
return Err(cerulion_cli_engine::error::CliError::Validation(
format!(
"`--policy data_trigger={input_name}` and \
`-T <SCHEMA> {t_name}` disagree on the trigger input"
),
));
}
Some(cerulion_core::MacroPolicy::DataTrigger {
input_name: input_name.clone(),
})
}
(Some(_), Some(_)) => {
return Err(cerulion_cli_engine::error::CliError::Validation(
"`-T` is shorthand for `--policy data_trigger=<NAME>`; supply one or the other, not both"
.to_string(),
));
}
(None, None) => None,
};
if let Some(cerulion_core::MacroPolicy::DataTrigger { input_name }) =
effective_policy.as_ref()
{
let added_in_this_call = trigger_inputs
.iter()
.chain(regular_inputs.iter())
.any(|(_, n)| n == input_name);
if !added_in_this_call {
let metadata = node_cmd::node_info(&ws.nodes_dir, &node_type)?;
let exists = metadata.inputs.iter().any(|p| p.name == *input_name);
if !exists {
return Err(cerulion_cli_engine::error::CliError::Validation(
format!(
"`--policy data_trigger={input_name}` requires the input \
to exist; add it via `-i SCHEMA {input_name}` (or \
`-T SCHEMA {input_name}`) in the same call or in a \
prior `node modify`"
),
));
}
}
}
if !output.is_empty() {
let (schema, name) = parse_port_args(&output);
let schema = resolve_and_report(&ws.schemas_dir, &schema)?;
node_cmd::node_modify_add_port(
&ws.nodes_dir,
&node_type,
&name,
Some(&schema),
true,
false,
)?;
println!("Added output '{}' to '{}'", name, node_type);
}
let trigger_target_name = match effective_policy.as_ref() {
Some(cerulion_core::MacroPolicy::DataTrigger { input_name }) => {
Some(input_name.clone())
}
_ => None,
};
for (schema, name) in trigger_inputs.iter().chain(regular_inputs.iter()) {
let mark_as_trigger = trigger_target_name
.as_deref()
.map(|t| t == name.as_str())
.unwrap_or(false);
node_cmd::node_modify_add_port(
&ws.nodes_dir,
&node_type,
name,
Some(schema),
false,
mark_as_trigger,
)?;
if mark_as_trigger {
println!(
"Added input '{}' to '{}' as the data trigger",
name, node_type
);
} else {
println!("Added input '{}' to '{}'", name, node_type);
}
}
if let Some(spec) = effective_policy.as_ref() {
match spec {
cerulion_core::MacroPolicy::DataTrigger { input_name } => {
let added_in_this_call = trigger_inputs
.iter()
.chain(regular_inputs.iter())
.any(|(_, n)| n == input_name);
if !added_in_this_call {
node_cmd::node_modify_promote_input_to_trigger(
&ws.nodes_dir,
&node_type,
input_name,
)?;
println!(
"Promoted existing input '{}' to data trigger on '{}'",
input_name, node_type
);
}
}
cerulion_core::MacroPolicy::External => {
node_cmd::node_modify_ext_trigger(&ws.nodes_dir, &node_type, true)?;
println!("Set policy=external on '{}'", node_type);
}
cerulion_core::MacroPolicy::Period { period_ms } => {
node_cmd::node_modify_set_period(
&ws.nodes_dir,
&node_type,
*period_ms,
)?;
println!("Set policy=period_ms={} on '{}'", period_ms, node_type);
}
cerulion_core::MacroPolicy::Sync { window_ms } => {
node_cmd::node_modify_set_sync(
&ws.nodes_dir,
&node_type,
*window_ms,
)?;
println!(
"Set policy=sync_window_ms={} on '{}'",
window_ms, node_type
);
}
cerulion_core::MacroPolicy::UnboundedSync => {
node_cmd::node_modify_set_policy(
&ws.nodes_dir,
&node_type,
&cerulion_core::MacroPolicy::UnboundedSync,
)?;
println!("Set policy=unbounded_sync on '{}'", node_type);
}
}
}
Ok(())
}
NodeAction::Build { node_type, release } => {
let _outcome = node_cmd::node_build_with_reporter(
&ws.root,
&node_type,
release,
&mut |notice| eprint!("{notice}"),
)?;
println!("Built '{}'", node_type);
Ok(())
}
NodeAction::Stage {
node_type,
id,
graph,
prefix: _,
input_binding,
} => {
let graph_name = resolve_graph_name(&ws, graph)?;
let inputs: Vec<(String, String)> = parse_input_bindings(&input_binding);
graph_cmd::stage_declared_node(
&ws.nodes_dir,
&ws.graphs_dir,
&graph_name,
&node_type,
id.as_deref(),
&inputs,
)?;
println!("Staged '{}' into graph '{}'", node_type, graph_name);
Ok(())
}
NodeAction::Run {
node_type,
prefix,
id,
release,
no_cpu_dma_lock,
no_monitor_wait,
network,
} => {
let running = setup_ctrlc_handler()?;
let prefix = prefix.unwrap_or_else(|| "standalone".to_string());
let metadata = node_cmd::node_info(&ws.nodes_dir, &node_type)?;
let outputs: Vec<(String, Option<String>)> = metadata
.outputs
.iter()
.map(|p| (p.name.clone(), p.schema.clone()))
.collect();
let node_def =
graph_cmd::build_node_def(&node_type, id.as_deref(), &outputs, &[]);
let temp_graph = format!("__temp_{}", node_type);
graph_cmd::graph_create(&ws.graphs_dir, &temp_graph, Some(&prefix))?;
graph_cmd::node_stage(&ws.graphs_dir, &temp_graph, node_def)?;
let result = graph_cmd::graph_run(
&ws.root,
&ws.graphs_dir,
&temp_graph,
running,
graph_cmd::TimeSource::Real, if no_cpu_dma_lock {
graph_cmd::CpuDmaLockMode::Disabled
} else {
graph_cmd::CpuDmaLockMode::Auto
},
if no_monitor_wait {
graph_cmd::MonitorWaitMode::Disabled
} else {
graph_cmd::MonitorWaitMode::Auto
},
true, release, None,
false,
network.is_some(),
graph_cmd::PRODUCTION_TRACE_LIMIT,
None, graph_cmd::RecordEnvMode::default(), graph_cmd::RecordCpu::default(), None,
false,
);
let _ =
std::fs::remove_file(ws.graphs_dir.join(format!("{}.yaml", temp_graph)));
result
}
NodeAction::List => {
let nodes = node_cmd::node_list(&ws.nodes_dir)?;
if nodes.is_empty() {
println!("No nodes found.");
} else {
println!("{:<20} {:<10} {:<10} POLICY", "TYPE", "INPUTS", "OUTPUTS");
for node in &nodes {
println!(
"{:<20} {:<10} {:<10} {}",
node.node_type,
node.inputs.len(),
node.outputs.len(),
format_policy_short(node.policy.as_ref())
);
}
}
Ok(())
}
NodeAction::Info { node_type } => {
let info = node_cmd::node_info(&ws.nodes_dir, &node_type)?;
println!("Node type: {}", info.node_type);
println!("Policy: {}", format_policy_short(info.policy.as_ref()));
if !info.inputs.is_empty() {
println!("Inputs:");
for port in &info.inputs {
println!(
" {} {}",
port.name,
port.schema.as_deref().unwrap_or("(untyped)")
);
}
}
if !info.outputs.is_empty() {
println!("Outputs:");
for port in &info.outputs {
println!(
" {} {}",
port.name,
port.schema.as_deref().unwrap_or("(untyped)")
);
}
}
Ok(())
}
}
}
Commands::Graph { action } => {
let ws = discover_workspace()?;
match action {
GraphAction::Create { name, prefix } => {
graph_cmd::graph_create(&ws.graphs_dir, &name, prefix.as_deref())?;
println!("Created graph '{}'", name);
Ok(())
}
GraphAction::Run {
name,
time_source,
no_validate,
release,
no_cpu_dma_lock,
no_monitor_wait,
peer_loss,
single_process,
network,
trace_limit,
no_rings,
record,
record_env,
record_cpu,
auto_partition,
yes,
} => {
use std::io::IsTerminal as _;
let running = setup_ctrlc_handler()?;
let mut confirm = stdin_yes_no_confirm;
let consent = partition_emit::PartitionConsent {
auto_partition,
assume_yes: yes,
is_tty: std::io::stdin().is_terminal(),
confirm: &mut confirm,
};
graph_cmd::graph_run(
&ws.root,
&ws.graphs_dir,
&name,
running,
time_source.into(),
if no_cpu_dma_lock {
graph_cmd::CpuDmaLockMode::Disabled
} else {
graph_cmd::CpuDmaLockMode::Auto
},
if no_monitor_wait {
graph_cmd::MonitorWaitMode::Disabled
} else {
graph_cmd::MonitorWaitMode::Auto
},
no_validate,
release,
peer_loss.map(Into::into),
single_process,
network.is_some(),
trace_limit as usize,
record,
record_env.into(),
cli::record_cpu_mode(record_cpu),
Some(consent),
no_rings,
)
}
GraphAction::Validate { name, release } => {
let report = graph_cmd::graph_validate(&ws.root, &name, release)?;
println!("{}", report);
if report.all_passed() {
Ok(())
} else {
Err(cerulion_cli_engine::error::CliError::Validation(
"graph validation failed".to_string(),
))
}
}
GraphAction::List => {
let graphs = graph_cmd::graph_list(&ws.graphs_dir)?;
if graphs.is_empty() {
println!("No graphs found.");
} else {
for name in &graphs {
println!("{}", name);
}
}
Ok(())
}
GraphAction::Levels { name } => {
let report = graph_cmd::graph_levels(&ws.root, &name)?;
print!("{}", report.rendered);
map_levels_partition_result(report.partition_error)
}
GraphAction::Profile {
name,
duration,
fires,
out,
} => {
let running = setup_ctrlc_handler()?;
let report = graph_cmd::graph_profile(
&ws.root,
&name,
running,
std::time::Duration::from_secs(duration),
fires,
out.as_deref(),
)?;
println!(
"Profiled graph '{}': {} node(s) over {:.2}s",
name,
report.node_count,
report.window_ns as f64 / 1e9
);
println!("Cost snapshot written: {}", report.artifact_path.display());
if let Some(bak) = &report.backup_path {
println!("Previous artifact backed up to: {}", bak.display());
}
if !report.isolated.is_empty() {
println!();
match report.fires_override {
Some(fires_target) => {
let implied_hz =
(fires_target as f64 / duration as f64).ceil() as u64;
println!(
"WARNING: {} node(s) under-sampled: the fires target ({}) \
requires >={} Hz sustained over the {} s cap; raise \
--duration or lower --fires for slower nodes. ISOLATED \
(no cost recorded):",
report.isolated.len(),
fires_target,
implied_hz,
duration
);
}
None => {
println!(
"WARNING: {} node(s) under-sampled against their \
auto-derived fire targets. ISOLATED (no cost recorded):",
report.isolated.len()
);
}
}
for iso in &report.isolated {
match iso.target {
Some(target) => println!(
" {} — {} fire(s) observed (target {})",
iso.node, iso.fires, target
),
None => println!(
" {} — silent through warm-up — no target derived",
iso.node
),
}
if !iso.starved_trigger_inputs.is_empty() {
println!(
" zero-rate trigger input(s): {}; an upstream \
goal/driver-fed input may be silent - profile under \
representative load (run the driver graph alongside)",
iso.starved_trigger_inputs.join(", ")
);
}
}
match report.fires_override {
Some(_) => println!(
"Each isolated node stays in its own process group; re-run \
with a longer --duration or a lower --fires to sample it."
),
None => println!(
"Each isolated node stays in its own process group; raise \
--duration to sample slow nodes (--fires N forces the \
uniform fixed-target mode)."
),
}
}
Ok(())
}
GraphAction::Partition {
name,
costs,
budget_ns,
dry_run,
yes,
} => {
use std::io::IsTerminal as _;
let opts = partition_emit::PartitionOptions {
costs_path: costs,
budget_ns,
dry_run,
assume_yes: yes,
};
let is_tty = std::io::stdin().is_terminal();
let mut confirm = stdin_yes_no_confirm;
let report = partition_emit::graph_partition(
&ws.root,
&name,
&opts,
is_tty,
&mut confirm,
)?;
if !report.preview_shown {
print!("{}", report.preview);
}
match &report.outcome {
partition_emit::PartitionOutcome::DryRun => {
println!("\ndry run — nothing written.");
}
partition_emit::PartitionOutcome::Unchanged => {
println!(
"\n{} already carries this partition — nothing to write.",
report.graph_path.display()
);
}
partition_emit::PartitionOutcome::Written { backup } => {
println!("\nPartition written to {}", report.graph_path.display());
if let Some(bak) = backup {
println!("Previous graph file backed up to: {}", bak.display());
}
}
partition_emit::PartitionOutcome::Declined => {
println!("\naborted — nothing written.");
}
}
Ok(())
}
GraphAction::RunWorker { plan } => {
let running = setup_ctrlc_handler()?;
graph_cmd::graph_run_worker(&ws.root, &plan, running)
}
GraphAction::RunGateway { handoff } => {
let running = setup_ctrlc_handler()?;
graph_cmd::graph_run_gateway(&handoff, running)
}
}
}
Commands::Topic { action } => match action {
TopicAction::List {
no_network,
connect,
listen,
scan,
} => {
let topics = topic_cmd::topic_list()?;
let mirrors = topic_cmd::gather_mirror_provenance();
let (genuine_local, streaming) =
topic_cmd::partition_local_topics(topics, &mirrors);
if genuine_local.is_empty() {
println!("No active local topics.");
} else {
println!("TOPIC");
for topic in &genuine_local {
println!("{}", topic.name);
}
}
let mut remote_rendered = false;
if let Some(opts) = topic_cmd::remote_discovery_options(no_network, connect, listen)
{
match topic_cmd::query_remote_topics_with_scan(&opts, scan) {
Ok(disc) => {
print!(
"{}",
topic_cmd::render_remote_topics_section_with_mirrors(
&disc,
opts.has_endpoints(),
&streaming
)
);
remote_rendered = true;
}
Err(e) => {
eprintln!(
"\nREMOTE TOPICS\n(remote discovery unavailable — {e}; \
pass --no-network to skip it)"
);
}
}
}
if !remote_rendered && !streaming.is_empty() {
print!(
"{}",
topic_cmd::render_remote_topics_section_with_mirrors(
&topic_cmd::RemoteDiscovery::empty(),
false,
&streaming
)
);
}
Ok(())
}
TopicAction::Info { topic } => {
let ws = discover_workspace().ok();
let schemas_dir = ws.as_ref().map(|w| w.schemas_dir.as_path());
let info = topic_cmd::topic_info(&topic, schemas_dir)?;
println!("{}", info);
Ok(())
}
TopicAction::Echo {
topic,
truncate_length,
} => {
let running = setup_ctrlc_handler()?;
let ws = discover_workspace().ok();
let schemas_dir = ws.as_ref().map(|w| w.schemas_dir.as_path());
topic_cmd::topic_echo(
&topic,
schemas_dir,
running,
&mut std::io::stdout(),
usize::try_from(truncate_length).unwrap_or(usize::MAX),
)
}
TopicAction::Hz { topic } => {
let running = setup_ctrlc_handler()?;
let ws = discover_workspace().ok();
let schemas_dir = ws.as_ref().map(|w| w.schemas_dir.as_path());
topic_cmd::topic_hz(&topic, schemas_dir, running, &mut std::io::stdout())
}
},
Commands::Viz {
topics,
robot,
connect,
listen,
no_spawn,
serve_web,
detach,
} => {
let running = setup_ctrlc_handler()?;
run_viz(
running,
&topics,
robot.as_deref(),
&connect,
&listen,
no_spawn,
serve_web,
detach,
)
}
Commands::Login => {
login_cmd::run_login(&mut std::io::stderr())?;
Ok(())
}
Commands::Account { action } => match action {
AccountAction::Devices { action } => {
let session = account_cmd::require_session()?;
match action {
DevicesAction::List => {
let devices = account_cmd::list_my_devices(&session)?;
account_cmd::render_devices(&mut std::io::stdout(), &devices).map_err(|e| {
cerulion_cli_engine::error::CliError::Validation(format!(
"could not print devices: {e}"
))
})
}
DevicesAction::Revoke { device_id } => {
let res = account_cmd::revoke_my_device(&session, &device_id)?;
println!("revoked device {} (revoked={})", res.device_id, res.revoked);
println!(
" cut on {} robot(s) you own (enforced once each syncs).",
res.robots_updated
);
if !res.robots_failed.is_empty() {
println!(
" WARNING: {} owned robot(s) could not be updated: {} — \
the device IS revoked; re-run this command to retry them.",
res.robots_failed.len(),
res.robots_failed.join(", ")
);
}
println!(
" note: a robot you don't OWN is not cut by this — its owner must \
revoke the device; and an offline robot enforces only after it syncs."
);
Ok(())
}
}
}
},
Commands::Tui => cerulion_cli_tui::run().map_err(|e| {
cerulion_cli_engine::error::CliError::Validation(format!("TUI error: {}", e))
}),
Commands::Trace { action } => match action {
TraceAction::Inspect {
dir,
filter,
limit,
reverse,
} => trace_inspect(&dir, filter.as_deref(), limit, reverse),
},
Commands::Completions { shell } => {
let mut stdout = std::io::stdout().lock();
completion::write_registration_script(shell, &mut stdout)
.map_err(cerulion_cli_engine::error::CliError::Io)?;
if std::io::IsTerminal::is_terminal(&std::io::stdout()) {
eprintln!("\n# To enable completions permanently, run:");
for line in shell.install_hint().lines() {
eprintln!("# {line}");
}
}
Ok(())
}
Commands::Bag { action } => run_bag(action),
#[cfg(unix)]
Commands::Flashback { note, pin, no_wait } => {
let running = setup_ctrlc_handler()?;
let mut out = std::io::stdout();
cerulion_cli_engine::flashback_cmd::flashback_capture(
cerulion_cli_engine::flashback_cmd::FlashbackOptions { note, pin, no_wait },
running,
&mut out,
)
}
#[cfg(not(unix))]
Commands::Flashback => Err(CliError::Validation(
"`cerulion flashback` is Unix-only: the recorder that holds the rolling window is \
driven by SIGTERM lifecycle signals and does not build on this platform"
.to_string(),
)),
Commands::Clean { report_only } => clean_iceoryx2_state(report_only),
Commands::Connect { .. } => {
unreachable!("`cerulion connect` is dispatched in main() before run()")
}
Commands::Pair { .. } => {
unreachable!("`cerulion pair` is dispatched in main() before run()")
}
#[cfg(unix)]
Commands::Bagd(_) => unreachable!("`cerulion bagd` is dispatched in main() before run()"),
#[cfg(not(unix))]
Commands::Bagd => unreachable!("`cerulion bagd` is rejected in main() before run()"),
Commands::Schema { action } => {
match action {
SchemaAction::Create { name } => {
let ws = discover_workspace()?;
schema_cmd::schema_create(&ws.schemas_dir, &name)?;
println!("Created schema '{}'", name);
Ok(())
}
SchemaAction::Delete { name } => {
let ws = discover_workspace()?;
schema_cmd::schema_delete(&ws.schemas_dir, &name)?;
println!("Deleted schema '{}'", name);
Ok(())
}
SchemaAction::Info { name } => {
let ws = discover_workspace().ok();
let schemas_dir = ws.as_ref().map(|w| w.schemas_dir.as_path());
let info = match schema_cmd::schema_info_unified_opt(schemas_dir, &name) {
Ok(info) => info,
Err(
local_err @ cerulion_cli_engine::error::CliError::SchemaNotFound {
..
},
) => match schema_cmd::remote_fetch_target(&name) {
Some(requested) => {
let opts = topic_cmd::RemoteTopicsOptions {
connect: vec![],
listen: vec![],
scouting: true,
};
let fetched = topic_cmd::fetch_remote_schema(&requested, &opts)?;
let reply = topic_cmd::schema_info_remote_outcome(
&requested, fetched, local_err,
)?;
print!("{}", schema_cmd::render_remote_schema(&reply));
return Ok(());
}
None => return Err(local_err),
},
Err(e) => return Err(e),
};
if let Some(shadowed) = &info.shadowed_builtin {
for builtin in shadowed.split(", ") {
let bare = builtin.rsplit('/').next().unwrap_or(builtin);
for entry in &info.result.entries {
if entry.name == builtin || entry.name == bare {
eprintln!(
"WARNING: workspace schema '{}' shadows built-in '{}' \
— workspace wins for schema info",
entry.name, builtin
);
}
}
}
}
print!("{}", info);
Ok(())
}
SchemaAction::List => {
let ws = discover_workspace().ok();
let schemas_dir = ws.as_ref().map(|w| w.schemas_dir.as_path());
let listing = schema_cmd::schema_list_opt(schemas_dir);
print!("{}", listing);
Ok(())
}
}
}
Commands::Ros { .. } => Err(cerulion_cli_engine::error::CliError::Validation(
ROS_VERB_MOVED_MSG.to_string(),
)),
Commands::Ros2 { action } => match action {
Ros2Action::Run { .. } | Ros2Action::Launch { .. } => {
unreachable!("`cerulion ros2 run|launch` are dispatched in main() before run()")
}
Ros2Action::Attach {
iface,
domain,
timeout,
dry_run,
yes,
graph_name,
topic_prefix,
robot_name,
} => {
use std::io::IsTerminal as _;
let ws = discover_workspace()?;
let running = setup_ctrlc_handler()?;
let topic_prefix = match topic_prefix {
Some(raw) => {
let n = ros_cmd::normalize_topic_prefix(&raw)?;
if n.was_normalized {
eprintln!(
"note: --topic-prefix '{raw}' has no leading '/' — using \
'{}' (pass the leading '/' to silence this note)",
n.prefix
);
}
Some(n.prefix)
}
None => None,
};
let opts = ros_cmd::RosAttachOptions {
iface,
domain_id: domain,
window: std::time::Duration::from_secs_f64(timeout),
dry_run,
assume_yes: yes,
graph_name,
topic_prefix,
robot_name,
};
let discovery = cerulion_dds::LiveDiscovery;
let wire_params = cerulion_dds::DiscoveryParams {
only_networks: vec![opts.iface],
domain_id: opts.domain_id,
window: opts.window,
};
let acquirers = ros_cmd::AttachAcquirers::production(Box::new(
cerulion_dds::WireServiceAcquirer::new(wire_params),
));
let chain = acquirers.chain();
let is_tty = std::io::stdin().is_terminal();
let mut confirm = ros_attach_confirm;
let report = ros_cmd::ros_attach_with_acquirer(
&discovery,
&chain,
&ws.root,
&opts,
is_tty,
&mut confirm,
)?;
if report.preview_shown {
} else if matches!(report.outcome, ros_cmd::AttachOutcome::Written { .. }) {
print!("{}", report.preview);
} else {
print!("{}", report.report);
}
let graph_to_run = report.graph_to_run.clone();
match &report.outcome {
ros_cmd::AttachOutcome::DryRun | ros_cmd::AttachOutcome::NothingResolvable => {
Ok(())
}
ros_cmd::AttachOutcome::Declined => {
println!("ros2 attach: declined — nothing written.");
Ok(())
}
ros_cmd::AttachOutcome::Written {
graph_path,
config_path,
schema_writes,
..
} => {
println!(
"ros2 attach: wrote {} and {}",
graph_path.display(),
config_path.display()
);
for sw in schema_writes {
let note = match &sw.backup {
Some(bak) => format!(
"note: acquired schema written to {} (backed up the prior \
file to {})",
sw.path.display(),
bak.display()
),
None => format!(
"note: acquired schema written to {}",
sw.path.display()
),
};
eprintln!("{note}");
}
match graph_to_run {
Some(graph) => {
if let Some(note) = bridge_config_override_note(
std::env::var_os("DDS_BRIDGE_CONFIG").as_deref(),
config_path,
) {
eprintln!("{note}");
}
std::env::set_var("DDS_BRIDGE_CONFIG", config_path);
println!(
"ros2 attach: running `cerulion graph run {graph} \
--single-process` (Ctrl+C to stop)"
);
graph_cmd::graph_run(
&ws.root,
&ws.graphs_dir,
&graph,
running,
graph_cmd::TimeSource::Real,
graph_cmd::CpuDmaLockMode::Auto,
graph_cmd::MonitorWaitMode::Auto,
false, false, None, true, false, graph_cmd::PRODUCTION_TRACE_LIMIT,
None, graph_cmd::RecordEnvMode::None,
cli::record_cpu_mode(None),
None, false, )
}
None => Ok(()),
}
}
}
}
},
}
}
fn bridge_config_override_note(
existing: Option<&std::ffi::OsStr>,
generated: &Path,
) -> Option<String> {
match existing {
Some(prev) if prev != generated.as_os_str() => Some(format!(
"note: DDS_BRIDGE_CONFIG was already set to '{}' — overriding for this run with \
the config you just approved: '{}'",
Path::new(prev).display(),
generated.display()
)),
_ => None,
}
}
fn ros_attach_confirm(preview: &str) -> CliResult<bool> {
use std::io::Write as _;
print!("{preview}");
print!("\nWrite these files and run the graph? [y/N] ");
std::io::stdout()
.flush()
.map_err(cerulion_cli_engine::error::CliError::Io)?;
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(cerulion_cli_engine::error::CliError::Io)?;
Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes" | "YES"))
}
fn discover_workspace() -> CliResult<CerulionWorkspace> {
let cwd = std::env::current_dir()?;
CerulionWorkspace::discover(&cwd)
}
#[allow(clippy::too_many_arguments)]
fn run_viz(
running: Arc<AtomicBool>,
topics: &[String],
robot: Option<&str>,
connect: &[String],
listen: &[String],
no_spawn: bool,
serve_web: bool,
detach: bool,
) -> CliResult<()> {
#[cfg(not(unix))]
{
let _ = (
running, topics, robot, connect, listen, no_spawn, serve_web, detach,
);
Err(cerulion_cli_engine::error::CliError::Validation(
"cerulion viz needs the cerulion-vizd daemon, which speaks over a Unix domain socket \
and is not supported on this platform (Windows is a future target)"
.to_string(),
))
}
#[cfg(unix)]
{
run_viz_unix(
running, topics, robot, connect, listen, no_spawn, serve_web, detach,
)
}
}
#[cfg(unix)]
fn parse_viz_topic_arg(arg: &str) -> (String, Option<String>) {
let (raw, schema) = match arg.split_once('=') {
Some((t, s)) => (t.trim(), Some(s.trim().to_string())),
None => (arg.trim(), None),
};
let topic = if raw.starts_with('/') {
raw.to_string()
} else {
format!("/{raw}")
};
(topic, schema)
}
#[cfg(unix)]
#[allow(clippy::too_many_arguments)]
fn run_viz_unix(
running: Arc<AtomicBool>,
topics: &[String],
robot: Option<&str>,
connect: &[String],
listen: &[String],
no_spawn: bool,
serve_web: bool,
detach: bool,
) -> CliResult<()> {
use cerulion_cli_engine::error::CliError;
use std::time::Duration;
let val = |m: String| CliError::Validation(m);
let explicit_want: Option<Vec<(String, Option<String>)>> = if robot.is_some() {
if topics.is_empty() {
return Err(val(
"cerulion viz --robot needs at least one TOPIC (a schema pin is optional — the \
daemon resolves the type from the robot's catalog): e.g. `/utlidar/cloud` or \
`/utlidar/cloud=sensor_msgs/PointCloud2`"
.to_string(),
));
}
Some(topics.iter().map(|a| parse_viz_topic_arg(a)).collect())
} else if topics.is_empty() {
None } else {
Some(topics.iter().map(|a| parse_viz_topic_arg(a)).collect())
};
let socket = viz_client::vizd_socket_path();
let log_path = viz_client::vizd_log_path(&socket);
let log_for_spawn = log_path.clone();
let connect_for_spawn = connect.to_vec();
let listen_for_spawn = listen.to_vec();
let (mut conn, spawned) =
viz_client::ensure_daemon(&socket, Duration::from_secs(10), move || {
let bin = viz_client::find_vizd_binary().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"no `{}` daemon binary found alongside `cerulion` or on $PATH — build it \
(`cargo build --release -p cerulion_vizd`) or install it",
viz_client::VIZD_BINARY
),
)
})?;
viz_client::spawn_vizd_detached(
&bin,
&log_for_spawn,
&connect_for_spawn,
&listen_for_spawn,
)?;
println!("cerulion viz: started the cerulion-vizd daemon");
Ok(())
})
.map_err(|e| {
val(format!(
"cerulion viz: could not reach the cerulion-vizd daemon: {e}\n\
(if the daemon spawned, its diagnostics are in {})",
log_path.display()
))
})?;
if !spawned && (!connect.is_empty() || !listen.is_empty()) {
eprintln!(
"cerulion viz: note — the cerulion-vizd daemon was already running, so \
--connect/--listen apply only to a daemon THIS command starts; the running daemon \
keeps its boot-time locators (scouting still covers LAN robots). Restart it \
(stop the daemon, then re-run) to bake in these locators."
);
}
let rerun_url = conn.banner().rerun_url.clone();
println!(
"{}",
viz_cmd::ensure_viewer_for_url(rerun_url.as_deref(), no_spawn, serve_web)
);
let want: Vec<(String, Option<String>)> = match explicit_want {
Some(w) => w,
None => {
println!("cerulion viz: discovering local topics from the daemon…");
let discovered = conn
.discover(1)
.map_err(|e| val(format!("cerulion viz: daemon discover failed: {e}")))?;
if discovered.is_empty() {
return Err(val(
"cerulion viz: the daemon found no local topics to visualize — start some \
producers (or a `cerulion graph run`), or name a topic explicitly"
.to_string(),
));
}
discovered
}
};
let mut created: Vec<String> = Vec::new();
let mut ok_count = 0usize;
let mut failures = 0usize;
let mut cancelled_on: Option<String> = None;
for (i, (topic, schema)) in want.iter().enumerate() {
let mut progress = |waited: std::time::Duration| {
eprintln!(
"cerulion viz: waiting for '{topic}' to be discovered on the network… ({:.1}s)",
waited.as_secs_f64()
);
};
let attached = conn
.attach_waiting_for_discovery(
(i as u64) + 100,
topic,
None,
robot,
schema.as_deref(),
viz_client::first_contact_attach_policy(),
Some(running.as_ref()),
&mut progress,
)
.map_err(|e| val(format!("cerulion viz: attach `{topic}` failed: {e}")))?;
if attached.outcome == viz_client::WaitOutcome::Cancelled {
cancelled_on = Some(topic.clone());
break;
}
let reply = attached.reply;
if reply.ok {
ok_count += 1;
if !reply.already_attached {
created.push(topic.clone());
}
let schema = reply.schema.as_deref().unwrap_or("(resolving…)");
let archetype = reply.archetype.as_deref().unwrap_or("(pending)");
let entity = reply.entity.as_deref().unwrap_or("");
let note = if reply.already_attached {
" (already tapped by another controller — left running on exit)"
} else {
""
};
println!("cerulion viz: attached {topic} → {schema} [{archetype}] at {entity}{note}");
} else {
failures += 1;
eprintln!(
"cerulion viz: could not attach {topic}: {}",
reply.error.as_deref().unwrap_or("(no error message)")
);
}
}
if let Some(topic) = &cancelled_on {
eprintln!(
"cerulion viz: interrupted while discovering '{topic}' — nothing was concluded \
about it, and the remaining topics were not attempted"
);
}
if ok_count == 0 && cancelled_on.is_none() {
return Err(val(format!(
"cerulion viz: attached 0 of {} topic(s) — see the per-topic errors above",
want.len()
)));
}
if failures > 0 {
eprintln!("cerulion viz: attached {ok_count} topic(s); {failures} failed (see above)");
}
if ok_count == 0 && cancelled_on.is_some() {
return Ok(());
}
if detach {
println!(
"cerulion viz: attached {ok_count} topic(s) and detached — the daemon + viewer keep running."
);
return Ok(());
}
println!("cerulion viz: visualizing {ok_count} topic(s) via cerulion-vizd (Ctrl+C to stop)");
while running.load(std::sync::atomic::Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(200));
}
for (i, topic) in created.iter().enumerate() {
let _ = conn.detach((i as u64) + 1000, topic);
}
if created.is_empty() {
println!(
"cerulion viz: exiting — every tap was opened by another controller and keeps running."
);
} else {
println!(
"cerulion viz: detached {} topic(s) this run created; the daemon keeps running.",
created.len()
);
}
Ok(())
}
fn map_levels_partition_result(partition_error: Option<String>) -> CliResult<()> {
match partition_error {
None => Ok(()),
Some(diagnostic) => Err(cerulion_cli_engine::error::CliError::Validation(format!(
"process_groups partition is not spawner-consumable: {diagnostic}"
))),
}
}
fn clean_iceoryx2_state(report_only: bool) -> CliResult<()> {
let (result, converged) = clean_dead_nodes();
let reclaim = !report_only && converged;
if !report_only && !converged {
println!(
"Skipping stale `.shm_state` reclamation: dead iceoryx2 nodes are still registered \
(see the failures above). Removing a name mapping they still need would make them \
permanently unreclaimable. Clear the failures, then re-run `cerulion clean`."
);
}
report_shm_state_population(!reclaim);
result
}
fn report_shm_state_population(report_only: bool) {
use cerulion_cli_engine::shm_state;
let nodes = shm_state::count_node_registry(
&shm_state::iceoryx2_node_dir(),
shm_state::SHM_STATE_REPORT_BUDGET,
);
#[cfg(unix)]
let shm = shm_state::platform_uses_shm_state_files().then(|| {
let (mode, budget) = if report_only {
(
shm_state::ScanMode::ReportOnly,
shm_state::SHM_STATE_REPORT_BUDGET,
)
} else {
(
shm_state::ScanMode::Reclaim,
shm_state::SHM_STATE_RECLAIM_BUDGET,
)
};
shm_state::scan(
std::path::Path::new(shm_state::SHM_STATE_DIRECTORY),
budget,
shm_state::classify_budget(budget),
mode,
&shm_state::LibcProbe,
)
});
#[cfg(not(unix))]
let shm: Option<shm_state::ShmStateReport> = {
let _ = report_only;
None
};
for line in shm_state::render_lines(&nodes, shm.as_ref()) {
println!("{line}");
}
}
fn clean_dead_nodes() -> (CliResult<()>, bool) {
let report = cerulion_cli_engine::ipc_cleanup::cleanup_dead_iceoryx2_nodes_with_diagnostics();
let converged = report.failed_cleanups == 0;
if report.cleanups == 0 && report.failed_cleanups == 0 {
println!("No dead iceoryx2 nodes found — nothing to clean.");
return (Ok(()), converged);
}
println!(
"Cleaned {} dead iceoryx2 node(s); {} cleanup(s) failed.",
report.cleanups, report.failed_cleanups
);
if report.failed_cleanups == 0 {
return (Ok(()), converged);
}
if !report.failures_by_cause.is_empty() {
println!("Failure breakdown:");
for (cause, count) in &report.failures_by_cause {
let remediation = match cause.as_str() {
"permission denied" => {
"the dead node's resources belong to a different user; \
re-run as that user, or `chmod` the resources under `/tmp/iceoryx2/`"
}
"version mismatch" => {
"the dead node's on-disk state was produced by a different \
iceoryx2 version; live nodes' state is unaffected, but the \
dead-node remnants need a manual `rm -rf /tmp/iceoryx2/` to clear"
}
"lock contention" => {
"another process is concurrently cleaning the same dead node; \
usually self-resolves on retry"
}
"monitoring resource still in use" => {
"iceoryx2's monitoring layer holds a reference; should resolve \
after the holding process exits"
}
"iceoryx2 internal error" => {
"iceoryx2 reported an internal error; \
run with `IOX2_LOG_LEVEL=trace cerulion clean` for the underlying message"
}
_ => "see iceoryx2 logs for details",
};
println!(" {} × {} — {}", count, cause, remediation);
}
}
if !report.unclassified.is_empty() {
println!(
"Unclassified failures ({}): run with `RUST_LOG=iceoryx2=trace cerulion clean` for raw causes",
report.unclassified.len()
);
}
(Ok(()), converged)
}
fn trace_inspect(
dir: &str,
filter: Option<&str>,
limit: Option<usize>,
reverse: bool,
) -> CliResult<()> {
let dir_path = std::path::Path::new(dir);
if !dir_path.is_dir() {
return Err(cerulion_cli_engine::error::CliError::Validation(format!(
"trace inspect: `{}` is not a directory",
dir
)));
}
let mut bag_files: Vec<std::path::PathBuf> = std::fs::read_dir(dir_path)
.map_err(|e| {
cerulion_cli_engine::error::CliError::Validation(format!(
"trace inspect: failed to read directory `{}`: {}",
dir, e
))
})?
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("trace_") && n.ends_with(".jsonl"))
})
.collect();
bag_files.sort();
if bag_files.is_empty() {
println!("No trace_*.jsonl files found in `{}`", dir);
return Ok(());
}
let mut lines: Vec<String> = Vec::new();
for file in &bag_files {
let contents = std::fs::read_to_string(file).map_err(|e| {
cerulion_cli_engine::error::CliError::Validation(format!(
"trace inspect: failed to read `{}`: {}",
file.display(),
e
))
})?;
for line in contents.lines() {
if let Some(topic_filter) = filter {
let needle = format!("\"topic\":\"{}\"", topic_filter);
if !line.contains(&needle) {
continue;
}
}
lines.push(line.to_string());
}
}
if reverse {
lines.reverse();
}
if let Some(n) = limit {
lines.truncate(n);
}
for line in &lines {
match parse_jsonl_record(line) {
Some((topic, seq, ts, schema)) => {
println!("{} seq={} t={}ns schema=0x{:016X}", topic, seq, ts, schema);
}
None => {
println!("(malformed) {}", line);
}
}
}
println!();
println!(
"[{} record(s) across {} file(s){}]",
lines.len(),
bag_files.len(),
if let Some(t) = filter {
format!(" filtered by topic={:?}", t)
} else {
String::new()
}
);
Ok(())
}
fn parse_jsonl_record(line: &str) -> Option<(String, u64, u64, u64)> {
let extract = |key: &str| -> Option<&str> {
let pat = format!("\"{}\":", key);
let start = line.find(&pat)? + pat.len();
let rest = &line[start..];
if let Some(stripped) = rest.strip_prefix('"') {
let end = stripped.find('"')?;
Some(&stripped[..end])
} else {
let end = rest.find([',', '}'])?;
Some(&rest[..end])
}
};
let topic = extract("topic")?.to_string();
let seq: u64 = extract("seq")?.parse().ok()?;
let ts: u64 = extract("ts_ns")?.parse().ok()?;
let schema_raw = extract("schema_hash")?;
let schema = u64::from_str_radix(schema_raw.trim_start_matches("0x"), 16).ok()?;
Some((topic, seq, ts, schema))
}
fn format_policy_short(spec: Option<&cerulion_core::MacroPolicy>) -> String {
use cerulion_core::MacroPolicy;
match spec {
Some(MacroPolicy::Period { period_ms }) => format!("period {}ms", period_ms),
Some(MacroPolicy::Sync { window_ms }) => format!("sync {}ms", window_ms),
Some(MacroPolicy::UnboundedSync) => "sync ∞".to_string(),
Some(MacroPolicy::External) => "external".to_string(),
Some(MacroPolicy::DataTrigger { input_name }) => format!("trigger:{}", input_name),
None => "-".to_string(),
}
}
fn check_dash_t_dash_i_name_collision(
trigger_inputs: &[(String, String)],
regular_inputs: &[(String, String)],
) -> CliResult<()> {
use cerulion_cli_engine::error::CliError;
if let Some((_, t_name)) = trigger_inputs.first() {
if regular_inputs.iter().any(|(_, n)| n == t_name) {
return Err(CliError::Validation(format!(
"port name '{t_name}' appears in both `-T` (trigger) and \
`-i` (regular input). Each port name must be unique. \
Drop either the `-T` or the `-i` for this name."
)));
}
}
Ok(())
}
fn resolve_create_policy(
explicit: Option<&cerulion_core::MacroPolicy>,
trigger_input: Option<&(String, String)>,
regular_inputs: &[(String, String)],
) -> CliResult<Option<cerulion_core::MacroPolicy>> {
use cerulion_cli_engine::error::CliError;
use cerulion_core::MacroPolicy;
match (explicit, trigger_input) {
(Some(MacroPolicy::DataTrigger { input_name }), Some((_, t_name))) => {
if input_name != t_name {
return Err(CliError::Validation(format!(
"`--policy data_trigger={input_name}` and `-T <SCHEMA> {t_name}` disagree \
on the trigger input"
)));
}
Ok(Some(MacroPolicy::DataTrigger {
input_name: input_name.clone(),
}))
}
(Some(_non_data), Some(_)) => Err(CliError::Validation(
"`-T` declares a data-trigger input, which conflicts with a non-data `--policy`. \
Drop `-T` or change the policy."
.to_string(),
)),
(Some(MacroPolicy::DataTrigger { input_name }), None) => {
let matches_input = regular_inputs.iter().any(|(_, n)| n == input_name);
if !matches_input {
return Err(CliError::Validation(format!(
"`--policy data_trigger={input_name}` requires `-i SCHEMA {input_name}` \
(or `-T SCHEMA {input_name}`) to declare the trigger input"
)));
}
Ok(Some(MacroPolicy::DataTrigger {
input_name: input_name.clone(),
}))
}
(Some(p), None) => Ok(Some(p.clone())),
(None, Some((_, name))) => Ok(Some(MacroPolicy::DataTrigger {
input_name: name.clone(),
})),
(None, None) => {
if regular_inputs.is_empty() {
Err(CliError::Validation(
"source-only nodes (no `-i` or `-T`) must declare a non-data trigger policy. \
Pass `--policy period_ms=N` or `--policy external`."
.to_string(),
))
} else {
Ok(None)
}
}
}
}
fn parse_policy_spec(spec: &str) -> CliResult<cerulion_core::MacroPolicy> {
use cerulion_cli_engine::error::CliError;
use cerulion_core::MacroPolicy;
let trimmed = spec.trim();
if trimmed.eq_ignore_ascii_case("external") {
return Ok(MacroPolicy::External);
}
let (key, value) = match trimmed.split_once('=') {
Some((k, v)) => (k.trim(), Some(v.trim())),
None => (trimmed, None),
};
let parse_u64_positive = |label: &str, v: Option<&str>| -> CliResult<u64> {
let v = v.ok_or_else(|| {
CliError::Validation(format!(
"`--policy {label}` requires a value, e.g. `--policy {label}=100`"
))
})?;
let parsed = v.parse::<u64>().map_err(|_| {
CliError::Validation(format!(
"`--policy {label}={v}` — value must be a positive integer (milliseconds)"
))
})?;
if parsed == 0 {
return Err(CliError::Validation(format!(
"`--policy {label}=0` — value must be > 0 (zero-duration {label} would spin-loop the scheduler)"
)));
}
Ok(parsed)
};
let parse_name = |label: &str, v: Option<&str>| -> CliResult<String> {
let v = v.ok_or_else(|| {
CliError::Validation(format!(
"`--policy {label}` requires a value, e.g. `--policy {label}=count`"
))
})?;
if v.is_empty() {
return Err(CliError::Validation(format!(
"`--policy {label}=` — value (input name) must not be empty"
)));
}
Ok(v.to_string())
};
match key {
"period_ms" => Ok(MacroPolicy::Period {
period_ms: parse_u64_positive("period_ms", value)?,
}),
"sync_window_ms" => Ok(MacroPolicy::Sync {
window_ms: parse_u64_positive("sync_window_ms", value)?,
}),
"data_trigger" | "trigger" => Ok(MacroPolicy::DataTrigger {
input_name: parse_name(key, value)?,
}),
other => Err(CliError::Validation(format!(
"unknown policy spec `{other}`. Accepted: \
`period_ms=N`, `sync_window_ms=N`, `external`, \
`data_trigger=NAME` (or `trigger=NAME`)"
))),
}
}
fn stdin_yes_no_confirm(preview: &str) -> CliResult<bool> {
prompt_yes_no(preview, "Apply this partition to the graph file?")
}
fn prompt_yes_no(preview: &str, question: &str) -> CliResult<bool> {
use std::io::Write as _;
print!("{preview}");
print!("\n{question} [y/N] ");
std::io::stdout()
.flush()
.map_err(cerulion_cli_engine::error::CliError::Io)?;
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(cerulion_cli_engine::error::CliError::Io)?;
Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes" | "YES"))
}
fn command_needs_identity(command: &Commands) -> bool {
!matches!(
command,
Commands::Login
| Commands::Completions { .. }
| Commands::Graph {
action: GraphAction::RunWorker { .. } | GraphAction::RunGateway { .. },
}
)
}
fn setup_ctrlc_handler() -> CliResult<Arc<AtomicBool>> {
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::Relaxed);
})
.map_err(|e| {
cerulion_cli_engine::error::CliError::Validation(format!(
"failed to set Ctrl+C handler: {}",
e
))
})?;
Ok(running)
}
fn init_logging(verbose: bool, quiet_default: bool) {
cerulion_core::init_logging(verbose, quiet_default);
}
fn parse_port_args(args: &[String]) -> (String, String) {
debug_assert_eq!(
args.len(),
2,
"parse_port_args expects exactly 2 args; caller must reject multi-invocation \
(`args.len() > 2`) before calling"
);
let schema = cerulion_cli_engine::schema_cmd::normalize_schema(&args[0]);
let name = args.get(1).cloned().unwrap_or_else(|| {
schema.rsplit('/').next().unwrap_or(&schema).to_lowercase()
});
(schema, name)
}
fn resolve_and_report(schemas_dir: &Path, raw: &str) -> CliResult<String> {
use cerulion_cli_engine::schema_cmd::PortSchemaProvenance;
let resolved = schema_cmd::resolve_port_schema(schemas_dir, raw)?;
match &resolved.provenance {
PortSchemaProvenance::ResolvedBuiltin { qualified } => {
eprintln!(
"note: resolved bare schema '{raw}' to built-in '{qualified}' \
(qualify it to silence this note)"
);
}
PortSchemaProvenance::Workspace {
shadowed: Some(shadowed),
} => {
eprintln!(
"WARNING: workspace schema '{raw}' shadows built-in '{shadowed}' \
— workspace wins for node ports"
);
}
PortSchemaProvenance::Qualified | PortSchemaProvenance::Workspace { shadowed: None } => {}
}
Ok(resolved.schema)
}
fn parse_input_bindings(args: &[String]) -> Vec<(String, String)> {
args.chunks(2)
.filter_map(|chunk| {
if chunk.len() == 2 {
Some((chunk[0].clone(), chunk[1].clone()))
} else {
None
}
})
.collect()
}
fn resolve_graph_name(ws: &CerulionWorkspace, explicit: Option<String>) -> CliResult<String> {
if let Some(name) = explicit {
return Ok(name);
}
let graphs: Vec<_> = std::fs::read_dir(&ws.graphs_dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "yaml")
.unwrap_or(false)
})
.collect();
match graphs.len() {
0 => Err(cerulion_cli_engine::error::CliError::Validation(
"no graphs found — create one with `cerulion graph create <name>`".to_string(),
)),
1 => {
let name = graphs[0]
.path()
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
Ok(name)
}
_ => Err(cerulion_cli_engine::error::CliError::Validation(
"multiple graphs found — specify with -g <name>".to_string(),
)),
}
}
#[cfg(test)]
mod login_gate_exemption_tests {
use super::*;
#[test]
fn login_verb_is_exempt() {
assert!(!command_needs_identity(&Commands::Login));
}
#[test]
fn internal_subprocess_verbs_are_exempt() {
assert!(!command_needs_identity(&Commands::Graph {
action: GraphAction::RunWorker {
plan: PathBuf::from("plan.json"),
},
}));
assert!(!command_needs_identity(&Commands::Graph {
action: GraphAction::RunGateway {
handoff: PathBuf::from("handoff.json"),
},
}));
}
#[test]
fn ordinary_verbs_are_gated() {
assert!(command_needs_identity(&Commands::Clean {
report_only: false
}));
assert!(command_needs_identity(&Commands::Graph {
action: GraphAction::List,
}));
assert!(command_needs_identity(&Commands::Node {
action: NodeAction::List,
}));
}
}
#[cfg(test)]
mod parse_port_args_tests {
use super::*;
#[test]
fn parse_port_args_canonicalizes_colon_form() {
let args = vec!["sensor_msgs::Image".to_string(), "image".to_string()];
assert_eq!(
parse_port_args(&args),
("sensor_msgs/Image".to_string(), "image".to_string()),
"colon-form input must be canonicalised to slash form"
);
}
#[test]
fn parse_port_args_passes_slash_form() {
let args = vec!["sensor_msgs/Image".to_string(), "image".to_string()];
assert_eq!(
parse_port_args(&args),
("sensor_msgs/Image".to_string(), "image".to_string()),
"slash-form input must round-trip unchanged"
);
}
#[test]
fn parse_port_args_preserves_explicit_name() {
let args = vec!["sensor_msgs::Image".to_string(), "frame".to_string()];
assert_eq!(
parse_port_args(&args),
("sensor_msgs/Image".to_string(), "frame".to_string()),
"explicit NAME arg must be preserved verbatim"
);
}
#[test]
fn parse_port_args_unqualified_passes_through() {
let args = vec!["MyType".to_string(), "mytype".to_string()];
assert_eq!(
parse_port_args(&args),
("MyType".to_string(), "mytype".to_string())
);
}
}
#[cfg(test)]
mod resolve_and_report_tests {
use super::*;
#[test]
fn unique_bare_builtin_returns_qualified() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(
resolve_and_report(tmp.path(), "Vector3").unwrap(),
"geometry_msgs/Vector3"
);
}
#[test]
fn qualified_name_passes_through_unchanged() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(
resolve_and_report(tmp.path(), "geometry_msgs/Vector3").unwrap(),
"geometry_msgs/Vector3"
);
}
#[test]
fn ambiguous_bare_name_propagates_validation_err() {
let tmp = tempfile::tempdir().unwrap();
let err = resolve_and_report(tmp.path(), "Pose2D").unwrap_err();
assert!(
matches!(err, cerulion_cli_engine::error::CliError::Validation(_)),
"expected Validation error, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("geometry_msgs/Pose2D") && msg.contains("vision_msgs/Pose2D"),
"error must name both candidate packages: {msg}"
);
}
#[test]
fn unknown_bare_name_propagates_schema_not_found() {
let tmp = tempfile::tempdir().unwrap();
let err = resolve_and_report(tmp.path(), "Vectorr3").unwrap_err();
assert!(
matches!(
err,
cerulion_cli_engine::error::CliError::SchemaNotFound { .. }
),
"expected SchemaNotFound error, got {err:?}"
);
}
}
#[cfg(test)]
mod map_levels_partition_result_tests {
use super::*;
#[test]
fn none_partition_error_maps_to_ok() {
assert!(
map_levels_partition_result(None).is_ok(),
"a valid/absent partition must map to Ok (exit 0)"
);
}
#[test]
fn some_partition_error_maps_to_validation_carrying_diagnostic() {
let diagnostic =
"process group 'X': node 'n2' ... foreign bridge node 'n1' breaks its chain"
.to_string();
let err = map_levels_partition_result(Some(diagnostic))
.expect_err("an invalid partition must map to a nonzero error");
match err {
cerulion_cli_engine::error::CliError::Validation(msg) => {
assert!(
msg.starts_with("process_groups partition is not spawner-consumable: "),
"the spawner-consumability preface must be present; got: {msg}"
);
assert!(
msg.contains("foreign bridge node 'n1'"),
"the chunk-B diagnostic (naming the bridge) must pass through verbatim; \
got: {msg}"
);
}
other => panic!("expected CliError::Validation, got {other:?}"),
}
}
}
#[cfg(test)]
mod parse_policy_spec_tests {
use super::*;
use cerulion_core::MacroPolicy;
#[test]
fn period_ms_bare_form_errors() {
let err = parse_policy_spec("period_ms").unwrap_err();
assert!(err.to_string().contains("requires a value"), "got: {err}");
}
#[test]
fn period_ms_with_value_parses_normally() {
let result = parse_policy_spec("period_ms=33").unwrap();
assert_eq!(result, MacroPolicy::Period { period_ms: 33 });
}
#[test]
fn deadline_ms_policy_removed() {
for spec in ["deadline_ms=100", "deadline_ms"] {
let err = parse_policy_spec(spec).unwrap_err();
assert!(
err.to_string()
.contains("unknown policy spec `deadline_ms`"),
"got: {err}"
);
}
}
#[test]
fn sync_window_ms_bare_form_errors() {
let err = parse_policy_spec("sync_window_ms").unwrap_err();
assert!(err.to_string().contains("requires a value"), "got: {err}");
}
#[test]
fn period_ms_zero_errors() {
let err = parse_policy_spec("period_ms=0").unwrap_err();
assert!(err.to_string().contains("must be > 0"), "got: {err}");
}
#[test]
fn sync_window_ms_zero_errors() {
let err = parse_policy_spec("sync_window_ms=0").unwrap_err();
assert!(err.to_string().contains("must be > 0"), "got: {err}");
}
#[test]
fn default_keyword_is_rejected() {
let err = parse_policy_spec("default").unwrap_err();
assert!(
err.to_string().contains("unknown policy spec"),
"got: {err}"
);
}
#[test]
fn external_keyword_round_trips() {
let result = parse_policy_spec("external").unwrap();
assert_eq!(result, MacroPolicy::External);
}
}
#[cfg(test)]
mod resolve_create_policy_tests {
use super::*;
use cerulion_core::MacroPolicy;
fn input(name: &str) -> (String, String) {
("test_msgs/Foo".to_string(), name.to_string())
}
#[test]
fn zero_inputs_no_policy_errors() {
let err = resolve_create_policy(None, None, &[]).unwrap_err();
assert!(err.to_string().contains("source-only nodes"), "got: {err}");
}
#[test]
fn one_input_no_policy_returns_none() {
let inputs = vec![input("data")];
let result = resolve_create_policy(None, None, &inputs).unwrap();
assert_eq!(result, None);
}
#[test]
fn two_inputs_no_policy_returns_none() {
let inputs = vec![input("a"), input("b")];
let result = resolve_create_policy(None, None, &inputs).unwrap();
assert_eq!(result, None);
}
#[test]
fn explicit_dash_t_still_writes_data_trigger() {
let trigger = input("scan");
let result = resolve_create_policy(None, Some(&trigger), &[]).unwrap();
assert_eq!(
result,
Some(MacroPolicy::DataTrigger {
input_name: "scan".to_string(),
})
);
}
#[test]
fn explicit_period_ms_unaffected_by_inputs() {
let explicit = MacroPolicy::Period { period_ms: 50 };
let inputs = vec![input("a")];
let result = resolve_create_policy(Some(&explicit), None, &inputs).unwrap();
assert_eq!(result, Some(MacroPolicy::Period { period_ms: 50 }));
}
}
#[cfg(test)]
mod dash_t_dash_i_collision_tests {
use super::*;
fn port(schema: &str, name: &str) -> (String, String) {
(schema.to_string(), name.to_string())
}
#[test]
fn no_trigger_no_collision() {
let result = check_dash_t_dash_i_name_collision(&[], &[port("sensor_msgs/Image", "image")]);
assert!(result.is_ok());
}
#[test]
fn trigger_alone_no_collision() {
let result = check_dash_t_dash_i_name_collision(&[port("sensor_msgs/Image", "image")], &[]);
assert!(result.is_ok());
}
#[test]
fn trigger_and_regular_with_different_names_ok() {
let result = check_dash_t_dash_i_name_collision(
&[port("sensor_msgs/LaserScan", "scan")],
&[port("sensor_msgs/Image", "image")],
);
assert!(result.is_ok());
}
#[test]
fn trigger_and_regular_with_same_name_rejected() {
let err = check_dash_t_dash_i_name_collision(
&[port("sensor_msgs/Image", "foo")],
&[port("sensor_msgs/Imu", "foo")],
)
.expect_err("must reject name collision");
let msg = err.to_string();
assert!(
msg.contains("'foo'"),
"error must name the colliding port; got: {msg}"
);
assert!(
msg.contains("-T") && msg.contains("-i"),
"error must mention both flags; got: {msg}"
);
}
#[test]
fn trigger_collides_with_one_of_many_regulars() {
let err = check_dash_t_dash_i_name_collision(
&[port("sensor_msgs/Image", "foo")],
&[
port("sensor_msgs/Image", "bar"),
port("sensor_msgs/Imu", "foo"),
port("std_msgs/String", "baz"),
],
)
.expect_err("must reject collision even when not first");
assert!(err.to_string().contains("'foo'"));
}
}
#[cfg(test)]
mod clap_parse_tests {
use super::cli::{Cli, Commands, GraphAction, NodeAction};
use clap::Parser;
fn try_parse(argv: &[&str]) -> Result<Cli, clap::Error> {
Cli::try_parse_from(argv)
}
#[test]
fn graph_validate_release_flag_parses() {
let cli = try_parse(&["cerulion", "graph", "validate", "perception", "--release"])
.expect("`graph validate --release` must parse");
let Commands::Graph {
action: GraphAction::Validate { name, release },
} = cli.command
else {
panic!("expected GraphAction::Validate");
};
assert_eq!(name, "perception");
assert!(release);
}
#[test]
fn graph_validate_defaults_to_debug_preference() {
let cli = try_parse(&["cerulion", "graph", "validate", "perception"])
.expect("`graph validate` without `--release` must parse");
let Commands::Graph {
action: GraphAction::Validate { release, .. },
} = cli.command
else {
panic!("expected GraphAction::Validate");
};
assert!(!release, "release preference must default to off");
}
#[test]
fn node_run_release_flag_parses() {
let cli = try_parse(&["cerulion", "node", "run", "camera", "--release"])
.expect("`node run --release` must parse");
let Commands::Node {
action: NodeAction::Run {
node_type, release, ..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert_eq!(node_type, "camera");
assert!(release, "`--release` must set the release preference");
}
#[test]
fn node_run_defaults_to_debug_preference() {
let cli = try_parse(&["cerulion", "node", "run", "camera"])
.expect("`node run` without `--release` must parse");
let Commands::Node {
action: NodeAction::Run { release, .. },
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert!(!release, "release preference must default to off");
}
#[test]
fn node_run_no_cpu_dma_lock_flag_parses() {
let cli = try_parse(&["cerulion", "node", "run", "camera", "--no-cpu-dma-lock"])
.expect("`node run --no-cpu-dma-lock` must parse");
let Commands::Node {
action:
NodeAction::Run {
node_type,
no_cpu_dma_lock,
..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert_eq!(node_type, "camera");
assert!(
no_cpu_dma_lock,
"`--no-cpu-dma-lock` must set no_cpu_dma_lock = true"
);
}
#[test]
fn node_run_no_cpu_dma_lock_defaults_false() {
let cli = try_parse(&["cerulion", "node", "run", "camera"])
.expect("`node run` without `--no-cpu-dma-lock` must parse");
let Commands::Node {
action: NodeAction::Run {
no_cpu_dma_lock, ..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert!(
!no_cpu_dma_lock,
"no_cpu_dma_lock must default to off (lock on for the live path)"
);
}
#[test]
fn node_run_no_monitor_wait_flag_parses() {
let cli = try_parse(&["cerulion", "node", "run", "camera", "--no-monitor-wait"])
.expect("`node run --no-monitor-wait` must parse");
let Commands::Node {
action:
NodeAction::Run {
node_type,
no_monitor_wait,
..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert_eq!(node_type, "camera");
assert!(
no_monitor_wait,
"`--no-monitor-wait` must set no_monitor_wait = true"
);
}
#[test]
fn node_run_no_monitor_wait_defaults_false() {
let cli = try_parse(&["cerulion", "node", "run", "camera"])
.expect("`node run` without `--no-monitor-wait` must parse");
let Commands::Node {
action: NodeAction::Run {
no_monitor_wait, ..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert!(
!no_monitor_wait,
"no_monitor_wait must default to off (park on for the live path)"
);
}
#[test]
fn dash_t_with_schema_and_name_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"detector",
"-T",
"sensor_msgs/Image",
"image",
])
.expect("`-T SCHEMA NAME` must parse");
let Commands::Node { action } = cli.command else {
panic!("expected Node command");
};
let NodeAction::Create { trigger_input, .. } = action else {
panic!("expected Create action");
};
assert_eq!(trigger_input, vec!["sensor_msgs/Image", "image"]);
}
#[test]
fn dash_t_with_only_schema_is_rejected_by_clap() {
let result = try_parse(&[
"cerulion",
"node",
"create",
"detector",
"-T",
"sensor_msgs/Image",
]);
let err = match result {
Ok(_) => panic!("`-T SCHEMA` alone must be rejected by clap"),
Err(e) => e,
};
let msg = err.to_string();
assert!(
msg.contains("NAME") || msg.contains("required") || msg.contains("argument"),
"expected clap to flag the missing `NAME` arg; got: {msg}"
);
}
#[test]
fn dash_i_with_schema_and_name_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"camera",
"-i",
"sensor_msgs/LaserScan",
"scan",
])
.expect("`-i SCHEMA NAME` must parse");
let Commands::Node {
action: NodeAction::Create { input, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert_eq!(input, vec!["sensor_msgs/LaserScan", "scan"]);
}
#[test]
fn multiple_dash_t_flat_vec_caught_by_length_guard() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"fusion",
"-T",
"sensor_msgs/Image",
"image",
"-T",
"sensor_msgs/Imu",
"imu",
])
.expect(
"clap must accept the flat-Vec accumulation; length guard runs later in the handler",
);
let Commands::Node {
action: NodeAction::Create { trigger_input, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert_eq!(
trigger_input.len(),
4,
"expected 4 args from two `-T` invocations"
);
assert_eq!(
trigger_input,
vec!["sensor_msgs/Image", "image", "sensor_msgs/Imu", "imu"]
);
}
#[test]
fn dash_o_with_schema_and_name_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"camera",
"-o",
"sensor_msgs/Image",
"image",
])
.expect("`-o SCHEMA NAME` must parse");
let Commands::Node {
action: NodeAction::Create { output, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert_eq!(output, vec!["sensor_msgs/Image", "image"]);
}
#[test]
fn raw_ffi_flag_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"camera",
"--raw-ffi",
"-o",
"sensor_msgs/Image",
"image",
])
.expect("`--raw-ffi` must parse alongside `-o`");
let Commands::Node {
action: NodeAction::Create { raw_ffi, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert!(raw_ffi);
}
#[test]
fn policy_period_ms_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"camera",
"--policy",
"period_ms=33",
"-o",
"sensor_msgs/Image",
"image",
])
.expect("`--policy period_ms=33` must parse");
let Commands::Node {
action: NodeAction::Create { policy, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert_eq!(policy.as_deref(), Some("period_ms=33"));
}
}
#[cfg(test)]
mod bridge_config_override_tests {
use super::*;
#[test]
fn override_note_fires_only_for_a_different_preexisting_value() {
let generated = Path::new("/ws/graphs/attach.bridge.yaml");
assert_eq!(bridge_config_override_note(None, generated), None);
assert_eq!(
bridge_config_override_note(
Some(std::ffi::OsStr::new("/ws/graphs/attach.bridge.yaml")),
generated
),
None
);
let note = bridge_config_override_note(
Some(std::ffi::OsStr::new("/other/go2.bridge.yaml")),
generated,
)
.expect("a different pre-existing value must produce the note");
assert!(note.contains("/other/go2.bridge.yaml"), "{note}");
assert!(note.contains("/ws/graphs/attach.bridge.yaml"), "{note}");
assert!(note.contains("overriding for this run"), "{note}");
}
}
#[cfg(test)]
mod resim_flag_mapping_tests {
use super::*;
use cli::BagAction;
use std::path::PathBuf;
fn play(strict_state: bool, verify: bool) -> BagAction {
BagAction::Play {
bag: PathBuf::from("/tmp/b.mcap"),
resim: Some("all".into()),
verify,
rate: None,
repeat: false,
topics: vec![],
duration: Some(11.0),
start_offset: Some(4.5),
report: Some(PathBuf::from("/tmp/r.json")),
tolerance: Some(PathBuf::from("/tmp/t.yaml")),
strict_state,
}
}
#[test]
fn every_flag_reaches_play_flags() {
let f = play_flags_of(&play(true, true)).expect("a `bag play` maps");
assert_eq!(f.resim.as_deref(), Some("all"));
assert!(f.verify);
assert_eq!(f.duration, Some(11.0));
assert_eq!(f.start_offset, Some(4.5));
assert_eq!(
f.report.as_deref(),
Some(std::path::Path::new("/tmp/r.json"))
);
assert_eq!(
f.tolerance.as_deref(),
Some(std::path::Path::new("/tmp/t.yaml"))
);
assert!(
f.strict_state,
"`--strict-state` must survive the variant -> PlayFlags mapping"
);
}
#[test]
fn a_flag_nobody_gave_is_not_invented() {
let f = play_flags_of(&play(false, false)).expect("a `bag play` maps");
assert!(!f.strict_state);
assert!(!f.verify);
assert!(!f.repeat);
assert!(f.rate.is_none());
assert!(f.topics.is_empty());
}
#[test]
fn a_non_play_action_carries_no_resim_flags() {
assert!(play_flags_of(&BagAction::Info {
bag: PathBuf::from("/tmp/b.mcap"),
})
.is_none());
}
}
#[cfg(test)]
mod is_resim_family_tests {
use super::*;
use cli::BagAction;
use std::path::PathBuf;
#[derive(Default)]
struct Flags {
resim: Option<String>,
verify: bool,
duration: Option<f64>,
start_offset: Option<f64>,
report: Option<PathBuf>,
tolerance: Option<PathBuf>,
strict_state: bool,
}
fn play(f: Flags) -> BagAction {
BagAction::Play {
bag: PathBuf::from("/tmp/b.mcap"),
resim: f.resim,
verify: f.verify,
rate: None,
repeat: false,
topics: vec![],
duration: f.duration,
start_offset: f.start_offset,
report: f.report,
tolerance: f.tolerance,
strict_state: f.strict_state,
}
}
#[test]
fn a_playback_only_bound_is_not_a_resim_invocation() {
for (label, action) in [
(
"--start-offset",
play(Flags {
start_offset: Some(1.5),
..Flags::default()
}),
),
(
"--duration",
play(Flags {
duration: Some(2.5),
..Flags::default()
}),
),
(
"both bounds",
play(Flags {
start_offset: Some(1.5),
duration: Some(2.5),
..Flags::default()
}),
),
] {
assert!(
!is_resim_family(&action),
"`bag play <bag> {label}` is PLAYBACK and must reach `run_bag`"
);
}
}
#[test]
fn a_playback_only_bound_under_resim_still_routes_to_the_resim_surface() {
assert!(is_resim_family(&play(Flags {
resim: Some("all".into()),
start_offset: Some(1.5),
..Flags::default()
})));
}
#[test]
fn every_resim_only_flag_claims_the_invocation_on_its_own() {
for (label, action) in [
(
"--resim",
play(Flags {
resim: Some("all".into()),
..Flags::default()
}),
),
(
"--verify",
play(Flags {
verify: true,
..Flags::default()
}),
),
(
"--report",
play(Flags {
report: Some(PathBuf::from("/tmp/r.json")),
..Flags::default()
}),
),
(
"--tolerance",
play(Flags {
tolerance: Some(PathBuf::from("/tmp/t.yaml")),
..Flags::default()
}),
),
(
"--strict-state",
play(Flags {
strict_state: true,
..Flags::default()
}),
),
] {
assert!(
is_resim_family(&action),
"`{label}` is resim-only and must answer with EXIT_USAGE"
);
}
assert!(!is_resim_family(&play(Flags::default())));
assert!(!is_resim_family(&BagAction::Info {
bag: PathBuf::from("/tmp/b.mcap"),
}));
}
}