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}