1use std::fmt;
9use std::str::FromStr;
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13
14use crate::errors::NameError;
15
16const MAX_PROJECT_NAME_LEN: usize = 64;
17const MAX_SERIES_NAME_LEN: usize = 64;
18const MAX_TAG_NAME_LEN: usize = 128;
19const MAX_TAG_GLOB_LEN: usize = 128;
20const MAX_MODULE_NAME_LEN: usize = 256;
21const MAX_FUNCTION_NAME_LEN: usize = 256;
22const MAX_TYPE_NAME_LEN: usize = 256;
23const MAX_RECORD_NAME_LEN: usize = 256;
24const MAX_FIELD_NAME_LEN: usize = 256;
25const MAX_APPLICATION_NAME_LEN: usize = 128;
26const MAX_BEHAVIOUR_NAME_LEN: usize = 256;
27const MAX_MACRO_NAME_LEN: usize = 256;
28const MAX_DEPENDENCY_NAME_LEN: usize = 128;
29const MAX_DEPENDENCY_VERSION_LEN: usize = 128;
30
31const APPLICATION_PATTERN: &str = "[a-z][a-z0-9_]{0,127}";
32
33const PROJECT_PATTERN: &str = "[a-z][a-z0-9_-]{0,63}";
34const SERIES_PATTERN: &str = "[a-z][a-z0-9_.-]{0,63}";
35
36fn is_project_name_char(c: char) -> bool {
37 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'
38}
39
40fn is_series_name_char(c: char) -> bool {
41 is_project_name_char(c) || c == '.'
42}
43
44fn is_application_name_char(c: char) -> bool {
45 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
46}
47
48fn validate_simple_name(
49 kind: &'static str,
50 value: &str,
51 max_len: usize,
52 first_char_ok: fn(char) -> bool,
53 rest_char_ok: fn(char) -> bool,
54 pattern: &'static str,
55) -> Result<(), NameError> {
56 if value.is_empty() {
57 return Err(NameError::Empty { kind });
58 }
59 if value.len() > max_len {
60 return Err(NameError::TooLong {
61 kind,
62 len: value.len(),
63 max: max_len,
64 });
65 }
66 let mut chars = value.chars();
67 let first = chars.next().expect("non-empty");
68 if !first_char_ok(first) {
69 return Err(NameError::PatternMismatch {
70 kind,
71 value: value.to_owned(),
72 pattern,
73 });
74 }
75 for ch in chars {
76 if !rest_char_ok(ch) {
77 return Err(NameError::InvalidCharacter {
78 kind,
79 ch,
80 value: value.to_owned(),
81 });
82 }
83 }
84 Ok(())
85}
86
87fn validate_tag_glob(value: &str) -> Result<(), NameError> {
88 if value.is_empty() {
89 return Err(NameError::Empty { kind: "tag glob" });
90 }
91 if value.len() > MAX_TAG_GLOB_LEN {
92 return Err(NameError::TooLong {
93 kind: "tag glob",
94 len: value.len(),
95 max: MAX_TAG_GLOB_LEN,
96 });
97 }
98 for ch in value.chars() {
99 if ch.is_ascii_control()
100 || ch.is_whitespace()
101 || ch == '/'
102 || ch == '\\'
103 || ch == '\0'
104 || ch == ':'
105 || ch == '~'
106 || ch == '^'
107 || ch == '['
108 {
109 return Err(NameError::InvalidCharacter {
110 kind: "tag glob",
111 ch,
112 value: value.to_owned(),
113 });
114 }
115 }
116 if value == "." || value == ".." || value.starts_with('.') || value.ends_with('.') {
117 return Err(NameError::PatternMismatch {
118 kind: "tag glob",
119 value: value.to_owned(),
120 pattern: "non-empty, no path separators or git ref-magic",
121 });
122 }
123 Ok(())
124}
125
126fn validate_tag_name(value: &str) -> Result<(), NameError> {
127 if value.is_empty() {
128 return Err(NameError::Empty { kind: "tag" });
129 }
130 if value.len() > MAX_TAG_NAME_LEN {
131 return Err(NameError::TooLong {
132 kind: "tag",
133 len: value.len(),
134 max: MAX_TAG_NAME_LEN,
135 });
136 }
137 for ch in value.chars() {
138 if ch.is_ascii_control()
139 || ch.is_whitespace()
140 || ch == '/'
141 || ch == '\\'
142 || ch == '\0'
143 || ch == ':'
144 || ch == '~'
145 || ch == '^'
146 || ch == '?'
147 || ch == '*'
148 || ch == '['
149 {
150 return Err(NameError::InvalidCharacter {
151 kind: "tag",
152 ch,
153 value: value.to_owned(),
154 });
155 }
156 }
157 if value == "." || value == ".." || value.starts_with('.') || value.ends_with('.') {
158 return Err(NameError::PatternMismatch {
159 kind: "tag",
160 value: value.to_owned(),
161 pattern: "non-empty, no path separators or git ref-magic",
162 });
163 }
164 Ok(())
165}
166
167fn validate_erlang_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
168 if value.is_empty() {
169 return Err(NameError::Empty { kind });
170 }
171 if value.len() > max_len {
172 return Err(NameError::TooLong {
173 kind,
174 len: value.len(),
175 max: max_len,
176 });
177 }
178 if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
179 return Ok(());
180 }
181 let first = value.chars().next().expect("non-empty");
182 if !(first.is_ascii_lowercase() || first == '_') {
183 return Err(NameError::PatternMismatch {
184 kind,
185 value: value.to_owned(),
186 pattern: "lowercase-led atom or single-quoted",
187 });
188 }
189 let last = value.chars().last().expect("non-empty");
190 let drop_trailing_punct = if matches!(last, '?' | '!') {
191 value.len() - last.len_utf8()
192 } else {
193 value.len()
194 };
195 for ch in value[..drop_trailing_punct].chars() {
196 if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '@') {
197 return Err(NameError::InvalidCharacter {
198 kind,
199 ch,
200 value: value.to_owned(),
201 });
202 }
203 }
204 Ok(())
205}
206
207fn validate_module_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
208 if value.is_empty() {
209 return Err(NameError::Empty { kind });
210 }
211 if value.len() > max_len {
212 return Err(NameError::TooLong {
213 kind,
214 len: value.len(),
215 max: max_len,
216 });
217 }
218 if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
219 return Ok(());
220 }
221 let first = value.chars().next().expect("non-empty");
222 if !(first.is_ascii_alphabetic() || first == '_') {
223 return Err(NameError::PatternMismatch {
224 kind,
225 value: value.to_owned(),
226 pattern: "Erlang atom or Elixir module name",
227 });
228 }
229 for ch in value.chars() {
230 if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '@' || ch == '.') {
231 return Err(NameError::InvalidCharacter {
232 kind,
233 ch,
234 value: value.to_owned(),
235 });
236 }
237 }
238 Ok(())
239}
240
241macro_rules! string_newtype {
242 ($name:ident, $validate:expr) => {
243 #[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
244 #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
245 #[serde(transparent)]
246 pub struct $name(Arc<str>);
247
248 impl $name {
249 pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
250 let v = value.into();
251 $validate(&v)?;
252 Ok(Self(Arc::from(v)))
253 }
254
255 pub fn as_str(&self) -> &str {
256 &self.0
257 }
258
259 pub fn into_inner(self) -> String {
260 self.0.to_string()
261 }
262 }
263
264 impl AsRef<str> for $name {
265 fn as_ref(&self) -> &str {
266 &self.0
267 }
268 }
269
270 impl fmt::Display for $name {
271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272 f.write_str(&self.0)
273 }
274 }
275
276 impl FromStr for $name {
277 type Err = NameError;
278
279 fn from_str(s: &str) -> Result<Self, NameError> {
280 Self::new(s.to_owned())
281 }
282 }
283 };
284}
285
286string_newtype!(ProjectName, |v: &str| {
287 validate_simple_name(
288 "project name",
289 v,
290 MAX_PROJECT_NAME_LEN,
291 |c| c.is_ascii_lowercase(),
292 is_project_name_char,
293 PROJECT_PATTERN,
294 )
295});
296
297string_newtype!(SeriesName, |v: &str| {
298 validate_simple_name(
299 "series name",
300 v,
301 MAX_SERIES_NAME_LEN,
302 |c| c.is_ascii_lowercase(),
303 is_series_name_char,
304 SERIES_PATTERN,
305 )
306});
307
308string_newtype!(TagName, validate_tag_name);
309
310const MAX_GIT_REF_LEN: usize = 256;
311string_newtype!(GitRef, validate_git_ref);
312
313fn validate_git_ref(value: &str) -> Result<(), NameError> {
314 if value.is_empty() {
315 return Err(NameError::Empty { kind: "git ref" });
316 }
317 if value.len() > MAX_GIT_REF_LEN {
318 return Err(NameError::TooLong {
319 kind: "git ref",
320 len: value.len(),
321 max: MAX_GIT_REF_LEN,
322 });
323 }
324 for ch in value.chars() {
325 if ch.is_ascii_control() || ch.is_whitespace() || ch == '\0' || ch == ':' {
326 return Err(NameError::InvalidCharacter {
327 kind: "git ref",
328 ch,
329 value: value.to_owned(),
330 });
331 }
332 }
333 Ok(())
334}
335
336string_newtype!(TagGlob, validate_tag_glob);
337
338impl TagGlob {
339 pub fn matches(&self, tag: &TagName) -> bool {
341 glob_match(self.as_str(), tag.as_str())
342 }
343}
344
345fn glob_match(pattern: &str, text: &str) -> bool {
346 let pattern: Vec<char> = pattern.chars().collect();
347 let text: Vec<char> = text.chars().collect();
348 let mut pi = 0usize;
349 let mut ti = 0usize;
350 let mut star_p: Option<usize> = None;
351 let mut star_t: Option<usize> = None;
352 while ti < text.len() {
353 if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
354 pi += 1;
355 ti += 1;
356 } else if pi < pattern.len() && pattern[pi] == '*' {
357 star_p = Some(pi);
358 star_t = Some(ti);
359 pi += 1;
360 } else if let (Some(sp), Some(st)) = (star_p, star_t) {
361 pi = sp + 1;
362 star_t = Some(st + 1);
363 ti = st + 1;
364 } else {
365 return false;
366 }
367 }
368 while pi < pattern.len() && pattern[pi] == '*' {
369 pi += 1;
370 }
371 pi == pattern.len()
372}
373
374string_newtype!(ModuleName, |v: &str| {
375 validate_module_name("module name", v, MAX_MODULE_NAME_LEN)
376});
377
378string_newtype!(FunctionName, |v: &str| {
379 validate_erlang_name("function name", v, MAX_FUNCTION_NAME_LEN)
380});
381
382string_newtype!(TypeName, |v: &str| {
383 validate_erlang_name("type name", v, MAX_TYPE_NAME_LEN)
384});
385
386string_newtype!(RecordName, |v: &str| {
387 validate_erlang_name("record name", v, MAX_RECORD_NAME_LEN)
388});
389
390string_newtype!(FieldName, |v: &str| {
391 validate_erlang_name("field name", v, MAX_FIELD_NAME_LEN)
392});
393
394string_newtype!(ApplicationName, |v: &str| {
395 validate_simple_name(
396 "application name",
397 v,
398 MAX_APPLICATION_NAME_LEN,
399 |c| c.is_ascii_lowercase(),
400 is_application_name_char,
401 APPLICATION_PATTERN,
402 )
403});
404
405string_newtype!(BehaviourName, |v: &str| {
406 validate_module_name("behaviour name", v, MAX_BEHAVIOUR_NAME_LEN)
407});
408
409string_newtype!(MacroName, |v: &str| {
410 validate_macro_name("macro name", v, MAX_MACRO_NAME_LEN)
411});
412
413string_newtype!(DependencyName, |v: &str| {
414 validate_dependency_name("dependency name", v, MAX_DEPENDENCY_NAME_LEN)
415});
416
417string_newtype!(DependencyVersion, |v: &str| {
418 validate_dependency_version("dependency version", v, MAX_DEPENDENCY_VERSION_LEN)
419});
420
421fn validate_macro_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
422 validate_simple_name(
423 kind,
424 value,
425 max_len,
426 |c| c.is_ascii_alphabetic() || c == '_',
427 |c| c.is_ascii_alphanumeric() || c == '_',
428 "letter or underscore, then letters digits underscores",
429 )
430}
431
432fn validate_dependency_name(
433 kind: &'static str,
434 value: &str,
435 max_len: usize,
436) -> Result<(), NameError> {
437 validate_simple_name(
438 kind,
439 value,
440 max_len,
441 |c| c.is_ascii_lowercase(),
442 is_application_name_char,
443 "lowercase letter, then lowercase digits underscores",
444 )
445}
446
447fn validate_dependency_version(
448 kind: &'static str,
449 value: &str,
450 max_len: usize,
451) -> Result<(), NameError> {
452 if value.is_empty() {
453 return Err(NameError::Empty { kind });
454 }
455 if value.len() > max_len {
456 return Err(NameError::TooLong {
457 kind,
458 len: value.len(),
459 max: max_len,
460 });
461 }
462 for ch in value.chars() {
463 if ch.is_ascii_control() || ch == '\0' || ch.is_whitespace() {
464 return Err(NameError::InvalidCharacter {
465 kind,
466 ch,
467 value: value.to_owned(),
468 });
469 }
470 }
471 Ok(())
472}
473
474const MAX_RELATIVE_PATH_LEN: usize = 4096;
475
476string_newtype!(RelativePath, validate_relative_path);
477
478fn validate_relative_path(value: &str) -> Result<(), NameError> {
479 if value.is_empty() {
480 return Err(NameError::Empty {
481 kind: "relative path",
482 });
483 }
484 if value.len() > MAX_RELATIVE_PATH_LEN {
485 return Err(NameError::TooLong {
486 kind: "relative path",
487 len: value.len(),
488 max: MAX_RELATIVE_PATH_LEN,
489 });
490 }
491 if value.starts_with('/') {
492 return Err(NameError::Invalid {
493 kind: "relative path",
494 value: value.to_owned(),
495 reason: "absolute paths are not allowed",
496 });
497 }
498 if value.split('/').any(|seg| seg == "..") {
499 return Err(NameError::Invalid {
500 kind: "relative path",
501 value: value.to_owned(),
502 reason: "parent-directory segments are not allowed",
503 });
504 }
505 if value.contains('\0') {
506 return Err(NameError::Invalid {
507 kind: "relative path",
508 value: value.to_owned(),
509 reason: "NUL bytes are not allowed",
510 });
511 }
512 Ok(())
513}
514
515impl RelativePath {
516 pub fn from_bytes(bytes: &[u8]) -> Result<Self, NameError> {
524 let s = std::str::from_utf8(bytes).map_err(|_| NameError::Invalid {
525 kind: "relative path",
526 value: String::from_utf8_lossy(bytes).into_owned(),
527 reason: "path is not valid UTF-8",
528 })?;
529 Self::new(s.to_owned())
530 }
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
534#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
535#[serde(transparent)]
536pub struct Arity(u8);
537
538impl Arity {
539 pub const fn new(value: u8) -> Self {
540 Self(value)
541 }
542
543 pub const fn get(self) -> u8 {
544 self.0
545 }
546}
547
548impl fmt::Display for Arity {
549 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
550 write!(f, "{}", self.0)
551 }
552}
553
554impl FromStr for Arity {
555 type Err = NameError;
556
557 fn from_str(s: &str) -> Result<Self, NameError> {
558 let parsed: i64 = s
559 .parse()
560 .map_err(|_| NameError::InvalidArityParse { raw: s.to_owned() })?;
561 if !(0..=255).contains(&parsed) {
562 return Err(NameError::InvalidArity { value: parsed });
563 }
564 Ok(Self(parsed as u8))
565 }
566}
567
568impl TryFrom<usize> for Arity {
569 type Error = NameError;
570
571 fn try_from(value: usize) -> Result<Self, NameError> {
572 u8::try_from(value)
573 .map(Self)
574 .map_err(|_| NameError::InvalidArity {
575 value: i64::try_from(value).unwrap_or(i64::MAX),
576 })
577 }
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
581#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
582#[serde(transparent)]
583pub struct CommitSha(Arc<str>);
584
585impl CommitSha {
586 pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
587 let v = value.into();
588 if v.len() != 40
589 || !v
590 .chars()
591 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
592 {
593 return Err(NameError::InvalidCommitSha { value: v });
594 }
595 Ok(Self(Arc::from(v)))
596 }
597
598 pub fn as_str(&self) -> &str {
599 &self.0
600 }
601
602 #[must_use]
606 pub fn abbreviated(&self) -> &str {
607 &self.0[..12]
608 }
609}
610
611impl fmt::Display for CommitSha {
612 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
613 f.write_str(&self.0)
614 }
615}
616
617impl FromStr for CommitSha {
618 type Err = NameError;
619
620 fn from_str(s: &str) -> Result<Self, NameError> {
621 Self::new(s.to_owned())
622 }
623}
624
625impl AsRef<str> for CommitSha {
626 fn as_ref(&self) -> &str {
627 &self.0
628 }
629}
630
631const MIN_COMMIT_SHA_PREFIX_LEN: usize = 7;
632const MAX_COMMIT_SHA_PREFIX_LEN: usize = 40;
633
634#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
635#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
636#[serde(try_from = "String", into = "String")]
637pub struct CommitShaPrefix(String);
638
639impl TryFrom<String> for CommitShaPrefix {
640 type Error = NameError;
641 fn try_from(value: String) -> Result<Self, NameError> {
642 Self::new(value)
643 }
644}
645
646impl From<CommitShaPrefix> for String {
647 fn from(p: CommitShaPrefix) -> Self {
648 p.0
649 }
650}
651
652impl CommitShaPrefix {
653 pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
654 let raw = value.into();
655 if raw.is_empty() {
656 return Err(NameError::EmptyCommitShaPrefix);
657 }
658 let normalised = raw.to_ascii_lowercase();
659 if let Some((position, ch)) = normalised
660 .chars()
661 .enumerate()
662 .find(|(_, c)| !c.is_ascii_hexdigit())
663 {
664 return Err(NameError::CommitShaPrefixNonHex {
665 value: normalised,
666 ch,
667 position,
668 });
669 }
670 let len = normalised.len();
671 if len < MIN_COMMIT_SHA_PREFIX_LEN {
672 return Err(NameError::CommitShaPrefixTooShort {
673 value: normalised,
674 len,
675 });
676 }
677 if len > MAX_COMMIT_SHA_PREFIX_LEN {
678 return Err(NameError::CommitShaPrefixTooLong {
679 value: normalised,
680 len,
681 });
682 }
683 Ok(Self(normalised))
684 }
685
686 pub fn as_str(&self) -> &str {
687 &self.0
688 }
689
690 pub fn len(&self) -> usize {
691 self.0.len()
692 }
693
694 pub fn is_empty(&self) -> bool {
695 self.0.is_empty()
696 }
697
698 #[must_use]
699 pub fn is_full(&self) -> bool {
700 self.0.len() == MAX_COMMIT_SHA_PREFIX_LEN
701 }
702
703 #[must_use]
704 pub fn as_full_sha(&self) -> Option<CommitSha> {
705 if self.is_full() {
706 CommitSha::new(self.0.clone()).ok()
707 } else {
708 None
709 }
710 }
711}
712
713impl fmt::Display for CommitShaPrefix {
714 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
715 f.write_str(&self.0)
716 }
717}
718
719impl FromStr for CommitShaPrefix {
720 type Err = NameError;
721
722 fn from_str(s: &str) -> Result<Self, NameError> {
723 Self::new(s.to_owned())
724 }
725}
726
727impl AsRef<str> for CommitShaPrefix {
728 fn as_ref(&self) -> &str {
729 &self.0
730 }
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
734#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
735pub struct Mfa {
736 pub module: ModuleName,
737 pub function: FunctionName,
738 pub arity: Arity,
739}
740
741impl Mfa {
742 pub fn new(module: ModuleName, function: FunctionName, arity: Arity) -> Self {
743 Self {
744 module,
745 function,
746 arity,
747 }
748 }
749}
750
751impl fmt::Display for Mfa {
752 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
753 write!(f, "{}:{}/{}", self.module, self.function, self.arity)
754 }
755}
756
757impl FromStr for Mfa {
758 type Err = NameError;
759
760 fn from_str(s: &str) -> Result<Self, NameError> {
761 let (head, arity_part) = s.rsplit_once('/').ok_or_else(|| NameError::InvalidMfa {
762 value: s.to_owned(),
763 })?;
764 let (module_part, function_part) =
765 head.split_once(':').ok_or_else(|| NameError::InvalidMfa {
766 value: s.to_owned(),
767 })?;
768 let arity = arity_part
769 .parse::<Arity>()
770 .map_err(|_| NameError::InvalidMfa {
771 value: s.to_owned(),
772 })?;
773 Ok(Self {
774 module: module_part.parse()?,
775 function: function_part.parse()?,
776 arity,
777 })
778 }
779}