use std::fmt;
use semver::{BuildMetadata, Prerelease, Version, VersionReq};
use serde::{Deserialize, Serialize};
pub const AUTUMN_WEB_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const PLUGIN_CONTRACT_MARKER: &str = "[autumn:plugin-contract] ";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum SurfaceTier {
Stable,
Experimental,
}
impl fmt::Display for SurfaceTier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Stable => f.write_str("stable"),
Self::Experimental => f.write_str("experimental"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct PluginSurface {
pub name: &'static str,
pub tier: SurfaceTier,
pub note: &'static str,
}
pub const PLUGIN_SURFACES: &[PluginSurface] = &[
PluginSurface {
name: "AppBuilder::config_section",
tier: SurfaceTier::Stable,
note: "Declare a plugin-owned top-level config root so `server.strict_config` treats it as known-and-opaque.",
},
PluginSurface {
name: "AppBuilder::declare_plugin_routes",
tier: SurfaceTier::Stable,
note: "Make routes mounted through an opaque `nest`/`merge` router visible to `autumn routes` and the conformance harness.",
},
PluginSurface {
name: "AppBuilder::error_pages",
tier: SurfaceTier::Stable,
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.",
},
PluginSurface {
name: "AppBuilder::merge",
tier: SurfaceTier::Stable,
note: "Merge a raw axum router at the root. Pair it with `declare_plugin_routes` so the routes stay attributable.",
},
PluginSurface {
name: "AppBuilder::nest",
tier: SurfaceTier::Stable,
note: "Mount a raw axum router under a prefix. Pair it with `declare_plugin_routes` so the routes stay attributable.",
},
PluginSurface {
name: "AppBuilder::on_shutdown",
tier: SurfaceTier::Stable,
note: "Register an async shutdown hook that runs during graceful drain.",
},
PluginSurface {
name: "AppBuilder::on_startup",
tier: SurfaceTier::Stable,
note: "Register an async startup hook that runs once before the server binds.",
},
PluginSurface {
name: "AppBuilder::plugin",
tier: SurfaceTier::Stable,
note: "Mount a plugin. Also the seam a cooperative plugin uses to mount a plugin of its own.",
},
PluginSurface {
name: "AppBuilder::plugin_assets",
tier: SurfaceTier::Stable,
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`.",
},
PluginSurface {
name: "AppBuilder::plugin_contracts",
tier: SurfaceTier::Stable,
note: "Read the contracts declared by the plugins mounted on a builder — what the route dump and `autumn plugin-check` are built on.",
},
PluginSurface {
name: "AppBuilder::plugin_migrations",
tier: SurfaceTier::Stable,
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).",
},
PluginSurface {
name: "AppBuilder::plugins",
tier: SurfaceTier::Stable,
note: "Mount a tuple of up to eight plugins in declaration order.",
},
PluginSurface {
name: "AppBuilder::routes",
tier: SurfaceTier::Stable,
note: "Register typed routes from `routes![]`. Plugin routes registered this way are attributed automatically.",
},
PluginSurface {
name: "AppBuilder::with_config_loader",
tier: SurfaceTier::Stable,
note: "Replace the tier-1 configuration loader (e.g. a secrets-manager backend).",
},
PluginSurface {
name: "AppBuilder::with_edge_kv",
tier: SurfaceTier::Experimental,
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.",
},
PluginSurface {
name: "AppBuilder::with_extension",
tier: SurfaceTier::Stable,
note: "Publish a typed value into application state for handlers to extract.",
},
PluginSurface {
name: "AppBuilder::with_pool_provider",
tier: SurfaceTier::Stable,
note: "Replace the tier-1 database pool provider (requires the `db` feature).",
},
PluginSurface {
name: "AppBuilder::with_session_store",
tier: SurfaceTier::Stable,
note: "Replace the tier-1 session store.",
},
PluginSurface {
name: "AppBuilder::with_telemetry_provider",
tier: SurfaceTier::Stable,
note: "Replace the tier-1 telemetry provider.",
},
PluginSurface {
name: "Plugin::build",
tier: SurfaceTier::Stable,
note: "The one required method: apply the plugin's wiring to the builder. Runs exactly once per app.",
},
PluginSurface {
name: "Plugin::contract",
tier: SurfaceTier::Stable,
note: "Declare the `autumn-web` range this plugin supports and any experimental surface it uses.",
},
PluginSurface {
name: "Plugin::name",
tier: SurfaceTier::Stable,
note: "Stable identifier used for duplicate-registration detection and route attribution.",
},
PluginSurface {
name: "autumn_edge::host",
tier: SurfaceTier::Experimental,
note: "Reference edge-capsule host API. Experimental alongside the rest of the edge lane (issue #1790).",
},
PluginSurface {
name: "db::Pool",
tier: SurfaceTier::Stable,
note: "The pool type `DatabasePoolProvider::create_pool` returns, re-exported so a plugin need not depend on `diesel-async` itself (requires the `db` feature).",
},
PluginSurface {
name: "plugin_conformance",
tier: SurfaceTier::Stable,
note: "The library-level conformance harness plugin authors run in their own test suite.",
},
PluginSurface {
name: "plugin_contract",
tier: SurfaceTier::Stable,
note: "This module: `PluginContract`, `PLUGIN_SURFACES`, `SurfaceTier`, `evaluate`, `lockstep_range`, and the `PLUGIN_CONTRACT_MARKER` dump protocol.",
},
PluginSurface {
name: "route_listing::RouteInfo",
tier: SurfaceTier::Stable,
note: "The route manifest type the conformance harness and `autumn routes --format json` share.",
},
];
#[must_use]
pub fn surface(name: &str) -> Option<&'static PluginSurface> {
PLUGIN_SURFACES.iter().find(|s| s.name == name)
}
pub fn stable_surface_names() -> impl Iterator<Item = &'static str> {
PLUGIN_SURFACES
.iter()
.filter(|s| s.tier == SurfaceTier::Stable)
.map(|s| s.name)
}
pub fn experimental_surface_names() -> impl Iterator<Item = &'static str> {
PLUGIN_SURFACES
.iter()
.filter(|s| s.tier == SurfaceTier::Experimental)
.map(|s| s.name)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PluginContract {
pub plugin: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub autumn_web: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub experimental_surfaces: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registered_as: Option<String>,
}
impl PluginContract {
#[must_use]
pub fn new(plugin: impl Into<String>) -> Self {
Self {
plugin: plugin.into(),
..Self::default()
}
}
#[must_use]
pub fn plugin_version(mut self, version: impl Into<String>) -> Self {
self.plugin_version = Some(version.into());
self
}
#[must_use]
pub fn autumn_web(mut self, requirement: impl Into<String>) -> Self {
self.autumn_web = Some(requirement.into());
self
}
#[must_use]
pub fn uses_experimental(mut self, surface: impl Into<String>) -> Self {
self.experimental_surfaces.push(surface.into());
self
}
#[must_use]
pub fn label(&self) -> String {
self.plugin_version
.as_ref()
.map_or_else(|| self.plugin.clone(), |v| format!("{} {v}", self.plugin))
}
}
#[must_use]
pub fn lockstep_range(version: &str) -> String {
let version = version.trim();
let mut parts = version.split('.');
let (Some(major), Some(minor)) = (parts.next(), parts.next()) else {
return version.to_owned();
};
if major.is_empty() || minor.is_empty() || !minor.chars().all(|c| c.is_ascii_digit()) {
return version.to_owned();
}
if major == "0" {
format!("0.{minor}")
} else if major.chars().all(|c| c.is_ascii_digit()) {
major.to_owned()
} else {
version.to_owned()
}
}
#[must_use]
pub fn lockstep_contract(name: &str, version: &str) -> PluginContract {
PluginContract::new(name)
.plugin_version(version)
.autumn_web(lockstep_range(version))
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ContractVerdict {
Undeclared,
Compatible,
Incompatible(PluginCompatibilityError),
#[non_exhaustive]
Unparseable {
requirement: String,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PluginCompatibilityError {
pub plugin: String,
pub plugin_crate: String,
pub declared: String,
pub found: String,
}
impl fmt::Display for PluginCompatibilityError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"plugin `{plugin}` supports autumn-web {declared}, but this application builds against autumn-web {found}.\n \
\u{2192} upgrade the plugin to a release built for autumn-web {found} \
(`cargo update -p {krate}`), or\n \
\u{2192} pin the framework the plugin supports: autumn-web = \"{declared}\"",
plugin = self.plugin,
declared = self.declared,
found = self.found,
krate = self.plugin_crate,
)
}
}
impl std::error::Error for PluginCompatibilityError {}
#[must_use]
pub fn evaluate(contract: &PluginContract, autumn_web_version: &str) -> ContractVerdict {
let Some(requirement) = contract.autumn_web.as_deref() else {
return ContractVerdict::Undeclared;
};
let parsed = match Version::parse(autumn_web_version) {
Ok(v) => v,
Err(e) => {
return ContractVerdict::Unparseable {
requirement: requirement.to_owned(),
reason: format!(
"autumn-web version `{autumn_web_version}` is not a semver version: {e}"
),
};
}
};
let req = match VersionReq::parse(requirement) {
Ok(r) => r,
Err(e) => {
return ContractVerdict::Unparseable {
requirement: requirement.to_owned(),
reason: format!("`{requirement}` is not a Cargo version requirement: {e}"),
};
}
};
let requirement_names_a_prerelease = req.comparators.iter().any(|c| !c.pre.is_empty());
let version = if requirement_names_a_prerelease {
parsed
} else {
Version {
pre: Prerelease::EMPTY,
build: BuildMetadata::EMPTY,
..parsed
}
};
if req.matches(&version) {
ContractVerdict::Compatible
} else {
ContractVerdict::Incompatible(PluginCompatibilityError {
plugin: contract.label(),
plugin_crate: contract.plugin.clone(),
declared: requirement.to_owned(),
found: autumn_web_version.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_framework_version_constant_is_a_semver_version() {
assert!(
Version::parse(AUTUMN_WEB_VERSION).is_ok(),
"AUTUMN_WEB_VERSION = {AUTUMN_WEB_VERSION}"
);
}
#[test]
fn undeclared_contracts_are_not_incompatible() {
let c = PluginContract::new("p");
assert_eq!(
evaluate(&c, AUTUMN_WEB_VERSION),
ContractVerdict::Undeclared
);
}
#[test]
fn a_caret_requirement_matches_its_series() {
let c = PluginContract::new("p").autumn_web("0.7");
assert_eq!(evaluate(&c, "0.7.4"), ContractVerdict::Compatible);
assert!(matches!(
evaluate(&c, "0.8.0"),
ContractVerdict::Incompatible(_)
));
}
#[test]
fn an_exact_pin_matches_only_that_version() {
let c = PluginContract::new("p").autumn_web("=0.7.1");
assert_eq!(evaluate(&c, "0.7.1"), ContractVerdict::Compatible);
assert!(matches!(
evaluate(&c, "0.7.2"),
ContractVerdict::Incompatible(_)
));
}
#[test]
fn label_omits_a_version_that_was_never_declared() {
assert_eq!(PluginContract::new("p").label(), "p");
assert_eq!(
PluginContract::new("p").plugin_version("1.0.0").label(),
"p 1.0.0"
);
}
#[test]
fn lockstep_range_is_the_minor_series_below_one_zero() {
assert_eq!(lockstep_range("0.7.0"), "0.7");
assert_eq!(lockstep_range("0.7.3-rc.1"), "0.7");
assert_eq!(lockstep_range("0.12.0"), "0.12");
}
#[test]
fn lockstep_range_is_the_major_from_one_zero_on() {
assert_eq!(lockstep_range("1.4.2"), "1");
assert_eq!(lockstep_range("2.0.0"), "2");
}
#[test]
fn lockstep_range_passes_through_what_it_cannot_split() {
for odd in ["", "1", "not.a.version", "x.y.z", ".7.0"] {
assert_eq!(lockstep_range(odd), odd, "input {odd:?}");
}
}
#[test]
fn lockstep_contract_combines_name_version_and_lockstep_range() {
let contract = lockstep_contract("my-plugin", "0.7.3");
assert_eq!(contract.plugin, "my-plugin");
assert_eq!(contract.plugin_version.as_deref(), Some("0.7.3"));
assert_eq!(contract.autumn_web.as_deref(), Some("0.7"));
assert!(contract.experimental_surfaces.is_empty());
}
#[test]
fn a_lockstep_range_admits_its_own_version() {
for version in ["0.7.0", "0.7.9", "1.4.2"] {
let c = PluginContract::new("p").autumn_web(lockstep_range(version));
assert_eq!(
evaluate(&c, version),
ContractVerdict::Compatible,
"version {version}"
);
}
}
#[test]
fn every_experimental_surface_note_says_what_may_change() {
for name in experimental_surface_names() {
let note = surface(name).expect("registry entry").note;
assert!(
note.contains("change") || note.contains("Experimental"),
"`{name}`'s note has to tell an author what they are opting into: {note}"
);
}
}
}