Skip to main content

ai_memory/cli/
serve_banner.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Round-2 F8 + F12 — `ai-memory serve` startup banner.
5//!
6//! The banner is the operator-facing summary of the daemon's posture
7//! at boot: which permissions mode is live, whether the migration
8//! warning fired, and whether an Ed25519 signing keypair was
9//! auto-generated. The text is composed by pure functions in this
10//! module so the daemon's `serve()` body in `daemon_runtime.rs` can
11//! call them, and the unit-test suite can assert on the rendered
12//! lines without spinning up the full HTTP server.
13//!
14//! ## Public surface
15//!
16//! - [`compose_banner`] — render the banner lines for a given
17//!   [`BannerInputs`] tuple. Stable across versions; new lines are
18//!   appended, never reordered.
19//! - [`BannerInputs`] / [`BannerLine`] — the input + output shapes.
20//!
21//! Mechanical wiring of [`compose_banner`] into the daemon's
22//! `tracing::info!` stream is left to the integrator — see
23//! `daemon_runtime::serve` for the call-site.
24
25use crate::config::PermissionsMode;
26use crate::permissions::{resolve_v07_default_mode, startup_banner_line};
27
28/// Inputs to the banner composer. All fields are derived from
29/// `AppConfig` and the runtime keypair-bootstrap result; the composer
30/// itself is pure and side-effect free.
31#[derive(Debug, Clone)]
32pub struct BannerInputs {
33    /// `Some(mode)` when the operator explicitly set
34    /// `[permissions].mode` in `config.toml`. `None` when the field is
35    /// absent (the F8 migration warning fires in this case).
36    pub configured_permissions_mode: Option<PermissionsMode>,
37    /// `Some(path)` when the F12 keypair-autogen path created a fresh
38    /// keypair this boot. `None` when one already existed or the
39    /// auto-gen was disabled by `[identity].disabled = true`.
40    pub auto_generated_keypair_path: Option<String>,
41    /// `true` when the operator has set `[identity].disabled = true`
42    /// in config — the daemon emits a single line acknowledging the
43    /// opt-out so an unsigned-link deployment is intentional, not
44    /// silent.
45    pub identity_disabled: bool,
46}
47
48/// One rendered banner line, tagged by severity. The daemon maps
49/// `Info` → `tracing::info!` and `Warn` → `tracing::warn!` so the
50/// migration notice surfaces in operator dashboards as a warning.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum BannerLine {
53    /// `tracing::info!`-level line.
54    Info(String),
55    /// `tracing::warn!`-level line. F8 migration warning + F12
56    /// "consider backing up" both ride this lane.
57    Warn(String),
58}
59
60impl BannerLine {
61    /// Body of the line, regardless of severity. Useful in tests.
62    #[must_use]
63    pub fn message(&self) -> &str {
64        match self {
65            BannerLine::Info(s) | BannerLine::Warn(s) => s,
66        }
67    }
68
69    /// `true` if the line is `Warn`.
70    #[must_use]
71    pub fn is_warn(&self) -> bool {
72        matches!(self, BannerLine::Warn(_))
73    }
74}
75
76/// Compose the v0.7.0 startup banner.
77///
78/// Always emits at least the `permissions: <mode>` line (F8 banner
79/// requirement). Conditionally appends the migration warning, the
80/// auto-gen-keypair line, and the identity-disabled acknowledgement.
81///
82/// The composer never panics and never performs I/O — the daemon's
83/// `serve()` body is responsible for routing each [`BannerLine`] to
84/// `tracing` (or to a captured buffer in tests).
85#[must_use]
86pub fn compose_banner(inputs: &BannerInputs) -> Vec<BannerLine> {
87    let mut out: Vec<BannerLine> = Vec::new();
88
89    // F8 — resolve the effective mode and the migration warning (if
90    // any) using the canonical helper in `permissions.rs`.
91    let (mode, migration_warning) = resolve_v07_default_mode(inputs.configured_permissions_mode);
92    out.push(BannerLine::Info(startup_banner_line(mode)));
93    if let Some(w) = migration_warning {
94        out.push(BannerLine::Warn(w));
95    }
96
97    // F12 — surface keypair-autogen result. Only one of the two
98    // branches fires (auto-gen vs. disabled); when neither fires the
99    // pre-existing keypair was re-used and we stay silent so the
100    // banner doesn't grow on every boot.
101    if let Some(path) = &inputs.auto_generated_keypair_path {
102        out.push(BannerLine::Warn(format!(
103            "auto-generated identity keypair at {path} — consider backing up"
104        )));
105    } else if inputs.identity_disabled {
106        out.push(BannerLine::Info(
107            "identity: disabled in config — link signing skipped".to_string(),
108        ));
109    }
110
111    out
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn banner_unconfigured_mode_emits_enforce_and_warning() {
120        let lines = compose_banner(&BannerInputs {
121            configured_permissions_mode: None,
122            auto_generated_keypair_path: None,
123            identity_disabled: false,
124        });
125        // First line must be the permissions banner at info level.
126        assert_eq!(lines[0], BannerLine::Info("permissions: enforce".into()));
127        // Second line must be the migration warning.
128        assert!(lines[1].is_warn(), "expected warn line, got {:?}", lines[1]);
129        assert!(
130            lines[1]
131                .message()
132                .contains("v0.7.0 default changed to enforce")
133        );
134        // No keypair / disabled lines when neither input is set.
135        assert_eq!(lines.len(), 2);
136    }
137
138    #[test]
139    fn banner_configured_advisory_skips_migration_warning() {
140        let lines = compose_banner(&BannerInputs {
141            configured_permissions_mode: Some(PermissionsMode::Advisory),
142            auto_generated_keypair_path: None,
143            identity_disabled: false,
144        });
145        assert_eq!(lines.len(), 1);
146        assert_eq!(lines[0], BannerLine::Info("permissions: advisory".into()));
147    }
148
149    #[test]
150    fn banner_configured_enforce_skips_migration_warning() {
151        let lines = compose_banner(&BannerInputs {
152            configured_permissions_mode: Some(PermissionsMode::Enforce),
153            auto_generated_keypair_path: None,
154            identity_disabled: false,
155        });
156        assert_eq!(lines.len(), 1);
157        assert_eq!(lines[0], BannerLine::Info("permissions: enforce".into()));
158    }
159
160    #[test]
161    fn banner_includes_auto_gen_keypair_line() {
162        let lines = compose_banner(&BannerInputs {
163            configured_permissions_mode: Some(PermissionsMode::Enforce),
164            auto_generated_keypair_path: Some("/tmp/k.priv".into()),
165            identity_disabled: false,
166        });
167        // permissions: enforce + the keypair warning.
168        assert_eq!(lines.len(), 2);
169        assert!(lines[1].is_warn());
170        let msg = lines[1].message();
171        assert!(
172            msg.contains("auto-generated identity keypair at /tmp/k.priv"),
173            "got: {msg}"
174        );
175        assert!(msg.contains("consider backing up"));
176    }
177
178    #[test]
179    fn banner_identity_disabled_emits_info_line_when_no_autogen() {
180        let lines = compose_banner(&BannerInputs {
181            configured_permissions_mode: Some(PermissionsMode::Enforce),
182            auto_generated_keypair_path: None,
183            identity_disabled: true,
184        });
185        assert_eq!(lines.len(), 2);
186        assert_eq!(
187            lines[1],
188            BannerLine::Info("identity: disabled in config — link signing skipped".to_string())
189        );
190    }
191
192    #[test]
193    fn banner_identity_disabled_yields_to_autogen_line() {
194        // If both flags are somehow set (operator disabled identity but
195        // the bootstrap path produced a keypair anyway — defensive)
196        // the auto-gen line wins because it's the load-bearing event.
197        let lines = compose_banner(&BannerInputs {
198            configured_permissions_mode: Some(PermissionsMode::Enforce),
199            auto_generated_keypair_path: Some("/tmp/k.priv".into()),
200            identity_disabled: true,
201        });
202        assert_eq!(lines.len(), 2);
203        assert!(
204            lines[1]
205                .message()
206                .contains("auto-generated identity keypair")
207        );
208    }
209}