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}