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 #[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#[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 #[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 #[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
385fn 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#[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 #[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 #[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, EnumIter)]
501pub enum Adapter {
502 Markdown,
503 Mdx,
504 PlainAdvisory,
505}
506
507impl Adapter {
508 #[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}