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