1use strum::{EnumIter, EnumString, IntoEnumIterator, IntoStaticStr};
2
3use crate::json::Value;
4
5#[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#[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 #[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 #[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
88impl From<&RepoPathText> for RepoPath {
91 fn from(text: &RepoPathText) -> Self {
92 Self(Repr::Text(text.as_str().to_owned()))
93 }
94}
95
96impl 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
113impl 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#[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 #[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 #[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#[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 #[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 #[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
420fn 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#[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 #[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 #[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, EnumIter)]
536pub enum Adapter {
537 Markdown,
538 Mdx,
539 PlainAdvisory,
540}
541
542impl Adapter {
543 #[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}