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    /// Whole seconds since 1970-01-01T00:00:00Z, computed from the already
248    /// validated calendar fields with the days-from-civil identity.
249    #[expect(
250        clippy::arithmetic_side_effects,
251        reason = "every field is bounded by the validated 20-byte format, so all terms stay far inside i64"
252    )]
253    #[must_use]
254    pub fn epoch_seconds(&self) -> i64 {
255        let bytes = self.0.as_bytes();
256        let part = |start: usize, len: usize| i64::from(field(bytes, start, len).unwrap_or(0));
257        let year = part(0, 4);
258        let month = part(5, 2);
259        let day = part(8, 2);
260        let shifted_year = if month <= 2 { year - 1 } else { year };
261        let era = shifted_year.div_euclid(400);
262        let year_of_era = shifted_year - era * 400;
263        let month_index = if month > 2 { month - 3 } else { month + 9 };
264        let day_of_year = (153 * month_index + 2) / 5 + day - 1;
265        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
266        let days = era * 146_097 + day_of_era - 719_468;
267        days * 86_400 + part(11, 2) * 3_600 + part(14, 2) * 60 + part(17, 2)
268    }
269}
270
271fn field(bytes: &[u8], start: usize, len: usize) -> Option<u32> {
272    let end = start.checked_add(len)?;
273    bytes.get(start..end)?.iter().try_fold(0_u32, |acc, byte| {
274        let digit = u32::from(byte.wrapping_sub(b'0'));
275        acc.checked_mul(10)?.checked_add(digit)
276    })
277}
278
279fn days_in_month(year: u32, month: u32) -> u32 {
280    let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
281    match month {
282        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
283        4 | 6 | 9 | 11 => 30,
284        2 if leap => 29,
285        2 => 28,
286        _ => 0,
287    }
288}
289
290/// Full branch ref under the rolling `ref-format` contract.
291#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
292pub struct BranchRef(String);
293
294impl BranchRef {
295    #[must_use]
296    #[expect(
297        clippy::case_sensitive_file_extension_comparisons,
298        reason = "ref-format component rules are byte-exact"
299    )]
300    pub fn new(raw: String) -> Option<Self> {
301        if raw.len() > 266 {
302            return None;
303        }
304        let suffix = raw.strip_prefix("refs/heads/")?;
305        if suffix.is_empty() || suffix.contains("..") || suffix.contains("@{") {
306            return None;
307        }
308        if suffix.bytes().any(|b| {
309            b < 0x20
310                || b == 0x7f
311                || matches!(b, b' ' | b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
312        }) {
313            return None;
314        }
315        if suffix.ends_with('.') {
316            return None;
317        }
318        let components_ok = suffix
319            .split('/')
320            .all(|c| !c.is_empty() && !c.starts_with('.') && !c.ends_with(".lock"));
321        if components_ok { Some(Self(raw)) } else { None }
322    }
323
324    #[must_use]
325    pub fn as_str(&self) -> &str {
326        &self.0
327    }
328}
329
330#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
331pub struct RepositoryIdentity {
332    pub host: String,
333    pub owner: String,
334    pub name: String,
335}
336
337impl RepositoryIdentity {
338    /// The open identity: any canonical forge host, an owner of one or more
339    /// slash-joined segments (nested segments spell a GitLab group path),
340    /// and a name.
341    #[must_use]
342    pub fn new(host: String, owner: String, name: String) -> Option<Self> {
343        let owner_ok = (1..=255).contains(&owner.len())
344            && owner
345                .as_bytes()
346                .split(|&byte| byte == b'/')
347                .all(identity_segment);
348        if host_valid(&host) && owner_ok && name_valid(&name) {
349            Some(Self { host, owner, name })
350        } else {
351            None
352        }
353    }
354
355    /// Convenience constructor for GitHub's fixed host and single-segment
356    /// owner form.
357    #[must_use]
358    pub fn github(owner: String, name: String) -> Option<Self> {
359        if identity_segment(owner.as_bytes()) && name_valid(&name) {
360            Some(Self {
361                host: "github.com".to_owned(),
362                owner,
363                name,
364            })
365        } else {
366            None
367        }
368    }
369}
370
371fn identity_segment(segment: &[u8]) -> bool {
372    (1..=100).contains(&segment.len())
373        && segment.iter().copied().all(identity_byte)
374        && segment.first().is_some_and(u8::is_ascii_alphanumeric)
375        && segment.last().is_some_and(u8::is_ascii_alphanumeric)
376}
377
378fn name_valid(name: &str) -> bool {
379    (1..=100).contains(&name.len())
380        && name.bytes().all(identity_byte)
381        && name != "."
382        && name != ".."
383}
384
385/// The host is an opaque claim the engine never resolves or normalizes;
386/// the caller owns its spelling. A slash would make the identity triple
387/// ambiguous, and the cap bounds it like every other wire string.
388fn host_valid(host: &str) -> bool {
389    (1..=255).contains(&host.len()) && !host.contains('/')
390}
391
392fn identity_byte(byte: u8) -> bool {
393    byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
394}
395
396/// The same-repository URL dialect a run applies: named in the report's
397/// evaluation and selecting the recognition grammar in the resolver.
398#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter, EnumString, IntoStaticStr)]
399#[strum(serialize_all = "lowercase")]
400pub enum ForgeDialect {
401    Github,
402    Gitlab,
403    Gitea,
404}
405
406impl ForgeDialect {
407    /// Every supported URL dialect in wire-contract order.
408    #[must_use]
409    pub fn all() -> impl ExactSizeIterator<Item = Self> {
410        Self::iter()
411    }
412
413    #[must_use]
414    pub fn as_str(self) -> &'static str {
415        self.into()
416    }
417
418    /// The known-host default table; an explicit flag always wins over it.
419    #[must_use]
420    pub fn default_for_host(host: &str) -> Option<Self> {
421        match host {
422            "github.com" => Some(Self::Github),
423            "gitlab.com" => Some(Self::Gitlab),
424            "codeberg.org" => Some(Self::Gitea),
425            _ => None,
426        }
427    }
428}
429
430#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
431pub enum ObjectFormat {
432    Sha1,
433    Sha256,
434}
435
436impl ObjectFormat {
437    #[must_use]
438    pub const fn as_str(self) -> &'static str {
439        match self {
440            Self::Sha1 => "sha1",
441            Self::Sha256 => "sha256",
442        }
443    }
444}
445
446#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
447pub struct TreeIdentity {
448    pub object_format: ObjectFormat,
449    pub tree_oid: String,
450}
451
452impl TreeIdentity {
453    #[must_use]
454    pub fn new(object_format: ObjectFormat, tree_oid: String) -> Option<Self> {
455        if oid_hex(object_format, &tree_oid) {
456            Some(Self {
457                object_format,
458                tree_oid,
459            })
460        } else {
461            None
462        }
463    }
464}
465
466/// Full lowercase object ID for one declared object format.
467#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
468pub struct Oid(String);
469
470impl Oid {
471    #[must_use]
472    pub fn new(object_format: ObjectFormat, raw: String) -> Option<Self> {
473        if oid_hex(object_format, &raw) {
474            Some(Self(raw))
475        } else {
476            None
477        }
478    }
479
480    #[must_use]
481    pub fn as_str(&self) -> &str {
482        &self.0
483    }
484}
485
486fn oid_hex(object_format: ObjectFormat, raw: &str) -> bool {
487    let expected = match object_format {
488        ObjectFormat::Sha1 => 40,
489        ObjectFormat::Sha256 => 64,
490    };
491    raw.len() == expected
492        && raw
493            .bytes()
494            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
495}
496
497/// The three closed source adapters. Every wire string an adapter contributes
498/// (identity, grammar profile, frontmatter contract, projection, address
499/// scheme) is frozen here so no call site can spell one by hand.
500#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, EnumIter)]
501pub enum Adapter {
502    Markdown,
503    Mdx,
504    PlainAdvisory,
505}
506
507impl Adapter {
508    /// Every source adapter in wire-contract order.
509    #[must_use]
510    pub fn all() -> impl ExactSizeIterator<Item = Self> {
511        Self::iter()
512    }
513
514    #[must_use]
515    pub const fn adapter_id(self) -> &'static str {
516        match self {
517            Self::Markdown => "markdown",
518            Self::Mdx => "mdx",
519            Self::PlainAdvisory => "plain-advisory",
520        }
521    }
522
523    #[must_use]
524    pub const fn parser_name(self) -> &'static str {
525        match self {
526            Self::Markdown => "amiss-markdown-adapter",
527            Self::Mdx => "amiss-mdx-adapter",
528            Self::PlainAdvisory => "amiss-plain-advisory",
529        }
530    }
531
532    #[must_use]
533    pub const fn grammar_profile(self) -> &'static str {
534        match self {
535            Self::Markdown => "commonmark-gfm",
536            Self::Mdx => "mdx-source",
537            Self::PlainAdvisory => "plain-zero-lexer",
538        }
539    }
540
541    #[must_use]
542    pub const fn frontmatter_contract(self) -> &'static str {
543        match self {
544            Self::Markdown | Self::Mdx => "frontmatter",
545            Self::PlainAdvisory => "none",
546        }
547    }
548
549    #[must_use]
550    pub const fn source_projection(self) -> &'static str {
551        match self {
552            Self::Markdown | Self::Mdx => "source-projection",
553            Self::PlainAdvisory => "none",
554        }
555    }
556
557    #[must_use]
558    pub const fn structural_address(self) -> &'static str {
559        match self {
560            Self::Markdown => "markdown-ast-node-path",
561            Self::Mdx => "mdx-ast-node-path",
562            Self::PlainAdvisory => "none",
563        }
564    }
565}