1use std::sync::{Mutex, OnceLock};
2use std::{
3 fmt::{Debug, Display, Formatter},
4 ops::BitOr,
5 str::FromStr,
6};
7
8use enumflags2::{BitFlags, bitflags};
9use thiserror::Error;
10use uv_warnings::warn_user_once;
11
12enum PreviewState {
14 Provisional(Preview),
15 Final(Preview),
16}
17
18enum PreviewMode {
21 Normal(Mutex<PreviewState>),
23 #[cfg(feature = "testing")]
25 Test(std::sync::RwLock<Option<Preview>>),
26}
27
28static PREVIEW: OnceLock<PreviewMode> = OnceLock::new();
29
30#[derive(Debug, Error)]
32pub enum PreviewError {
33 #[error("The preview configuration has already been finalized")]
35 AlreadyFinalized,
36
37 #[error("The preview configuration has not been initialized yet")]
39 NotInitialized,
40
41 #[cfg(feature = "testing")]
43 #[error("The preview configuration is in test mode and {}::{} cannot be used", module_path!(), .0)]
44 InTest(&'static str),
45}
46
47pub fn set(preview: Preview) -> Result<(), PreviewError> {
51 let mode = PREVIEW.get_or_init(|| {
52 PreviewMode::Normal(Mutex::new(PreviewState::Provisional(Preview::default())))
53 });
54 match mode {
55 PreviewMode::Normal(mutex) => {
56 let mut state = mutex.lock().unwrap();
59 match &*state {
60 PreviewState::Provisional(_) => {
61 *state = PreviewState::Provisional(preview);
62 Ok(())
63 }
64 PreviewState::Final(_) => Err(PreviewError::AlreadyFinalized),
65 }
66 }
67 #[cfg(feature = "testing")]
68 PreviewMode::Test(_) => Err(PreviewError::InTest("set")),
69 }
70}
71
72pub fn finalize() -> Result<(), PreviewError> {
73 match PREVIEW.get().ok_or(PreviewError::NotInitialized)? {
74 PreviewMode::Normal(mutex) => {
75 let mut state = mutex.lock().unwrap();
78 match &*state {
79 PreviewState::Provisional(preview) => {
80 *state = PreviewState::Final(*preview);
81 Ok(())
82 }
83 PreviewState::Final(_) => Err(PreviewError::AlreadyFinalized),
84 }
85 }
86 #[cfg(feature = "testing")]
87 PreviewMode::Test(_) => Err(PreviewError::InTest("finalize")),
88 }
89}
90
91#[derive(Debug, Error)]
93#[error("The preview configuration has already been finalized")]
94pub struct SetError;
95
96pub fn get() -> Preview {
103 match PREVIEW.get() {
104 Some(PreviewMode::Normal(mutex)) => match *mutex.lock().unwrap() {
105 PreviewState::Provisional(preview) => preview,
106 PreviewState::Final(preview) => preview,
107 },
108 #[cfg(feature = "testing")]
109 Some(PreviewMode::Test(rwlock)) => {
110 assert!(
111 test::HELD.get(),
112 "The preview configuration is in test mode but the current thread does not hold a `FeaturesGuard`\nHint: Use `{}::test::with_features` to get a `FeaturesGuard` and hold it when testing functions which rely on the global preview state",
113 module_path!()
114 );
115 rwlock
119 .read()
120 .unwrap()
121 .expect("FeaturesGuard is held but preview value is not set")
122 }
123 #[cfg(feature = "testing")]
124 None => panic!(
125 "The preview configuration has not been initialized\nHint: Use `{}::init` or `{}::test::with_features` to initialize it",
126 module_path!(),
127 module_path!()
128 ),
129 #[cfg(not(feature = "testing"))]
130 None => panic!("The preview configuration has not been initialized"),
131 }
132}
133
134pub fn is_enabled(flag: PreviewFeature) -> bool {
136 get().is_enabled(flag)
137}
138
139#[cfg(feature = "testing")]
141pub mod test {
142 use super::{PREVIEW, Preview, PreviewMode};
143 use std::cell::Cell;
144 use std::sync::{Mutex, MutexGuard, RwLock};
145
146 static MUTEX: Mutex<()> = Mutex::new(());
150
151 thread_local! {
152 pub(crate) static HELD: Cell<bool> = const { Cell::new(false) };
158 }
159
160 #[derive(Debug)]
163 #[expect(unused)]
164 pub struct FeaturesGuard(MutexGuard<'static, ()>);
165
166 pub fn with_features(features: &[super::PreviewFeature]) -> FeaturesGuard {
178 assert!(
179 !HELD.get(),
180 "Additional calls to `{}::with_features` are not allowed while holding a `FeaturesGuard`",
181 module_path!()
182 );
183
184 let guard = match MUTEX.lock() {
185 Ok(guard) => guard,
186 Err(err) => err.into_inner(),
191 };
192
193 HELD.set(true);
194
195 let state = PREVIEW.get_or_init(|| PreviewMode::Test(RwLock::new(None)));
196 match state {
197 PreviewMode::Test(rwlock) => {
198 *rwlock.write().unwrap() = Some(Preview::new(features));
199 }
200 PreviewMode::Normal(_) => {
201 panic!(
202 "Cannot use `{}::with_features` after `uv_preview::init` has been called",
203 module_path!()
204 );
205 }
206 }
207 FeaturesGuard(guard)
208 }
209
210 impl Drop for FeaturesGuard {
211 fn drop(&mut self) {
212 HELD.set(false);
213
214 match PREVIEW.get().unwrap() {
215 PreviewMode::Test(rwlock) => {
216 *rwlock.write().unwrap() = None;
217 }
218 PreviewMode::Normal(_) => {
219 unreachable!("FeaturesGuard should not exist when in Normal mode");
220 }
221 }
222 }
223 }
224}
225
226#[bitflags]
227#[repr(u32)]
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum PreviewFeature {
230 PythonInstallDefault = 1 << 0,
231 PythonUpgrade = 1 << 1,
232 JsonOutput = 1 << 2,
233 Pylock = 1 << 3,
234 AddBounds = 1 << 4,
235 PackageConflicts = 1 << 5,
236 ExtraBuildDependencies = 1 << 6,
237 DetectModuleConflicts = 1 << 7,
238 Format = 1 << 8,
239 NativeAuth = 1 << 9,
240 S3Endpoint = 1 << 10,
241 CacheSize = 1 << 11,
242 InitProjectFlag = 1 << 12,
243 WorkspaceMetadata = 1 << 13,
244 WorkspaceDir = 1 << 14,
245 WorkspaceList = 1 << 15,
246 SbomExport = 1 << 16,
247 AuthHelper = 1 << 17,
248 DirectPublish = 1 << 18,
249 TargetWorkspaceDiscovery = 1 << 19,
250 MetadataJson = 1 << 20,
251 GcsEndpoint = 1 << 21,
252 AdjustUlimit = 1 << 22,
253 SpecialCondaEnvNames = 1 << 23,
254 RelocatableEnvsDefault = 1 << 24,
255 PublishRequireNormalized = 1 << 25,
256 Audit = 1 << 26,
257 ProjectDirectoryMustExist = 1 << 27,
258 IndexExcludeNewer = 1 << 28,
259 AzureEndpoint = 1 << 29,
260 TomlBackwardsCompatibility = 1 << 30,
261}
262
263impl PreviewFeature {
264 fn as_str(self) -> &'static str {
266 match self {
267 Self::PythonInstallDefault => "python-install-default",
268 Self::PythonUpgrade => "python-upgrade",
269 Self::JsonOutput => "json-output",
270 Self::Pylock => "pylock",
271 Self::AddBounds => "add-bounds",
272 Self::PackageConflicts => "package-conflicts",
273 Self::ExtraBuildDependencies => "extra-build-dependencies",
274 Self::DetectModuleConflicts => "detect-module-conflicts",
275 Self::Format => "format",
276 Self::NativeAuth => "native-auth",
277 Self::S3Endpoint => "s3-endpoint",
278 Self::CacheSize => "cache-size",
279 Self::InitProjectFlag => "init-project-flag",
280 Self::WorkspaceMetadata => "workspace-metadata",
281 Self::WorkspaceDir => "workspace-dir",
282 Self::WorkspaceList => "workspace-list",
283 Self::SbomExport => "sbom-export",
284 Self::AuthHelper => "auth-helper",
285 Self::DirectPublish => "direct-publish",
286 Self::TargetWorkspaceDiscovery => "target-workspace-discovery",
287 Self::MetadataJson => "metadata-json",
288 Self::GcsEndpoint => "gcs-endpoint",
289 Self::AdjustUlimit => "adjust-ulimit",
290 Self::SpecialCondaEnvNames => "special-conda-env-names",
291 Self::RelocatableEnvsDefault => "relocatable-envs-default",
292 Self::PublishRequireNormalized => "publish-require-normalized",
293 Self::Audit => "audit",
294 Self::ProjectDirectoryMustExist => "project-directory-must-exist",
295 Self::IndexExcludeNewer => "index-exclude-newer",
296 Self::AzureEndpoint => "azure-endpoint",
297 Self::TomlBackwardsCompatibility => "toml-backwards-compatibility",
298 }
299 }
300}
301
302impl Display for PreviewFeature {
303 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
304 write!(f, "{}", self.as_str())
305 }
306}
307
308#[derive(Debug, Error, Clone)]
309#[error("Unknown feature flag")]
310pub struct PreviewFeatureParseError;
311
312impl FromStr for PreviewFeature {
313 type Err = PreviewFeatureParseError;
314
315 fn from_str(s: &str) -> Result<Self, Self::Err> {
316 Ok(match s {
317 "python-install-default" => Self::PythonInstallDefault,
318 "python-upgrade" => Self::PythonUpgrade,
319 "json-output" => Self::JsonOutput,
320 "pylock" => Self::Pylock,
321 "add-bounds" => Self::AddBounds,
322 "package-conflicts" => Self::PackageConflicts,
323 "extra-build-dependencies" => Self::ExtraBuildDependencies,
324 "detect-module-conflicts" => Self::DetectModuleConflicts,
325 "format" => Self::Format,
326 "native-auth" => Self::NativeAuth,
327 "s3-endpoint" => Self::S3Endpoint,
328 "gcs-endpoint" => Self::GcsEndpoint,
329 "cache-size" => Self::CacheSize,
330 "init-project-flag" => Self::InitProjectFlag,
331 "workspace-metadata" => Self::WorkspaceMetadata,
332 "workspace-dir" => Self::WorkspaceDir,
333 "workspace-list" => Self::WorkspaceList,
334 "sbom-export" => Self::SbomExport,
335 "auth-helper" => Self::AuthHelper,
336 "direct-publish" => Self::DirectPublish,
337 "target-workspace-discovery" => Self::TargetWorkspaceDiscovery,
338 "metadata-json" => Self::MetadataJson,
339 "adjust-ulimit" => Self::AdjustUlimit,
340 "special-conda-env-names" => Self::SpecialCondaEnvNames,
341 "relocatable-envs-default" => Self::RelocatableEnvsDefault,
342 "publish-require-normalized" => Self::PublishRequireNormalized,
343 "audit" => Self::Audit,
344 "project-directory-must-exist" => Self::ProjectDirectoryMustExist,
345 "index-exclude-newer" => Self::IndexExcludeNewer,
346 "azure-endpoint" => Self::AzureEndpoint,
347 "toml-backwards-compatibility" => Self::TomlBackwardsCompatibility,
348 _ => return Err(PreviewFeatureParseError),
349 })
350 }
351}
352
353#[derive(Clone, Copy, PartialEq, Eq, Default)]
354pub struct Preview {
355 flags: BitFlags<PreviewFeature>,
356}
357
358impl Debug for Preview {
359 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
360 let flags: Vec<_> = self.flags.iter().collect();
361 f.debug_struct("Preview").field("flags", &flags).finish()
362 }
363}
364
365impl Preview {
366 pub fn new(flags: &[PreviewFeature]) -> Self {
367 Self {
368 flags: flags.iter().copied().fold(BitFlags::empty(), BitOr::bitor),
369 }
370 }
371
372 pub fn all() -> Self {
373 Self {
374 flags: BitFlags::all(),
375 }
376 }
377
378 pub fn from_args(preview: bool, no_preview: bool, preview_features: &[PreviewFeature]) -> Self {
379 if no_preview {
380 return Self::default();
381 }
382
383 if preview {
384 return Self::all();
385 }
386
387 Self::new(preview_features)
388 }
389
390 pub fn is_enabled(&self, flag: PreviewFeature) -> bool {
392 self.flags.contains(flag)
393 }
394
395 pub fn all_enabled(&self) -> bool {
397 self.flags.is_all()
398 }
399
400 pub fn any_enabled(&self) -> bool {
402 !self.flags.is_empty()
403 }
404}
405
406impl Display for Preview {
407 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
408 if self.flags.is_empty() {
409 write!(f, "disabled")
410 } else if self.flags.is_all() {
411 write!(f, "enabled")
412 } else {
413 write!(
414 f,
415 "{}",
416 itertools::join(self.flags.iter().map(PreviewFeature::as_str), ",")
417 )
418 }
419 }
420}
421
422#[derive(Debug, Error, Clone)]
423pub enum PreviewParseError {
424 #[error("Empty string in preview features: {0}")]
425 Empty(String),
426}
427
428impl FromStr for Preview {
429 type Err = PreviewParseError;
430
431 fn from_str(s: &str) -> Result<Self, Self::Err> {
432 let mut flags = BitFlags::empty();
433
434 for part in s.split(',') {
435 let part = part.trim();
436 if part.is_empty() {
437 return Err(PreviewParseError::Empty(
438 "Empty string in preview features".to_string(),
439 ));
440 }
441
442 match PreviewFeature::from_str(part) {
443 Ok(flag) => flags |= flag,
444 Err(_) => {
445 warn_user_once!("Unknown preview feature: `{part}`");
446 }
447 }
448 }
449
450 Ok(Self { flags })
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn test_preview_feature_from_str() {
460 let features = PreviewFeature::from_str("python-install-default").unwrap();
461 assert_eq!(features, PreviewFeature::PythonInstallDefault);
462 }
463
464 #[test]
465 fn test_preview_from_str() {
466 let preview = Preview::from_str("python-install-default").unwrap();
468 assert_eq!(preview.flags, PreviewFeature::PythonInstallDefault);
469
470 let preview = Preview::from_str("python-upgrade,json-output").unwrap();
472 assert!(preview.is_enabled(PreviewFeature::PythonUpgrade));
473 assert!(preview.is_enabled(PreviewFeature::JsonOutput));
474 assert_eq!(preview.flags.bits().count_ones(), 2);
475
476 let preview = Preview::from_str("pylock , add-bounds").unwrap();
478 assert!(preview.is_enabled(PreviewFeature::Pylock));
479 assert!(preview.is_enabled(PreviewFeature::AddBounds));
480
481 assert!(Preview::from_str("").is_err());
483 assert!(Preview::from_str("pylock,").is_err());
484 assert!(Preview::from_str(",pylock").is_err());
485
486 let preview = Preview::from_str("unknown-feature,pylock").unwrap();
488 assert!(preview.is_enabled(PreviewFeature::Pylock));
489 assert_eq!(preview.flags.bits().count_ones(), 1);
490 }
491
492 #[test]
493 fn test_preview_display() {
494 let preview = Preview::default();
496 assert_eq!(preview.to_string(), "disabled");
497 let preview = Preview::new(&[]);
498 assert_eq!(preview.to_string(), "disabled");
499
500 let preview = Preview::all();
502 assert_eq!(preview.to_string(), "enabled");
503
504 let preview = Preview::new(&[PreviewFeature::PythonInstallDefault]);
506 assert_eq!(preview.to_string(), "python-install-default");
507
508 let preview = Preview::new(&[PreviewFeature::PythonUpgrade, PreviewFeature::Pylock]);
510 assert_eq!(preview.to_string(), "python-upgrade,pylock");
511 }
512
513 #[test]
514 fn test_preview_from_args() {
515 let preview = Preview::from_args(false, false, &[]);
517 assert_eq!(preview.to_string(), "disabled");
518
519 let preview = Preview::from_args(true, true, &[]);
521 assert_eq!(preview.to_string(), "disabled");
522
523 let preview = Preview::from_args(true, false, &[]);
525 assert_eq!(preview.to_string(), "enabled");
526
527 let features = vec![PreviewFeature::PythonUpgrade, PreviewFeature::JsonOutput];
529 let preview = Preview::from_args(false, false, &features);
530 assert!(preview.is_enabled(PreviewFeature::PythonUpgrade));
531 assert!(preview.is_enabled(PreviewFeature::JsonOutput));
532 assert!(!preview.is_enabled(PreviewFeature::Pylock));
533 }
534
535 #[test]
536 fn test_preview_feature_as_str() {
537 assert_eq!(
538 PreviewFeature::PythonInstallDefault.as_str(),
539 "python-install-default"
540 );
541 assert_eq!(PreviewFeature::PythonUpgrade.as_str(), "python-upgrade");
542 assert_eq!(PreviewFeature::JsonOutput.as_str(), "json-output");
543 assert_eq!(PreviewFeature::Pylock.as_str(), "pylock");
544 assert_eq!(PreviewFeature::AddBounds.as_str(), "add-bounds");
545 assert_eq!(
546 PreviewFeature::PackageConflicts.as_str(),
547 "package-conflicts"
548 );
549 assert_eq!(
550 PreviewFeature::ExtraBuildDependencies.as_str(),
551 "extra-build-dependencies"
552 );
553 assert_eq!(
554 PreviewFeature::DetectModuleConflicts.as_str(),
555 "detect-module-conflicts"
556 );
557 assert_eq!(PreviewFeature::Format.as_str(), "format");
558 assert_eq!(PreviewFeature::NativeAuth.as_str(), "native-auth");
559 assert_eq!(PreviewFeature::S3Endpoint.as_str(), "s3-endpoint");
560 assert_eq!(PreviewFeature::CacheSize.as_str(), "cache-size");
561 assert_eq!(
562 PreviewFeature::InitProjectFlag.as_str(),
563 "init-project-flag"
564 );
565 assert_eq!(
566 PreviewFeature::WorkspaceMetadata.as_str(),
567 "workspace-metadata"
568 );
569 assert_eq!(PreviewFeature::WorkspaceDir.as_str(), "workspace-dir");
570 assert_eq!(PreviewFeature::WorkspaceList.as_str(), "workspace-list");
571 assert_eq!(PreviewFeature::SbomExport.as_str(), "sbom-export");
572 assert_eq!(PreviewFeature::AuthHelper.as_str(), "auth-helper");
573 assert_eq!(PreviewFeature::DirectPublish.as_str(), "direct-publish");
574 assert_eq!(
575 PreviewFeature::TargetWorkspaceDiscovery.as_str(),
576 "target-workspace-discovery"
577 );
578 assert_eq!(PreviewFeature::MetadataJson.as_str(), "metadata-json");
579 assert_eq!(PreviewFeature::GcsEndpoint.as_str(), "gcs-endpoint");
580 assert_eq!(PreviewFeature::AdjustUlimit.as_str(), "adjust-ulimit");
581 assert_eq!(
582 PreviewFeature::SpecialCondaEnvNames.as_str(),
583 "special-conda-env-names"
584 );
585 assert_eq!(
586 PreviewFeature::RelocatableEnvsDefault.as_str(),
587 "relocatable-envs-default"
588 );
589 assert_eq!(
590 PreviewFeature::PublishRequireNormalized.as_str(),
591 "publish-require-normalized"
592 );
593 assert_eq!(
594 PreviewFeature::ProjectDirectoryMustExist.as_str(),
595 "project-directory-must-exist"
596 );
597 assert_eq!(
598 PreviewFeature::IndexExcludeNewer.as_str(),
599 "index-exclude-newer"
600 );
601 assert_eq!(PreviewFeature::AzureEndpoint.as_str(), "azure-endpoint");
602 assert_eq!(
603 PreviewFeature::TomlBackwardsCompatibility.as_str(),
604 "toml-backwards-compatibility"
605 );
606 }
607
608 #[test]
609 fn test_global_preview() {
610 {
611 let _guard =
612 test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
613 assert!(!is_enabled(PreviewFeature::InitProjectFlag));
614 assert!(is_enabled(PreviewFeature::Pylock));
615 assert!(is_enabled(PreviewFeature::WorkspaceMetadata));
616 assert!(!is_enabled(PreviewFeature::AuthHelper));
617 }
618 {
619 let _guard =
620 test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
621 assert!(is_enabled(PreviewFeature::InitProjectFlag));
622 assert!(!is_enabled(PreviewFeature::Pylock));
623 assert!(!is_enabled(PreviewFeature::WorkspaceMetadata));
624 assert!(is_enabled(PreviewFeature::AuthHelper));
625 }
626 }
627
628 #[test]
629 #[should_panic(
630 expected = "Additional calls to `uv_preview::test::with_features` are not allowed while holding a `FeaturesGuard`"
631 )]
632 fn test_global_preview_panic_nested() {
633 let _guard =
634 test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
635 let _guard2 =
636 test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
637 }
638
639 #[test]
640 #[should_panic(expected = "uv_preview::test::with_features")]
641 fn test_global_preview_panic_uninitialized() {
642 let _preview = get();
643 }
644}