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 host: String,
331    pub owner: String,
332    pub name: String,
333}
334
335impl RepositoryIdentity {
336    /// The open identity: any canonical forge host, an owner of one or more
337    /// slash-joined segments (nested segments spell a GitLab group path),
338    /// and a name.
339    #[must_use]
340    pub fn new(host: String, owner: String, name: String) -> Option<Self> {
341        let owner_ok = (1..=255).contains(&owner.len())
342            && owner
343                .as_bytes()
344                .split(|&byte| byte == b'/')
345                .all(identity_segment);
346        if host_valid(&host) && owner_ok && name_valid(&name) {
347            Some(Self { host, owner, name })
348        } else {
349            None
350        }
351    }
352
353    /// The identity form the v1 control documents can spell: host fixed to
354    /// github.com, single-segment owner.
355    #[must_use]
356    pub fn github(owner: String, name: String) -> Option<Self> {
357        if identity_segment(owner.as_bytes()) && name_valid(&name) {
358            Some(Self {
359                host: "github.com".to_owned(),
360                owner,
361                name,
362            })
363        } else {
364            None
365        }
366    }
367}
368
369fn identity_segment(segment: &[u8]) -> bool {
370    (1..=100).contains(&segment.len())
371        && segment.iter().copied().all(identity_byte)
372        && segment.first().is_some_and(u8::is_ascii_alphanumeric)
373        && segment.last().is_some_and(u8::is_ascii_alphanumeric)
374}
375
376fn name_valid(name: &str) -> bool {
377    (1..=100).contains(&name.len())
378        && name.bytes().all(identity_byte)
379        && name != "."
380        && name != ".."
381}
382
383/// The host is an opaque claim the engine never resolves or normalizes;
384/// the caller owns its spelling. A slash would make the identity triple
385/// ambiguous, and the cap bounds it like every other wire string.
386fn host_valid(host: &str) -> bool {
387    (1..=255).contains(&host.len()) && !host.contains('/')
388}
389
390fn identity_byte(byte: u8) -> bool {
391    byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
392}
393
394/// The same-repository URL dialect a run applies: named in the report's
395/// evaluation and selecting the recognition grammar in the resolver.
396#[derive(Clone, Copy, Debug, PartialEq, Eq)]
397pub enum ForgeDialect {
398    Github,
399    Gitlab,
400    Gitea,
401}
402
403impl ForgeDialect {
404    #[must_use]
405    pub const fn as_str(self) -> &'static str {
406        match self {
407            Self::Github => "github",
408            Self::Gitlab => "gitlab",
409            Self::Gitea => "gitea",
410        }
411    }
412
413    /// The known-host default table; an explicit flag always wins over it.
414    #[must_use]
415    pub fn default_for_host(host: &str) -> Option<Self> {
416        match host {
417            "github.com" => Some(Self::Github),
418            "gitlab.com" => Some(Self::Gitlab),
419            "codeberg.org" => Some(Self::Gitea),
420            _ => None,
421        }
422    }
423}
424
425#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
426pub enum ObjectFormat {
427    Sha1,
428    Sha256,
429}
430
431impl ObjectFormat {
432    #[must_use]
433    pub const fn as_str(self) -> &'static str {
434        match self {
435            Self::Sha1 => "sha1",
436            Self::Sha256 => "sha256",
437        }
438    }
439}
440
441#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
442pub struct TreeIdentity {
443    pub object_format: ObjectFormat,
444    pub tree_oid: String,
445}
446
447impl TreeIdentity {
448    #[must_use]
449    pub fn new(object_format: ObjectFormat, tree_oid: String) -> Option<Self> {
450        if oid_hex(object_format, &tree_oid) {
451            Some(Self {
452                object_format,
453                tree_oid,
454            })
455        } else {
456            None
457        }
458    }
459}
460
461/// Full lowercase object ID for one declared object format.
462#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
463pub struct Oid(String);
464
465impl Oid {
466    #[must_use]
467    pub fn new(object_format: ObjectFormat, raw: String) -> Option<Self> {
468        if oid_hex(object_format, &raw) {
469            Some(Self(raw))
470        } else {
471            None
472        }
473    }
474
475    #[must_use]
476    pub fn as_str(&self) -> &str {
477        &self.0
478    }
479}
480
481fn oid_hex(object_format: ObjectFormat, raw: &str) -> bool {
482    let expected = match object_format {
483        ObjectFormat::Sha1 => 40,
484        ObjectFormat::Sha256 => 64,
485    };
486    raw.len() == expected
487        && raw
488            .bytes()
489            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
490}
491
492/// The three closed source adapters. Every wire string an adapter contributes
493/// (identity, grammar profile, frontmatter contract, projection, address
494/// scheme) is frozen here so no call site can spell one by hand.
495#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
496pub enum Adapter {
497    Markdown,
498    Mdx,
499    PlainAdvisory,
500}
501
502impl Adapter {
503    pub const ALL: [Self; 3] = [Self::Markdown, Self::Mdx, Self::PlainAdvisory];
504
505    #[must_use]
506    pub const fn adapter_id(self) -> &'static str {
507        match self {
508            Self::Markdown => "markdown-v1",
509            Self::Mdx => "mdx-v1",
510            Self::PlainAdvisory => "plain-advisory-v1",
511        }
512    }
513
514    #[must_use]
515    pub const fn parser_name(self) -> &'static str {
516        match self {
517            Self::Markdown => "amiss-markdown-adapter",
518            Self::Mdx => "amiss-mdx-adapter",
519            Self::PlainAdvisory => "amiss-plain-advisory",
520        }
521    }
522
523    #[must_use]
524    pub const fn grammar_profile(self) -> &'static str {
525        match self {
526            Self::Markdown => "commonmark-gfm-v1",
527            Self::Mdx => "mdx-source-v1",
528            Self::PlainAdvisory => "plain-zero-lexer-v1",
529        }
530    }
531
532    #[must_use]
533    pub const fn frontmatter_contract(self) -> &'static str {
534        match self {
535            Self::Markdown | Self::Mdx => "frontmatter-v1",
536            Self::PlainAdvisory => "none",
537        }
538    }
539
540    #[must_use]
541    pub const fn source_projection(self) -> &'static str {
542        match self {
543            Self::Markdown | Self::Mdx => "source-projection-v1",
544            Self::PlainAdvisory => "none",
545        }
546    }
547
548    #[must_use]
549    pub const fn structural_address(self) -> &'static str {
550        match self {
551            Self::Markdown => "markdown-ast-node-path",
552            Self::Mdx => "mdx-ast-node-path",
553            Self::PlainAdvisory => "none",
554        }
555    }
556}