Skip to main content

ai_memory/cli/
boot.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory boot` — universal session-boot context primitive (issue #487).
5//!
6//! Every AI-agent integration recipe (Claude Code SessionStart hook, Cursor
7//! `.cursorrules` boot directive, Cline / Continue / Windsurf system-message,
8//! Codex CLI / Claude Agent SDK / OpenAI Apps SDK programmatic prepend,
9//! OpenClaw built-in, local models via LM Studio / Ollama / vLLM) calls this
10//! same subcommand and consumes its stdout as the agent's first-turn context.
11//!
12//! Boot deliberately does **not** load the embedder. It returns the
13//! most-recently-accessed memories in the inferred namespace, falls back to
14//! the most-recently-accessed memories globally if the namespace is empty,
15//! and clamps output to a token budget so a misconfigured agent can't bloat
16//! its first turn.
17//!
18//! Failure modes are graceful by default:
19//! - DB unavailable + `--quiet`: exit 0, empty stdout (a hook that fails
20//!   here would otherwise wedge the agent's session).
21//! - DB unavailable + no `--quiet`: write the error to stderr, emit only
22//!   the header on stdout, still exit 0.
23//! - No memories found: emit the header (or nothing with `--no-header`),
24//!   exit 0.
25//!
26//! ## Diagnostic manifest (PR-4 of #487)
27//!
28//! Boot's header is a transparent, multi-field manifest — never a black box.
29//! Every field reflects a fact about *this* invocation so agents and humans
30//! always know exactly what was loaded and what's configured. Fields:
31//!
32//! - `version`     — binary version (`CARGO_PKG_VERSION` at compile time)
33//! - `db`          — resolved DB path + schema version + total live memories
34//! - `tier`        — active feature tier and *configured* (not loaded)
35//!                   embedder / reranker / llm models
36//! - `latency`     — wall-clock from `run()` entry to header emit
37//! - `namespace`   — resolved namespace + how many memories matched
38
39use crate::cli::CliOutput;
40use crate::cli::helpers::{human_age, id_short};
41use crate::config::AppConfig;
42use crate::models::field_names;
43use crate::{db, models, toon};
44use anyhow::Result;
45use clap::Args;
46use models::Tier;
47use std::path::Path;
48use std::time::Instant;
49
50/// Lower bound of the DB-schema range this binary supports. Below this
51/// we emit a `warn-schema-unsupported` manifest header so the user
52/// knows their `ai-memory` binary is too new for an old DB. Set to the
53/// v0.6.3 baseline (16) — older schemas won't have the columns the
54/// recall pipeline expects. v0.6.3.1 (PR-9h / issue #487 PR #497 req #72).
55pub const MIN_SUPPORTED_SCHEMA: u32 = 16;
56
57/// Upper bound of the DB-schema range this binary supports.
58///
59/// Derived from the schema-version SSOT
60/// [`crate::storage::migrations::current_schema_version()`] — NOT a
61/// hand-maintained literal. A binary supports DBs up to the schema its
62/// own migration ladder produces; the next schema bump therefore moves
63/// this bound automatically with no edit here. Both the sqlite and
64/// postgres ladders land at the same version in lockstep — see
65/// `docs/MIGRATION_v0.7.md` for the per-version column inventory and
66/// `migrations/{sqlite,postgres}/` for the SQL.
67///
68/// **Current value: tracks `current_schema_version()` (v54 at v0.7.0
69/// release).** The const auto-resolves from the SSOT at
70/// `crate::storage::migrations::CURRENT_SCHEMA_VERSION`; this docstring
71/// names the v0.7.0-release tip for narrative continuity but the value
72/// will move on every schema bump WITHOUT requiring a docstring edit.
73/// For the canonical current value at any HEAD, run
74/// `ai-memory --version` (boot manifest prints the resolved schema)
75/// or grep `CURRENT_SCHEMA_VERSION` in `src/storage/migrations.rs`. The
76/// `scripts/check-docs-vs-ssot.sh` drift gate keeps narrative anchors
77/// honest across operator-facing docs.
78///
79/// v0.7.0 ladder summary (v48 → v53):
80/// v48 added `federation_push_dlq` (#933); v49 added 14 nullable
81/// `archived_memories` columns (#1025); v50 extended `agent_quotas`
82/// PK with `namespace` (#1156); v51 added `federation_nonce_cache` (#1255
83/// / PR #1296); v52 added `transcript_line_dedup` backing #1389
84/// L4/RFC-0001 capture_turn; v53 scoped the `memories_au` FTS5 trigger
85/// to `(title, content, tags)` only (R5.F5.2 / #1418).
86///
87/// When a DB's `schema_version` exceeds this, the binary is too old
88/// for a newer DB and we surface a `warn-schema-unsupported` manifest
89/// header so the user knows to upgrade. v0.6.3.1 (PR-9h / issue #487
90/// PR #497 req #72).
91#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
92pub const MAX_SUPPORTED_SCHEMA: u32 = crate::storage::migrations::current_schema_version() as u32;
93
94/// Pure boundary check: `true` when `v` lies within
95/// `[MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA]`. Extracted so the
96/// boundary semantics (inclusive both ends) can be unit-tested without
97/// needing a synthetic DB whose `schema_version` lies outside the
98/// migration ladder's reach. v0.6.3.1 (PR-9h / issue #487 PR #497 req
99/// #72).
100#[must_use]
101pub fn schema_in_supported_range(v: u32) -> bool {
102    v >= MIN_SUPPORTED_SCHEMA && v <= MAX_SUPPORTED_SCHEMA
103}
104
105/// Default budget — large enough for ~10 toon-compact rows, small enough that
106/// a misconfigured hook can't wedge the first turn with megabytes of context.
107const DEFAULT_BUDGET_TOKENS: usize = 4096;
108
109/// Approximate tokens-per-character for cl100k_base / English text. Used for
110/// the cheap budget clamp. Real tokenization happens elsewhere (recall_hybrid);
111/// boot's budget is advisory and only needs to be in the right order of
112/// magnitude to bound output cost.
113const TOKENS_PER_CHAR: f32 = 0.25;
114
115/// Sentinel used in manifest fields that couldn't be resolved on this
116/// invocation — most often because the DB itself is unreachable, so
117/// schema/total/etc. simply don't have an answer.
118const UNAVAILABLE: &str = "<unavailable>";
119
120/// Output formats supported by `ai-memory boot`.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum BootFormat {
123    /// Human-readable bulleted list (the default — works in any agent's
124    /// system message and is easiest to scan).
125    Text,
126    /// JSON object: `{namespace, count, memories: [...]}`. For programmatic
127    /// integrations (Claude Agent SDK, Apps SDK, Codex CLI prepend).
128    Json,
129    /// TOON-compact (the canonical token-efficient memory format).
130    /// Mirrors the wire shape `memory_recall` returns over MCP.
131    Toon,
132}
133
134impl BootFormat {
135    fn parse(s: &str) -> Result<Self> {
136        match s {
137            "text" => Ok(Self::Text),
138            "json" => Ok(Self::Json),
139            "toon" | "toon-compact" | crate::toon::FORMAT_TOON_COMPACT => Ok(Self::Toon),
140            other => Err(anyhow::anyhow!(
141                "unknown --format value: {other} (expected: text | json | toon)"
142            )),
143        }
144    }
145}
146
147/// Args for `ai-memory boot`. Every field has a defaulted value so the
148/// subcommand is safe to invoke with no arguments — that is the contract
149/// every integration recipe relies on.
150#[derive(Args, Debug)]
151pub struct BootArgs {
152    /// Override the inferred namespace. Default: derived from the current
153    /// working directory via the same `auto_namespace` helper used by
154    /// `ai-memory store` (git remote name → cwd basename → "global").
155    #[arg(long)]
156    pub namespace: Option<String>,
157    /// Maximum number of memories to return. Clamped to `[1, 50]`.
158    #[arg(long, default_value_t = 10)]
159    pub limit: usize,
160    /// Approximate token budget for the rendered output. Cumulative
161    /// character count divided by 4 ≈ tokens; boot stops adding rows
162    /// when the next row would exceed the budget. Set to 0 to disable.
163    #[arg(long, default_value_t = DEFAULT_BUDGET_TOKENS)]
164    pub budget_tokens: usize,
165    /// Output format: `text` (default), `json`, or `toon`.
166    #[arg(long, default_value = "text")]
167    pub format: String,
168    /// Suppress the `# ai-memory boot context (...)` header line.
169    /// Useful when the integration recipe wraps boot output inside
170    /// its own framing.
171    #[arg(long, default_value_t = false)]
172    pub no_header: bool,
173    /// Exit 0 with empty stdout if the DB is unavailable or no memories
174    /// are found. Without this flag, errors land on stderr and stdout
175    /// gets the header only. Hooks should pass `--quiet` so a failed
176    /// boot never wedges the agent's first turn.
177    #[arg(long, default_value_t = false)]
178    pub quiet: bool,
179    /// Override `auto_namespace`'s working-directory inference. Useful
180    /// when the hook fires before the agent has chdir'd into the
181    /// project root.
182    #[arg(long, value_name = "PATH")]
183    pub cwd: Option<std::path::PathBuf>,
184}
185
186/// Resolve the boot namespace. Explicit `--namespace` wins; otherwise
187/// `auto_namespace` runs against the optional `--cwd` (or the current
188/// process's CWD if unset).
189fn resolve_namespace(args: &BootArgs) -> String {
190    if let Some(ref ns) = args.namespace {
191        return ns.clone();
192    }
193    if let Some(ref cwd) = args.cwd {
194        let _ = std::env::set_current_dir(cwd);
195    }
196    // #1590 — configured [storage].default_namespace beats the git/cwd
197    // inference; unconfigured deployments keep the historical ladder.
198    crate::cli::helpers::resolve_namespace(None)
199}
200
201/// Pull the boot set from the DB. Two-stage:
202///   1. List most-recently-accessed memories in the resolved namespace.
203///   2. If empty, fall back to the most-recently-accessed memories at
204///      tier=Long globally (cross-project context for greenfield checkouts).
205fn fetch_boot_memories(
206    conn: &rusqlite::Connection,
207    namespace: &str,
208    limit: usize,
209) -> Result<(Vec<models::Memory>, String)> {
210    // Stage 1: namespace-scoped list.
211    let primary = db::list(
212        conn,
213        Some(namespace),
214        None,
215        limit,
216        0,
217        None,
218        None,
219        None,
220        None,
221        None,
222    )?;
223    if !primary.is_empty() {
224        return Ok((primary, namespace.to_string()));
225    }
226    // Stage 2: global tier=Long fallback. The "" sentinel signals
227    // "no namespace match found; surfacing global context" to the
228    // formatter so it can flag the divergence in the header.
229    let fallback = db::list(
230        conn,
231        None,
232        Some(&Tier::Long),
233        limit,
234        0,
235        None,
236        None,
237        None,
238        None,
239        None,
240    )?;
241    Ok((fallback, String::new()))
242}
243
244/// Cumulative character → approximate-tokens budget clamp. Returns the
245/// prefix of `mems` that fits in the budget. Always keeps the first
246/// memory (R1 always-return-at-least-one parity with `memory_recall`).
247fn clamp_to_budget(mems: Vec<models::Memory>, budget_tokens: usize) -> Vec<models::Memory> {
248    if budget_tokens == 0 || mems.is_empty() {
249        return mems;
250    }
251    let mut chars_so_far: usize = 0;
252    let mut out = Vec::with_capacity(mems.len());
253    for (idx, mem) in mems.into_iter().enumerate() {
254        // Conservative per-row width: title + namespace + tier label +
255        // age + ~20 chars of decorations. Real toon/text rows are ~150
256        // chars; we round up to bound risk.
257        let row_chars = mem.title.len() + mem.namespace.len() + 80;
258        let projected_tokens =
259            ((chars_so_far + row_chars) as f32 * TOKENS_PER_CHAR).ceil() as usize;
260        if idx > 0 && projected_tokens > budget_tokens {
261            break;
262        }
263        chars_so_far += row_chars;
264        out.push(mem);
265    }
266    out
267}
268
269/// Boot status — encodes the diagnostic the agent (and the human running
270/// it) sees on every invocation. End users asked for an always-visible
271/// signal so a missing memory context is a known state, not a guess.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273enum BootStatus {
274    /// Found memories in the requested namespace. Normal happy path.
275    OkLoaded,
276    /// Requested namespace had no memories; falling back to global Long tier.
277    InfoFallback,
278    /// Both the requested namespace and the global fallback were empty.
279    /// First-run condition for greenfield checkouts. Not an error.
280    InfoEmpty,
281    /// Requested DB path does not exist or could not be opened. With
282    /// `--quiet` we still exit 0, but the header surfaces the warning so
283    /// the agent can say "I would have loaded context but couldn't" rather
284    /// than silently appearing memory-less.
285    WarnDbUnavailable,
286    /// DB opened cleanly but its `schema_version` falls outside the
287    /// `[MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA]` range this binary
288    /// implements. We still proceed (boot exits 0 — never wedge the
289    /// agent's first turn) but the manifest tells the user to run
290    /// `ai-memory doctor` and consider upgrading. v0.6.3.1 (PR-9h /
291    /// issue #487 PR #497 req #72).
292    WarnSchemaUnsupported { db_schema: u32 },
293}
294
295impl BootStatus {
296    fn label(self) -> &'static str {
297        match self {
298            Self::OkLoaded => "ok",
299            Self::InfoFallback | Self::InfoEmpty => "info",
300            Self::WarnDbUnavailable | Self::WarnSchemaUnsupported { .. } => "warn",
301        }
302    }
303}
304
305/// Read the schema version from the DB's `schema_version` table.
306/// Returns the formatted display string (`vN` or `<unavailable>`) and,
307/// when the table read succeeded, the parsed integer for in-range checks
308/// against `[MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA]`. The manifest
309/// is best-effort: a query error degrades to the sentinel rather than
310/// failing boot.
311fn read_schema_version(conn: &rusqlite::Connection) -> (String, Option<u32>) {
312    match conn.query_row(
313        crate::storage::migrations::SELECT_SCHEMA_VERSION_SQL,
314        [],
315        |r| r.get::<_, i64>(0),
316    ) {
317        Ok(v) => {
318            let display = format!("v{v}");
319            // Negative or absurdly-large values fall back to the
320            // sentinel — schema_version is monotonically increasing
321            // u32-ish in practice.
322            let numeric = u32::try_from(v).ok();
323            (display, numeric)
324        }
325        Err(_) => (UNAVAILABLE.to_string(), None),
326    }
327}
328
329/// Cheap COUNT of live (non-expired) memories. Same expiry semantics as
330/// the recall pipeline: NULL `expires_at` is permanent, otherwise must be
331/// in the future. Errors degrade to the sentinel rather than fail boot.
332fn count_live_memories(conn: &rusqlite::Connection) -> String {
333    let now = chrono::Utc::now().to_rfc3339();
334    conn.query_row(
335        "SELECT COUNT(*) FROM memories WHERE expires_at IS NULL OR expires_at > ?1",
336        rusqlite::params![now],
337        |r| r.get::<_, i64>(0),
338    )
339    .map_or_else(|_| UNAVAILABLE.to_string(), |v| v.to_string())
340}
341
342/// Diagnostic manifest assembled before each header emit. Every field is
343/// a string so the same struct can carry both real values and the
344/// `<unavailable>` sentinel without branching downstream.
345///
346/// Field semantics:
347/// - `version`         — `crate::PKG_VERSION` at compile time
348/// - `db_path`         — resolved path the boot ran against
349/// - `schema_version`  — `vN` from the DB's `schema_version` table
350/// - `total_memories`  — count of live (non-expired) rows
351/// - `tier`            — active feature tier name
352/// - `embedder`        — *configured* embedder (boot does NOT load it)
353/// - `reranker`        — *configured* cross-encoder (or "none")
354/// - `llm`             — *configured* LLM model id (or "none")
355/// - `latency_ms`      — wall-clock from `run()` entry to emit
356/// - `namespace`       — the namespace the body actually came from
357/// - `count`           — number of memories included in the body
358struct BootManifest {
359    version: String,
360    db_path: String,
361    schema_version: String,
362    total_memories: String,
363    tier: String,
364    embedder: String,
365    reranker: String,
366    llm: String,
367    latency_ms: u128,
368    namespace: String,
369    count: usize,
370    // The status note, used by JSON `note` and (for InfoEmpty / Warn) the
371    // multi-line text/toon header expansion.
372    note: String,
373    status: BootStatus,
374    /// PR-9h (#487 PR #497 req #72) — `true` when the DB's
375    /// `schema_version` lies within `[MIN_SUPPORTED_SCHEMA,
376    /// MAX_SUPPORTED_SCHEMA]`. Surfaced as a top-level JSON field
377    /// (`schema_supported`) so SIEMs / fleet dashboards can alert on
378    /// schema drift without parsing the free-text status note. `false`
379    /// when the DB couldn't be opened or the schema falls outside the
380    /// supported range.
381    schema_supported: bool,
382}
383
384impl BootManifest {
385    fn build(
386        status: BootStatus,
387        namespace: &str,
388        count: usize,
389        db_path: &Path,
390        app_config: &AppConfig,
391        schema_version: String,
392        total_memories: String,
393        latency_ms: u128,
394        schema_supported: bool,
395    ) -> Self {
396        // v0.7.x (#1146) — Boot reports the SAME resolved configuration
397        // that the live MCP / HTTP / CLI surfaces will use, instead of
398        // the compiled tier preset. The resolver folds CLI flags (none
399        // at boot time), AI_MEMORY_LLM_* env vars, the `[llm]` /
400        // `[embeddings]` / `[reranker]` config sections, the legacy
401        // flat fields, and the compiled fallback through the uniform
402        // precedence ladder. The banner emits `backend:model` so the
403        // operator can verify wiring at a glance (`llm=xai:grok-4.3`
404        // vs `llm=ollama:gemma3:4b` vs `llm=ollama:none`).
405        let feature_tier = app_config.effective_tier(None);
406        let resolved_llm = app_config.resolve_llm(None, None, None);
407        let resolved_emb = app_config.resolve_embeddings();
408        let resolved_rer = app_config.resolve_reranker();
409
410        // Embedder: report the resolver's view UNLESS the tier preset
411        // disables embeddings entirely (keyword tier), in which case
412        // we keep the historical "none" string so existing scrapers
413        // continue to recognise a tier-disabled embedder posture.
414        let embedder = if feature_tier.config().embedding_model.is_none() {
415            "none".to_string()
416        } else {
417            resolved_emb.model.clone()
418        };
419
420        // LLM: `backend:model` provenance. On the Ollama-native wire
421        // shape we emit just the model (legacy banner shape) so
422        // existing grep'ing tools that match `llm=gemma3:4b` continue
423        // to work; on any OpenAI-compatible vendor the full
424        // `xai:grok-4.3` shape is emitted so the operator-facing
425        // disambiguation is loud. The vendor-literal check lives on
426        // `ResolvedLlm` (issue #1174 PR4 — substrate-vendor cleanup)
427        // so the banner code never re-names the backend.
428        let llm = if resolved_llm.is_ollama_native() {
429            resolved_llm.model.clone()
430        } else {
431            resolved_llm.display_label()
432        };
433
434        // Reranker: respect the resolver (which folds `[reranker]` +
435        // legacy `cross_encoder`); fall back to tier preset only when
436        // neither configured the field.
437        let reranker = if resolved_rer.enabled || feature_tier.config().cross_encoder {
438            resolved_rer.model.clone()
439        } else {
440            "none".to_string()
441        };
442
443        let note = match status {
444            BootStatus::OkLoaded => format!(
445                "loaded {count} memor{plural} from ns={namespace}",
446                plural = if count == 1 { "y" } else { "ies" }
447            ),
448            BootStatus::InfoFallback => format!(
449                "namespace empty; loaded {count} memor{plural} from global Long tier fallback",
450                plural = if count == 1 { "y" } else { "ies" }
451            ),
452            BootStatus::InfoEmpty => format!(
453                "namespace '{namespace}' is empty and no global Long-tier fallback found — \
454                 nothing to load (this is normal on a fresh install)"
455            ),
456            BootStatus::WarnDbUnavailable => format!(
457                "db unavailable at {} — proceeding without memory context. \
458                 Run `ai-memory doctor` to diagnose. \
459                 See https://github.com/alphaonedev/ai-memory-mcp/blob/main/docs/integrations/README.md",
460                db_path.display()
461            ),
462            BootStatus::WarnSchemaUnsupported { db_schema } => format!(
463                "db schema v{db_schema} unsupported by binary {bin_ver} \
464                 (supports v{min}..v{max}); proceeding with degraded context. \
465                 Run `ai-memory doctor` and consider upgrading.",
466                bin_ver = crate::PKG_VERSION,
467                min = MIN_SUPPORTED_SCHEMA,
468                max = MAX_SUPPORTED_SCHEMA,
469            ),
470        };
471
472        Self {
473            version: crate::PKG_VERSION.to_string(),
474            db_path: db_path.display().to_string(),
475            schema_version,
476            total_memories,
477            tier: feature_tier.as_str().to_string(),
478            embedder,
479            reranker,
480            llm,
481            latency_ms,
482            namespace: namespace.to_string(),
483            count,
484            note,
485            status,
486            schema_supported,
487        }
488    }
489}
490
491/// `ai-memory boot` entry point.
492#[allow(clippy::too_many_lines)]
493pub fn run(
494    db_path: &Path,
495    args: &BootArgs,
496    app_config: &AppConfig,
497    out: &mut CliOutput<'_>,
498) -> Result<()> {
499    let start = Instant::now();
500
501    // PR-9h (#487 PR #497 req #73) — privacy kill-switch. When the
502    // operator sets `[boot] enabled = false` (or
503    // `AI_MEMORY_BOOT_ENABLED=0`), boot exits 0 with empty stdout AND
504    // empty stderr. The hook injects nothing — true silence for
505    // privacy-sensitive hosts. This MUST run before any other side
506    // effect (file open, env probe, etc.) so the contract is "boot
507    // produces zero output."
508    let boot_cfg = app_config.effective_boot();
509    if !boot_cfg.effective_enabled() {
510        return Ok(());
511    }
512    let redact_titles = boot_cfg.effective_redact_titles();
513
514    let format = BootFormat::parse(&args.format)?;
515    let limit = args.limit.clamp(1, 50);
516    let namespace = resolve_namespace(args);
517
518    // Open the DB. On failure, honor `--quiet` (exit 0 with empty stdout
519    // when `--no-header` is also set; otherwise emit a warning header so
520    // the agent always sees that boot ran, even on failure).
521    let conn = match db::open(db_path) {
522        Ok(c) => c,
523        Err(e) => {
524            if !args.quiet {
525                writeln!(
526                    out.stderr,
527                    "ai-memory boot: db unavailable at {}: {e}",
528                    db_path.display()
529                )?;
530            }
531            if !args.no_header {
532                let manifest = BootManifest::build(
533                    BootStatus::WarnDbUnavailable,
534                    &namespace,
535                    0,
536                    db_path,
537                    app_config,
538                    UNAVAILABLE.to_string(),
539                    UNAVAILABLE.to_string(),
540                    start.elapsed().as_millis(),
541                    false, // schema_supported: DB unavailable → unknown → false
542                );
543                emit_status_header(out, &manifest, format)?;
544            }
545            return Ok(());
546        }
547    };
548
549    // Cheap diagnostic lookups. Both degrade to the sentinel rather than
550    // fail the boot — the manifest is best-effort.
551    let (schema_version, schema_int) = read_schema_version(&conn);
552    let total_memories = count_live_memories(&conn);
553
554    // PR-9h (#487 PR #497 req #72) — version-drift detection. If the
555    // DB's schema lies outside `[MIN, MAX]`, surface a warn-schema
556    // header. Boot still exits 0 (consistent with WarnDbUnavailable —
557    // never wedge the agent's first turn). When schema_int is None
558    // (parse failure / unreadable table) we treat schema as unsupported
559    // for SIEM purposes but otherwise carry on with the existing
560    // status flow.
561    let schema_supported = schema_int.is_some_and(schema_in_supported_range);
562    if let Some(v) = schema_int
563        && !schema_in_supported_range(v)
564    {
565        if !args.no_header {
566            let manifest = BootManifest::build(
567                BootStatus::WarnSchemaUnsupported { db_schema: v },
568                &namespace,
569                0,
570                db_path,
571                app_config,
572                schema_version,
573                total_memories,
574                start.elapsed().as_millis(),
575                false,
576            );
577            emit_status_header(out, &manifest, format)?;
578        }
579        return Ok(());
580    }
581
582    let (mems, used_namespace) = fetch_boot_memories(&conn, &namespace, limit)?;
583    let mems = clamp_to_budget(mems, args.budget_tokens);
584    let fell_back = !mems.is_empty() && used_namespace.is_empty();
585
586    if mems.is_empty() {
587        if !args.no_header {
588            let manifest = BootManifest::build(
589                BootStatus::InfoEmpty,
590                &namespace,
591                0,
592                db_path,
593                app_config,
594                schema_version,
595                total_memories,
596                start.elapsed().as_millis(),
597                schema_supported,
598            );
599            emit_status_header(out, &manifest, format)?;
600        }
601        return Ok(());
602    }
603
604    let displayed_ns = if fell_back {
605        crate::DEFAULT_NAMESPACE
606    } else {
607        &namespace
608    };
609    let status = if fell_back {
610        BootStatus::InfoFallback
611    } else {
612        BootStatus::OkLoaded
613    };
614
615    match format {
616        BootFormat::Json => {
617            // JSON output is one object: header fields + memories together.
618            // `--no-header` is meaningless for JSON (the JSON IS the
619            // boundary) but we honor it as "skip the diagnostic JSON" by
620            // emitting only the memories array, for advanced wrappers.
621            if args.no_header {
622                writeln!(
623                    out.stdout,
624                    "{}",
625                    serde_json::to_string(&serde_json::json!({
626                        "memories": render_memories_for_emit(&mems, redact_titles)
627                    }))?
628                )?;
629            } else {
630                let manifest = BootManifest::build(
631                    status,
632                    displayed_ns,
633                    mems.len(),
634                    db_path,
635                    app_config,
636                    schema_version,
637                    total_memories,
638                    start.elapsed().as_millis(),
639                    schema_supported,
640                );
641                emit_json_with_status(out, &manifest, &mems, fell_back, redact_titles)?;
642            }
643        }
644        BootFormat::Text => {
645            if !args.no_header {
646                let manifest = BootManifest::build(
647                    status,
648                    displayed_ns,
649                    mems.len(),
650                    db_path,
651                    app_config,
652                    schema_version,
653                    total_memories,
654                    start.elapsed().as_millis(),
655                    schema_supported,
656                );
657                emit_status_header(out, &manifest, format)?;
658            }
659            emit_text(out, &mems, redact_titles)?;
660        }
661        BootFormat::Toon => {
662            if !args.no_header {
663                let manifest = BootManifest::build(
664                    status,
665                    displayed_ns,
666                    mems.len(),
667                    db_path,
668                    app_config,
669                    schema_version,
670                    total_memories,
671                    start.elapsed().as_millis(),
672                    schema_supported,
673                );
674                emit_status_header(out, &manifest, format)?;
675            }
676            emit_toon(out, &mems, redact_titles)?;
677        }
678    }
679
680    Ok(())
681}
682
683/// Sentinel substituted for `memory.title` when `[boot] redact_titles =
684/// true`. Identical to the `redact_content` placeholder pattern used by
685/// the audit subsystem (PR-5). v0.6.3.1 (PR-9h / issue #487 PR #497 req
686/// #73).
687const REDACTED_TITLE: &str = "<redacted>";
688
689/// Apply title redaction to a slice of memories, returning a freshly
690/// owned `Vec` with each `title` replaced by [`REDACTED_TITLE`] when
691/// the operator opted in via `[boot] redact_titles = true`. The
692/// no-redact path returns a clone — the cost is one Vec allocation per
693/// boot, which is dwarfed by the SQL list call.
694fn render_memories_for_emit(mems: &[models::Memory], redact_titles: bool) -> Vec<models::Memory> {
695    if !redact_titles {
696        return mems.to_vec();
697    }
698    mems.iter()
699        .map(|m| {
700            let mut redacted = m.clone();
701            redacted.title = REDACTED_TITLE.to_string();
702            redacted
703        })
704        .collect()
705}
706
707/// Always-visible diagnostic header. Agents see this in their session log
708/// even when the body is empty, so the absence of memory context is a
709/// surfaced signal rather than a silent failure.
710///
711/// **Format (text/toon)** — multi-line manifest, every field labelled:
712/// ```text
713/// # ai-memory boot: ok
714/// #   version:    0.6.3+patch.1
715/// #   db:         /home/u/.claude/ai-memory.db (schema=v19, 161 memories)
716/// #   tier:       autonomous (embedder=..., reranker=..., llm=...)
717/// #   latency:    12ms
718/// #   namespace:  ns-x (loaded 3 memories)
719/// ```
720///
721/// **Format (json)** — single JSON object with every manifest field as a
722/// top-level key (`version`, `db_path`, `schema_version`, `total_memories`,
723/// `tier`, `embedder`, `reranker`, `llm`, `latency_ms`, `namespace`,
724/// `count`, `status`, `note`).
725fn emit_status_header(
726    out: &mut CliOutput<'_>,
727    manifest: &BootManifest,
728    format: BootFormat,
729) -> Result<()> {
730    match format {
731        BootFormat::Json => {
732            writeln!(
733                out.stdout,
734                "{}",
735                serde_json::json!({
736                    "status": manifest.status.label(),
737                    "version": manifest.version,
738                    "db_path": manifest.db_path,
739                    (field_names::SCHEMA_VERSION): manifest.schema_version,
740                    "schema_supported": manifest.schema_supported,
741                    (field_names::TOTAL_MEMORIES): manifest.total_memories,
742                    "tier": manifest.tier,
743                    "embedder": manifest.embedder,
744                    "reranker": manifest.reranker,
745                    "llm": manifest.llm,
746                    (field_names::LATENCY_MS): manifest.latency_ms,
747                    "namespace": manifest.namespace,
748                    "count": manifest.count,
749                    "note": manifest.note,
750                })
751            )?;
752        }
753        _ => {
754            // Multi-line transparent manifest. Each field is on its own
755            // line so a grep / log scrape can pick them out individually,
756            // and the human reader sees a top-down summary.
757            writeln!(out.stdout, "# ai-memory boot: {}", manifest.status.label())?;
758            writeln!(out.stdout, "#   version:    {}", manifest.version)?;
759            writeln!(
760                out.stdout,
761                "#   db:         {} (schema={}, {} memories)",
762                manifest.db_path, manifest.schema_version, manifest.total_memories
763            )?;
764            writeln!(
765                out.stdout,
766                "#   tier:       {} (embedder={}, reranker={}, llm={})",
767                manifest.tier, manifest.embedder, manifest.reranker, manifest.llm
768            )?;
769            writeln!(out.stdout, "#   latency:    {}ms", manifest.latency_ms)?;
770            // Namespace line carries the same status-specific note the
771            // single-line PR-1 header used to carry — so a reader can see
772            // *why* a particular count showed up.
773            match manifest.status {
774                BootStatus::OkLoaded => {
775                    writeln!(
776                        out.stdout,
777                        "#   namespace:  {} (loaded {} memor{})",
778                        manifest.namespace,
779                        manifest.count,
780                        if manifest.count == 1 { "y" } else { "ies" }
781                    )?;
782                }
783                BootStatus::InfoFallback => {
784                    writeln!(
785                        out.stdout,
786                        "#   namespace:  {} (fallback: loaded {} memor{} from global Long tier)",
787                        manifest.namespace,
788                        manifest.count,
789                        if manifest.count == 1 { "y" } else { "ies" }
790                    )?;
791                }
792                BootStatus::InfoEmpty => {
793                    writeln!(
794                        out.stdout,
795                        "#   namespace:  {} (empty — nothing to load; this is normal on a fresh install)",
796                        manifest.namespace
797                    )?;
798                }
799                BootStatus::WarnDbUnavailable => {
800                    writeln!(
801                        out.stdout,
802                        "#   namespace:  {} (db unavailable — see `ai-memory doctor`)",
803                        manifest.namespace
804                    )?;
805                }
806                BootStatus::WarnSchemaUnsupported { db_schema } => {
807                    // PR-9h (#487 PR #497 req #72) — full warn-schema
808                    // message: `db schema vN unsupported by binary
809                    // X.Y.Z (supports v{MIN}..v{MAX}); proceeding with
810                    // degraded context. Run \`ai-memory doctor\` and
811                    // consider upgrading.`
812                    writeln!(
813                        out.stdout,
814                        "#   namespace:  {} (db schema v{} unsupported by binary {} \
815                         (supports v{}..v{}); proceeding with degraded context. \
816                         Run `ai-memory doctor` and consider upgrading.)",
817                        manifest.namespace,
818                        db_schema,
819                        manifest.version,
820                        MIN_SUPPORTED_SCHEMA,
821                        MAX_SUPPORTED_SCHEMA,
822                    )?;
823                }
824            }
825        }
826    }
827    Ok(())
828}
829
830fn emit_text(out: &mut CliOutput<'_>, mems: &[models::Memory], redact_titles: bool) -> Result<()> {
831    for mem in mems {
832        let age = human_age(&mem.updated_at);
833        // PR-9h (#487 PR #497 req #73) — when `[boot] redact_titles =
834        // true`, replace the title field with the redaction sentinel.
835        // Every other row field (tier, id_short, namespace, priority,
836        // age) still surfaces so the operator retains the audit-trail
837        // signal of "boot ran with N memories" without exposing
838        // memory subjects.
839        let title: &str = if redact_titles {
840            REDACTED_TITLE
841        } else {
842            &mem.title
843        };
844        writeln!(
845            out.stdout,
846            "- [{}/{}] {} (ns={}, p={}, {})",
847            mem.tier,
848            id_short(&mem.id),
849            title,
850            mem.namespace,
851            mem.priority,
852            age
853        )?;
854    }
855    Ok(())
856}
857
858fn emit_json_with_status(
859    out: &mut CliOutput<'_>,
860    manifest: &BootManifest,
861    mems: &[models::Memory],
862    fell_back: bool,
863    redact_titles: bool,
864) -> Result<()> {
865    // Same shape as `emit_status_header` JSON path, plus `memories` and
866    // `fell_back_to_global`. Agents that ingest JSON get every manifest
867    // field as a top-level key so they can reason about the runtime
868    // without parsing a free-text header.
869    let rendered = render_memories_for_emit(mems, redact_titles);
870    let body = serde_json::json!({
871        "status": manifest.status.label(),
872        "version": manifest.version,
873        "db_path": manifest.db_path,
874        (field_names::SCHEMA_VERSION): manifest.schema_version,
875        "schema_supported": manifest.schema_supported,
876        (field_names::TOTAL_MEMORIES): manifest.total_memories,
877        "tier": manifest.tier,
878        "embedder": manifest.embedder,
879        "reranker": manifest.reranker,
880        "llm": manifest.llm,
881        (field_names::LATENCY_MS): manifest.latency_ms,
882        "namespace": manifest.namespace,
883        "count": manifest.count,
884        "note": manifest.note,
885        "fell_back_to_global": fell_back,
886        "memories": rendered,
887    });
888    writeln!(out.stdout, "{}", serde_json::to_string(&body)?)?;
889    Ok(())
890}
891
892fn emit_toon(out: &mut CliOutput<'_>, mems: &[models::Memory], redact_titles: bool) -> Result<()> {
893    // Reuse the canonical TOON serializer used by `memory_recall` so boot
894    // output is byte-identical to a recall response on the wire format.
895    // `memories_to_toon` takes the `{memories: [...], count: N}` shape.
896    let rendered = render_memories_for_emit(mems, redact_titles);
897    let body = serde_json::json!({
898        "memories": rendered,
899        "count": rendered.len(),
900    });
901    let toon_str = toon::memories_to_toon(&body, true);
902    writeln!(out.stdout, "{toon_str}")?;
903    Ok(())
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909    use crate::cli::test_utils::{TestEnv, seed_memory};
910
911    fn default_args() -> BootArgs {
912        BootArgs {
913            namespace: None,
914            limit: 10,
915            budget_tokens: DEFAULT_BUDGET_TOKENS,
916            format: "text".to_string(),
917            no_header: false,
918            quiet: false,
919            cwd: None,
920        }
921    }
922
923    fn default_config() -> AppConfig {
924        AppConfig::default()
925    }
926
927    /// Process-wide guard for the boot-test suite. `BootConfig::
928    /// effective_enabled` reads `AI_MEMORY_BOOT_ENABLED` on every
929    /// invocation — to avoid parallel tests observing the env-var
930    /// override fired by [`boot_disabled_via_env_var_overrides_config`],
931    /// every test that calls [`run`] takes this guard. Cheap (one
932    /// `Mutex` lock) and bullet-proof.
933    fn test_lock() -> std::sync::MutexGuard<'static, ()> {
934        use std::sync::{Mutex, OnceLock};
935        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
936        LOCK.get_or_init(|| Mutex::new(()))
937            .lock()
938            .unwrap_or_else(std::sync::PoisonError::into_inner)
939    }
940
941    #[test]
942    fn boot_format_parse_accepts_aliases() {
943        assert_eq!(BootFormat::parse("text").unwrap(), BootFormat::Text);
944        assert_eq!(BootFormat::parse("json").unwrap(), BootFormat::Json);
945        assert_eq!(BootFormat::parse("toon").unwrap(), BootFormat::Toon);
946        assert_eq!(BootFormat::parse("toon-compact").unwrap(), BootFormat::Toon);
947        assert_eq!(BootFormat::parse("toon_compact").unwrap(), BootFormat::Toon);
948        assert!(BootFormat::parse("yaml").is_err());
949    }
950
951    #[test]
952    fn boot_emits_ok_header_with_loaded_memories() {
953        let _g = test_lock();
954        let mut env = TestEnv::fresh();
955        seed_memory(&env.db_path, "ns-x", "first", "content one");
956        seed_memory(&env.db_path, "ns-x", "second", "content two");
957        seed_memory(&env.db_path, "ns-y", "elsewhere", "content three");
958        let db_path = env.db_path.clone();
959        let cfg = default_config();
960        let mut args = default_args();
961        args.namespace = Some("ns-x".to_string());
962        let mut out = env.output();
963        run(&db_path, &args, &cfg, &mut out).unwrap();
964        let stdout = std::str::from_utf8(&env.stdout).unwrap();
965        // Status line + every manifest field appears.
966        assert!(
967            stdout.contains("# ai-memory boot: ok"),
968            "expected ok status header, got: {stdout}"
969        );
970        assert!(
971            stdout.contains("#   version:"),
972            "manifest missing version line: {stdout}"
973        );
974        assert!(
975            stdout.contains("#   db:"),
976            "manifest missing db line: {stdout}"
977        );
978        assert!(
979            stdout.contains("#   tier:"),
980            "manifest missing tier line: {stdout}"
981        );
982        assert!(
983            stdout.contains("#   latency:"),
984            "manifest missing latency line: {stdout}"
985        );
986        assert!(
987            stdout.contains("#   namespace:") && stdout.contains("ns-x"),
988            "namespace line should contain ns-x: {stdout}"
989        );
990        assert!(stdout.contains("loaded 2 memories"));
991        assert!(stdout.contains("first"));
992        assert!(stdout.contains("second"));
993        assert!(!stdout.contains("elsewhere"));
994    }
995
996    #[test]
997    fn boot_header_includes_version() {
998        let _g = test_lock();
999        let mut env = TestEnv::fresh();
1000        seed_memory(&env.db_path, "ns-v", "row", "x");
1001        let db_path = env.db_path.clone();
1002        let cfg = default_config();
1003        let mut args = default_args();
1004        args.namespace = Some("ns-v".to_string());
1005        let mut out = env.output();
1006        run(&db_path, &args, &cfg, &mut out).unwrap();
1007        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1008        // CARGO_PKG_VERSION is "0.6.3+patch.1" or similar; assert the
1009        // crate constant surfaces verbatim, not a hardcoded string.
1010        let version = env!("CARGO_PKG_VERSION");
1011        assert!(
1012            stdout.contains(version),
1013            "expected version `{version}` in header: {stdout}"
1014        );
1015    }
1016
1017    #[test]
1018    fn boot_header_includes_db_path() {
1019        let _g = test_lock();
1020        let mut env = TestEnv::fresh();
1021        seed_memory(&env.db_path, "ns-d", "row", "x");
1022        let db_path = env.db_path.clone();
1023        let cfg = default_config();
1024        let mut args = default_args();
1025        args.namespace = Some("ns-d".to_string());
1026        let mut out = env.output();
1027        run(&db_path, &args, &cfg, &mut out).unwrap();
1028        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1029        let db_str = db_path.display().to_string();
1030        assert!(
1031            stdout.contains(&db_str),
1032            "expected db path `{db_str}` in header: {stdout}"
1033        );
1034    }
1035
1036    #[test]
1037    fn boot_header_includes_schema_version() {
1038        let _g = test_lock();
1039        let mut env = TestEnv::fresh();
1040        seed_memory(&env.db_path, "ns-s", "row", "x");
1041        let db_path = env.db_path.clone();
1042        let cfg = default_config();
1043        let mut args = default_args();
1044        args.namespace = Some("ns-s".to_string());
1045        let mut out = env.output();
1046        run(&db_path, &args, &cfg, &mut out).unwrap();
1047        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1048        assert!(
1049            stdout.contains("schema=v"),
1050            "expected `schema=vN` in header: {stdout}"
1051        );
1052    }
1053
1054    #[test]
1055    fn boot_header_includes_latency_ms() {
1056        let _g = test_lock();
1057        let mut env = TestEnv::fresh();
1058        seed_memory(&env.db_path, "ns-lat", "row", "x");
1059        let db_path = env.db_path.clone();
1060        let cfg = default_config();
1061        let mut args = default_args();
1062        args.namespace = Some("ns-lat".to_string());
1063        let mut out = env.output();
1064        run(&db_path, &args, &cfg, &mut out).unwrap();
1065        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1066        // Latency line must exist; the value must end with `ms` and be
1067        // numeric (we don't pin a value because wall-clock varies).
1068        let latency_line = stdout
1069            .lines()
1070            .find(|l| l.contains("latency:"))
1071            .expect("latency line must exist in manifest");
1072        let suffix = latency_line.split("latency:").nth(1).unwrap().trim();
1073        assert!(
1074            suffix.ends_with("ms"),
1075            "latency value should end with `ms`: {suffix}"
1076        );
1077        let num_str = suffix.trim_end_matches("ms");
1078        assert!(
1079            num_str.parse::<u128>().is_ok(),
1080            "latency must parse as integer ms: {num_str}"
1081        );
1082    }
1083
1084    #[test]
1085    fn boot_json_includes_all_manifest_fields() {
1086        let _g = test_lock();
1087        let mut env = TestEnv::fresh();
1088        seed_memory(&env.db_path, "ns-jm", "row", "x");
1089        let db_path = env.db_path.clone();
1090        let cfg = default_config();
1091        let mut args = default_args();
1092        args.namespace = Some("ns-jm".to_string());
1093        args.format = "json".to_string();
1094        let mut out = env.output();
1095        run(&db_path, &args, &cfg, &mut out).unwrap();
1096        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1097        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1098        // Status fields (PR-1 contract preserved).
1099        assert_eq!(parsed["status"], "ok");
1100        assert_eq!(parsed["namespace"], "ns-jm");
1101        assert_eq!(parsed["count"], 1);
1102        assert_eq!(parsed["fell_back_to_global"], false);
1103        assert!(parsed["memories"].is_array());
1104        // PR-4 manifest fields.
1105        for key in [
1106            "version",
1107            "db_path",
1108            "schema_version",
1109            "total_memories",
1110            "tier",
1111            "embedder",
1112            "reranker",
1113            "llm",
1114            "latency_ms",
1115            "note",
1116        ] {
1117            assert!(
1118                parsed.get(key).is_some(),
1119                "json output missing manifest field `{key}`: {stdout}"
1120            );
1121        }
1122        assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
1123        assert!(parsed["latency_ms"].is_number());
1124        assert!(
1125            parsed["schema_version"]
1126                .as_str()
1127                .unwrap_or("")
1128                .starts_with('v'),
1129            "schema_version should be `vN` form"
1130        );
1131    }
1132
1133    #[test]
1134    fn boot_respects_limit() {
1135        let _g = test_lock();
1136        let mut env = TestEnv::fresh();
1137        for i in 0..5 {
1138            seed_memory(&env.db_path, "ns-l", &format!("m{i}"), "x");
1139        }
1140        let db_path = env.db_path.clone();
1141        let cfg = default_config();
1142        let mut args = default_args();
1143        args.namespace = Some("ns-l".to_string());
1144        args.limit = 2;
1145        let mut out = env.output();
1146        run(&db_path, &args, &cfg, &mut out).unwrap();
1147        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1148        assert!(stdout.contains("loaded 2 memories"));
1149        let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
1150        assert_eq!(row_count, 2, "expected 2 rows, got {row_count}: {stdout}");
1151    }
1152
1153    #[test]
1154    fn boot_no_header_with_flag_suppresses_status() {
1155        let _g = test_lock();
1156        let mut env = TestEnv::fresh();
1157        seed_memory(&env.db_path, "ns-h", "row-one", "x");
1158        let db_path = env.db_path.clone();
1159        let cfg = default_config();
1160        let mut args = default_args();
1161        args.namespace = Some("ns-h".to_string());
1162        args.no_header = true;
1163        let mut out = env.output();
1164        run(&db_path, &args, &cfg, &mut out).unwrap();
1165        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1166        assert!(!stdout.contains("# ai-memory boot"));
1167        assert!(stdout.contains("row-one"));
1168    }
1169
1170    #[test]
1171    fn boot_json_format_emits_status_and_memories() {
1172        let _g = test_lock();
1173        let mut env = TestEnv::fresh();
1174        seed_memory(&env.db_path, "ns-j", "row", "x");
1175        let db_path = env.db_path.clone();
1176        let cfg = default_config();
1177        let mut args = default_args();
1178        args.namespace = Some("ns-j".to_string());
1179        args.format = "json".to_string();
1180        let mut out = env.output();
1181        run(&db_path, &args, &cfg, &mut out).unwrap();
1182        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1183        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1184        assert_eq!(parsed["status"], "ok");
1185        assert_eq!(parsed["namespace"], "ns-j");
1186        assert_eq!(parsed["count"], 1);
1187        assert_eq!(parsed["fell_back_to_global"], false);
1188        assert!(parsed["memories"].is_array());
1189    }
1190
1191    #[test]
1192    fn boot_quiet_with_unreachable_db_emits_warn_header_no_stderr() {
1193        // The user-facing diagnostic header MUST appear so the agent (and
1194        // a human looking at the agent log) sees that boot ran but
1195        // couldn't load context. --quiet suppresses *only* stderr.
1196        // PR-4: the warn variant still emits the manifest fields, with
1197        // `<unavailable>` in slots that need a live DB to fill.
1198        let _g = test_lock();
1199        let mut env = TestEnv::fresh();
1200        let bad_path = env
1201            .db_path
1202            .parent()
1203            .unwrap()
1204            .join("subdir/that/does/not/exist/db.sqlite");
1205        let cfg = default_config();
1206        let mut args = default_args();
1207        args.quiet = true;
1208        let mut out = env.output();
1209        run(&bad_path, &args, &cfg, &mut out).unwrap();
1210        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1211        assert!(
1212            stdout.contains("# ai-memory boot: warn"),
1213            "warn header should always appear under --quiet: {stdout}"
1214        );
1215        assert!(
1216            stdout.contains("db unavailable"),
1217            "header should explain the warning cause: {stdout}"
1218        );
1219        // What the warn variant CAN surface even without the DB.
1220        assert!(
1221            stdout.contains("#   version:"),
1222            "warn manifest should still carry version: {stdout}"
1223        );
1224        assert!(
1225            stdout.contains(env!("CARGO_PKG_VERSION")),
1226            "warn manifest version should be CARGO_PKG_VERSION: {stdout}"
1227        );
1228        assert!(
1229            stdout.contains("#   tier:"),
1230            "warn manifest should still carry tier: {stdout}"
1231        );
1232        assert!(
1233            stdout.contains("#   latency:"),
1234            "warn manifest should still carry latency: {stdout}"
1235        );
1236        // Slots that need a live DB degrade to the sentinel.
1237        assert!(
1238            stdout.contains(UNAVAILABLE),
1239            "warn manifest should mark unreachable fields as <unavailable>: {stdout}"
1240        );
1241        assert!(
1242            env.stderr.is_empty(),
1243            "stderr should be silent under --quiet"
1244        );
1245    }
1246
1247    #[test]
1248    fn boot_db_unavailable_without_quiet_writes_to_stderr() {
1249        let _g = test_lock();
1250        let mut env = TestEnv::fresh();
1251        let bad_path = env
1252            .db_path
1253            .parent()
1254            .unwrap()
1255            .join("subdir/that/does/not/exist/db.sqlite");
1256        let cfg = default_config();
1257        let args = default_args();
1258        // quiet = false (default) — error goes to stderr too.
1259        let mut out = env.output();
1260        run(&bad_path, &args, &cfg, &mut out).unwrap();
1261        let stderr = std::str::from_utf8(&env.stderr).unwrap();
1262        assert!(
1263            stderr.contains("ai-memory boot: db unavailable"),
1264            "stderr should carry the diagnostic without --quiet: {stderr}"
1265        );
1266    }
1267
1268    #[test]
1269    fn boot_quiet_with_no_header_silent_for_legacy_wrappers() {
1270        // Wrappers that frame their own context can opt out of both the
1271        // diagnostic header AND any error output by combining flags.
1272        let _g = test_lock();
1273        let mut env = TestEnv::fresh();
1274        let bad_path = env
1275            .db_path
1276            .parent()
1277            .unwrap()
1278            .join("subdir/that/does/not/exist/db.sqlite");
1279        let cfg = default_config();
1280        let mut args = default_args();
1281        args.quiet = true;
1282        args.no_header = true;
1283        let mut out = env.output();
1284        run(&bad_path, &args, &cfg, &mut out).unwrap();
1285        assert!(env.stdout.is_empty());
1286        assert!(env.stderr.is_empty());
1287    }
1288
1289    #[test]
1290    fn boot_falls_back_to_long_tier_when_namespace_empty() {
1291        let _g = test_lock();
1292        let mut env = TestEnv::fresh();
1293        let id = seed_memory(&env.db_path, "other", "long-tier-row", "x");
1294        let conn = db::open(&env.db_path).unwrap();
1295        // v0.7.0 #1036 (Agent-3 #7) — test fixture seed. The seed
1296        // helper produces a default-tier row; this UPDATE flips it
1297        // to long-tier so the boot fallback fires. Non-version-
1298        // bumping is correct here because the test isolates a
1299        // single freshly-seeded row in a fresh DB. Pinned by
1300        // `tests/non_version_bumping_sites_1036.rs`.
1301        conn.execute(
1302            "UPDATE memories SET tier='long' WHERE id=?1",
1303            rusqlite::params![id],
1304        )
1305        .unwrap();
1306        drop(conn);
1307        let db_path = env.db_path.clone();
1308        let cfg = default_config();
1309        let mut args = default_args();
1310        args.namespace = Some("nonexistent-ns".to_string());
1311        let mut out = env.output();
1312        run(&db_path, &args, &cfg, &mut out).unwrap();
1313        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1314        assert!(
1315            stdout.contains("# ai-memory boot: info") && stdout.contains("fallback"),
1316            "expected info/fallback status: {stdout}"
1317        );
1318        assert!(stdout.contains("long-tier-row"));
1319    }
1320
1321    #[test]
1322    fn boot_empty_namespace_emits_info_empty_status() {
1323        let _g = test_lock();
1324        let mut env = TestEnv::fresh();
1325        let db_path = env.db_path.clone();
1326        let cfg = default_config();
1327        let mut args = default_args();
1328        args.namespace = Some("nothing-here".to_string());
1329        let mut out = env.output();
1330        run(&db_path, &args, &cfg, &mut out).unwrap();
1331        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1332        assert!(
1333            stdout.contains("# ai-memory boot: info")
1334                && stdout.contains("nothing-here")
1335                && stdout.contains("empty"),
1336            "info/empty header expected: {stdout}"
1337        );
1338    }
1339
1340    #[test]
1341    fn boot_budget_tokens_clamps_output() {
1342        let _g = test_lock();
1343        let mut env = TestEnv::fresh();
1344        for i in 0..20 {
1345            seed_memory(
1346                &env.db_path,
1347                "ns-budget",
1348                &format!("memory number {i} with a moderate-length title"),
1349                "x",
1350            );
1351        }
1352        let db_path = env.db_path.clone();
1353        let cfg = default_config();
1354        let mut args = default_args();
1355        args.namespace = Some("ns-budget".to_string());
1356        args.limit = 50;
1357        args.budget_tokens = 100;
1358        let mut out = env.output();
1359        run(&db_path, &args, &cfg, &mut out).unwrap();
1360        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1361        let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
1362        assert!(
1363            row_count >= 1 && row_count < 20,
1364            "budget_tokens=100 should clamp to fewer than 20 rows; got {row_count}\noutput:\n{stdout}"
1365        );
1366    }
1367
1368    #[test]
1369    fn boot_json_warn_status_when_db_unavailable() {
1370        let _g = test_lock();
1371        let mut env = TestEnv::fresh();
1372        let bad_path = env
1373            .db_path
1374            .parent()
1375            .unwrap()
1376            .join("subdir/that/does/not/exist/db.sqlite");
1377        let cfg = default_config();
1378        let mut args = default_args();
1379        args.format = "json".to_string();
1380        args.quiet = true;
1381        let mut out = env.output();
1382        run(&bad_path, &args, &cfg, &mut out).unwrap();
1383        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1384        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1385        assert_eq!(parsed["status"], "warn");
1386        assert_eq!(parsed["count"], 0);
1387        assert!(parsed["note"].as_str().unwrap().contains("db unavailable"));
1388        // PR-4: warn JSON variant carries the manifest with sentinels in
1389        // slots that need a live DB.
1390        assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
1391        assert_eq!(parsed["schema_version"], UNAVAILABLE);
1392        assert_eq!(parsed["total_memories"], UNAVAILABLE);
1393        // PR-9h (req #72): schema_supported is `false` when DB unavailable.
1394        assert_eq!(parsed["schema_supported"], false);
1395    }
1396
1397    // -----------------------------------------------------------------
1398    // PR-9h Part 1 — version-drift detection (#487 PR #497 req #72)
1399    // -----------------------------------------------------------------
1400
1401    /// Force-set the on-disk schema_version row to a synthetic value
1402    /// AFTER a previous `db::open` (via `seed_memory`) has run the
1403    /// migration ladder up to `CURRENT_SCHEMA_VERSION` (== MAX). Used
1404    /// to drive the ABOVE-MAX path. Uses a raw `rusqlite::Connection`
1405    /// so we don't trigger another round of migrations that would
1406    /// re-bump the version. The BELOW-MIN path is unreachable by this
1407    /// technique (the migration ladder ratchets any sub-MAX version
1408    /// back to MAX); see [`schema_below_min_is_unsupported`] for the
1409    /// pure-function unit test that covers the lower-bound semantics.
1410    fn override_schema_version(db_path: &std::path::Path, v: i64) {
1411        let conn = rusqlite::Connection::open(db_path).expect("rusqlite::open");
1412        conn.execute("DELETE FROM schema_version", []).unwrap();
1413        conn.execute(
1414            "INSERT INTO schema_version (version) VALUES (?1)",
1415            rusqlite::params![v],
1416        )
1417        .unwrap();
1418    }
1419
1420    #[test]
1421    fn boot_warns_on_schema_above_max() {
1422        let _g = test_lock();
1423        let mut env = TestEnv::fresh();
1424        seed_memory(&env.db_path, "ns-drift", "row", "x");
1425        // Bump schema beyond MAX_SUPPORTED_SCHEMA. Cast through i64 to
1426        // sidestep the migration ladder which would force MAX on
1427        // re-open.
1428        override_schema_version(&env.db_path, i64::from(MAX_SUPPORTED_SCHEMA) + 1);
1429        let db_path = env.db_path.clone();
1430        let cfg = default_config();
1431        let mut args = default_args();
1432        args.namespace = Some("ns-drift".to_string());
1433        let mut out = env.output();
1434        run(&db_path, &args, &cfg, &mut out).unwrap();
1435        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1436        assert!(
1437            stdout.contains("# ai-memory boot: warn"),
1438            "expected warn header for schema drift: {stdout}"
1439        );
1440        assert!(
1441            stdout.contains("unsupported by binary"),
1442            "expected schema-drift message text: {stdout}"
1443        );
1444        assert!(
1445            stdout.contains(&format!(
1446                "v{}..v{}",
1447                MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA
1448            )),
1449            "expected supported range in message: {stdout}"
1450        );
1451    }
1452
1453    #[test]
1454    fn boot_warns_on_schema_below_min() {
1455        // The maintainer's #72 directive lists this as a required test.
1456        // BELOW-MIN drift is unreachable via a synthetic DB because the
1457        // migration ladder in `db::open` ratchets any sub-MAX version
1458        // back to `CURRENT_SCHEMA_VERSION`. We therefore exercise the
1459        // lower-bound semantics through the pure boundary helper.
1460        // See [`schema_below_min_is_unsupported`] for the exhaustive
1461        // boundary table; this test is the directive-named smoke
1462        // check.
1463        assert!(
1464            !schema_in_supported_range(MIN_SUPPORTED_SCHEMA - 1),
1465            "schemas below MIN must be reported as unsupported"
1466        );
1467    }
1468
1469    #[test]
1470    fn schema_below_min_is_unsupported() {
1471        // Exhaustive boundary table for the inclusive [MIN, MAX]
1472        // window. Pure-function — no DB, no env, no test_lock needed.
1473        assert!(!schema_in_supported_range(0));
1474        assert!(!schema_in_supported_range(MIN_SUPPORTED_SCHEMA - 1));
1475        assert!(schema_in_supported_range(MIN_SUPPORTED_SCHEMA));
1476        assert!(schema_in_supported_range(MAX_SUPPORTED_SCHEMA));
1477        assert!(!schema_in_supported_range(MAX_SUPPORTED_SCHEMA + 1));
1478        assert!(!schema_in_supported_range(u32::MAX));
1479    }
1480
1481    #[test]
1482    fn boot_ok_for_schema_at_min() {
1483        let _g = test_lock();
1484        let mut env = TestEnv::fresh();
1485        seed_memory(&env.db_path, "ns-min", "row", "x");
1486        override_schema_version(&env.db_path, i64::from(MIN_SUPPORTED_SCHEMA));
1487        let db_path = env.db_path.clone();
1488        let cfg = default_config();
1489        let mut args = default_args();
1490        args.namespace = Some("ns-min".to_string());
1491        let mut out = env.output();
1492        run(&db_path, &args, &cfg, &mut out).unwrap();
1493        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1494        assert!(
1495            stdout.contains("# ai-memory boot: ok"),
1496            "MIN boundary should be supported (not warn): {stdout}"
1497        );
1498    }
1499
1500    #[test]
1501    fn boot_ok_for_schema_at_max() {
1502        let _g = test_lock();
1503        let mut env = TestEnv::fresh();
1504        seed_memory(&env.db_path, "ns-max", "row", "x");
1505        override_schema_version(&env.db_path, i64::from(MAX_SUPPORTED_SCHEMA));
1506        let db_path = env.db_path.clone();
1507        let cfg = default_config();
1508        let mut args = default_args();
1509        args.namespace = Some("ns-max".to_string());
1510        let mut out = env.output();
1511        run(&db_path, &args, &cfg, &mut out).unwrap();
1512        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1513        assert!(
1514            stdout.contains("# ai-memory boot: ok"),
1515            "MAX boundary should be supported (not warn): {stdout}"
1516        );
1517    }
1518
1519    #[test]
1520    fn boot_json_includes_schema_supported_flag() {
1521        // Happy path — schema in range → schema_supported = true.
1522        let _g = test_lock();
1523        let mut env = TestEnv::fresh();
1524        seed_memory(&env.db_path, "ns-ssj", "row", "x");
1525        let db_path = env.db_path.clone();
1526        let cfg = default_config();
1527        let mut args = default_args();
1528        args.namespace = Some("ns-ssj".to_string());
1529        args.format = "json".to_string();
1530        let mut out = env.output();
1531        run(&db_path, &args, &cfg, &mut out).unwrap();
1532        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1533        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1534        assert_eq!(
1535            parsed["schema_supported"], true,
1536            "happy path → schema_supported=true: {stdout}"
1537        );
1538
1539        // Drift path — schema beyond MAX → schema_supported = false.
1540        let mut env2 = TestEnv::fresh();
1541        seed_memory(&env2.db_path, "ns-ssj2", "row", "x");
1542        override_schema_version(&env2.db_path, i64::from(MAX_SUPPORTED_SCHEMA) + 1);
1543        let db_path2 = env2.db_path.clone();
1544        let mut args2 = default_args();
1545        args2.namespace = Some("ns-ssj2".to_string());
1546        args2.format = "json".to_string();
1547        let mut out2 = env2.output();
1548        run(&db_path2, &args2, &cfg, &mut out2).unwrap();
1549        let stdout2 = std::str::from_utf8(&env2.stdout).unwrap();
1550        let parsed2: serde_json::Value = serde_json::from_str(stdout2.trim()).unwrap();
1551        assert_eq!(
1552            parsed2["schema_supported"], false,
1553            "drift path → schema_supported=false: {stdout2}"
1554        );
1555        assert_eq!(parsed2["status"], "warn");
1556    }
1557
1558    // -----------------------------------------------------------------
1559    // PR-9h Part 2 — `[boot]` privacy controls (#487 PR #497 req #73)
1560    // -----------------------------------------------------------------
1561
1562    fn config_with_boot(enabled: Option<bool>, redact_titles: Option<bool>) -> AppConfig {
1563        let mut cfg = AppConfig::default();
1564        cfg.boot = Some(crate::config::BootConfig {
1565            enabled,
1566            redact_titles,
1567        });
1568        cfg
1569    }
1570
1571    #[test]
1572    fn boot_disabled_emits_nothing_at_all() {
1573        // `[boot] enabled = false` → empty stdout AND empty stderr,
1574        // exit 0. This is the privacy-sensitive escape hatch.
1575        let _g = test_lock();
1576        // SAFETY: process-wide env mutation; serialized by `_g`.
1577        unsafe {
1578            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1579        }
1580        let mut env = TestEnv::fresh();
1581        seed_memory(&env.db_path, "ns-silent", "private-title", "secret");
1582        let db_path = env.db_path.clone();
1583        let cfg = config_with_boot(Some(false), None);
1584        let args = default_args();
1585        let mut out = env.output();
1586        run(&db_path, &args, &cfg, &mut out).unwrap();
1587        assert!(
1588            env.stdout.is_empty(),
1589            "stdout must be empty when boot is disabled: {:?}",
1590            std::str::from_utf8(&env.stdout)
1591        );
1592        assert!(
1593            env.stderr.is_empty(),
1594            "stderr must be empty when boot is disabled: {:?}",
1595            std::str::from_utf8(&env.stderr)
1596        );
1597    }
1598
1599    #[test]
1600    fn boot_disabled_via_env_var_overrides_config() {
1601        // Config says enabled=true (default), but env var forces disabled.
1602        let _g = test_lock();
1603        // SAFETY: process-wide env mutation; serialized by `_g`.
1604        unsafe {
1605            std::env::set_var("AI_MEMORY_BOOT_ENABLED", "0");
1606        }
1607        let mut env = TestEnv::fresh();
1608        seed_memory(&env.db_path, "ns-envoff", "row", "x");
1609        let db_path = env.db_path.clone();
1610        let cfg = config_with_boot(Some(true), None);
1611        let args = default_args();
1612        let mut out = env.output();
1613        let result = run(&db_path, &args, &cfg, &mut out);
1614        // Always restore the env var before assertions so a panic
1615        // doesn't poison subsequent tests.
1616        // SAFETY: process-wide env mutation; serialized by `_guard`.
1617        unsafe {
1618            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1619        }
1620        result.unwrap();
1621        assert!(
1622            env.stdout.is_empty(),
1623            "env-var off must override config: stdout={:?}",
1624            std::str::from_utf8(&env.stdout)
1625        );
1626        assert!(env.stderr.is_empty());
1627    }
1628
1629    #[test]
1630    fn boot_redact_titles_replaces_titles_in_body() {
1631        let _g = test_lock();
1632        // SAFETY: process-wide env mutation; serialized by `_g`.
1633        unsafe {
1634            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1635        }
1636        let mut env = TestEnv::fresh();
1637        seed_memory(&env.db_path, "ns-redact", "secret-subject-alpha", "x");
1638        seed_memory(&env.db_path, "ns-redact", "secret-subject-beta", "y");
1639        let db_path = env.db_path.clone();
1640        let cfg = config_with_boot(Some(true), Some(true));
1641        let mut args = default_args();
1642        args.namespace = Some("ns-redact".to_string());
1643        let mut out = env.output();
1644        run(&db_path, &args, &cfg, &mut out).unwrap();
1645        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1646        // Manifest still appears (audit-trail signal preserved).
1647        assert!(
1648            stdout.contains("# ai-memory boot: ok"),
1649            "manifest header should still appear when only redacting titles: {stdout}"
1650        );
1651        // Body rows replace title with `<redacted>`.
1652        let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
1653        assert_eq!(row_count, 2, "expected 2 body rows: {stdout}");
1654        assert!(
1655            stdout.contains(REDACTED_TITLE),
1656            "expected redacted sentinel in body: {stdout}"
1657        );
1658        // The original distinctive titles MUST NOT leak.
1659        assert!(
1660            !stdout.contains("secret-subject-alpha"),
1661            "title leaked despite redact_titles=true: {stdout}"
1662        );
1663        assert!(
1664            !stdout.contains("secret-subject-beta"),
1665            "title leaked despite redact_titles=true: {stdout}"
1666        );
1667    }
1668
1669    #[test]
1670    fn boot_redact_titles_keeps_other_fields() {
1671        // Redacting titles MUST NOT redact namespace, tier, id_short,
1672        // priority, or age — those are non-PII operational signal.
1673        let _g = test_lock();
1674        // SAFETY: process-wide env mutation; serialized by `_g`.
1675        unsafe {
1676            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1677        }
1678        let mut env = TestEnv::fresh();
1679        seed_memory(&env.db_path, "ns-redact-keep", "private-title", "x");
1680        let db_path = env.db_path.clone();
1681        let cfg = config_with_boot(Some(true), Some(true));
1682        let mut args = default_args();
1683        args.namespace = Some("ns-redact-keep".to_string());
1684        let mut out = env.output();
1685        run(&db_path, &args, &cfg, &mut out).unwrap();
1686        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1687        // namespace surfaces.
1688        assert!(
1689            stdout.contains("ns-redact-keep"),
1690            "namespace must still surface under redact_titles: {stdout}"
1691        );
1692        // Find the body row line.
1693        let row_line = stdout
1694            .lines()
1695            .find(|l| l.starts_with("- ["))
1696            .expect("body row must exist");
1697        // Tier prefix `[mid/...]` (test_utils seeds Tier::Mid).
1698        assert!(
1699            row_line.starts_with("- [mid/"),
1700            "tier + id_short prefix must remain: {row_line}"
1701        );
1702        // Priority + age suffix appears.
1703        assert!(row_line.contains("p=5"), "priority must remain: {row_line}");
1704        // The redaction sentinel sits in the title slot.
1705        assert!(
1706            row_line.contains(REDACTED_TITLE),
1707            "title slot must carry the redaction sentinel: {row_line}"
1708        );
1709        // The original title MUST NOT leak anywhere.
1710        assert!(
1711            !stdout.contains("private-title"),
1712            "raw title must not leak: {stdout}"
1713        );
1714    }
1715
1716    #[test]
1717    fn boot_default_config_unchanged_behavior() {
1718        // Sanity: no [boot] section in config → behaves identically to
1719        // the PR-1 / PR-4 baseline (manifest + body, titles surfaced
1720        // verbatim).
1721        let _g = test_lock();
1722        // SAFETY: process-wide env mutation; serialized by `_g`.
1723        unsafe {
1724            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1725        }
1726        let mut env = TestEnv::fresh();
1727        seed_memory(&env.db_path, "ns-default", "visible-title", "x");
1728        let db_path = env.db_path.clone();
1729        let cfg = AppConfig::default(); // boot = None
1730        let mut args = default_args();
1731        args.namespace = Some("ns-default".to_string());
1732        let mut out = env.output();
1733        run(&db_path, &args, &cfg, &mut out).unwrap();
1734        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1735        assert!(
1736            stdout.contains("# ai-memory boot: ok"),
1737            "default config → manifest header: {stdout}"
1738        );
1739        assert!(
1740            stdout.contains("visible-title"),
1741            "default config → title surfaces verbatim: {stdout}"
1742        );
1743        assert!(
1744            !stdout.contains(REDACTED_TITLE),
1745            "default config must NOT redact: {stdout}"
1746        );
1747    }
1748
1749    // ---------- E1 coverage uplift -----------------------------------
1750    // Targets: toon format (emit_toon + status header toon branch),
1751    // --no-header + --format json (memories-only JSON path),
1752    // resolve_namespace with --cwd override.
1753
1754    #[test]
1755    fn boot_toon_format_emits_compact_body_with_header() {
1756        // Drives emit_toon (lines 840-852) + the BootFormat::Toon arm
1757        // (lines 609-625).
1758        let _g = test_lock();
1759        // SAFETY: process-wide env mutation; serialized by `_g`.
1760        unsafe {
1761            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1762        }
1763        let mut env = TestEnv::fresh();
1764        seed_memory(&env.db_path, "ns-toon", "toon-row", "x");
1765        let db_path = env.db_path.clone();
1766        let cfg = default_config();
1767        let mut args = default_args();
1768        args.namespace = Some("ns-toon".to_string());
1769        args.format = "toon".to_string();
1770        let mut out = env.output();
1771        run(&db_path, &args, &cfg, &mut out).unwrap();
1772        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1773        // Header should still appear (it's text/toon format).
1774        assert!(stdout.contains("# ai-memory boot: ok"));
1775        // The body is toon-encoded; the title field should still surface.
1776        assert!(stdout.contains("toon-row"));
1777    }
1778
1779    #[test]
1780    fn boot_toon_format_no_header_emits_body_only() {
1781        // Drives BootFormat::Toon + no_header=true (skips manifest emit).
1782        let _g = test_lock();
1783        unsafe {
1784            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1785        }
1786        let mut env = TestEnv::fresh();
1787        seed_memory(&env.db_path, "ns-toon-nh", "row-x", "x");
1788        let db_path = env.db_path.clone();
1789        let cfg = default_config();
1790        let mut args = default_args();
1791        args.namespace = Some("ns-toon-nh".to_string());
1792        args.format = "toon".to_string();
1793        args.no_header = true;
1794        let mut out = env.output();
1795        run(&db_path, &args, &cfg, &mut out).unwrap();
1796        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1797        assert!(!stdout.contains("# ai-memory boot"));
1798        // Body still emits the title.
1799        assert!(stdout.contains("row-x"));
1800    }
1801
1802    #[test]
1803    fn boot_json_no_header_emits_memories_only() {
1804        // Drives the args.no_header arm inside the JSON format branch
1805        // (lines 569-576).
1806        let _g = test_lock();
1807        unsafe {
1808            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1809        }
1810        let mut env = TestEnv::fresh();
1811        seed_memory(&env.db_path, "ns-json-nh", "json-nh-row", "x");
1812        let db_path = env.db_path.clone();
1813        let cfg = default_config();
1814        let mut args = default_args();
1815        args.namespace = Some("ns-json-nh".to_string());
1816        args.format = "json".to_string();
1817        args.no_header = true;
1818        let mut out = env.output();
1819        run(&db_path, &args, &cfg, &mut out).unwrap();
1820        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1821        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1822        // Only the `memories` key — no status, no manifest fields.
1823        assert!(parsed.get("memories").is_some());
1824        assert!(parsed.get("status").is_none());
1825        assert!(parsed.get("version").is_none());
1826    }
1827
1828    #[test]
1829    fn boot_resolve_namespace_with_cwd_override() {
1830        // Hits resolve_namespace's `set_current_dir(cwd)` branch
1831        // (line 178). The override doesn't have to land on a git repo —
1832        // the resolver swallows the result.
1833        let _g = test_lock();
1834        unsafe {
1835            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1836        }
1837        let tmp = tempfile::tempdir().unwrap();
1838        let mut env = TestEnv::fresh();
1839        let db_path = env.db_path.clone();
1840        let cfg = default_config();
1841        let mut args = default_args();
1842        args.cwd = Some(tmp.path().to_path_buf());
1843        // namespace is None so resolve_namespace falls into the cwd branch.
1844        let saved_cwd = std::env::current_dir().unwrap();
1845        let mut out = env.output();
1846        run(&db_path, &args, &cfg, &mut out).unwrap();
1847        // Restore so subsequent tests aren't perturbed.
1848        std::env::set_current_dir(&saved_cwd).unwrap();
1849        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1850        // The header surfaces *some* namespace; we don't pin which.
1851        assert!(stdout.contains("# ai-memory boot"));
1852    }
1853
1854    #[test]
1855    fn boot_redact_titles_json_output_replaces_titles() {
1856        // Drives render_memories_for_emit's redact-clone arm (lines
1857        // 646-652) under the JSON format path.
1858        let _g = test_lock();
1859        unsafe {
1860            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1861        }
1862        let mut env = TestEnv::fresh();
1863        seed_memory(&env.db_path, "ns-rj", "private-jt", "x");
1864        let db_path = env.db_path.clone();
1865        let cfg = config_with_boot(Some(true), Some(true));
1866        let mut args = default_args();
1867        args.namespace = Some("ns-rj".to_string());
1868        args.format = "json".to_string();
1869        let mut out = env.output();
1870        run(&db_path, &args, &cfg, &mut out).unwrap();
1871        let stdout = std::str::from_utf8(&env.stdout).unwrap();
1872        let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1873        let memories = parsed["memories"].as_array().expect("memories array");
1874        assert_eq!(memories.len(), 1);
1875        assert_eq!(memories[0]["title"].as_str().unwrap(), REDACTED_TITLE);
1876    }
1877
1878    #[test]
1879    fn boot_format_parse_unknown_value_propagates() {
1880        // Drives BootFormat::parse's error arm.
1881        let _g = test_lock();
1882        unsafe {
1883            std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1884        }
1885        let mut env = TestEnv::fresh();
1886        let db_path = env.db_path.clone();
1887        let cfg = default_config();
1888        let mut args = default_args();
1889        args.format = "xml".to_string();
1890        let mut out = env.output();
1891        let res = run(&db_path, &args, &cfg, &mut out);
1892        assert!(res.is_err());
1893        assert!(res.unwrap_err().to_string().contains("unknown --format"));
1894    }
1895}