guild_cli/config/
types.rs1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::ParseError;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(try_from = "String", into = "String")]
13pub struct ProjectName(String);
14
15impl ProjectName {
16 pub fn new(s: &str) -> Result<Self, ParseError> {
17 if s.is_empty() {
18 return Err(ParseError::ProjectName {
19 value: s.to_string(),
20 reason: "must not be empty".to_string(),
21 });
22 }
23 if !s
24 .chars()
25 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
26 {
27 return Err(ParseError::ProjectName {
28 value: s.to_string(),
29 reason:
30 "must contain only lowercase alphanumeric characters, hyphens, and underscores"
31 .to_string(),
32 });
33 }
34 Ok(Self(s.to_string()))
35 }
36
37 pub fn as_str(&self) -> &str {
38 &self.0
39 }
40}
41
42impl FromStr for ProjectName {
43 type Err = ParseError;
44
45 fn from_str(s: &str) -> Result<Self, Self::Err> {
46 Self::new(s)
47 }
48}
49
50impl fmt::Display for ProjectName {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.write_str(&self.0)
53 }
54}
55
56impl From<ProjectName> for String {
57 fn from(name: ProjectName) -> Self {
58 name.0
59 }
60}
61
62impl TryFrom<String> for ProjectName {
63 type Error = ParseError;
64
65 fn try_from(s: String) -> Result<Self, Self::Error> {
66 Self::new(&s)
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[serde(try_from = "String", into = "String")]
75pub struct TargetName(String);
76
77impl TargetName {
78 pub fn new(s: &str) -> Result<Self, ParseError> {
79 if s.is_empty() {
80 return Err(ParseError::TargetName {
81 value: s.to_string(),
82 reason: "must not be empty".to_string(),
83 });
84 }
85 if !s
86 .chars()
87 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
88 {
89 return Err(ParseError::TargetName {
90 value: s.to_string(),
91 reason:
92 "must contain only lowercase alphanumeric characters, hyphens, and underscores"
93 .to_string(),
94 });
95 }
96 Ok(Self(s.to_string()))
97 }
98
99 pub fn as_str(&self) -> &str {
100 &self.0
101 }
102}
103
104impl FromStr for TargetName {
105 type Err = ParseError;
106
107 fn from_str(s: &str) -> Result<Self, Self::Err> {
108 Self::new(s)
109 }
110}
111
112impl fmt::Display for TargetName {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 f.write_str(&self.0)
115 }
116}
117
118impl From<TargetName> for String {
119 fn from(name: TargetName) -> Self {
120 name.0
121 }
122}
123
124impl TryFrom<String> for TargetName {
125 type Error = ParseError;
126
127 fn try_from(s: String) -> Result<Self, Self::Error> {
128 Self::new(&s)
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Hash)]
137pub enum DependsOn {
138 Local(TargetName),
140 Upstream(TargetName),
142}
143
144impl DependsOn {
145 pub fn target_name(&self) -> &TargetName {
146 match self {
147 DependsOn::Local(name) | DependsOn::Upstream(name) => name,
148 }
149 }
150
151 pub fn is_upstream(&self) -> bool {
152 matches!(self, DependsOn::Upstream(_))
153 }
154}
155
156impl FromStr for DependsOn {
157 type Err = ParseError;
158
159 fn from_str(s: &str) -> Result<Self, Self::Err> {
160 if s.is_empty() {
161 return Err(ParseError::DependsOn {
162 value: s.to_string(),
163 reason: "must not be empty".to_string(),
164 });
165 }
166 if let Some(rest) = s.strip_prefix('^') {
167 let target = TargetName::new(rest).map_err(|_| ParseError::DependsOn {
168 value: s.to_string(),
169 reason: format!("invalid target name after '^': '{rest}'"),
170 })?;
171 Ok(DependsOn::Upstream(target))
172 } else {
173 let target = TargetName::new(s).map_err(|_| ParseError::DependsOn {
174 value: s.to_string(),
175 reason: format!("invalid target name: '{s}'"),
176 })?;
177 Ok(DependsOn::Local(target))
178 }
179 }
180}
181
182impl fmt::Display for DependsOn {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 match self {
185 DependsOn::Local(name) => write!(f, "{name}"),
186 DependsOn::Upstream(name) => write!(f, "^{name}"),
187 }
188 }
189}
190
191impl Serialize for DependsOn {
192 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
193 serializer.serialize_str(&self.to_string())
194 }
195}
196
197impl<'de> Deserialize<'de> for DependsOn {
198 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
199 let s = String::deserialize(deserializer)?;
200 s.parse().map_err(serde::de::Error::custom)
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn pname(s: &str) -> ProjectName {
210 s.parse().unwrap()
211 }
212 fn tname(s: &str) -> TargetName {
213 s.parse().unwrap()
214 }
215 fn dep(s: &str) -> DependsOn {
216 s.parse().unwrap()
217 }
218
219 #[test]
220 fn test_project_name_valid() {
221 assert_eq!(pname("my-app").as_str(), "my-app");
222 assert_eq!(pname("lib_utils").as_str(), "lib_utils");
223 assert_eq!(pname("app123").as_str(), "app123");
224 }
225
226 #[test]
227 fn test_project_name_empty_rejected() {
228 assert!(ProjectName::new("").is_err());
229 }
230
231 #[test]
232 fn test_project_name_uppercase_rejected() {
233 assert!(ProjectName::new("MyApp").is_err());
234 }
235
236 #[test]
237 fn test_project_name_display_roundtrip() {
238 let name = pname("my-app");
239 let roundtrip: ProjectName = name.to_string().parse().unwrap();
240 assert_eq!(name, roundtrip);
241 }
242
243 #[test]
244 fn test_target_name_valid() {
245 assert_eq!(tname("build").as_str(), "build");
246 assert_eq!(tname("test").as_str(), "test");
247 assert_eq!(tname("type-check").as_str(), "type-check");
248 }
249
250 #[test]
251 fn test_target_name_empty_rejected() {
252 assert!(TargetName::new("").is_err());
253 }
254
255 #[test]
256 fn test_target_name_display_roundtrip() {
257 let name = tname("build");
258 let roundtrip: TargetName = name.to_string().parse().unwrap();
259 assert_eq!(name, roundtrip);
260 }
261
262 #[test]
263 fn test_depends_on_local() {
264 let d = dep("build");
265 assert!(!d.is_upstream());
266 assert_eq!(d.target_name().as_str(), "build");
267 assert_eq!(d.to_string(), "build");
268 }
269
270 #[test]
271 fn test_depends_on_upstream() {
272 let d = dep("^build");
273 assert!(d.is_upstream());
274 assert_eq!(d.target_name().as_str(), "build");
275 assert_eq!(d.to_string(), "^build");
276 }
277
278 #[test]
279 fn test_depends_on_empty_rejected() {
280 assert!(DependsOn::from_str("").is_err());
281 }
282
283 #[test]
284 fn test_depends_on_display_roundtrip() {
285 let local = dep("test");
286 assert_eq!(local, local.to_string().parse().unwrap());
287
288 let upstream = dep("^build");
289 assert_eq!(upstream, upstream.to_string().parse().unwrap());
290 }
291}