nextest_runner/config/
identifier.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::errors::InvalidIdentifier;
5use smol_str::SmolStr;
6use std::fmt;
7use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
8
9/// An identifier used in configuration.
10///
11/// The identifier goes through some basic validation:
12/// * conversion to NFC
13/// * ensuring that it is of the form (XID_Start)(XID_Continue | -)*
14///
15/// Identifiers can also be tool identifiers, which are of the form "@tool:tool-name:identifier".
16#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct ConfigIdentifier(SmolStr);
18
19impl ConfigIdentifier {
20    /// Validates and creates a new identifier.
21    pub fn new(identifier: SmolStr) -> Result<Self, InvalidIdentifier> {
22        let identifier = if is_nfc_quick(identifier.chars()) == IsNormalized::Yes {
23            identifier
24        } else {
25            identifier.nfc().collect::<SmolStr>()
26        };
27
28        if identifier.is_empty() {
29            return Err(InvalidIdentifier::Empty);
30        }
31
32        // Tool identifiers are of the form "@tool:identifier:identifier".
33
34        if let Some(suffix) = identifier.strip_prefix("@tool:") {
35            let mut parts = suffix.splitn(2, ':');
36            let tool_name = parts
37                .next()
38                .expect("at least one identifier should be returned.");
39            let tool_identifier = match parts.next() {
40                Some(tool_identifier) => tool_identifier,
41                None => return Err(InvalidIdentifier::ToolIdentifierInvalidFormat(identifier)),
42            };
43
44            for x in [tool_name, tool_identifier] {
45                Self::is_valid_unicode(x).map_err(|error| match error {
46                    InvalidIdentifierKind::Empty => {
47                        InvalidIdentifier::ToolComponentEmpty(identifier.clone())
48                    }
49                    InvalidIdentifierKind::InvalidXid => {
50                        InvalidIdentifier::ToolIdentifierInvalidXid(identifier.clone())
51                    }
52                })?;
53            }
54        } else {
55            // This should be a regular identifier.
56            Self::is_valid_unicode(&identifier).map_err(|error| match error {
57                InvalidIdentifierKind::Empty => InvalidIdentifier::Empty,
58                InvalidIdentifierKind::InvalidXid => {
59                    InvalidIdentifier::InvalidXid(identifier.clone())
60                }
61            })?;
62        }
63
64        Ok(Self(identifier))
65    }
66
67    /// Returns true if this is a tool identifier.
68    pub fn is_tool_identifier(&self) -> bool {
69        self.0.starts_with("@tool:")
70    }
71
72    /// Returns the tool name and identifier, if this is a tool identifier.
73    pub fn tool_components(&self) -> Option<(&str, &str)> {
74        self.0.strip_prefix("@tool:").map(|suffix| {
75            let mut parts = suffix.splitn(2, ':');
76            let tool_name = parts
77                .next()
78                .expect("identifier was checked to have 2 components above");
79            let tool_identifier = parts
80                .next()
81                .expect("identifier was checked to have 2 components above");
82            (tool_name, tool_identifier)
83        })
84    }
85
86    /// Returns the identifier as a string slice.
87    #[inline]
88    pub fn as_str(&self) -> &str {
89        &self.0
90    }
91
92    fn is_valid_unicode(identifier: &str) -> Result<(), InvalidIdentifierKind> {
93        if identifier.is_empty() {
94            return Err(InvalidIdentifierKind::Empty);
95        }
96
97        let mut first = true;
98        for ch in identifier.chars() {
99            if first {
100                if !unicode_ident::is_xid_start(ch) {
101                    return Err(InvalidIdentifierKind::InvalidXid);
102                }
103                first = false;
104            } else if !(ch == '-' || unicode_ident::is_xid_continue(ch)) {
105                return Err(InvalidIdentifierKind::InvalidXid);
106            }
107        }
108        Ok(())
109    }
110}
111
112impl fmt::Display for ConfigIdentifier {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(f, "{}", self.0)
115    }
116}
117
118impl<'de> serde::Deserialize<'de> for ConfigIdentifier {
119    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
120    where
121        D: serde::Deserializer<'de>,
122    {
123        let identifier = SmolStr::deserialize(deserializer)?;
124        ConfigIdentifier::new(identifier).map_err(serde::de::Error::custom)
125    }
126}
127
128#[derive(Clone, Copy, Debug)]
129enum InvalidIdentifierKind {
130    Empty,
131    InvalidXid,
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use serde::Deserialize;
138
139    #[derive(Deserialize, Debug, PartialEq, Eq)]
140    struct TestDeserialize {
141        identifier: ConfigIdentifier,
142    }
143
144    fn make_json(identifier: &str) -> String {
145        format!(r#"{{ "identifier": "{identifier}" }}"#)
146    }
147
148    #[test]
149    fn test_valid() {
150        let valid_inputs = ["foo", "foo-bar", "Δabc"];
151
152        for &input in &valid_inputs {
153            let identifier = ConfigIdentifier::new(input.into()).unwrap();
154            assert_eq!(identifier.as_str(), input);
155            assert!(!identifier.is_tool_identifier());
156
157            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap();
158        }
159
160        let valid_tool_inputs = ["@tool:foo:bar", "@tool:Δabc_def-ghi:foo-bar"];
161
162        for &input in &valid_tool_inputs {
163            let identifier = ConfigIdentifier::new(input.into()).unwrap();
164            assert_eq!(identifier.as_str(), input);
165            assert!(identifier.is_tool_identifier());
166
167            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap();
168        }
169    }
170
171    #[test]
172    fn test_invalid() {
173        let identifier = ConfigIdentifier::new("".into());
174        assert_eq!(identifier.unwrap_err(), InvalidIdentifier::Empty);
175
176        let invalid_xid = ["foo bar", "_", "-foo", "_foo", "@foo", "@tool"];
177
178        for &input in &invalid_xid {
179            let identifier = ConfigIdentifier::new(input.into());
180            assert_eq!(
181                identifier.unwrap_err(),
182                InvalidIdentifier::InvalidXid(input.into())
183            );
184
185            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap_err();
186        }
187
188        let tool_component_empty = ["@tool::", "@tool:foo:", "@tool::foo"];
189
190        for &input in &tool_component_empty {
191            let identifier = ConfigIdentifier::new(input.into());
192            assert_eq!(
193                identifier.unwrap_err(),
194                InvalidIdentifier::ToolComponentEmpty(input.into())
195            );
196
197            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap_err();
198        }
199
200        let tool_identifier_invalid_format = ["@tool:", "@tool:foo"];
201
202        for &input in &tool_identifier_invalid_format {
203            let identifier = ConfigIdentifier::new(input.into());
204            assert_eq!(
205                identifier.unwrap_err(),
206                InvalidIdentifier::ToolIdentifierInvalidFormat(input.into())
207            );
208
209            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap_err();
210        }
211
212        let tool_identifier_invalid_xid = ["@tool:_foo:bar", "@tool:foo:#bar", "@tool:foo:bar:baz"];
213
214        for &input in &tool_identifier_invalid_xid {
215            let identifier = ConfigIdentifier::new(input.into());
216            assert_eq!(
217                identifier.unwrap_err(),
218                InvalidIdentifier::ToolIdentifierInvalidXid(input.into())
219            );
220
221            serde_json::from_str::<TestDeserialize>(&make_json(input)).unwrap_err();
222        }
223    }
224}