Skip to main content

rustdv_sim/
path.rs

1//! `RustdvPath` — a component's position in the tree, as segments.
2//!
3//! The path is **derived by the walk and never stored on a component** (D7).
4//! Making it a type rather than a `String` buys three things:
5//!
6//! 1. **It cannot be fabricated.** A `RustdvPath` is produced only by the
7//!    framework's walk ([`RustdvPath::root`] then [`RustdvPath::child`]), so a
8//!    user cannot hand-type `"env.loga"` where a path is expected. That is the
9//!    guarantee D7 and D62 exist to protect: the old
10//!    `Logger::new("env.loga")` kept compiling — and kept addressing the wrong
11//!    subtree — after a rename.
12//! 2. **Prefix matching is correct by construction.** Comparing segment lists
13//!    cannot confuse `ab` with a child of `a`, which the string form had to
14//!    special-case (D64).
15//! 3. **Depth is a count, not a scan** — and D13's config precedence is scaled
16//!    by the setter's depth.
17//!
18//! The rendered dotted form is cached alongside the segments, so `Display` and
19//! `as_str()` are free and every log line prints exactly as it always has.
20
21use std::fmt;
22
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
24pub struct RustdvPath {
25    segments: Vec<String>,
26    /// The dotted rendering, kept in step with `segments`. Built once per node
27    /// during the walk rather than formatted at every log call.
28    rendered: String,
29}
30
31impl RustdvPath {
32    /// The root of a tree — the test's registered name (D49).
33    pub fn root(name: &str) -> RustdvPath {
34        RustdvPath { segments: vec![name.to_string()], rendered: name.to_string() }
35    }
36
37    /// An empty path, for contexts with no position (a free `#[rustdv::test]`
38    /// function, which has no component tree).
39    pub fn empty() -> RustdvPath {
40        RustdvPath::default()
41    }
42
43    /// This path extended by one segment — how the walk descends (D9).
44    pub fn child(&self, name: &str) -> RustdvPath {
45        let mut segments = self.segments.clone();
46        segments.push(name.to_string());
47        let rendered =
48            if self.rendered.is_empty() { name.to_string() } else { format!("{}.{}", self.rendered, name) };
49        RustdvPath { segments, rendered }
50    }
51
52    /// The dotted form, e.g. `RandomTest.env.scoreboard`.
53    pub fn as_str(&self) -> &str {
54        &self.rendered
55    }
56
57    pub fn segments(&self) -> &[String] {
58        &self.segments
59    }
60
61    /// How deep this node sits. The root is depth 1; an empty path is 0.
62    pub fn depth(&self) -> usize {
63        self.segments.len()
64    }
65
66    pub fn is_empty(&self) -> bool {
67        self.segments.is_empty()
68    }
69
70    /// Is this path at or below `prefix`? Segment-wise, so `a.b` is under `a`
71    /// and `ab` is not — without the string special-case D64 needed.
72    pub fn is_under(&self, prefix: &RustdvPath) -> bool {
73        prefix.segments.len() <= self.segments.len()
74            && self.segments[..prefix.segments.len()] == prefix.segments[..]
75    }
76}
77
78impl fmt::Display for RustdvPath {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.write_str(&self.rendered)
81    }
82}
83
84/// Segment-wise "is `path` at or below `prefix`?" for the string form, used by
85/// the log module's per-subtree handlers where prefixes arrive as text.
86///
87/// This replaces `path == prefix || path.starts_with(&format!("{prefix}."))` —
88/// same answer, no allocation, and the dot rule falls out of the comparison
89/// instead of being bolted on.
90pub fn str_is_under(path: &str, prefix: &str) -> bool {
91    if prefix.is_empty() {
92        return true;
93    }
94    let mut have = path.split('.');
95    for want in prefix.split('.') {
96        match have.next() {
97            Some(seg) if seg == want => continue,
98            _ => return false,
99        }
100    }
101    true
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn child_extends_and_renders() {
110        let root = RustdvPath::root("RandomTest");
111        let env = root.child("env");
112        let sb = env.child("scoreboard");
113        assert_eq!(sb.as_str(), "RandomTest.env.scoreboard");
114        assert_eq!(sb.depth(), 3);
115        assert_eq!(sb.segments(), ["RandomTest", "env", "scoreboard"]);
116    }
117
118    #[test]
119    fn under_is_segment_wise() {
120        let a = RustdvPath::root("a");
121        let ab = a.child("b");
122        assert!(ab.is_under(&a));
123        assert!(a.is_under(&a));
124        assert!(!a.is_under(&ab));
125        // the bug the string form had to special-case
126        assert!(!RustdvPath::root("ab").is_under(&a));
127    }
128
129    #[test]
130    fn str_under_matches_on_segments() {
131        assert!(str_is_under("a.b", "a"));
132        assert!(str_is_under("a", "a"));
133        assert!(!str_is_under("ab", "a")); // not a child of `a`
134        assert!(!str_is_under("a", "a.b"));
135        assert!(str_is_under("anything", ""));
136    }
137
138    #[test]
139    fn empty_path_has_no_segments() {
140        let e = RustdvPath::empty();
141        assert!(e.is_empty());
142        assert_eq!(e.depth(), 0);
143        assert_eq!(e.as_str(), "");
144        assert_eq!(e.child("top").as_str(), "top");
145    }
146}