Skip to main content

wdl_modules/
dependency.rs

1//! Dependency names and sources for `module.json`.
2
3use 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/// An error parsing a [`DependencyName`].
21#[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
25/// Returns `true` if `s` matches the dependency-name grammar
26/// `[A-Za-z][A-Za-z0-9_-]*`.
27fn 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/// A dependency name.
37///
38/// Dependency names begin with an ASCII letter and continue with ASCII
39/// letters, digits, underscores, or hyphens. Following Cargo's
40/// convention, hyphens and underscores are interchangeable: `spell-book`
41/// and `spell_book` refer to the same dependency.
42///
43/// Two forms are stored: the **manifest** form preserves the exact
44/// spelling from `module.json`, and the **identifier** form replaces
45/// hyphens with underscores to produce a valid WDL identifier suitable
46/// for use in symbolic imports. The identifier form must not be a
47/// reserved keyword.
48///
49/// `Eq`, `Ord`, and `Hash` operate on the **identifier** form only,
50/// so `spell-book` and `spell_book` are the same key in maps and
51/// sets. This enforces the spec rule that hyphens and underscores are
52/// interchangeable for the purpose of identity. Use
53/// [`manifest()`](Self::manifest) when exact-spelling fidelity is
54/// needed (e.g., serialization or display).
55#[derive(Clone, Debug, SerializeDisplay, DeserializeFromStr)]
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 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}