1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct PythonVirtualEnv {
10 name: PythonVirtualEnvName,
11 kind: PythonVirtualEnvKind,
12 path: Option<PythonVirtualEnvPath>,
13}
14
15impl PythonVirtualEnv {
16 #[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 #[must_use]
28 pub fn with_path(mut self, path: PythonVirtualEnvPath) -> Self {
29 self.path = Some(path);
30 self
31 }
32
33 #[must_use]
35 pub const fn name(&self) -> &PythonVirtualEnvName {
36 &self.name
37 }
38
39 #[must_use]
41 pub const fn kind(&self) -> PythonVirtualEnvKind {
42 self.kind
43 }
44
45 #[must_use]
47 pub const fn path(&self) -> Option<&PythonVirtualEnvPath> {
48 self.path.as_ref()
49 }
50}
51
52#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct PythonVirtualEnvName(String);
55
56impl PythonVirtualEnvName {
57 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 #[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#[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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
152pub struct PythonVirtualEnvPath(String);
153
154impl PythonVirtualEnvPath {
155 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 #[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#[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
229pub enum PythonEnvVar {
230 VirtualEnv,
231 PythonPath,
232 PythonHome,
233}
234
235impl PythonEnvVar {
236 #[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#[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}