1use crate::json::Value;
2
3#[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#[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 #[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 #[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
86impl From<&RepoPathText> for RepoPath {
89 fn from(text: &RepoPathText) -> Self {
90 Self(Repr::Text(text.as_str().to_owned()))
91 }
92}
93
94impl 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
111impl 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#[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 #[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#[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#[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#[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}