wdl_modules/
dependency.rs1use std::hash::Hash;
4use std::hash::Hasher;
5use std::str::FromStr;
6
7use serde::Deserialize;
8use serde::Serialize;
9use thiserror::Error;
10
11mod source;
12
13pub use source::DependencySource;
14pub use source::DependencySourceError;
15pub use source::GitModulePath;
16pub use source::GitModulePathError;
17pub use source::GitSelector;
18
19#[derive(Debug, Error, PartialEq, Eq)]
21#[error("dependency name `{0}` does not match `[A-Za-z][A-Za-z0-9_-]*`")]
22pub struct DependencyNameError(String);
23
24fn is_dependency_name(s: &str) -> bool {
27 let mut chars = s.chars();
28 match chars.next() {
29 Some(c) if c.is_ascii_alphabetic() => {}
30 _ => return false,
31 }
32 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
33}
34
35#[derive(Clone, Debug, Serialize, Deserialize)]
55#[serde(into = "String", try_from = "String")]
56pub struct DependencyName {
57 manifest: String,
59 identifier: String,
61}
62
63impl PartialEq for DependencyName {
64 fn eq(&self, other: &Self) -> bool {
65 self.identifier == other.identifier
66 }
67}
68
69impl Eq for DependencyName {}
70
71impl PartialOrd for DependencyName {
72 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
73 Some(self.cmp(other))
74 }
75}
76
77impl Ord for DependencyName {
78 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
79 self.identifier.cmp(&other.identifier)
80 }
81}
82
83impl Hash for DependencyName {
84 fn hash<H: Hasher>(&self, state: &mut H) {
85 self.identifier.hash(state);
86 }
87}
88
89impl DependencyName {
90 pub fn manifest(&self) -> &str {
92 &self.manifest
93 }
94
95 pub fn identifier(&self) -> &str {
98 &self.identifier
99 }
100
101 pub fn into_manifest(self) -> String {
103 self.manifest
104 }
105
106 pub fn into_identifier(self) -> String {
108 self.identifier
109 }
110}
111
112impl TryFrom<String> for DependencyName {
113 type Error = DependencyNameError;
114
115 fn try_from(s: String) -> Result<Self, Self::Error> {
116 if !is_dependency_name(&s) {
117 return Err(DependencyNameError(s));
118 }
119 let identifier = s.replace('-', "_");
120 if !wdl_grammar::lexer::v1::is_ident(&identifier) {
121 return Err(DependencyNameError(s));
122 }
123 Ok(Self {
124 manifest: s,
125 identifier,
126 })
127 }
128}
129
130impl FromStr for DependencyName {
131 type Err = DependencyNameError;
132
133 fn from_str(s: &str) -> Result<Self, Self::Err> {
134 Self::try_from(s.to_string())
135 }
136}
137
138impl From<DependencyName> for String {
139 fn from(name: DependencyName) -> Self {
140 name.manifest
141 }
142}
143
144impl AsRef<str> for DependencyName {
145 fn as_ref(&self) -> &str {
146 &self.manifest
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn accepts_valid_names() {
156 for name in [
157 "a",
158 "spellbook",
159 "spell_book",
160 "spell-book",
161 "Spell2",
162 "X_1_2_3",
163 "my-crate",
164 ] {
165 assert!(name.parse::<DependencyName>().is_ok(), "rejected `{name}`");
166 }
167 }
168
169 #[test]
170 fn normalizes_hyphens_to_underscores() {
171 let hyphen: DependencyName = "spell-book".parse().unwrap();
172 let underscore: DependencyName = "spell_book".parse().unwrap();
173 assert_eq!(hyphen.identifier(), "spell_book");
174 assert_eq!(hyphen.manifest(), "spell-book");
175 assert_eq!(underscore.manifest(), "spell_book");
176 }
177
178 #[test]
179 fn hyphen_and_underscore_are_equal() {
180 let hyphen: DependencyName = "spell-book".parse().unwrap();
181 let underscore: DependencyName = "spell_book".parse().unwrap();
182 assert_eq!(hyphen, underscore);
183 assert_eq!(hyphen.cmp(&underscore), std::cmp::Ordering::Equal);
184 }
185
186 #[test]
187 fn rejects_invalid_format() {
188 for bad in [
189 "",
190 "1spellbook",
191 "_spellbook",
192 "-spellbook",
193 "spell book",
194 "spell.book",
195 "spell/book",
196 ] {
197 assert!(bad.parse::<DependencyName>().is_err(), "accepted `{bad}`");
198 }
199 }
200
201 #[test]
202 fn rejects_reserved_keywords() {
203 for bad in ["task", "workflow", "import", "if", "as"] {
204 assert!(
205 bad.parse::<DependencyName>().is_err(),
206 "accepted reserved keyword `{bad}` as a dependency name"
207 );
208 }
209 }
210
211 #[test]
212 fn round_trips_via_serde() {
213 let name: DependencyName = "spell-book".parse().unwrap();
214 let json = serde_json::to_string(&name).unwrap();
215 assert_eq!(json, r#""spell-book""#);
216 let parsed: DependencyName = serde_json::from_str(&json).unwrap();
217 assert_eq!(parsed, name);
218 assert_eq!(parsed.manifest(), "spell-book");
219 }
220
221 #[test]
222 fn deserialize_rejects_invalid() {
223 let err = serde_json::from_str::<DependencyName>(r#""1spellbook""#).unwrap_err();
224 assert!(err.to_string().contains("does not match"));
225 }
226}