1use std::fmt;
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use crate::Result;
10use crate::error::Error;
11
12const MAX_LEN: usize = 63;
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct RunName(String);
19
20impl RunName {
21 pub fn new(value: &str) -> Result<RunName> {
27 let mut chars = value.chars();
28 let first = chars.next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
29 let rest = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_'));
30 if first && rest && value.len() <= MAX_LEN {
31 Ok(RunName(value.to_owned()))
32 } else {
33 Err(Error::RunName { value: value.to_owned() })
34 }
35 }
36
37 #[must_use]
39 pub fn as_str(&self) -> &str {
40 &self.0
41 }
42}
43
44impl fmt::Display for RunName {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 f.write_str(&self.0)
47 }
48}
49
50impl Serialize for RunName {
51 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
52 s.serialize_str(&self.0)
53 }
54}
55
56impl<'de> Deserialize<'de> for RunName {
57 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<RunName, D::Error> {
58 RunName::new(&String::deserialize(d)?).map_err(serde::de::Error::custom)
59 }
60}
61
62impl std::str::FromStr for RunName {
63 type Err = Error;
64
65 fn from_str(s: &str) -> Result<RunName> {
66 RunName::new(s)
67 }
68}
69
70impl TryFrom<&str> for RunName {
71 type Error = Error;
72
73 fn try_from(value: &str) -> Result<RunName> {
74 RunName::new(value)
75 }
76}
77
78impl TryFrom<String> for RunName {
79 type Error = Error;
80
81 fn try_from(value: String) -> Result<RunName> {
82 RunName::new(&value)
83 }
84}
85
86impl AsRef<str> for RunName {
87 fn as_ref(&self) -> &str {
88 &self.0
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn a_run_name_is_a_compose_project_name() {
98 for good in ["flag-portal", "cyberctf-invoice-portal-api", "a", "lab_2", "9lives"] {
99 assert_eq!(RunName::new(good).unwrap().as_str(), good);
100 }
101 for bad in ["", "Flag", "-x", "_x", "a b", "a/b", "..", "é", &"a".repeat(64)] {
102 assert_eq!(RunName::new(bad).unwrap_err().code(), crate::ErrorCode::RunName, "{bad}");
103 }
104 }
105
106 #[test]
107 fn a_run_name_reads_back_checked() {
108 let name: RunName = serde_json::from_str("\"web-1\"").unwrap();
109 assert_eq!(serde_json::to_string(&name).unwrap(), "\"web-1\"");
110 assert!(serde_json::from_str::<RunName>("\"../etc\"").is_err());
111 }
112}