mod cli;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use clap::Parser;
use cerulion_cli_engine::error::CliResult;
use cerulion_cli_engine::workspace::CerulionWorkspace;
use cerulion_cli_engine::{graph_cmd, node_cmd, schema_cmd, topic_cmd, workspace};
use cli::{
Cli, Commands, GraphAction, NodeAction, SchemaAction, TopicAction, TraceAction, WorkspaceAction,
};
fn main() -> ExitCode {
if std::env::var_os("IOX2_LOG_LEVEL").is_none() {
std::env::set_var("IOX2_LOG_LEVEL", "error");
}
let cli = Cli::parse();
if !matches!(cli.command, Commands::Tui) {
init_logging(cli.verbose);
}
let _ = cerulion_core::iceoryx_logger::install_iceoryx2_tracing_bridge();
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error: {}", e);
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> CliResult<()> {
match cli.command {
Commands::Workspace { action } => match action {
WorkspaceAction::Create { name } => {
let cwd = std::env::current_dir()?;
let ws = workspace::workspace_create(&cwd, &name)?;
println!("Created workspace at {}", ws.root.display());
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());
Ok(())
}
},
Commands::Node { action } => {
let ws = discover_workspace()?;
match action {
NodeAction::Create {
node_type,
output,
input,
trigger_input,
policy,
raw_ffi,
} => {
if trigger_input.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-T` per node; for sync over multiple inputs use \
`-i` for each plus `--policy sync_window_ms=N`"
.to_string(),
));
}
if input.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-i` per `node create` (use `node modify` to add \
more inputs after creation)"
.to_string(),
));
}
if output.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-o` per `node create` (use `node modify` to add \
more outputs after creation)"
.to_string(),
));
}
let outputs: Vec<(String, String)> = if output.is_empty() {
vec![]
} else {
vec![parse_port_args(&output)]
};
let regular_inputs: Vec<(String, String)> = if input.is_empty() {
vec![]
} else {
vec![parse_port_args(&input)]
};
let trigger_inputs: Vec<(String, String)> = if trigger_input.is_empty() {
vec![]
} else {
vec![parse_port_args(&trigger_input)]
};
let explicit_policy = match policy.as_deref() {
Some(spec) => Some(parse_policy_spec(spec)?),
None => None,
};
let resolved_policy = resolve_create_policy(
explicit_policy.as_ref(),
trigger_inputs.first(),
®ular_inputs,
)?;
check_dash_t_dash_i_name_collision(&trigger_inputs, ®ular_inputs)?;
let mut combined_inputs = trigger_inputs.clone();
combined_inputs.extend(regular_inputs.iter().cloned());
let trigger_name = trigger_inputs
.first()
.map(|(_, name)| name.clone())
.or_else(|| match resolved_policy.as_ref() {
Some(cerulion_core::MacroPolicy::DataTrigger { input_name }) => {
Some(input_name.clone())
}
_ => None,
});
let options = node_cmd::NodeCreateOptions {
outputs,
inputs: combined_inputs,
trigger: trigger_name,
raw_ffi,
};
node_cmd::node_create_with_options(
&ws.nodes_dir,
&ws.root.join("Cargo.toml"),
&node_type,
resolved_policy,
&options,
)?;
println!("Created node type '{}'", node_type);
Ok(())
}
NodeAction::Delete { node_type } => {
node_cmd::node_delete(&ws.nodes_dir, &ws.root.join("Cargo.toml"), &node_type)?;
println!("Deleted node type '{}'", node_type);
Ok(())
}
NodeAction::Modify {
node_type,
input,
trigger_input,
output,
policy,
} => {
if trigger_input.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-T` per call; for sync over multiple inputs use \
`-i` for each plus `--policy sync_window_ms=N`"
.to_string(),
));
}
if input.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-i` per `node modify` call".to_string(),
));
}
if output.len() > 2 {
return Err(cerulion_cli_engine::error::CliError::Validation(
"at most one `-o` per `node modify` call".to_string(),
));
}
let regular_inputs: Vec<(String, String)> = if input.is_empty() {
vec![]
} else {
vec![parse_port_args(&input)]
};
let trigger_inputs: Vec<(String, String)> = if trigger_input.is_empty() {
vec![]
} else {
vec![parse_port_args(&trigger_input)]
};
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);
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 } => {
node_cmd::node_build(&ws.root, &node_type, release)?;
println!("Built '{}'", node_type);
Ok(())
}
NodeAction::Stage {
node_type,
id,
graph,
prefix: _,
input_binding,
} => {
let graph_name = resolve_graph_name(&ws, graph)?;
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 inputs: Vec<(String, String)> = parse_input_bindings(&input_binding);
let node_def =
graph_cmd::build_node_def(&node_type, id.as_deref(), &outputs, &inputs);
graph_cmd::node_stage(&ws.graphs_dir, &graph_name, node_def)?;
println!("Staged '{}' into graph '{}'", node_type, graph_name);
Ok(())
}
NodeAction::Run {
node_type,
prefix,
id,
release,
no_cpu_dma_lock,
no_monitor_wait,
} => {
let running = setup_ctrlc_handler()?;
let prefix = prefix.unwrap_or_else(|| "standalone".to_string());
let metadata = node_cmd::node_info(&ws.nodes_dir, &node_type)?;
let outputs: Vec<(String, Option<String>)> = metadata
.outputs
.iter()
.map(|p| (p.name.clone(), p.schema.clone()))
.collect();
let node_def =
graph_cmd::build_node_def(&node_type, id.as_deref(), &outputs, &[]);
let temp_graph = format!("__temp_{}", node_type);
graph_cmd::graph_create(&ws.graphs_dir, &temp_graph, Some(&prefix))?;
graph_cmd::node_stage(&ws.graphs_dir, &temp_graph, node_def)?;
let result = graph_cmd::graph_run(
&ws.root,
&ws.graphs_dir,
&temp_graph,
running,
graph_cmd::TimeSource::Real, if no_cpu_dma_lock {
graph_cmd::CpuDmaLockMode::Disabled
} else {
graph_cmd::CpuDmaLockMode::Auto
},
if no_monitor_wait {
graph_cmd::MonitorWaitMode::Disabled
} else {
graph_cmd::MonitorWaitMode::Auto
},
true, release, );
let _ =
std::fs::remove_file(ws.graphs_dir.join(format!("{}.yaml", temp_graph)));
result
}
NodeAction::List => {
let nodes = node_cmd::node_list(&ws.nodes_dir)?;
if nodes.is_empty() {
println!("No nodes found.");
} else {
println!("{:<20} {:<10} {:<10} POLICY", "TYPE", "INPUTS", "OUTPUTS");
for node in &nodes {
println!(
"{:<20} {:<10} {:<10} {}",
node.node_type,
node.inputs.len(),
node.outputs.len(),
format_policy_short(node.policy.as_ref())
);
}
}
Ok(())
}
NodeAction::Info { node_type } => {
let info = node_cmd::node_info(&ws.nodes_dir, &node_type)?;
println!("Node type: {}", info.node_type);
println!("Policy: {}", format_policy_short(info.policy.as_ref()));
if !info.inputs.is_empty() {
println!("Inputs:");
for port in &info.inputs {
println!(
" {} {}",
port.name,
port.schema.as_deref().unwrap_or("(untyped)")
);
}
}
if !info.outputs.is_empty() {
println!("Outputs:");
for port in &info.outputs {
println!(
" {} {}",
port.name,
port.schema.as_deref().unwrap_or("(untyped)")
);
}
}
Ok(())
}
}
}
Commands::Graph { action } => {
let ws = discover_workspace()?;
match action {
GraphAction::Create { name, prefix } => {
graph_cmd::graph_create(&ws.graphs_dir, &name, prefix.as_deref())?;
println!("Created graph '{}'", name);
Ok(())
}
GraphAction::Run {
name,
time_source,
no_validate,
release,
no_cpu_dma_lock,
no_monitor_wait,
} => {
let running = setup_ctrlc_handler()?;
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,
)
}
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(())
}
}
}
Commands::Topic { action } => match action {
TopicAction::List => {
let topics = topic_cmd::topic_list()?;
if topics.is_empty() {
println!("No active topics.");
} else {
println!("TOPIC");
for topic in &topics {
println!("{}", topic.name);
}
}
Ok(())
}
TopicAction::Info { topic } => {
let info = topic_cmd::topic_info(&topic)?;
println!("{}", info);
Ok(())
}
TopicAction::Echo { topic } => {
let running = setup_ctrlc_handler()?;
topic_cmd::topic_echo(&topic, running, &mut std::io::stdout())
}
TopicAction::Hz { topic } => {
let running = setup_ctrlc_handler()?;
topic_cmd::topic_hz(&topic, running, &mut std::io::stdout())
}
},
Commands::Tui => cerulion_cli_tui::run().map_err(|e| {
cerulion_cli_engine::error::CliError::Validation(format!("TUI error: {}", e))
}),
Commands::Trace { action } => match action {
TraceAction::Inspect {
dir,
filter,
limit,
reverse,
} => trace_inspect(&dir, filter.as_deref(), limit, reverse),
},
Commands::Clean => clean_iceoryx2_state(),
Commands::Schema { action } => {
let ws = discover_workspace()?;
match action {
SchemaAction::Create { name } => {
schema_cmd::schema_create(&ws.schemas_dir, &name)?;
println!("Created schema '{}'", name);
Ok(())
}
SchemaAction::Delete { name } => {
schema_cmd::schema_delete(&ws.schemas_dir, &name)?;
println!("Deleted schema '{}'", name);
Ok(())
}
SchemaAction::Info { name } => {
let msg_dir = ws.root.join("../native_ros2_messages/msg");
if msg_dir.is_dir() {
if let Ok(info) = schema_cmd::msg_schema_info(&msg_dir, &name) {
print!("{}", info);
return Ok(());
}
}
let info = schema_cmd::schema_info(&ws.schemas_dir, &name)?;
for entry in &info.entries {
println!("Schema: {}", entry.name);
println!("Description: {}", entry.description);
println!("Fields: {}", entry.field_count);
for field in &entry.fields {
println!(" {}: {}", field.name, field.type_display);
}
println!("Hash: 0x{:016x}", entry.schema_hash);
}
Ok(())
}
}
}
}
}
fn discover_workspace() -> CliResult<CerulionWorkspace> {
let cwd = std::env::current_dir()?;
CerulionWorkspace::discover(&cwd)
}
fn clean_iceoryx2_state() -> CliResult<()> {
let report = cerulion_cli_engine::ipc_cleanup::cleanup_dead_iceoryx2_nodes_with_diagnostics();
if report.cleanups == 0 && report.failed_cleanups == 0 {
println!("No dead iceoryx2 nodes found — nothing to clean.");
return Ok(());
}
println!(
"Cleaned {} dead iceoryx2 node(s); {} cleanup(s) failed.",
report.cleanups, report.failed_cleanups
);
if report.failed_cleanups == 0 {
return Ok(());
}
if !report.failures_by_cause.is_empty() {
println!("Failure breakdown:");
for (cause, count) in &report.failures_by_cause {
let remediation = match cause.as_str() {
"permission denied" => {
"the dead node's resources belong to a different user; \
re-run as that user, or `chmod` the resources under `/tmp/iceoryx2/`"
}
"version mismatch" => {
"the dead node's on-disk state was produced by a different \
iceoryx2 version; live nodes' state is unaffected, but the \
dead-node remnants need a manual `rm -rf /tmp/iceoryx2/` to clear"
}
"lock contention" => {
"another process is concurrently cleaning the same dead node; \
usually self-resolves on retry"
}
"monitoring resource still in use" => {
"iceoryx2's monitoring layer holds a reference; should resolve \
after the holding process exits"
}
"iceoryx2 internal error" => {
"iceoryx2 reported an internal error; \
run with `IOX2_LOG_LEVEL=trace cerulion clean` for the underlying message"
}
_ => "see iceoryx2 logs for details",
};
println!(" {} × {} — {}", count, cause, remediation);
}
}
if !report.unclassified.is_empty() {
println!(
"Unclassified failures ({}): run with `RUST_LOG=iceoryx2=trace cerulion clean` for raw causes",
report.unclassified.len()
);
}
Ok(())
}
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..];
let end = if let Some(stripped) = rest.strip_prefix('"') {
1 + stripped.find('"')?
} else {
rest.find([',', '}'])?
};
Some(rest[..=end].trim_matches('"'))
};
let topic = extract("topic")?.to_string();
let seq: u64 = extract("seq")?.parse().ok()?;
let ts: u64 = extract("ts_ns")?.parse().ok()?;
let schema_raw = extract("schema_hash")?;
let schema = u64::from_str_radix(schema_raw.trim_start_matches("0x"), 16).ok()?;
Some((topic, seq, ts, schema))
}
fn format_policy_short(spec: Option<&cerulion_core::MacroPolicy>) -> String {
use cerulion_core::MacroPolicy;
match spec {
Some(MacroPolicy::Period { period_ms }) => format!("period {}ms", period_ms),
Some(MacroPolicy::Sync { window_ms }) => format!("sync {}ms", window_ms),
Some(MacroPolicy::UnboundedSync) => "sync ∞".to_string(),
Some(MacroPolicy::External) => "external".to_string(),
Some(MacroPolicy::DataTrigger { input_name }) => format!("trigger:{}", input_name),
None => "-".to_string(),
}
}
fn check_dash_t_dash_i_name_collision(
trigger_inputs: &[(String, String)],
regular_inputs: &[(String, String)],
) -> CliResult<()> {
use cerulion_cli_engine::error::CliError;
if let Some((_, t_name)) = trigger_inputs.first() {
if regular_inputs.iter().any(|(_, n)| n == t_name) {
return Err(CliError::Validation(format!(
"port name '{t_name}' appears in both `-T` (trigger) and \
`-i` (regular input). Each port name must be unique. \
Drop either the `-T` or the `-i` for this name."
)));
}
}
Ok(())
}
fn resolve_create_policy(
explicit: Option<&cerulion_core::MacroPolicy>,
trigger_input: Option<&(String, String)>,
regular_inputs: &[(String, String)],
) -> CliResult<Option<cerulion_core::MacroPolicy>> {
use cerulion_cli_engine::error::CliError;
use cerulion_core::MacroPolicy;
match (explicit, trigger_input) {
(Some(MacroPolicy::DataTrigger { input_name }), Some((_, t_name))) => {
if input_name != t_name {
return Err(CliError::Validation(format!(
"`--policy data_trigger={input_name}` and `-T <SCHEMA> {t_name}` disagree \
on the trigger input"
)));
}
Ok(Some(MacroPolicy::DataTrigger {
input_name: input_name.clone(),
}))
}
(Some(_non_data), Some(_)) => Err(CliError::Validation(
"`-T` declares a data-trigger input, which conflicts with a non-data `--policy`. \
Drop `-T` or change the policy."
.to_string(),
)),
(Some(MacroPolicy::DataTrigger { input_name }), None) => {
let matches_input = regular_inputs.iter().any(|(_, n)| n == input_name);
if !matches_input {
return Err(CliError::Validation(format!(
"`--policy data_trigger={input_name}` requires `-i SCHEMA {input_name}` \
(or `-T SCHEMA {input_name}`) to declare the trigger input"
)));
}
Ok(Some(MacroPolicy::DataTrigger {
input_name: input_name.clone(),
}))
}
(Some(p), None) => Ok(Some(p.clone())),
(None, Some((_, name))) => Ok(Some(MacroPolicy::DataTrigger {
input_name: name.clone(),
})),
(None, None) => {
if regular_inputs.is_empty() {
Err(CliError::Validation(
"source-only nodes (no `-i` or `-T`) must declare a non-data trigger policy. \
Pass `--policy period_ms=N` or `--policy external`."
.to_string(),
))
} else {
Ok(None)
}
}
}
}
fn parse_policy_spec(spec: &str) -> CliResult<cerulion_core::MacroPolicy> {
use cerulion_cli_engine::error::CliError;
use cerulion_core::MacroPolicy;
let trimmed = spec.trim();
if trimmed.eq_ignore_ascii_case("external") {
return Ok(MacroPolicy::External);
}
let (key, value) = match trimmed.split_once('=') {
Some((k, v)) => (k.trim(), Some(v.trim())),
None => (trimmed, None),
};
let parse_u64_positive = |label: &str, v: Option<&str>| -> CliResult<u64> {
let v = v.ok_or_else(|| {
CliError::Validation(format!(
"`--policy {label}` requires a value, e.g. `--policy {label}=100`"
))
})?;
let parsed = v.parse::<u64>().map_err(|_| {
CliError::Validation(format!(
"`--policy {label}={v}` — value must be a positive integer (milliseconds)"
))
})?;
if parsed == 0 {
return Err(CliError::Validation(format!(
"`--policy {label}=0` — value must be > 0 (zero-duration {label} would spin-loop the scheduler)"
)));
}
Ok(parsed)
};
let parse_name = |label: &str, v: Option<&str>| -> CliResult<String> {
let v = v.ok_or_else(|| {
CliError::Validation(format!(
"`--policy {label}` requires a value, e.g. `--policy {label}=count`"
))
})?;
if v.is_empty() {
return Err(CliError::Validation(format!(
"`--policy {label}=` — value (input name) must not be empty"
)));
}
Ok(v.to_string())
};
match key {
"period_ms" => Ok(MacroPolicy::Period {
period_ms: parse_u64_positive("period_ms", value)?,
}),
"sync_window_ms" => Ok(MacroPolicy::Sync {
window_ms: parse_u64_positive("sync_window_ms", value)?,
}),
"data_trigger" | "trigger" => Ok(MacroPolicy::DataTrigger {
input_name: parse_name(key, value)?,
}),
other => Err(CliError::Validation(format!(
"unknown policy spec `{other}`. Accepted: \
`period_ms=N`, `sync_window_ms=N`, `external`, \
`data_trigger=NAME` (or `trigger=NAME`)"
))),
}
}
fn 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) {
cerulion_core::init_logging(verbose);
}
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 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 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 parse_policy_spec_tests {
use super::*;
use cerulion_core::MacroPolicy;
#[test]
fn period_ms_bare_form_errors() {
let err = parse_policy_spec("period_ms").unwrap_err();
assert!(err.to_string().contains("requires a value"), "got: {err}");
}
#[test]
fn period_ms_with_value_parses_normally() {
let result = parse_policy_spec("period_ms=33").unwrap();
assert_eq!(result, MacroPolicy::Period { period_ms: 33 });
}
#[test]
fn deadline_ms_policy_removed() {
for spec in ["deadline_ms=100", "deadline_ms"] {
let err = parse_policy_spec(spec).unwrap_err();
assert!(
err.to_string()
.contains("unknown policy spec `deadline_ms`"),
"got: {err}"
);
}
}
#[test]
fn sync_window_ms_bare_form_errors() {
let err = parse_policy_spec("sync_window_ms").unwrap_err();
assert!(err.to_string().contains("requires a value"), "got: {err}");
}
#[test]
fn period_ms_zero_errors() {
let err = parse_policy_spec("period_ms=0").unwrap_err();
assert!(err.to_string().contains("must be > 0"), "got: {err}");
}
#[test]
fn sync_window_ms_zero_errors() {
let err = parse_policy_spec("sync_window_ms=0").unwrap_err();
assert!(err.to_string().contains("must be > 0"), "got: {err}");
}
#[test]
fn default_keyword_is_rejected() {
let err = parse_policy_spec("default").unwrap_err();
assert!(
err.to_string().contains("unknown policy spec"),
"got: {err}"
);
}
#[test]
fn external_keyword_round_trips() {
let result = parse_policy_spec("external").unwrap();
assert_eq!(result, MacroPolicy::External);
}
}
#[cfg(test)]
mod resolve_create_policy_tests {
use super::*;
use cerulion_core::MacroPolicy;
fn input(name: &str) -> (String, String) {
("test_msgs/Foo".to_string(), name.to_string())
}
#[test]
fn zero_inputs_no_policy_errors() {
let err = resolve_create_policy(None, None, &[]).unwrap_err();
assert!(err.to_string().contains("source-only nodes"), "got: {err}");
}
#[test]
fn one_input_no_policy_returns_none() {
let inputs = vec![input("data")];
let result = resolve_create_policy(None, None, &inputs).unwrap();
assert_eq!(result, None);
}
#[test]
fn two_inputs_no_policy_returns_none() {
let inputs = vec![input("a"), input("b")];
let result = resolve_create_policy(None, None, &inputs).unwrap();
assert_eq!(result, None);
}
#[test]
fn explicit_dash_t_still_writes_data_trigger() {
let trigger = input("scan");
let result = resolve_create_policy(None, Some(&trigger), &[]).unwrap();
assert_eq!(
result,
Some(MacroPolicy::DataTrigger {
input_name: "scan".to_string(),
})
);
}
#[test]
fn explicit_period_ms_unaffected_by_inputs() {
let explicit = MacroPolicy::Period { period_ms: 50 };
let inputs = vec![input("a")];
let result = resolve_create_policy(Some(&explicit), None, &inputs).unwrap();
assert_eq!(result, Some(MacroPolicy::Period { period_ms: 50 }));
}
}
#[cfg(test)]
mod dash_t_dash_i_collision_tests {
use super::*;
fn port(schema: &str, name: &str) -> (String, String) {
(schema.to_string(), name.to_string())
}
#[test]
fn no_trigger_no_collision() {
let result = check_dash_t_dash_i_name_collision(&[], &[port("sensor_msgs/Image", "image")]);
assert!(result.is_ok());
}
#[test]
fn trigger_alone_no_collision() {
let result = check_dash_t_dash_i_name_collision(&[port("sensor_msgs/Image", "image")], &[]);
assert!(result.is_ok());
}
#[test]
fn trigger_and_regular_with_different_names_ok() {
let result = check_dash_t_dash_i_name_collision(
&[port("sensor_msgs/LaserScan", "scan")],
&[port("sensor_msgs/Image", "image")],
);
assert!(result.is_ok());
}
#[test]
fn trigger_and_regular_with_same_name_rejected() {
let err = check_dash_t_dash_i_name_collision(
&[port("sensor_msgs/Image", "foo")],
&[port("sensor_msgs/Imu", "foo")],
)
.expect_err("must reject name collision");
let msg = err.to_string();
assert!(
msg.contains("'foo'"),
"error must name the colliding port; got: {msg}"
);
assert!(
msg.contains("-T") && msg.contains("-i"),
"error must mention both flags; got: {msg}"
);
}
#[test]
fn trigger_collides_with_one_of_many_regulars() {
let err = check_dash_t_dash_i_name_collision(
&[port("sensor_msgs/Image", "foo")],
&[
port("sensor_msgs/Image", "bar"),
port("sensor_msgs/Imu", "foo"),
port("std_msgs/String", "baz"),
],
)
.expect_err("must reject collision even when not first");
assert!(err.to_string().contains("'foo'"));
}
}
#[cfg(test)]
mod clap_parse_tests {
use super::cli::{Cli, Commands, GraphAction, NodeAction};
use clap::Parser;
fn try_parse(argv: &[&str]) -> Result<Cli, clap::Error> {
Cli::try_parse_from(argv)
}
#[test]
fn graph_validate_release_flag_parses() {
let cli = try_parse(&["cerulion", "graph", "validate", "perception", "--release"])
.expect("`graph validate --release` must parse");
let Commands::Graph {
action: GraphAction::Validate { name, release },
} = cli.command
else {
panic!("expected GraphAction::Validate");
};
assert_eq!(name, "perception");
assert!(release);
}
#[test]
fn graph_validate_defaults_to_debug_preference() {
let cli = try_parse(&["cerulion", "graph", "validate", "perception"])
.expect("`graph validate` without `--release` must parse");
let Commands::Graph {
action: GraphAction::Validate { release, .. },
} = cli.command
else {
panic!("expected GraphAction::Validate");
};
assert!(!release, "release preference must default to off");
}
#[test]
fn node_run_release_flag_parses() {
let cli = try_parse(&["cerulion", "node", "run", "camera", "--release"])
.expect("`node run --release` must parse");
let Commands::Node {
action: NodeAction::Run {
node_type, release, ..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert_eq!(node_type, "camera");
assert!(release, "`--release` must set the release preference");
}
#[test]
fn node_run_defaults_to_debug_preference() {
let cli = try_parse(&["cerulion", "node", "run", "camera"])
.expect("`node run` without `--release` must parse");
let Commands::Node {
action: NodeAction::Run { release, .. },
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert!(!release, "release preference must default to off");
}
#[test]
fn node_run_no_cpu_dma_lock_flag_parses() {
let cli = try_parse(&["cerulion", "node", "run", "camera", "--no-cpu-dma-lock"])
.expect("`node run --no-cpu-dma-lock` must parse");
let Commands::Node {
action:
NodeAction::Run {
node_type,
no_cpu_dma_lock,
..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert_eq!(node_type, "camera");
assert!(
no_cpu_dma_lock,
"`--no-cpu-dma-lock` must set no_cpu_dma_lock = true"
);
}
#[test]
fn node_run_no_cpu_dma_lock_defaults_false() {
let cli = try_parse(&["cerulion", "node", "run", "camera"])
.expect("`node run` without `--no-cpu-dma-lock` must parse");
let Commands::Node {
action: NodeAction::Run {
no_cpu_dma_lock, ..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert!(
!no_cpu_dma_lock,
"no_cpu_dma_lock must default to off (lock on for the live path)"
);
}
#[test]
fn node_run_no_monitor_wait_flag_parses() {
let cli = try_parse(&["cerulion", "node", "run", "camera", "--no-monitor-wait"])
.expect("`node run --no-monitor-wait` must parse");
let Commands::Node {
action:
NodeAction::Run {
node_type,
no_monitor_wait,
..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert_eq!(node_type, "camera");
assert!(
no_monitor_wait,
"`--no-monitor-wait` must set no_monitor_wait = true"
);
}
#[test]
fn node_run_no_monitor_wait_defaults_false() {
let cli = try_parse(&["cerulion", "node", "run", "camera"])
.expect("`node run` without `--no-monitor-wait` must parse");
let Commands::Node {
action: NodeAction::Run {
no_monitor_wait, ..
},
} = cli.command
else {
panic!("expected NodeAction::Run");
};
assert!(
!no_monitor_wait,
"no_monitor_wait must default to off (park on for the live path)"
);
}
#[test]
fn dash_t_with_schema_and_name_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"detector",
"-T",
"sensor_msgs/Image",
"image",
])
.expect("`-T SCHEMA NAME` must parse");
let Commands::Node { action } = cli.command else {
panic!("expected Node command");
};
let NodeAction::Create { trigger_input, .. } = action else {
panic!("expected Create action");
};
assert_eq!(trigger_input, vec!["sensor_msgs/Image", "image"]);
}
#[test]
fn dash_t_with_only_schema_is_rejected_by_clap() {
let result = try_parse(&[
"cerulion",
"node",
"create",
"detector",
"-T",
"sensor_msgs/Image",
]);
let err = match result {
Ok(_) => panic!("`-T SCHEMA` alone must be rejected by clap"),
Err(e) => e,
};
let msg = err.to_string();
assert!(
msg.contains("NAME") || msg.contains("required") || msg.contains("argument"),
"expected clap to flag the missing `NAME` arg; got: {msg}"
);
}
#[test]
fn dash_i_with_schema_and_name_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"camera",
"-i",
"sensor_msgs/LaserScan",
"scan",
])
.expect("`-i SCHEMA NAME` must parse");
let Commands::Node {
action: NodeAction::Create { input, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert_eq!(input, vec!["sensor_msgs/LaserScan", "scan"]);
}
#[test]
fn multiple_dash_t_flat_vec_caught_by_length_guard() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"fusion",
"-T",
"sensor_msgs/Image",
"image",
"-T",
"sensor_msgs/Imu",
"imu",
])
.expect(
"clap must accept the flat-Vec accumulation; length guard runs later in the handler",
);
let Commands::Node {
action: NodeAction::Create { trigger_input, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert_eq!(
trigger_input.len(),
4,
"expected 4 args from two `-T` invocations"
);
assert_eq!(
trigger_input,
vec!["sensor_msgs/Image", "image", "sensor_msgs/Imu", "imu"]
);
}
#[test]
fn dash_o_with_schema_and_name_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"camera",
"-o",
"sensor_msgs/Image",
"image",
])
.expect("`-o SCHEMA NAME` must parse");
let Commands::Node {
action: NodeAction::Create { output, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert_eq!(output, vec!["sensor_msgs/Image", "image"]);
}
#[test]
fn raw_ffi_flag_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"camera",
"--raw-ffi",
"-o",
"sensor_msgs/Image",
"image",
])
.expect("`--raw-ffi` must parse alongside `-o`");
let Commands::Node {
action: NodeAction::Create { raw_ffi, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert!(raw_ffi);
}
#[test]
fn policy_period_ms_parses() {
let cli = try_parse(&[
"cerulion",
"node",
"create",
"camera",
"--policy",
"period_ms=33",
"-o",
"sensor_msgs/Image",
"image",
])
.expect("`--policy period_ms=33` must parse");
let Commands::Node {
action: NodeAction::Create { policy, .. },
} = cli.command
else {
panic!("expected NodeAction::Create");
};
assert_eq!(policy.as_deref(), Some("period_ms=33"));
}
}