Skip to main content

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