autumn-cli 0.9.0

CLI tool for the Autumn web framework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
//! The install catalog: what `autumn plugin list` shows and what
//! `autumn plugin add` knows how to mount.
//!
//! Two sources feed the catalog, exactly as issue #1606 scopes discovery
//! ("crates.io plus the existing naming convention"):
//!
//! - **First-party** plugins ship as a static table compiled into the CLI.
//!   They are released in lockstep with `autumn-web`, so the CLI's own version
//!   *is* the version to install, and the mount snippet can be written down
//!   once and compile-gated in CI.
//! - **Community** plugins are discovered on crates.io through the documented
//!   `autumn-plugin-<name>` naming convention (see `docs/plugins.md`). Their
//!   mount is *derived* from the same convention and printed for the user to
//!   apply, never spliced in: nothing here can verify a third-party crate
//!   really exposes `<Name>Plugin`, and a wrong guess would leave the app not
//!   compiling.

/// One installable first-party plugin.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CatalogEntry {
    /// crates.io name, e.g. `autumn-admin-plugin`.
    pub crate_name: &'static str,
    /// One-line description shown by `autumn plugin list`. Kept byte-identical
    /// to the crate's own `Cargo.toml` `description` (pinned by a unit test)
    /// so the listing can never quietly drift from what crates.io shows.
    pub summary: &'static str,
    /// Snippet spliced into the `AppBuilder` chain, already indented to sit
    /// under the builder-opening `autumn_web::app()` line. Its first line is
    /// the `// added by ...` marker.
    pub mount: &'static str,
    /// The builder call this plugin mounts through: `.plugin(` for a
    /// `Plugin`, `.with_blob_store(` for `autumn-storage-s3`.
    pub mount_call: &'static str,
    /// The fully-qualified path this command's own mount writes. Trusted only
    /// *inside* a [`Self::mount_call`] argument: on its own it is just a type
    /// name, so `fn configure(_: autumn_admin_plugin::AdminPlugin) {}` would
    /// otherwise read as a mount and suppress the real one.
    pub mount_arg: &'static str,
    /// The constructor call a mount must contain (`AdminPlugin::new(`).
    /// Trusted anywhere in code, because a plugin built into a variable and
    /// mounted as `.plugin(configured)` is still a mount — and splicing a
    /// second, default-constructed one over it would win the duplicate check
    /// and silently discard the user's configuration.
    pub constructor: &'static str,
    /// Migration versions the plugin ships or registers, as the directory
    /// names its own `migrations/` tree uses. Declared here because
    /// `__diesel_schema_migrations` is keyed by version alone and carries no
    /// source column, so a departed plugin's rows are otherwise
    /// indistinguishable from the app's own (issue #1631). Empty for a plugin
    /// that owns no schema.
    pub migrations: &'static [&'static str],
    /// Tables the plugin creates and owns, in safe **drop order** — dependents
    /// before the tables they reference — so `plugin remove --drop-data`'s
    /// statements apply top to bottom, exactly like the plugin's own
    /// `down.sql`. Empty for a plugin that owns no tables.
    pub tables: &'static [&'static str],
    /// Config keys the plugin needs before the app can serve it.
    pub config_keys: &'static [&'static str],
    /// Follow-up steps printed after a successful install.
    pub post_install: &'static [&'static str],
}

