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