Skip to main content

wdl_modules/
dependency.rs

1//! Dependency names and sources for `module.json`.
2
3use 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/// An error parsing a [`DependencyName`].
20#[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
24/// Returns `true` if `s` matches the dependency-name grammar
25/// `[A-Za-z][A-Za-z0-9_-]*`.
26fn 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/// A dependency name.
36///
37/// Dependency names begin with an ASCII letter and continue with ASCII
38/// letters, digits, underscores, or hyphens. Following Cargo's
39/// convention, hyphens and underscores are interchangeable: `spell-book`
40/// and `spell_book` refer to the same dependency.
41///
42/// Two forms are stored: the **manifest** form preserves the exact
43/// spelling from `module.json`, and the **identifier** form replaces
44/// hyphens with underscores to produce a valid WDL identifier suitable
45/// for use in symbolic imports. The identifier form must not be a
46/// reserved keyword.
47///
48/// `Eq`, `Ord`, and `Hash` operate on the **identifier** form only,
49/// so `spell-book` and `spell_book` are the same key in maps and
50/// sets. This enforces the spec rule that hyphens and underscores are
51/// interchangeable for the purpose of identity. Use
52/// [`manifest()`](Self::manifest) when exact-spelling fidelity is
53/// needed (e.g., serialization or display).
54#[derive(Clone, Debug, Serialize, Deserialize)]
55#[serde(into = "String", try_from = "String")]
56pub struct DependencyName {
57    /// The name as written in `module.json`.
58    manifest: String,
59    /// The WDL identifier form (hyphens replaced with underscores).
60    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    /// Returns the name as written in `module.json`.
91    pub fn manifest(&self) -> &str {
92        &self.manifest
93    }
94
95    /// Returns the WDL identifier form of the name (hyphens replaced
96    /// with underscores).
97    pub fn identifier(&self) -> &str {
98        &self.identifier
99    }
100
101    /// Consumes the [`DependencyName`] and returns the manifest form.
102    pub fn into_manifest(self) -> String {
103        self.manifest
104    }
105
106    /// Consumes the [`DependencyName`] and returns the identifier form.
107    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}