Skip to main content

wdl_modules/dependency/
name.rs

1//! Dependency-name newtype with hyphen-to-underscore normalization.
2
3use std::str::FromStr;
4
5use serde::Deserialize;
6use serde::Serialize;
7use thiserror::Error;
8
9/// An error parsing a [`DependencyName`].
10#[derive(Debug, Error, PartialEq, Eq)]
11#[error("dependency name `{0}` does not match `[A-Za-z][A-Za-z0-9_-]*`")]
12pub struct DependencyNameError(String);
13
14/// Returns `true` if `s` matches the dependency-name grammar
15/// `[A-Za-z][A-Za-z0-9_-]*`.
16fn is_dependency_name(s: &str) -> bool {
17    let mut chars = s.chars();
18    match chars.next() {
19        Some(c) if c.is_ascii_alphabetic() => {}
20        _ => return false,
21    }
22    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
23}
24
25/// A dependency name.
26///
27/// Dependency names begin with an ASCII letter and continue with ASCII
28/// letters, digits, underscores, or hyphens. Following Cargo's
29/// convention, hyphens and underscores are interchangeable: `spell-book`
30/// and `spell_book` refer to the same dependency.
31///
32/// Two forms are stored: the **manifest** form preserves the exact
33/// spelling from `module.json`, and the **identifier** form replaces
34/// hyphens with underscores to produce a valid WDL identifier suitable
35/// for use in symbolic imports. The identifier form must not be a
36/// reserved keyword.
37#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
38#[serde(into = "String", try_from = "String")]
39pub struct DependencyName {
40    /// The name as written in `module.json`.
41    manifest: String,
42    /// The WDL identifier form (hyphens replaced with underscores).
43    identifier: String,
44}
45
46impl DependencyName {
47    /// Returns the name as written in `module.json`.
48    pub fn manifest(&self) -> &str {
49        &self.manifest
50    }
51
52    /// Returns the WDL identifier form of the name (hyphens replaced
53    /// with underscores).
54    pub fn identifier(&self) -> &str {
55        &self.identifier
56    }
57
58    /// Consumes the [`DependencyName`] and returns the manifest form.
59    pub fn into_manifest(self) -> String {
60        self.manifest
61    }
62
63    /// Consumes the [`DependencyName`] and returns the identifier form.
64    pub fn into_identifier(self) -> String {
65        self.identifier
66    }
67}
68
69impl TryFrom<String> for DependencyName {
70    type Error = DependencyNameError;
71
72    fn try_from(s: String) -> Result<Self, Self::Error> {
73        if !is_dependency_name(&s) {
74            return Err(DependencyNameError(s));
75        }
76        let identifier = s.replace('-', "_");
77        if !wdl_grammar::lexer::v1::is_ident(&identifier) {
78            return Err(DependencyNameError(s));
79        }
80        Ok(Self {
81            manifest: s,
82            identifier,
83        })
84    }
85}
86
87impl FromStr for DependencyName {
88    type Err = DependencyNameError;
89
90    fn from_str(s: &str) -> Result<Self, Self::Err> {
91        Self::try_from(s.to_string())
92    }
93}
94
95impl From<DependencyName> for String {
96    fn from(name: DependencyName) -> Self {
97        name.manifest
98    }
99}
100
101impl AsRef<str> for DependencyName {
102    fn as_ref(&self) -> &str {
103        &self.manifest
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn accepts_valid_names() {
113        for name in [
114            "a",
115            "spellbook",
116            "spell_book",
117            "spell-book",
118            "Spell2",
119            "X_1_2_3",
120            "my-crate",
121        ] {
122            assert!(name.parse::<DependencyName>().is_ok(), "rejected `{name}`");
123        }
124    }
125
126    #[test]
127    fn normalizes_hyphens_to_underscores() {
128        let hyphen: DependencyName = "spell-book".parse().unwrap();
129        let underscore: DependencyName = "spell_book".parse().unwrap();
130        assert_eq!(hyphen.identifier(), "spell_book");
131        assert_eq!(hyphen.manifest(), "spell-book");
132        assert_eq!(underscore.manifest(), "spell_book");
133    }
134
135    #[test]
136    fn rejects_invalid_format() {
137        for bad in [
138            "",
139            "1spellbook",
140            "_spellbook",
141            "-spellbook",
142            "spell book",
143            "spell.book",
144            "spell/book",
145        ] {
146            assert!(bad.parse::<DependencyName>().is_err(), "accepted `{bad}`");
147        }
148    }
149
150    #[test]
151    fn rejects_reserved_keywords() {
152        for bad in ["task", "workflow", "import", "if", "as"] {
153            assert!(
154                bad.parse::<DependencyName>().is_err(),
155                "accepted reserved keyword `{bad}` as a dependency name"
156            );
157        }
158    }
159
160    #[test]
161    fn round_trips_via_serde() {
162        let name: DependencyName = "spell-book".parse().unwrap();
163        let json = serde_json::to_string(&name).unwrap();
164        assert_eq!(json, r#""spell-book""#);
165        let parsed: DependencyName = serde_json::from_str(&json).unwrap();
166        assert_eq!(parsed, name);
167        assert_eq!(parsed.manifest(), "spell-book");
168    }
169
170    #[test]
171    fn deserialize_rejects_invalid() {
172        let err = serde_json::from_str::<DependencyName>(r#""1spellbook""#).unwrap_err();
173        assert!(err.to_string().contains("does not match"));
174    }
175}