uv_preview/lib.rs
1use std::borrow::Cow;
2#[cfg(any(test, feature = "testing"))]
3use std::ops::BitOr;
4use std::sync::{Mutex, OnceLock};
5use std::{
6 fmt::{Debug, Display, Formatter},
7 str::FromStr,
8};
9
10use enumflags2::{BitFlags, bitflags};
11use thiserror::Error;
12use uv_macros::PreviewMetadata;
13use uv_warnings::warn_user_once;
14
15/// Indicates if the preview state has been finalized yet or not.
16enum PreviewState {
17 Provisional(Preview),
18 Final(Preview),
19}
20
21/// Indicates how the preview was initialised, to distinguish between normal
22/// code and unit tests.
23enum PreviewMode {
24 /// Initialised by a call to [`init`].
25 Normal(Mutex<PreviewState>),
26 /// Initialised by a call to [`test::with_features`].
27 #[cfg(feature = "testing")]
28 Test(std::sync::RwLock<Option<Preview>>),
29}
30
31static PREVIEW: OnceLock<PreviewMode> = OnceLock::new();
32
33/// Error type for global preview state initialization related errors
34#[derive(Debug, Error)]
35pub enum PreviewError {
36 /// Returned when [`set`] or [`finalize`] are called on a finalized state.
37 #[error("The preview configuration has already been finalized")]
38 AlreadyFinalized,
39
40 /// Returned when [`finalize`] is called on an uninitialized state.
41 #[error("The preview configuration has not been initialized yet")]
42 NotInitialized,
43
44 /// Returned when [`set`] or [`finalize`] are called on a test state.
45 #[cfg(feature = "testing")]
46 #[error("The preview configuration is in test mode and {}::{} cannot be used", module_path!(), .0)]
47 InTest(&'static str),
48}
49
50/// Initialize the global preview configuration.
51///
52/// This should be called once at startup with the resolved preview settings.
53pub fn set(preview: Preview) -> Result<(), PreviewError> {
54 let mode = PREVIEW.get_or_init(|| {
55 PreviewMode::Normal(Mutex::new(PreviewState::Provisional(Preview::default())))
56 });
57 match mode {
58 PreviewMode::Normal(mutex) => {
59 // Calling `set` in a test context is already disallowed, so a panic if
60 // the mutex is poisoned is fine.
61 let mut state = mutex.lock().unwrap();
62 match &*state {
63 PreviewState::Provisional(_) => {
64 *state = PreviewState::Provisional(preview);
65 Ok(())
66 }
67 PreviewState::Final(_) => Err(PreviewError::AlreadyFinalized),
68 }
69 }
70 #[cfg(feature = "testing")]
71 PreviewMode::Test(_) => Err(PreviewError::InTest("set")),
72 }
73}
74
75pub fn finalize() -> Result<(), PreviewError> {
76 match PREVIEW.get().ok_or(PreviewError::NotInitialized)? {
77 PreviewMode::Normal(mutex) => {
78 // Calling `set` in a test context is already disallowed, so a panic if
79 // the mutex is poisoned is fine.
80 let mut state = mutex.lock().unwrap();
81 match &*state {
82 PreviewState::Provisional(preview) => {
83 *state = PreviewState::Final(*preview);
84 Ok(())
85 }
86 PreviewState::Final(_) => Err(PreviewError::AlreadyFinalized),
87 }
88 }
89 #[cfg(feature = "testing")]
90 PreviewMode::Test(_) => Err(PreviewError::InTest("finalize")),
91 }
92}
93
94/// Get the current global preview configuration.
95///
96/// # Panics
97///
98/// When called before [`init`] or (with the `testing` feature) when the
99/// current thread does not hold a [`test::with_features`] guard.
100fn get() -> Preview {
101 match PREVIEW.get() {
102 Some(PreviewMode::Normal(mutex)) => match *mutex.lock().unwrap() {
103 PreviewState::Provisional(preview) => preview,
104 PreviewState::Final(preview) => preview,
105 },
106 #[cfg(feature = "testing")]
107 Some(PreviewMode::Test(rwlock)) => {
108 assert!(
109 test::HELD.get(),
110 "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",
111 module_path!()
112 );
113 // The unwrap may panic only if the current thread had panicked
114 // while attempting to write the value and then recovered with
115 // `catch_unwind`. This seems unlikely.
116 rwlock
117 .read()
118 .unwrap()
119 .expect("FeaturesGuard is held but preview value is not set")
120 }
121 #[cfg(feature = "testing")]
122 None => panic!(
123 "The preview configuration has not been initialized\nHint: Use `{}::init` or `{}::test::with_features` to initialize it",
124 module_path!(),
125 module_path!()
126 ),
127 #[cfg(not(feature = "testing"))]
128 None => panic!("The preview configuration has not been initialized"),
129 }
130}
131
132/// Check if a specific preview feature is enabled globally.
133pub fn is_enabled(flag: PreviewFeature) -> bool {
134 get().is_enabled(flag)
135}
136
137/// Functions for unit tests, do not use from normal code!
138#[cfg(feature = "testing")]
139pub mod test {
140 use super::{PREVIEW, Preview, PreviewMode};
141 use std::cell::Cell;
142 use std::sync::{Mutex, MutexGuard, RwLock};
143
144 /// The global preview state test mutex. It does not guard any data but is
145 /// simply used to ensure tests which rely on the global preview state are
146 /// ran serially.
147 static MUTEX: Mutex<()> = Mutex::new(());
148
149 thread_local! {
150 /// Whether the current thread holds the global mutex.
151 ///
152 /// This is used to catch situations where a test forgets to set the
153 /// global test state but happens to work anyway because of another test
154 /// setting the state.
155 pub(crate) static HELD: Cell<bool> = const { Cell::new(false) };
156 }
157
158 /// A scope guard which ensures that the global preview state is configured
159 /// and consistent for the duration of its lifetime.
160 #[derive(Debug)]
161 #[expect(unused)]
162 pub struct FeaturesGuard(MutexGuard<'static, ()>);
163
164 /// Temporarily set the state of preview features for the duration of the
165 /// lifetime of the returned guard.
166 ///
167 /// Calls cannot be nested, and this function must be used to set the global
168 /// preview features when testing functionality which uses it, otherwise
169 /// that functionality will panic.
170 ///
171 /// The preview state will only be valid for the thread which calls this
172 /// function, it will not be valid for any other thread. This is a
173 /// consequence of how `HELD` is used to check for tests which are missing
174 /// the guard.
175 pub fn with_features(features: &[super::PreviewFeature]) -> FeaturesGuard {
176 assert!(
177 !HELD.get(),
178 "Additional calls to `{}::with_features` are not allowed while holding a `FeaturesGuard`",
179 module_path!()
180 );
181
182 let guard = match MUTEX.lock() {
183 Ok(guard) => guard,
184 // This is okay because the mutex isn't guarding any data, so when
185 // it gets poisoned, it just means a test thread died while holding
186 // it, so it's safe to just re-grab it from the PoisonError, there's
187 // no chance of any corruption.
188 Err(err) => err.into_inner(),
189 };
190
191 HELD.set(true);
192
193 let state = PREVIEW.get_or_init(|| PreviewMode::Test(RwLock::new(None)));
194 match state {
195 PreviewMode::Test(rwlock) => {
196 *rwlock.write().unwrap() = Some(Preview::new(features));
197 }
198 PreviewMode::Normal(_) => {
199 panic!(
200 "Cannot use `{}::with_features` after `uv_preview::init` has been called",
201 module_path!()
202 );
203 }
204 }
205 FeaturesGuard(guard)
206 }
207
208 impl Drop for FeaturesGuard {
209 fn drop(&mut self) {
210 HELD.set(false);
211
212 match PREVIEW.get().unwrap() {
213 PreviewMode::Test(rwlock) => {
214 *rwlock.write().unwrap() = None;
215 }
216 PreviewMode::Normal(_) => {
217 unreachable!("FeaturesGuard should not exist when in Normal mode");
218 }
219 }
220 }
221 }
222}
223
224#[bitflags]
225#[expect(
226 clippy::use_self,
227 reason = "enumflags2 refers to the enum by name when inferring bits"
228)]
229#[repr(u64)]
230#[derive(Debug, Clone, Copy, PartialEq, Eq, PreviewMetadata)]
231pub enum PreviewFeature {
232 /// The `uv python install --default` option installs `python` and `python3` executables
233 /// alongside the versioned executable, making a uv-managed Python available without specifying
234 /// its minor version. When no version is requested and no `.python-version` file is found,
235 /// enabling this feature also makes `uv python install` create these executables by default.
236 /// See [installing Python executables](./python-versions.md#installing-python-executables) for
237 /// details about the installation location and handling of existing executables.
238 PythonInstallDefault,
239 /// Commands that support `--output-format json`, such as `uv tree`, can produce
240 /// machine-readable output for use by other tools. The JSON schemas are experimental and may
241 /// change without warning; enabling this feature silences the preview warning.
242 JsonOutput,
243 /// The `uv pip install` and `uv pip sync` commands can install dependencies from `pylock.toml`
244 /// files, the standardized Python lockfile format. For example, use
245 /// `uv pip install -r pylock.toml` or `uv pip sync pylock.toml`; enabling this feature silences
246 /// the preview warning.
247 Pylock,
248 /// The [`add-bounds`](../reference/settings.md#add-bounds) setting controls the default version
249 /// bounds added by `uv add`. Instead of the default lower bound, dependencies can be
250 /// constrained to a major or minor version range, or pinned to an exact version.
251 AddBounds,
252 /// Workspace members can declare [conflicting
253 /// dependencies](./resolution.md#conflicting-dependencies) at the package level, in addition to
254 /// conflicts between extras or dependency groups. This allows members with incompatible
255 /// requirements to share a lockfile, provided they are not installed together.
256 PackageConflicts,
257 /// The [`extra-build-dependencies`](./projects/config.md#augmenting-build-dependencies) setting
258 /// augments a package's declared build dependencies without disabling build isolation. It can
259 /// supply a missing build dependency or, with `match-runtime = true`, ensure that a build
260 /// dependency uses the same version as the project environment.
261 ExtraBuildDependencies,
262 /// uv warns when multiple packages install conflicting Python modules into the same
263 /// environment. These conflicts can cause imports to depend on installation order, even when
264 /// the packages have different distribution names.
265 DetectModuleConflicts,
266 /// The `uv format` command formats Python code with Ruff, downloading the formatter when
267 /// needed. It can also check formatting with `--check` or show proposed changes with `--diff`;
268 /// enabling this feature silences the preview warning.
269 #[preview(alias = "format")]
270 FormatCommand,
271 /// The `uv auth` commands store credentials in a [system-native
272 /// location](./authentication/http.md#the-uv-credentials-store), using Keychain Services on
273 /// macOS, Credential Manager on Windows, or the Secret Service API on Linux. uv only retrieves
274 /// credentials that it has stored itself, rather than credentials saved by other applications.
275 NativeAuth,
276 /// uv can authenticate requests to an S3-compatible storage endpoint using AWS Signature
277 /// Version 4. Set [`UV_S3_ENDPOINT_URL`](../reference/environment.md#uv_s3_endpoint_url) to
278 /// identify the endpoint; credentials are obtained from the configured AWS credential sources.
279 S3Endpoint,
280 /// The `uv cache size` command reports the total size of uv's cache. It supports human-readable
281 /// output and a machine-readable byte count; enabling this feature silences the preview
282 /// warning.
283 CacheSize,
284 /// Cache cleanup reports the physical disk space reclaimed, accounting for hardlinks and
285 /// copy-on-write clones on macOS and Linux. If an entry's allocated size cannot be measured, uv
286 /// reports a lower bound; other platforms continue to use a coarser estimate. See [clearing the
287 /// cache](./cache.md#clearing-the-cache) for details.
288 CachePhysicalSpace,
289 /// The `uv init` command rejects the deprecated `--project` option. To choose where to create a
290 /// project, pass the target directory as a positional argument, such as `uv init my-project`.
291 InitProjectFlag,
292 /// The `uv workspace metadata` command exposes structured information about a workspace and its
293 /// resolved dependencies for use by other tools. Its output is experimental and may change; use
294 /// `--sync` to include a mapping from importable modules to the packages that provide them.
295 WorkspaceMetadata,
296 /// The `uv workspace dir` command prints the path to the workspace root. Use `--package` to
297 /// print the path to a specific workspace member, for example, with `--package my-package`.
298 WorkspaceDir,
299 /// The `uv workspace list` command lists the names of workspace members, with one name per
300 /// line. Use `--paths` to display their paths instead, for example, when passing workspace
301 /// directories to another tool.
302 WorkspaceList,
303 /// The `uv export --format cyclonedx1.5` command exports a software bill of materials in
304 /// CycloneDX 1.5 JSON format. This describes the locked dependencies in a format that can be
305 /// consumed by software inventory and security tools.
306 SbomExport,
307 /// The [`uv auth helper`](./authentication/cli.md#using-credentials-with-external-tools)
308 /// command lets external tools retrieve HTTP credentials through uv. It currently supports the
309 /// Bazel credential helper protocol, reading a JSON request from standard input and writing a
310 /// JSON response containing authentication headers when credentials are available.
311 AuthHelper,
312 /// For a local `uv run` target, uv starts project and workspace discovery from the directory
313 /// containing the target instead of the current working directory. This feature takes effect
314 /// before configuration is loaded, so it must be enabled on the command line or through an
315 /// environment variable.
316 TargetWorkspaceDiscovery,
317 /// The uv build backend includes `METADATA.json` and `WHEEL.json` files in built wheels
318 /// alongside the standard `METADATA` and `WHEEL` files. These additional files expose package
319 /// and wheel metadata in JSON format.
320 MetadataJson,
321 /// uv can authenticate requests to a Google Cloud Storage endpoint using Google Cloud
322 /// credentials. Set [`UV_GCS_ENDPOINT_URL`](../reference/environment.md#uv_gcs_endpoint_url) to
323 /// identify the endpoint; authentication uses `GOOGLE_APPLICATION_CREDENTIALS` or Application
324 /// Default Credentials.
325 GcsEndpoint,
326 /// On Unix, uv raises the process's soft open-file limit at startup, up to the hard limit. This
327 /// helps avoid "too many open files" errors during concurrent operations, and child processes
328 /// inherit the raised limit.
329 AdjustUlimit,
330 /// Conda environments named `base` or `root` are classified using their paths, like other named
331 /// environments. The name alone does not cause uv to treat a user-created environment as the
332 /// base Conda installation.
333 SpecialCondaEnvNames,
334 /// uv creates relocatable virtual environments by default, using relative paths in their entry
335 /// point and activation scripts. This allows the environment to be moved without invalidating
336 /// those scripts, although arbitrary binaries and nonstandard scripts are not guaranteed to be
337 /// relocatable. Use `uv venv --no-relocatable` to opt out.
338 RelocatableEnvsDefault,
339 /// The `uv publish` command requires distribution filenames to be normalized. Files with
340 /// non-normalized names are skipped when selecting distributions to upload.
341 PublishRequireNormalized,
342 /// The `uv audit` and `uv tool audit` commands check project and installed-tool dependencies
343 /// for known vulnerabilities. Tool audits use the lockfiles recorded by the
344 /// [`tool-install-locks`](#tool-install-locks) feature; enabling both features silences the
345 /// preview warning for `uv tool audit`.
346 #[preview(alias = "audit")]
347 AuditCommand,
348 /// The `--project` option rejects invalid paths instead of warning and continuing in the
349 /// current directory. Except for `uv init`, the path must already exist as a directory or point
350 /// to a `pyproject.toml` file. This feature takes effect before configuration is loaded, so it
351 /// must be enabled on the command line or through an environment variable.
352 ProjectDirectoryMustExist,
353 /// Each configured package index can set its own
354 /// [`exclude-newer`](./indexes.md#configuring-exclude-newer-for-an-index) cutoff, overriding
355 /// the global cutoff for packages served by that index. Set the value to `false` to disable the
356 /// cutoff for an index; package-specific `exclude-newer-package` values still take precedence.
357 IndexExcludeNewer,
358 /// uv can authenticate requests to an Azure Blob Storage endpoint using Azure credentials. Set
359 /// [`UV_AZURE_ENDPOINT_URL`](../reference/environment.md#uv_azure_endpoint_url) to identify the
360 /// endpoint; authentication uses the default Azure credential chain, including Azure CLI
361 /// credentials and workload identity.
362 AzureEndpoint,
363 /// When building source distributions, the uv build backend rewrites `pyproject.toml` as TOML
364 /// 1.0 for compatibility with older build tools. The original file is included as
365 /// `pyproject.toml.orig` in the source distribution.
366 TomlBackwardsCompatibility,
367 /// The `uv sync` command and other installation commands can check packages for malware using
368 /// [OSV](https://osv.dev) before installing them. This checks for known malicious packages,
369 /// while [`uv audit`](#audit-command) checks dependencies for known vulnerabilities.
370 MalwareCheck,
371 /// The `uv venv --clear` option refuses to clear a directory that does not contain a
372 /// `pyvenv.cfg` file. This helps avoid removing unrelated files when the target is not a
373 /// virtual environment; use `--force` to explicitly allow clearing such a directory.
374 VenvSafeClear,
375 /// The `uv check` command runs Python type checking with ty. It uses the project's environment
376 /// to resolve imports and can synchronize that environment before checking; enabling this
377 /// feature silences the preview warning.
378 #[preview(alias = "check")]
379 CheckCommand,
380 /// The `uv init` command creates a packaged application by default, with a `src/` layout, a
381 /// build system, and a script entry point. This gives new applications an installable package
382 /// structure without requiring `--package`.
383 PackagedInit,
384 /// uv stores default [project virtual
385 /// environments](./projects/layout.md#centralized-project-environments) in its cache and
386 /// attempts to link `.venv` to the cached environment. Switching interpreters selects separate
387 /// cached environments that can be reused later. Explicit environment paths and environments
388 /// selected with `--active` are not centralized.
389 CentralizedProjectEnvs,
390 /// uv stores a `uv.lock` file alongside each installed tool and reuses it for subsequent
391 /// installations and upgrades. The lockfile records the tool's resolved dependencies and also
392 /// provides the dependency information used by `uv tool audit`.
393 ToolInstallLocks,
394 /// The `uv workspace list --scripts` command lists standalone Python scripts with inline
395 /// metadata under the workspace root. It prints script paths relative to the workspace root,
396 /// allowing tools to discover scripts separately from workspace members.
397 WorkspaceListScripts,
398 /// Each configured package index can [require a hash
399 /// algorithm](./indexes.md#requiring-a-hash-algorithm) with the `hash-algorithm` setting. uv
400 /// records that algorithm's hash in the lockfile and fails if a distribution does not advertise
401 /// it, instead of selecting another available algorithm.
402 IndexHashAlgorithm,
403 /// Commands using `--locked` or `--check` reject non-canonical lockfile formatting, even if the
404 /// lockfile can be parsed. This makes formatting part of the lockfile check in addition to
405 /// checking whether the resolution is up to date.
406 LockfileFormatCheck,
407 /// uv combines equivalent dependency declarations when writing lockfiles. This reduces
408 /// duplicated declarations in the recorded requirements, constraints, overrides, exclusions,
409 /// and dependency groups.
410 LockfileNormalization,
411 /// uv omits the `package.metadata` tables from `uv.lock`, except for remote URL and Git
412 /// dependencies. Their metadata is retained so uv can check whether those sources are requested
413 /// or stale without network access.
414 LockWithoutMetadata,
415 /// The `--index` and `--default-index` options accept the names of configured package indexes
416 /// as well as URLs. For example, `--index internal` selects the configured index named
417 /// `internal`, so its URL does not need to be repeated on the command line.
418 IndexByName,
419 /// The `uv pip compile --generate-hashes` command restricts the generated hashes to artifacts
420 /// allowed by the selected binary and build policies. For example, when source builds are
421 /// disabled, hashes for source distributions are omitted.
422 ArtifactHashFiltering,
423 /// uv identifies cached wheel archives by a digest of their contents. Matching archive contents
424 /// can share a cache entry, reducing duplication when the same wheel contents are encountered
425 /// more than once.
426 ContentAddressedCache,
427 /// uv omits `exclude-newer-package` entries from the lockfile when the corresponding packages
428 /// are absent from the resolved dependencies. This keeps cutoffs for unrelated packages out of
429 /// `uv.lock`.
430 MissingExcludeNewerPackageLock,
431 /// uv omits redundant runtime constraints and unused overrides, exclusions, dependency
432 /// metadata, and package-specific upload cutoffs from the lockfile. It records which settings
433 /// were consulted during resolution, including backtracking, so settings that affected
434 /// resolution can be retained even when their packages are absent from the final dependency
435 /// graph.
436 ResolutionInputs,
437 /// The `uv export --batch` option exports multiple dependency selections from a TOML manifest
438 /// containing `[[export]]` entries. Each entry specifies an `output-file` and its own package,
439 /// extra, and dependency group selections; output paths are relative to the manifest.
440 BatchExport,
441 /// Frozen project commands can use `uv.lock` without the workspace's `pyproject.toml` file.
442 /// Discovery requires a lockfile with revision 5 or later, which records the workspace
443 /// information needed to select dependencies without the manifest.
444 FrozenLockfile,
445 /// The [`minimum-libc-version`](./resolution.md#minimum-libc-version) setting specifies the
446 /// oldest glibc or musl versions that must be supported during universal resolution. Use it
447 /// with `required-environments` to require compatible wheels for the selected Linux
448 /// environments without excluding wheels for newer libc versions.
449 MinimumLibcVersion,
450 /// Before a nonisolated `uv build`, uv checks that the selected environment satisfies declared,
451 /// backend-reported, and transitive build requirements. This reports missing or incompatible
452 /// build dependencies before building; use `--skip-dependency-check` to skip the check.
453 BuildDependencyCheck,
454 /// uv enables lazy imports when invoking build backends on CPython 3.15 and later. This can
455 /// reduce the work performed by imports during a build, but can also change import-time side
456 /// effects in third-party build backends.
457 BuildLazyImports,
458 /// The `--require-build-hashes` option requires hashes for every build dependency, including
459 /// transitive dependencies. It applies when build dependencies are downloaded during builds,
460 /// project resolution, and installation. See [project build dependency
461 /// hashes](./projects/build.md#project-build-dependency-hashes) for configuration and exceptions.
462 BuildDependencyHashes,
463}
464
465impl Display for PreviewFeature {
466 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
467 write!(f, "{}", self.as_str())
468 }
469}
470
471#[derive(Debug, Error, Clone)]
472#[error("Unknown feature flag")]
473pub struct PreviewFeatureParseError;
474
475impl FromStr for PreviewFeature {
476 type Err = PreviewFeatureParseError;
477
478 fn from_str(s: &str) -> Result<Self, Self::Err> {
479 Self::metadata()
480 .iter()
481 .find(|(feature, _, aliases)| feature.as_str() == s || aliases.contains(&s))
482 .map(|(feature, _, _)| *feature)
483 .ok_or(PreviewFeatureParseError)
484 }
485}
486
487#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
488#[error("preview feature name cannot be empty")]
489pub struct EmptyPreviewFeatureNameError;
490
491/// A user-provided preview feature name, which may refer to an unknown feature.
492#[derive(Debug, Clone)]
493pub enum MaybePreviewFeature {
494 Known(PreviewFeature),
495 Unknown(String),
496}
497
498impl FromStr for MaybePreviewFeature {
499 type Err = EmptyPreviewFeatureNameError;
500
501 fn from_str(s: &str) -> Result<Self, Self::Err> {
502 let s = s.trim();
503 if s.is_empty() {
504 return Err(EmptyPreviewFeatureNameError);
505 }
506
507 Ok(match PreviewFeature::from_str(s) {
508 Ok(feature) => Self::Known(feature),
509 Err(_) => Self::Unknown(s.to_string()),
510 })
511 }
512}
513
514impl<'de> serde::Deserialize<'de> for MaybePreviewFeature {
515 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
516 where
517 D: serde::Deserializer<'de>,
518 {
519 let name: Cow<'de, str> = serde::Deserialize::deserialize(deserializer)?;
520 Self::from_str(&name).map_err(serde::de::Error::custom)
521 }
522}
523
524#[cfg(feature = "schemars")]
525impl schemars::JsonSchema for MaybePreviewFeature {
526 fn schema_name() -> Cow<'static, str> {
527 Cow::Borrowed("PreviewFeature")
528 }
529
530 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
531 // Advertise canonical names for editor completions, while accepting any nonempty name to
532 // match the forwards-compatible runtime parsing behavior.
533 let choices: Vec<&str> = BitFlags::<PreviewFeature>::all()
534 .iter()
535 .map(PreviewFeature::as_str)
536 .collect();
537 schemars::json_schema!({
538 "type": "string",
539 "anyOf": [
540 {
541 "enum": choices,
542 },
543 {
544 "pattern": "\\S",
545 },
546 ],
547 })
548 }
549}
550
551#[derive(Clone, Copy, PartialEq, Eq, Default)]
552pub struct Preview {
553 flags: BitFlags<PreviewFeature>,
554}
555
556impl Debug for Preview {
557 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
558 let flags: Vec<_> = self.flags.iter().collect();
559 f.debug_struct("Preview").field("flags", &flags).finish()
560 }
561}
562
563impl Preview {
564 #[cfg(any(test, feature = "testing"))]
565 fn new(flags: &[PreviewFeature]) -> Self {
566 Self {
567 flags: flags.iter().copied().fold(BitFlags::empty(), BitOr::bitor),
568 }
569 }
570
571 pub fn all() -> Self {
572 Self {
573 flags: BitFlags::all(),
574 }
575 }
576
577 /// Check if a single feature is enabled.
578 pub fn is_enabled(&self, flag: PreviewFeature) -> bool {
579 self.flags.contains(flag)
580 }
581
582 /// Check if all preview feature rae enabled.
583 pub fn all_enabled(&self) -> bool {
584 self.flags.is_all()
585 }
586
587 /// Check if any preview feature is enabled.
588 pub fn any_enabled(&self) -> bool {
589 !self.flags.is_empty()
590 }
591
592 /// Resolve preview feature names, warning and ignoring unknown names.
593 pub fn from_feature_names<'a>(
594 feature_names: impl IntoIterator<Item = &'a MaybePreviewFeature>,
595 ) -> Self {
596 let mut flags = BitFlags::empty();
597
598 for feature_name in feature_names {
599 match feature_name {
600 MaybePreviewFeature::Known(feature) => flags |= *feature,
601 MaybePreviewFeature::Unknown(feature_name) => {
602 warn_user_once!("Unknown preview feature: `{feature_name}`");
603 }
604 }
605 }
606
607 Self { flags }
608 }
609}
610
611impl Display for Preview {
612 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
613 if self.flags.is_empty() {
614 write!(f, "disabled")
615 } else if self.flags.is_all() {
616 write!(f, "enabled")
617 } else {
618 write!(
619 f,
620 "{}",
621 itertools::join(self.flags.iter().map(PreviewFeature::as_str), ",")
622 )
623 }
624 }
625}
626
627impl FromStr for Preview {
628 type Err = EmptyPreviewFeatureNameError;
629
630 fn from_str(s: &str) -> Result<Self, Self::Err> {
631 let feature_names = s
632 .split(',')
633 .map(MaybePreviewFeature::from_str)
634 .collect::<Result<Vec<_>, _>>()?;
635
636 Ok(Self::from_feature_names(&feature_names))
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
645 fn test_preview_feature_from_str() {
646 for &(feature, _, aliases) in PreviewFeature::metadata() {
647 assert_eq!(PreviewFeature::from_str(feature.as_str()).unwrap(), feature);
648
649 for &alias in aliases {
650 assert_eq!(PreviewFeature::from_str(alias).unwrap(), feature);
651 }
652 }
653 }
654
655 #[test]
656 fn test_preview_from_str() {
657 // Test single feature
658 let preview = Preview::from_str("python-install-default").unwrap();
659 assert_eq!(preview.flags, PreviewFeature::PythonInstallDefault);
660
661 // Test multiple features
662 let preview = Preview::from_str("json-output,pylock").unwrap();
663 assert!(preview.is_enabled(PreviewFeature::JsonOutput));
664 assert!(preview.is_enabled(PreviewFeature::Pylock));
665 assert_eq!(preview.flags.bits().count_ones(), 2);
666
667 let preview = Preview::from_str("tool-install-locks").unwrap();
668 assert!(preview.is_enabled(PreviewFeature::ToolInstallLocks));
669
670 // Test with whitespace
671 let preview = Preview::from_str("pylock , add-bounds").unwrap();
672 assert!(preview.is_enabled(PreviewFeature::Pylock));
673 assert!(preview.is_enabled(PreviewFeature::AddBounds));
674
675 // Test empty string error
676 assert_eq!(Preview::from_str(""), Err(EmptyPreviewFeatureNameError));
677 assert!(Preview::from_str("pylock,").is_err());
678 assert!(Preview::from_str(",pylock").is_err());
679
680 // Test unknown feature (should be ignored with warning)
681 let preview = Preview::from_str("unknown-feature,pylock").unwrap();
682 assert!(preview.is_enabled(PreviewFeature::Pylock));
683 assert_eq!(preview.flags.bits().count_ones(), 1);
684 }
685
686 #[test]
687 fn test_preview_display() {
688 // Test disabled
689 let preview = Preview::default();
690 assert_eq!(preview.to_string(), "disabled");
691 let preview = Preview::new(&[]);
692 assert_eq!(preview.to_string(), "disabled");
693
694 // Test enabled (all features)
695 let preview = Preview::all();
696 assert_eq!(preview.to_string(), "enabled");
697
698 // Test single feature
699 let preview = Preview::new(&[PreviewFeature::PythonInstallDefault]);
700 assert_eq!(preview.to_string(), "python-install-default");
701
702 // Test multiple features
703 let preview = Preview::new(&[PreviewFeature::JsonOutput, PreviewFeature::Pylock]);
704 assert_eq!(preview.to_string(), "json-output,pylock");
705 }
706
707 #[test]
708 fn test_global_preview() {
709 {
710 let _guard =
711 test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
712 assert!(!is_enabled(PreviewFeature::InitProjectFlag));
713 assert!(is_enabled(PreviewFeature::Pylock));
714 assert!(is_enabled(PreviewFeature::WorkspaceMetadata));
715 assert!(!is_enabled(PreviewFeature::AuthHelper));
716 }
717 {
718 let _guard =
719 test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
720 assert!(is_enabled(PreviewFeature::InitProjectFlag));
721 assert!(!is_enabled(PreviewFeature::Pylock));
722 assert!(!is_enabled(PreviewFeature::WorkspaceMetadata));
723 assert!(is_enabled(PreviewFeature::AuthHelper));
724 }
725 }
726
727 #[test]
728 #[should_panic(
729 expected = "Additional calls to `uv_preview::test::with_features` are not allowed while holding a `FeaturesGuard`"
730 )]
731 fn test_global_preview_panic_nested() {
732 let _guard =
733 test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
734 let _guard2 =
735 test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
736 }
737
738 #[test]
739 #[should_panic(expected = "uv_preview::test::with_features")]
740 fn test_global_preview_panic_uninitialized() {
741 let _preview = get();
742 }
743}