Skip to main content

rootle_provider/
id.rs

1//! Typed identity for the provider seam (plans/0025): the three
2//! nouns that cross every boundary — which repo, which content, which
3//! revision — as opaque newtypes instead of naked `String`s. The bug
4//! classes this kills: a sha where a ref was meant, a ref where a
5//! repo id was meant, a repo id formatted differently at two sites.
6//!
7//! Wire discipline: the protocol is stringly by design (reader
8//! tolerance, opaque ids); these types are the *Rust* vocabulary.
9//! They are `#[serde(transparent)]` so wire models can adopt them
10//! without format changes. Millers-pane structure (separate
11//! owner/name) is a UI concern and stays untyped — the joined id is
12//! minted where the seam begins.
13
14use serde::{Deserialize, Serialize};
15
16macro_rules! opaque_id {
17    ($name:ident, $doc:literal) => {
18        #[doc = $doc]
19        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
20        #[serde(transparent)]
21        pub struct $name(String);
22
23        impl $name {
24            /// The wire/UI form, verbatim.
25            pub fn as_str(&self) -> &str {
26                &self.0
27            }
28        }
29
30        impl std::fmt::Display for $name {
31            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32                f.write_str(&self.0)
33            }
34        }
35
36        impl From<String> for $name {
37            fn from(value: String) -> Self {
38                $name(value)
39            }
40        }
41
42        impl From<&str> for $name {
43            fn from(value: &str) -> Self {
44                $name(value.to_string())
45            }
46        }
47
48        impl AsRef<str> for $name {
49            fn as_ref(&self) -> &str {
50                &self.0
51            }
52        }
53    };
54}
55
56opaque_id!(
57    RepoId,
58    "Opaque `\"group/project\"` repo identity (protocol: the UI never\n\
59     parses it; each backend owns its grammar)."
60);
61
62opaque_id!(
63    Sha,
64    "Opaque content id: MUST change when content changes (the cache\n\
65     is content-keyed and immutable). A commit sha on backends that\n\
66     have them; whatever the adapter guarantees otherwise."
67);
68
69opaque_id!(
70    GitRef,
71    "A resolvable revision name — branch, tag, or commit sha — as the\n\
72     backend interprets it (`repo/tree`'s `ref` param). `None` at a\n\
73     call site means the default branch; this type never encodes it."
74);
75
76opaque_id!(
77    RepoPath,
78    "An opaque repository-relative path, not a host filesystem path."
79);
80opaque_id!(
81    OrgId,
82    "An opaque organization or group identity, independent of its displayed caption."
83);
84opaque_id!(
85    OwnerId,
86    "An opaque repository namespace; it does not imply a personal account or organization."
87);
88
89impl Sha {
90    pub fn short(&self) -> String {
91        self.as_str().chars().take(7).collect()
92    }
93}
94
95/// Domain-tagged request generation. Different pipelines cannot compare
96/// clocks, even though both happen to be represented by an integer.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98pub struct Generation<Domain> {
99    value: u64,
100    domain: std::marker::PhantomData<Domain>,
101}
102
103impl<Domain> Default for Generation<Domain> {
104    fn default() -> Self {
105        Self {
106            value: 0,
107            domain: std::marker::PhantomData,
108        }
109    }
110}
111
112impl<Domain: Copy + PartialEq> Generation<Domain> {
113    pub fn tick(&mut self) -> Self {
114        self.value = self
115            .value
116            .checked_add(1)
117            .expect("request generation exhausted");
118        *self
119    }
120
121    pub fn is_current(&self, other: Self) -> bool {
122        *self == other
123    }
124}
125
126impl<Domain> std::fmt::Display for Generation<Domain> {
127    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        self.value.fmt(formatter)
129    }
130}
131
132/// Diagnostic/wire serialization exposes the count without weakening the
133/// domain-typed comparison API or round-tripping through Display.
134impl<Domain> serde::Serialize for Generation<Domain> {
135    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
136        serializer.serialize_u64(self.value)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn generation_guards_drop_stale_captures() {
146        let mut clock = Generation::<()>::default();
147        let spawn = clock.tick();
148        assert!(clock.is_current(spawn));
149        let _newer = clock.tick();
150        assert!(!clock.is_current(spawn));
151    }
152}