Skip to main content

guild_cli/config/
types.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::ParseError;
7
8/// A validated project name.
9///
10/// Must be non-empty and contain only lowercase alphanumeric characters, hyphens, and underscores.
11#[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/// A validated target name (e.g., "build", "test", "lint").
71///
72/// Must be non-empty and contain only lowercase alphanumeric characters, hyphens, and underscores.
73#[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/// A dependency reference in a target's `depends_on` list.
133///
134/// - `"build"` — depends on the local `build` target in the same project.
135/// - `"^build"` — depends on the `build` target in all dependency projects.
136#[derive(Debug, Clone, PartialEq, Eq, Hash)]
137pub enum DependsOn {
138    /// Depends on a target in the same project.
139    Local(TargetName),
140    /// Depends on a target in upstream dependency projects (prefixed with `^`).
141    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    // Test helpers
209    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}