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