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 {
35 segments: vec![name.to_string()],
36 rendered: name.to_string(),
37 }
38 }
39
40 /// An empty path, for contexts with no position (a free `#[rustdv::test]`
41 /// function, which has no component tree).
42 pub fn empty() -> RustdvPath {
43 RustdvPath::default()
44 }
45
46 /// This path extended by one segment — how the walk descends (D9).
47 pub fn child(&self, name: &str) -> RustdvPath {
48 let mut segments = self.segments.clone();
49 segments.push(name.to_string());
50 let rendered = if self.rendered.is_empty() {
51 name.to_string()
52 } else {
53 format!("{}.{}", self.rendered, name)
54 };
55 RustdvPath { segments, rendered }
56 }
57
58 /// The dotted form, e.g. `RandomTest.env.scoreboard`.
59 pub fn as_str(&self) -> &str {
60 &self.rendered
61 }
62
63 pub fn segments(&self) -> &[String] {
64 &self.segments
65 }
66
67 /// How deep this node sits. The root is depth 1; an empty path is 0.
68 pub fn depth(&self) -> usize {
69 self.segments.len()
70 }
71
72 pub fn is_empty(&self) -> bool {
73 self.segments.is_empty()
74 }
75
76 /// Is this path at or below `prefix`? Segment-wise, so `a.b` is under `a`
77 /// and `ab` is not — without the string special-case D64 needed.
78 pub fn is_under(&self, prefix: &RustdvPath) -> bool {
79 prefix.segments.len() <= self.segments.len()
80 && self.segments[..prefix.segments.len()] == prefix.segments[..]
81 }
82}
83
84impl fmt::Display for RustdvPath {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.write_str(&self.rendered)
87 }
88}
89
90/// Segment-wise "is `path` at or below `prefix`?" for the string form, used by
91/// the log module's per-subtree handlers where prefixes arrive as text.
92///
93/// This replaces `path == prefix || path.starts_with(&format!("{prefix}."))` —
94/// same answer, no allocation, and the dot rule falls out of the comparison
95/// instead of being bolted on.
96pub fn str_is_under(path: &str, prefix: &str) -> bool {
97 if prefix.is_empty() {
98 return true;
99 }
100 let mut have = path.split('.');
101 for want in prefix.split('.') {
102 match have.next() {
103 Some(seg) if seg == want => continue,
104 _ => return false,
105 }
106 }
107 true
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn child_extends_and_renders() {
116 let root = RustdvPath::root("RandomTest");
117 let env = root.child("env");
118 let sb = env.child("scoreboard");
119 assert_eq!(sb.as_str(), "RandomTest.env.scoreboard");
120 assert_eq!(sb.depth(), 3);
121 assert_eq!(sb.segments(), ["RandomTest", "env", "scoreboard"]);
122 }
123
124 #[test]
125 fn under_is_segment_wise() {
126 let a = RustdvPath::root("a");
127 let ab = a.child("b");
128 assert!(ab.is_under(&a));
129 assert!(a.is_under(&a));
130 assert!(!a.is_under(&ab));
131 // the bug the string form had to special-case
132 assert!(!RustdvPath::root("ab").is_under(&a));
133 }
134
135 #[test]
136 fn str_under_matches_on_segments() {
137 assert!(str_is_under("a.b", "a"));
138 assert!(str_is_under("a", "a"));
139 assert!(!str_is_under("ab", "a")); // not a child of `a`
140 assert!(!str_is_under("a", "a.b"));
141 assert!(str_is_under("anything", ""));
142 }
143
144 #[test]
145 fn empty_path_has_no_segments() {
146 let e = RustdvPath::empty();
147 assert!(e.is_empty());
148 assert_eq!(e.depth(), 0);
149 assert_eq!(e.as_str(), "");
150 assert_eq!(e.child("top").as_str(), "top");
151 }
152}