1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_python_identifier::{PythonIdentifier, PythonIdentifierError};
8
9macro_rules! pytest_identifier_newtype {
10 ($name:ident) => {
11 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12 pub struct $name(PythonIdentifier);
13
14 impl $name {
15 pub fn new(input: &str) -> Result<Self, PytestNameError> {
21 PythonIdentifier::new(input)
22 .map(Self)
23 .map_err(PytestNameError::Identifier)
24 }
25
26 #[must_use]
28 pub fn as_str(&self) -> &str {
29 self.0.as_str()
30 }
31 }
32
33 impl fmt::Display for $name {
34 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35 formatter.write_str(self.as_str())
36 }
37 }
38
39 impl FromStr for $name {
40 type Err = PytestNameError;
41
42 fn from_str(input: &str) -> Result<Self, Self::Err> {
43 Self::new(input)
44 }
45 }
46
47 impl TryFrom<&str> for $name {
48 type Error = PytestNameError;
49
50 fn try_from(value: &str) -> Result<Self, Self::Error> {
51 Self::new(value)
52 }
53 }
54 };
55}
56
57pytest_identifier_newtype!(PytestTestName);
58pytest_identifier_newtype!(PytestMarkerName);
59pytest_identifier_newtype!(PytestFixtureName);
60
61#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
63pub struct PytestNodeId(String);
64
65impl PytestNodeId {
66 pub fn new(input: &str) -> Result<Self, PytestNameError> {
72 let trimmed = input.trim();
73 if trimmed.is_empty() {
74 Err(PytestNameError::Empty)
75 } else {
76 Ok(Self(trimmed.to_string()))
77 }
78 }
79
80 #[must_use]
82 pub fn as_str(&self) -> &str {
83 &self.0
84 }
85
86 #[must_use]
88 pub fn has_scope_separator(&self) -> bool {
89 self.0.contains("::")
90 }
91}
92
93impl fmt::Display for PytestNodeId {
94 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95 formatter.write_str(self.as_str())
96 }
97}
98
99impl FromStr for PytestNodeId {
100 type Err = PytestNameError;
101
102 fn from_str(input: &str) -> Result<Self, Self::Err> {
103 Self::new(input)
104 }
105}
106
107impl TryFrom<&str> for PytestNodeId {
108 type Error = PytestNameError;
109
110 fn try_from(value: &str) -> Result<Self, Self::Error> {
111 Self::new(value)
112 }
113}
114
115#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
117pub enum PytestConfigFile {
118 PyProjectToml,
119 PytestIni,
120 SetupCfg,
121 ToxIni,
122}
123
124impl PytestConfigFile {
125 #[must_use]
127 pub const fn as_str(self) -> &'static str {
128 match self {
129 Self::PyProjectToml => "pyproject.toml",
130 Self::PytestIni => "pytest.ini",
131 Self::SetupCfg => "setup.cfg",
132 Self::ToxIni => "tox.ini",
133 }
134 }
135}
136
137impl fmt::Display for PytestConfigFile {
138 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139 formatter.write_str(self.as_str())
140 }
141}
142
143impl FromStr for PytestConfigFile {
144 type Err = PytestNameError;
145
146 fn from_str(input: &str) -> Result<Self, Self::Err> {
147 match input.trim().to_ascii_lowercase().as_str() {
148 "pyproject.toml" | "pyprojecttoml" => Ok(Self::PyProjectToml),
149 "pytest.ini" | "pytestini" => Ok(Self::PytestIni),
150 "setup.cfg" | "setupcfg" => Ok(Self::SetupCfg),
151 "tox.ini" | "toxini" => Ok(Self::ToxIni),
152 "" => Err(PytestNameError::Empty),
153 _ => Err(PytestNameError::UnknownLabel),
154 }
155 }
156}
157
158#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
160pub enum PytestOutcome {
161 Passed,
162 Failed,
163 Skipped,
164 XFailed,
165 XPassed,
166 Error,
167}
168
169impl PytestOutcome {
170 #[must_use]
172 pub const fn as_str(self) -> &'static str {
173 match self {
174 Self::Passed => "passed",
175 Self::Failed => "failed",
176 Self::Skipped => "skipped",
177 Self::XFailed => "xfailed",
178 Self::XPassed => "xpassed",
179 Self::Error => "error",
180 }
181 }
182}
183
184impl fmt::Display for PytestOutcome {
185 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186 formatter.write_str(self.as_str())
187 }
188}
189
190impl FromStr for PytestOutcome {
191 type Err = PytestNameError;
192
193 fn from_str(input: &str) -> Result<Self, Self::Err> {
194 match normalized_label(input)?.as_str() {
195 "passed" | "pass" => Ok(Self::Passed),
196 "failed" | "fail" => Ok(Self::Failed),
197 "skipped" | "skip" => Ok(Self::Skipped),
198 "xfailed" | "xfail" => Ok(Self::XFailed),
199 "xpassed" | "xpass" => Ok(Self::XPassed),
200 "error" => Ok(Self::Error),
201 _ => Err(PytestNameError::UnknownLabel),
202 }
203 }
204}
205
206#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
208pub enum PytestScope {
209 Function,
210 Class,
211 Module,
212 Package,
213 Session,
214}
215
216impl PytestScope {
217 #[must_use]
219 pub const fn as_str(self) -> &'static str {
220 match self {
221 Self::Function => "function",
222 Self::Class => "class",
223 Self::Module => "module",
224 Self::Package => "package",
225 Self::Session => "session",
226 }
227 }
228}
229
230impl fmt::Display for PytestScope {
231 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232 formatter.write_str(self.as_str())
233 }
234}
235
236impl FromStr for PytestScope {
237 type Err = PytestNameError;
238
239 fn from_str(input: &str) -> Result<Self, Self::Err> {
240 match normalized_label(input)?.as_str() {
241 "function" => Ok(Self::Function),
242 "class" => Ok(Self::Class),
243 "module" => Ok(Self::Module),
244 "package" => Ok(Self::Package),
245 "session" => Ok(Self::Session),
246 _ => Err(PytestNameError::UnknownLabel),
247 }
248 }
249}
250
251#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
253pub enum PytestFileKind {
254 TestModule,
255 Conftest,
256 FixtureModule,
257}
258
259impl PytestFileKind {
260 #[must_use]
262 pub const fn as_str(self) -> &'static str {
263 match self {
264 Self::TestModule => "test-module",
265 Self::Conftest => "conftest",
266 Self::FixtureModule => "fixture-module",
267 }
268 }
269}
270
271impl fmt::Display for PytestFileKind {
272 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273 formatter.write_str(self.as_str())
274 }
275}
276
277impl FromStr for PytestFileKind {
278 type Err = PytestNameError;
279
280 fn from_str(input: &str) -> Result<Self, Self::Err> {
281 match normalized_label(input)?.as_str() {
282 "testmodule" | "test" => Ok(Self::TestModule),
283 "conftest" => Ok(Self::Conftest),
284 "fixturemodule" | "fixture" => Ok(Self::FixtureModule),
285 _ => Err(PytestNameError::UnknownLabel),
286 }
287 }
288}
289
290#[derive(Clone, Copy, Debug, Eq, PartialEq)]
292pub enum PytestNameError {
293 Empty,
294 Identifier(PythonIdentifierError),
295 UnknownLabel,
296}
297
298impl fmt::Display for PytestNameError {
299 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
300 match self {
301 Self::Empty => formatter.write_str("pytest metadata name cannot be empty"),
302 Self::Identifier(error) => write!(formatter, "invalid pytest identifier: {error}"),
303 Self::UnknownLabel => formatter.write_str("unknown pytest metadata label"),
304 }
305 }
306}
307
308impl Error for PytestNameError {}
309
310fn normalized_label(input: &str) -> Result<String, PytestNameError> {
311 let trimmed = input.trim();
312 if trimmed.is_empty() {
313 Err(PytestNameError::Empty)
314 } else {
315 Ok(trimmed.to_ascii_lowercase().replace(['-', '_', ' '], ""))
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::{
322 PytestConfigFile, PytestFileKind, PytestFixtureName, PytestMarkerName, PytestNameError,
323 PytestNodeId, PytestOutcome, PytestScope, PytestTestName,
324 };
325
326 #[test]
327 fn validates_pytest_identifier_names() -> Result<(), PytestNameError> {
328 let test_name = PytestTestName::new("test_smoke")?;
329 let marker = PytestMarkerName::new("slow")?;
330 let fixture = PytestFixtureName::new("tmp_path")?;
331
332 assert_eq!(test_name.as_str(), "test_smoke");
333 assert_eq!(marker.as_str(), "slow");
334 assert_eq!(fixture.as_str(), "tmp_path");
335 Ok(())
336 }
337
338 #[test]
339 fn validates_node_ids_and_labels() -> Result<(), PytestNameError> {
340 let node_id = PytestNodeId::new("tests/test_app.py::test_smoke")?;
341
342 assert!(node_id.has_scope_separator());
343 assert_eq!(
344 "pyproject.toml".parse::<PytestConfigFile>()?,
345 PytestConfigFile::PyProjectToml
346 );
347 assert_eq!(PytestConfigFile::ToxIni.to_string(), "tox.ini");
348 assert_eq!("xfail".parse::<PytestOutcome>()?, PytestOutcome::XFailed);
349 assert_eq!(PytestOutcome::Passed.to_string(), "passed");
350 assert_eq!("session".parse::<PytestScope>()?, PytestScope::Session);
351 assert_eq!(PytestScope::Function.to_string(), "function");
352 assert_eq!(
353 "fixture-module".parse::<PytestFileKind>()?,
354 PytestFileKind::FixtureModule
355 );
356 assert_eq!(PytestFileKind::Conftest.to_string(), "conftest");
357 Ok(())
358 }
359}