Skip to main content

autumn_web/
plugin_contract.rs

1//! The versioned plugin API stability contract (issue #1601).
2//!
3//! Autumn's plugin system is a compile-time Rust surface: a plugin depends on
4//! `autumn-web`, implements [`Plugin`](crate::plugin::Plugin), and is mounted
5//! with [`AppBuilder::plugin`](crate::app::AppBuilder::plugin). That makes most
6//! breakage a compiler error rather than a production incident — but only if
7//! plugin authors know *which* APIs they may build on, and get told when the
8//! framework they are mounted into is not the one they were written for.
9//!
10//! This module is the machine-readable half of that contract. It carries three
11//! things:
12//!
13//! 1. [`PLUGIN_SURFACES`] — every plugin-facing API, declared [`Stable`] or
14//!    [`Experimental`]. `docs/plugins.md` renders the same list, and
15//!    `scripts/check-plugin-surface.sh` fails when the two disagree.
16//! 2. [`PluginContract`] — what a plugin declares about *itself*: the
17//!    `autumn-web` range it supports and any experimental surface it knowingly
18//!    leans on. A plugin returns one from
19//!    [`Plugin::contract`](crate::plugin::Plugin::contract).
20//! 3. [`evaluate`] — the compatibility verdict, and the diagnostic an
21//!    incompatible pairing produces.
22//!
23//! # The `SemVer` policy, by tier
24//!
25//! | Tier | Promise | What a break costs |
26//! |------|---------|--------------------|
27//! | [`SurfaceTier::Stable`] | Covered by `STABILITY.md`. Below `1.0` a break needs a minor bump, a migration guide, and a filled *Plugin authors* section in that guide; from `1.0` on it needs a major. | A build failure with an upgrade path. |
28//! | [`SurfaceTier::Experimental`] | May change in **any** release, including a patch. Declare it with [`PluginContract::uses_experimental`] so `autumn plugin-check` can report it. | A build failure you opted into. |
29//!
30//! # Declaring a contract
31//!
32//! ```rust
33//! use autumn_web::app::AppBuilder;
34//! use autumn_web::plugin::Plugin;
35//! use autumn_web::plugin_contract::PluginContract;
36//!
37//! struct MyPlugin;
38//!
39//! impl Plugin for MyPlugin {
40//!     fn contract(&self) -> Option<PluginContract> {
41//!         Some(
42//!             PluginContract::new(env!("CARGO_PKG_NAME"))
43//!                 .plugin_version(env!("CARGO_PKG_VERSION"))
44//!                 .autumn_web("0.9"),
45//!         )
46//!     }
47//!
48//!     fn build(self, app: AppBuilder) -> AppBuilder {
49//!         app
50//!     }
51//! }
52//! ```
53//!
54//! [`Stable`]: SurfaceTier::Stable
55//! [`Experimental`]: SurfaceTier::Experimental
56
57use std::fmt;
58
59use semver::{BuildMetadata, Prerelease, Version, VersionReq};
60use serde::{Deserialize, Serialize};
61
62/// The `autumn-web` version this build presents to plugins.
63///
64/// This is the framework's own crate version — plugins are compile-time
65/// dependencies, so the version a plugin is mounted into is always the one it
66/// was linked against.
67pub const AUTUMN_WEB_VERSION: &str = env!("CARGO_PKG_VERSION");
68
69/// Machine-readable stderr marker carrying the declared plugin contracts of a
70/// built application.
71///
72/// Emitted by the `AUTUMN_DUMP_ROUTES` dump only when
73/// `AUTUMN_DUMP_PLUGIN_CONTRACT=1` is *also* set — which `autumn plugin-check`
74/// sets and the plain `autumn routes` listing does not. A single compact JSON
75/// array of [`PluginContract`] follows the marker on the same line. Kept off
76/// stdout so the routes-only JSON parse path stays byte-compatible, exactly as
77/// for [`SECURITY_CONFIG_MARKER`](crate::route_listing::SECURITY_CONFIG_MARKER).
78pub const PLUGIN_CONTRACT_MARKER: &str = "[autumn:plugin-contract] ";
79
80// ── The surface registry ───────────────────────────────────────────────────
81
82/// Stability tier of a plugin-facing API.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85#[non_exhaustive]
86pub enum SurfaceTier {
87    /// Covered by [`STABILITY.md`]'s `SemVer` promise. A break needs a version
88    /// bump *and* a *Plugin authors* section in the release's migration guide.
89    ///
90    /// [`STABILITY.md`]: https://github.com/autumn-foundation/autumn/blob/trunk-dev/STABILITY.md
91    Stable,
92    /// May change in any release, including a patch. Plugins that use it should
93    /// say so via [`PluginContract::uses_experimental`].
94    Experimental,
95}
96
97impl fmt::Display for SurfaceTier {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::Stable => f.write_str("stable"),
101            Self::Experimental => f.write_str("experimental"),
102        }
103    }
104}
105
106/// One plugin-facing API, with the tier it is declared at.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108#[non_exhaustive]
109pub struct PluginSurface {
110    /// Canonical name, as a plugin author would write it (`AppBuilder::nest`).
111    pub name: &'static str,
112    /// Which promise covers it.
113    pub tier: SurfaceTier,
114    /// One line an author can act on: what it is for, and — for experimental
115    /// entries — what may change.
116    pub note: &'static str,
117}
118
119/// Every plugin-facing API Autumn declares, sorted by [`name`](PluginSurface::name).
120///
121/// This is the contract `docs/plugins.md` renders and
122/// `scripts/check-plugin-surface.sh` gates: an entry here that the reference
123/// plugin (`autumn-plugin-reference`) does not exercise fails CI, and so does a
124/// docs table that has drifted from this list.
125///
126/// Sorted so the table and the diff stay readable; `plugin_contract`'s test
127/// suite enforces both the ordering and the absence of duplicates.
128pub const PLUGIN_SURFACES: &[PluginSurface] = &[
129    PluginSurface {
130        name: "AppBuilder::config_section",
131        tier: SurfaceTier::Stable,
132        note: "Declare a plugin-owned top-level config root so `server.strict_config` treats it as known-and-opaque.",
133    },
134    PluginSurface {
135        name: "AppBuilder::declare_plugin_routes",
136        tier: SurfaceTier::Stable,
137        note: "Make routes mounted through an opaque `nest`/`merge` router visible to `autumn routes` and the conformance harness.",
138    },
139    PluginSurface {
140        name: "AppBuilder::error_pages",
141        tier: SurfaceTier::Stable,
142        note: "Replace the rendered error pages — a tier-1 subsystem seam (requires the `maud` feature). The plugin crate needs its own `maud` dependency: `html!` expands to absolute `::maud::` paths, which no re-export can satisfy.",
143    },
144    PluginSurface {
145        name: "AppBuilder::merge",
146        tier: SurfaceTier::Stable,
147        note: "Merge a raw axum router at the root. Pair it with `declare_plugin_routes` so the routes stay attributable.",
148    },
149    PluginSurface {
150        name: "AppBuilder::nest",
151        tier: SurfaceTier::Stable,
152        note: "Mount a raw axum router under a prefix. Pair it with `declare_plugin_routes` so the routes stay attributable.",
153    },
154    PluginSurface {
155        name: "AppBuilder::on_shutdown",
156        tier: SurfaceTier::Stable,
157        note: "Register an async shutdown hook that runs during graceful drain.",
158    },
159    PluginSurface {
160        name: "AppBuilder::on_startup",
161        tier: SurfaceTier::Stable,
162        note: "Register an async startup hook that runs once before the server binds.",
163    },
164    PluginSurface {
165        name: "AppBuilder::plugin",
166        tier: SurfaceTier::Stable,
167        note: "Mount a plugin. Also the seam a cooperative plugin uses to mount a plugin of its own.",
168    },
169    PluginSurface {
170        name: "AppBuilder::plugin_assets",
171        tier: SurfaceTier::Stable,
172        note: "Serve a `PluginAssets` bundle of files compiled into the plugin crate under `/static/_plugins/<namespace>/`, at content-hashed `immutable` URLs with SRI hashes, and declare its routes. Build the bundle with `plugin_assets!` (requires the `embed-assets` feature) or `PluginAssets::from_files`.",
173    },
174    PluginSurface {
175        name: "AppBuilder::plugin_contracts",
176        tier: SurfaceTier::Stable,
177        note: "Read the contracts declared by the plugins mounted on a builder — what the route dump and `autumn plugin-check` are built on.",
178    },
179    PluginSurface {
180        name: "AppBuilder::plugin_migrations",
181        tier: SurfaceTier::Stable,
182        note: "Contribute embedded database migrations tagged with the plugin's own name. Needs `reexports::diesel_migrations` in scope, because `embed_migrations!` expands to unqualified paths (requires the `db` feature).",
183    },
184    PluginSurface {
185        name: "AppBuilder::plugins",
186        tier: SurfaceTier::Stable,
187        note: "Mount a tuple of up to eight plugins in declaration order.",
188    },
189    PluginSurface {
190        name: "AppBuilder::routes",
191        tier: SurfaceTier::Stable,
192        note: "Register typed routes from `routes![]`. Plugin routes registered this way are attributed automatically.",
193    },
194    PluginSurface {
195        name: "AppBuilder::with_config_loader",
196        tier: SurfaceTier::Stable,
197        note: "Replace the tier-1 configuration loader (e.g. a secrets-manager backend).",
198    },
199    PluginSurface {
200        name: "AppBuilder::with_edge_kv",
201        tier: SurfaceTier::Experimental,
202        note: "Edge-capsule KV binding (requires the `edge` feature). The whole edge lane (issue #1790) may change in any release; the capsule wire protocol carries its own version field.",
203    },
204    PluginSurface {
205        name: "AppBuilder::with_extension",
206        tier: SurfaceTier::Stable,
207        note: "Publish a typed value into application state for handlers to extract.",
208    },
209    PluginSurface {
210        name: "AppBuilder::with_pool_provider",
211        tier: SurfaceTier::Stable,
212        note: "Replace the tier-1 database pool provider (requires the `db` feature).",
213    },
214    PluginSurface {
215        name: "AppBuilder::with_session_store",
216        tier: SurfaceTier::Stable,
217        note: "Replace the tier-1 session store.",
218    },
219    PluginSurface {
220        name: "AppBuilder::with_telemetry_provider",
221        tier: SurfaceTier::Stable,
222        note: "Replace the tier-1 telemetry provider.",
223    },
224    PluginSurface {
225        name: "Plugin::build",
226        tier: SurfaceTier::Stable,
227        note: "The one required method: apply the plugin's wiring to the builder. Runs exactly once per app.",
228    },
229    PluginSurface {
230        name: "Plugin::contract",
231        tier: SurfaceTier::Stable,
232        note: "Declare the `autumn-web` range this plugin supports and any experimental surface it uses.",
233    },
234    PluginSurface {
235        name: "Plugin::name",
236        tier: SurfaceTier::Stable,
237        note: "Stable identifier used for duplicate-registration detection and route attribution.",
238    },
239    PluginSurface {
240        name: "autumn_edge::host",
241        tier: SurfaceTier::Experimental,
242        note: "Reference edge-capsule host API. Experimental alongside the rest of the edge lane (issue #1790).",
243    },
244    PluginSurface {
245        name: "db::Pool",
246        tier: SurfaceTier::Stable,
247        note: "The pool type `DatabasePoolProvider::create_pool` returns, re-exported so a plugin need not depend on `diesel-async` itself (requires the `db` feature).",
248    },
249    PluginSurface {
250        name: "plugin_conformance",
251        tier: SurfaceTier::Stable,
252        note: "The library-level conformance harness plugin authors run in their own test suite.",
253    },
254    PluginSurface {
255        name: "plugin_contract",
256        tier: SurfaceTier::Stable,
257        note: "This module: `PluginContract`, `PLUGIN_SURFACES`, `SurfaceTier`, `evaluate`, `lockstep_range`, and the `PLUGIN_CONTRACT_MARKER` dump protocol.",
258    },
259    PluginSurface {
260        name: "route_listing::RouteInfo",
261        tier: SurfaceTier::Stable,
262        note: "The route manifest type the conformance harness and `autumn routes --format json` share.",
263    },
264];
265
266/// Look a surface up by its canonical name.
267#[must_use]
268pub fn surface(name: &str) -> Option<&'static PluginSurface> {
269    PLUGIN_SURFACES.iter().find(|s| s.name == name)
270}
271
272/// The names of every [`SurfaceTier::Stable`] surface, in registry order.
273pub fn stable_surface_names() -> impl Iterator<Item = &'static str> {
274    PLUGIN_SURFACES
275        .iter()
276        .filter(|s| s.tier == SurfaceTier::Stable)
277        .map(|s| s.name)
278}
279
280/// The names of every [`SurfaceTier::Experimental`] surface, in registry order.
281pub fn experimental_surface_names() -> impl Iterator<Item = &'static str> {
282    PLUGIN_SURFACES
283        .iter()
284        .filter(|s| s.tier == SurfaceTier::Experimental)
285        .map(|s| s.name)
286}
287
288// ── The contract a plugin declares ─────────────────────────────────────────
289
290/// What a plugin declares about its own compatibility.
291///
292/// Built with the fluent constructors and returned from
293/// [`Plugin::contract`](crate::plugin::Plugin::contract). Every field is
294/// optional: a plugin that declares nothing keeps working exactly as before
295/// ([`ContractVerdict::Undeclared`]).
296#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
297#[non_exhaustive]
298pub struct PluginContract {
299    /// The plugin's crate name, e.g. `"autumn-admin-plugin"`.
300    pub plugin: String,
301    /// The plugin's own version, when it declares one. Carried purely so the
302    /// mismatch diagnostic can name it.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub plugin_version: Option<String>,
305    /// A Cargo-style version requirement on `autumn-web` (`"0.7"`,
306    /// `">=0.6, <0.9"`). `None` means the plugin declares no range.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub autumn_web: Option<String>,
309    /// Names of [`SurfaceTier::Experimental`] surfaces this plugin knowingly
310    /// depends on. Reported by `autumn plugin-check`.
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub experimental_surfaces: Vec<String>,
313    /// The [`Plugin::name`](crate::plugin::Plugin::name) the plugin was
314    /// registered under, filled in by
315    /// [`AppBuilder::plugin`](crate::app::AppBuilder::plugin).
316    ///
317    /// **Not set by the plugin author.** It exists because
318    /// [`plugin`](Self::plugin) is the plugin's *crate* name while route
319    /// attribution keys on `Plugin::name()`, and the default `name()` is
320    /// [`std::any::type_name`] — so a plugin that declares
321    /// `env!("CARGO_PKG_NAME")` without overriding `name()` would otherwise be
322    /// unfindable by the one identifier `autumn plugin-check --plugin-name`
323    /// takes. Carrying both lets the CLI match on either.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub registered_as: Option<String>,
326}
327
328impl PluginContract {
329    /// Start a contract for the named plugin crate.
330    ///
331    /// Pass `env!("CARGO_PKG_NAME")` so the name can never drift from the
332    /// crate it describes.
333    #[must_use]
334    pub fn new(plugin: impl Into<String>) -> Self {
335        Self {
336            plugin: plugin.into(),
337            ..Self::default()
338        }
339    }
340
341    /// Record the plugin's own version, so a mismatch diagnostic can name it.
342    ///
343    /// Pass `env!("CARGO_PKG_VERSION")`.
344    #[must_use]
345    pub fn plugin_version(mut self, version: impl Into<String>) -> Self {
346        self.plugin_version = Some(version.into());
347        self
348    }
349
350    /// Declare the `autumn-web` range this plugin supports.
351    ///
352    /// The string is a Cargo version requirement: `"0.7"` (the whole `0.7`
353    /// series), `">=0.6, <0.9"`, `"=0.7.1"`. First-party plugins ship in
354    /// lockstep with the framework and declare their own minor series.
355    #[must_use]
356    pub fn autumn_web(mut self, requirement: impl Into<String>) -> Self {
357        self.autumn_web = Some(requirement.into());
358        self
359    }
360
361    /// Declare that this plugin knowingly depends on an experimental surface.
362    ///
363    /// The name must match a [`SurfaceTier::Experimental`] entry in
364    /// [`PLUGIN_SURFACES`]; `autumn plugin-check` fails on a name it cannot
365    /// resolve, because a declaration nobody can look up is worse than none.
366    #[must_use]
367    pub fn uses_experimental(mut self, surface: impl Into<String>) -> Self {
368        self.experimental_surfaces.push(surface.into());
369        self
370    }
371
372    /// The plugin as it should appear in a diagnostic: `name version` when a
373    /// version was declared, bare `name` otherwise.
374    #[must_use]
375    pub fn label(&self) -> String {
376        self.plugin_version
377            .as_ref()
378            .map_or_else(|| self.plugin.clone(), |v| format!("{} {v}", self.plugin))
379    }
380}
381
382/// The `autumn-web` range a crate released **in lockstep** with the framework
383/// declares support for.
384///
385/// First-party plugins ship at the framework's own version, so their supported
386/// range is their own version's compatibility key: the `MAJOR.MINOR` series
387/// below `1.0` (where `STABILITY.md` treats every minor bump as breaking), and
388/// the major from `1.0` on.
389///
390/// Pass `env!("CARGO_PKG_VERSION")` rather than a literal so a version bump
391/// cannot leave a stale range behind:
392///
393/// ```rust
394/// use autumn_web::plugin_contract::{PluginContract, lockstep_range};
395///
396/// let contract = PluginContract::new(env!("CARGO_PKG_NAME"))
397///     .autumn_web(lockstep_range(env!("CARGO_PKG_VERSION")));
398/// ```
399///
400/// # This is for lockstep crates only
401///
402/// A crate that versions **independently** of `autumn-web` must not use this:
403/// a third-party plugin at its own `1.2.0` would derive `"1"`, which excludes
404/// every `0.x` framework and makes every consuming app fail at registration.
405/// Write the range you actually support as a literal instead.
406///
407/// A version this cannot split into `MAJOR.MINOR` is returned trimmed but
408/// otherwise unchanged, so the malformed value reaches [`evaluate`] as written
409/// rather than being silently reshaped into a range that means something else.
410/// Note that some such strings — `"1"`, or the empty string — *are* valid
411/// Cargo requirements (`^1` and `*`), so a caller passing something other than
412/// `env!("CARGO_PKG_VERSION")` should validate it.
413#[must_use]
414pub fn lockstep_range(version: &str) -> String {
415    let version = version.trim();
416    let mut parts = version.split('.');
417    let (Some(major), Some(minor)) = (parts.next(), parts.next()) else {
418        return version.to_owned();
419    };
420    if major.is_empty() || minor.is_empty() || !minor.chars().all(|c| c.is_ascii_digit()) {
421        return version.to_owned();
422    }
423    if major == "0" {
424        format!("0.{minor}")
425    } else if major.chars().all(|c| c.is_ascii_digit()) {
426        major.to_owned()
427    } else {
428        version.to_owned()
429    }
430}
431
432/// Builds the compatibility contract a first-party plugin declares from
433/// [`Plugin::contract`](crate::plugin::Plugin::contract) (issue #1601).
434///
435/// First-party plugins ship in lockstep with `autumn-web`, so the declared
436/// range is this crate's own minor series — the two versions are the same
437/// number by construction. Below `1.0` a minor bump is breaking (see
438/// `STABILITY.md`), which is exactly what the `MAJOR.MINOR` requirement says.
439///
440/// Pass `env!("CARGO_PKG_NAME")` / `env!("CARGO_PKG_VERSION")` so both fields
441/// describe the calling crate, not this one:
442///
443/// ```rust
444/// use autumn_web::app::AppBuilder;
445/// use autumn_web::plugin::Plugin;
446/// use autumn_web::plugin_contract::{PluginContract, lockstep_contract};
447///
448/// struct MyFirstPartyPlugin;
449///
450/// impl Plugin for MyFirstPartyPlugin {
451///     fn contract(&self) -> Option<PluginContract> {
452///         Some(lockstep_contract(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")))
453///     }
454///
455///     fn build(self, app: AppBuilder) -> AppBuilder {
456///         app
457///     }
458/// }
459/// ```
460///
461/// # This is for lockstep crates only
462///
463/// Same restriction as [`lockstep_range`]: a plugin that versions
464/// independently of `autumn-web` must declare its own literal range instead
465/// (see that function's docs for why).
466#[must_use]
467pub fn lockstep_contract(name: &str, version: &str) -> PluginContract {
468    PluginContract::new(name)
469        .plugin_version(version)
470        .autumn_web(lockstep_range(version))
471}
472
473// ── Evaluation ─────────────────────────────────────────────────────────────
474
475/// The outcome of checking a [`PluginContract`] against a framework version.
476#[derive(Debug, Clone, PartialEq, Eq)]
477#[non_exhaustive]
478pub enum ContractVerdict {
479    /// The plugin declares no `autumn-web` range. Nothing to enforce.
480    Undeclared,
481    /// The declared range admits this framework version.
482    Compatible,
483    /// The declared range excludes this framework version.
484    Incompatible(PluginCompatibilityError),
485    /// The requirement or the framework version could not be parsed, so no
486    /// verdict is possible. Warned about at runtime and **failed** by
487    /// `autumn plugin-check`: the author's gate is the place to catch a typo,
488    /// not somebody else's application boot.
489    #[non_exhaustive]
490    Unparseable {
491        /// The requirement string as declared.
492        requirement: String,
493        /// Why it could not be evaluated.
494        reason: String,
495    },
496}
497
498/// An incompatible plugin/framework pairing, rendered as the diagnostic a user
499/// sees.
500#[derive(Debug, Clone, PartialEq, Eq)]
501#[non_exhaustive]
502pub struct PluginCompatibilityError {
503    /// The plugin as it appears in the message (`name version`).
504    pub plugin: String,
505    /// The plugin's crate name on its own, for `cargo update -p`.
506    pub plugin_crate: String,
507    /// The `autumn-web` range the plugin declared.
508    pub declared: String,
509    /// The `autumn-web` version actually in the build.
510    pub found: String,
511}
512
513impl fmt::Display for PluginCompatibilityError {
514    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
515        write!(
516            f,
517            "plugin `{plugin}` supports autumn-web {declared}, but this application builds against autumn-web {found}.\n  \
518             \u{2192} upgrade the plugin to a release built for autumn-web {found} \
519             (`cargo update -p {krate}`), or\n  \
520             \u{2192} pin the framework the plugin supports: autumn-web = \"{declared}\"",
521            plugin = self.plugin,
522            declared = self.declared,
523            found = self.found,
524            krate = self.plugin_crate,
525        )
526    }
527}
528
529impl std::error::Error for PluginCompatibilityError {}
530
531/// Check a plugin's declared `autumn-web` range against a framework version.
532///
533/// # Prerelease handling
534///
535/// `SemVer` excludes prereleases from a requirement that does not mention one,
536/// so a strict reading would call `autumn-web 0.7.0-rc.1` incompatible with a
537/// plugin declaring `"0.7"`. That is the opposite of what an author means: an
538/// RC of the series *is* the series. The framework version's prerelease and
539/// build metadata are therefore stripped before matching — **unless the
540/// requirement itself names a prerelease**, in which case the author is
541/// deliberately pinning one (`"=0.8.0-rc.1"`) and stripping it would make the
542/// pin fail against the exact build it names.
543#[must_use]
544pub fn evaluate(contract: &PluginContract, autumn_web_version: &str) -> ContractVerdict {
545    let Some(requirement) = contract.autumn_web.as_deref() else {
546        return ContractVerdict::Undeclared;
547    };
548
549    let parsed = match Version::parse(autumn_web_version) {
550        Ok(v) => v,
551        Err(e) => {
552            return ContractVerdict::Unparseable {
553                requirement: requirement.to_owned(),
554                reason: format!(
555                    "autumn-web version `{autumn_web_version}` is not a semver version: {e}"
556                ),
557            };
558        }
559    };
560
561    let req = match VersionReq::parse(requirement) {
562        Ok(r) => r,
563        Err(e) => {
564            return ContractVerdict::Unparseable {
565                requirement: requirement.to_owned(),
566                reason: format!("`{requirement}` is not a Cargo version requirement: {e}"),
567            };
568        }
569    };
570
571    let requirement_names_a_prerelease = req.comparators.iter().any(|c| !c.pre.is_empty());
572    let version = if requirement_names_a_prerelease {
573        parsed
574    } else {
575        Version {
576            pre: Prerelease::EMPTY,
577            build: BuildMetadata::EMPTY,
578            ..parsed
579        }
580    };
581
582    if req.matches(&version) {
583        ContractVerdict::Compatible
584    } else {
585        ContractVerdict::Incompatible(PluginCompatibilityError {
586            plugin: contract.label(),
587            plugin_crate: contract.plugin.clone(),
588            declared: requirement.to_owned(),
589            found: autumn_web_version.to_owned(),
590        })
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn the_framework_version_constant_is_a_semver_version() {
600        assert!(
601            Version::parse(AUTUMN_WEB_VERSION).is_ok(),
602            "AUTUMN_WEB_VERSION = {AUTUMN_WEB_VERSION}"
603        );
604    }
605
606    #[test]
607    fn undeclared_contracts_are_not_incompatible() {
608        let c = PluginContract::new("p");
609        assert_eq!(
610            evaluate(&c, AUTUMN_WEB_VERSION),
611            ContractVerdict::Undeclared
612        );
613    }
614
615    #[test]
616    fn a_caret_requirement_matches_its_series() {
617        let c = PluginContract::new("p").autumn_web("0.7");
618        assert_eq!(evaluate(&c, "0.7.4"), ContractVerdict::Compatible);
619        assert!(matches!(
620            evaluate(&c, "0.8.0"),
621            ContractVerdict::Incompatible(_)
622        ));
623    }
624
625    #[test]
626    fn an_exact_pin_matches_only_that_version() {
627        let c = PluginContract::new("p").autumn_web("=0.7.1");
628        assert_eq!(evaluate(&c, "0.7.1"), ContractVerdict::Compatible);
629        assert!(matches!(
630            evaluate(&c, "0.7.2"),
631            ContractVerdict::Incompatible(_)
632        ));
633    }
634
635    #[test]
636    fn label_omits_a_version_that_was_never_declared() {
637        assert_eq!(PluginContract::new("p").label(), "p");
638        assert_eq!(
639            PluginContract::new("p").plugin_version("1.0.0").label(),
640            "p 1.0.0"
641        );
642    }
643
644    #[test]
645    fn lockstep_range_is_the_minor_series_below_one_zero() {
646        assert_eq!(lockstep_range("0.7.0"), "0.7");
647        assert_eq!(lockstep_range("0.7.3-rc.1"), "0.7");
648        assert_eq!(lockstep_range("0.12.0"), "0.12");
649    }
650
651    #[test]
652    fn lockstep_range_is_the_major_from_one_zero_on() {
653        assert_eq!(lockstep_range("1.4.2"), "1");
654        assert_eq!(lockstep_range("2.0.0"), "2");
655    }
656
657    #[test]
658    fn lockstep_range_passes_through_what_it_cannot_split() {
659        // Returned unchanged so the malformed value surfaces as `Unparseable`
660        // rather than as a range that silently means something else.
661        for odd in ["", "1", "not.a.version", "x.y.z", ".7.0"] {
662            assert_eq!(lockstep_range(odd), odd, "input {odd:?}");
663        }
664    }
665
666    #[test]
667    fn lockstep_contract_combines_name_version_and_lockstep_range() {
668        let contract = lockstep_contract("my-plugin", "0.7.3");
669        assert_eq!(contract.plugin, "my-plugin");
670        assert_eq!(contract.plugin_version.as_deref(), Some("0.7.3"));
671        assert_eq!(contract.autumn_web.as_deref(), Some("0.7"));
672        assert!(contract.experimental_surfaces.is_empty());
673    }
674
675    #[test]
676    fn a_lockstep_range_admits_its_own_version() {
677        for version in ["0.7.0", "0.7.9", "1.4.2"] {
678            let c = PluginContract::new("p").autumn_web(lockstep_range(version));
679            assert_eq!(
680                evaluate(&c, version),
681                ContractVerdict::Compatible,
682                "version {version}"
683            );
684        }
685    }
686
687    #[test]
688    fn every_experimental_surface_note_says_what_may_change() {
689        for name in experimental_surface_names() {
690            let note = surface(name).expect("registry entry").note;
691            assert!(
692                note.contains("change") || note.contains("Experimental"),
693                "`{name}`'s note has to tell an author what they are opting into: {note}"
694            );
695        }
696    }
697}