Skip to main content

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}