Skip to main content

zeroclaw_config/
sections.rs

1//! Curated sections surface — a flat ordered set of [`Section`]s the
2//! operator walks (new install) or scans (returning user) to configure
3//! a working ZeroClaw deployment.
4//!
5//! Every fact about a section (its enum variant, its on-the-wire key,
6//! its UI shape, its help blurb, its canonical position) lives in ONE
7//! table — the `sections!` invocation below. The macro expands that
8//! table into the [`Section`] enum, every per-variant `match` helper,
9//! and the [`QUICKSTART_SECTIONS`] const, so adding a section is exactly
10//! one row, no hand-listed variant set anywhere else.
11//!
12//! Consumers (CLI runtime, gateway, dashboard) dispatch off this enum;
13//! drift is a compile error.
14
15use serde::{Deserialize, Serialize};
16
17/// UI rendering shape for a section. Drives picker / form dispatch on
18/// the `/config` curated section explorer and the Quickstart flow.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
21#[serde(rename_all = "snake_case")]
22pub enum SectionShape {
23    /// `<section>` renders a schema-driven form with no picker step.
24    DirectForm,
25    /// `<section>.<alias>` map of structured entries; the section page
26    /// shows an alias list with `+ Add` and clicking an alias opens its
27    /// schema form.
28    OneTierAliasMap,
29    /// `<section>.<type>.<alias>` two-tier map. Picker chooses `<type>`,
30    /// alias-list step chooses `<alias>`, then the schema form opens.
31    TypedFamilyMap,
32    /// Single non-alias choice (memory backend, tunnel provider). Picker
33    /// flips a top-level field, then the schema form for the chosen
34    /// backend/provider renders.
35    BackendPicker,
36}
37
38/// Humanize a section wire key for display (`risk_profiles` → `Risk profiles`,
39/// `providers.models` → `Model providers`). Single source of truth for section
40/// labels across the gateway dashboard, zerocode Config pane, and docs. Specific
41/// wording overrides are listed explicitly; everything else is mechanically
42/// title-cased from the key.
43#[must_use]
44pub fn humanize_section_key(key: &str) -> String {
45    match key {
46        "providers.models" => return "Model providers".to_string(),
47        "providers.tts" => return "TTS providers".to_string(),
48        "providers.transcription" => return "Transcription providers".to_string(),
49        _ => {}
50    }
51    let mut s = key.replace(['_', '-'], " ");
52    if let Some(c) = s.get_mut(0..1) {
53        c.make_ascii_uppercase();
54    }
55    s
56}
57
58/// Single source of truth for every pickable config section. Each row
59/// maps 1:1 to a dashboard `/config/<key>` page, a CLI
60/// `zeroclaw quickstart` flow and the gateway section picker handler.
61/// Adding/removing a section is one row here and every consumer's
62/// `match` either compiles cleanly or fails with an exhaustiveness
63/// error pointing at exactly what needs an arm.
64///
65/// Row order is the canonical order operators see in the dashboard
66/// and walk through in the CLI. It is dependency-correct: every
67/// downstream alias reference an Agent carries (model_provider,
68/// risk_profile, runtime_profile, channels, *_bundles) appears earlier
69/// in the list than [`Section::Agents`], so walking top-to-bottom
70/// never produces a dangling reference.
71macro_rules! sections {
72    (
73        $(
74            $var:ident => {
75                key:   $key:literal,
76                shape: $shape:ident,
77                help:  $help:expr $(,)?
78            }
79        ),+ $(,)?
80    ) => {
81        /// One pickable section. The variant ordering follows the
82        /// `sections!` macro invocation.
83        ///
84        /// With the `clap` feature on, this enum doubles as the
85        /// `zeroclaw quickstart` and curated-section endpoints — no separate
86        /// mirror enum in the binary crate.
87        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88        #[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
89        #[cfg_attr(feature = "clap", derive(clap::Subcommand))]
90        #[serde(rename_all = "snake_case")]
91        pub enum Section {
92            $(
93                // Both clap (`--help`) and our runtime `help()` method
94                // need the same blurb; emit it once as a doc comment so
95                // the two surfaces share a single string per variant.
96                #[doc = $help]
97                #[cfg_attr(feature = "clap", command(name = $key))]
98                $var,
99            )+
100        }
101
102        impl Section {
103            /// Stable on-the-wire key. Also serves as the TOML
104            /// top-level prefix (e.g. `providers.models.<type>.<alias>`),
105            /// the curated section URL segment, and the
106            /// `SectionInfo.key` field returned by the gateway.
107            #[must_use]
108            pub const fn as_str(self) -> &'static str {
109                match self {
110                    $( Self::$var => $key, )+
111                }
112            }
113
114            /// Editor shape — the dashboard and the CLI both
115            /// dispatch off this so the same component lights up for
116            /// the same section in both surfaces.
117            #[must_use]
118            pub const fn shape(self) -> SectionShape {
119                match self {
120                    $( Self::$var => SectionShape::$shape, )+
121                }
122            }
123
124            /// Per-section help blurb — single source of truth for
125            /// the copy shown above the section's picker / form on
126            /// every surface (CLI `ui.note(...)`, TUI heading,
127            /// dashboard `SectionInfo.help`).
128            #[must_use]
129            pub const fn help(self) -> &'static str {
130                match self {
131                    $( Self::$var => $help, )+
132                }
133            }
134
135            /// Human-readable section label shown in every Config surface
136            /// (gateway dashboard sidebar, zerocode Config pane, docs).
137            /// Single source of truth — derived from the canonical wire key
138            /// so the gateway, runtime, and docs cannot disagree.
139            #[must_use]
140            pub fn label(self) -> String {
141                humanize_section_key(self.key())
142            }
143
144            /// The canonical wire key for this section.
145            #[must_use]
146            pub const fn key(self) -> &'static str {
147                match self {
148                    $( Self::$var => $key, )+
149                }
150            }
151
152            /// Parse a stable wire key, tolerating both the snake and
153            /// kebab spellings of any section. The schema mixes the two:
154            /// `model_providers` (snake) and `peer-groups` (kebab) are
155            /// both valid wire forms produced elsewhere in the codebase.
156            /// Callers (dashboard URL routing, gateway picker dispatch,
157            /// CLI clap subcommands) can pass either form; `from_key`
158            /// resolves to the same variant. Returns `None` for keys
159            /// outside the known section table. Named `from_key` rather
160            /// than `from_str` so clippy doesn't flag it as confusable
161            /// with `std::str::FromStr` (parse failure is `None`, not
162            /// `Err(_)`).
163            #[must_use]
164            pub fn from_key(s: &str) -> Option<Self> {
165                let try_match = |s: &str| -> Option<Self> {
166                    match s {
167                        $( $key => Some(Self::$var), )+
168                        _ => None,
169                    }
170                };
171                if let Some(v) = try_match(s) {
172                    return Some(v);
173                }
174                if s.contains('_')
175                    && let Some(v) = try_match(&s.replace('_', "-"))
176                {
177                    return Some(v);
178                }
179                if s.contains('-')
180                    && let Some(v) = try_match(&s.replace('-', "_"))
181                {
182                    return Some(v);
183                }
184                None
185            }
186        }
187
188        /// Canonical ordering of sections enumerated by
189        /// the Quickstart flow and the curated section explorer. The
190        /// dashboard renders Next/Finish navigation against this list.
191        /// Every consumer that needs section ordering reads from here.
192        pub const QUICKSTART_SECTIONS: &[Section] = &[ $( Section::$var ),+ ];
193    };
194}
195
196sections! {
197    // Tier 1 — Brain. An agent cannot think without a model provider.
198    ModelProviders => {
199        key:   "providers.models",
200        shape: TypedFamilyMap,
201        help:  "Pick a model provider to configure (Anthropic, OpenAI, OpenRouter, \
202                Ollama, custom OpenAI-compatible gateways, etc.). Multiple aliases per \
203                provider are supported — e.g. anthropic.production and anthropic.dev \
204                can coexist.",
205    },
206
207    // Tier 2 — Behavior shape. agents.<alias>.risk_profile and
208    // .runtime_profile are required alias refs; both must exist before
209    // an Agent that points at them can resolve.
210    RiskProfiles => {
211        key:   "risk_profiles",
212        shape: OneTierAliasMap,
213        help:  "Named risk profiles binding allowlists, denylists, and approval \
214                thresholds. Agents reference one via `agents.<alias>.risk_profile`.",
215    },
216    RuntimeProfiles => {
217        key:   "runtime_profiles",
218        shape: OneTierAliasMap,
219        help:  "Named runtime tuning profiles (token limits, retry policy, timeouts). \
220                Agents reference one via `agents.<alias>.runtime_profile`.",
221    },
222
223    // Tier 3 — Storage. memory.backend points at a storage.<type>.<alias>
224    // instance, so storage must exist first.
225    Storage => {
226        key:   "storage",
227        shape: TypedFamilyMap,
228        help:  "SQLite is the safe default for single-node installs (file-based, \
229                zero-config, no extra services). Pick Postgres for shared or \
230                multi-instance deployments, Qdrant for vector search, Markdown or \
231                Lucid for human-readable files. Each backend supports multiple \
232                aliased instances; agents reference them via `memory.storage_ref`.",
233    },
234    Memory => {
235        key:   "memory",
236        shape: BackendPicker,
237        help:  "Persistent memory backend. SQLite is the default; pick `none` to \
238                disable long-term recall entirely.",
239    },
240
241    // Tier 4 — Capabilities. Bundles that agents reference via
242    // skill_bundles / mcp_bundle / knowledge_bundles.
243    Skills => {
244        key:   "skills",
245        shape: DirectForm,
246        help:  "Skills tool settings — where skill markdown lives on disk (defaults \
247                to the data dir), and how the skills loader handles community \
248                repositories. Add skill BUNDLES under `skill-bundles` below.",
249    },
250    SkillBundles => {
251        key:   "skill_bundles",
252        shape: OneTierAliasMap,
253        help:  "Named bundles of skill files. Agents reference a bundle to load a \
254                set of capabilities at startup.",
255    },
256    Mcp => {
257        key:   "mcp",
258        shape: DirectForm,
259        help:  "Model Context Protocol settings. Toggle `enabled` and pick deferred \
260                or eager loading. Individual MCP servers live under `mcp.servers[]`.",
261    },
262    McpBundles => {
263        key:   "mcp_bundles",
264        shape: OneTierAliasMap,
265        help:  "Named bundles of MCP servers. Agents reference a bundle to pull in \
266                a set of MCP tools as one unit.",
267    },
268    KnowledgeBundles => {
269        key:   "knowledge_bundles",
270        shape: OneTierAliasMap,
271        help:  "Named bundles of knowledge sources (RAG indexes, doc folders). Agents \
272                reference a bundle to surface relevant snippets at inference time.",
273    },
274
275    // Tier 5 — Modal IO. Optional voice in/out providers.
276    TtsProviders => {
277        key:   "providers.tts",
278        shape: TypedFamilyMap,
279        help:  "Text-to-speech providers (OpenAI, ElevenLabs, Google, Edge, Piper). \
280                Configure one per voice / language; agents reference them by alias.",
281    },
282    TranscriptionProviders => {
283        key:   "providers.transcription",
284        shape: TypedFamilyMap,
285        help:  "Speech-to-text providers (OpenAI Whisper, Groq, Deepgram, AssemblyAI, \
286                Google, local Whisper). Configure one per pipeline; agents reference \
287                them by alias.",
288    },
289
290    // Tier 6 — Channels. How agents listen. agents.<alias>.channels
291    // references channel aliases, so channels must exist first.
292    Channels => {
293        key:   "channels",
294        shape: TypedFamilyMap,
295        help:  "Pick which chat platforms ZeroClaw should listen on. You can \
296                configure multiple — each channel gets its own alias.",
297    },
298    Hardware => {
299        key:   "hardware",
300        shape: DirectForm,
301        help:  "Optional: hardware peripherals (Arduino, STM32, GPIO, etc.). \
302                Skip if you don't need them.",
303    },
304
305    // Tier 7 — Bind. Pulls tiers 1–6 together. Every alias ref an
306    // Agent carries exists by this point.
307    // Personality is intentionally NOT a top-level section —
308    // markdown personality files live per-agent and surface inside the
309    // agent edit form.
310    Agents => {
311        key:   "agents",
312        shape: OneTierAliasMap,
313        help:  "An agent binds a model provider, profiles, bundles, and channels \
314                into one dispatchable unit. Add one per persona; reuse the same \
315                alias across channels to share state.",
316    },
317
318    // Tier 8 — Topology. Multi-agent relationships and scheduled
319    // invocations; both reference agents and must follow Agents.
320    PeerGroups => {
321        key:   "peer_groups",
322        shape: OneTierAliasMap,
323        help:  "Named groups binding a channel, member agents, and external peers. \
324                Mutual opt-in: two agents become peers only when both appear in the \
325                same group's `agents` list.",
326    },
327    Cron => {
328        key:   "cron",
329        shape: OneTierAliasMap,
330        help:  "Scheduled tasks. Each cron entry binds a schedule expression to a \
331                prompt, channel, and target.",
332    },
333
334    // Tier 9 — Exposure. Gateway public-internet exposure. Only
335    // relevant when a webhook-mode channel needs a public URL.
336    Tunnel => {
337        key:   "tunnel",
338        shape: BackendPicker,
339        help:  "Optional: expose your gateway over the public internet via Cloudflare \
340                or ngrok. Pick `none` to keep it localhost-only.",
341    },
342
343    // Tier 10 — Lifecycle state. Not part of any agent dependency
344    // chain. Tracks whether the Quickstart has completed on this
345    // install; surfaces dispatch on it to decide whether to auto-open
346    // the Quickstart on launch. The on-disk TOML key stays
347    // `onboard_state` for backwards compatibility with installs that
348    // already wrote against it; only the in-code symbol is renamed.
349    QuickstartState => {
350        key:   "onboard_state",
351        shape: DirectForm,
352        help:  "Quickstart lifecycle state. `quickstart_completed` flips to true \
353                once the Quickstart finishes a successful run; while false, the \
354                web gateway and TUI auto-launch the Quickstart on startup. \
355                `completed_sections` is a legacy per-section ledger retained for \
356                backwards compatibility with prior data.",
357    },
358}
359
360impl std::fmt::Display for Section {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        f.write_str(self.as_str())
363    }
364}
365
366/// Canonical-order index of `section` in [`QUICKSTART_SECTIONS`].
367/// Always `Some` for any valid `Section` variant — the const includes
368/// every variant by construction. Returns `Option` for API symmetry
369/// with [`section_index_for_key`], which can fail on unknown keys.
370#[must_use]
371pub fn section_index(section: Section) -> Option<usize> {
372    QUICKSTART_SECTIONS.iter().position(|s| *s == section)
373}
374
375/// Canonical-order index for a wire key, or `None` if the key isn't a
376/// known [`Section`]. Used by gateway / dashboard sort comparators that
377/// take string keys from the HTTP layer.
378#[must_use]
379pub fn section_index_for_key(key: &str) -> Option<usize> {
380    Section::from_key(key).and_then(section_index)
381}
382
383/// True when `key` parses as a known [`Section`].
384#[must_use]
385pub fn is_known_section(key: &str) -> bool {
386    Section::from_key(key).is_some()
387}
388
389/// Help blurb for a section key, covering both `Section` variants and
390/// the long tail of top-level `Config` fields the dashboard / TUI config
391/// editor surface (gateway, scheduler, observability, …). Single source
392/// of truth shared by every surface — the gateway sidebar, the CLI
393/// Quickstart flow, and the future TUI config editor all call this rather
394/// than maintaining parallel tables.
395///
396/// Resolution order:
397/// 1. `Section` variants (curated `help` text next to the variant
398///    declaration in the `sections!` macro).
399/// 2. The `Config` struct's `#[nested]` field-level `///` docstring,
400///    harvested by the `Configurable` derive into
401///    `Config::nested_section_help`. This is what makes adding a new
402///    top-level section a one-line schema change with no parallel
403///    help table to update.
404///
405/// Returns `""` for keys without a docstring so callers can decide
406/// whether to omit the help row or show a fallback.
407#[must_use]
408pub fn section_help(key: &str) -> &'static str {
409    if let Some(s) = Section::from_key(key) {
410        return s.help();
411    }
412    crate::schema::Config::nested_section_help(key).unwrap_or("")
413}
414
415/// First segment of a dotted property path mapped back to the section
416/// it lives under, or `None` for non-section paths
417/// (`onboard_state.completed_sections`, etc.).
418#[must_use]
419pub fn section_for_path(path: &str) -> Option<Section> {
420    Section::from_key(path.split('.').next()?)
421}
422
423/// Does this section show any signal of having been touched on this
424/// install? Used by callers (RPC config-list filtering, lifecycle
425/// dispatch) to decide whether to surface a section as "untouched".
426///
427/// Each variant decides what counts as a real signal vs a default
428/// value that round-trips identically across a fresh install.
429pub fn section_has_signal(cfg: &crate::schema::Config, section: Section) -> bool {
430    match section {
431        Section::ModelProviders => !cfg.providers.models.is_empty(),
432        // `channels.cli: bool` is a default-true scalar that lives directly
433        // under `channels.*`, so a bare `starts_with("channels.")` check
434        // fires on every fresh install. Require a nested channel config
435        // (e.g. `channels.telegram.bot-token`) — anything with a second dot
436        // segment — to count as user-driven signal.
437        Section::Channels => cfg.prop_fields().iter().any(|f| {
438            f.name
439                .strip_prefix("channels.")
440                .is_some_and(|rest| rest.contains('.'))
441        }),
442        Section::Hardware => cfg.hardware.enabled,
443        // Memory's default backend is "sqlite" and Tunnel's is "none" —
444        // both are valid user choices indistinguishable from untouched
445        // defaults. TTS / transcription providers and agents start
446        // empty; their existence in the typed family map IS the signal,
447        // not a derivable default-divergence. Marker-only for these.
448        Section::TtsProviders
449        | Section::TranscriptionProviders
450        | Section::Memory
451        | Section::Tunnel
452        | Section::Agents
453        | Section::Skills
454        | Section::SkillBundles
455        | Section::RiskProfiles
456        | Section::RuntimeProfiles
457        | Section::PeerGroups
458        | Section::Storage
459        | Section::Cron
460        | Section::Mcp
461        | Section::McpBundles
462        | Section::KnowledgeBundles
463        | Section::QuickstartState => false,
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    /// Round-trip every entry in the canonical list. `from_key`,
472    /// `as_str`, `section_index`, and `QUICKSTART_SECTIONS` are all
473    /// generated from the same `sections!` row, so this test exercises
474    /// the table — adding a row that breaks any of them fails here
475    /// without listing variants by hand.
476    #[test]
477    fn sections_round_trip() {
478        for s in QUICKSTART_SECTIONS {
479            assert_eq!(Section::from_key(s.as_str()), Some(*s), "{s} round-trip");
480            assert_eq!(
481                section_index(*s),
482                Some(QUICKSTART_SECTIONS.iter().position(|x| x == s).unwrap()),
483            );
484        }
485        assert_eq!(Section::from_key("gateway"), None);
486        assert_eq!(Section::from_key("not_a_section"), None);
487    }
488
489    /// Every section the dashboard URL surface points at must resolve
490    /// through `Section::from_key`. The dashboard URL form is kebab-case
491    /// (`peer-groups`), the canonical wire form may be snake_case
492    /// (`peer_groups`); both must parse to the same variant.
493    #[test]
494    fn dashboard_url_sections_round_trip_kebab_and_snake() {
495        let kebab_then_snake: &[(&str, &str, Section)] = &[
496            ("peer-groups", "peer_groups", Section::PeerGroups),
497            ("mcp-bundles", "mcp_bundles", Section::McpBundles),
498            (
499                "knowledge-bundles",
500                "knowledge_bundles",
501                Section::KnowledgeBundles,
502            ),
503            ("skill-bundles", "skill_bundles", Section::SkillBundles),
504            ("risk-profiles", "risk_profiles", Section::RiskProfiles),
505            (
506                "runtime-profiles",
507                "runtime_profiles",
508                Section::RuntimeProfiles,
509            ),
510            ("storage", "storage", Section::Storage),
511            ("cron", "cron", Section::Cron),
512            ("mcp", "mcp", Section::Mcp),
513        ];
514        for (kebab, snake, expected) in kebab_then_snake {
515            assert_eq!(
516                Section::from_key(kebab),
517                Some(*expected),
518                "kebab `{kebab}` should resolve to {expected:?}",
519            );
520            assert_eq!(
521                Section::from_key(snake),
522                Some(*expected),
523                "snake `{snake}` should resolve to {expected:?}",
524            );
525            assert!(
526                QUICKSTART_SECTIONS.contains(expected),
527                "{expected:?} must be in QUICKSTART_SECTIONS",
528            );
529        }
530    }
531
532    /// Every OneTierAliasMap section's wire key must appear verbatim
533    /// in `Config::map_key_sections()`. That table is what
534    /// `Config::create_map_key` dispatches off, so a mismatch silently
535    /// breaks the dashboard's `+ Add` affordance.
536    #[test]
537    fn alias_map_section_wire_keys_match_map_key_sections() {
538        use crate::schema::Config;
539        let sections = Config::map_key_sections();
540        let paths: std::collections::BTreeSet<&str> = sections.iter().map(|s| s.path).collect();
541        let alias_map_sections = [
542            Section::PeerGroups,
543            Section::Cron,
544            Section::McpBundles,
545            Section::KnowledgeBundles,
546            Section::SkillBundles,
547            Section::RiskProfiles,
548            Section::RuntimeProfiles,
549        ];
550        for section in alias_map_sections {
551            assert!(
552                paths.contains(section.as_str()),
553                "`Section::{section:?}.as_str() = {}` is not in map_key_sections; the \
554                 picker's create_map_key call site will fail. Registered paths: {paths:?}",
555                section.as_str(),
556            );
557        }
558    }
559
560    /// Canonical order is dependency-correct: every Section that
561    /// `AliasedAgentConfig` references through an alias field appears
562    /// earlier in the list than `Section::Agents`. Walking
563    /// `QUICKSTART_SECTIONS` top-to-bottom never asks the operator to
564    /// configure an Agent before the things it has to bind to exist.
565    #[test]
566    fn ordering_respects_agent_dependency_tiers() {
567        let idx = |s: Section| {
568            QUICKSTART_SECTIONS
569                .iter()
570                .position(|x| *x == s)
571                .unwrap_or_else(|| panic!("{s:?} missing from QUICKSTART_SECTIONS"))
572        };
573
574        // Brain + behavior shape + bundles + channels all precede Agents.
575        for upstream in [
576            Section::ModelProviders,
577            Section::RiskProfiles,
578            Section::RuntimeProfiles,
579            Section::SkillBundles,
580            Section::McpBundles,
581            Section::KnowledgeBundles,
582            Section::Channels,
583        ] {
584            assert!(
585                idx(upstream) < idx(Section::Agents),
586                "{upstream:?} must precede Agents (Agent references it through an alias field)",
587            );
588        }
589
590        // Storage precedes Memory (memory.backend = "<storage_type>.<alias>").
591        assert!(
592            idx(Section::Storage) < idx(Section::Memory),
593            "Storage must precede Memory (memory.backend points at a storage instance)",
594        );
595
596        // Topology references agents.
597        for downstream in [Section::PeerGroups, Section::Cron] {
598            assert!(
599                idx(Section::Agents) < idx(downstream),
600                "{downstream:?} references agents and must follow Agents in the canonical order",
601            );
602        }
603    }
604
605    /// Storage help must steer first-time operators toward SQLite as the
606    /// safe default. Pins the contract: SQLite is named, flagged as a
607    /// default/safe/recommended choice, and positioned before the
608    /// alternatives so the recommendation lands first instead of being
609    /// buried in a closing list.
610    #[test]
611    fn storage_help_steers_to_sqlite_default() {
612        let help = section_help("storage").to_lowercase();
613        let sqlite_pos = help
614            .find("sqlite")
615            .expect("storage help must mention SQLite by name");
616        assert!(
617            help.contains("default") || help.contains("safe") || help.contains("recommend"),
618            "storage help must signal SQLite is the default/safe/recommended choice; got: {help}",
619        );
620        for other in ["postgres", "qdrant", "markdown", "lucid"] {
621            let other_pos = help.find(other).unwrap_or_else(|| {
622                panic!(
623                    "storage help must still name `{other}` so operators know the alternatives \
624                     exist; got: {help}",
625                )
626            });
627            assert!(
628                sqlite_pos < other_pos,
629                "SQLite (at {sqlite_pos}) must be mentioned before `{other}` (at {other_pos}) so \
630                 the default recommendation lands first",
631            );
632        }
633    }
634}