wdl_modules/
dependency.rs1use std::fmt;
4use std::hash::Hash;
5use std::hash::Hasher;
6use std::str::FromStr;
7
8use serde_with::DeserializeFromStr;
9use serde_with::SerializeDisplay;
10use thiserror::Error;
11
12mod source;
13
14pub use source::DependencySource;
15pub use source::DependencySourceError;
16pub use source::GitModulePath;
17pub use source::GitModulePathError;
18pub use source::GitSelector;
19
20#[derive(Debug, Error, PartialEq, Eq)]
22#[error("dependency name `{0}` does not match `[A-Za-z][A-Za-z0-9_-]*`")]
23pub struct DependencyNameError(String);
24
25fn is_dependency_name(s: &str) -> bool {
28 let mut chars = s.chars();
29 match chars.next() {
30 Some(c) if c.is_ascii_alphabetic() => {}
31 _ => return false,
32 }
33 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
34}
35
36#[derive(Clone, Debug, SerializeDisplay, DeserializeFromStr)]
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 FromStr for DependencyName {
113 type Err = DependencyNameError;
114
115 fn from_str(s: &str) -> Result<Self, Self::Err> {
116 if !is_dependency_name(s) {
117 return Err(DependencyNameError(s.to_string()));
118 }
119
120 let identifier = s.replace('-', "_");
121 if !wdl_grammar::lexer::v1::is_ident(&identifier) {
122 return Err(DependencyNameError(s.to_string()));
123 }
124
125 Ok(Self {
126 manifest: s.to_string(),
127 identifier,
128 })
129 }
130}
131
132impl fmt::Display for DependencyName {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 self.manifest.fmt(f)
135 }
136}
137
138impl AsRef<str> for DependencyName {
139 fn as_ref(&self) -> &str {
140 &self.manifest
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn accepts_valid_names() {
150 for name in [
151 "a",
152 "spellbook",
153 "spell_book",
154 "spell-book",
155 "Spell2",
156 "X_1_2_3",
157 "my-crate",
158 ] {
159 assert!(name.parse::<DependencyName>().is_ok(), "rejected `{name}`");
160 }
161 }
162
163 #[test]
164 fn normalizes_hyphens_to_underscores() {
165 let hyphen: DependencyName = "spell-book".parse().unwrap();
166 let underscore: DependencyName = "spell_book".parse().unwrap();
167 assert_eq!(hyphen.identifier(), "spell_book");
168 assert_eq!(hyphen.manifest(), "spell-book");
169 assert_eq!(underscore.manifest(), "spell_book");
170 }
171
172 #[test]
173 fn hyphen_and_underscore_are_equal() {
174 let hyphen: DependencyName = "spell-book".parse().unwrap();
175 let underscore: DependencyName = "spell_book".parse().unwrap();
176 assert_eq!(hyphen, underscore);
177 assert_eq!(hyphen.cmp(&underscore), std::cmp::Ordering::Equal);
178 }
179
180 #[test]
181 fn rejects_invalid_format() {
182 for bad in [
183 "",
184 "1spellbook",
185 "_spellbook",
186 "-spellbook",
187 "spell book",
188 "spell.book",
189 "spell/book",
190 ] {
191 assert!(bad.parse::<DependencyName>().is_err(), "accepted `{bad}`");
192 }
193 }
194
195 #[test]
196 fn rejects_reserved_keywords() {
197 for bad in ["task", "workflow", "import", "if", "as"] {
198 assert!(
199 bad.parse::<DependencyName>().is_err(),
200 "accepted reserved keyword `{bad}` as a dependency name"
201 );
202 }
203 }
204
205 #[test]
206 fn round_trips_via_serde() {
207 let name: DependencyName = "spell-book".parse().unwrap();
208 let json = serde_json::to_string(&name).unwrap();
209 assert_eq!(json, r#""spell-book""#);
210 let parsed: DependencyName = serde_json::from_str(&json).unwrap();
211 assert_eq!(parsed, name);
212 assert_eq!(parsed.manifest(), "spell-book");
213 }
214
215 #[test]
216 fn deserialize_rejects_invalid() {
217 let err = serde_json::from_str::<DependencyName>(r#""1spellbook""#).unwrap_err();
218 assert!(err.to_string().contains("does not match"));
219 }
220}