#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CatalogEntry {
pub crate_name: &'static str,
pub summary: &'static str,
pub mount: &'static str,
pub mount_call: &'static str,
pub mount_arg: &'static str,
pub constructor: &'static str,
pub migrations: &'static [&'static str],
pub tables: &'static [&'static str],
pub config_keys: &'static [&'static str],
pub post_install: &'static [&'static str],
}
pub const FIRST_PARTY: &[CatalogEntry] = &[
CatalogEntry {
crate_name: "autumn-admin-plugin",
summary: "Out-of-the-box admin panel plugin for autumn-web applications",
mount: concat!(
" // added by `autumn plugin add autumn-admin-plugin`\n",
" .plugin(autumn_admin_plugin::AdminPlugin::new())\n",
),
mount_call: ".plugin(",
mount_arg: "autumn_admin_plugin::AdminPlugin",
constructor: "AdminPlugin::new(",
migrations: &[],
tables: &[],
config_keys: &["[database]\nprimary_url = \"postgres://localhost/my_app\""],
post_install: &[
"Register a model with `autumn generate admin <Model>` — the panel mounts at /admin but starts with no models.",
"Sign in with a session carrying the `admin` role; override with `AdminPlugin::new().require_role(...)`.",
"The jobs dashboard at /admin/jobs works without a database; model screens need a configured pool.",
],
},
CatalogEntry {
crate_name: "autumn-billing",
summary: "Subscription billing plugin (Stripe checkout, portal, webhook mirror, dunning) for autumn-web applications",
mount: concat!(
" // added by `autumn plugin add autumn-billing`\n",
" .plugin(autumn_billing::BillingPlugin::new())\n",
),
mount_call: ".plugin(",
mount_arg: "autumn_billing::BillingPlugin",
constructor: "BillingPlugin::new(",
migrations: &["20260910203829_billing_mirror"],
tables: &[
"billing_dunning",
"billing_events",
"billing_invoices",
"billing_subscriptions",
"billing_customers",
],
config_keys: &[
"[[security.webhooks.endpoints]]\nname = \"billing\"\npath = \"/billing/webhook\"\nprovider = \"stripe\"\nsecret_env = \"STRIPE_WEBHOOK_SECRET\"\nmax_body_bytes = 4194304",
"[billing]\nsuccess_url = \"/billing/success\"\ncancel_url = \"/billing/cancel\"",
],
post_install: &[
"Set `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` in the environment; declare the `/billing/webhook` endpoint under `[[security.webhooks.endpoints]]`.",
"Declare plans in code (`BillingPlugin::plans`) or under `[[billing.plans]]`, then gate routes with `Entitled<YourPlan>`.",
],
},
CatalogEntry {
crate_name: "autumn-cache-redis",
summary: "Redis-backed shared cache plugin for autumn-web applications",
mount: concat!(
" // added by `autumn plugin add autumn-cache-redis`\n",
" .plugin(autumn_cache_redis::RedisCachePlugin::new())\n",
),
mount_call: ".plugin(",
mount_arg: "autumn_cache_redis::RedisCachePlugin",
constructor: "RedisCachePlugin::new(",
migrations: &[],
tables: &[],
config_keys: &[
"[cache]\nbackend = \"redis\"\n\n[cache.redis]\nurl = \"redis://127.0.0.1:6379\"",
],
post_install: &[
"Until `[cache] backend = \"redis\"` is set the plugin is a no-op and the in-process Moka cache stays in use.",
],
},
CatalogEntry {
crate_name: "autumn-media-plugin",
summary: "Live-streaming media plugin (broadcast + rooms) for autumn-web applications",
mount: concat!(
" // added by `autumn plugin add autumn-media-plugin`\n",
" .plugin(autumn_media_plugin::MediaPlugin::new())\n",
),
mount_call: ".plugin(",
mount_arg: "autumn_media_plugin::MediaPlugin",
constructor: "MediaPlugin::new(",
migrations: &["20260720000000_media_rooms"],
tables: &["media_room_participants", "media_rooms"],
config_keys: &[
"[media]\nroom_max_participants = 6\n\n[media.mediamtx]\napi_base = \"http://127.0.0.1:9997\"",
],
post_install: &[
"Both primitives are off by default — chain `.with_broadcast()` and/or `.with_rooms()` to enable them.",
"Broadcast needs a reachable MediaMTX origin and an ffmpeg binary; see autumn-media-plugin/README.md.",
],
},
CatalogEntry {
crate_name: "autumn-search",
summary: "Keyword and vector search plugin for autumn-web applications: mark a model searchable, get an index that stays in sync",
mount: concat!(
" // added by `autumn plugin add autumn-search`\n",
" .plugin(autumn_search::SearchPlugin::new())\n",
),
mount_call: ".plugin(",
mount_arg: "autumn_search::SearchPlugin",
constructor: "SearchPlugin::new(",
migrations: &[],
tables: &["autumn_search_documents", "autumn_search_deletes"],
config_keys: &["[search]\nengine = \"postgres\""],
post_install: &[
"Mark a model with `#[searchable]`, then register it: `SearchPlugin::new().postgres().index::<Model>()`.",
"Keep the index in sync by giving the model's `#[repository]` a `SearchSyncHooks` alias.",
"Backfill an existing table with `autumn search reindex`.",
],
},
CatalogEntry {
crate_name: "autumn-storage-s3",
summary: "S3-compatible blob storage plugin for autumn-web applications",
mount: concat!(
" // added by `autumn plugin add autumn-storage-s3`\n",
" .with_blob_store({\n",
" // `load_lenient_unknown_roots` (not `load`) so a config section\n",
" // belonging to another plugin — `[media]`, `[search]` — is a warning\n",
" // here rather than a hard failure: this load only needs `[storage]`.\n",
" let config = autumn_web::config::AutumnConfig::load_lenient_unknown_roots()\n",
" .expect(\"autumn.toml must load before the S3 blob store can be built\");\n",
" autumn_storage_s3::S3BlobStore::from_config(&config.storage.s3)\n",
" .await\n",
" .expect(\"`[storage.s3]` must set `bucket` and `region`\")\n",
" })\n",
),
mount_call: ".with_blob_store(",
mount_arg: "autumn_storage_s3::S3BlobStore",
constructor: "S3BlobStore::from_config(",
migrations: &[],
tables: &[],
config_keys: &[
"[storage]\nbackend = \"s3\"\n\n[storage.s3]\nbucket = \"my-bucket\"\nregion = \"us-east-1\"",
],
post_install: &[
"Set `[storage.s3] bucket` and `region` before starting the app — the mount panics with that message until you do.",
"Credentials come from the standard AWS chain, or name env vars with `access_key_id_env` / `secret_access_key_env`.",
],
},
];
#[must_use]
pub fn lookup(name: &str) -> Option<&'static CatalogEntry> {
FIRST_PARTY.iter().find(|entry| entry.crate_name == name)
}
pub const COMMUNITY_PREFIX: &str = "autumn-plugin-";
#[must_use]
pub fn is_community_name(name: &str) -> bool {
name.strip_prefix(COMMUNITY_PREFIX).is_some_and(|rest| {
!rest.is_empty()
&& rest
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
})
}
#[must_use]
pub fn community_struct_name(name: &str) -> Option<String> {
if !is_community_name(name) {
return None;
}
let rest = name.strip_prefix(COMMUNITY_PREFIX)?;
let mut out = String::with_capacity(rest.len() + "Plugin".len());
for segment in rest.split('-').filter(|s| !s.is_empty()) {
let mut chars = segment.chars();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
}
if out.is_empty() {
return None;
}
out.push_str("Plugin");
Some(out)
}
#[must_use]
pub fn module_path_for(crate_name: &str) -> String {
crate_name.replace('-', "_")
}
#[must_use]
pub fn community_mount_snippet(crate_name: &str) -> Option<String> {
let struct_name = community_struct_name(crate_name)?;
Some(format!(
" .plugin({}::{struct_name}::new())",
module_path_for(crate_name)
))
}
#[cfg(test)]
mod tests {
use super::*;
const NON_PLUGIN_MEMBERS: &[&str] = &[
"autumn",
"autumn-macros",
"autumn-macros-model",
"autumn-macros-repository",
"autumn-macros-support",
"autumn-cli",
"autumn-schema-core",
"autumn-edge",
"autumn-plugin-reference",
];
const PUBLISHED_NON_PLUGIN_MEMBERS: &[&str] = &[
"autumn",
"autumn-macros",
"autumn-macros-model",
"autumn-macros-repository",
"autumn-macros-support",
"autumn-cli",
"autumn-schema-core",
"autumn-edge",
];
#[test]
fn every_excluded_member_is_unpublishable_or_core() {
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root");
for member in NON_PLUGIN_MEMBERS {
if PUBLISHED_NON_PLUGIN_MEMBERS.contains(member) {
continue;
}
let manifest = workspace.join(member).join("Cargo.toml");
let content = std::fs::read_to_string(&manifest)
.unwrap_or_else(|e| panic!("{}: {e}", manifest.display()));
let table: toml::Table = toml::from_str(&content).expect("parse manifest");
let publish = table
.get("package")
.and_then(|p| p.get("publish"))
.and_then(toml::Value::as_bool);
assert_eq!(
publish,
Some(false),
"{member} is excluded from the plugin catalog but is publishable; either list it \
in the catalog or add it to PUBLISHED_NON_PLUGIN_MEMBERS with a reason"
);
}
}
#[test]
fn catalog_covers_every_first_party_plugin_in_the_workspace() {
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root");
let manifest = std::fs::read_to_string(workspace.join("Cargo.toml")).expect("manifest");
let table: toml::Table = toml::from_str(&manifest).expect("parse workspace manifest");
let members = table["workspace"]["members"]
.as_array()
.expect("members array");
let expected: Vec<String> = members
.iter()
.filter_map(toml::Value::as_str)
.filter(|member| !member.contains('/') && *member != "example-e2e")
.filter(|member| !NON_PLUGIN_MEMBERS.contains(member))
.map(std::borrow::ToOwned::to_owned)
.collect();
assert!(
expected.len() >= 5,
"the workspace enumeration found too few plugin crates: {expected:?}"
);
let names: Vec<&str> = FIRST_PARTY.iter().map(|e| e.crate_name).collect();
for member in &expected {
assert!(
names.contains(&member.as_str()),
"catalog is missing the first-party plugin crate {member}"
);
}
}
#[test]
fn catalog_entries_match_their_crate_manifests() {
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root");
for entry in FIRST_PARTY {
let manifest = workspace.join(entry.crate_name).join("Cargo.toml");
let content = std::fs::read_to_string(&manifest)
.unwrap_or_else(|e| panic!("{}: {e}", manifest.display()));
let table: toml::Table = toml::from_str(&content).expect("parse manifest");
let description = table["package"]["description"]
.as_str()
.expect("description")
.to_owned();
assert_eq!(
entry.summary, description,
"{} summary drifted from its Cargo.toml description",
entry.crate_name
);
}
}
#[test]
fn every_mount_contains_its_own_detection_markers() {
for entry in FIRST_PARTY {
for (label, needle) in [
("mount_call", entry.mount_call),
("mount_arg", entry.mount_arg),
("constructor", entry.constructor),
] {
assert!(
entry.mount.contains(needle),
"{}: {label} {needle:?} not in its own mount",
entry.crate_name
);
}
assert!(
entry
.mount
.contains(&format!("autumn plugin add {}", entry.crate_name)),
"{}: mount is missing its marker comment",
entry.crate_name
);
}
}
#[test]
fn detection_markers_have_the_right_shapes() {
for entry in FIRST_PARTY {
assert!(
entry.mount_arg.contains("::") && !entry.mount_arg.contains('('),
"{}: mount_arg should be a bare type path",
entry.crate_name
);
assert!(
entry.constructor.ends_with('('),
"{}: constructor should be a call",
entry.crate_name
);
assert!(
entry.mount_call.starts_with('.') && entry.mount_call.ends_with('('),
"{}: mount_call should be a builder call",
entry.crate_name
);
}
}
#[test]
fn lookup_finds_first_party_entries() {
assert_eq!(
lookup("autumn-admin-plugin").map(|e| e.crate_name),
Some("autumn-admin-plugin")
);
}
#[test]
fn lookup_rejects_unknown_names() {
assert!(lookup("autumn-plugin-nope").is_none());
assert!(lookup("serde").is_none());
}
#[test]
fn community_names_follow_the_documented_prefix() {
assert!(is_community_name("autumn-plugin-live-feed"));
assert!(is_community_name("autumn-plugin-audit_log2"));
assert!(!is_community_name("autumn-admin-plugin"));
assert!(!is_community_name("autumn-plugin-"));
assert!(!is_community_name("serde"));
}
#[test]
fn community_names_reject_anything_that_is_not_a_crate_name() {
for hostile in [
"autumn-plugin-x/../../crates/serde",
"autumn-plugin-x?q=1",
"autumn-plugin-x y",
"autumn-plugin-x\"\n[dependencies]\nevil = \"1",
"autumn-plugin-../serde",
] {
assert!(!is_community_name(hostile), "{hostile:?} must be rejected");
assert!(community_mount_snippet(hostile).is_none(), "{hostile:?}");
}
}
#[test]
fn community_struct_name_follows_the_documented_convention() {
assert_eq!(
community_struct_name("autumn-plugin-live-feed").as_deref(),
Some("LiveFeedPlugin")
);
assert_eq!(
community_struct_name("autumn-plugin-audit").as_deref(),
Some("AuditPlugin")
);
assert_eq!(community_struct_name("autumn-plugin-").as_deref(), None);
assert_eq!(community_struct_name("autumn-admin-plugin"), None);
}
#[test]
fn module_path_replaces_hyphens_with_underscores() {
assert_eq!(
module_path_for("autumn-plugin-live-feed"),
"autumn_plugin_live_feed"
);
assert_eq!(
module_path_for("autumn-admin-plugin"),
"autumn_admin_plugin"
);
}
#[test]
fn community_mount_snippet_is_a_plugin_call() {
let snippet = community_mount_snippet("autumn-plugin-live-feed").expect("snippet");
assert!(
snippet.contains(".plugin(autumn_plugin_live_feed::LiveFeedPlugin::new())"),
"{snippet}"
);
assert!(community_mount_snippet("autumn-plugin-").is_none());
}
#[test]
fn billing_webhook_entry_declares_the_body_limit_billing_needs() {
const BILLING_MIN_BODY_LIMIT: i64 = 4 * 1024 * 1024;
let entry = lookup("autumn-billing").expect("billing is in the catalog");
let snippet = entry
.config_keys
.iter()
.find(|keys| keys.contains("[[security.webhooks.endpoints]]"))
.expect("billing declares its webhook endpoint");
let table: toml::Table = snippet.parse().expect("the printed entry is valid TOML");
let limit = table["security"]["webhooks"]["endpoints"][0]["max_body_bytes"]
.as_integer()
.expect("the entry declares max_body_bytes");
assert!(
limit >= BILLING_MIN_BODY_LIMIT,
"the printed billing entry allows {limit} bytes, below the {BILLING_MIN_BODY_LIMIT} billing's boot check requires:\n{snippet}"
);
}
}