Skip to content
6 changes: 6 additions & 0 deletions crates/ros-z-py/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ impl PyZContextBuilder {
slf
}

/// Set the default namespace inherited by nodes created from this context.
pub fn with_namespace(mut slf: PyRefMut<'_, Self>, namespace: String) -> PyRefMut<'_, Self> {
slf.builder = std::mem::take(&mut slf.builder).with_namespace(namespace);
slf
}

/// Enable Zenoh logging initialization with default level "error"
pub fn with_logging_enabled(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> {
slf.builder = std::mem::take(&mut slf.builder).with_logging_enabled();
Expand Down
98 changes: 98 additions & 0 deletions crates/ros-z-tests/tests/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,101 @@ async fn test_client_invalid_transition_returns_false() {
.expect("trigger");
assert!(!result);
}

// ---------------------------------------------------------------------------
// Context-level namespace inheritance
// ---------------------------------------------------------------------------

#[test]
fn test_lifecycle_node_inherits_context_namespace() {
let ctx = ZContextBuilder::default()
.with_namespace("/my_ns")
.disable_multicast_scouting()
.build()
.expect("context");

let node = ctx
.create_lifecycle_node("lc_ns_inherit")
.build()
.expect("lifecycle node");

assert_eq!(node.inner.namespace(), "/my_ns");
}

#[test]
fn test_lifecycle_node_namespace_override_takes_precedence() {
let ctx = ZContextBuilder::default()
.with_namespace("/ctx_ns")
.disable_multicast_scouting()
.build()
.expect("context");

let node = ctx
.create_lifecycle_node("lc_ns_override")
.with_namespace("/override_ns")
.build()
.expect("lifecycle node");

assert_eq!(node.inner.namespace(), "/override_ns");
}

#[test]
fn test_lifecycle_node_no_context_namespace_defaults_to_root() {
let ctx = ZContextBuilder::default()
.disable_multicast_scouting()
.build()
.expect("context");

let node = ctx
.create_lifecycle_node("lc_ns_root")
.build()
.expect("lifecycle node");

assert_eq!(node.inner.namespace(), "");
}

// ---------------------------------------------------------------------------
// Context-level namespace inheritance — regular nodes
// ---------------------------------------------------------------------------

#[test]
fn test_node_inherits_context_namespace() {
let ctx = ZContextBuilder::default()
.with_namespace("/my_ns")
.disable_multicast_scouting()
.build()
.expect("context");

let node = ctx.create_node("ns_inherit").build().expect("node");

assert_eq!(node.namespace(), "/my_ns");
}

#[test]
fn test_node_namespace_override_takes_precedence() {
let ctx = ZContextBuilder::default()
.with_namespace("/ctx_ns")
.disable_multicast_scouting()
.build()
.expect("context");

let node = ctx
.create_node("ns_override")
.with_namespace("/override_ns")
.build()
.expect("node");

assert_eq!(node.namespace(), "/override_ns");
}

#[test]
fn test_node_no_context_namespace_defaults_to_root() {
let ctx = ZContextBuilder::default()
.disable_multicast_scouting()
.build()
.expect("context");

let node = ctx.create_node("ns_root").build().expect("node");

assert_eq!(node.namespace(), "");
}
19 changes: 17 additions & 2 deletions crates/ros-z/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use zenoh::{Result, Session, Wait};

use crate::{
Builder,
entity::normalize_node_namespace,
graph::Graph,
node::ZNodeBuilder,
time::{ClockKind, ZClock},
Expand Down Expand Up @@ -71,6 +72,7 @@ impl RemapRules {
#[derive(Default)]
pub struct ZContextBuilder {
domain_id: usize,
namespace: String,
enclave: String,
zenoh_config: Option<zenoh::Config>,
config_file: Option<PathBuf>,
Expand All @@ -89,6 +91,12 @@ impl ZContextBuilder {
self
}

/// Set the default namespace inherited by nodes created from this context.
pub fn with_namespace(mut self, namespace: impl AsRef<str>) -> Self {
self.namespace = normalize_node_namespace(namespace.as_ref());
self
}

/// Set the enclave name
pub fn with_enclave<S: Into<String>>(mut self, enclave: S) -> Self {
self.enclave = enclave.into();
Expand Down Expand Up @@ -539,6 +547,7 @@ impl Builder for ZContextBuilder {
session: Arc::new(session),
counter: Arc::new(GlobalCounter::default()),
domain_id,
namespace: builder.namespace,
enclave,
graph,
remap_rules: builder.remap_rules,
Expand Down Expand Up @@ -568,6 +577,7 @@ pub struct ZContext {
// Global counter for the participants
counter: Arc<GlobalCounter>,
domain_id: usize,
namespace: String,
enclave: String,
graph: Arc<Graph>,
remap_rules: RemapRules,
Expand All @@ -580,6 +590,7 @@ impl std::fmt::Debug for ZContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ZContext")
.field("domain_id", &self.domain_id)
.field("namespace", &self.namespace)
.field("enclave", &self.enclave)
.finish_non_exhaustive()
}
Expand All @@ -598,7 +609,11 @@ impl ZContext {
crate::lifecycle::node::ZLifecycleNodeBuilder {
ctx: self.clone(),
name: name.as_ref().to_owned(),
namespace: None,
namespace: if self.namespace.is_empty() {
None
} else {
Some(self.namespace.clone())
},
enable_communication_interface: true,
}
}
Expand All @@ -609,7 +624,7 @@ impl ZContext {
ZNodeBuilder {
domain_id: self.domain_id,
name: name.as_ref().to_owned(),
namespace: "".to_string(),
namespace: self.namespace.clone(),
enclave: self.enclave.clone(),
session: self.session.clone(),
counter: self.counter.clone(),
Expand Down
24 changes: 16 additions & 8 deletions crates/ros-z/src/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,24 @@ pub type Topic = String;

// Extension functions for NodeEntity (can't use impl due to orphan rules)

/// Get the key for this node (namespace, name)
pub fn node_key(entity: &NodeEntity) -> NodeKey {
// Normalize namespace: "/" (root namespace) should be treated as "" (empty)
// This ensures consistent HashMap lookups across local and remote entities
let normalized_namespace = if entity.namespace == "/" {
/// Normalize a node namespace for internal storage.
///
/// The root namespace (`"/"`) is stored as an empty string so local and remote
/// entities use the same key representation.
pub fn normalize_node_namespace(namespace: &str) -> String {
if namespace == "/" {
String::new()
} else {
entity.namespace.clone()
};
(normalized_namespace, entity.name.clone())
namespace.to_owned()
}
}

/// Get the key for this node (namespace, name)
pub fn node_key(entity: &NodeEntity) -> NodeKey {
(
normalize_node_namespace(&entity.namespace),
entity.name.clone(),
)
}

/// Get the liveliness token key expression for a node
Expand Down
8 changes: 8 additions & 0 deletions crates/ros-z/src/ffi/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ pub struct CContextConfig {
pub remap_rules_count: usize,
/// Whether to enable logging
pub enable_logging: bool,
/// Default namespace inherited by nodes created from this context (nullable).
/// Added after all pre-existing fields to preserve ABI compatibility.
pub namespace: *const c_char,
}

/// Create a new ros-z context with default config (convenience)
Expand Down Expand Up @@ -78,6 +81,11 @@ pub unsafe extern "C" fn ros_z_context_create_with_config(
let mut builder =
crate::context::ZContextBuilder::default().with_domain_id(cfg.domain_id as usize);

if let Some(Ok(namespace)) = (!cfg.namespace.is_null()).then(|| cstr_to_str(cfg.namespace))
{
builder = builder.with_namespace(namespace);
}

// Config file
if let Some(Ok(path)) = (!cfg.config_file.is_null()).then(|| cstr_to_str(cfg.config_file)) {
builder = builder.with_config_file(path);
Expand Down
32 changes: 13 additions & 19 deletions crates/ros-z/src/ffi/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,16 @@ pub unsafe extern "C" fn ros_z_node_create(
Err(_) => return std::ptr::null_mut(),
};

let namespace_str = if namespace.is_null() {
""
} else {
match cstr_to_str(namespace) {
let mut builder = ctx_ref.create_node(name_str);
if !namespace.is_null() {
let namespace_str = match cstr_to_str(namespace) {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
}
};
};
builder = builder.with_namespace(namespace_str);
}

match ctx_ref
.create_node(name_str)
.with_namespace(namespace_str)
.build()
{
match builder.build() {
Ok(node) => Box::into_raw(Box::new(CNode {
inner: Box::new(node),
})),
Expand Down Expand Up @@ -92,16 +88,14 @@ pub unsafe extern "C" fn ros_z_node_create_with_config(
Err(_) => return std::ptr::null_mut(),
};

let namespace_str = if cfg.namespace.is_null() {
""
} else {
match cstr_to_str(cfg.namespace) {
let mut builder = ctx_ref.create_node(name_str);
if !cfg.namespace.is_null() {
let namespace_str = match cstr_to_str(cfg.namespace) {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
}
};

let mut builder = ctx_ref.create_node(name_str).with_namespace(namespace_str);
};
builder = builder.with_namespace(namespace_str);
}

if cfg.enable_type_description_service {
builder = builder.with_type_description_service();
Expand Down
4 changes: 2 additions & 2 deletions crates/ros-z/src/lifecycle/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,8 @@ pub struct ZLifecycleNodeBuilder {
}

impl ZLifecycleNodeBuilder {
pub fn with_namespace<S: Into<String>>(mut self, ns: S) -> Self {
self.namespace = Some(ns.into());
pub fn with_namespace<S: AsRef<str>>(mut self, ns: S) -> Self {
self.namespace = Some(crate::entity::normalize_node_namespace(ns.as_ref()));
self
}

Expand Down
9 changes: 1 addition & 8 deletions crates/ros-z/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,7 @@ pub struct ZNodeBuilder {

impl ZNodeBuilder {
pub fn with_namespace<S: AsRef<str>>(mut self, namespace: S) -> Self {
// Normalize namespace: "/" (root namespace) should be treated as "" (empty)
// This ensures consistent HashMap lookups across local and remote entities
let ns = namespace.as_ref();
self.namespace = if ns == "/" {
String::new()
} else {
ns.to_owned()
};
self.namespace = normalize_node_namespace(namespace.as_ref());
self
}

Expand Down
Loading