Skip to main content

uv_preview/
lib.rs

1use std::{
2    fmt::{Debug, Display, Formatter},
3    ops::BitOr,
4    str::FromStr,
5};
6
7use enumflags2::{BitFlags, bitflags};
8use thiserror::Error;
9use uv_warnings::warn_user_once;
10
11#[bitflags]
12#[repr(u32)]
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PreviewFeature {
15    PythonInstallDefault = 1 << 0,
16    PythonUpgrade = 1 << 1,
17    JsonOutput = 1 << 2,
18    Pylock = 1 << 3,
19    AddBounds = 1 << 4,
20    PackageConflicts = 1 << 5,
21    ExtraBuildDependencies = 1 << 6,
22    DetectModuleConflicts = 1 << 7,
23    Format = 1 << 8,
24    NativeAuth = 1 << 9,
25    S3Endpoint = 1 << 10,
26    CacheSize = 1 << 11,
27    InitProjectFlag = 1 << 12,
28    WorkspaceMetadata = 1 << 13,
29    WorkspaceDir = 1 << 14,
30    WorkspaceList = 1 << 15,
31    SbomExport = 1 << 16,
32    AuthHelper = 1 << 17,
33    DirectPublish = 1 << 18,
34    TargetWorkspaceDiscovery = 1 << 19,
35    MetadataJson = 1 << 20,
36    GcsEndpoint = 1 << 21,
37    AdjustUlimit = 1 << 22,
38    SpecialCondaEnvNames = 1 << 23,
39}
40
41impl PreviewFeature {
42    /// Returns the string representation of a single preview feature flag.
43    fn as_str(self) -> &'static str {
44        match self {
45            Self::PythonInstallDefault => "python-install-default",
46            Self::PythonUpgrade => "python-upgrade",
47            Self::JsonOutput => "json-output",
48            Self::Pylock => "pylock",
49            Self::AddBounds => "add-bounds",
50            Self::PackageConflicts => "package-conflicts",
51            Self::ExtraBuildDependencies => "extra-build-dependencies",
52            Self::DetectModuleConflicts => "detect-module-conflicts",
53            Self::Format => "format",
54            Self::NativeAuth => "native-auth",
55            Self::S3Endpoint => "s3-endpoint",
56            Self::CacheSize => "cache-size",
57            Self::InitProjectFlag => "init-project-flag",
58            Self::WorkspaceMetadata => "workspace-metadata",
59            Self::WorkspaceDir => "workspace-dir",
60            Self::WorkspaceList => "workspace-list",
61            Self::SbomExport => "sbom-export",
62            Self::AuthHelper => "auth-helper",
63            Self::DirectPublish => "direct-publish",
64            Self::TargetWorkspaceDiscovery => "target-workspace-discovery",
65            Self::MetadataJson => "metadata-json",
66            Self::GcsEndpoint => "gcs-endpoint",
67            Self::AdjustUlimit => "adjust-ulimit",
68            Self::SpecialCondaEnvNames => "special-conda-env-names",
69        }
70    }
71}
72
73impl Display for PreviewFeature {
74    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
75        write!(f, "{}", self.as_str())
76    }
77}
78
79#[derive(Debug, Error, Clone)]
80#[error("Unknown feature flag")]
81pub struct PreviewFeatureParseError;
82
83impl FromStr for PreviewFeature {
84    type Err = PreviewFeatureParseError;
85
86    fn from_str(s: &str) -> Result<Self, Self::Err> {
87        Ok(match s {
88            "python-install-default" => Self::PythonInstallDefault,
89            "python-upgrade" => Self::PythonUpgrade,
90            "json-output" => Self::JsonOutput,
91            "pylock" => Self::Pylock,
92            "add-bounds" => Self::AddBounds,
93            "package-conflicts" => Self::PackageConflicts,
94            "extra-build-dependencies" => Self::ExtraBuildDependencies,
95            "detect-module-conflicts" => Self::DetectModuleConflicts,
96            "format" => Self::Format,
97            "native-auth" => Self::NativeAuth,
98            "s3-endpoint" => Self::S3Endpoint,
99            "gcs-endpoint" => Self::GcsEndpoint,
100            "cache-size" => Self::CacheSize,
101            "init-project-flag" => Self::InitProjectFlag,
102            "workspace-metadata" => Self::WorkspaceMetadata,
103            "workspace-dir" => Self::WorkspaceDir,
104            "workspace-list" => Self::WorkspaceList,
105            "sbom-export" => Self::SbomExport,
106            "auth-helper" => Self::AuthHelper,
107            "direct-publish" => Self::DirectPublish,
108            "target-workspace-discovery" => Self::TargetWorkspaceDiscovery,
109            "metadata-json" => Self::MetadataJson,
110            "adjust-ulimit" => Self::AdjustUlimit,
111            "special-conda-env-names" => Self::SpecialCondaEnvNames,
112            _ => return Err(PreviewFeatureParseError),
113        })
114    }
115}
116
117#[derive(Clone, Copy, PartialEq, Eq, Default)]
118pub struct Preview {
119    flags: BitFlags<PreviewFeature>,
120}
121
122impl Debug for Preview {
123    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
124        let flags: Vec<_> = self.flags.iter().collect();
125        f.debug_struct("Preview").field("flags", &flags).finish()
126    }
127}
128
129impl Preview {
130    pub fn new(flags: &[PreviewFeature]) -> Self {
131        Self {
132            flags: flags.iter().copied().fold(BitFlags::empty(), BitOr::bitor),
133        }
134    }
135
136    pub fn all() -> Self {
137        Self {
138            flags: BitFlags::all(),
139        }
140    }
141
142    pub fn from_args(preview: bool, no_preview: bool, preview_features: &[PreviewFeature]) -> Self {
143        if no_preview {
144            return Self::default();
145        }
146
147        if preview {
148            return Self::all();
149        }
150
151        Self::new(preview_features)
152    }
153
154    /// Check if a single feature is enabled
155    pub fn is_enabled(&self, flag: PreviewFeature) -> bool {
156        self.flags.contains(flag)
157    }
158}
159
160impl Display for Preview {
161    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
162        if self.flags.is_empty() {
163            write!(f, "disabled")
164        } else if self.flags.is_all() {
165            write!(f, "enabled")
166        } else {
167            write!(
168                f,
169                "{}",
170                itertools::join(self.flags.iter().map(PreviewFeature::as_str), ",")
171            )
172        }
173    }
174}
175
176#[derive(Debug, Error, Clone)]
177pub enum PreviewParseError {
178    #[error("Empty string in preview features: {0}")]
179    Empty(String),
180}
181
182impl FromStr for Preview {
183    type Err = PreviewParseError;
184
185    fn from_str(s: &str) -> Result<Self, Self::Err> {
186        let mut flags = BitFlags::empty();
187
188        for part in s.split(',') {
189            let part = part.trim();
190            if part.is_empty() {
191                return Err(PreviewParseError::Empty(
192                    "Empty string in preview features".to_string(),
193                ));
194            }
195
196            match PreviewFeature::from_str(part) {
197                Ok(flag) => flags |= flag,
198                Err(_) => {
199                    warn_user_once!("Unknown preview feature: `{part}`");
200                }
201            }
202        }
203
204        Ok(Self { flags })
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_preview_feature_from_str() {
214        let features = PreviewFeature::from_str("python-install-default").unwrap();
215        assert_eq!(features, PreviewFeature::PythonInstallDefault);
216    }
217
218    #[test]
219    fn test_preview_from_str() {
220        // Test single feature
221        let preview = Preview::from_str("python-install-default").unwrap();
222        assert_eq!(preview.flags, PreviewFeature::PythonInstallDefault);
223
224        // Test multiple features
225        let preview = Preview::from_str("python-upgrade,json-output").unwrap();
226        assert!(preview.is_enabled(PreviewFeature::PythonUpgrade));
227        assert!(preview.is_enabled(PreviewFeature::JsonOutput));
228        assert_eq!(preview.flags.bits().count_ones(), 2);
229
230        // Test with whitespace
231        let preview = Preview::from_str("pylock , add-bounds").unwrap();
232        assert!(preview.is_enabled(PreviewFeature::Pylock));
233        assert!(preview.is_enabled(PreviewFeature::AddBounds));
234
235        // Test empty string error
236        assert!(Preview::from_str("").is_err());
237        assert!(Preview::from_str("pylock,").is_err());
238        assert!(Preview::from_str(",pylock").is_err());
239
240        // Test unknown feature (should be ignored with warning)
241        let preview = Preview::from_str("unknown-feature,pylock").unwrap();
242        assert!(preview.is_enabled(PreviewFeature::Pylock));
243        assert_eq!(preview.flags.bits().count_ones(), 1);
244    }
245
246    #[test]
247    fn test_preview_display() {
248        // Test disabled
249        let preview = Preview::default();
250        assert_eq!(preview.to_string(), "disabled");
251        let preview = Preview::new(&[]);
252        assert_eq!(preview.to_string(), "disabled");
253
254        // Test enabled (all features)
255        let preview = Preview::all();
256        assert_eq!(preview.to_string(), "enabled");
257
258        // Test single feature
259        let preview = Preview::new(&[PreviewFeature::PythonInstallDefault]);
260        assert_eq!(preview.to_string(), "python-install-default");
261
262        // Test multiple features
263        let preview = Preview::new(&[PreviewFeature::PythonUpgrade, PreviewFeature::Pylock]);
264        assert_eq!(preview.to_string(), "python-upgrade,pylock");
265    }
266
267    #[test]
268    fn test_preview_from_args() {
269        // Test no preview and no no_preview, and no features
270        let preview = Preview::from_args(false, false, &[]);
271        assert_eq!(preview.to_string(), "disabled");
272
273        // Test no_preview
274        let preview = Preview::from_args(true, true, &[]);
275        assert_eq!(preview.to_string(), "disabled");
276
277        // Test preview (all features)
278        let preview = Preview::from_args(true, false, &[]);
279        assert_eq!(preview.to_string(), "enabled");
280
281        // Test specific features
282        let features = vec![PreviewFeature::PythonUpgrade, PreviewFeature::JsonOutput];
283        let preview = Preview::from_args(false, false, &features);
284        assert!(preview.is_enabled(PreviewFeature::PythonUpgrade));
285        assert!(preview.is_enabled(PreviewFeature::JsonOutput));
286        assert!(!preview.is_enabled(PreviewFeature::Pylock));
287    }
288
289    #[test]
290    fn test_preview_feature_as_str() {
291        assert_eq!(
292            PreviewFeature::PythonInstallDefault.as_str(),
293            "python-install-default"
294        );
295        assert_eq!(PreviewFeature::PythonUpgrade.as_str(), "python-upgrade");
296        assert_eq!(PreviewFeature::JsonOutput.as_str(), "json-output");
297        assert_eq!(PreviewFeature::Pylock.as_str(), "pylock");
298        assert_eq!(PreviewFeature::AddBounds.as_str(), "add-bounds");
299        assert_eq!(
300            PreviewFeature::PackageConflicts.as_str(),
301            "package-conflicts"
302        );
303        assert_eq!(
304            PreviewFeature::ExtraBuildDependencies.as_str(),
305            "extra-build-dependencies"
306        );
307        assert_eq!(
308            PreviewFeature::DetectModuleConflicts.as_str(),
309            "detect-module-conflicts"
310        );
311        assert_eq!(PreviewFeature::Format.as_str(), "format");
312        assert_eq!(PreviewFeature::NativeAuth.as_str(), "native-auth");
313        assert_eq!(PreviewFeature::S3Endpoint.as_str(), "s3-endpoint");
314        assert_eq!(PreviewFeature::CacheSize.as_str(), "cache-size");
315        assert_eq!(
316            PreviewFeature::InitProjectFlag.as_str(),
317            "init-project-flag"
318        );
319        assert_eq!(
320            PreviewFeature::WorkspaceMetadata.as_str(),
321            "workspace-metadata"
322        );
323        assert_eq!(PreviewFeature::WorkspaceDir.as_str(), "workspace-dir");
324        assert_eq!(PreviewFeature::WorkspaceList.as_str(), "workspace-list");
325        assert_eq!(PreviewFeature::SbomExport.as_str(), "sbom-export");
326        assert_eq!(PreviewFeature::AuthHelper.as_str(), "auth-helper");
327        assert_eq!(PreviewFeature::DirectPublish.as_str(), "direct-publish");
328        assert_eq!(
329            PreviewFeature::TargetWorkspaceDiscovery.as_str(),
330            "target-workspace-discovery"
331        );
332        assert_eq!(PreviewFeature::MetadataJson.as_str(), "metadata-json");
333        assert_eq!(PreviewFeature::GcsEndpoint.as_str(), "gcs-endpoint");
334        assert_eq!(PreviewFeature::AdjustUlimit.as_str(), "adjust-ulimit");
335        assert_eq!(
336            PreviewFeature::SpecialCondaEnvNames.as_str(),
337            "special-conda-env-names"
338        );
339    }
340}