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
163impl Display for Preview {
164    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
165        if self.flags.is_empty() {
166            write!(f, "disabled")
167        } else if self.flags.is_all() {
168            write!(f, "enabled")
169        } else {
170            write!(
171                f,
172                "{}",
173                itertools::join(self.flags.iter().map(PreviewFeature::as_str), ",")
174            )
175        }
176    }
177}
178
179#[derive(Debug, Error, Clone)]
180pub enum PreviewParseError {
181    #[error("Empty string in preview features: {0}")]
182    Empty(String),
183}
184
185impl FromStr for Preview {
186    type Err = PreviewParseError;
187
188    fn from_str(s: &str) -> Result<Self, Self::Err> {
189        let mut flags = BitFlags::empty();
190
191        for part in s.split(',') {
192            let part = part.trim();
193            if part.is_empty() {
194                return Err(PreviewParseError::Empty(
195                    "Empty string in preview features".to_string(),
196                ));
197            }
198
199            match PreviewFeature::from_str(part) {
200                Ok(flag) => flags |= flag,
201                Err(_) => {
202                    warn_user_once!("Unknown preview feature: `{part}`");
203                }
204            }
205        }
206
207        Ok(Self { flags })
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_preview_feature_from_str() {
217        let features = PreviewFeature::from_str("python-install-default").unwrap();
218        assert_eq!(features, PreviewFeature::PythonInstallDefault);
219    }
220
221    #[test]
222    fn test_preview_from_str() {
223        // Test single feature
224        let preview = Preview::from_str("python-install-default").unwrap();
225        assert_eq!(preview.flags, PreviewFeature::PythonInstallDefault);
226
227        // Test multiple features
228        let preview = Preview::from_str("python-upgrade,json-output").unwrap();
229        assert!(preview.is_enabled(PreviewFeature::PythonUpgrade));
230        assert!(preview.is_enabled(PreviewFeature::JsonOutput));
231        assert_eq!(preview.flags.bits().count_ones(), 2);
232
233        // Test with whitespace
234        let preview = Preview::from_str("pylock , add-bounds").unwrap();
235        assert!(preview.is_enabled(PreviewFeature::Pylock));
236        assert!(preview.is_enabled(PreviewFeature::AddBounds));
237
238        // Test empty string error
239        assert!(Preview::from_str("").is_err());
240        assert!(Preview::from_str("pylock,").is_err());
241        assert!(Preview::from_str(",pylock").is_err());
242
243        // Test unknown feature (should be ignored with warning)
244        let preview = Preview::from_str("unknown-feature,pylock").unwrap();
245        assert!(preview.is_enabled(PreviewFeature::Pylock));
246        assert_eq!(preview.flags.bits().count_ones(), 1);
247    }
248
249    #[test]
250    fn test_preview_display() {
251        // Test disabled
252        let preview = Preview::default();
253        assert_eq!(preview.to_string(), "disabled");
254        let preview = Preview::new(&[]);
255        assert_eq!(preview.to_string(), "disabled");
256
257        // Test enabled (all features)
258        let preview = Preview::all();
259        assert_eq!(preview.to_string(), "enabled");
260
261        // Test single feature
262        let preview = Preview::new(&[PreviewFeature::PythonInstallDefault]);
263        assert_eq!(preview.to_string(), "python-install-default");
264
265        // Test multiple features
266        let preview = Preview::new(&[PreviewFeature::PythonUpgrade, PreviewFeature::Pylock]);
267        assert_eq!(preview.to_string(), "python-upgrade,pylock");
268    }
269
270    #[test]
271    fn test_preview_from_args() {
272        // Test no preview and no no_preview, and no features
273        let preview = Preview::from_args(false, false, &[]);
274        assert_eq!(preview.to_string(), "disabled");
275
276        // Test no_preview
277        let preview = Preview::from_args(true, true, &[]);
278        assert_eq!(preview.to_string(), "disabled");
279
280        // Test preview (all features)
281        let preview = Preview::from_args(true, false, &[]);
282        assert_eq!(preview.to_string(), "enabled");
283
284        // Test specific features
285        let features = vec![PreviewFeature::PythonUpgrade, PreviewFeature::JsonOutput];
286        let preview = Preview::from_args(false, false, &features);
287        assert!(preview.is_enabled(PreviewFeature::PythonUpgrade));
288        assert!(preview.is_enabled(PreviewFeature::JsonOutput));
289        assert!(!preview.is_enabled(PreviewFeature::Pylock));
290    }
291
292    #[test]
293    fn test_preview_feature_as_str() {
294        assert_eq!(
295            PreviewFeature::PythonInstallDefault.as_str(),
296            "python-install-default"
297        );
298        assert_eq!(PreviewFeature::PythonUpgrade.as_str(), "python-upgrade");
299        assert_eq!(PreviewFeature::JsonOutput.as_str(), "json-output");
300        assert_eq!(PreviewFeature::Pylock.as_str(), "pylock");
301        assert_eq!(PreviewFeature::AddBounds.as_str(), "add-bounds");
302        assert_eq!(
303            PreviewFeature::PackageConflicts.as_str(),
304            "package-conflicts"
305        );
306        assert_eq!(
307            PreviewFeature::ExtraBuildDependencies.as_str(),
308            "extra-build-dependencies"
309        );
310        assert_eq!(
311            PreviewFeature::DetectModuleConflicts.as_str(),
312            "detect-module-conflicts"
313        );
314        assert_eq!(PreviewFeature::Format.as_str(), "format");
315        assert_eq!(PreviewFeature::NativeAuth.as_str(), "native-auth");
316        assert_eq!(PreviewFeature::S3Endpoint.as_str(), "s3-endpoint");
317        assert_eq!(PreviewFeature::CacheSize.as_str(), "cache-size");
318        assert_eq!(
319            PreviewFeature::InitProjectFlag.as_str(),
320            "init-project-flag"
321        );
322        assert_eq!(
323            PreviewFeature::WorkspaceMetadata.as_str(),
324            "workspace-metadata"
325        );
326        assert_eq!(PreviewFeature::WorkspaceDir.as_str(), "workspace-dir");
327        assert_eq!(PreviewFeature::WorkspaceList.as_str(), "workspace-list");
328        assert_eq!(PreviewFeature::SbomExport.as_str(), "sbom-export");
329        assert_eq!(PreviewFeature::AuthHelper.as_str(), "auth-helper");
330        assert_eq!(PreviewFeature::DirectPublish.as_str(), "direct-publish");
331        assert_eq!(
332            PreviewFeature::TargetWorkspaceDiscovery.as_str(),
333            "target-workspace-discovery"
334        );
335        assert_eq!(PreviewFeature::MetadataJson.as_str(), "metadata-json");
336        assert_eq!(PreviewFeature::GcsEndpoint.as_str(), "gcs-endpoint");
337        assert_eq!(PreviewFeature::AdjustUlimit.as_str(), "adjust-ulimit");
338        assert_eq!(
339            PreviewFeature::SpecialCondaEnvNames.as_str(),
340            "special-conda-env-names"
341        );
342        assert_eq!(
343            PreviewFeature::RelocatableEnvsDefault.as_str(),
344            "relocatable-envs-default"
345        );
346    }
347}