Skip to main content

use_venv/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Python virtual environment metadata.
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct PythonVirtualEnv {
10    name: PythonVirtualEnvName,
11    kind: PythonVirtualEnvKind,
12    path: Option<PythonVirtualEnvPath>,
13}
14
15impl PythonVirtualEnv {
16    /// Creates virtual environment metadata.
17    #[must_use]
18    pub const fn new(name: PythonVirtualEnvName, kind: PythonVirtualEnvKind) -> Self {
19        Self {
20            name,
21            kind,
22            path: None,
23        }
24    }
25
26    /// Adds path metadata.
27    #[must_use]
28    pub fn with_path(mut self, path: PythonVirtualEnvPath) -> Self {
29        self.path = Some(path);
30        self
31    }
32
33    /// Returns the environment name.
34    #[must_use]
35    pub const fn name(&self) -> &PythonVirtualEnvName {
36        &self.name
37    }
38
39    /// Returns the environment kind.
40    #[must_use]
41    pub const fn kind(&self) -> PythonVirtualEnvKind {
42        self.kind
43    }
44
45    /// Returns path metadata when present.
46    #[must_use]
47    pub const fn path(&self) -> Option<&PythonVirtualEnvPath> {
48        self.path.as_ref()
49    }
50}
51
52/// Validated virtual environment name metadata.
53#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct PythonVirtualEnvName(String);
55
56impl PythonVirtualEnvName {
57    /// Creates virtual environment name metadata.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`PythonVirtualEnvError`] when `input` is empty after trimming or contains path separators.
62    pub fn new(input: &str) -> Result<Self, PythonVirtualEnvError> {
63        let trimmed = input.trim();
64        if trimmed.is_empty() {
65            return Err(PythonVirtualEnvError::Empty);
66        }
67        if trimmed.contains(['/', '\\']) {
68            return Err(PythonVirtualEnvError::ContainsPathSeparator);
69        }
70        Ok(Self(trimmed.to_string()))
71    }
72
73    /// Returns the environment name.
74    #[must_use]
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78}
79
80impl fmt::Display for PythonVirtualEnvName {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        formatter.write_str(self.as_str())
83    }
84}
85
86impl FromStr for PythonVirtualEnvName {
87    type Err = PythonVirtualEnvError;
88
89    fn from_str(input: &str) -> Result<Self, Self::Err> {
90        Self::new(input)
91    }
92}
93
94impl TryFrom<&str> for PythonVirtualEnvName {
95    type Error = PythonVirtualEnvError;
96
97    fn try_from(value: &str) -> Result<Self, Self::Error> {
98        Self::new(value)
99    }
100}
101
102/// Python virtual environment manager kind.
103#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub enum PythonVirtualEnvKind {
105    Venv,
106    Virtualenv,
107    Conda,
108    Poetry,
109    Uv,
110    Pipenv,
111}
112
113impl PythonVirtualEnvKind {
114    /// Returns the environment kind label.
115    #[must_use]
116    pub const fn as_str(self) -> &'static str {
117        match self {
118            Self::Venv => "venv",
119            Self::Virtualenv => "virtualenv",
120            Self::Conda => "conda",
121            Self::Poetry => "poetry",
122            Self::Uv => "uv",
123            Self::Pipenv => "pipenv",
124        }
125    }
126}
127
128impl fmt::Display for PythonVirtualEnvKind {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        formatter.write_str(self.as_str())
131    }
132}
133
134impl FromStr for PythonVirtualEnvKind {
135    type Err = PythonVirtualEnvError;
136
137    fn from_str(input: &str) -> Result<Self, Self::Err> {
138        match normalized_label(input)?.as_str() {
139            "venv" => Ok(Self::Venv),
140            "virtualenv" => Ok(Self::Virtualenv),
141            "conda" => Ok(Self::Conda),
142            "poetry" => Ok(Self::Poetry),
143            "uv" => Ok(Self::Uv),
144            "pipenv" => Ok(Self::Pipenv),
145            _ => Err(PythonVirtualEnvError::UnknownLabel),
146        }
147    }
148}
149
150/// Virtual environment path metadata.
151#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
152pub struct PythonVirtualEnvPath(String);
153
154impl PythonVirtualEnvPath {
155    /// Creates path metadata.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`PythonVirtualEnvError::Empty`] when `input` is empty after trimming.
160    pub fn new(input: &str) -> Result<Self, PythonVirtualEnvError> {
161        let trimmed = input.trim();
162        if trimmed.is_empty() {
163            Err(PythonVirtualEnvError::Empty)
164        } else {
165            Ok(Self(trimmed.to_string()))
166        }
167    }
168
169    /// Returns the path text.
170    #[must_use]
171    pub fn as_str(&self) -> &str {
172        &self.0
173    }
174}
175
176impl fmt::Display for PythonVirtualEnvPath {
177    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
178        formatter.write_str(self.as_str())
179    }
180}
181
182/// Activation shell labels.
183#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
184pub enum PythonActivationShell {
185    Bash,
186    Zsh,
187    Fish,
188    PowerShell,
189    Cmd,
190}
191
192impl PythonActivationShell {
193    /// Returns the activation shell label.
194    #[must_use]
195    pub const fn as_str(self) -> &'static str {
196        match self {
197            Self::Bash => "bash",
198            Self::Zsh => "zsh",
199            Self::Fish => "fish",
200            Self::PowerShell => "powershell",
201            Self::Cmd => "cmd",
202        }
203    }
204}
205
206impl fmt::Display for PythonActivationShell {
207    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
208        formatter.write_str(self.as_str())
209    }
210}
211
212impl FromStr for PythonActivationShell {
213    type Err = PythonVirtualEnvError;
214
215    fn from_str(input: &str) -> Result<Self, Self::Err> {
216        match normalized_label(input)?.as_str() {
217            "bash" => Ok(Self::Bash),
218            "zsh" => Ok(Self::Zsh),
219            "fish" => Ok(Self::Fish),
220            "powershell" | "pwsh" => Ok(Self::PowerShell),
221            "cmd" => Ok(Self::Cmd),
222            _ => Err(PythonVirtualEnvError::UnknownLabel),
223        }
224    }
225}
226
227/// Python environment variable labels.
228#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
229pub enum PythonEnvVar {
230    VirtualEnv,
231    PythonPath,
232    PythonHome,
233}
234
235impl PythonEnvVar {
236    /// Returns the environment variable label.
237    #[must_use]
238    pub const fn as_str(self) -> &'static str {
239        match self {
240            Self::VirtualEnv => "VIRTUAL_ENV",
241            Self::PythonPath => "PYTHONPATH",
242            Self::PythonHome => "PYTHONHOME",
243        }
244    }
245}
246
247impl fmt::Display for PythonEnvVar {
248    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249        formatter.write_str(self.as_str())
250    }
251}
252
253impl FromStr for PythonEnvVar {
254    type Err = PythonVirtualEnvError;
255
256    fn from_str(input: &str) -> Result<Self, Self::Err> {
257        match normalized_label(input)?.as_str() {
258            "virtualenv" => Ok(Self::VirtualEnv),
259            "pythonpath" => Ok(Self::PythonPath),
260            "pythonhome" => Ok(Self::PythonHome),
261            _ => Err(PythonVirtualEnvError::UnknownLabel),
262        }
263    }
264}
265
266/// Error returned when virtual environment metadata is invalid.
267#[derive(Clone, Copy, Debug, Eq, PartialEq)]
268pub enum PythonVirtualEnvError {
269    Empty,
270    ContainsPathSeparator,
271    UnknownLabel,
272}
273
274impl fmt::Display for PythonVirtualEnvError {
275    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Self::Empty => formatter.write_str("virtual environment metadata cannot be empty"),
278            Self::ContainsPathSeparator => {
279                formatter.write_str("virtual environment name cannot contain path separators")
280            }
281            Self::UnknownLabel => formatter.write_str("unknown virtual environment metadata label"),
282        }
283    }
284}
285
286impl Error for PythonVirtualEnvError {}
287
288fn normalized_label(input: &str) -> Result<String, PythonVirtualEnvError> {
289    let trimmed = input.trim();
290    if trimmed.is_empty() {
291        Err(PythonVirtualEnvError::Empty)
292    } else {
293        Ok(trimmed.to_ascii_lowercase().replace(['-', '_', ' '], ""))
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::{
300        PythonActivationShell, PythonEnvVar, PythonVirtualEnv, PythonVirtualEnvError,
301        PythonVirtualEnvKind, PythonVirtualEnvName, PythonVirtualEnvPath,
302    };
303
304    #[test]
305    fn validates_virtual_environment_names() -> Result<(), PythonVirtualEnvError> {
306        let name = PythonVirtualEnvName::new(".venv")?;
307
308        assert_eq!(name.as_str(), ".venv");
309        assert_eq!(
310            PythonVirtualEnvName::new(""),
311            Err(PythonVirtualEnvError::Empty)
312        );
313        assert_eq!(
314            PythonVirtualEnvName::new("env/bin"),
315            Err(PythonVirtualEnvError::ContainsPathSeparator)
316        );
317        Ok(())
318    }
319
320    #[test]
321    fn models_environment_metadata() -> Result<(), PythonVirtualEnvError> {
322        let env = PythonVirtualEnv::new(
323            PythonVirtualEnvName::new(".venv")?,
324            PythonVirtualEnvKind::Venv,
325        )
326        .with_path(PythonVirtualEnvPath::new(".venv")?);
327
328        assert_eq!(env.kind(), PythonVirtualEnvKind::Venv);
329        assert_eq!(
330            "venv".parse::<PythonVirtualEnvKind>()?,
331            PythonVirtualEnvKind::Venv
332        );
333        assert_eq!(PythonActivationShell::PowerShell.to_string(), "powershell");
334        assert_eq!(
335            "pwsh".parse::<PythonActivationShell>()?,
336            PythonActivationShell::PowerShell
337        );
338        assert_eq!(PythonEnvVar::VirtualEnv.to_string(), "VIRTUAL_ENV");
339        assert_eq!(
340            "PYTHONPATH".parse::<PythonEnvVar>()?,
341            PythonEnvVar::PythonPath
342        );
343        Ok(())
344    }
345}