Skip to main content

amiss_wire/
model.rs

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