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