Skip to main content

amiss_wire/
model.rs

1use crate::json::Value;
2
3/// A repository path whose bytes are valid UTF-8, mirroring the schema's
4/// `RepoPathText`: the form every configuration surface is confined to.
5#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
6pub struct RepoPathText(String);
7
8impl RepoPathText {
9    #[must_use]
10    pub fn new(raw: String) -> Option<Self> {
11        if path_bytes_valid(raw.as_bytes()) {
12            Some(Self(raw))
13        } else {
14            None
15        }
16    }
17
18    #[must_use]
19    pub fn as_str(&self) -> &str {
20        &self.0
21    }
22}
23
24/// A repository path as the snapshot names it, mirroring the schema's
25/// `RepoPath` union: text when the raw bytes are valid UTF-8, and the bytes
26/// themselves otherwise. Construction classifies, so one logical path has
27/// exactly one representation and a digest can never split across forms.
28#[derive(Clone, Debug)]
29pub struct RepoPath(Repr);
30
31#[derive(Clone, Debug)]
32enum Repr {
33    Text(String),
34    Bytes(Vec<u8>),
35}
36
37impl RepoPath {
38    /// The primary constructor: validates the byte grammar, then holds the
39    /// path as text exactly when the bytes decode as UTF-8.
40    #[must_use]
41    pub fn from_bytes(raw: Vec<u8>) -> Option<Self> {
42        if !path_bytes_valid(&raw) {
43            return None;
44        }
45        match String::from_utf8(raw) {
46            Ok(text) => Some(Self(Repr::Text(text))),
47            Err(invalid) => Some(Self(Repr::Bytes(invalid.into_bytes()))),
48        }
49    }
50
51    #[must_use]
52    pub fn new(raw: String) -> Option<Self> {
53        Self::from_bytes(raw.into_bytes())
54    }
55
56    #[must_use]
57    pub fn as_bytes(&self) -> &[u8] {
58        match &self.0 {
59            Repr::Text(text) => text.as_bytes(),
60            Repr::Bytes(bytes) => bytes,
61        }
62    }
63
64    #[must_use]
65    pub fn as_str(&self) -> Option<&str> {
66        match &self.0 {
67            Repr::Text(text) => Some(text),
68            Repr::Bytes(_) => None,
69        }
70    }
71
72    /// The wire form: a plain string for text, byte-identical to the first
73    /// contract, and the `bytes_hex` object for a path text cannot hold.
74    #[must_use]
75    pub fn to_value(&self) -> Value {
76        match &self.0 {
77            Repr::Text(text) => Value::String(text.clone()),
78            Repr::Bytes(bytes) => Value::Object(vec![(
79                "bytes_hex".to_owned(),
80                Value::String(hex_lower(bytes)),
81            )]),
82        }
83    }
84}
85
86/// Text-form paths embed without revalidation: both types enforce the one
87/// byte grammar, and a `String` is UTF-8 by construction.
88impl From<&RepoPathText> for RepoPath {
89    fn from(text: &RepoPathText) -> Self {
90        Self(Repr::Text(text.as_str().to_owned()))
91    }
92}
93
94/// Map queries run on raw bytes, because a range boundary such as `path/`
95/// is not itself a valid path. Sound: ordering and equality are the byte
96/// forms already.
97impl std::borrow::Borrow<[u8]> for RepoPath {
98    fn borrow(&self) -> &[u8] {
99        self.as_bytes()
100    }
101}
102
103impl PartialEq for RepoPath {
104    fn eq(&self, other: &Self) -> bool {
105        self.as_bytes() == other.as_bytes()
106    }
107}
108
109impl Eq for RepoPath {}
110
111// derived ordering would sort by variant before content
112impl Ord for RepoPath {
113    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
114        self.as_bytes().cmp(other.as_bytes())
115    }
116}
117
118impl PartialOrd for RepoPath {
119    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
120        Some(self.cmp(other))
121    }
122}
123
124fn path_bytes_valid(raw: &[u8]) -> bool {
125    if raw.is_empty() || raw.len() > 4096 || raw.contains(&0) || raw.contains(&b'\\') {
126        return false;
127    }
128    !raw.split(|byte| *byte == b'/')
129        .any(|segment| segment.is_empty() || segment == b"." || segment == b"..")
130}
131
132pub(crate) fn hex_lower(bytes: &[u8]) -> String {
133    let mut out = String::with_capacity(bytes.len().saturating_mul(2));
134    for byte in bytes {
135        let _infallible = std::fmt::Write::write_fmt(&mut out, format_args!("{byte:02x}"));
136    }
137    out
138}
139
140#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
141pub struct ArtifactId(String);
142
143impl ArtifactId {
144    #[must_use]
145    pub fn new(raw: String) -> Option<Self> {
146        let mut bytes = raw.bytes();
147        let first = bytes.next()?;
148        if raw.len() > 128 || !first.is_ascii_lowercase() && !first.is_ascii_digit() {
149            return None;
150        }
151        if bytes.all(id_tail_byte) {
152            Some(Self(raw))
153        } else {
154            None
155        }
156    }
157
158    #[must_use]
159    pub fn as_str(&self) -> &str {
160        &self.0
161    }
162}
163
164#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
165pub struct OwnerId(String);
166
167impl OwnerId {
168    #[must_use]
169    pub fn new(raw: String) -> Option<Self> {
170        if raw.len() > 160 {
171            return None;
172        }
173        let suffix = ["team:", "service:", "user:"]
174            .iter()
175            .find_map(|prefix| raw.strip_prefix(prefix))?;
176        let mut bytes = suffix.bytes();
177        let first = bytes.next()?;
178        if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
179            return None;
180        }
181        if bytes.all(id_tail_byte) {
182            Some(Self(raw))
183        } else {
184            None
185        }
186    }
187
188    #[must_use]
189    pub fn as_str(&self) -> &str {
190        &self.0
191    }
192}
193
194fn id_tail_byte(byte: u8) -> bool {
195    byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'/' | b'-')
196}
197
198/// Whole-second UTC instant; the fixed-width form makes lexicographic order
199/// chronological, so ordering derives from the raw string.
200#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
201pub struct UtcInstant(String);
202
203impl UtcInstant {
204    #[must_use]
205    pub fn new(raw: String) -> Option<Self> {
206        let bytes = raw.as_bytes();
207        if bytes.len() != 20 {
208            return None;
209        }
210        for (index, byte) in bytes.iter().enumerate() {
211            let expected_digit = !matches!(index, 4 | 7 | 10 | 13 | 16 | 19);
212            if expected_digit != byte.is_ascii_digit() {
213                return None;
214            }
215        }
216        if bytes.get(4) != Some(&b'-')
217            || bytes.get(7) != Some(&b'-')
218            || bytes.get(10) != Some(&b'T')
219            || bytes.get(13) != Some(&b':')
220            || bytes.get(16) != Some(&b':')
221            || bytes.get(19) != Some(&b'Z')
222        {
223            return None;
224        }
225        let year = field(bytes, 0, 4)?;
226        let month = field(bytes, 5, 2)?;
227        let day = field(bytes, 8, 2)?;
228        let hour = field(bytes, 11, 2)?;
229        let minute = field(bytes, 14, 2)?;
230        let second = field(bytes, 17, 2)?;
231        if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
232            return None;
233        }
234        if hour > 23 || minute > 59 || second > 59 {
235            return None;
236        }
237        Some(Self(raw))
238    }
239
240    #[must_use]
241    pub fn as_str(&self) -> &str {
242        &self.0
243    }
244
245    /// Whole seconds since 1970-01-01T00:00:00Z, computed from the already
246    /// validated calendar fields with the days-from-civil identity.
247    #[expect(
248        clippy::arithmetic_side_effects,
249        reason = "every field is bounded by the validated 20-byte format, so all terms stay far inside i64"
250    )]
251    #[must_use]
252    pub fn epoch_seconds(&self) -> i64 {
253        let bytes = self.0.as_bytes();
254        let part = |start: usize, len: usize| i64::from(field(bytes, start, len).unwrap_or(0));
255        let year = part(0, 4);
256        let month = part(5, 2);
257        let day = part(8, 2);
258        let shifted_year = if month <= 2 { year - 1 } else { year };
259        let era = shifted_year.div_euclid(400);
260        let year_of_era = shifted_year - era * 400;
261        let month_index = if month > 2 { month - 3 } else { month + 9 };
262        let day_of_year = (153 * month_index + 2) / 5 + day - 1;
263        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
264        let days = era * 146_097 + day_of_era - 719_468;
265        days * 86_400 + part(11, 2) * 3_600 + part(14, 2) * 60 + part(17, 2)
266    }
267}
268
269fn field(bytes: &[u8], start: usize, len: usize) -> Option<u32> {
270    let end = start.checked_add(len)?;
271    bytes.get(start..end)?.iter().try_fold(0_u32, |acc, byte| {
272        let digit = u32::from(byte.wrapping_sub(b'0'));
273        acc.checked_mul(10)?.checked_add(digit)
274    })
275}
276
277fn days_in_month(year: u32, month: u32) -> u32 {
278    let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
279    match month {
280        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
281        4 | 6 | 9 | 11 => 30,
282        2 if leap => 29,
283        2 => 28,
284        _ => 0,
285    }
286}
287
288/// Full branch ref under the frozen `ref-format-v1` contract.
289#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
290pub struct BranchRef(String);
291
292impl BranchRef {
293    #[must_use]
294    #[expect(
295        clippy::case_sensitive_file_extension_comparisons,
296        reason = "ref-format-v1 component rules are byte-exact"
297    )]
298    pub fn new(raw: String) -> Option<Self> {
299        if raw.len() > 266 {
300            return None;
301        }
302        let suffix = raw.strip_prefix("refs/heads/")?;
303        if suffix.is_empty() || suffix.contains("..") || suffix.contains("@{") {
304            return None;
305        }
306        if suffix.bytes().any(|b| {
307            b < 0x20
308                || b == 0x7f
309                || matches!(b, b' ' | b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
310        }) {
311            return None;
312        }
313        if suffix.ends_with('.') {
314            return None;
315        }
316        let components_ok = suffix
317            .split('/')
318            .all(|c| !c.is_empty() && !c.starts_with('.') && !c.ends_with(".lock"));
319        if components_ok { Some(Self(raw)) } else { None }
320    }
321
322    #[must_use]
323    pub fn as_str(&self) -> &str {
324        &self.0
325    }
326}
327
328#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
329pub struct RepositoryIdentity {
330    pub owner: String,
331    pub name: String,
332}
333
334impl RepositoryIdentity {
335    #[must_use]
336    pub fn new(owner: String, name: String) -> Option<Self> {
337        let owner_bytes = owner.as_bytes();
338        let owner_ok = (1..=100).contains(&owner_bytes.len())
339            && owner_bytes.iter().copied().all(identity_byte)
340            && owner_bytes.first().is_some_and(u8::is_ascii_alphanumeric)
341            && owner_bytes.last().is_some_and(u8::is_ascii_alphanumeric);
342        let name_ok = (1..=100).contains(&name.len())
343            && name.bytes().all(identity_byte)
344            && name != "."
345            && name != "..";
346        if owner_ok && name_ok {
347            Some(Self { owner, name })
348        } else {
349            None
350        }
351    }
352}
353
354fn identity_byte(byte: u8) -> bool {
355    byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
356}
357
358#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
359pub enum ObjectFormat {
360    Sha1,
361    Sha256,
362}
363
364impl ObjectFormat {
365    #[must_use]
366    pub const fn as_str(self) -> &'static str {
367        match self {
368            Self::Sha1 => "sha1",
369            Self::Sha256 => "sha256",
370        }
371    }
372}
373
374#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
375pub struct TreeIdentity {
376    pub object_format: ObjectFormat,
377    pub tree_oid: String,
378}
379
380impl TreeIdentity {
381    #[must_use]
382    pub fn new(object_format: ObjectFormat, tree_oid: String) -> Option<Self> {
383        if oid_hex(object_format, &tree_oid) {
384            Some(Self {
385                object_format,
386                tree_oid,
387            })
388        } else {
389            None
390        }
391    }
392}
393
394/// Full lowercase object ID for one declared object format.
395#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
396pub struct Oid(String);
397
398impl Oid {
399    #[must_use]
400    pub fn new(object_format: ObjectFormat, raw: String) -> Option<Self> {
401        if oid_hex(object_format, &raw) {
402            Some(Self(raw))
403        } else {
404            None
405        }
406    }
407
408    #[must_use]
409    pub fn as_str(&self) -> &str {
410        &self.0
411    }
412}
413
414fn oid_hex(object_format: ObjectFormat, raw: &str) -> bool {
415    let expected = match object_format {
416        ObjectFormat::Sha1 => 40,
417        ObjectFormat::Sha256 => 64,
418    };
419    raw.len() == expected
420        && raw
421            .bytes()
422            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
423}
424
425/// The three closed source adapters. Every wire string an adapter contributes
426/// (identity, grammar profile, frontmatter contract, projection, address
427/// scheme) is frozen here so no call site can spell one by hand.
428#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
429pub enum Adapter {
430    Markdown,
431    Mdx,
432    PlainAdvisory,
433}
434
435impl Adapter {
436    pub const ALL: [Self; 3] = [Self::Markdown, Self::Mdx, Self::PlainAdvisory];
437
438    #[must_use]
439    pub const fn adapter_id(self) -> &'static str {
440        match self {
441            Self::Markdown => "markdown-v1",
442            Self::Mdx => "mdx-v1",
443            Self::PlainAdvisory => "plain-advisory-v1",
444        }
445    }
446
447    #[must_use]
448    pub const fn parser_name(self) -> &'static str {
449        match self {
450            Self::Markdown => "amiss-markdown-adapter",
451            Self::Mdx => "amiss-mdx-adapter",
452            Self::PlainAdvisory => "amiss-plain-advisory",
453        }
454    }
455
456    #[must_use]
457    pub const fn grammar_profile(self) -> &'static str {
458        match self {
459            Self::Markdown => "commonmark-gfm-v1",
460            Self::Mdx => "mdx-source-v1",
461            Self::PlainAdvisory => "plain-zero-lexer-v1",
462        }
463    }
464
465    #[must_use]
466    pub const fn frontmatter_contract(self) -> &'static str {
467        match self {
468            Self::Markdown | Self::Mdx => "frontmatter-v1",
469            Self::PlainAdvisory => "none",
470        }
471    }
472
473    #[must_use]
474    pub const fn source_projection(self) -> &'static str {
475        match self {
476            Self::Markdown | Self::Mdx => "source-projection-v1",
477            Self::PlainAdvisory => "none",
478        }
479    }
480
481    #[must_use]
482    pub const fn structural_address(self) -> &'static str {
483        match self {
484            Self::Markdown => "markdown-ast-node-path",
485            Self::Mdx => "mdx-ast-node-path",
486            Self::PlainAdvisory => "none",
487        }
488    }
489}