/// Every first-party plugin in this workspace, in `plugin list` order.
///
/// No entry adds `features = [...]` to the app's own `autumn-web` dependency.
/// Each plugin crate already depends on `autumn-web` with the features its
/// mount needs, and Cargo unifies features across the graph — so one dependency
/// line is genuinely enough, and writing features into the user's manifest
/// would duplicate a fact the plugin crate already owns. The `plugin-install`
/// CI gate compiles every one of these mounts into a fresh scaffold, so if a
/// plugin ever drops a feature its mount needs, that gate is what fails.
///
/// Four of the five implement `Plugin` and mount with `.plugin(...)`.
/// `autumn-storage-s3` is the exception: it exposes a `BlobStore`, not a
/// `Plugin`, and `S3BlobStore::from_config` is `async` — but `.await` is legal
/// inside a method-call argument in an `async fn`, so its mount is still one
/// splice into the same builder chain, with no second anchor and no statement
/// rewriting.
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(",
        // The admin panel reads framework-owned tables (`autumn_experiments`,
        // `autumn_experiment_changes`) created by `autumn-web`'s OWN
        // migrations, not by the plugin. Removing the plugin leaves nothing of
        // the plugin's behind, so there is nothing to declare.
        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(",
        // Registered through `plugin_migrations`, so the app's own migration
        // run creates the mirror tables.
        migrations: &["20260910203829_billing_mirror"],
        // Drop order mirrors the migration's `down.sql`.
        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(",
        // Cache entries live in Redis, not in the app's database.
        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(",
        // Shipped in the crate's own `migrations/` tree, applied by hand for
        // apps that set `[media] room_store_backend = "db"`.
        migrations: &["20260720000000_media_rooms"],
        // Drop order mirrors the migration's `down.sql`: participants
        // reference their room.
        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(",
        // The Postgres engine bootstraps its schema at runtime with
        // `CREATE TABLE IF NOT EXISTS` (`autumn-search/src/postgres.rs`)
        // rather than through `diesel_migrations`, so nothing is recorded in
        // `__diesel_schema_migrations` — but the tables are still the
        // plugin's, and still there after it is unwired.
        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(",
        // Blobs live in the S3 bucket, which this command never touches: it is
        // not the app's database and may well outlive the app.
        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`.",
        ],
    },
];

/// The first-party entry named `name`, if there is one.
#[must_use]
pub fn lookup(name: &str) -> Option<&'static CatalogEntry> {
    FIRST_PARTY.iter().find(|entry| entry.crate_name == name)
}

/// The documented third-party crate-name prefix (`docs/plugins.md`).
pub const COMMUNITY_PREFIX: &str = "autumn-plugin-";

/// Whether `name` follows the documented third-party naming convention **and**
/// is a legal crates.io crate name.
///
/// The charset check is load-bearing, not cosmetic: this predicate is the only
/// gate on a name that is then interpolated into a crates.io URL *and* written
/// as a bare TOML key. `autumn-plugin-x/../../crates/serde` would otherwise be
/// URL-normalised into a request for a different crate and then written as a
/// key containing `/` and `.`, which no longer parses — the one outcome
/// `plugin add` promises is impossible.
///
/// The bare prefix with nothing after it is not a crate name either.
#[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 == '_')
    })
}

/// The `<Name>Plugin` struct a community crate is expected to expose, derived
/// from the documented convention (`autumn-plugin-live-feed` →
/// `LiveFeedPlugin`). `None` when `name` does not follow the convention.
#[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)
}

/// The Rust module path for a crate name (`autumn-plugin-live-feed` →
/// `autumn_plugin_live_feed`).
#[must_use]
pub fn module_path_for(crate_name: &str) -> String {
    crate_name.replace('-', "_")
}

/// The `.plugin(...)` line a community crate is expected to mount with.
/// `None` when the struct name cannot be derived.
///
/// Printed, never spliced: nothing here can check that a third-party crate
/// really follows the convention, and a wrong guess would leave the app not
/// compiling — the one outcome issue #1606 rules out.
#[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::*;

    /// Workspace members that are not *installable* plugin crates: the
    /// framework itself, its macros, this CLI, the schema core, the edge
    /// runtime, and the pinned plugin-surface reference (issue #1601) — none of
    /// which appear in `docs/plugins.md`'s first-party plugin table.
    ///
    /// `autumn-plugin-reference` really is a `Plugin`, which is the point of
    /// it: it exists so CI compiles the declared stable plugin surface on every
    /// change. It is never published, so `autumn plugin add` could not install
    /// it if it were listed. `every_excluded_member_is_unpublishable_or_core`
    /// below keeps that from becoming a way to hide a real plugin.
    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",
    ];

    /// Crates in [`NON_PLUGIN_MEMBERS`] that ARE published, and so are excluded
    /// for being core rather than for being uninstallable.
    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",
    ];

    /// The exclusion list above is the one way a genuinely installable
    /// first-party plugin could be dropped from `autumn plugin list` without
    /// the coverage test noticing. So every entry has to earn its place: it is
    /// either one of the named core crates, or it is `publish = false` and
    /// therefore not installable at all.
    #[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"
            );
        }
    }

    /// AC #1: the listing covers **all** first-party plugins in this
    /// workspace, not just the three `plugin add` is spelled out for.
    ///
    /// The expected set is ENUMERATED from the workspace manifest rather than
    /// hardcoded, so a sixth first-party plugin crate added later fails this
    /// test instead of quietly never appearing in `autumn plugin list`.
    #[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)
            // Top-level crates only: `examples/*`, `benchmarks/*` and
            // `example-e2e` are apps, not plugin crates.
            .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}"
            );
        }
    }

    /// The catalog is a hand-written table, so it can drift from the crates it
    /// describes. Pin it to the manifests: every entry must name a crate that
    /// exists in this workspace, and carry that crate's own description.
    #[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
            );
        }
    }

    /// Idempotency (AC #4) is decided by these three fields, so a mount that
    /// does not contain its own call, type path and constructor could never
    /// detect the mount it just wrote.
    #[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
            );
        }
    }

    /// The type path is only trusted inside a mount call, and the constructor
    /// anywhere — so each must be the shape the other is not.
    #[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"));
    }

    /// The prefix check is the ONLY gate on a name that is interpolated into
    /// a crates.io URL and written as a bare TOML key, so it must also reject
    /// anything that is not a legal crate name. `autumn-plugin-x/../../crates/
    /// serde` would otherwise be URL-normalised into a request for a
    /// different crate and then written as a key that no longer parses.
    #[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:?}");
        }
    }

    /// `docs/plugins.md`: third-party crates are `autumn-plugin-<name>` and
    /// expose `<Name>Plugin`.
    #[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());
    }

    /// `autumn plugin add autumn-billing` prints the `autumn.toml` entry an app
    /// pastes in, and billing's boot check rejects a webhook endpoint that allows
    /// less than 4 MiB (the webhook default is 1 MiB, which an invoice event with
    /// many lines can exceed). The printed entry must therefore pass that check.
    #[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}"
        );
    }
}