Skip to main content

tear_types/
address.rs

1//! Addresses — the *mutable* half of a session's two keys.
2//!
3//! A session carries two independent keys and they answer two
4//! different questions:
5//!
6//! | key | question | mutable? | derived from |
7//! |---|---|---|---|
8//! | [`crate::Guid`] | *which session is this, forever?* | no | its [`crate::Genesis`] |
9//! | [`Address`] | *what do I call it today?* | yes | operator intent |
10//!
11//! An `Address` is a NATS-style, dot-separated alias —
12//! `work.akeyless.helm-charts.build` — used for lookup, aggregation,
13//! and assignment. Because every durable record keys on the `Guid` and
14//! never on the `Address`, renaming or re-parenting a session cannot
15//! orphan its data: the alias moves, the identity does not.
16//!
17//! ## Parse, don't validate
18//!
19//! [`Segment`] is the one label type, and the ONLY way to obtain one
20//! is [`Segment::parse`] (or its [`FromStr`] / serde equivalents,
21//! which call it). There is no public field, no `Segment::new`, no
22//! `From<String>`. A `Segment` in hand is therefore a *proof* that the
23//! label already passed every rule below — no downstream re-checking,
24//! no "did someone forget to validate this" question.
25//!
26//! ## The charset rule
27//!
28//! A segment is **ASCII alphanumeric plus `-` and `_`**, 1 to
29//! [`SEGMENT_MAX_LEN`] bytes, and is **case-sensitive** (no
30//! normalisation — parsing never silently rewrites its input).
31//!
32//! Deliberately conservative. That set is exactly what survives a
33//! filesystem path, a NATS subject token, a DNS-ish label, a shell
34//! word, and a URL path element without quoting, so an address can be
35//! pasted into any of those without an escaping layer. Everything else
36//! is rejected rather than escaped, including:
37//!
38//! - the empty string (an address never has a hole in it)
39//! - `.` — the separator itself, which would silently re-shape the tree
40//! - any whitespace — invisible differences must never be two names
41//! - `*` and `>` — those are [`Pattern`] syntax, never labels
42//! - every non-ASCII character (no homoglyph confusables in a key)
43//!
44//! Widening the set later is additive and safe; narrowing it would
45//! invalidate stored addresses. Start narrow.
46//!
47//! ## Naming note
48//!
49//! [`Segment`] is reachable as `tear_types::address::Segment` and is
50//! deliberately NOT re-exported at the crate root: the root already
51//! binds `Segment` to [`crate::statusbar::Segment`], the status-bar
52//! widget. Two unrelated meanings, one word; the module path keeps
53//! them apart instead of renaming a shipped type.
54
55use core::fmt;
56use core::num::NonZeroUsize;
57use core::str::FromStr;
58
59use serde::{Deserialize, Serialize};
60use thiserror::Error;
61
62/// The character between segments.
63pub const SEPARATOR: char = '.';
64
65/// The wildcard token matching exactly one segment.
66pub const WILDCARD_ONE: &str = "*";
67
68/// The wildcard token matching one-or-more remaining segments. Legal
69/// only as the final token of a [`Pattern`].
70pub const WILDCARD_TAIL: &str = ">";
71
72/// Longest segment accepted, in bytes. Segments are ASCII so bytes ==
73/// characters.
74pub const SEGMENT_MAX_LEN: usize = 64;
75
76/// Why a candidate label is not a [`Segment`].
77///
78/// Every arm names one rule from the module's charset section, so a
79/// caller can report *which* rule the operator tripped rather than a
80/// generic "bad address".
81#[derive(Clone, Debug, PartialEq, Eq, Error)]
82pub enum SegmentError {
83    /// The label was the empty string.
84    #[error("segment is empty")]
85    Empty,
86    /// The label contained `.`, which would silently re-shape the tree.
87    #[error("segment `{0}` contains the `{SEPARATOR}` separator")]
88    ContainsSeparator(String),
89    /// The label contained whitespace — invisible differences must
90    /// never produce two distinct names.
91    #[error("segment `{0}` contains whitespace")]
92    ContainsWhitespace(String),
93    /// The label was exactly `*` or `>`. Those are [`Pattern`] syntax
94    /// and can never be a literal label.
95    #[error("`{0}` is a pattern wildcard, not a label")]
96    WildcardToken(String),
97    /// The label held a character outside the accepted charset.
98    #[error("segment `{segment}`: illegal character `{ch}` at byte {index}; allowed: a-z A-Z 0-9 `-` `_`")]
99    IllegalChar {
100        /// The rejected label.
101        segment: String,
102        /// The first offending character.
103        ch: char,
104        /// Its byte offset within the label.
105        index: usize,
106    },
107    /// The label was longer than [`SEGMENT_MAX_LEN`].
108    #[error("segment `{segment}` is {len} bytes, over the {} byte maximum", SEGMENT_MAX_LEN)]
109    TooLong {
110        /// The rejected label.
111        segment: String,
112        /// Its length in bytes.
113        len: usize,
114    },
115}
116
117/// One address label — a validated, non-empty, separator-free token.
118///
119/// Constructed only through [`Segment::parse`]; see the module docs
120/// for the charset rule and why it is narrow.
121#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
122#[serde(into = "String", try_from = "String")]
123pub struct Segment(String);
124
125impl Segment {
126    /// The single fallible constructor. Rules are checked in the order
127    /// documented on [`SegmentError`]'s arms, so the error names the
128    /// most specific failure (`ContainsSeparator` beats a generic
129    /// "illegal character").
130    pub fn parse(s: &str) -> Result<Self, SegmentError> {
131        if s.is_empty() {
132            return Err(SegmentError::Empty);
133        }
134        if s == WILDCARD_ONE || s == WILDCARD_TAIL {
135            return Err(SegmentError::WildcardToken(s.to_string()));
136        }
137        if s.contains(SEPARATOR) {
138            return Err(SegmentError::ContainsSeparator(s.to_string()));
139        }
140        if s.chars().any(char::is_whitespace) {
141            return Err(SegmentError::ContainsWhitespace(s.to_string()));
142        }
143        if s.len() > SEGMENT_MAX_LEN {
144            return Err(SegmentError::TooLong {
145                segment: s.to_string(),
146                len: s.len(),
147            });
148        }
149        if let Some((index, ch)) = s.char_indices().find(|(_, c)| !Self::is_legal(*c)) {
150            return Err(SegmentError::IllegalChar {
151                segment: s.to_string(),
152                ch,
153                index,
154            });
155        }
156        Ok(Self(s.to_string()))
157    }
158
159    /// Borrow the validated label.
160    #[must_use]
161    pub fn as_str(&self) -> &str {
162        &self.0
163    }
164
165    fn is_legal(c: char) -> bool {
166        c.is_ascii_alphanumeric() || c == '-' || c == '_'
167    }
168}
169
170impl fmt::Display for Segment {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.write_str(&self.0)
173    }
174}
175
176impl FromStr for Segment {
177    type Err = SegmentError;
178    fn from_str(s: &str) -> Result<Self, Self::Err> {
179        Self::parse(s)
180    }
181}
182
183impl TryFrom<String> for Segment {
184    type Error = SegmentError;
185    fn try_from(s: String) -> Result<Self, Self::Error> {
186        Self::parse(&s)
187    }
188}
189
190impl From<Segment> for String {
191    fn from(v: Segment) -> Self {
192        v.0
193    }
194}
195
196/// Why a candidate string is not an [`Address`].
197#[derive(Clone, Debug, PartialEq, Eq, Error)]
198pub enum AddressError {
199    /// The address had no segments at all.
200    #[error("address is empty")]
201    Empty,
202    /// One segment failed [`Segment::parse`]. The index is positional
203    /// from the left, so the caller can point at the offending token.
204    #[error("address segment {index}: {source}")]
205    Segment {
206        /// Zero-based position of the failing segment.
207        index: usize,
208        /// Why that segment was rejected.
209        #[source]
210        source: SegmentError,
211    },
212}
213
214/// An ordered, non-empty sequence of [`Segment`]s — a session's alias.
215///
216/// Displays and parses dot-joined (`work.akeyless.build`). Non-empty
217/// is an invariant of the type, not a runtime check: every constructor
218/// is fallible or takes at least one segment, and [`Address::depth`]
219/// returns [`NonZeroUsize`] so the guarantee is visible in the
220/// signature.
221#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
222#[serde(into = "String", try_from = "String")]
223pub struct Address(Vec<Segment>);
224
225impl Address {
226    /// Parse a dot-joined address. An empty string is
227    /// [`AddressError::Empty`]; any bad segment surfaces with its
228    /// position.
229    pub fn parse(s: &str) -> Result<Self, AddressError> {
230        if s.is_empty() {
231            return Err(AddressError::Empty);
232        }
233        let mut segments = Vec::new();
234        for (index, part) in s.split(SEPARATOR).enumerate() {
235            let seg = Segment::parse(part)
236                .map_err(|source| AddressError::Segment { index, source })?;
237            segments.push(seg);
238        }
239        Ok(Self(segments))
240    }
241
242    /// Build from already-validated segments. Fails only on an empty
243    /// iterator — there is nothing left to check.
244    pub fn from_segments<I>(segments: I) -> Result<Self, AddressError>
245    where
246        I: IntoIterator<Item = Segment>,
247    {
248        let segments: Vec<Segment> = segments.into_iter().collect();
249        if segments.is_empty() {
250            return Err(AddressError::Empty);
251        }
252        Ok(Self(segments))
253    }
254
255    /// A one-segment address. Infallible: one segment is already
256    /// non-empty.
257    #[must_use]
258    pub fn root(segment: Segment) -> Self {
259        Self(vec![segment])
260    }
261
262    /// The segments, left to right. Never empty.
263    #[must_use]
264    pub fn segments(&self) -> &[Segment] {
265        &self.0
266    }
267
268    /// The rightmost segment.
269    #[must_use]
270    pub fn leaf(&self) -> &Segment {
271        self.0.last().expect("Address is non-empty by construction")
272    }
273
274    /// How many segments. Typed [`NonZeroUsize`] because an address
275    /// with zero segments does not exist.
276    #[must_use]
277    pub fn depth(&self) -> NonZeroUsize {
278        NonZeroUsize::new(self.0.len()).expect("Address is non-empty by construction")
279    }
280
281    /// This address with its last segment dropped. `None` at depth 1 —
282    /// a root has no parent, and the empty address is unrepresentable.
283    #[must_use]
284    pub fn parent(&self) -> Option<Self> {
285        if self.0.len() == 1 {
286            return None;
287        }
288        Some(Self(self.0[..self.0.len() - 1].to_vec()))
289    }
290
291    /// This address extended by one segment.
292    #[must_use]
293    pub fn child(&self, segment: Segment) -> Self {
294        let mut segments = self.0.clone();
295        segments.push(segment);
296        Self(segments)
297    }
298
299    /// Whether `prefix` is a segment-wise prefix of this address.
300    /// Segment-wise, never string-wise: `work.helm` is NOT a prefix of
301    /// `work.helm-charts`.
302    #[must_use]
303    pub fn starts_with(&self, prefix: &Self) -> bool {
304        self.0.len() >= prefix.0.len() && self.0[..prefix.0.len()] == prefix.0[..]
305    }
306}
307
308impl fmt::Display for Address {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        let mut first = true;
311        for seg in &self.0 {
312            if !first {
313                f.write_str(".")?;
314            }
315            f.write_str(seg.as_str())?;
316            first = false;
317        }
318        Ok(())
319    }
320}
321
322impl FromStr for Address {
323    type Err = AddressError;
324    fn from_str(s: &str) -> Result<Self, Self::Err> {
325        Self::parse(s)
326    }
327}
328
329impl TryFrom<String> for Address {
330    type Error = AddressError;
331    fn try_from(s: String) -> Result<Self, Self::Error> {
332        Self::parse(&s)
333    }
334}
335
336impl From<Address> for String {
337    fn from(v: Address) -> Self {
338        v.to_string()
339    }
340}
341
342/// One token of a [`Pattern`].
343#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
344pub enum PatternToken {
345    /// A literal label — matches itself and nothing else.
346    Literal(Segment),
347    /// `*` — matches exactly one segment, whatever it is.
348    One,
349    /// `>` — matches one-or-more remaining segments. Only ever the
350    /// final token; a `>` elsewhere is a parse error, so a `Pattern`
351    /// with a mid-sequence `Tail` is unrepresentable.
352    Tail,
353}
354
355impl fmt::Display for PatternToken {
356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357        match self {
358            PatternToken::Literal(s) => f.write_str(s.as_str()),
359            PatternToken::One => f.write_str(WILDCARD_ONE),
360            PatternToken::Tail => f.write_str(WILDCARD_TAIL),
361        }
362    }
363}
364
365/// Why a candidate string is not a [`Pattern`].
366#[derive(Clone, Debug, PartialEq, Eq, Error)]
367pub enum PatternError {
368    /// The pattern had no tokens at all.
369    #[error("pattern is empty")]
370    Empty,
371    /// A `>` appeared somewhere other than the final position. This is
372    /// a *parse* error, which is why [`Pattern::matches`] never has to
373    /// consider the case.
374    #[error("`{WILDCARD_TAIL}` at token {index} is not final; the tail wildcard is legal only last")]
375    TailNotFinal {
376        /// Zero-based position of the offending `>`.
377        index: usize,
378    },
379    /// A literal token failed [`Segment::parse`].
380    #[error("pattern token {index}: {source}")]
381    Token {
382        /// Zero-based position of the failing token.
383        index: usize,
384        /// Why that token was rejected as a literal.
385        #[source]
386        source: SegmentError,
387    },
388}
389
390/// A NATS-style matcher over [`Address`].
391///
392/// - a literal segment matches itself
393/// - `*` matches exactly one segment
394/// - `>` matches one-or-more remaining segments, and is legal only as
395///   the final token
396///
397/// The "only final" rule is enforced at [`Pattern::parse`], not at
398/// match time: an ill-formed pattern never becomes a value.
399#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
400#[serde(into = "String", try_from = "String")]
401pub struct Pattern(Vec<PatternToken>);
402
403impl Pattern {
404    /// Parse a dot-joined pattern.
405    pub fn parse(s: &str) -> Result<Self, PatternError> {
406        if s.is_empty() {
407            return Err(PatternError::Empty);
408        }
409        let parts: Vec<&str> = s.split(SEPARATOR).collect();
410        let last = parts.len() - 1;
411        let mut tokens = Vec::with_capacity(parts.len());
412        for (index, part) in parts.into_iter().enumerate() {
413            let token = match part {
414                WILDCARD_ONE => PatternToken::One,
415                WILDCARD_TAIL => {
416                    if index != last {
417                        return Err(PatternError::TailNotFinal { index });
418                    }
419                    PatternToken::Tail
420                }
421                literal => PatternToken::Literal(
422                    Segment::parse(literal)
423                        .map_err(|source| PatternError::Token { index, source })?,
424                ),
425            };
426            tokens.push(token);
427        }
428        Ok(Self(tokens))
429    }
430
431    /// The exact-match pattern for one address — every token literal,
432    /// no wildcards.
433    #[must_use]
434    pub fn exact(address: &Address) -> Self {
435        Self(
436            address
437                .segments()
438                .iter()
439                .cloned()
440                .map(PatternToken::Literal)
441                .collect(),
442        )
443    }
444
445    /// The tokens, left to right.
446    #[must_use]
447    pub fn tokens(&self) -> &[PatternToken] {
448        &self.0
449    }
450
451    /// Whether this pattern matches `address`.
452    ///
453    /// Linear, single pass: because `>` can only be final, there is no
454    /// backtracking to do.
455    #[must_use]
456    pub fn matches(&self, address: &Address) -> bool {
457        let segments = address.segments();
458        for (i, token) in self.0.iter().enumerate() {
459            match token {
460                // Final by construction; needs at least one segment
461                // left to consume, so `>` never matches zero.
462                PatternToken::Tail => return segments.len() > i,
463                PatternToken::One => {
464                    if segments.len() <= i {
465                        return false;
466                    }
467                }
468                PatternToken::Literal(want) => match segments.get(i) {
469                    Some(have) if have == want => {}
470                    _ => return false,
471                },
472            }
473        }
474        segments.len() == self.0.len()
475    }
476}
477
478impl fmt::Display for Pattern {
479    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
480        let mut first = true;
481        for token in &self.0 {
482            if !first {
483                f.write_str(".")?;
484            }
485            write!(f, "{token}")?;
486            first = false;
487        }
488        Ok(())
489    }
490}
491
492impl FromStr for Pattern {
493    type Err = PatternError;
494    fn from_str(s: &str) -> Result<Self, Self::Err> {
495        Self::parse(s)
496    }
497}
498
499impl TryFrom<String> for Pattern {
500    type Error = PatternError;
501    fn try_from(s: String) -> Result<Self, Self::Error> {
502        Self::parse(&s)
503    }
504}
505
506impl From<Pattern> for String {
507    fn from(v: Pattern) -> Self {
508        v.to_string()
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn seg(s: &str) -> Segment {
517        Segment::parse(s).expect("test fixture must be a legal segment")
518    }
519
520    fn addr(s: &str) -> Address {
521        Address::parse(s).expect("test fixture must be a legal address")
522    }
523
524    fn pat(s: &str) -> Pattern {
525        Pattern::parse(s).expect("test fixture must be a legal pattern")
526    }
527
528    // ── Segment: every rejection path ──────────────────────────────
529
530    #[test]
531    fn segment_rejects_empty() {
532        assert_eq!(Segment::parse("").unwrap_err(), SegmentError::Empty);
533    }
534
535    #[test]
536    fn segment_rejects_the_separator() {
537        let err = Segment::parse("work.build").unwrap_err();
538        assert_eq!(err, SegmentError::ContainsSeparator("work.build".into()));
539        // A leading/trailing dot is the same rule, not a special case.
540        assert!(matches!(
541            Segment::parse(".x"),
542            Err(SegmentError::ContainsSeparator(_))
543        ));
544        assert!(matches!(
545            Segment::parse("x."),
546            Err(SegmentError::ContainsSeparator(_))
547        ));
548    }
549
550    #[test]
551    fn segment_rejects_whitespace() {
552        for bad in ["a b", " a", "a ", "a\tb", "a\nb", "\u{a0}a"] {
553            assert!(
554                matches!(Segment::parse(bad), Err(SegmentError::ContainsWhitespace(_))),
555                "expected whitespace rejection for {bad:?}"
556            );
557        }
558    }
559
560    #[test]
561    fn segment_rejects_the_wildcard_tokens() {
562        assert_eq!(
563            Segment::parse("*").unwrap_err(),
564            SegmentError::WildcardToken("*".into())
565        );
566        assert_eq!(
567            Segment::parse(">").unwrap_err(),
568            SegmentError::WildcardToken(">".into())
569        );
570    }
571
572    #[test]
573    fn segment_rejects_wildcard_characters_inside_a_label() {
574        // `a*b` is not the wildcard TOKEN, so it falls through to the
575        // charset rule — still rejected, with a precise position.
576        match Segment::parse("a*b").unwrap_err() {
577            SegmentError::IllegalChar { ch, index, .. } => {
578                assert_eq!(ch, '*');
579                assert_eq!(index, 1);
580            }
581            other => panic!("expected IllegalChar, got {other:?}"),
582        }
583        assert!(matches!(
584            Segment::parse("a>b"),
585            Err(SegmentError::IllegalChar { ch: '>', .. })
586        ));
587    }
588
589    #[test]
590    fn segment_rejects_characters_outside_the_charset() {
591        for (bad, ch, index) in [
592            ("work/build", '/', 4),
593            ("caf\u{e9}", '\u{e9}', 3),
594            ("a:b", ':', 1),
595            ("a+b", '+', 1),
596            ("a$b", '$', 1),
597        ] {
598            match Segment::parse(bad).unwrap_err() {
599                SegmentError::IllegalChar {
600                    ch: got_ch,
601                    index: got_index,
602                    ..
603                } => {
604                    assert_eq!(got_ch, ch, "wrong char for {bad:?}");
605                    assert_eq!(got_index, index, "wrong index for {bad:?}");
606                }
607                other => panic!("expected IllegalChar for {bad:?}, got {other:?}"),
608            }
609        }
610    }
611
612    #[test]
613    fn segment_rejects_over_the_length_maximum() {
614        let ok = "a".repeat(SEGMENT_MAX_LEN);
615        assert!(Segment::parse(&ok).is_ok());
616        let too_long = "a".repeat(SEGMENT_MAX_LEN + 1);
617        match Segment::parse(&too_long).unwrap_err() {
618            SegmentError::TooLong { len, .. } => assert_eq!(len, SEGMENT_MAX_LEN + 1),
619            other => panic!("expected TooLong, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn segment_accepts_the_documented_charset() {
625        for good in ["a", "Z", "0", "helm-charts", "build_2", "AkeyLess", "x-_-x"] {
626            assert_eq!(seg(good).as_str(), good);
627        }
628    }
629
630    #[test]
631    fn segment_is_case_sensitive_and_never_normalises() {
632        assert_ne!(seg("Build"), seg("build"));
633        assert_eq!(seg("Build").as_str(), "Build");
634    }
635
636    #[test]
637    fn segment_display_round_trips_through_from_str() {
638        let s = seg("helm-charts");
639        let back: Segment = s.to_string().parse().unwrap();
640        assert_eq!(s, back);
641    }
642
643    #[test]
644    fn segment_serde_is_a_plain_string_and_validates_on_the_way_in() {
645        let s = seg("build");
646        assert_eq!(serde_json::to_string(&s).unwrap(), "\"build\"");
647        let back: Segment = serde_json::from_str("\"build\"").unwrap();
648        assert_eq!(s, back);
649        // The border holds through serde too — a wildcard cannot be
650        // smuggled in as a label.
651        assert!(serde_json::from_str::<Segment>("\"*\"").is_err());
652        assert!(serde_json::from_str::<Segment>("\"a.b\"").is_err());
653    }
654
655    // ── Address ────────────────────────────────────────────────────
656
657    #[test]
658    fn address_parses_and_displays_dot_joined() {
659        let a = addr("work.akeyless.helm-charts.build");
660        assert_eq!(a.to_string(), "work.akeyless.helm-charts.build");
661        assert_eq!(a.depth().get(), 4);
662        assert_eq!(a.leaf().as_str(), "build");
663    }
664
665    #[test]
666    fn address_rejects_the_empty_string() {
667        assert_eq!(Address::parse("").unwrap_err(), AddressError::Empty);
668    }
669
670    #[test]
671    fn address_reports_the_failing_segment_and_its_index() {
672        match Address::parse("work..build").unwrap_err() {
673            AddressError::Segment { index, source } => {
674                assert_eq!(index, 1);
675                assert_eq!(source, SegmentError::Empty);
676            }
677            other => panic!("expected Segment error, got {other:?}"),
678        }
679        match Address::parse("work.a b.c").unwrap_err() {
680            AddressError::Segment { index, source } => {
681                assert_eq!(index, 1);
682                assert!(matches!(source, SegmentError::ContainsWhitespace(_)));
683            }
684            other => panic!("expected Segment error, got {other:?}"),
685        }
686        // A wildcard is not an address token.
687        match Address::parse("work.*.build").unwrap_err() {
688            AddressError::Segment { index, source } => {
689                assert_eq!(index, 1);
690                assert!(matches!(source, SegmentError::WildcardToken(_)));
691            }
692            other => panic!("expected Segment error, got {other:?}"),
693        }
694    }
695
696    #[test]
697    fn address_from_segments_rejects_an_empty_sequence() {
698        assert_eq!(
699            Address::from_segments(Vec::new()).unwrap_err(),
700            AddressError::Empty
701        );
702        let a = Address::from_segments(vec![seg("work"), seg("build")]).unwrap();
703        assert_eq!(a.to_string(), "work.build");
704    }
705
706    #[test]
707    fn address_parent_child_and_depth_compose() {
708        let a = addr("work.akeyless");
709        let child = a.child(seg("build"));
710        assert_eq!(child.to_string(), "work.akeyless.build");
711        assert_eq!(child.depth().get(), 3);
712        assert_eq!(child.parent().unwrap(), a);
713        assert_eq!(a.parent().unwrap(), Address::root(seg("work")));
714    }
715
716    #[test]
717    fn address_root_has_no_parent() {
718        let root = Address::root(seg("work"));
719        assert_eq!(root.depth().get(), 1);
720        assert!(root.parent().is_none());
721    }
722
723    #[test]
724    fn address_starts_with_is_segment_wise_not_string_wise() {
725        let a = addr("work.helm-charts.build");
726        assert!(a.starts_with(&addr("work")));
727        assert!(a.starts_with(&addr("work.helm-charts")));
728        assert!(a.starts_with(&a));
729        // string-prefix but NOT a segment prefix
730        assert!(!a.starts_with(&addr("work.helm")));
731        assert!(!addr("work").starts_with(&a));
732    }
733
734    #[test]
735    fn address_serde_is_a_plain_string_and_validates_on_the_way_in() {
736        let a = addr("work.build");
737        assert_eq!(serde_json::to_string(&a).unwrap(), "\"work.build\"");
738        let back: Address = serde_json::from_str("\"work.build\"").unwrap();
739        assert_eq!(a, back);
740        assert!(serde_json::from_str::<Address>("\"\"").is_err());
741        assert!(serde_json::from_str::<Address>("\"work..build\"").is_err());
742    }
743
744    // ── Pattern ────────────────────────────────────────────────────
745
746    #[test]
747    fn pattern_rejects_empty() {
748        assert_eq!(Pattern::parse("").unwrap_err(), PatternError::Empty);
749    }
750
751    #[test]
752    fn pattern_tail_wildcard_must_be_final() {
753        assert_eq!(
754            Pattern::parse("work.>.build").unwrap_err(),
755            PatternError::TailNotFinal { index: 1 }
756        );
757        assert_eq!(
758            Pattern::parse(">.work").unwrap_err(),
759            PatternError::TailNotFinal { index: 0 }
760        );
761        assert_eq!(
762            Pattern::parse("a.>.>").unwrap_err(),
763            PatternError::TailNotFinal { index: 1 }
764        );
765        // Final is fine, including on its own.
766        assert!(Pattern::parse("work.>").is_ok());
767        assert!(Pattern::parse(">").is_ok());
768    }
769
770    #[test]
771    fn pattern_reports_the_failing_token_and_its_index() {
772        match Pattern::parse("work.a b.>").unwrap_err() {
773            PatternError::Token { index, source } => {
774                assert_eq!(index, 1);
775                assert!(matches!(source, SegmentError::ContainsWhitespace(_)));
776            }
777            other => panic!("expected Token error, got {other:?}"),
778        }
779        match Pattern::parse("work..build").unwrap_err() {
780            PatternError::Token { index, source } => {
781                assert_eq!(index, 1);
782                assert_eq!(source, SegmentError::Empty);
783            }
784            other => panic!("expected Token error, got {other:?}"),
785        }
786    }
787
788    #[test]
789    fn pattern_literal_matches_itself_and_nothing_else() {
790        let p = pat("work.build");
791        assert!(p.matches(&addr("work.build")));
792        assert!(!p.matches(&addr("work.Build")));
793        assert!(!p.matches(&addr("work")));
794        assert!(!p.matches(&addr("work.build.extra")));
795        assert!(!p.matches(&addr("other.build")));
796    }
797
798    #[test]
799    fn pattern_star_matches_exactly_one_segment() {
800        let p = pat("work.*.build");
801        assert!(p.matches(&addr("work.akeyless.build")));
802        assert!(p.matches(&addr("work.x.build")));
803        // zero segments in the slot
804        assert!(!p.matches(&addr("work.build")));
805        // two segments in the slot
806        assert!(!p.matches(&addr("work.a.b.build")));
807
808        let trailing = pat("work.*");
809        assert!(trailing.matches(&addr("work.build")));
810        assert!(!trailing.matches(&addr("work")));
811        assert!(!trailing.matches(&addr("work.build.deep")));
812    }
813
814    #[test]
815    fn pattern_tail_matches_one_or_more_but_never_zero() {
816        let p = pat("work.>");
817        assert!(p.matches(&addr("work.build")));
818        assert!(p.matches(&addr("work.akeyless.helm-charts.build")));
819        // one-or-more, so the bare prefix does NOT match
820        assert!(!p.matches(&addr("work")));
821        assert!(!p.matches(&addr("other.build")));
822
823        let everything = pat(">");
824        assert!(everything.matches(&addr("work")));
825        assert!(everything.matches(&addr("work.a.b.c")));
826    }
827
828    #[test]
829    fn pattern_mixes_literals_stars_and_a_tail() {
830        let p = pat("work.*.helm-charts.>");
831        assert!(p.matches(&addr("work.akeyless.helm-charts.build")));
832        assert!(p.matches(&addr("work.pleme.helm-charts.a.b")));
833        assert!(!p.matches(&addr("work.akeyless.helm-charts")));
834        assert!(!p.matches(&addr("work.akeyless.other.build")));
835        assert!(!p.matches(&addr("work.helm-charts.build")));
836    }
837
838    #[test]
839    fn pattern_display_round_trips_through_from_str() {
840        for src in ["work.build", "work.*.build", "work.>", ">", "*"] {
841            let p = pat(src);
842            assert_eq!(p.to_string(), src);
843            let back: Pattern = p.to_string().parse().unwrap();
844            assert_eq!(p, back);
845        }
846    }
847
848    #[test]
849    fn pattern_exact_matches_only_its_own_address() {
850        let a = addr("work.akeyless.build");
851        let p = Pattern::exact(&a);
852        assert_eq!(p.to_string(), a.to_string());
853        assert!(p.matches(&a));
854        assert!(!p.matches(&addr("work.akeyless")));
855        assert!(!p.matches(&addr("work.akeyless.build.x")));
856        assert_eq!(p.tokens().len(), 3);
857    }
858
859    #[test]
860    fn pattern_serde_is_a_plain_string_and_validates_on_the_way_in() {
861        let p = pat("work.*.>");
862        assert_eq!(serde_json::to_string(&p).unwrap(), "\"work.*.>\"");
863        let back: Pattern = serde_json::from_str("\"work.*.>\"").unwrap();
864        assert_eq!(p, back);
865        assert!(serde_json::from_str::<Pattern>("\"work.>.build\"").is_err());
866    }
867
868    // ── structural forcing function ────────────────────────────────
869
870    /// Parse-don't-validate is a claim about the *absence* of other
871    /// ways in, which cannot be asserted at runtime. Comment-stripped
872    /// source scan, same construction as `shutai.rs`'s
873    /// `shutai_never_becomes_deserializable`.
874    #[test]
875    fn the_only_way_in_is_a_fallible_parse() {
876        let src = include_str!("address.rs");
877        let code: String = src
878            .lines()
879            .map(str::trim_start)
880            .filter(|l| !l.starts_with("//"))
881            .collect::<Vec<_>>()
882            .join("\n");
883        let code = code.split("mod tests").next().unwrap_or(&code);
884
885        // Private inner fields: no other module can build one of these
886        // without going through a constructor in this file.
887        for decl in [
888            "pub struct Segment(String);",
889            "pub struct Address(Vec<Segment>);",
890            "pub struct Pattern(Vec<PatternToken>);",
891        ] {
892            assert!(code.contains(decl), "inner state must stay private: {decl}");
893        }
894        assert!(
895            !code.contains("pub fn new("),
896            "an infallible `new` would be a second, unchecked way in"
897        );
898        assert!(
899            !code.contains("impl From<String> for"),
900            "an infallible `From<String>` would bypass parsing"
901        );
902
903        // Anti-vacuity: the scan must be looking at real code.
904        assert!(code.contains("pub fn parse(s: &str) -> Result<Self, SegmentError>"));
905        assert!(code.contains("try_from = \"String\""));
906    }
907}