Skip to main content

use_pytest/
lib.rs

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            /// Creates pytest identifier metadata.
16            ///
17            /// # Errors
18            ///
19            /// Returns [`PytestNameError::Identifier`] when `input` is not an ASCII-safe Python identifier.
20            pub fn new(input: &str) -> Result<Self, PytestNameError> {
21                PythonIdentifier::new(input)
22                    .map(Self)
23                    .map_err(PytestNameError::Identifier)
24            }
25
26            /// Returns the stored name.
27            #[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/// pytest node ID metadata.
62#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
63pub struct PytestNodeId(String);
64
65impl PytestNodeId {
66    /// Creates pytest node ID metadata.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`PytestNameError::Empty`] when `input` is empty after trimming.
71    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    /// Returns the node ID text.
81    #[must_use]
82    pub fn as_str(&self) -> &str {
83        &self.0
84    }
85
86    /// Returns whether the node ID contains a pytest `::` separator.
87    #[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/// Common pytest config file labels.
116#[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    /// Returns the config file label.
126    #[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/// pytest outcome labels.
159#[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    /// Returns the outcome label.
171    #[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/// pytest fixture scope labels.
207#[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    /// Returns the fixture scope label.
218    #[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/// pytest file-kind labels.
252#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
253pub enum PytestFileKind {
254    TestModule,
255    Conftest,
256    FixtureModule,
257}
258
259impl PytestFileKind {
260    /// Returns the file kind label.
261    #[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/// Error returned when pytest metadata names are invalid.
291#[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}