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