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