#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
Auto,
Review,
Manual,
}
impl Confidence {
pub const fn label(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Review => "review",
Self::Manual => "manual",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallForm {
AssociatedFunction,
Method,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReceiverShape {
pub prefix: &'static str,
pub suffix: &'static str,
}
impl ReceiverShape {
#[must_use]
pub fn matches(&self, ident: &str) -> bool {
ident.len() >= self.prefix.len() + self.suffix.len()
&& ident.starts_with(self.prefix)
&& ident.ends_with(self.suffix)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rewrite {
CallRename {
from: &'static str,
to: &'static str,
form: CallForm,
args: usize,
receiver: Option<ReceiverShape>,
},
GuideOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppMigration {
pub id: &'static str,
pub version: &'static str,
pub title: &'static str,
pub confidence: Confidence,
pub guide: &'static str,
pub rewrite: Rewrite,
}
pub static APP_MIGRATIONS: &[AppMigration] = &[
AppMigration {
id: "0.4.0-security-production-signing-secret",
version: "0.4.0",
title: "a production signing secret is required",
confidence: Confidence::Manual,
guide: "docs/migrations/0.4.0.md#security-production-signing-secret-is-required",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.4.0-storage-s3-plugin-crate",
version: "0.4.0",
title: "`storage-s3` moved to the `autumn-storage-s3` plugin crate",
confidence: Confidence::Manual,
guide: "docs/migrations/0.4.0.md#storage-storage-s3-moved-to-a-plugin-crate",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.4.0-authorization-policy-required",
version: "0.4.0",
title: "repository APIs require a policy in production",
confidence: Confidence::Manual,
guide: "docs/migrations/0.4.0.md#authorization-repository-apis-require-a-policy-in-production",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.4.0-mail-deliver-later-durable",
version: "0.4.0",
title: "production `deliver_later` must be durable or acknowledged",
confidence: Confidence::Manual,
guide: "docs/migrations/0.4.0.md#mail-production-deliver_later-must-be-durable-or-acknowledged",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.5.0-security-forwarded-header-trust",
version: "0.5.0",
title: "forwarded-header trust is declared once, centrally",
confidence: Confidence::Manual,
guide: "docs/migrations/0.5.0.md#security-forwarded-header-trust-is-now-declared-once",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.5.0-deprecated-configuration-keys",
version: "0.5.0",
title: "deprecated configuration keys",
confidence: Confidence::Manual,
guide: "docs/migrations/0.5.0.md#deprecated-configuration-keys",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.5.0-client-identity-extractors",
version: "0.5.0",
title: "read client identity through `ClientAddr` / `ClientHost` / `ClientScheme`",
confidence: Confidence::Manual,
guide: "docs/migrations/0.5.0.md#reading-client-identity-clientaddr-clienthost-clientscheme",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.6.0-tenancy-jwt-secret-secretstring",
version: "0.6.0",
title: "`TenancyConfig::jwt_secret` is now a `secrecy::SecretString`",
confidence: Confidence::Manual,
guide: "docs/migrations/0.6.0.md#security-tenancyconfigjwt_secret-is-now-a-secrecysecretstring",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.6.0-repository-with-pool-untracked",
version: "0.6.0",
title: "repository constructor `with_pool` is renamed to `with_pool_untracked`",
confidence: Confidence::Auto,
guide: "docs/migrations/0.6.0.md#repository-with_pool-is-renamed-to-with_pool_untracked",
rewrite: Rewrite::CallRename {
from: "with_pool",
to: "with_pool_untracked",
form: CallForm::AssociatedFunction,
args: 1,
receiver: Some(ReceiverShape {
prefix: "Pg",
suffix: "Repository",
}),
},
},
AppMigration {
id: "0.7.0-routing-route-seo-field",
version: "0.7.0",
title: "`Route` and `StaticRouteMeta` gained an `seo` field",
confidence: Confidence::Manual,
guide: "docs/migrations/0.7.0.md#routing-route-and-staticroutemeta-gained-an-seo-field",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.7.0-api-lock-version-required",
version: "0.7.0",
title: "a `lock_version` model requires the version on JSON `PUT`/`PATCH`",
confidence: Confidence::Manual,
guide: "docs/migrations/0.7.0.md#json-api-a-lock_version-model-now-requires-the-version-on-putpatch",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.7.0-app-layers-erased",
version: "0.7.0",
title: "app-wide layers are registered against the erased ingress service",
confidence: Confidence::Manual,
guide: "docs/migrations/0.7.0.md#app-wide-layers-are-now-erased",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.7.0-migrate-profile-agnostic",
version: "0.7.0",
title: "`auto_migrate_in_production` is honoured on any non-`dev` profile",
confidence: Confidence::Manual,
guide: "docs/migrations/0.7.0.md#startup-migrations-are-now-profile-agnostic-1903",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.7.0-query-decoding",
version: "0.7.0",
title: "`Query<T>` decodes structure, rejects duplicate scalar keys, and sorts pair vectors",
confidence: Confidence::Manual,
guide: "docs/migrations/0.7.0.md#queryt-decoding-1972",
rewrite: Rewrite::GuideOnly,
},
];
pub fn app_migrations() -> &'static [AppMigration] {
APP_MIGRATIONS
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Version {
pub major: u64,
pub minor: u64,
pub patch: u64,
pub prerelease: Option<String>,
}
impl Version {
#[must_use]
pub const fn new(major: u64, minor: u64, patch: u64) -> Self {
Self {
major,
minor,
patch,
prerelease: None,
}
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
(self.major, self.minor, self.patch)
.cmp(&(other.major, other.minor, other.patch))
.then_with(|| other.prerelease.is_some().cmp(&self.prerelease.is_some()))
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
if let Some(prerelease) = &self.prerelease {
write!(f, "-{prerelease}")?;
}
Ok(())
}
}
pub fn parse_version_req(req: &str) -> Option<Version> {
fn is_wildcard(part: &str) -> bool {
matches!(part, "*" | "x" | "X")
}
let req = req.trim();
if req.is_empty() || req.contains(',') {
return None;
}
if req.starts_with('<') {
return None;
}
let without_build = req
.trim_start_matches(['=', '^', '~', '>'])
.trim()
.split('+')
.next()?;
let (core, prerelease) = without_build.split_once('-').map_or_else(
|| (without_build, None),
|(core, identifier)| (core, Some(identifier.to_owned())),
);
let core = core.trim();
if core.is_empty() {
return None;
}
let mut parts = core.split('.');
let first = parts.next()?;
if is_wildcard(first) {
return None;
}
let major = first.parse::<u64>().ok()?;
let mut wildcarded = false;
let mut segment = |part: Option<&str>| -> Option<u64> {
match part {
None => Some(0),
Some(part) if is_wildcard(part) => {
wildcarded = true;
Some(0)
}
Some(_) if wildcarded => None,
Some(part) => part.parse::<u64>().ok(),
}
};
let minor = segment(parts.next())?;
let patch = segment(parts.next())?;
if parts.next().is_some() {
return None;
}
Some(Version {
prerelease,
..Version::new(major, minor, patch)
})
}
pub const GUIDE_BASE_URL: &str = "https://github.com/autumn-foundation/autumn/blob/trunk-dev/";
#[must_use]
pub fn guide_url(guide: &str) -> String {
format!("{GUIDE_BASE_URL}{guide}")
}
pub fn migrations_between(from: &Version, to: &Version) -> Vec<&'static AppMigration> {
select_between(APP_MIGRATIONS, from, to)
}
pub fn select_between(
registry: &'static [AppMigration],
from: &Version,
to: &Version,
) -> Vec<&'static AppMigration> {
registry
.iter()
.filter(|migration| {
parse_version_req(migration.version)
.is_some_and(|version| version > *from && version <= *to)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn v(major: u64, minor: u64, patch: u64) -> Version {
Version::new(major, minor, patch)
}
fn pre(major: u64, minor: u64, patch: u64) -> Version {
pre_id(major, minor, patch, "rc.1")
}
fn pre_id(major: u64, minor: u64, patch: u64, identifier: &str) -> Version {
Version {
prerelease: Some(identifier.to_owned()),
..Version::new(major, minor, patch)
}
}
#[test]
fn parses_bare_and_operator_prefixed_requirements() {
assert_eq!(parse_version_req("0.6.0"), Some(v(0, 6, 0)));
assert_eq!(parse_version_req("=0.5.0"), Some(v(0, 5, 0)));
assert_eq!(parse_version_req("^0.5.1"), Some(v(0, 5, 1)));
assert_eq!(parse_version_req(">=0.4"), Some(v(0, 4, 0)));
assert_eq!(parse_version_req("~0.5"), Some(v(0, 5, 0)));
assert_eq!(parse_version_req(" 0.6.0 "), Some(v(0, 6, 0)));
}
#[test]
fn parses_partial_requirements_as_their_lowest_admitted_version() {
assert_eq!(parse_version_req("0.5"), Some(v(0, 5, 0)));
assert_eq!(parse_version_req("1"), Some(v(1, 0, 0)));
}
#[test]
fn ignores_build_metadata_but_keeps_the_prerelease() {
assert_eq!(parse_version_req("0.7.0+build.5"), Some(v(0, 7, 0)));
assert_eq!(parse_version_req("0.7.0-rc.1"), Some(pre(0, 7, 0)));
assert_eq!(parse_version_req("0.7.0-rc.1+build.5"), Some(pre(0, 7, 0)));
assert_eq!(
parse_version_req("0.7.0-beta.2"),
Some(pre_id(0, 7, 0, "beta.2"))
);
assert_eq!(
parse_version_req("0.7.0-rc.1")
.map(|v| v.to_string())
.as_deref(),
Some("0.7.0-rc.1")
);
assert_eq!(
parse_version_req("0.7.0-beta.2")
.map(|v| v.to_string())
.as_deref(),
Some("0.7.0-beta.2")
);
}
#[test]
fn prereleases_of_one_triple_are_interchangeable_for_selection() {
assert_eq!(pre_id(0, 7, 0, "rc.1"), pre_id(0, 7, 0, "rc.1"));
assert!(pre_id(0, 7, 0, "beta.2") < v(0, 7, 0));
assert_eq!(
pre_id(0, 7, 0, "rc.1").cmp(&pre_id(0, 7, 0, "beta.2")),
std::cmp::Ordering::Equal,
"two pre-releases of one triple are interchangeable for selection"
);
}
#[test]
fn a_prerelease_sorts_below_its_release() {
assert!(pre(0, 7, 0) < v(0, 7, 0), "0.7.0-rc.1 precedes 0.7.0");
assert!(v(0, 6, 0) < pre(0, 7, 0), "and still follows 0.6.0");
assert_ne!(pre(0, 7, 0), v(0, 7, 0));
}
#[test]
fn an_app_on_a_release_candidate_still_needs_that_releases_migrations() {
let ids = ids(&select_between(
FIXTURE_REGISTRY,
&pre(0, 7, 0),
&v(0, 7, 0),
));
assert_eq!(ids, vec!["0.7.0-c"]);
}
#[test]
fn targeting_a_release_candidate_does_not_select_the_release() {
let ids = ids(&select_between(
FIXTURE_REGISTRY,
&v(0, 6, 0),
&pre(0, 7, 0),
));
assert!(ids.is_empty(), "0.7.0 is not reached yet, got {ids:?}");
}
#[test]
fn rejects_unparsable_requirements() {
assert_eq!(parse_version_req("*"), None);
assert_eq!(parse_version_req(">=0.4, <0.6"), None);
assert_eq!(parse_version_req("<0.6"), None);
assert_eq!(parse_version_req("<=0.6.0"), None);
assert_eq!(parse_version_req(""), None);
assert_eq!(parse_version_req("trunk-dev"), None);
assert_eq!(parse_version_req("x"), None);
assert_eq!(parse_version_req("X.1"), None);
assert_eq!(parse_version_req("1.*.3"), None);
}
#[test]
fn a_wildcard_segment_floors_at_zero() {
assert_eq!(parse_version_req("0.5.*"), Some(Version::new(0, 5, 0)));
assert_eq!(parse_version_req("0.5.x"), Some(Version::new(0, 5, 0)));
assert_eq!(parse_version_req("^1.2.*"), Some(Version::new(1, 2, 0)));
assert_eq!(parse_version_req("0.*"), parse_version_req("0"));
assert_eq!(parse_version_req("0.x"), Some(Version::new(0, 0, 0)));
}
#[test]
fn selects_migrations_newer_than_the_recorded_version() {
let ids: Vec<_> = migrations_between(&v(0, 5, 0), &v(0, 6, 0))
.iter()
.map(|m| m.id)
.collect();
assert!(
ids.contains(&"0.6.0-repository-with-pool-untracked"),
"0.5.0 -> 0.6.0 must include the 0.6.0 rename, got {ids:?}"
);
}
#[test]
fn excludes_migrations_the_app_is_already_past() {
let selected = migrations_between(&v(0, 6, 0), &v(0, 7, 0));
let ids: Vec<_> = selected.iter().map(|m| m.id).collect();
assert!(
selected.iter().all(|m| m.version == "0.7.0"),
"0.6.0 -> 0.7.0 must select only 0.7.0 migrations, got {ids:?}"
);
assert!(
!ids.is_empty(),
"0.7.0 registers migrations, so this range is not empty — if it is, \
the range filter dropped them"
);
}
#[test]
fn excludes_migrations_newer_than_the_target() {
let selected = migrations_between(&v(0, 4, 0), &v(0, 5, 0));
assert!(
selected.iter().all(|m| m.version == "0.5.0"),
"targeting 0.5.0 selects 0.5.0 and nothing newer, got {:?}",
selected.iter().map(|m| m.id).collect::<Vec<_>>()
);
assert!(
!selected.is_empty(),
"0.5.0 is ahead of 0.4.0 and its changes must be reported"
);
}
#[test]
fn selection_is_empty_when_target_is_not_newer() {
assert!(migrations_between(&v(0, 6, 0), &v(0, 6, 0)).is_empty());
assert!(migrations_between(&v(0, 6, 0), &v(0, 5, 0)).is_empty());
}
#[test]
fn selection_is_ordered_oldest_release_first() {
let selected = migrations_between(&v(0, 0, 0), &v(9, 9, 9));
let mut previous = v(0, 0, 0);
for migration in &selected {
let current = parse_version_req(migration.version)
.unwrap_or_else(|| panic!("registry version {} must parse", migration.version));
assert!(
current >= previous,
"registry must stay sorted oldest-first: {current} after {previous}"
);
previous = current;
}
assert_eq!(selected.len(), APP_MIGRATIONS.len());
}
static FIXTURE_REGISTRY: &[AppMigration] = &[
AppMigration {
id: "0.5.0-a",
version: "0.5.0",
title: "a",
confidence: Confidence::Manual,
guide: "docs/migrations/0.5.0.md#a",
rewrite: Rewrite::GuideOnly,
},
AppMigration {
id: "0.6.0-b",
version: "0.6.0",
title: "b",
confidence: Confidence::Auto,
guide: "docs/migrations/0.6.0.md#b",
rewrite: Rewrite::CallRename {
from: "old",
to: "new",
form: CallForm::AssociatedFunction,
args: 1,
receiver: None,
},
},
AppMigration {
id: "0.7.0-c",
version: "0.7.0",
title: "c",
confidence: Confidence::Review,
guide: "docs/migrations/0.7.0.md#c",
rewrite: Rewrite::CallRename {
from: "older",
to: "newer",
form: CallForm::AssociatedFunction,
args: 1,
receiver: None,
},
},
];
fn ids(selected: &[&'static AppMigration]) -> Vec<&'static str> {
selected.iter().map(|m| m.id).collect()
}
#[test]
fn a_multi_release_range_selects_every_release_it_spans_in_order() {
assert_eq!(
ids(&select_between(FIXTURE_REGISTRY, &v(0, 4, 0), &v(0, 7, 0))),
vec!["0.5.0-a", "0.6.0-b", "0.7.0-c"],
);
}
#[test]
fn a_multi_release_range_excludes_both_ends_correctly() {
assert_eq!(
ids(&select_between(FIXTURE_REGISTRY, &v(0, 5, 0), &v(0, 6, 0))),
vec!["0.6.0-b"],
);
assert_eq!(
ids(&select_between(FIXTURE_REGISTRY, &v(0, 5, 0), &v(0, 7, 0))),
vec!["0.6.0-b", "0.7.0-c"],
);
assert!(select_between(FIXTURE_REGISTRY, &v(0, 7, 0), &v(0, 7, 0)).is_empty());
}
#[test]
fn registry_entries_are_well_formed() {
let mut seen = std::collections::HashSet::new();
for migration in app_migrations() {
assert!(
seen.insert(migration.id),
"duplicate migration id {}",
migration.id
);
assert!(
parse_version_req(migration.version).is_some(),
"{} has an unparsable version {}",
migration.id,
migration.version
);
assert!(
migration.id.starts_with(migration.version),
"{} must be prefixed with its release version",
migration.id
);
assert!(
migration.guide.starts_with("docs/migrations/") && migration.guide.contains('#'),
"{} must link a guide section, got {}",
migration.id,
migration.guide
);
match migration.rewrite {
Rewrite::CallRename { from, to, .. } => {
assert_ne!(from, to, "{} renames nothing", migration.id);
assert_ne!(
migration.confidence,
Confidence::Manual,
"{} rewrites code, so it cannot be labelled manual",
migration.id
);
}
Rewrite::GuideOnly => assert_eq!(
migration.confidence,
Confidence::Manual,
"{} rewrites nothing, so it must be labelled manual",
migration.id
),
}
}
}
#[test]
fn no_two_renames_in_one_release_chain_into_each_other() {
for migration in app_migrations() {
let Rewrite::CallRename { to, .. } = migration.rewrite else {
continue;
};
for other in app_migrations() {
let Rewrite::CallRename { from, .. } = other.rewrite else {
continue;
};
assert!(
from != to || migration.version != other.version,
"{} renames to `{to}`, which {} renames again in the same release",
migration.id,
other.id
);
}
}
}
#[test]
fn the_first_shipped_codemod_is_the_with_pool_rename() {
let migration = app_migrations()
.iter()
.find(|m| m.id == "0.6.0-repository-with-pool-untracked")
.expect("the with_pool rename is the first shipped codemod (issue #1629)");
assert_eq!(migration.confidence, Confidence::Auto);
assert_eq!(
migration.rewrite,
Rewrite::CallRename {
from: "with_pool",
to: "with_pool_untracked",
form: CallForm::AssociatedFunction,
args: 1,
receiver: Some(ReceiverShape {
prefix: "Pg",
suffix: "Repository",
}),
}
);
}
#[test]
fn the_receiver_shape_matches_what_the_repository_macro_emits() {
let shape = ReceiverShape {
prefix: "Pg",
suffix: "Repository",
};
assert!(shape.matches("PgPostRepository"));
assert!(shape.matches("PgSubredditRepository"));
assert!(shape.matches("PgRepository"), "a trait named `Repository`");
assert!(!shape.matches("PgCache"));
assert!(!shape.matches("PgConnectionPool"));
assert!(!shape.matches("PostRepository"), "that is the trait");
assert!(!shape.matches("Cache"));
assert!(!shape.matches("Pg"));
}
#[test]
fn confidence_labels_match_the_guide_vocabulary() {
assert_eq!(Confidence::Auto.label(), "auto");
assert_eq!(Confidence::Review.label(), "review");
assert_eq!(Confidence::Manual.label(), "manual");
}
#[test]
fn versions_order_by_major_then_minor_then_patch() {
assert!(v(0, 5, 9) < v(0, 6, 0));
assert!(v(1, 0, 0) > v(0, 9, 9));
assert_eq!(v(0, 6, 1), v(0, 6, 1));
}
}