Skip to main content

ros2args/
names.rs

1//! ROS2 naming validation for nodes and topics
2//!
3//! This module provides validation functions for ROS2 names according to the
4//! [ROS2 Topic and Service Names](https://design.ros2.org/articles/topic_and_service_names.html)
5//! specification.
6//!
7//! # ROS2 Naming Rules
8//!
9//! ## Topic and Service Names
10//!
11//! - Must not be empty
12//! - May contain alphanumeric characters (`[0-9|a-z|A-Z]`), underscores (`_`), or forward slashes (`/`)
13//! - May use balanced curly braces (`{}`) for substitutions
14//! - May start with a tilde (`~`), the private namespace substitution character
15//! - Must not start with a numeric character (`[0-9]`)
16//! - Must not end with a forward slash (`/`)
17//! - Must not contain repeated forward slashes (`//`)
18//! - Must not contain repeated underscores (`__`)
19//! - Must separate a tilde (`~`) from the rest of the name with a forward slash (`/`)
20//!
21//! ## Node Names (Base Names)
22//!
23//! - Must not be empty
24//! - May contain alphanumeric characters (`[0-9|a-z|A-Z]`) and underscores (`_`)
25//! - Must not start with a numeric character (`[0-9]`)
26//! - Must not contain forward slashes (`/`) or tildes (`~`)
27//! - Must not contain repeated underscores (`__`)
28//!
29//! ## Namespace Names
30//!
31//! - Must start with a forward slash (`/`) if absolute
32//! - Follow similar rules to topic names but cannot contain tilde or substitutions
33//!
34//! # Examples
35//!
36//! ```
37//! use ros2args::names::{validate_topic_name, validate_node_name, validate_namespace};
38//!
39//! // Valid topic names
40//! assert!(validate_topic_name("foo").is_ok());
41//! assert!(validate_topic_name("/foo/bar").is_ok());
42//! assert!(validate_topic_name("~/foo").is_ok());
43//! assert!(validate_topic_name("foo_bar").is_ok());
44//!
45//! // Invalid topic names
46//! assert!(validate_topic_name("").is_err());        // empty
47//! assert!(validate_topic_name("123abc").is_err());  // starts with number
48//! assert!(validate_topic_name("foo//bar").is_err()); // double slash
49//! assert!(validate_topic_name("foo__bar").is_err()); // double underscore
50//!
51//! // Valid node names
52//! assert!(validate_node_name("my_node").is_ok());
53//! assert!(validate_node_name("node123").is_ok());
54//!
55//! // Invalid node names
56//! assert!(validate_node_name("my/node").is_err()); // contains slash
57//! assert!(validate_node_name("~node").is_err());   // contains tilde
58//! ```
59
60use crate::errors::{Ros2ArgsError, Ros2ArgsResult};
61
62/// Represents what kind of name is being validated
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum NameKind {
65    /// A topic or service name
66    Topic,
67    /// A node base name (no namespace)
68    Node,
69    /// A namespace
70    Namespace,
71    /// A substitution content (inside curly braces)
72    Substitution,
73}
74
75impl std::fmt::Display for NameKind {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::Topic => write!(f, "topic"),
79            Self::Node => write!(f, "node"),
80            Self::Namespace => write!(f, "namespace"),
81            Self::Substitution => write!(f, "substitution"),
82        }
83    }
84}
85
86/// Check if a character is valid for ROS2 names
87///
88/// Valid characters are alphanumeric characters and underscores.
89#[inline]
90#[must_use]
91pub fn is_valid_name_char(c: char) -> bool {
92    c.is_ascii_alphanumeric() || c == '_'
93}
94
95/// Check if a character is valid for topic/service names (includes `/`)
96#[inline]
97#[must_use]
98pub fn is_valid_topic_char(c: char) -> bool {
99    is_valid_name_char(c) || c == '/'
100}
101
102/// Validate a ROS2 topic or service name
103///
104/// # Rules
105///
106/// - Must not be empty
107/// - May contain alphanumeric characters, underscores, or forward slashes
108/// - May use balanced curly braces for substitutions
109/// - May start with a tilde (~) for private namespace substitution
110/// - Must not start with a numeric character
111/// - Must not end with a forward slash
112/// - Must not contain repeated forward slashes
113/// - Must not contain repeated underscores
114/// - Tilde must be followed by a forward slash if not alone
115///
116/// # Errors
117///
118/// Returns `Ros2ArgsError::InvalidName` if the name violates any of the above rules.
119///
120/// # Examples
121///
122/// ```
123/// use ros2args::names::validate_topic_name;
124///
125/// assert!(validate_topic_name("foo").is_ok());
126/// assert!(validate_topic_name("/foo/bar").is_ok());
127/// assert!(validate_topic_name("~/private").is_ok());
128/// assert!(validate_topic_name("{node}/topic").is_ok());
129///
130/// assert!(validate_topic_name("").is_err());
131/// assert!(validate_topic_name("123").is_err());
132/// assert!(validate_topic_name("foo//bar").is_err());
133/// ```
134pub fn validate_topic_name(name: &str) -> Ros2ArgsResult<()> {
135    validate_name_impl(name, NameKind::Topic)
136}
137
138/// Validate a ROS2 node base name
139///
140/// Node names have stricter rules than topic names:
141/// - Must not be empty
142/// - May contain alphanumeric characters and underscores only
143/// - Must not start with a numeric character
144/// - Must not contain forward slashes, tildes, or curly braces
145/// - Must not contain repeated underscores
146///
147/// # Errors
148///
149/// Returns `Ros2ArgsError::InvalidName` if the name violates any of the above rules.
150///
151/// # Examples
152///
153/// ```
154/// use ros2args::names::validate_node_name;
155///
156/// assert!(validate_node_name("my_node").is_ok());
157/// assert!(validate_node_name("node123").is_ok());
158/// assert!(validate_node_name("MyNode").is_ok());
159///
160/// assert!(validate_node_name("").is_err());
161/// assert!(validate_node_name("my/node").is_err());
162/// assert!(validate_node_name("123node").is_err());
163/// ```
164pub fn validate_node_name(name: &str) -> Ros2ArgsResult<()> {
165    validate_name_impl(name, NameKind::Node)
166}
167
168/// Validate a ROS2 namespace
169///
170/// Namespace rules:
171/// - Must not be empty
172/// - Must start with a forward slash if absolute
173/// - May contain alphanumeric characters, underscores, and forward slashes
174/// - Must not start with a numeric character (after the leading slash)
175/// - Must not end with a forward slash (unless it's just "/")
176/// - Must not contain repeated forward slashes
177/// - Must not contain repeated underscores
178///
179/// # Errors
180///
181/// Returns `Ros2ArgsError::InvalidName` if the namespace violates any of the above rules.
182///
183/// # Examples
184///
185/// ```
186/// use ros2args::names::validate_namespace;
187///
188/// assert!(validate_namespace("/").is_ok());
189/// assert!(validate_namespace("/foo").is_ok());
190/// assert!(validate_namespace("/foo/bar").is_ok());
191///
192/// assert!(validate_namespace("").is_err());
193/// assert!(validate_namespace("/foo/").is_err());
194/// assert!(validate_namespace("/foo//bar").is_err());
195/// ```
196pub fn validate_namespace(namespace: &str) -> Ros2ArgsResult<()> {
197    validate_name_impl(namespace, NameKind::Namespace)
198}
199
200/// Validate a substitution name (content inside curly braces)
201///
202/// Substitution rules:
203/// - Must not be empty
204/// - May contain alphanumeric characters and underscores
205/// - Must not start with a numeric character
206///
207/// # Errors
208///
209/// Returns `Ros2ArgsError::InvalidName` if the substitution name violates any of the above rules.
210///
211/// # Examples
212///
213/// ```
214/// use ros2args::names::validate_substitution;
215///
216/// assert!(validate_substitution("node").is_ok());
217/// assert!(validate_substitution("namespace").is_ok());
218///
219/// assert!(validate_substitution("").is_err());
220/// assert!(validate_substitution("123").is_err());
221/// ```
222pub fn validate_substitution(name: &str) -> Ros2ArgsResult<()> {
223    validate_name_impl(name, NameKind::Substitution)
224}
225
226/// Internal implementation for name validation
227#[allow(clippy::too_many_lines)]
228fn validate_name_impl(name: &str, kind: NameKind) -> Ros2ArgsResult<()> {
229    // Rule: Must not be empty
230    if name.is_empty() {
231        return Err(Ros2ArgsError::InvalidName {
232            kind,
233            name: name.to_string(),
234            reason: "name must not be empty".to_string(),
235        });
236    }
237
238    let chars: Vec<char> = name.chars().collect();
239    let mut i = 0;
240
241    // Handle leading characters based on name kind
242    match kind {
243        NameKind::Topic => {
244            // Topics can start with '/', '~', '{', or an alpha/underscore
245            if chars[0] == '~' {
246                // Tilde must be alone or followed by '/'
247                if chars.len() > 1 && chars[1] != '/' {
248                    return Err(Ros2ArgsError::InvalidName {
249                        kind,
250                        name: name.to_string(),
251                        reason: "tilde (~) must be followed by a forward slash (/)".to_string(),
252                    });
253                }
254                i = 1;
255            } else if chars[0] == '/' {
256                i = 1;
257            } else if chars[0] == '{' {
258                // Will be validated in the main loop
259            } else if chars[0].is_ascii_digit() {
260                return Err(Ros2ArgsError::InvalidName {
261                    kind,
262                    name: name.to_string(),
263                    reason: "name must not start with a numeric character".to_string(),
264                });
265            } else if !chars[0].is_ascii_alphabetic() && chars[0] != '_' {
266                return Err(Ros2ArgsError::InvalidName {
267                    kind,
268                    name: name.to_string(),
269                    reason: format!("invalid character '{}' at position 0", chars[0]),
270                });
271            }
272        }
273        NameKind::Namespace => {
274            // Namespaces must start with '/'
275            if chars[0] != '/' {
276                return Err(Ros2ArgsError::InvalidName {
277                    kind,
278                    name: name.to_string(),
279                    reason: "namespace must start with a forward slash (/)".to_string(),
280                });
281            }
282            // Root namespace "/" is valid
283            if name == "/" {
284                return Ok(());
285            }
286            i = 1;
287            // Check that first character after '/' is not a digit
288            if i < chars.len() && chars[i].is_ascii_digit() {
289                return Err(Ros2ArgsError::InvalidName {
290                    kind,
291                    name: name.to_string(),
292                    reason: "namespace token must not start with a numeric character".to_string(),
293                });
294            }
295        }
296        NameKind::Node | NameKind::Substitution => {
297            // Node names and substitutions must start with alpha or underscore
298            if chars[0].is_ascii_digit() {
299                return Err(Ros2ArgsError::InvalidName {
300                    kind,
301                    name: name.to_string(),
302                    reason: "name must not start with a numeric character".to_string(),
303                });
304            }
305            if !is_valid_name_char(chars[0]) {
306                return Err(Ros2ArgsError::InvalidName {
307                    kind,
308                    name: name.to_string(),
309                    reason: format!("invalid character '{}' at position 0", chars[0]),
310                });
311            }
312        }
313    }
314
315    // Track curly brace balance for substitutions (only in topic names)
316    let mut brace_depth = 0;
317    let mut prev_char: Option<char> = if i > 0 { Some(chars[i - 1]) } else { None };
318
319    while i < chars.len() {
320        let c = chars[i];
321
322        match kind {
323            NameKind::Topic => {
324                if c == '{' {
325                    brace_depth += 1;
326                } else if c == '}' {
327                    if brace_depth == 0 {
328                        return Err(Ros2ArgsError::InvalidName {
329                            kind,
330                            name: name.to_string(),
331                            reason: "unbalanced curly braces: unexpected '}'".to_string(),
332                        });
333                    }
334                    brace_depth -= 1;
335                } else if brace_depth > 0 {
336                    // Inside substitution: only alphanumeric and underscore allowed
337                    if !is_valid_name_char(c) {
338                        return Err(Ros2ArgsError::InvalidName {
339                            kind,
340                            name: name.to_string(),
341                            reason: format!(
342                                "invalid character '{c}' inside substitution at position {i}"
343                            ),
344                        });
345                    }
346                } else if !is_valid_topic_char(c) {
347                    return Err(Ros2ArgsError::InvalidName {
348                        kind,
349                        name: name.to_string(),
350                        reason: format!("invalid character '{c}' at position {i}"),
351                    });
352                }
353
354                // Check for repeated slashes
355                if c == '/' && prev_char == Some('/') {
356                    return Err(Ros2ArgsError::InvalidName {
357                        kind,
358                        name: name.to_string(),
359                        reason: "name must not contain repeated forward slashes (//)".to_string(),
360                    });
361                }
362
363                // Check for repeated underscores
364                if c == '_' && prev_char == Some('_') {
365                    return Err(Ros2ArgsError::InvalidName {
366                        kind,
367                        name: name.to_string(),
368                        reason: "name must not contain repeated underscores (__)".to_string(),
369                    });
370                }
371
372                // Check that tilde only appears at the start
373                if c == '~' {
374                    return Err(Ros2ArgsError::InvalidName {
375                        kind,
376                        name: name.to_string(),
377                        reason: "tilde (~) may only appear at the beginning of a name".to_string(),
378                    });
379                }
380
381                // Check that tokens after '/' don't start with a digit
382                if prev_char == Some('/') && c.is_ascii_digit() {
383                    return Err(Ros2ArgsError::InvalidName {
384                        kind,
385                        name: name.to_string(),
386                        reason: format!(
387                            "token after '/' must not start with a numeric character at position {i}"
388                        ),
389                    });
390                }
391            }
392            NameKind::Namespace => {
393                if !is_valid_topic_char(c) {
394                    return Err(Ros2ArgsError::InvalidName {
395                        kind,
396                        name: name.to_string(),
397                        reason: format!("invalid character '{c}' at position {i}"),
398                    });
399                }
400
401                // Check for repeated slashes
402                if c == '/' && prev_char == Some('/') {
403                    return Err(Ros2ArgsError::InvalidName {
404                        kind,
405                        name: name.to_string(),
406                        reason: "namespace must not contain repeated forward slashes (//)"
407                            .to_string(),
408                    });
409                }
410
411                // Check for repeated underscores
412                if c == '_' && prev_char == Some('_') {
413                    return Err(Ros2ArgsError::InvalidName {
414                        kind,
415                        name: name.to_string(),
416                        reason: "namespace must not contain repeated underscores (__)".to_string(),
417                    });
418                }
419
420                // Check that tokens after '/' don't start with a digit
421                if prev_char == Some('/') && c.is_ascii_digit() {
422                    return Err(Ros2ArgsError::InvalidName {
423                        kind,
424                        name: name.to_string(),
425                        reason: format!(
426                            "namespace token after '/' must not start with a numeric character at position {i}"
427                        ),
428                    });
429                }
430            }
431            NameKind::Node => {
432                // Node names cannot contain '/', '~', or '{}'
433                if c == '/' {
434                    return Err(Ros2ArgsError::InvalidName {
435                        kind,
436                        name: name.to_string(),
437                        reason: "node name must not contain forward slash (/)".to_string(),
438                    });
439                }
440                if c == '~' {
441                    return Err(Ros2ArgsError::InvalidName {
442                        kind,
443                        name: name.to_string(),
444                        reason: "node name must not contain tilde (~)".to_string(),
445                    });
446                }
447                if c == '{' || c == '}' {
448                    return Err(Ros2ArgsError::InvalidName {
449                        kind,
450                        name: name.to_string(),
451                        reason: "node name must not contain curly braces".to_string(),
452                    });
453                }
454                if !is_valid_name_char(c) {
455                    return Err(Ros2ArgsError::InvalidName {
456                        kind,
457                        name: name.to_string(),
458                        reason: format!("invalid character '{c}' at position {i}"),
459                    });
460                }
461
462                // Check for repeated underscores
463                if c == '_' && prev_char == Some('_') {
464                    return Err(Ros2ArgsError::InvalidName {
465                        kind,
466                        name: name.to_string(),
467                        reason: "node name must not contain repeated underscores (__)".to_string(),
468                    });
469                }
470            }
471            NameKind::Substitution => {
472                if !is_valid_name_char(c) {
473                    return Err(Ros2ArgsError::InvalidName {
474                        kind,
475                        name: name.to_string(),
476                        reason: format!("invalid character '{c}' at position {i}"),
477                    });
478                }
479            }
480        }
481
482        prev_char = Some(c);
483        i += 1;
484    }
485
486    // Final checks
487    match kind {
488        NameKind::Topic => {
489            // Check for unbalanced braces
490            if brace_depth != 0 {
491                return Err(Ros2ArgsError::InvalidName {
492                    kind,
493                    name: name.to_string(),
494                    reason: "unbalanced curly braces: missing '}'".to_string(),
495                });
496            }
497
498            // Check for trailing slash
499            if name.ends_with('/') {
500                return Err(Ros2ArgsError::InvalidName {
501                    kind,
502                    name: name.to_string(),
503                    reason: "name must not end with a forward slash (/)".to_string(),
504                });
505            }
506        }
507        NameKind::Namespace => {
508            // Check for trailing slash (except for root namespace)
509            if name.len() > 1 && name.ends_with('/') {
510                return Err(Ros2ArgsError::InvalidName {
511                    kind,
512                    name: name.to_string(),
513                    reason: "namespace must not end with a forward slash (/)".to_string(),
514                });
515            }
516        }
517        NameKind::Node | NameKind::Substitution => {}
518    }
519
520    Ok(())
521}
522
523/// Validate a fully qualified name (absolute topic or service name)
524///
525/// Fully qualified names have additional restrictions:
526/// - Must start with a forward slash (/)
527/// - Must not contain tilde (~) or curly braces ({})
528///
529/// # Errors
530///
531/// Returns `Ros2ArgsError::InvalidName` if the name is not a valid fully qualified name.
532///
533/// # Examples
534///
535/// ```
536/// use ros2args::names::validate_fully_qualified_name;
537///
538/// assert!(validate_fully_qualified_name("/foo").is_ok());
539/// assert!(validate_fully_qualified_name("/foo/bar").is_ok());
540///
541/// assert!(validate_fully_qualified_name("foo").is_err());      // not absolute
542/// assert!(validate_fully_qualified_name("/~foo").is_err());    // contains tilde
543/// assert!(validate_fully_qualified_name("/{sub}").is_err());   // contains substitution
544/// ```
545pub fn validate_fully_qualified_name(name: &str) -> Ros2ArgsResult<()> {
546    if name.is_empty() {
547        return Err(Ros2ArgsError::InvalidName {
548            kind: NameKind::Topic,
549            name: name.to_string(),
550            reason: "fully qualified name must not be empty".to_string(),
551        });
552    }
553
554    if !name.starts_with('/') {
555        return Err(Ros2ArgsError::InvalidName {
556            kind: NameKind::Topic,
557            name: name.to_string(),
558            reason: "fully qualified name must start with a forward slash (/)".to_string(),
559        });
560    }
561
562    if name.contains('~') {
563        return Err(Ros2ArgsError::InvalidName {
564            kind: NameKind::Topic,
565            name: name.to_string(),
566            reason: "fully qualified name must not contain tilde (~)".to_string(),
567        });
568    }
569
570    if name.contains('{') || name.contains('}') {
571        return Err(Ros2ArgsError::InvalidName {
572            kind: NameKind::Topic,
573            name: name.to_string(),
574            reason: "fully qualified name must not contain curly braces ({})".to_string(),
575        });
576    }
577
578    // Validate as a topic name
579    validate_topic_name(name)
580}
581
582/// Check if a name is a relative name (does not start with '/' or '~')
583#[inline]
584#[must_use]
585pub fn is_relative_name(name: &str) -> bool {
586    !name.is_empty() && !name.starts_with('/') && !name.starts_with('~')
587}
588
589/// Check if a name is an absolute name (starts with '/')
590#[inline]
591#[must_use]
592pub fn is_absolute_name(name: &str) -> bool {
593    name.starts_with('/')
594}
595
596/// Check if a name is a private name (starts with '~')
597#[inline]
598#[must_use]
599pub fn is_private_name(name: &str) -> bool {
600    name.starts_with('~')
601}
602
603/// Check if a name is hidden (contains a token starting with '_')
604///
605/// Any topic or service name that contains tokens starting with an underscore
606/// is considered hidden.
607#[must_use]
608pub fn is_hidden_name(name: &str) -> bool {
609    // Check if any token starts with underscore
610    name.starts_with('_')
611        || name.contains("/_")
612        || name
613            .split('/')
614            .any(|token| !token.is_empty() && token.starts_with('_'))
615}
616
617/// Expand a topic name to its fully qualified form
618///
619/// This function takes a node's namespace, node name, and a topic name,
620/// and returns the fully qualified topic name after expanding special
621/// characters and handling relative names.
622///
623/// # Expansion Rules
624///
625/// - **Absolute names** (starting with `/`): Returned as-is, ignoring the node's namespace
626/// - **Private names** (starting with `~`): The `~` is replaced with the node's FQN
627///   (namespace + node name), e.g., `~/foo` becomes `/my_ns/my_node/foo`
628/// - **Relative names**: Prefixed with the node's namespace,
629///   e.g., `foo` becomes `/my_ns/foo`
630///
631/// # Arguments
632///
633/// * `node_namespace` - The node's namespace (must start with `/`)
634/// * `node_name` - The node's base name (without namespace)
635/// * `topic_name` - The topic name to expand
636///
637/// # Errors
638///
639/// Returns an error if:
640/// - The node namespace is invalid
641/// - The node name is invalid
642/// - The resulting topic name is invalid
643///
644/// # Examples
645///
646/// ```
647/// use ros2args::names::expand_topic_name;
648///
649/// // Absolute topic - returned as-is
650/// let fqn = expand_topic_name("/my_ns", "my_node", "/absolute/topic").unwrap();
651/// assert_eq!(fqn, "/absolute/topic");
652///
653/// // Private topic - ~ replaced with node FQN
654/// let fqn = expand_topic_name("/my_ns", "my_node", "~/private").unwrap();
655/// assert_eq!(fqn, "/my_ns/my_node/private");
656///
657/// // Just tilde - expands to node FQN
658/// let fqn = expand_topic_name("/my_ns", "my_node", "~").unwrap();
659/// assert_eq!(fqn, "/my_ns/my_node");
660///
661/// // Relative topic - prefixed with namespace
662/// let fqn = expand_topic_name("/my_ns", "my_node", "relative/topic").unwrap();
663/// assert_eq!(fqn, "/my_ns/relative/topic");
664///
665/// // Root namespace
666/// let fqn = expand_topic_name("/", "my_node", "~/private").unwrap();
667/// assert_eq!(fqn, "/my_node/private");
668///
669/// // Root namespace with relative topic
670/// let fqn = expand_topic_name("/", "my_node", "relative").unwrap();
671/// assert_eq!(fqn, "/relative");
672/// ```
673pub fn expand_topic_name(
674    node_namespace: &str,
675    node_name: &str,
676    topic_name: &str,
677) -> Ros2ArgsResult<String> {
678    // Validate inputs
679    validate_namespace(node_namespace)?;
680    validate_node_name(node_name)?;
681    validate_topic_name(topic_name)?;
682
683    let expanded = if is_absolute_name(topic_name) {
684        // Absolute names are returned as-is
685        topic_name.to_string()
686    } else if is_private_name(topic_name) {
687        // Private names: replace ~ with node's FQN
688        let node_fqn = build_node_fqn(node_namespace, node_name);
689        if topic_name == "~" {
690            node_fqn
691        } else {
692            // topic_name is "~/something", strip the "~" and append
693            format!("{}{}", node_fqn, &topic_name[1..])
694        }
695    } else {
696        // Relative names: prefix with namespace
697        if node_namespace == "/" {
698            format!("/{topic_name}")
699        } else {
700            format!("{node_namespace}/{topic_name}")
701        }
702    };
703
704    // Validate the resulting FQN
705    validate_fully_qualified_name(&expanded)?;
706
707    Ok(expanded)
708}
709
710/// Build the fully qualified node name from namespace and node name
711///
712/// # Examples
713///
714/// ```
715/// use ros2args::names::build_node_fqn;
716///
717/// assert_eq!(build_node_fqn("/my_ns", "my_node"), "/my_ns/my_node");
718/// assert_eq!(build_node_fqn("/", "my_node"), "/my_node");
719/// assert_eq!(build_node_fqn("/foo/bar", "node"), "/foo/bar/node");
720/// ```
721#[must_use]
722pub fn build_node_fqn(namespace: &str, node_name: &str) -> String {
723    if namespace == "/" {
724        format!("/{node_name}")
725    } else {
726        format!("{namespace}/{node_name}")
727    }
728}
729
730/// Expand a topic name using a pre-built node FQN
731///
732/// This is a convenience function when you already have the node's fully
733/// qualified name. It performs the same expansion as [`expand_topic_name`]
734/// but takes the node FQN directly.
735///
736/// # Arguments
737///
738/// * `node_fqn` - The node's fully qualified name (e.g., `/my_ns/my_node`)
739/// * `topic_name` - The topic name to expand
740///
741/// # Errors
742///
743/// Returns an error if:
744/// - The node FQN is invalid
745/// - The topic name is invalid
746/// - The resulting topic name is invalid
747///
748/// # Examples
749///
750/// ```
751/// use ros2args::names::expand_topic_name_with_fqn;
752///
753/// let fqn = expand_topic_name_with_fqn("/my_ns/my_node", "~/private").unwrap();
754/// assert_eq!(fqn, "/my_ns/my_node/private");
755///
756/// let fqn = expand_topic_name_with_fqn("/my_ns/my_node", "/absolute").unwrap();
757/// assert_eq!(fqn, "/absolute");
758///
759/// let fqn = expand_topic_name_with_fqn("/my_ns/my_node", "relative").unwrap();
760/// assert_eq!(fqn, "/my_ns/relative");
761/// ```
762pub fn expand_topic_name_with_fqn(node_fqn: &str, topic_name: &str) -> Ros2ArgsResult<String> {
763    // Validate node FQN
764    validate_fully_qualified_name(node_fqn)?;
765    validate_topic_name(topic_name)?;
766
767    // Extract namespace from node FQN (everything except the last token)
768    let node_namespace = extract_namespace(node_fqn);
769
770    let expanded = if is_absolute_name(topic_name) {
771        // Absolute names are returned as-is
772        topic_name.to_string()
773    } else if is_private_name(topic_name) {
774        // Private names: replace ~ with node's FQN
775        if topic_name == "~" {
776            node_fqn.to_string()
777        } else {
778            // topic_name is "~/something", strip the "~" and append
779            format!("{node_fqn}{}", &topic_name[1..])
780        }
781    } else {
782        // Relative names: prefix with namespace
783        if node_namespace == "/" {
784            format!("/{topic_name}")
785        } else {
786            format!("{node_namespace}/{topic_name}")
787        }
788    };
789
790    // Validate the resulting FQN
791    validate_fully_qualified_name(&expanded)?;
792
793    Ok(expanded)
794}
795
796/// Extract the namespace from a fully qualified node name
797///
798/// Returns the namespace portion of a node FQN (everything before the last token).
799///
800/// # Examples
801///
802/// ```
803/// use ros2args::names::extract_namespace;
804///
805/// assert_eq!(extract_namespace("/my_ns/my_node"), "/my_ns");
806/// assert_eq!(extract_namespace("/my_node"), "/");
807/// assert_eq!(extract_namespace("/foo/bar/baz"), "/foo/bar");
808/// ```
809#[must_use]
810pub fn extract_namespace(node_fqn: &str) -> &str {
811    if let Some(last_slash_pos) = node_fqn.rfind('/') {
812        if last_slash_pos == 0 {
813            "/"
814        } else {
815            &node_fqn[..last_slash_pos]
816        }
817    } else {
818        "/"
819    }
820}
821
822/// Extract the base name from a fully qualified node name
823///
824/// Returns the base name portion of a node FQN (the last token after the final `/`).
825///
826/// # Examples
827///
828/// ```
829/// use ros2args::names::extract_base_name;
830///
831/// assert_eq!(extract_base_name("/my_ns/my_node"), "my_node");
832/// assert_eq!(extract_base_name("/my_node"), "my_node");
833/// assert_eq!(extract_base_name("/foo/bar/baz"), "baz");
834/// ```
835#[must_use]
836pub fn extract_base_name(node_fqn: &str) -> &str {
837    if let Some(last_slash_pos) = node_fqn.rfind('/') {
838        &node_fqn[last_slash_pos + 1..]
839    } else {
840        node_fqn
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    // ==================== Topic Name Tests ====================
849
850    #[test]
851    fn test_valid_topic_names() {
852        let valid_names = [
853            "foo",
854            "bar",
855            "abc123",
856            "_foo",
857            "Foo",
858            "BAR",
859            "foo/bar",
860            "/foo",
861            "/foo/bar",
862            "~",
863            "~/foo",
864            "~/foo/bar",
865            "{foo}_bar",
866            "foo/{ping}/bar",
867            "foo/_bar",
868            "foo_/bar",
869            "foo_",
870        ];
871
872        for name in &valid_names {
873            assert!(
874                validate_topic_name(name).is_ok(),
875                "Expected '{name}' to be valid",
876            );
877        }
878    }
879
880    #[test]
881    fn test_invalid_topic_names_empty() {
882        assert!(validate_topic_name("").is_err());
883    }
884
885    #[test]
886    fn test_invalid_topic_names_start_with_number() {
887        assert!(validate_topic_name("123abc").is_err());
888        assert!(validate_topic_name("123").is_err());
889    }
890
891    #[test]
892    fn test_invalid_topic_names_double_slash() {
893        assert!(validate_topic_name("foo//bar").is_err());
894        assert!(validate_topic_name("//foo").is_err());
895    }
896
897    #[test]
898    fn test_invalid_topic_names_double_underscore() {
899        assert!(validate_topic_name("foo__bar").is_err());
900    }
901
902    #[test]
903    fn test_invalid_topic_names_tilde_not_at_start() {
904        assert!(validate_topic_name("/~").is_err());
905        assert!(validate_topic_name("foo~").is_err());
906        assert!(validate_topic_name("foo~/bar").is_err());
907        assert!(validate_topic_name("foo/~bar").is_err());
908        assert!(validate_topic_name("foo/~/bar").is_err());
909    }
910
911    #[test]
912    fn test_invalid_topic_names_tilde_not_followed_by_slash() {
913        assert!(validate_topic_name("~foo").is_err());
914    }
915
916    #[test]
917    fn test_invalid_topic_names_trailing_slash() {
918        assert!(validate_topic_name("foo/").is_err());
919        assert!(validate_topic_name("/foo/bar/").is_err());
920    }
921
922    #[test]
923    fn test_invalid_topic_names_space() {
924        assert!(validate_topic_name("foo bar").is_err());
925        assert!(validate_topic_name(" ").is_err());
926    }
927
928    #[test]
929    fn test_invalid_topic_names_unbalanced_braces() {
930        assert!(validate_topic_name("{foo").is_err());
931        assert!(validate_topic_name("foo}").is_err());
932        assert!(validate_topic_name("{foo/bar").is_err());
933    }
934
935    // ==================== Node Name Tests ====================
936
937    #[test]
938    fn test_valid_node_names() {
939        let valid_names = [
940            "my_node",
941            "node123",
942            "MyNode",
943            "NODE",
944            "_private_node",
945            "node_",
946            "a",
947            "A",
948        ];
949
950        for name in &valid_names {
951            assert!(
952                validate_node_name(name).is_ok(),
953                "Expected '{name}' to be valid node name",
954            );
955        }
956    }
957
958    #[test]
959    fn test_invalid_node_names_empty() {
960        assert!(validate_node_name("").is_err());
961    }
962
963    #[test]
964    fn test_invalid_node_names_start_with_number() {
965        assert!(validate_node_name("123node").is_err());
966        assert!(validate_node_name("1").is_err());
967    }
968
969    #[test]
970    fn test_invalid_node_names_contains_slash() {
971        assert!(validate_node_name("my/node").is_err());
972        assert!(validate_node_name("/node").is_err());
973        assert!(validate_node_name("node/").is_err());
974    }
975
976    #[test]
977    fn test_invalid_node_names_contains_tilde() {
978        assert!(validate_node_name("~node").is_err());
979        assert!(validate_node_name("node~").is_err());
980        assert!(validate_node_name("my~node").is_err());
981    }
982
983    #[test]
984    fn test_invalid_node_names_contains_braces() {
985        assert!(validate_node_name("{node}").is_err());
986        assert!(validate_node_name("node{").is_err());
987        assert!(validate_node_name("}node").is_err());
988    }
989
990    #[test]
991    fn test_invalid_node_names_double_underscore() {
992        assert!(validate_node_name("my__node").is_err());
993    }
994
995    #[test]
996    fn test_invalid_node_names_special_chars() {
997        assert!(validate_node_name("my-node").is_err());
998        assert!(validate_node_name("my.node").is_err());
999        assert!(validate_node_name("my node").is_err());
1000        assert!(validate_node_name("my@node").is_err());
1001    }
1002
1003    // ==================== Namespace Tests ====================
1004
1005    #[test]
1006    fn test_valid_namespaces() {
1007        let valid_namespaces = [
1008            "/",
1009            "/foo",
1010            "/foo/bar",
1011            "/foo/bar/baz",
1012            "/my_namespace",
1013            "/_private",
1014        ];
1015
1016        for ns in &valid_namespaces {
1017            assert!(
1018                validate_namespace(ns).is_ok(),
1019                "Expected '{ns}' to be valid namespace",
1020            );
1021        }
1022    }
1023
1024    #[test]
1025    fn test_invalid_namespace_empty() {
1026        assert!(validate_namespace("").is_err());
1027    }
1028
1029    #[test]
1030    fn test_invalid_namespace_not_starting_with_slash() {
1031        assert!(validate_namespace("foo").is_err());
1032        assert!(validate_namespace("foo/bar").is_err());
1033    }
1034
1035    #[test]
1036    fn test_invalid_namespace_trailing_slash() {
1037        assert!(validate_namespace("/foo/").is_err());
1038        assert!(validate_namespace("/foo/bar/").is_err());
1039    }
1040
1041    #[test]
1042    fn test_invalid_namespace_double_slash() {
1043        assert!(validate_namespace("//foo").is_err());
1044        assert!(validate_namespace("/foo//bar").is_err());
1045    }
1046
1047    #[test]
1048    fn test_invalid_namespace_double_underscore() {
1049        assert!(validate_namespace("/foo__bar").is_err());
1050    }
1051
1052    #[test]
1053    fn test_invalid_namespace_token_starts_with_number() {
1054        assert!(validate_namespace("/123").is_err());
1055        assert!(validate_namespace("/foo/123bar").is_err());
1056    }
1057
1058    // ==================== Fully Qualified Name Tests ====================
1059
1060    #[test]
1061    fn test_valid_fully_qualified_names() {
1062        let valid_names = [
1063            "/foo",
1064            "/bar/baz",
1065            "/_private/thing",
1066            "/public_namespace/_private/thing",
1067        ];
1068
1069        for name in &valid_names {
1070            assert!(
1071                validate_fully_qualified_name(name).is_ok(),
1072                "Expected '{name}' to be valid FQN",
1073            );
1074        }
1075    }
1076
1077    #[test]
1078    fn test_invalid_fqn_not_absolute() {
1079        assert!(validate_fully_qualified_name("foo").is_err());
1080        assert!(validate_fully_qualified_name("foo/bar").is_err());
1081    }
1082
1083    #[test]
1084    fn test_invalid_fqn_contains_tilde() {
1085        assert!(validate_fully_qualified_name("/~").is_err());
1086        assert!(validate_fully_qualified_name("/~/foo").is_err());
1087    }
1088
1089    #[test]
1090    fn test_invalid_fqn_contains_substitution() {
1091        assert!(validate_fully_qualified_name("/{sub}").is_err());
1092        assert!(validate_fully_qualified_name("/foo/{bar}").is_err());
1093    }
1094
1095    // ==================== Helper Function Tests ====================
1096
1097    #[test]
1098    fn test_is_relative_name() {
1099        assert!(is_relative_name("foo"));
1100        assert!(is_relative_name("foo/bar"));
1101        assert!(!is_relative_name("/foo"));
1102        assert!(!is_relative_name("~"));
1103        assert!(!is_relative_name("~/foo"));
1104        assert!(!is_relative_name(""));
1105    }
1106
1107    #[test]
1108    fn test_is_absolute_name() {
1109        assert!(is_absolute_name("/foo"));
1110        assert!(is_absolute_name("/"));
1111        assert!(!is_absolute_name("foo"));
1112        assert!(!is_absolute_name("~"));
1113    }
1114
1115    #[test]
1116    fn test_is_private_name() {
1117        assert!(is_private_name("~"));
1118        assert!(is_private_name("~/foo"));
1119        assert!(!is_private_name("/foo"));
1120        assert!(!is_private_name("foo"));
1121    }
1122
1123    #[test]
1124    fn test_is_hidden_name() {
1125        assert!(is_hidden_name("_foo"));
1126        assert!(is_hidden_name("/foo/_bar"));
1127        assert!(is_hidden_name("/_private/thing"));
1128        assert!(!is_hidden_name("foo"));
1129        assert!(!is_hidden_name("/foo/bar"));
1130        assert!(!is_hidden_name("foo_bar"));
1131    }
1132
1133    #[test]
1134    fn test_is_valid_name_char() {
1135        assert!(is_valid_name_char('a'));
1136        assert!(is_valid_name_char('Z'));
1137        assert!(is_valid_name_char('5'));
1138        assert!(is_valid_name_char('_'));
1139        assert!(!is_valid_name_char('/'));
1140        assert!(!is_valid_name_char('-'));
1141        assert!(!is_valid_name_char(' '));
1142    }
1143
1144    #[test]
1145    fn test_is_valid_topic_char() {
1146        assert!(is_valid_topic_char('a'));
1147        assert!(is_valid_topic_char('Z'));
1148        assert!(is_valid_topic_char('5'));
1149        assert!(is_valid_topic_char('_'));
1150        assert!(is_valid_topic_char('/'));
1151        assert!(!is_valid_topic_char('-'));
1152        assert!(!is_valid_topic_char(' '));
1153    }
1154
1155    // ==================== Substitution Tests ====================
1156
1157    #[test]
1158    fn test_valid_substitutions() {
1159        assert!(validate_substitution("node").is_ok());
1160        assert!(validate_substitution("namespace").is_ok());
1161        assert!(validate_substitution("foo_bar").is_ok());
1162        assert!(validate_substitution("_private").is_ok());
1163    }
1164
1165    #[test]
1166    fn test_invalid_substitution_empty() {
1167        assert!(validate_substitution("").is_err());
1168    }
1169
1170    #[test]
1171    fn test_invalid_substitution_starts_with_number() {
1172        assert!(validate_substitution("123").is_err());
1173        assert!(validate_substitution("1foo").is_err());
1174    }
1175
1176    #[test]
1177    fn test_invalid_substitution_special_chars() {
1178        assert!(validate_substitution("foo/bar").is_err());
1179        assert!(validate_substitution("foo-bar").is_err());
1180    }
1181
1182    // ==================== Topic Expansion Tests ====================
1183
1184    #[test]
1185    fn test_expand_absolute_topic() {
1186        // Absolute topics are returned as-is
1187        let fqn = expand_topic_name("/my_ns", "my_node", "/absolute/topic").unwrap();
1188        assert_eq!(fqn, "/absolute/topic");
1189
1190        let fqn = expand_topic_name("/", "node", "/foo").unwrap();
1191        assert_eq!(fqn, "/foo");
1192
1193        let fqn = expand_topic_name("/deep/ns", "node", "/other/topic").unwrap();
1194        assert_eq!(fqn, "/other/topic");
1195    }
1196
1197    #[test]
1198    fn test_expand_private_topic() {
1199        // Private topics: ~ replaced with node FQN
1200        let fqn = expand_topic_name("/my_ns", "my_node", "~/private").unwrap();
1201        assert_eq!(fqn, "/my_ns/my_node/private");
1202
1203        let fqn = expand_topic_name("/my_ns", "my_node", "~").unwrap();
1204        assert_eq!(fqn, "/my_ns/my_node");
1205
1206        let fqn = expand_topic_name("/", "my_node", "~/private").unwrap();
1207        assert_eq!(fqn, "/my_node/private");
1208
1209        let fqn = expand_topic_name("/", "my_node", "~").unwrap();
1210        assert_eq!(fqn, "/my_node");
1211
1212        let fqn = expand_topic_name("/foo/bar", "node", "~/baz").unwrap();
1213        assert_eq!(fqn, "/foo/bar/node/baz");
1214    }
1215
1216    #[test]
1217    fn test_expand_relative_topic() {
1218        // Relative topics: prefixed with namespace
1219        let fqn = expand_topic_name("/my_ns", "my_node", "relative").unwrap();
1220        assert_eq!(fqn, "/my_ns/relative");
1221
1222        let fqn = expand_topic_name("/my_ns", "my_node", "foo/bar").unwrap();
1223        assert_eq!(fqn, "/my_ns/foo/bar");
1224
1225        let fqn = expand_topic_name("/", "my_node", "relative").unwrap();
1226        assert_eq!(fqn, "/relative");
1227
1228        let fqn = expand_topic_name("/deep/namespace", "node", "topic").unwrap();
1229        assert_eq!(fqn, "/deep/namespace/topic");
1230    }
1231
1232    #[test]
1233    fn test_expand_topic_with_fqn() {
1234        let fqn = expand_topic_name_with_fqn("/my_ns/my_node", "~/private").unwrap();
1235        assert_eq!(fqn, "/my_ns/my_node/private");
1236
1237        let fqn = expand_topic_name_with_fqn("/my_ns/my_node", "/absolute").unwrap();
1238        assert_eq!(fqn, "/absolute");
1239
1240        let fqn = expand_topic_name_with_fqn("/my_ns/my_node", "relative").unwrap();
1241        assert_eq!(fqn, "/my_ns/relative");
1242
1243        let fqn = expand_topic_name_with_fqn("/my_node", "~/private").unwrap();
1244        assert_eq!(fqn, "/my_node/private");
1245
1246        let fqn = expand_topic_name_with_fqn("/my_node", "relative").unwrap();
1247        assert_eq!(fqn, "/relative");
1248    }
1249
1250    #[test]
1251    fn test_build_node_fqn() {
1252        assert_eq!(build_node_fqn("/my_ns", "my_node"), "/my_ns/my_node");
1253        assert_eq!(build_node_fqn("/", "my_node"), "/my_node");
1254        assert_eq!(build_node_fqn("/foo/bar", "node"), "/foo/bar/node");
1255    }
1256
1257    #[test]
1258    fn test_extract_namespace() {
1259        assert_eq!(extract_namespace("/my_ns/my_node"), "/my_ns");
1260        assert_eq!(extract_namespace("/my_node"), "/");
1261        assert_eq!(extract_namespace("/foo/bar/baz"), "/foo/bar");
1262        assert_eq!(extract_namespace("/a/b/c/d"), "/a/b/c");
1263    }
1264
1265    #[test]
1266    fn test_extract_base_name() {
1267        assert_eq!(extract_base_name("/my_ns/my_node"), "my_node");
1268        assert_eq!(extract_base_name("/my_node"), "my_node");
1269        assert_eq!(extract_base_name("/foo/bar/baz"), "baz");
1270    }
1271
1272    #[test]
1273    fn test_expand_topic_invalid_inputs() {
1274        // Invalid namespace
1275        assert!(expand_topic_name("invalid", "node", "topic").is_err());
1276
1277        // Invalid node name
1278        assert!(expand_topic_name("/ns", "invalid/node", "topic").is_err());
1279
1280        // Invalid topic
1281        assert!(expand_topic_name("/ns", "node", "invalid//topic").is_err());
1282    }
1283}