Skip to main content

amiss_wire/
model.rs

1#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
2pub struct RepoPath(String);
3
4impl RepoPath {
5    #[must_use]
6    pub fn new(raw: String) -> Option<Self> {
7        if raw.is_empty() || raw.len() > 4096 || raw.contains(['\\', '\u{0}']) {
8            return None;
9        }
10        if raw
11            .split('/')
12            .any(|segment| segment.is_empty() || segment == "." || segment == "..")
13        {
14            return None;
15        }
16        Some(Self(raw))
17    }
18
19    #[must_use]
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23}
24
25#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
26pub struct ArtifactId(String);
27
28impl ArtifactId {
29    #[must_use]
30    pub fn new(raw: String) -> Option<Self> {
31        let mut bytes = raw.bytes();
32        let first = bytes.next()?;
33        if raw.len() > 128 || !first.is_ascii_lowercase() && !first.is_ascii_digit() {
34            return None;
35        }
36        if bytes.all(id_tail_byte) {
37            Some(Self(raw))
38        } else {
39            None
40        }
41    }
42
43    #[must_use]
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47}
48
49#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
50pub struct OwnerId(String);
51
52impl OwnerId {
53    #[must_use]
54    pub fn new(raw: String) -> Option<Self> {
55        if raw.len() > 160 {
56            return None;
57        }
58        let suffix = ["team:", "service:", "user:"]
59            .iter()
60            .find_map(|prefix| raw.strip_prefix(prefix))?;
61        let mut bytes = suffix.bytes();
62        let first = bytes.next()?;
63        if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
64            return None;
65        }
66        if bytes.all(id_tail_byte) {
67            Some(Self(raw))
68        } else {
69            None
70        }
71    }
72
73    #[must_use]
74    pub fn as_str(&self) -> &str {
75        &self.0
76    }
77}
78
79fn id_tail_byte(byte: u8) -> bool {
80    byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'/' | b'-')
81}
82
83/// Whole-second UTC instant; the fixed-width form makes lexicographic order
84/// chronological, so ordering derives from the raw string.
85#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
86pub struct UtcInstant(String);
87
88impl UtcInstant {
89    #[must_use]
90    pub fn new(raw: String) -> Option<Self> {
91        let bytes = raw.as_bytes();
92        if bytes.len() != 20 {
93            return None;
94        }
95        for (index, byte) in bytes.iter().enumerate() {
96            let expected_digit = !matches!(index, 4 | 7 | 10 | 13 | 16 | 19);
97            if expected_digit != byte.is_ascii_digit() {
98                return None;
99            }
100        }
101        if bytes.get(4) != Some(&b'-')
102            || bytes.get(7) != Some(&b'-')
103            || bytes.get(10) != Some(&b'T')
104            || bytes.get(13) != Some(&b':')
105            || bytes.get(16) != Some(&b':')
106            || bytes.get(19) != Some(&b'Z')
107        {
108            return None;
109        }
110        let year = field(bytes, 0, 4)?;
111        let month = field(bytes, 5, 2)?;
112        let day = field(bytes, 8, 2)?;
113        let hour = field(bytes, 11, 2)?;
114        let minute = field(bytes, 14, 2)?;
115        let second = field(bytes, 17, 2)?;
116        if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
117            return None;
118        }
119        if hour > 23 || minute > 59 || second > 59 {
120            return None;
121        }
122        Some(Self(raw))
123    }
124
125    #[must_use]
126    pub fn as_str(&self) -> &str {
127        &self.0
128    }
129
130    /// Whole seconds since 1970-01-01T00:00:00Z, computed from the already
131    /// validated calendar fields with the days-from-civil identity.
132    #[expect(
133        clippy::arithmetic_side_effects,
134        reason = "every field is bounded by the validated 20-byte format, so all terms stay far inside i64"
135    )]
136    #[must_use]
137    pub fn epoch_seconds(&self) -> i64 {
138        let bytes = self.0.as_bytes();
139        let part = |start: usize, len: usize| i64::from(field(bytes, start, len).unwrap_or(0));
140        let year = part(0, 4);
141        let month = part(5, 2);
142        let day = part(8, 2);
143        let shifted_year = if month <= 2 { year - 1 } else { year };
144        let era = shifted_year.div_euclid(400);
145        let year_of_era = shifted_year - era * 400;
146        let month_index = if month > 2 { month - 3 } else { month + 9 };
147        let day_of_year = (153 * month_index + 2) / 5 + day - 1;
148        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
149        let days = era * 146_097 + day_of_era - 719_468;
150        days * 86_400 + part(11, 2) * 3_600 + part(14, 2) * 60 + part(17, 2)
151    }
152}
153
154fn field(bytes: &[u8], start: usize, len: usize) -> Option<u32> {
155    let end = start.checked_add(len)?;
156    bytes.get(start..end)?.iter().try_fold(0_u32, |acc, byte| {
157        let digit = u32::from(byte.wrapping_sub(b'0'));
158        acc.checked_mul(10)?.checked_add(digit)
159    })
160}
161
162fn days_in_month(year: u32, month: u32) -> u32 {
163    let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
164    match month {
165        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
166        4 | 6 | 9 | 11 => 30,
167        2 if leap => 29,
168        2 => 28,
169        _ => 0,
170    }
171}
172
173/// Full branch ref under the frozen `ref-format-v1` contract.
174#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
175pub struct BranchRef(String);
176
177impl BranchRef {
178    #[must_use]
179    #[expect(
180        clippy::case_sensitive_file_extension_comparisons,
181        reason = "ref-format-v1 component rules are byte-exact"
182    )]
183    pub fn new(raw: String) -> Option<Self> {
184        if raw.len() > 266 {
185            return None;
186        }
187        let suffix = raw.strip_prefix("refs/heads/")?;
188        if suffix.is_empty() || suffix.contains("..") || suffix.contains("@{") {
189            return None;
190        }
191        if suffix.bytes().any(|b| {
192            b < 0x20
193                || b == 0x7f
194                || matches!(b, b' ' | b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
195        }) {
196            return None;
197        }
198        if suffix.ends_with('.') {
199            return None;
200        }
201        let components_ok = suffix
202            .split('/')
203            .all(|c| !c.is_empty() && !c.starts_with('.') && !c.ends_with(".lock"));
204        if components_ok { Some(Self(raw)) } else { None }
205    }
206
207    #[must_use]
208    pub fn as_str(&self) -> &str {
209        &self.0
210    }
211}
212
213#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
214pub struct RepositoryIdentity {
215    pub owner: String,
216    pub name: String,
217}
218
219impl RepositoryIdentity {
220    #[must_use]
221    pub fn new(owner: String, name: String) -> Option<Self> {
222        let owner_bytes = owner.as_bytes();
223        let owner_ok = (1..=100).contains(&owner_bytes.len())
224            && owner_bytes.iter().copied().all(identity_byte)
225            && owner_bytes.first().is_some_and(u8::is_ascii_alphanumeric)
226            && owner_bytes.last().is_some_and(u8::is_ascii_alphanumeric);
227        let name_ok = (1..=100).contains(&name.len())
228            && name.bytes().all(identity_byte)
229            && name != "."
230            && name != "..";
231        if owner_ok && name_ok {
232            Some(Self { owner, name })
233        } else {
234            None
235        }
236    }
237}
238
239fn identity_byte(byte: u8) -> bool {
240    byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
241}
242
243#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
244pub enum ObjectFormat {
245    Sha1,
246    Sha256,
247}
248
249impl ObjectFormat {
250    #[must_use]
251    pub const fn as_str(self) -> &'static str {
252        match self {
253            Self::Sha1 => "sha1",
254            Self::Sha256 => "sha256",
255        }
256    }
257}
258
259#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
260pub struct TreeIdentity {
261    pub object_format: ObjectFormat,
262    pub tree_oid: String,
263}
264
265impl TreeIdentity {
266    #[must_use]
267    pub fn new(object_format: ObjectFormat, tree_oid: String) -> Option<Self> {
268        if oid_hex(object_format, &tree_oid) {
269            Some(Self {
270                object_format,
271                tree_oid,
272            })
273        } else {
274            None
275        }
276    }
277}
278
279/// Full lowercase object ID for one declared object format.
280#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
281pub struct Oid(String);
282
283impl Oid {
284    #[must_use]
285    pub fn new(object_format: ObjectFormat, raw: String) -> Option<Self> {
286        if oid_hex(object_format, &raw) {
287            Some(Self(raw))
288        } else {
289            None
290        }
291    }
292
293    #[must_use]
294    pub fn as_str(&self) -> &str {
295        &self.0
296    }
297}
298
299fn oid_hex(object_format: ObjectFormat, raw: &str) -> bool {
300    let expected = match object_format {
301        ObjectFormat::Sha1 => 40,
302        ObjectFormat::Sha256 => 64,
303    };
304    raw.len() == expected
305        && raw
306            .bytes()
307            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
308}
309
310/// The three closed source adapters. Every wire string an adapter contributes
311/// (identity, grammar profile, frontmatter contract, projection, address
312/// scheme) is frozen here so no call site can spell one by hand.
313#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
314pub enum Adapter {
315    Markdown,
316    Mdx,
317    PlainAdvisory,
318}
319
320impl Adapter {
321    pub const ALL: [Self; 3] = [Self::Markdown, Self::Mdx, Self::PlainAdvisory];
322
323    #[must_use]
324    pub const fn adapter_id(self) -> &'static str {
325        match self {
326            Self::Markdown => "markdown-v1",
327            Self::Mdx => "mdx-v1",
328            Self::PlainAdvisory => "plain-advisory-v1",
329        }
330    }
331
332    #[must_use]
333    pub const fn parser_name(self) -> &'static str {
334        match self {
335            Self::Markdown => "amiss-markdown-adapter",
336            Self::Mdx => "amiss-mdx-adapter",
337            Self::PlainAdvisory => "amiss-plain-advisory",
338        }
339    }
340
341    #[must_use]
342    pub const fn grammar_profile(self) -> &'static str {
343        match self {
344            Self::Markdown => "commonmark-gfm-v1",
345            Self::Mdx => "mdx-source-v1",
346            Self::PlainAdvisory => "plain-zero-lexer-v1",
347        }
348    }
349
350    #[must_use]
351    pub const fn frontmatter_contract(self) -> &'static str {
352        match self {
353            Self::Markdown | Self::Mdx => "frontmatter-v1",
354            Self::PlainAdvisory => "none",
355        }
356    }
357
358    #[must_use]
359    pub const fn source_projection(self) -> &'static str {
360        match self {
361            Self::Markdown | Self::Mdx => "source-projection-v1",
362            Self::PlainAdvisory => "none",
363        }
364    }
365
366    #[must_use]
367    pub const fn structural_address(self) -> &'static str {
368        match self {
369            Self::Markdown => "markdown-ast-node-path",
370            Self::Mdx => "mdx-ast-node-path",
371            Self::PlainAdvisory => "none",
372        }
373    }
374}