nextest_runner/config/core/
identifier.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{
5    config::utils::{InvalidIdentifierKind, is_valid_identifier_unicode},
6    errors::{InvalidIdentifier, InvalidToolName},
7};
8use smol_str::SmolStr;
9use std::fmt;
10use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
11
12/// An identifier used in configuration.
13///
14/// The identifier goes through some basic validation:
15/// * conversion to NFC
16/// * ensuring that it is of the form (XID_Start)(XID_Continue | -)*
17///
18/// Identifiers can also be tool identifiers, which are of the form "@tool:tool-name:identifier".
19#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub struct ConfigIdentifier(SmolStr);
21
22impl ConfigIdentifier {
23    /// Validates and creates a new identifier.
24    pub fn new(identifier: SmolStr) -> Result<Self, InvalidIdentifier> {
25        let identifier = if is_nfc_quick(identifier.chars()) == IsNormalized::Yes {
26            identifier
27        } else {
28            identifier.nfc().collect::<SmolStr>()
29        };
30
31        if identifier.is_empty() {
32            return Err(InvalidIdentifier::Empty);
33        }
34
35        // Tool identifiers are of the form "@tool:identifier:identifier".
36
37        if let Some(suffix) = identifier.strip_prefix("@tool:") {
38            let mut parts = suffix.splitn(2, ':');
39            let tool_name = parts
40                .next()
41                .expect("at least one identifier should be returned.");
42            let tool_identifier = match parts.next() {
43                Some(tool_identifier) => tool_identifier,
44                None => return Err(InvalidIdentifier::ToolIdentifierInvalidFormat(identifier)),
45            };
46
47            for x in [tool_name, tool_identifier] {
48                is_valid_identifier_unicode(x).map_err(|error| match error {
49                    InvalidIdentifierKind::Empty => {
50                        InvalidIdentifier::ToolComponentEmpty(identifier.clone())
51                    }
52                    InvalidIdentifierKind::InvalidXid => {
53                        InvalidIdentifier::ToolIdentifierInvalidXid(identifier.clone())
54                    }
55                })?;
56            }
57        } else {
58            // This should be a regular identifier.
59            is_valid_identifier_unicode(&identifier).map_err(|error| match error {
60                InvalidIdentifierKind::Empty => InvalidIdentifier::Empty,
61                InvalidIdentifierKind::InvalidXid => {
62                    InvalidIdentifier::InvalidXid(identifier.clone())
63                }
64            })?;
65        }
66
67        Ok(Self(identifier))
68    }
69
70    /// Returns true if this is a tool identifier.
71    pub fn is_tool_identifier(&self) -> bool {
72        self.0.starts_with("@tool:")
73    }
74
75    /// Returns the tool name and identifier, if this is a tool identifier.
76    pub fn tool_components(&self) -> Option<(&str, &str)> {
77        self.0.strip_prefix("@tool:").map(|suffix| {
78            let mut parts = suffix.splitn(2, ':');
79            let tool_name = parts
80                .next()
81                .expect("identifier was checked to have 2 components above");
82            let tool_identifier = parts
83                .next()
84                .expect("identifier was checked to have 2 components above");
85            (tool_name, tool_identifier)
86        })
87    }
88
89    /// Returns the identifier as a string slice.
90    #[inline]
91    pub fn as_str(&self) -> &str {
92        &self.0
93    }
94}
95
96impl fmt::Display for ConfigIdentifier {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(f, "{}", self.0)
99    }
100}
101
102impl<'de> serde::Deserialize<'de> for ConfigIdentifier {
103    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
104    where
105        D: serde::Deserializer<'de>,
106    {
107        let identifier = SmolStr::deserialize(deserializer)?;
108        ConfigIdentifier::new(identifier).map_err(serde::de::Error::custom)
109    }
110}
111
112/// A tool name used in tool configuration files.
113///
114/// Tool names follow the same validation rules as regular identifiers:
115/// * Conversion to NFC.
116/// * Ensuring that it is of the form (XID_Start)(XID_Continue | -)*
117///
118/// Tool names are used in `--tool-config-file` arguments and to validate tool
119/// identifiers in configuration.
120#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
121pub struct ToolName(SmolStr);
122
123impl ToolName {
124    /// Validates and creates a new tool name.
125    pub fn new(name: SmolStr) -> Result<Self, InvalidToolName> {
126        let name = if is_nfc_quick(name.chars()) == IsNormalized::Yes {
127            name
128        } else {
129            name.nfc().collect::<SmolStr>()
130        };
131
132        if name.is_empty() {
133            return Err(InvalidToolName::Empty);
134        }
135
136        // Tool names cannot start with the reserved @tool prefix. Check this
137        // before XID validation for a better error message.
138        if name.starts_with("@tool") {
139            return Err(InvalidToolName::StartsWithToolPrefix(name));
140        }
141
142        // Validate as a regular identifier.
143        is_valid_identifier_unicode(&name).map_err(|error| match error {
144            InvalidIdentifierKind::Empty => InvalidToolName::Empty,
145            InvalidIdentifierKind::InvalidXid => InvalidToolName::InvalidXid(name.clone()),
146        })?;
147
148        Ok(Self(name))
149    }
150
151    /// Returns the tool name as a string slice.
152    #[inline]
153    pub fn as_str(&self) -> &str {
154        &self.0
155    }
156}
157
158impl fmt::Display for ToolName {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        self.0.fmt(f)
161    }
162}
163
164impl<'de> serde::Deserialize<'de> for ToolName {
165    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
166    where
167        D: serde::Deserializer<'de>,
168    {
169        let name = SmolStr::deserialize(deserializer)?;
170        ToolName::new(name).map_err(serde::de::Error::custom)
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use serde::Deserialize;
178
179    #[derive(Deserialize, Debug, PartialEq, Eq)]
180    struct TestDeserialize {
181        identifier: ConfigIdentifier,
182    }
183
184    fn make_json(identifier: &str) -> String {
185        format!(r#"{{ "identifier": "{identifier}" }}"#)
186    }
187
188    #[test]
189    fn test_valid() {
190        let valid_inputs = ["foo", "foo-bar", "Δabc"];
191
192        for &input in &valid_inputs {
193            let identifier = ConfigIdentifier::new(input.into()).unwrap();
194            assert_eq!(identifier.as_str(), input);
195            assert!(!identifier.is_tool_identifier());
196
197            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap();
198        }
199
200        let valid_tool_inputs = ["@tool:foo:bar", "@tool:Δabc_def-ghi:foo-bar"];
201
202        for &input in &valid_tool_inputs {
203            let identifier = ConfigIdentifier::new(input.into()).unwrap();
204            assert_eq!(identifier.as_str(), input);
205            assert!(identifier.is_tool_identifier());
206
207            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap();
208        }
209    }
210
211    #[test]
212    fn test_invalid() {
213        let identifier = ConfigIdentifier::new("".into());
214        assert_eq!(identifier.unwrap_err(), InvalidIdentifier::Empty);
215
216        let invalid_xid = ["foo bar", "_", "-foo", "_foo", "@foo", "@tool"];
217
218        for &input in &invalid_xid {
219            let identifier = ConfigIdentifier::new(input.into());
220            assert_eq!(
221                identifier.unwrap_err(),
222                InvalidIdentifier::InvalidXid(input.into())
223            );
224
225            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap_err();
226        }
227
228        let tool_component_empty = ["@tool::", "@tool:foo:", "@tool::foo"];
229
230        for &input in &tool_component_empty {
231            let identifier = ConfigIdentifier::new(input.into());
232            assert_eq!(
233                identifier.unwrap_err(),
234                InvalidIdentifier::ToolComponentEmpty(input.into())
235            );
236
237            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap_err();
238        }
239
240        let tool_identifier_invalid_format = ["@tool:", "@tool:foo"];
241
242        for &input in &tool_identifier_invalid_format {
243            let identifier = ConfigIdentifier::new(input.into());
244            assert_eq!(
245                identifier.unwrap_err(),
246                InvalidIdentifier::ToolIdentifierInvalidFormat(input.into())
247            );
248
249            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap_err();
250        }
251
252        let tool_identifier_invalid_xid = ["@tool:_foo:bar", "@tool:foo:#bar", "@tool:foo:bar:baz"];
253
254        for &input in &tool_identifier_invalid_xid {
255            let identifier = ConfigIdentifier::new(input.into());
256            assert_eq!(
257                identifier.unwrap_err(),
258                InvalidIdentifier::ToolIdentifierInvalidXid(input.into())
259            );
260
261            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap_err();
262        }
263    }
264
265    #[derive(Deserialize, Debug, PartialEq, Eq)]
266    struct TestToolNameDeserialize {
267        tool_name: ToolName,
268    }
269
270    fn make_tool_name_json(name: &str) -> String {
271        format!(r#"{{ "tool_name": "{name}" }}"#)
272    }
273
274    #[test]
275    fn test_tool_name_valid() {
276        let valid_inputs = ["foo", "foo-bar", "Δabc", "my-tool", "myTool123"];
277
278        for &input in &valid_inputs {
279            let tool_name = ToolName::new(input.into()).unwrap();
280            assert_eq!(tool_name.as_str(), input);
281
282            serde_json::from_str::<TestToolNameDeserialize>(&make_tool_name_json(input)).unwrap();
283        }
284    }
285
286    #[test]
287    fn test_tool_name_invalid() {
288        let tool_name = ToolName::new("".into());
289        assert_eq!(tool_name.unwrap_err(), InvalidToolName::Empty);
290
291        let invalid_xid = ["foo bar", "_", "-foo", "_foo", "@foo"];
292
293        for &input in &invalid_xid {
294            let tool_name = ToolName::new(input.into());
295            assert_eq!(
296                tool_name.unwrap_err(),
297                InvalidToolName::InvalidXid(input.into())
298            );
299
300            serde_json::from_str::<TestToolNameDeserialize>(&make_tool_name_json(input))
301                .unwrap_err();
302        }
303
304        // Tool names cannot start with @tool (with or without colon).
305        let starts_with_tool_prefix = ["@tool", "@tool:foo:bar", "@tool:test:id", "@toolname"];
306
307        for &input in &starts_with_tool_prefix {
308            let tool_name = ToolName::new(input.into());
309            assert_eq!(
310                tool_name.unwrap_err(),
311                InvalidToolName::StartsWithToolPrefix(input.into()),
312                "for input {input:?}"
313            );
314
315            serde_json::from_str::<TestToolNameDeserialize>(&make_tool_name_json(input))
316                .unwrap_err();
317        }
318    }
319}