Skip to main content

uv_preview/
lib.rs

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