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#[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 #[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#[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#[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#[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}