diff --git a/crates/ros-z-py/src/context.rs b/crates/ros-z-py/src/context.rs index cd51f163d..aed906ec0 100644 --- a/crates/ros-z-py/src/context.rs +++ b/crates/ros-z-py/src/context.rs @@ -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(); diff --git a/crates/ros-z-tests/tests/lifecycle.rs b/crates/ros-z-tests/tests/lifecycle.rs index b99c1cf04..9f723fed2 100644 --- a/crates/ros-z-tests/tests/lifecycle.rs +++ b/crates/ros-z-tests/tests/lifecycle.rs @@ -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(), ""); +} diff --git a/crates/ros-z/src/context.rs b/crates/ros-z/src/context.rs index b8866e411..f733cb63b 100644 --- a/crates/ros-z/src/context.rs +++ b/crates/ros-z/src/context.rs @@ -8,6 +8,7 @@ use zenoh::{Result, Session, Wait}; use crate::{ Builder, + entity::normalize_node_namespace, graph::Graph, node::ZNodeBuilder, time::{ClockKind, ZClock}, @@ -71,6 +72,7 @@ impl RemapRules { #[derive(Default)] pub struct ZContextBuilder { domain_id: usize, + namespace: String, enclave: String, zenoh_config: Option, config_file: Option, @@ -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) -> Self { + self.namespace = normalize_node_namespace(namespace.as_ref()); + self + } + /// Set the enclave name pub fn with_enclave>(mut self, enclave: S) -> Self { self.enclave = enclave.into(); @@ -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, @@ -568,6 +577,7 @@ pub struct ZContext { // Global counter for the participants counter: Arc, domain_id: usize, + namespace: String, enclave: String, graph: Arc, remap_rules: RemapRules, @@ -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() } @@ -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, } } @@ -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(), diff --git a/crates/ros-z/src/entity.rs b/crates/ros-z/src/entity.rs index 4326aba81..cba95bca9 100644 --- a/crates/ros-z/src/entity.rs +++ b/crates/ros-z/src/entity.rs @@ -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 diff --git a/crates/ros-z/src/ffi/context.rs b/crates/ros-z/src/ffi/context.rs index 76cdaded8..03ac0aeac 100644 --- a/crates/ros-z/src/ffi/context.rs +++ b/crates/ros-z/src/ffi/context.rs @@ -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) @@ -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); diff --git a/crates/ros-z/src/ffi/node.rs b/crates/ros-z/src/ffi/node.rs index 196d9c5ed..6d03db416 100644 --- a/crates/ros-z/src/ffi/node.rs +++ b/crates/ros-z/src/ffi/node.rs @@ -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), })), @@ -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(); diff --git a/crates/ros-z/src/lifecycle/node.rs b/crates/ros-z/src/lifecycle/node.rs index eb528e0a3..86ac3a809 100644 --- a/crates/ros-z/src/lifecycle/node.rs +++ b/crates/ros-z/src/lifecycle/node.rs @@ -210,8 +210,8 @@ pub struct ZLifecycleNodeBuilder { } impl ZLifecycleNodeBuilder { - pub fn with_namespace>(mut self, ns: S) -> Self { - self.namespace = Some(ns.into()); + pub fn with_namespace>(mut self, ns: S) -> Self { + self.namespace = Some(crate::entity::normalize_node_namespace(ns.as_ref())); self } diff --git a/crates/ros-z/src/node.rs b/crates/ros-z/src/node.rs index f0b9a9d46..979a58783 100644 --- a/crates/ros-z/src/node.rs +++ b/crates/ros-z/src/node.rs @@ -86,14 +86,7 @@ pub struct ZNodeBuilder { impl ZNodeBuilder { pub fn with_namespace>(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 }