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 host: String,
331 pub owner: String,
332 pub name: String,
333}
334
335impl RepositoryIdentity {
336 #[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 #[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
383fn 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#[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 #[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#[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#[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}