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