Skip to main content

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