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}