nextest_runner/config/core/
identifier.rs1use 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#[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 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 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 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 pub fn is_tool_identifier(&self) -> bool {
80 self.0.starts_with("@tool:")
81 }
82
83 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 #[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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
129pub struct ToolName(SmolStr);
130
131impl ToolName {
132 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 if name.starts_with("@tool") {
147 return Err(InvalidToolName::StartsWithToolPrefix(name));
148 }
149
150 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 #[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 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}