mod cli;
mod completion;
mod families;
mod install_marker;
mod network_switch;
mod telemetry;
mod telemetry_events;
#[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::FromArgMatches;
use cerulion_cli_engine::error::CliResult;
use cerulion_cli_engine::ipc_cleanup::SweepMode;
use cerulion_cli_engine::node_cmd::resolve_create_policy;
use cerulion_cli_engine::ros_attach_root;
use cerulion_cli_engine::workspace::CerulionWorkspace;
use cerulion_cli_engine::{
account_cmd, connect_cmd, graph_cmd, login_cmd, node_cmd, partition_emit, ros_cmd, schema_cmd,
topic_cmd, viz_client, workspace,
};
use cli::{
AccountAction, BagAction, Cli, Commands, DevicesAction, GraphAction, NodeAction, NodeLanguage,
Ros2Action, 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`.";
const PAIR_UNAVAILABLE_MSG: &str = "code pairing is not available in this release; robots on your account are reached by signing in with `cerulion login`";
fn main() -> ExitCode {
clap_complete::CompleteEnv::with_factory(families::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 matches = families::cli_command().get_matches();
#[cfg(unix)]
if matches!(
matches.subcommand_name(),
Some("bootstrap-robot" | "robot-sync-epoch")
) {
let worker = match Cli::from_arg_matches(&matches)
.unwrap_or_else(|e| e.exit())
.command
{
Commands::BootstrapRobot { state_root } => bootstrap_robot(&state_root),
Commands::RobotSyncEpoch { state_root } => robot_sync_epoch(&state_root),
_ => unreachable!("the subcommand name selected an internal worker"),
};
return match worker {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error: {e}");
ExitCode::FAILURE
}
};
}
let switch = network_switch::SwitchEnv::from_process();
let command_run = telemetry::CommandRun::start(&matches, &switch);
let verbose = matches.get_flag("verbose");
let family_code = families::route(
families::FAMILIES,
&matches,
&switch,
|class| {
init_logging(verbose, class.is_quiet_default());
let _ = cerulion_core::iceoryx_logger::install_iceoryx2_tracing_bridge();
},
login_gate,
);
let code = match family_code {
Some(code) => code,
None => dispatch(
Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()),
&switch,
),
};
if let Some(command_run) = command_run {
command_run.finish(code);
}
code
}
fn login_gate() -> Option<ExitCode> {
let gate =
login_cmd::ensure_login_gate_carrying(&mut std::io::stderr(), telemetry::login_anon_id);
if let Ok(Some(outcome)) = &gate {
telemetry::login_completed(outcome);
}
match gate {
Ok(_) => None,
Err(e) => {
eprintln!("Error: {e}");
Some(ExitCode::from(
cerulion_cli_engine::login_cmd::EXIT_AUTH_REQUIRED,
))
}
}
}
fn dispatch(cli: Cli, switch: &network_switch::SwitchEnv) -> ExitCode {
#[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::Pair { .. }) {
eprintln!("Error: {PAIR_UNAVAILABLE_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 let Some(refusal) = viz_refusal(&cli.command, cfg!(target_os = "android")) {
eprintln!("Error: {refusal}");
return ExitCode::FAILURE;
}
if let Some(refused) = network_switch::refuse_or_gate(
command_network_refusal(&cli.command, switch),
command_needs_identity(&cli.command),
login_gate,
) {
return refused;
}
if let Commands::Bag { action } = &cli.command {
if is_resim_family(action) {
let started = std::time::Instant::now();
let code = resim_exit_code(cli.command);
telemetry::emit(
telemetry_events::RESIM_COMPLETED,
telemetry_events::resim_completed(started.elapsed(), code),
);
return ExitCode::from(code);
}
}
if matches!(cli.command, Commands::Connect { .. }) {
return connect_exit_code(cli.command);
}
if let Commands::Graph { action } = &cli.command {
match action {
GraphAction::Pause { run_id } => return run_control_exit_code(run_id, true),
GraphAction::Resume { run_id } => return run_control_exit_code(run_id, false),
_ => {}
}
}
if matches!(
cli.command,
Commands::Ros2 {
action: Ros2Action::Run { .. } | Ros2Action::Launch { .. } | Ros2Action::Migrate { .. }
}
) {
let Commands::Ros2 { action } = cli.command else {
unreachable!("guarded by the matches! above")
};
return match action {
Ros2Action::Run { args } => {
ros2_exit_code(cerulion_cli_engine::ros2_cmd::Ros2NativeVerb::Run, args)
}
Ros2Action::Launch { args } => {
ros2_exit_code(cerulion_cli_engine::ros2_cmd::Ros2NativeVerb::Launch, args)
}
Ros2Action::Migrate {
workspace,
write,
yes,
} => ros2_migrate_exit_code(workspace, write, yes),
Ros2Action::Attach { .. } => unreachable!("guarded by the matches! above"),
};
}
families::exit_code_of(run(cli))
}
fn cli_error_exit_code(err: &cerulion_cli_engine::error::CliError) -> u8 {
use cerulion_cli_engine::error::{CliError, EXIT_NODE_FATAL};
match err {
CliError::AmbiguousTopic(_) => 2,
CliError::NodeFatal { .. }
| CliError::WorkerNodeFatal { .. }
| CliError::WorkerNodeFatalAnnounced => EXIT_NODE_FATAL,
_ => 1,
}
}
#[cfg(unix)]
fn run_control_exit_code(run_id: &str, pause: bool) -> ExitCode {
use cerulion_cli_engine::run_control::{run_control_verb, RunControlOp};
let op = if pause {
RunControlOp::Pause
} else {
RunControlOp::Resume
};
match run_control_verb(run_id, op, &mut std::io::stdout(), &mut std::io::stderr()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error: {e}");
ExitCode::from(e.exit_code())
}
}
}
#[cfg(not(unix))]
fn run_control_exit_code(_run_id: &str, pause: bool) -> ExitCode {
eprintln!(
"Error: `cerulion graph {}` is only supported on Unix platforms (a run's pause \
control is POSIX shared memory)",
if pause { "pause" } else { "resume" }
);
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,
allow_param_change,
record_out,
feed_index_budget,
..
} = 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,
allow_param_change: *allow_param_change,
record_out: record_out.clone(),
feed_index_budget: *feed_index_budget,
})
}
fn is_resim_family(action: &BagAction) -> bool {
match action {
BagAction::Play {
resim,
verify,
report,
tolerance,
strict_state,
allow_param_change,
record_out,
feed_index_budget,
..
} => {
resim.is_some()
|| *verify
|| report.is_some()
|| tolerance.is_some()
|| *strict_state
|| *allow_param_change
|| record_out.is_some()
|| feed_index_budget.is_some()
}
BagAction::Info { .. } | BagAction::Record { .. } | BagAction::Migrate { .. } => false,
}
}
#[cfg(unix)]
fn resim_exit_code(command: Commands) -> u8 {
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")
};
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,
allow_param_change,
record_out,
feed_index_budget,
} => {
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,
allow_param_change,
record_out,
feed_index_budget,
},
)
.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 started = std::time::Instant::now();
let running = setup_ctrlc_handler().inspect_err(|_| {
telemetry::emit(
telemetry_events::BAG_REPLAY_COMPLETED,
telemetry_events::bag_replay_completed(started.elapsed(), false),
);
})?;
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,
);
telemetry::emit(
telemetry_events::BAG_REPLAY_COMPLETED,
telemetry_events::bag_replay_completed(started.elapsed(), summary.is_ok()),
);
let summary = summary?;
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 started = std::time::Instant::now();
let running = setup_ctrlc_handler().inspect_err(|_| {
telemetry::emit(
telemetry_events::BAG_RECORD_FAILED,
telemetry_events::bag_record_failed(started.elapsed()),
);
})?;
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,
);
match &summary {
Ok(summary) => telemetry::emit(
telemetry_events::BAG_RECORD_COMPLETED,
telemetry_events::bag_record_completed(
started.elapsed(),
summary
.bag_paths
.iter()
.filter_map(|path| std::fs::metadata(path).ok())
.map(|meta| meta.len())
.sum(),
summary.per_topic.len(),
),
),
Err(_) => telemetry::emit(
telemetry_events::BAG_RECORD_FAILED,
telemetry_events::bag_record_failed(started.elapsed()),
),
}
let summary = summary?;
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) -> u8 {
eprintln!(
"Error: `cerulion bag play --resim` is only supported on Unix platforms (the bag reader \
depends on Unix-only POSIX trace-ring types)"
);
1
}
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 started = std::time::Instant::now();
let plan = match connect_cmd::plan(&args) {
Ok(p) => p,
Err(e) => {
eprintln!("Error: {e}");
telemetry::emit(
telemetry_events::CONNECT_SESSION_COMPLETED,
telemetry_events::connect_session_completed(started.elapsed(), 1),
);
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");
let result = connect_cmd::spawn_and_wait(&plan, running);
telemetry::emit(
telemetry_events::CONNECT_SESSION_COMPLETED,
telemetry_events::connect_session_completed(
started.elapsed(),
result.as_ref().map_or(1, |code| i64::from(*code)),
),
);
match result {
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::plan_ros2_passthrough(verb, &args) {
Ok(plan) => plan,
Err(e) => {
eprintln!("Error: {e}");
return ExitCode::from(ros2_cmd::classify(&e));
}
};
if let Some(notice) = ros2_cmd::delivery_notice(&plan) {
eprintln!("{notice}");
}
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 ros2_migrate_exit_code(workspace: PathBuf, write: bool, yes: bool) -> ExitCode {
use cerulion_cli_engine::ros2_migrate as rm;
use std::io::IsTerminal as _;
use std::io::Write as _;
let tool = match rm::resolve_engine_binary() {
Ok(tool) => tool,
Err(msg) => {
eprintln!("Error: {msg}");
return ExitCode::from(cerulion_cli_engine::ros2_cmd::EXIT_UNAVAILABLE);
}
};
let mut engine = rm::ClangToolEngine { tool };
let mut confirm = |prompt: &str| -> CliResult<bool> {
print!("{prompt}");
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"))
};
let mut build_runner = |ws: &std::path::Path, pkgs: &[String]| rm::run_colcon_build(ws, pkgs);
let mut stdout = std::io::stdout();
let running: Option<Arc<AtomicBool>> = if write {
match setup_ctrlc_handler() {
Ok(r) => Some(r),
Err(e) => {
eprintln!(
"Error: cannot protect the write window from Ctrl-C \
({e}) — refusing to write. Re-run when a signal \
handler can be installed."
);
return ExitCode::FAILURE;
}
}
} else {
None
};
let interrupted = move || running.as_ref().is_some_and(|r| !r.load(Ordering::Relaxed));
let outcome = rm::run_migrate(
&rm::MigrateOptions {
workspace,
write,
assume_yes: yes,
},
&mut rm::MigrateDeps {
engine: &mut engine,
is_tty: std::io::stdin().is_terminal(),
confirm: &mut confirm,
build_runner: &mut build_runner,
out: &mut stdout,
interrupted: &interrupted,
},
);
match outcome {
Ok(rm::MigrateOutcome::Applied {
build_error: Some(msg),
..
}) => {
eprintln!("Error: {msg}");
ExitCode::FAILURE
}
Ok(_) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(unix)]
fn bootstrap_robot(state_root: &std::path::Path) -> CliResult<()> {
cerulion_cli_engine::robot_bootstrap::run(state_root, &mut std::io::stdout().lock())
}
#[cfg(unix)]
fn robot_sync_epoch(state_root: &std::path::Path) -> CliResult<()> {
cerulion_cli_engine::robot_bootstrap::sync_epoch(state_root, &mut std::io::stdout().lock())
}
fn run(cli: Cli) -> CliResult<()> {
match cli.command {
#[cfg(unix)]
Commands::BootstrapRobot { state_root } => bootstrap_robot(&state_root),
#[cfg(unix)]
Commands::RobotSyncEpoch { state_root } => robot_sync_epoch(&state_root),
Commands::Workspace { action } => match action {
WorkspaceAction::Create { name, starter } => {
let cwd = std::env::current_dir()?;
let starter: Option<cerulion_cli_engine::starter::Starter> =
starter.map(Into::into);
let ws = if let Some(starter) = starter {
cerulion_cli_engine::starter::workspace_create_with_starter(
&cwd, &name, starter,
)?
} else {
workspace::workspace_create(&cwd, &name)?
};
println!("Created workspace at {}", ws.root.display());
if let Some(starter) = starter {
println!(
" starter: {} (bundled with this CLI); see README.md",
starter.name()
);
}
if let Some(source) = &ws.dependency_source {
println!(" dependencies: {source}");
}
cerulion_cli_engine::rust_setup::check_rust(&ws.root);
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}");
}
cerulion_cli_engine::rust_setup::check_rust(&ws.root);
Ok(())
}
},
Commands::Node { action } => {
let ws = discover_workspace()?;
match action {
NodeAction::Create {
node_type,
lang,
output,
input,
trigger_input,
policy,
raw_ffi,
} => {
if raw_ffi && matches!(lang, NodeLanguage::Python) {
return Err(cerulion_cli_engine::error::CliError::Validation(
"--raw-ffi applies to Rust nodes only; Python nodes always use the embedded-CPython template"
.to_string(),
));
}
if trigger_input.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-T` per node. For a sync node over several inputs, \
create it with `--policy sync_window_ms=N`, add each further input \
with `cerulion node modify <TYPE> -i SCHEMA NAME`, then mark the \
inputs to align as `#[input(trigger)]` in nodes/<TYPE>/src/lib.rs"
.to_string(),
));
}
let python = matches!(lang, NodeLanguage::Python);
if input.len() > 2 && !python {
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 && !python {
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 resolve_ports = |args: &[String]| {
args.chunks(2)
.map(|pair| {
let (schema, name) = parse_port_args(pair);
Ok((resolve_and_report(&ws.schemas_dir, &schema, !python)?, name))
})
.collect::<Result<Vec<(String, String)>, cerulion_cli_engine::error::CliError>>()
};
let outputs = resolve_ports(&output)?;
let regular_inputs = resolve_ports(&input)?;
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, !python)?, 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 build_note = node_create_build_note(
&node_type,
resolved_policy.as_ref(),
trigger_name.is_some(),
raw_ffi,
);
let options = node_cmd::NodeCreateOptions {
outputs,
inputs: combined_inputs,
trigger: trigger_name,
raw_ffi,
language: match lang {
NodeLanguage::Rust => node_cmd::NodeLanguage::Rust,
NodeLanguage::Python => node_cmd::NodeLanguage::Python,
},
};
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);
if let Some(note) = build_note {
eprintln!("{note}");
}
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 a sync node over several inputs, \
set `--policy sync_window_ms=N`, add one input per call with `-i \
SCHEMA NAME`, then mark the inputs to align as `#[input(trigger)]` \
in nodes/<TYPE>/src/lib.rs"
.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, true)?, 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, true)?, 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, true)?;
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 cargo_dir = ws
.nodes_dir
.join(&node_type)
.exists()
.then(|| cerulion_cli_engine::rust_setup::check_rust(&ws.root))
.flatten();
let started = std::time::Instant::now();
let outcome = node_cmd::node_build_with_cargo_dir(
&ws.root,
&node_type,
release,
&mut |notice| eprint!("{notice}"),
&mut |line| eprintln!("{line}"),
cargo_dir.as_deref(),
);
telemetry::emit(
telemetry_events::NODE_BUILD_COMPLETED,
telemetry_events::node_build_completed(
started.elapsed(),
outcome.is_ok(),
release,
),
);
let _outcome = outcome?;
println!("Built '{}'", node_type);
Ok(())
}
NodeAction::Stage {
node_type,
id,
graph,
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,
local,
} => {
let started = std::time::Instant::now();
let handler = setup_ctrlc_handler();
telemetry::emit(
telemetry_events::GRAPH_RUN_STARTED,
telemetry_events::graph_run_started(false),
);
let result = handler.and_then(|running| {
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,
local || 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
});
telemetry::emit(
telemetry_events::GRAPH_RUN_COMPLETED,
telemetry_events::graph_run_completed(started.elapsed(), result.is_ok()),
);
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")
);
}
}
let declared = cerulion_cli_engine::node_params_info::declared_params(
&ws.root,
&node_type,
&cerulion_cli_engine::node_inspector::InProcessInspector,
);
print!(
"{}",
cerulion_cli_engine::node_params_info::render_params(&node_type, &declared)
);
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,
launch_args,
time_source,
no_validate,
release,
no_cpu_dma_lock,
no_monitor_wait,
peer_loss,
single_process,
network,
local,
trace_limit,
no_rings,
record,
record_env,
record_cpu,
auto_partition,
yes,
} => {
let launch_args =
cerulion_cli_engine::graph_launch_args::parse_overrides(&launch_args)?;
let started = std::time::Instant::now();
let handler = setup_ctrlc_handler();
telemetry::emit(
telemetry_events::GRAPH_RUN_STARTED,
telemetry_events::graph_run_started(single_process),
);
let running = handler.inspect_err(|_| {
telemetry::emit(
telemetry_events::GRAPH_RUN_COMPLETED,
telemetry_events::graph_run_completed(started.elapsed(), false),
);
})?;
let consent = partition_emit::PartitionConsent {
auto_partition,
assume_yes: yes,
};
let result = 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,
local || network.is_some(),
trace_limit as usize,
record,
record_env.into(),
cli::record_cpu_mode(record_cpu),
Some(consent),
no_rings,
&launch_args,
);
telemetry::emit(
telemetry_events::GRAPH_RUN_COMPLETED,
telemetry_events::graph_run_completed(started.elapsed(), result.is_ok()),
);
result
}
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::Plan { name } => {
print!("{}", graph_cmd::graph_plan(&ws.root, &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::Pause { .. } | GraphAction::Resume { .. } => {
unreachable!(
"`graph pause|resume` are dispatched in `main` before the workspace \
lookup, because they address a live run"
)
}
GraphAction::RunWorker { plan } => {
let running = setup_ctrlc_handler()?;
graph_cmd::graph_run_worker_host(&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 {
all,
no_network,
connect,
listen,
scan,
show_types,
} => {
let ws = if show_types {
discover_optional_workspace()?
} else {
None
};
topic_cmd::introspect::print_topic_list(
all,
if show_types {
topic_cmd::introspect::TypeColumn::Shown
} else {
topic_cmd::introspect::TypeColumn::Hidden
},
topic_cmd::remote_discovery_options(no_network, connect, listen),
scan,
ws.as_ref().map(|w| w.schemas_dir.as_path()),
&mut std::io::stdout(),
&mut std::io::stderr(),
)
}
TopicAction::Info {
topic,
local,
robot,
} => {
let ws = discover_workspace().ok();
let schemas_dir = ws.as_ref().map(|w| w.schemas_dir.as_path());
let info = topic_cmd::topic_info_with_selection(
&topic,
schemas_dir,
topic_cmd::TopicScope::from_local(local),
robot.as_deref(),
)?;
println!("{}", info);
Ok(())
}
TopicAction::Echo {
topic,
truncate_length,
local,
robot,
} => {
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_with_selection(
&topic,
schemas_dir,
running,
&mut std::io::stdout(),
usize::try_from(truncate_length).unwrap_or(usize::MAX),
topic_cmd::TopicScope::from_local(local),
robot.as_deref(),
)
}
TopicAction::Hz {
topic,
local,
robot,
} => {
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_with_selection(
&topic,
schemas_dir,
running,
&mut std::io::stdout(),
topic_cmd::TopicScope::from_local(local),
robot.as_deref(),
)
}
},
Commands::Viz {
topics,
robot,
connect,
listen,
detach,
} => {
let running = setup_ctrlc_handler()?;
run_viz(
running,
&topics,
robot.as_deref(),
&connect,
&listen,
detach,
network_switch::SwitchEnv::from_process().network_engaged(),
)
}
Commands::Login => {
let outcome =
login_cmd::run_login_carrying(&mut std::io::stderr(), telemetry::login_anon_id)?;
telemetry::login_completed(&outcome);
Ok(())
}
Commands::Logout => {
match login_cmd::run_logout()? {
login_cmd::LogoutOutcome::SignedOut { account_id } => {
println!("signed_out: account={account_id}");
eprintln!("Signed out. Run `cerulion login` to sign in again.");
}
login_cmd::LogoutOutcome::SignedOutUnrevoked { account_id, reason } => {
println!("signed_out: account={account_id}");
return Err(cerulion_cli_engine::error::CliError::Login(format!(
"signed out on this machine, but the account service did not confirm \
the session was revoked, so it stays valid there until it expires: \
{reason}"
)));
}
login_cmd::LogoutOutcome::NotSignedIn => {
println!("not_signed_in");
eprintln!("This machine was not signed in; nothing changed.");
}
}
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::Telemetry { action } => telemetry::run_verb(action, &mut std::io::stdout()),
Commands::Tui => cerulion_cli_tui::run().map_err(|e| {
cerulion_cli_engine::error::CliError::Validation(format!(
"`cerulion tui` failed: {e}. It needs an interactive terminal: if you ran it \
from a pipe, a script or a non-interactive SSH command, run it in a terminal \
window instead"
))
}),
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(if report_only {
SweepMode::ReportOnly
} else {
SweepMode::Remove
}),
Commands::Connect { .. } => {
unreachable!("`cerulion connect` 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::Ros { .. } => Err(cerulion_cli_engine::error::CliError::Validation(
ROS_VERB_MOVED_MSG.to_string(),
)),
Commands::Pair { .. } => Err(cerulion_cli_engine::error::CliError::Validation(
PAIR_UNAVAILABLE_MSG.to_string(),
)),
Commands::Ros2 { action } => match action {
Ros2Action::Run { .. } | Ros2Action::Launch { .. } | Ros2Action::Migrate { .. } => {
unreachable!(
"`cerulion ros2 run|launch|migrate` 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 (attach_root, _root_guard) =
ros_attach_root::AttachRoot::resolve(&std::env::current_dir()?, dry_run)?;
let workspace_root = attach_root.path().to_path_buf();
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,
};
if !opts.dry_run && !graph_cmd::remote_network_suppressed() {
login_cmd::require_serving_authority().map_err(|reason| {
cerulion_cli_engine::error::CliError::Login(reason.into())
})?;
}
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,
&workspace_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)"
);
telemetry::emit(telemetry_events::ROS2_BRIDGE_STARTED, Vec::new());
telemetry::emit(
telemetry_events::GRAPH_RUN_STARTED,
telemetry_events::graph_run_started(true),
);
let started = std::time::Instant::now();
let result = graph_cmd::graph_run(
&workspace_root,
&workspace_root.join("graphs"),
&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, &[], );
telemetry::emit(
telemetry_events::GRAPH_RUN_COMPLETED,
telemetry_events::graph_run_completed(
started.elapsed(),
result.is_ok(),
),
);
result
}
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)
}
fn discover_optional_workspace() -> CliResult<Option<CerulionWorkspace>> {
match discover_workspace() {
Ok(ws) => Ok(Some(ws)),
Err(cerulion_cli_engine::error::CliError::WorkspaceNotFound { .. }) => Ok(None),
Err(e) => Err(e),
}
}
fn run_viz(
running: Arc<AtomicBool>,
topics: &[String],
robot: Option<&str>,
connect: &[String],
listen: &[String],
detach: bool,
network_off: bool,
) -> CliResult<()> {
if let Some(message) = viz_client::unsupported_viz_message(cfg!(target_os = "android")) {
return Err(cerulion_cli_engine::error::CliError::Validation(
message.to_string(),
));
}
#[cfg(not(unix))]
{
let _ = (running, topics, robot, connect, listen, detach, network_off);
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, detach, network_off)
}
}
#[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)]
fn run_viz_unix(
running: Arc<AtomicBool>,
topics: &[String],
robot: Option<&str>,
connect: &[String],
listen: &[String],
detach: bool,
network_off: 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(VIZ_ROBOT_NEEDS_A_TOPIC.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())
};
if let Some(refusal) = robot.and_then(cerulion_cli_engine::viz_cmd::remote_viz_refusal) {
return Err(val(refusal));
}
let account_target = robot
.map(|selector| {
cerulion_cli_engine::account_robot_access::prepare_viz_target(selector, connect, listen)
})
.transpose()?;
if let Some(target) = &account_target {
for diagnostic in &target.diagnostics {
eprintln!("cerulion viz: {diagnostic}");
}
}
let robot = account_target.as_ref().map(|target| target.route.as_str());
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 beside `cerulion` or on $PATH. Cerulion \
Studio bundles its own copy; to use `cerulion viz` from a terminal, build \
the daemon from a checkout of the Cerulion source tree (`cargo build \
--release -p cerulion_vizd`) and put it beside `cerulion` or on $PATH",
viz_client::VIZD_BINARY
),
)
})?;
viz_client::spawn_vizd_detached(
&bin,
&log_for_spawn,
&connect_for_spawn,
&listen_for_spawn,
network_off,
)?;
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. Restart it \
(stop the daemon, then re-run) to bake in these locators."
);
}
if let Some(note) = viz_reused_daemon_switch_note(spawned, network_off) {
eprintln!("{note}");
}
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(());
}
println!(
"cerulion viz: open Cerulion Studio to see {ok_count} attached topic(s) \
(Studio connects to the same cerulion-vizd daemon)."
);
if detach {
println!(
"cerulion viz: attached {ok_count} topic(s) and detached — the daemon keeps 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(mode: SweepMode) -> CliResult<()> {
let (result, converged) = clean_dead_nodes(mode);
let reclaim = mode.removes() && converged;
if mode.removes() && !converged {
println!(
"Skipping stale `.shm_state` reclamation: the dead-node sweep did not converge \
(see above — a refused node is still registered, or the registry could not be \
fully scanned). Removing a name mapping a still-registered dead node needs would \
make it permanently unreclaimable. Clear the failures, then re-run `cerulion clean`."
);
}
report_shm_state_population(if reclaim {
SweepMode::Remove
} else {
SweepMode::ReportOnly
});
if mode.reports_only() {
println!("{NOTHING_WAS_REMOVED}");
}
result
}
const NOTHING_WAS_REMOVED: &str =
"Report only: nothing was removed. Run `cerulion clean` without `--report-only` to act on \
everything listed above.";
fn report_shm_state_population(mode: SweepMode) {
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 (scan_mode, budget) = if mode.removes() {
(
shm_state::ScanMode::Reclaim,
shm_state::SHM_STATE_RECLAIM_BUDGET,
)
} else {
(
shm_state::ScanMode::ReportOnly,
shm_state::SHM_STATE_REPORT_BUDGET,
)
};
shm_state::scan(
std::path::Path::new(shm_state::SHM_STATE_DIRECTORY),
budget,
shm_state::classify_budget(budget),
scan_mode,
&shm_state::LibcProbe,
)
});
#[cfg(not(unix))]
let shm: Option<shm_state::ShmStateReport> = {
let _ = mode;
None
};
for line in shm_state::render_lines(&nodes, shm.as_ref()) {
println!("{line}");
}
}
fn clean_dead_nodes(mode: SweepMode) -> (CliResult<()>, bool) {
use cerulion_cli_engine::ipc_cleanup;
let report = ipc_cleanup::sweep_dead_nodes(mode);
report_sweep(&report, mode);
let converged = report.failed_cleanups == 0 && report.registry_errors.is_empty();
(Ok(()), converged)
}
fn report_sweep(report: &cerulion_cli_engine::ipc_cleanup::CleanupReport, mode: SweepMode) {
for line in render_registry_errors(&report.registry_errors) {
println!("{line}");
}
if mode.reports_only() && !report.dead_nodes.is_empty() {
for line in render_would_remove(
&report.dead_nodes,
&cerulion_cli_engine::shm_state::iceoryx2_node_dir(),
) {
println!("{line}");
}
return;
}
if report.cleanups == 0 && report.failed_cleanups == 0 {
if report.registry_errors.is_empty() {
println!("No dead iceoryx2 nodes found — nothing to clean.");
} else {
println!(
"No dead iceoryx2 node was reached — the registry could not be fully scanned \
(see above), so this is not evidence that none is registered."
);
}
return;
}
println!(
"Cleaned {} dead iceoryx2 node(s); {} cleanup(s) failed.",
report.cleanups, report.failed_cleanups
);
if report.failed_cleanups == 0 {
return;
}
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 refused to remove the node's registry entry; \
the node ids of the refused nodes listed below name the \
culprit and carry the sub-causes iceoryx2 logged. A \
stranded entry never converges on its own"
}
_ => "see iceoryx2 logs for details",
};
println!(" {} × {} — {}", count, cause, remediation);
}
}
for line in render_refused_nodes(&report.failures) {
println!("{line}");
}
if !report.unclassified.is_empty() {
println!(
"Unclassified failures ({}): {RAW_IOX2_LINES_HINT}",
report.unclassified.len()
);
}
}
const NODES_SHOWN: usize = 10;
const REMOVAL_CAN_STILL_BE_REFUSED: &str =
" (what the sweep would ATTEMPT, not a promise each one comes off: iceoryx2 reports \
insufficient permissions or a version mismatch only when it tries to remove a node, which \
a report does not do)";
fn render_would_remove(
dead: &[cerulion_cli_engine::ipc_cleanup::DeadNodeIdentity],
nodes_dir: &Path,
) -> Vec<String> {
if dead.is_empty() {
return Vec::new();
}
let mut lines = vec![format!(
"Dead iceoryx2 node(s) `cerulion clean` would sweep: {} under {} (report only; \
none was removed)",
dead.len(),
nodes_dir.display()
)];
lines.extend(
dead.iter()
.take(NODES_SHOWN)
.map(|node| format!(" node {}", node.name)),
);
if dead.len() > NODES_SHOWN {
lines.push(format!(" … and {} more", dead.len() - NODES_SHOWN));
}
lines.push(REMOVAL_CAN_STILL_BE_REFUSED.to_string());
lines
}
const RAW_IOX2_LINES_HINT: &str =
"run with `RUST_LOG=iceoryx2=trace cerulion clean` for the raw iceoryx2 lines";
fn render_registry_errors(errors: &[String]) -> Vec<String> {
if errors.is_empty() {
return Vec::new();
}
let mut lines = vec!["registry-wide sweep errors:".to_string()];
lines.extend(errors.iter().map(|error| format!(" {error}")));
lines.push(
" (the registry itself could not be fully scanned — not any node's fault; a dead node \
the sweep never reached may still be registered and is counted nowhere above)"
.to_string(),
);
lines
}
fn render_refused_nodes(
failures: &[cerulion_cli_engine::ipc_cleanup::FailedNodeCleanup],
) -> Vec<String> {
if failures.is_empty() {
return Vec::new();
}
let mut lines = vec!["Refused nodes:".to_string()];
for failure in failures.iter().take(NODES_SHOWN) {
let variant = if failure.variant.is_empty() {
"(variant not reported)"
} else {
failure.variant.as_str()
};
lines.push(format!(" node {}: {}", failure.node, variant));
if failure.causes.is_empty() {
lines.push(format!(
" (no sub-cause captured — {RAW_IOX2_LINES_HINT})"
));
}
for cause in &failure.causes {
lines.push(format!(" {cause}"));
}
}
if failures.len() > NODES_SHOWN {
lines.push(format!(" … and {} more", failures.len() - NODES_SHOWN));
}
lines
}
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 node_create_build_note(
node_type: &str,
policy: Option<&cerulion_core::MacroPolicy>,
has_trigger_input: bool,
raw_ffi: bool,
) -> Option<String> {
use cerulion_core::MacroPolicy;
if raw_ffi {
return None;
}
match policy {
None if !has_trigger_input => Some(format!(
"note: '{node_type}' has no trigger policy yet, so `cerulion node build {node_type}` \
fails until it has one. Set it with `cerulion node modify {node_type} --policy \
data_trigger=<INPUT>` (or `--policy period_ms=<N>`)."
)),
Some(MacroPolicy::Sync { .. }) | Some(MacroPolicy::UnboundedSync) => Some(format!(
"note: a sync node aligns two or more trigger inputs, so `cerulion node build \
{node_type}` fails until '{node_type}' has them. Add inputs with `cerulion node \
modify {node_type} -i SCHEMA NAME`, then mark each input to align as \
`#[input(trigger)]` in nodes/{node_type}/src/lib.rs."
)),
_ => 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::Telemetry { .. }
| Commands::Logout
| Commands::Completions { .. }
| Commands::Clean { .. }
| Commands::Graph {
action: GraphAction::RunWorker { .. } | GraphAction::RunGateway { .. },
}
)
}
fn command_network_refusal(
command: &Commands,
switch: &network_switch::SwitchEnv,
) -> Option<network_switch::NetworkRefusal> {
use network_switch::NetworkRefusal;
match command {
Commands::Viz { robot, .. } => {
let refusal = cerulion_cli_engine::viz_cmd::remote_viz_refusal_from(
robot.as_deref()?,
switch.network.clone(),
switch.netd.as_deref(),
switch.vizd.as_deref(),
)?;
Some(NetworkRefusal::error(refusal, 1))
}
Commands::Connect { network, .. } => {
connect_cmd::network_refusal(network.as_deref(), switch.network.as_deref())
.map(|refusal| NetworkRefusal::warning(refusal, 0))
}
Commands::Topic { action } => match action {
TopicAction::Info { robot, .. } => {
topic_observer_network_refusal(robot.as_deref(), "topic-info", switch)
}
TopicAction::Echo { robot, .. } => {
topic_observer_network_refusal(robot.as_deref(), "topic-echo", switch)
}
TopicAction::Hz { robot, .. } => {
topic_observer_network_refusal(robot.as_deref(), "topic-hz", switch)
}
TopicAction::List { .. } => None,
},
#[cfg(unix)]
Commands::BootstrapRobot { .. } | Commands::RobotSyncEpoch { .. } => None,
Commands::Workspace { .. }
| Commands::Node { .. }
| Commands::Graph { .. }
| Commands::Ros { .. }
| Commands::Pair { .. }
| Commands::Ros2 { .. }
| Commands::Login
| Commands::Logout
| Commands::Account { .. }
| Commands::Telemetry { .. }
| Commands::Tui
| Commands::Trace { .. }
| Commands::Bag { .. }
| Commands::Completions { .. }
| Commands::Clean { .. } => None,
#[cfg(unix)]
Commands::Flashback { .. } => None,
#[cfg(not(unix))]
Commands::Flashback => None,
#[cfg(unix)]
Commands::Bagd(..) => None,
#[cfg(not(unix))]
Commands::Bagd => None,
}
}
fn topic_observer_network_refusal(
robot: Option<&str>,
verb: &str,
switch: &network_switch::SwitchEnv,
) -> Option<network_switch::NetworkRefusal> {
let robot = robot?;
network_switch::robot_refusal(Some(robot), verb, switch).or_else(|| {
graph_cmd::netd_network_off_from(switch.netd.as_deref()).then(|| {
network_switch::NetworkRefusal::error("remote topic reads are disabled by CERULION_NETD_NETWORK=off; unset CERULION_NETD_NETWORK to read a robot", 2)
})
})
}
const VIZ_ROBOT_NEEDS_A_TOPIC: &str = concat!(
"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`"
);
#[cfg(unix)]
const VIZ_REUSED_DAEMON_UNDER_SWITCH: &str =
"cerulion viz: note: the cerulion-vizd daemon was already running, so this command's \
network kill switch does not change it: it keeps the telemetry setting it started with. \
If it started without the switch, run `cerulion telemetry off` to stop its heartbeats, \
or stop the daemon and re-run to start it under the switch.";
#[cfg(unix)]
fn viz_reused_daemon_switch_note(spawned: bool, network_off: bool) -> Option<&'static str> {
(!spawned && network_off).then_some(VIZ_REUSED_DAEMON_UNDER_SWITCH)
}
fn viz_refusal(command: &Commands, is_android: bool) -> Option<&'static str> {
let Commands::Viz { topics, robot, .. } = command else {
return None;
};
cerulion_cli_engine::viz_client::unsupported_viz_message(is_android)
.or_else(|| (robot.is_some() && topics.is_empty()).then_some(VIZ_ROBOT_NEEDS_A_TOPIC))
}
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, rust_port: bool) -> CliResult<String> {
use cerulion_cli_engine::schema_cmd::PortSchemaProvenance;
let resolved = schema_cmd::resolve_port_schema(schemas_dir, raw)?;
if rust_port {
schema_cmd::refuse_store_port_scaffold(&resolved, raw, schemas_dir)?;
}
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::QualifiedStore
| PortSchemaProvenance::ResolvedStore { .. }
| 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));
assert!(!command_needs_identity(&Commands::Logout));
}
#[test]
fn telemetry_verb_is_exempt() {
assert!(!command_needs_identity(&Commands::Telemetry {
action: cli::TelemetryAction::Off,
}));
}
#[test]
fn internal_subprocess_verbs_are_exempt() {
assert!(!command_needs_identity(&Commands::Graph {
action: GraphAction::RunWorker {
plan: vec![PathBuf::from("plan.json")],
},
}));
assert!(!command_needs_identity(&Commands::Graph {
action: GraphAction::RunGateway {
handoff: PathBuf::from("handoff.json"),
},
}));
}
#[test]
fn the_clean_verb_is_exempt() {
assert!(!command_needs_identity(&Commands::Clean {
report_only: true
}));
assert!(!command_needs_identity(&Commands::Clean {
report_only: false
}));
}
#[test]
fn the_android_viz_refusal_fires_before_the_identity_gate() {
let viz = Commands::Viz {
topics: vec!["/t".to_string()],
robot: None,
connect: Vec::new(),
listen: Vec::new(),
detach: false,
};
assert!(command_needs_identity(&viz));
assert_eq!(
viz_refusal(&viz, true),
cerulion_cli_engine::viz_client::unsupported_viz_message(true)
);
assert!(viz_refusal(&viz, true).is_some());
assert!(viz_refusal(&viz, false).is_none());
assert!(viz_refusal(&Commands::Login, true).is_none());
let robot_only = Commands::Viz {
topics: Vec::new(),
robot: Some("go2".to_string()),
connect: Vec::new(),
listen: Vec::new(),
detach: false,
};
assert!(viz_refusal(&robot_only, false)
.is_some_and(|r| r.starts_with("cerulion viz --robot needs at least one TOPIC")));
assert_eq!(
viz_refusal(&robot_only, true),
cerulion_cli_engine::viz_client::unsupported_viz_message(true)
);
let robot_topic = Commands::Viz {
topics: vec!["/t".to_string()],
robot: Some("go2".to_string()),
connect: Vec::new(),
listen: Vec::new(),
detach: false,
};
assert!(viz_refusal(&robot_topic, false).is_none());
}
#[test]
fn ordinary_verbs_are_gated() {
assert!(command_needs_identity(&Commands::Topic {
action: TopicAction::List {
all: false,
no_network: true,
connect: Vec::new(),
listen: Vec::new(),
scan: false,
show_types: 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", true).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", true).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", true).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", true).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 partition 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, NodeLanguage};
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 node_new_alias_and_python_language_parse() {
let cli = try_parse(&["cerulion", "node", "new", "counter", "--lang", "python"])
.expect("node new --lang python must parse");
let Commands::Node {
action: NodeAction::Create {
node_type, lang, ..
},
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert_eq!(node_type, "counter");
assert_eq!(lang, NodeLanguage::Python);
}
#[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 python_create_with_raw_ffi_returns_validation_error() {
let _lock = crate::completion_wiring_tests::env_lock();
let _guard = crate::completion_wiring_tests::EnvGuard::capture();
let tmp = tempfile::tempdir().expect("tempdir");
let ws = cerulion_cli_engine::workspace::workspace_create(tmp.path(), "test_ws")
.expect("workspace");
std::env::set_current_dir(&ws.root).expect("enter workspace");
let cli = try_parse(&[
"cerulion",
"node",
"create",
"python_raw",
"--lang",
"python",
"--raw-ffi",
"--policy",
"period_ms=1",
])
.expect("parse Python raw FFI create");
let result = super::run(cli);
let err = result.expect_err("Python raw FFI must be rejected");
assert_eq!(
err.to_string(),
"--raw-ffi applies to Rust nodes only; Python nodes always use the embedded-CPython template"
);
assert!(!ws.nodes_dir.join("python_raw").exists());
}
#[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,
allow_param_change: strict_state,
record_out: Some(PathBuf::from("/tmp/o.mcap")),
feed_index_budget: verify.then_some(4096),
}
}
#[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_eq!(
f.record_out.as_deref(),
Some(std::path::Path::new("/tmp/o.mcap")),
"`--record-out` must survive the variant -> PlayFlags mapping"
);
assert!(
f.strict_state,
"`--strict-state` must survive the variant -> PlayFlags mapping"
);
assert!(
f.allow_param_change,
"`--allow-param-change` must survive the variant -> PlayFlags mapping"
);
assert_eq!(
f.feed_index_budget,
Some(4096),
"`--feed-index-budget` 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.allow_param_change);
assert!(!f.verify);
assert!(!f.repeat);
assert!(f.rate.is_none());
assert!(f.topics.is_empty());
assert!(f.feed_index_budget.is_none());
}
#[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,
allow_param_change: bool,
record_out: Option<PathBuf>,
feed_index_budget: Option<u64>,
}
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,
allow_param_change: f.allow_param_change,
record_out: f.record_out,
feed_index_budget: f.feed_index_budget,
}
}
#[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()
}),
),
(
"--allow-param-change",
play(Flags {
allow_param_change: true,
..Flags::default()
}),
),
(
"--record-out",
play(Flags {
record_out: Some(PathBuf::from("/tmp/o.mcap")),
..Flags::default()
}),
),
(
"--feed-index-budget",
play(Flags {
feed_index_budget: Some(4096),
..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"),
}));
}
}
#[cfg(test)]
mod cli_error_exit_code_tests {
use super::cli_error_exit_code;
use cerulion_cli_engine::error::CliError;
#[test]
fn fatal_nodes_exit_3_ambiguous_topics_exit_2_and_other_errors_exit_1() {
let in_process = CliError::NodeFatal {
nodes: vec![("lidar".to_string(), "Fatal error: lost".to_string())],
};
let worker = CliError::WorkerNodeFatal {
groups: vec!["g0".to_string()],
};
assert_eq!(cli_error_exit_code(&in_process), 3);
assert_eq!(cli_error_exit_code(&worker), 3);
assert_eq!(cli_error_exit_code(&CliError::WorkerNodeFatalAnnounced), 3);
assert_eq!(
cli_error_exit_code(&CliError::AmbiguousTopic("candidates".into())),
2
);
assert_eq!(
cli_error_exit_code(&CliError::Validation("x".to_string())),
1
);
}
}
#[cfg(all(test, unix))]
mod viz_reused_daemon_switch_note_tests {
use super::{viz_reused_daemon_switch_note, VIZ_REUSED_DAEMON_UNDER_SWITCH};
#[test]
fn only_a_reused_daemon_under_the_switch_gets_the_note() {
assert_eq!(
viz_reused_daemon_switch_note(false, true),
Some(VIZ_REUSED_DAEMON_UNDER_SWITCH),
"a daemon this run reused, under the switch"
);
assert_eq!(
viz_reused_daemon_switch_note(true, true),
None,
"a daemon this run started"
);
assert_eq!(
viz_reused_daemon_switch_note(false, false),
None,
"no switch"
);
assert_eq!(viz_reused_daemon_switch_note(true, false), None);
assert!(VIZ_REUSED_DAEMON_UNDER_SWITCH.contains("telemetry"));
assert!(VIZ_REUSED_DAEMON_UNDER_SWITCH.contains("`cerulion telemetry off`"));
}
}