uv_preview/
lib.rs

1use std::{
2    fmt::{Display, Formatter},
3    str::FromStr,
4};
5
6use thiserror::Error;
7use uv_warnings::warn_user_once;
8
9bitflags::bitflags! {
10    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11    pub struct PreviewFeatures: u32 {
12        const PYTHON_INSTALL_DEFAULT = 1 << 0;
13        const PYTHON_UPGRADE = 1 << 1;
14        const JSON_OUTPUT = 1 << 2;
15        const PYLOCK = 1 << 3;
16        const ADD_BOUNDS = 1 << 4;
17        const PACKAGE_CONFLICTS = 1 << 5;
18        const EXTRA_BUILD_DEPENDENCIES = 1 << 6;
19        const DETECT_MODULE_CONFLICTS = 1 << 7;
20        const FORMAT = 1 << 8;
21        const NATIVE_AUTH = 1 << 9;
22        const S3_ENDPOINT = 1 << 10;
23        const CACHE_SIZE = 1 << 11;
24        const INIT_PROJECT_FLAG = 1 << 12;
25        const WORKSPACE_METADATA = 1 << 13;
26        const WORKSPACE_DIR = 1 << 14;
27        const WORKSPACE_LIST = 1 << 15;
28        const SBOM_EXPORT = 1 << 16;
29    }
30}
31
32impl PreviewFeatures {
33    /// Returns the string representation of a single preview feature flag.
34    ///
35    /// Panics if given a combination of flags.
36    fn flag_as_str(self) -> &'static str {
37        match self {
38            Self::PYTHON_INSTALL_DEFAULT => "python-install-default",
39            Self::PYTHON_UPGRADE => "python-upgrade",
40            Self::JSON_OUTPUT => "json-output",
41            Self::PYLOCK => "pylock",
42            Self::ADD_BOUNDS => "add-bounds",
43            Self::PACKAGE_CONFLICTS => "package-conflicts",
44            Self::EXTRA_BUILD_DEPENDENCIES => "extra-build-dependencies",
45            Self::DETECT_MODULE_CONFLICTS => "detect-module-conflicts",
46            Self::FORMAT => "format",
47            Self::NATIVE_AUTH => "native-auth",
48            Self::S3_ENDPOINT => "s3-endpoint",
49            Self::CACHE_SIZE => "cache-size",
50            Self::INIT_PROJECT_FLAG => "init-project-flag",
51            Self::WORKSPACE_METADATA => "workspace-metadata",
52            Self::WORKSPACE_DIR => "workspace-dir",
53            Self::WORKSPACE_LIST => "workspace-list",
54            Self::SBOM_EXPORT => "sbom-export",
55            _ => panic!("`flag_as_str` can only be used for exactly one feature flag"),
56        }
57    }
58}
59
60impl Display for PreviewFeatures {
61    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
62        if self.is_empty() {
63            write!(f, "none")
64        } else {
65            let features: Vec<&str> = self.iter().map(Self::flag_as_str).collect();
66            write!(f, "{}", features.join(","))
67        }
68    }
69}
70
71#[derive(Debug, Error, Clone)]
72pub enum PreviewFeaturesParseError {
73    #[error("Empty string in preview features: {0}")]
74    Empty(String),
75}
76
77impl FromStr for PreviewFeatures {
78    type Err = PreviewFeaturesParseError;
79
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        let mut flags = Self::empty();
82
83        for part in s.split(',') {
84            let part = part.trim();
85            if part.is_empty() {
86                return Err(PreviewFeaturesParseError::Empty(
87                    "Empty string in preview features".to_string(),
88                ));
89            }
90
91            let flag = match part {
92                "python-install-default" => Self::PYTHON_INSTALL_DEFAULT,
93                "python-upgrade" => Self::PYTHON_UPGRADE,
94                "json-output" => Self::JSON_OUTPUT,
95                "pylock" => Self::PYLOCK,
96                "add-bounds" => Self::ADD_BOUNDS,
97                "package-conflicts" => Self::PACKAGE_CONFLICTS,
98                "extra-build-dependencies" => Self::EXTRA_BUILD_DEPENDENCIES,
99                "detect-module-conflicts" => Self::DETECT_MODULE_CONFLICTS,
100                "format" => Self::FORMAT,
101                "native-auth" => Self::NATIVE_AUTH,
102                "s3-endpoint" => Self::S3_ENDPOINT,
103                "cache-size" => Self::CACHE_SIZE,
104                "init-project-flag" => Self::INIT_PROJECT_FLAG,
105                "workspace-metadata" => Self::WORKSPACE_METADATA,
106                "workspace-dir" => Self::WORKSPACE_DIR,
107                "workspace-list" => Self::WORKSPACE_LIST,
108                "sbom-export" => Self::SBOM_EXPORT,
109                _ => {
110                    warn_user_once!("Unknown preview feature: `{part}`");
111                    continue;
112                }
113            };
114            flags |= flag;
115        }
116
117        Ok(flags)
118    }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub struct Preview {
123    flags: PreviewFeatures,
124}
125
126impl Preview {
127    pub fn new(flags: PreviewFeatures) -> Self {
128        Self { flags }
129    }
130
131    pub fn all() -> Self {
132        Self::new(PreviewFeatures::all())
133    }
134
135    pub fn from_args(
136        preview: bool,
137        no_preview: bool,
138        preview_features: &[PreviewFeatures],
139    ) -> Self {
140        if no_preview {
141            return Self::default();
142        }
143
144        if preview {
145            return Self::all();
146        }
147
148        let mut flags = PreviewFeatures::empty();
149
150        for features in preview_features {
151            flags |= *features;
152        }
153
154        Self { flags }
155    }
156
157    pub fn is_enabled(&self, flag: PreviewFeatures) -> bool {
158        self.flags.contains(flag)
159    }
160}
161
162impl Display for Preview {
163    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
164        if self.flags.is_empty() {
165            write!(f, "disabled")
166        } else if self.flags == PreviewFeatures::all() {
167            write!(f, "enabled")
168        } else {
169            write!(f, "{}", self.flags)
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_preview_features_from_str() {
180        // Test single feature
181        let features = PreviewFeatures::from_str("python-install-default").unwrap();
182        assert_eq!(features, PreviewFeatures::PYTHON_INSTALL_DEFAULT);
183
184        // Test multiple features
185        let features = PreviewFeatures::from_str("python-upgrade,json-output").unwrap();
186        assert!(features.contains(PreviewFeatures::PYTHON_UPGRADE));
187        assert!(features.contains(PreviewFeatures::JSON_OUTPUT));
188        assert!(!features.contains(PreviewFeatures::PYLOCK));
189
190        // Test with whitespace
191        let features = PreviewFeatures::from_str("pylock , add-bounds").unwrap();
192        assert!(features.contains(PreviewFeatures::PYLOCK));
193        assert!(features.contains(PreviewFeatures::ADD_BOUNDS));
194
195        // Test empty string error
196        assert!(PreviewFeatures::from_str("").is_err());
197        assert!(PreviewFeatures::from_str("pylock,").is_err());
198        assert!(PreviewFeatures::from_str(",pylock").is_err());
199
200        // Test unknown feature (should be ignored with warning)
201        let features = PreviewFeatures::from_str("unknown-feature,pylock").unwrap();
202        assert!(features.contains(PreviewFeatures::PYLOCK));
203        assert_eq!(features.bits().count_ones(), 1);
204    }
205
206    #[test]
207    fn test_preview_features_display() {
208        // Test empty
209        let features = PreviewFeatures::empty();
210        assert_eq!(features.to_string(), "none");
211
212        // Test single feature
213        let features = PreviewFeatures::PYTHON_INSTALL_DEFAULT;
214        assert_eq!(features.to_string(), "python-install-default");
215
216        // Test multiple features
217        let features = PreviewFeatures::PYTHON_UPGRADE | PreviewFeatures::JSON_OUTPUT;
218        assert_eq!(features.to_string(), "python-upgrade,json-output");
219    }
220
221    #[test]
222    fn test_preview_display() {
223        // Test disabled
224        let preview = Preview::default();
225        assert_eq!(preview.to_string(), "disabled");
226
227        // Test enabled (all features)
228        let preview = Preview::all();
229        assert_eq!(preview.to_string(), "enabled");
230
231        // Test specific features
232        let preview = Preview::new(PreviewFeatures::PYTHON_UPGRADE | PreviewFeatures::PYLOCK);
233        assert_eq!(preview.to_string(), "python-upgrade,pylock");
234    }
235
236    #[test]
237    fn test_preview_from_args() {
238        // Test no_preview
239        let preview = Preview::from_args(true, true, &[]);
240        assert_eq!(preview.to_string(), "disabled");
241
242        // Test preview (all features)
243        let preview = Preview::from_args(true, false, &[]);
244        assert_eq!(preview.to_string(), "enabled");
245
246        // Test specific features
247        let features = vec![
248            PreviewFeatures::PYTHON_UPGRADE,
249            PreviewFeatures::JSON_OUTPUT,
250        ];
251        let preview = Preview::from_args(false, false, &features);
252        assert!(preview.is_enabled(PreviewFeatures::PYTHON_UPGRADE));
253        assert!(preview.is_enabled(PreviewFeatures::JSON_OUTPUT));
254        assert!(!preview.is_enabled(PreviewFeatures::PYLOCK));
255    }
256
257    #[test]
258    fn test_as_str_single_flags() {
259        assert_eq!(
260            PreviewFeatures::PYTHON_INSTALL_DEFAULT.flag_as_str(),
261            "python-install-default"
262        );
263        assert_eq!(
264            PreviewFeatures::PYTHON_UPGRADE.flag_as_str(),
265            "python-upgrade"
266        );
267        assert_eq!(PreviewFeatures::JSON_OUTPUT.flag_as_str(), "json-output");
268        assert_eq!(PreviewFeatures::PYLOCK.flag_as_str(), "pylock");
269        assert_eq!(PreviewFeatures::ADD_BOUNDS.flag_as_str(), "add-bounds");
270        assert_eq!(
271            PreviewFeatures::PACKAGE_CONFLICTS.flag_as_str(),
272            "package-conflicts"
273        );
274        assert_eq!(
275            PreviewFeatures::EXTRA_BUILD_DEPENDENCIES.flag_as_str(),
276            "extra-build-dependencies"
277        );
278        assert_eq!(
279            PreviewFeatures::DETECT_MODULE_CONFLICTS.flag_as_str(),
280            "detect-module-conflicts"
281        );
282        assert_eq!(PreviewFeatures::FORMAT.flag_as_str(), "format");
283        assert_eq!(PreviewFeatures::S3_ENDPOINT.flag_as_str(), "s3-endpoint");
284        assert_eq!(PreviewFeatures::SBOM_EXPORT.flag_as_str(), "sbom-export");
285    }
286
287    #[test]
288    #[should_panic(expected = "`flag_as_str` can only be used for exactly one feature flag")]
289    fn test_as_str_multiple_flags_panics() {
290        let features = PreviewFeatures::PYTHON_UPGRADE | PreviewFeatures::JSON_OUTPUT;
291        let _ = features.flag_as_str();
292    }
293}