Skip to main content

nedb_engine/
cause.rs

1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! Qualified cause references — naming a causal ancestor that lives in
6//! ANOTHER object store.
7//!
8//! # Why this module exists
9//!
10//! A node carries `caused_by: Vec<String>` ([`crate::store::Node`]), and every
11//! entry in it is a bare BLAKE2b object hash. The causal edges those hashes
12//! stand for are materialised in [`crate::graph`] as filesystem paths,
13//! `graph/{from_hash}/{edge_type}/{to_hash}`, and `TRACE` walks them.
14//!
15//! That works for exactly as long as there is one object store, because with
16//! one store "the store" is not information — there is nothing to say. The
17//! moment branches exist as child stores it becomes the most important thing
18//! in the reference and it is the one thing a bare hash cannot express. A
19//! merge write in the destination store has to point at a node that was
20//! written in the branch's store, and on read `TRACE` gets two bad outcomes
21//! and no good one:
22//!
23//! ```text
24//! hash absent from the reading store  → the edge dangles, trace truncates
25//! hash present in BOTH stores         → resolves against the wrong one, silently
26//! ```
27//!
28//! The second is the dangerous one. A dangling edge is a visible failure; a
29//! confidently-wrong resolution is a corrupt causal history that verifies.
30//! Content addressing makes the collision case unlikely-but-real: two stores
31//! that share ancestry genuinely DO hold the same hashes, because the same
32//! bytes hash the same way everywhere. That is the point of content
33//! addressing, and it is precisely why a hash alone cannot be a locator.
34//!
35//! So a cause reference needs two parts — WHAT (the hash) and WHERE (the
36//! store) — while staying a plain string, because `caused_by` is
37//! `Vec<String>` on the wire today and changing the shape of a node is a
38//! storage-format break we are not willing to take for this.
39//!
40//! # The format
41//!
42//! ```text
43//! local      64 hex chars                 e.g. 3f9a...c1   (legacy, still valid)
44//! qualified  64 hex chars '@' store id    e.g. 3f9a...c1@branch-feature-x
45//! ```
46//!
47//! [`Cause::Local`] is not a deprecated form to be migrated away from. It is
48//! the correct encoding of "this cause lives wherever I do", which is what
49//! every intra-store edge means and what every node written before this module
50//! existed says. Those resolve against the reading store and always did; this
51//! module just gives that behaviour a name.
52//!
53//! # Why `@`
54//!
55//! The separator has to be a character that can appear in NEITHER side of the
56//! reference, or the encoding is ambiguous and the parse is a guess:
57//!
58//!   - hex is `[0-9a-f]`, so anything outside that alphabet is safe on the left;
59//!   - store ids are [`STORE_ID_ALPHABET`] (`[A-Za-z0-9_.-]`), which excludes
60//!     `@` by construction, and [`StoreId::new`] REFUSES any id containing it
61//!     rather than escaping it. An escape layer is a second encoding to get
62//!     wrong, and a store id that can break the reference format is not a
63//!     valid store id — it is a bug that has not been reported yet.
64//!
65//! Among the characters satisfying that, `@` is chosen over the alternatives
66//! for reasons that are operational rather than aesthetic:
67//!
68//!   - `:` is not a legal filename character on Windows, and store ids and
69//!     hashes both end up as path components in `graph/` and `objects/`;
70//!   - `/` is a path separator on every platform, so it would let a store id
71//!     escape its directory;
72//!   - `#`, `?`, `&` are URL-significant and these strings appear in query
73//!     strings and HTTP paths on the server surface;
74//!   - `@` is filesystem-safe everywhere, shell-safe unquoted, needs no URL
75//!     escaping in a path segment, and already means "at this location" to
76//!     every reader who has seen an email address.
77//!
78//! Hash goes FIRST, store second — `{hash}@{store}` rather than
79//! `{store}@{hash}` — so that the hash occupies the same leading bytes in both
80//! variants. Every existing display path that abbreviates a cause by taking a
81//! prefix (`&h[..8]`, the usual short-hash rendering) keeps showing a hash
82//! instead of suddenly showing a store name, and sorting a mixed list still
83//! groups by hash the way it does today. Grouping by store is the rarer query
84//! and can afford a parse.
85//!
86//! # What is strict, and why
87//!
88//! A hash must be exactly 64 LOWERCASE hex characters. Uppercase is refused,
89//! not normalised, and that is deliberate: normalising means `3F9A…` and
90//! `3f9a…` are two spellings of one reference, and a content-addressed system
91//! does not get to have two spellings of anything. The instant two exist,
92//! equality, deduplication, edge-path construction and set membership must all
93//! remember to canonicalise, one of them eventually forgets, and the graph
94//! grows a duplicate edge under a second path. Refusing at the boundary costs
95//! one error and buys the invariant everywhere downstream.
96
97use std::fmt;
98
99use serde::de::{self, Visitor};
100use serde::{Deserialize, Deserializer, Serialize, Serializer};
101
102/// Separator between hash and store id. See the module docs for why this
103/// character and not another one.
104pub const SEPARATOR: char = '@';
105
106/// Length of a BLAKE2b object hash in hex characters.
107const HASH_HEX_LEN: usize = 64;
108
109/// Upper bound on a store id, in bytes.
110///
111/// Store ids become path components (`stores/{id}/...`) and are embedded in
112/// every cause reference of every node that points across a store boundary, so
113/// they are paid for repeatedly in both inodes and bytes-on-disk. 64 is chosen
114/// to match the hash length: a qualified reference is then never more than
115/// ~2x a bare one, which keeps the worst case on a node's `caused_by` bounded
116/// and predictable. It is also comfortably under the 255-byte filename limit
117/// on every filesystem we target, with room for prefixes and suffixes.
118pub const MAX_STORE_ID_LEN: usize = 64;
119
120/// The characters a store id may contain, for error messages and docs.
121///
122/// `[A-Za-z0-9_.-]` — the intersection of "safe as a filesystem path
123/// component", "safe in a URL path segment unescaped", "safe unquoted in a
124/// shell", and "typeable without thinking". Notably excluded: whitespace (an
125/// id you cannot see the boundaries of), `/` and `\` (directory escape), `@`
126/// (the separator), and everything non-ASCII (because two visually identical
127/// ids under different Unicode normalisations would reintroduce exactly the
128/// two-spellings problem that the lowercase-hex rule exists to prevent).
129pub const STORE_ID_ALPHABET: &str = "A-Za-z0-9_.-";
130
131// ---------------------------------------------------------------------------
132// StoreId
133// ---------------------------------------------------------------------------
134
135/// A validated store identifier.
136///
137/// A newtype rather than a bare `String` so that the validation cannot be
138/// skipped: the inner field is private and [`StoreId::new`] is the only way in,
139/// which makes "this store id cannot break the cause encoding" a property of
140/// the type instead of a convention callers are asked to remember.
141#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
142pub struct StoreId(String);
143
144impl StoreId {
145    /// Validate and construct.
146    ///
147    /// Refuses anything that would make a cause reference ambiguous,
148    /// unparseable, or unsafe as a path component. See [`StoreIdError`].
149    pub fn new(s: impl Into<String>) -> Result<Self, StoreIdError> {
150        let s = s.into();
151
152        if s.is_empty() {
153            return Err(StoreIdError::Empty);
154        }
155        // Length is checked before the character scan so that an unbounded
156        // input is rejected without walking all of it. The consequence is that
157        // an oversized id that ALSO contains a bad character reports TooLong;
158        // both diagnoses are true and the caller has to fix both anyway.
159        if s.len() > MAX_STORE_ID_LEN {
160            return Err(StoreIdError::TooLong { len: s.len(), max: MAX_STORE_ID_LEN });
161        }
162
163        for (position, ch) in s.char_indices() {
164            // The separator gets its own variant even though the alphabet check
165            // below would also catch it. "contains the separator" is a
166            // different mistake from "contains a stray character" — it is
167            // usually someone passing an already-rendered `hash@store` where a
168            // store id was wanted — and it deserves a message that says so.
169            if ch == SEPARATOR {
170                return Err(StoreIdError::ContainsSeparator { position });
171            }
172            if !is_store_id_char(ch) {
173                return Err(StoreIdError::InvalidChar { ch, position });
174            }
175        }
176
177        // `.` and `..` are legal under the alphabet but mean "here" and "one
178        // level up" to every filesystem. A store id is used as a path
179        // component; these two would resolve to a directory that is not the
180        // store's own. Refused by name rather than by banning `.` outright,
181        // because `.` is genuinely useful inside an id (`team.alpha`).
182        if s == "." || s == ".." {
183            return Err(StoreIdError::Reserved { id: s });
184        }
185
186        Ok(StoreId(s))
187    }
188
189    /// Borrow the underlying string.
190    pub fn as_str(&self) -> &str {
191        &self.0
192    }
193
194    /// Consume and return the underlying string.
195    pub fn into_string(self) -> String {
196        self.0
197    }
198}
199
200fn is_store_id_char(ch: char) -> bool {
201    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.'
202}
203
204impl fmt::Display for StoreId {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.write_str(&self.0)
207    }
208}
209
210impl AsRef<str> for StoreId {
211    fn as_ref(&self) -> &str {
212        &self.0
213    }
214}
215
216impl std::str::FromStr for StoreId {
217    type Err = StoreIdError;
218    fn from_str(s: &str) -> Result<Self, Self::Err> {
219        StoreId::new(s)
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Cause
225// ---------------------------------------------------------------------------
226
227/// A reference to a causal ancestor.
228#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
229pub enum Cause {
230    /// A hash with no store qualification: every node written before this
231    /// format existed, plus every ordinary intra-store edge written after it.
232    /// Resolves against the store doing the reading.
233    Local(String),
234    /// Explicitly qualified — the hash lives in `store`, not in whichever
235    /// store happens to be reading.
236    Qualified { store: StoreId, hash: String },
237}
238
239impl Cause {
240    /// Build a local (unqualified) cause, validating the hash.
241    pub fn local(hash: impl Into<String>) -> Result<Self, CauseParseError> {
242        let hash = hash.into();
243        validate_hash(&hash)?;
244        Ok(Cause::Local(hash))
245    }
246
247    /// Build a qualified cause, validating the hash. The store id is already
248    /// validated by virtue of being a [`StoreId`].
249    pub fn qualified(store: StoreId, hash: impl Into<String>) -> Result<Self, CauseParseError> {
250        let hash = hash.into();
251        validate_hash(&hash)?;
252        Ok(Cause::Qualified { store, hash })
253    }
254
255    /// The object hash, regardless of variant.
256    pub fn hash(&self) -> &str {
257        match self {
258            Cause::Local(h) => h,
259            Cause::Qualified { hash, .. } => hash,
260        }
261    }
262
263    /// The store id, or `None` for a local cause.
264    pub fn store(&self) -> Option<&StoreId> {
265        match self {
266            Cause::Local(_) => None,
267            Cause::Qualified { store, .. } => Some(store),
268        }
269    }
270
271    /// True if this reference names a store explicitly.
272    pub fn is_qualified(&self) -> bool {
273        matches!(self, Cause::Qualified { .. })
274    }
275
276    /// Render to the wire form. Same as [`render`].
277    pub fn render(&self) -> String {
278        render(self)
279    }
280}
281
282impl fmt::Display for Cause {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            Cause::Local(h) => f.write_str(h),
286            Cause::Qualified { store, hash } => write!(f, "{hash}{SEPARATOR}{store}"),
287        }
288    }
289}
290
291impl std::str::FromStr for Cause {
292    type Err = CauseParseError;
293    fn from_str(s: &str) -> Result<Self, Self::Err> {
294        parse(s)
295    }
296}
297
298impl TryFrom<String> for Cause {
299    type Error = CauseParseError;
300    fn try_from(s: String) -> Result<Self, Self::Error> {
301        parse(&s)
302    }
303}
304
305impl From<Cause> for String {
306    fn from(c: Cause) -> String {
307        render(&c)
308    }
309}
310
311// ---------------------------------------------------------------------------
312// parse / render
313// ---------------------------------------------------------------------------
314
315/// Parse a cause reference.
316///
317/// A string with no [`SEPARATOR`] is a bare hash and parses as [`Cause::Local`]
318/// — this is the backward-compatibility path, and it is a path rather than a
319/// fallback: no existing database needs migrating, because every 64-hex
320/// `caused_by` entry ever written is already a valid input here and means
321/// exactly what it always meant.
322///
323/// Never guesses. Every rejection carries what it saw.
324pub fn parse(s: &str) -> Result<Cause, CauseParseError> {
325    if s.is_empty() {
326        return Err(CauseParseError::Empty);
327    }
328
329    let separators = s.matches(SEPARATOR).count();
330    match separators {
331        0 => {
332            validate_hash(s)?;
333            Ok(Cause::Local(s.to_string()))
334        }
335        1 => {
336            // `split_once` is safe here: exactly one separator, so both halves
337            // are well defined (either may be empty, which the validators
338            // below reject with a specific reason rather than a generic one).
339            let (hash, store) = s.split_once(SEPARATOR).expect("one separator counted");
340            validate_hash(hash)?;
341            let store = StoreId::new(store).map_err(CauseParseError::BadStoreId)?;
342            Ok(Cause::Qualified { store, hash: hash.to_string() })
343        }
344        // More than one separator is never a store id we would have produced
345        // (StoreId::new refuses the separator), so this is either a corrupted
346        // value or a double-qualification like `h@a@b`. Refusing beats picking
347        // a split and pretending we understood it.
348        count => Err(CauseParseError::TooManySeparators { count, input: s.to_string() }),
349    }
350}
351
352/// Render a cause to its wire form.
353///
354/// The inverse of [`parse`] for every value that [`parse`] can produce, and for
355/// every value the constructors can produce, because both sides enforce the
356/// same invariants: exactly-64 lowercase hex, and a store id that cannot
357/// contain the separator.
358pub fn render(c: &Cause) -> String {
359    match c {
360        Cause::Local(h) => h.clone(),
361        Cause::Qualified { store, hash } => {
362            let mut out = String::with_capacity(hash.len() + 1 + store.as_str().len());
363            out.push_str(hash);
364            out.push(SEPARATOR);
365            out.push_str(store.as_str());
366            out
367        }
368    }
369}
370
371/// Resolve a cause to the store it should be read from.
372///
373/// Local causes resolve to the reading store; qualified ones to their own.
374/// This is the whole point of the module in one function: a caller holding a
375/// `Cause` and knowing where it is reading from never has to decide what an
376/// unqualified hash means.
377pub fn target_store<'a>(c: &'a Cause, reading_store: &'a str) -> &'a str {
378    match c {
379        Cause::Local(_) => reading_store,
380        Cause::Qualified { store, .. } => store.as_str(),
381    }
382}
383
384/// Exactly 64 lowercase hex characters. No normalisation — see module docs.
385fn validate_hash(h: &str) -> Result<(), CauseParseError> {
386    if h.len() != HASH_HEX_LEN {
387        return Err(CauseParseError::BadHashLength { got: h.len(), expected: HASH_HEX_LEN });
388    }
389    for (position, ch) in h.char_indices() {
390        if ch.is_ascii_digit() || ('a'..='f').contains(&ch) {
391            continue;
392        }
393        // Uppercase hex is a distinct diagnosis from garbage: the caller has a
394        // real hash and the wrong case, and telling them that is the
395        // difference between a one-line fix and a debugging session. It is
396        // still an error, not a normalisation — see the module docs.
397        if ch.is_ascii_uppercase() && ch.is_ascii_hexdigit() {
398            return Err(CauseParseError::UppercaseHex { ch, position });
399        }
400        return Err(CauseParseError::NonHexChar { ch, position });
401    }
402    Ok(())
403}
404
405// ---------------------------------------------------------------------------
406// Errors
407// ---------------------------------------------------------------------------
408
409/// Why a store id was refused.
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub enum StoreIdError {
412    /// Zero-length.
413    Empty,
414    /// Longer than [`MAX_STORE_ID_LEN`].
415    TooLong { len: usize, max: usize },
416    /// Contains [`SEPARATOR`], which would make the cause encoding ambiguous.
417    ContainsSeparator { position: usize },
418    /// Contains a character outside [`STORE_ID_ALPHABET`].
419    InvalidChar { ch: char, position: usize },
420    /// `.` or `..` — legal characters, but they name a directory that is not
421    /// this store.
422    Reserved { id: String },
423}
424
425impl fmt::Display for StoreIdError {
426    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427        match self {
428            StoreIdError::Empty => write!(f, "store id is empty"),
429            StoreIdError::TooLong { len, max } => {
430                write!(f, "store id is {len} bytes, maximum is {max}")
431            }
432            StoreIdError::ContainsSeparator { position } => write!(
433                f,
434                "store id contains the reserved separator {SEPARATOR:?} at byte {position}; \
435                 a store id that can break the cause encoding is not a valid store id"
436            ),
437            StoreIdError::InvalidChar { ch, position } => write!(
438                f,
439                "store id contains invalid character {ch:?} at byte {position}; \
440                 allowed characters are [{STORE_ID_ALPHABET}]"
441            ),
442            StoreIdError::Reserved { id } => {
443                write!(f, "store id {id:?} is reserved (it names a directory, not a store)")
444            }
445        }
446    }
447}
448
449impl std::error::Error for StoreIdError {}
450
451/// Why a cause reference was refused.
452///
453/// Every variant carries what was actually seen. A parser that says only
454/// "invalid" forces the operator to reconstruct the input by hand, and the
455/// inputs here are 64-character hex strings where the difference between valid
456/// and invalid is one character in the middle.
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub enum CauseParseError {
459    /// Empty input.
460    Empty,
461    /// Hash is not exactly 64 characters.
462    BadHashLength { got: usize, expected: usize },
463    /// Hash contains a character that is not a hex digit.
464    NonHexChar { ch: char, position: usize },
465    /// Hash contains uppercase hex. Refused rather than lowercased — see the
466    /// module docs on why content addressing gets one spelling.
467    UppercaseHex { ch: char, position: usize },
468    /// The store half of a qualified reference is not a valid store id.
469    BadStoreId(StoreIdError),
470    /// More than one [`SEPARATOR`], so the split point is a guess.
471    TooManySeparators { count: usize, input: String },
472}
473
474impl fmt::Display for CauseParseError {
475    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
476        match self {
477            CauseParseError::Empty => {
478                write!(f, "empty cause reference: expected a 64-char hex hash, optionally followed by {SEPARATOR:?} and a store id")
479            }
480            CauseParseError::BadHashLength { got, expected } => {
481                write!(f, "cause hash is {got} characters, expected exactly {expected} hex characters")
482            }
483            CauseParseError::NonHexChar { ch, position } => {
484                write!(f, "cause hash contains non-hex character {ch:?} at position {position}; expected [0-9a-f]")
485            }
486            CauseParseError::UppercaseHex { ch, position } => write!(
487                f,
488                "cause hash contains uppercase hex character {ch:?} at position {position}; \
489                 hashes must be lowercase (refused rather than normalised, so that one hash has one spelling)"
490            ),
491            CauseParseError::BadStoreId(e) => write!(f, "invalid store id in cause reference: {e}"),
492            CauseParseError::TooManySeparators { count, input } => write!(
493                f,
494                "cause reference {input:?} contains {count} {SEPARATOR:?} separators, expected at most 1"
495            ),
496        }
497    }
498}
499
500impl std::error::Error for CauseParseError {
501    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
502        match self {
503            CauseParseError::BadStoreId(e) => Some(e),
504            _ => None,
505        }
506    }
507}
508
509impl From<StoreIdError> for CauseParseError {
510    fn from(e: StoreIdError) -> Self {
511        CauseParseError::BadStoreId(e)
512    }
513}
514
515// ---------------------------------------------------------------------------
516// Serde
517// ---------------------------------------------------------------------------
518//
519// A Cause serialises as a plain JSON STRING, never a tagged object. `caused_by`
520// is `Vec<String>` on the wire today, so a node encoded with `Vec<Cause>` must
521// be byte-identical to one encoded with `Vec<String>` for all existing values —
522// otherwise adopting this type would be a storage-format break, and the whole
523// design goal was that it not be one.
524//
525// Hand-written rather than `#[serde(try_from/into)]` so deserialisation borrows
526// the input `&str` on the common path instead of allocating a String only to
527// parse it and throw it away, and so the parse error surfaces with its own
528// Display text rather than serde's generic wrapper.
529
530impl Serialize for Cause {
531    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
532        s.serialize_str(&render(self))
533    }
534}
535
536impl<'de> Deserialize<'de> for Cause {
537    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
538        struct CauseVisitor;
539
540        impl Visitor<'_> for CauseVisitor {
541            type Value = Cause;
542
543            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
544                write!(f, "a cause reference string: 64 lowercase hex characters, optionally followed by {SEPARATOR:?} and a store id")
545            }
546
547            fn visit_str<E: de::Error>(self, v: &str) -> Result<Cause, E> {
548                // The parse error's Display is the whole diagnosis; passing it
549                // through as a custom message keeps "which character, where"
550                // instead of collapsing to "invalid value".
551                parse(v).map_err(|e| E::custom(e.to_string()))
552            }
553        }
554
555        d.deserialize_str(CauseVisitor)
556    }
557}
558
559impl Serialize for StoreId {
560    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
561        s.serialize_str(&self.0)
562    }
563}
564
565impl<'de> Deserialize<'de> for StoreId {
566    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
567        let s = String::deserialize(d)?;
568        StoreId::new(s).map_err(|e| de::Error::custom(e.to_string()))
569    }
570}
571
572// ---------------------------------------------------------------------------
573// Tests
574// ---------------------------------------------------------------------------
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    /// A real-shaped hash: 64 lowercase hex characters, deterministic per seed
581    /// so tests can name specific values and still be readable.
582    fn hash_n(seed: u8) -> String {
583        let alphabet = b"0123456789abcdef";
584        (0..HASH_HEX_LEN)
585            .map(|i| alphabet[(i.wrapping_mul(7).wrapping_add(seed as usize)) % 16] as char)
586            .collect()
587    }
588
589    const H: &str = "3f9a7c1e0b2d4f6a8c0e1b3d5f7a9c1e2d4f6a8c0e1b3d5f7a9c1e2d4f6a8c0e";
590
591    // --- constraint 1: backward compatibility -----------------------------
592
593    #[test]
594    fn legacy_bare_hash_parses_as_local() {
595        let c = parse(H).expect("64-hex must parse");
596        assert_eq!(c, Cause::Local(H.to_string()));
597        assert!(!c.is_qualified());
598        assert_eq!(c.hash(), H);
599        assert_eq!(c.store(), None);
600    }
601
602    #[test]
603    fn legacy_bare_hash_renders_byte_identically() {
604        // The backward-compatibility proof: parse-then-render of an existing
605        // caused_by entry returns the exact original bytes, so re-encoding a
606        // node written by today's engine changes nothing on disk.
607        for seed in 0..32u8 {
608            let h = hash_n(seed);
609            let round = render(&parse(&h).unwrap());
610            assert_eq!(round, h, "bare hash must survive parse/render unchanged");
611        }
612        assert_eq!(render(&parse(H).unwrap()), H);
613    }
614
615    #[test]
616    fn legacy_caused_by_vec_needs_no_migration() {
617        // A whole caused_by list as today's engine writes it.
618        let legacy: Vec<String> = (0..8u8).map(hash_n).collect();
619        let parsed: Vec<Cause> = legacy.iter().map(|s| parse(s).unwrap()).collect();
620        assert!(parsed.iter().all(|c| !c.is_qualified()));
621        let rendered: Vec<String> = parsed.iter().map(render).collect();
622        assert_eq!(rendered, legacy);
623    }
624
625    // --- constraint 2: unambiguity ----------------------------------------
626
627    #[test]
628    fn qualified_can_never_look_like_a_bare_hash() {
629        // A bare hash has no separator; a qualified one always does; and the
630        // separator can appear in neither half. So membership of `@` decides
631        // the variant with no lookahead and no ambiguity.
632        let q = Cause::qualified(StoreId::new("branch-x").unwrap(), H).unwrap();
633        let s = render(&q);
634        assert!(s.contains(SEPARATOR));
635        assert_eq!(s.matches(SEPARATOR).count(), 1);
636        assert!(!render(&Cause::local(H).unwrap()).contains(SEPARATOR));
637        // And the separator is outside the hex alphabet.
638        assert!(!SEPARATOR.is_ascii_hexdigit());
639        // ...and outside the store-id alphabet.
640        assert!(!is_store_id_char(SEPARATOR));
641    }
642
643    #[test]
644    fn store_id_containing_separator_is_refused_at_construction() {
645        let e = StoreId::new("branch@evil").unwrap_err();
646        assert_eq!(e, StoreIdError::ContainsSeparator { position: 6 });
647        assert!(e.to_string().contains("separator"));
648
649        // Every position, including the ends, where a naive `split_once` or
650        // `rsplit_once` would otherwise silently produce a different parse.
651        for bad in ["@main", "main@", "@", "a@b@c"] {
652            assert!(
653                matches!(StoreId::new(bad), Err(StoreIdError::ContainsSeparator { .. })),
654                "{bad:?} must be refused"
655            );
656        }
657
658        // And the shape that would actually be dangerous: feeding an
659        // already-rendered reference back in as a store id. It is refused for
660        // length first (70 > 64) — both diagnoses are true; what matters is
661        // that it never becomes a store id.
662        let rendered = format!("{H}{SEPARATOR}inner");
663        assert_eq!(
664            StoreId::new(&rendered).unwrap_err(),
665            StoreIdError::TooLong { len: rendered.len(), max: MAX_STORE_ID_LEN }
666        );
667        // Under the length bound, the separator diagnosis is the one reported,
668        // which is what a caller doing the same thing with a short id sees.
669        assert!(matches!(
670            StoreId::new("abcdef@inner"),
671            Err(StoreIdError::ContainsSeparator { position: 6 })
672        ));
673    }
674
675    #[test]
676    fn too_many_separators_is_refused_not_guessed() {
677        let s = format!("{H}@a@b");
678        match parse(&s).unwrap_err() {
679            CauseParseError::TooManySeparators { count, input } => {
680                assert_eq!(count, 2);
681                assert_eq!(input, s);
682            }
683            other => panic!("expected TooManySeparators, got {other:?}"),
684        }
685    }
686
687    // --- constraint 3: round-trips ----------------------------------------
688
689    #[test]
690    fn qualified_round_trips() {
691        let q = Cause::qualified(StoreId::new("branch-feature-x").unwrap(), H).unwrap();
692        let s = render(&q);
693        assert_eq!(s, format!("{H}@branch-feature-x"));
694        assert_eq!(parse(&s).unwrap(), q);
695        assert_eq!(render(&parse(&s).unwrap()), s);
696    }
697
698    #[test]
699    fn table_driven_round_trip() {
700        // 20+ varied inputs: both variants, every legal store-id character
701        // class, boundary lengths, and hashes of differing shapes.
702        let store_ids = [
703            "a",
704            "Z",
705            "0",
706            "_",
707            "-",
708            ".",                      // legal inside an id, illegal as the whole id
709            "main",
710            "MAIN",
711            "branch-feature-x",
712            "branch_feature_x",
713            "team.alpha",
714            "v1.2.3-rc.4_final",
715            "0123456789",
716            "A-Za-z0-9_.",
717            &"x".repeat(MAX_STORE_ID_LEN),
718            &"y".repeat(MAX_STORE_ID_LEN - 1),
719        ];
720
721        let mut cases: Vec<Cause> = Vec::new();
722
723        // Local variants.
724        for seed in 0..8u8 {
725            cases.push(Cause::local(hash_n(seed)).unwrap());
726        }
727        cases.push(Cause::local("0".repeat(64)).unwrap());
728        cases.push(Cause::local("f".repeat(64)).unwrap());
729        cases.push(Cause::local(H).unwrap());
730
731        // Qualified variants, cycling hashes so store and hash vary together.
732        for (i, sid) in store_ids.iter().enumerate() {
733            let store = if *sid == "." {
734                // `.` alone is reserved; use it in a position where it is legal.
735                StoreId::new("a.b").unwrap()
736            } else {
737                StoreId::new(*sid).unwrap_or_else(|e| panic!("{sid:?} should be valid: {e}"))
738            };
739            cases.push(Cause::qualified(store, hash_n(i as u8 * 3)).unwrap());
740        }
741
742        assert!(cases.len() >= 20, "want a decent sample, got {}", cases.len());
743
744        for c in &cases {
745            let s = render(c);
746            let back = parse(&s).unwrap_or_else(|e| panic!("{s:?} must re-parse: {e}"));
747            assert_eq!(&back, c, "parse(render(c)) must equal c");
748            assert_eq!(render(&back), s, "render must be stable across a round trip");
749            // Display agrees with render.
750            assert_eq!(c.to_string(), s);
751            // JSON round-trip of the same value.
752            let json = serde_json::to_string(c).unwrap();
753            assert!(json.starts_with('"') && json.ends_with('"'), "must be a JSON string");
754            let from_json: Cause = serde_json::from_str(&json).unwrap();
755            assert_eq!(&from_json, c);
756        }
757    }
758
759    // --- constraint 4: strict hash validation ------------------------------
760
761    #[test]
762    fn short_and_long_hashes_are_refused_distinguishably() {
763        let short = &H[..63];
764        let long = format!("{H}a");
765
766        let e_short = parse(short).unwrap_err();
767        let e_long = parse(&long).unwrap_err();
768
769        assert_eq!(e_short, CauseParseError::BadHashLength { got: 63, expected: 64 });
770        assert_eq!(e_long, CauseParseError::BadHashLength { got: 65, expected: 64 });
771        assert_ne!(e_short, e_long, "63 and 65 must be distinguishable");
772        assert!(e_short.to_string().contains("63"));
773        assert!(e_long.to_string().contains("65"));
774    }
775
776    #[test]
777    fn uppercase_hex_is_refused_not_normalised() {
778        let upper = H.to_uppercase();
779        match parse(&upper).unwrap_err() {
780            CauseParseError::UppercaseHex { ch, position } => {
781                assert_eq!(ch, 'F');
782                assert_eq!(position, 1); // "3F9A..." → the 'F'
783            }
784            other => panic!("expected UppercaseHex, got {other:?}"),
785        }
786        // Mixed case too, and nothing anywhere lowercases it for us.
787        let mixed = format!("{}A{}", &H[..10], &H[11..]);
788        assert!(matches!(parse(&mixed), Err(CauseParseError::UppercaseHex { ch: 'A', .. })));
789        assert!(parse(&upper).is_err());
790    }
791
792    #[test]
793    fn non_hex_character_is_refused_and_named() {
794        let bad = format!("{}z{}", &H[..5], &H[6..]);
795        match parse(&bad).unwrap_err() {
796            CauseParseError::NonHexChar { ch, position } => {
797                assert_eq!(ch, 'z');
798                assert_eq!(position, 5);
799            }
800            other => panic!("expected NonHexChar, got {other:?}"),
801        }
802        assert!(parse(&bad).unwrap_err().to_string().contains("'z'"));
803
804        // Non-ASCII counts too, and the byte position is reported.
805        let uni = format!("{}é{}", &H[..3], &H[5..]); // 'é' is 2 bytes → keeps len 64
806        assert!(matches!(parse(&uni), Err(CauseParseError::NonHexChar { ch: 'é', .. })));
807    }
808
809    #[test]
810    fn empty_input_is_its_own_error() {
811        assert_eq!(parse("").unwrap_err(), CauseParseError::Empty);
812        assert!(parse("").unwrap_err().to_string().contains("empty"));
813    }
814
815    #[test]
816    fn qualified_with_bad_hash_reports_the_hash_not_the_store() {
817        let e = parse("abc@main").unwrap_err();
818        assert_eq!(e, CauseParseError::BadHashLength { got: 3, expected: 64 });
819    }
820
821    // --- constraint 5: store id rules --------------------------------------
822
823    #[test]
824    fn empty_store_id_is_refused() {
825        assert_eq!(StoreId::new("").unwrap_err(), StoreIdError::Empty);
826        // And through the parser: a trailing separator with nothing after it.
827        assert_eq!(
828            parse(&format!("{H}@")).unwrap_err(),
829            CauseParseError::BadStoreId(StoreIdError::Empty)
830        );
831    }
832
833    #[test]
834    fn oversized_store_id_is_refused() {
835        let big = "a".repeat(MAX_STORE_ID_LEN + 1);
836        assert_eq!(
837            StoreId::new(&big).unwrap_err(),
838            StoreIdError::TooLong { len: MAX_STORE_ID_LEN + 1, max: MAX_STORE_ID_LEN }
839        );
840        // Exactly at the bound is fine.
841        assert!(StoreId::new("a".repeat(MAX_STORE_ID_LEN)).is_ok());
842        assert!(matches!(
843            parse(&format!("{H}@{big}")),
844            Err(CauseParseError::BadStoreId(StoreIdError::TooLong { .. }))
845        ));
846    }
847
848    #[test]
849    fn whitespace_in_store_id_is_refused() {
850        for (bad, pos) in [("main branch", 4), (" main", 0), ("main\t", 4), ("main\n", 4)] {
851            match StoreId::new(bad).unwrap_err() {
852                StoreIdError::InvalidChar { ch, position } => {
853                    assert_eq!(position, pos, "for {bad:?}");
854                    assert!(ch.is_whitespace(), "for {bad:?}");
855                }
856                other => panic!("expected InvalidChar for {bad:?}, got {other:?}"),
857            }
858        }
859    }
860
861    #[test]
862    fn path_unsafe_and_exotic_store_ids_are_refused_naming_the_character() {
863        for bad in ["a/b", "a\\b", "a:b", "a#b", "a?b", "a%b", "a*b", "a\0b", "brânch"] {
864            let e = StoreId::new(bad).unwrap_err();
865            match e {
866                StoreIdError::InvalidChar { ch, .. } => {
867                    assert!(
868                        e.to_string().contains(&format!("{ch:?}")),
869                        "message must name the offending character for {bad:?}"
870                    );
871                }
872                other => panic!("expected InvalidChar for {bad:?}, got {other:?}"),
873            }
874        }
875    }
876
877    #[test]
878    fn dot_store_ids_are_reserved_but_dots_inside_ids_are_fine() {
879        assert_eq!(StoreId::new(".").unwrap_err(), StoreIdError::Reserved { id: ".".into() });
880        assert_eq!(StoreId::new("..").unwrap_err(), StoreIdError::Reserved { id: "..".into() });
881        assert_eq!(StoreId::new("team.alpha").unwrap().as_str(), "team.alpha");
882        assert_eq!(StoreId::new("...").unwrap().as_str(), "...");
883    }
884
885    // --- constraint 6: typed, informative errors ---------------------------
886
887    #[test]
888    fn every_error_variant_has_a_distinct_informative_message() {
889        let errs = vec![
890            CauseParseError::Empty,
891            CauseParseError::BadHashLength { got: 63, expected: 64 },
892            CauseParseError::NonHexChar { ch: 'z', position: 5 },
893            CauseParseError::UppercaseHex { ch: 'F', position: 1 },
894            CauseParseError::BadStoreId(StoreIdError::Empty),
895            CauseParseError::BadStoreId(StoreIdError::TooLong { len: 99, max: 64 }),
896            CauseParseError::BadStoreId(StoreIdError::ContainsSeparator { position: 2 }),
897            CauseParseError::BadStoreId(StoreIdError::InvalidChar { ch: '/', position: 1 }),
898            CauseParseError::BadStoreId(StoreIdError::Reserved { id: "..".into() }),
899            CauseParseError::TooManySeparators { count: 2, input: "a@b@c".into() },
900        ];
901        let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
902        for (i, m) in msgs.iter().enumerate() {
903            assert!(!m.is_empty());
904            for (j, n) in msgs.iter().enumerate() {
905                if i != j {
906                    assert_ne!(m, n, "error messages must be distinguishable");
907                }
908            }
909        }
910        // Debug is derived and useful; source() chains for the nested case.
911        assert!(format!("{:?}", errs[1]).contains("BadHashLength"));
912        use std::error::Error as _;
913        assert!(errs[4].source().is_some());
914        assert!(errs[0].source().is_none());
915    }
916
917    // --- constraint 7: serde ------------------------------------------------
918
919    #[test]
920    fn vec_of_causes_is_a_json_array_of_plain_strings() {
921        let causes = vec![
922            Cause::local(H).unwrap(),
923            Cause::qualified(StoreId::new("branch-x").unwrap(), hash_n(1)).unwrap(),
924            Cause::local(hash_n(2)).unwrap(),
925        ];
926        let json = serde_json::to_string(&causes).unwrap();
927        assert_eq!(
928            json,
929            format!(
930                "[\"{}\",\"{}@branch-x\",\"{}\"]",
931                H,
932                hash_n(1),
933                hash_n(2)
934            )
935        );
936
937        // It is an array of strings by the parser's own reckoning, not just by
938        // eyeballing the text.
939        let as_values: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
940        assert!(as_values.iter().all(|v| v.is_string()));
941
942        let back: Vec<Cause> = serde_json::from_str(&json).unwrap();
943        assert_eq!(back, causes);
944    }
945
946    #[test]
947    fn caused_by_is_wire_compatible_with_vec_string() {
948        // The storage-format claim, tested: a Vec<Cause> of legacy values
949        // encodes to exactly the same JSON as the Vec<String> it replaces.
950        let legacy: Vec<String> = (0..5u8).map(hash_n).collect();
951        let as_causes: Vec<Cause> = legacy.iter().map(|h| Cause::local(h).unwrap()).collect();
952        assert_eq!(
953            serde_json::to_string(&as_causes).unwrap(),
954            serde_json::to_string(&legacy).unwrap()
955        );
956        // And a legacy JSON array reads straight back into Vec<Cause>.
957        let from_legacy: Vec<Cause> =
958            serde_json::from_str(&serde_json::to_string(&legacy).unwrap()).unwrap();
959        assert_eq!(from_legacy, as_causes);
960    }
961
962    #[test]
963    fn deserialising_garbage_fails_with_the_parse_diagnosis() {
964        let err = serde_json::from_str::<Cause>("\"nope\"").unwrap_err().to_string();
965        assert!(err.contains("expected exactly 64"), "got: {err}");
966
967        let err = serde_json::from_str::<Vec<Cause>>(&format!("[\"{}\"]", H.to_uppercase()))
968            .unwrap_err()
969            .to_string();
970        assert!(err.contains("uppercase"), "got: {err}");
971
972        // A tagged object is not a cause; only strings are.
973        assert!(serde_json::from_str::<Cause>("{\"Local\":\"x\"}").is_err());
974        assert!(serde_json::from_str::<Cause>("42").is_err());
975    }
976
977    #[test]
978    fn store_id_serde_round_trips_and_validates() {
979        let s = StoreId::new("branch-x").unwrap();
980        let json = serde_json::to_string(&s).unwrap();
981        assert_eq!(json, "\"branch-x\"");
982        assert_eq!(serde_json::from_str::<StoreId>(&json).unwrap(), s);
983        assert!(serde_json::from_str::<StoreId>("\"bad id\"").is_err());
984    }
985
986    // --- target_store --------------------------------------------------------
987
988    #[test]
989    fn target_store_resolves_local_to_reader_and_qualified_to_itself() {
990        let local = Cause::local(H).unwrap();
991        assert_eq!(target_store(&local, "main"), "main");
992        assert_eq!(target_store(&local, "some-other-store"), "some-other-store");
993
994        let q = Cause::qualified(StoreId::new("branch-x").unwrap(), H).unwrap();
995        assert_eq!(target_store(&q, "main"), "branch-x");
996        // The reading store is irrelevant for a qualified cause — that is the
997        // whole guarantee: a merge edge resolves the same from anywhere.
998        assert_eq!(target_store(&q, "branch-x"), "branch-x");
999        assert_eq!(target_store(&q, "anything-at-all"), "branch-x");
1000    }
1001
1002    // --- misc API surface ----------------------------------------------------
1003
1004    #[test]
1005    fn constructors_validate_and_accessors_agree() {
1006        assert!(Cause::local("short").is_err());
1007        assert!(Cause::qualified(StoreId::new("s").unwrap(), "short").is_err());
1008
1009        let q = Cause::qualified(StoreId::new("s").unwrap(), H).unwrap();
1010        assert_eq!(q.hash(), H);
1011        assert_eq!(q.store().unwrap().as_str(), "s");
1012        assert!(q.is_qualified());
1013        assert_eq!(q.render(), render(&q));
1014
1015        // FromStr / TryFrom / Into<String> all agree with parse/render.
1016        use std::str::FromStr as _;
1017        assert_eq!(Cause::from_str(H).unwrap(), Cause::local(H).unwrap());
1018        assert_eq!(Cause::try_from(H.to_string()).unwrap(), Cause::local(H).unwrap());
1019        assert_eq!(String::from(q.clone()), render(&q));
1020        assert_eq!(StoreId::from_str("ok").unwrap().into_string(), "ok");
1021        assert_eq!(StoreId::new("ok").unwrap().as_ref() as &str, "ok");
1022        assert_eq!(StoreId::new("ok").unwrap().to_string(), "ok");
1023    }
1024}