use crate::cli::CliOutput;
use crate::cli::helpers::{human_age, id_short};
use crate::config::AppConfig;
use crate::models::field_names;
use crate::{db, models, toon};
use anyhow::Result;
use clap::Args;
use models::Tier;
use std::path::Path;
use std::time::Instant;
pub const MIN_SUPPORTED_SCHEMA: u32 = 16;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub const MAX_SUPPORTED_SCHEMA: u32 = crate::storage::migrations::current_schema_version() as u32;
#[must_use]
pub fn schema_in_supported_range(v: u32) -> bool {
v >= MIN_SUPPORTED_SCHEMA && v <= MAX_SUPPORTED_SCHEMA
}
const DEFAULT_BUDGET_TOKENS: usize = 4096;
const TOKENS_PER_CHAR: f32 = 0.25;
const UNAVAILABLE: &str = "<unavailable>";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootFormat {
Text,
Json,
Toon,
}
impl BootFormat {
fn parse(s: &str) -> Result<Self> {
match s {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
"toon" | "toon-compact" | crate::toon::FORMAT_TOON_COMPACT => Ok(Self::Toon),
other => Err(anyhow::anyhow!(
"unknown --format value: {other} (expected: text | json | toon)"
)),
}
}
}
#[derive(Args, Debug)]
pub struct BootArgs {
#[arg(long)]
pub namespace: Option<String>,
#[arg(long, default_value_t = 10)]
pub limit: usize,
#[arg(long, default_value_t = DEFAULT_BUDGET_TOKENS)]
pub budget_tokens: usize,
#[arg(long, default_value = "text")]
pub format: String,
#[arg(long, default_value_t = false)]
pub no_header: bool,
#[arg(long, default_value_t = false)]
pub quiet: bool,
#[arg(long, value_name = "PATH")]
pub cwd: Option<std::path::PathBuf>,
}
fn resolve_namespace(args: &BootArgs) -> String {
if let Some(ref ns) = args.namespace {
return ns.clone();
}
if let Some(ref cwd) = args.cwd {
let _ = std::env::set_current_dir(cwd);
}
crate::cli::helpers::resolve_namespace(None)
}
fn fetch_boot_memories(
conn: &rusqlite::Connection,
namespace: &str,
limit: usize,
) -> Result<(Vec<models::Memory>, String)> {
let primary = db::list(
conn,
Some(namespace),
None,
limit,
0,
None,
None,
None,
None,
None,
)?;
if !primary.is_empty() {
return Ok((primary, namespace.to_string()));
}
let fallback = db::list(
conn,
None,
Some(&Tier::Long),
limit,
0,
None,
None,
None,
None,
None,
)?;
Ok((fallback, String::new()))
}
fn clamp_to_budget(mems: Vec<models::Memory>, budget_tokens: usize) -> Vec<models::Memory> {
if budget_tokens == 0 || mems.is_empty() {
return mems;
}
let mut chars_so_far: usize = 0;
let mut out = Vec::with_capacity(mems.len());
for (idx, mem) in mems.into_iter().enumerate() {
let row_chars = mem.title.len() + mem.namespace.len() + 80;
let projected_tokens =
((chars_so_far + row_chars) as f32 * TOKENS_PER_CHAR).ceil() as usize;
if idx > 0 && projected_tokens > budget_tokens {
break;
}
chars_so_far += row_chars;
out.push(mem);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BootStatus {
OkLoaded,
InfoFallback,
InfoEmpty,
WarnDbUnavailable,
WarnSchemaUnsupported { db_schema: u32 },
}
impl BootStatus {
fn label(self) -> &'static str {
match self {
Self::OkLoaded => "ok",
Self::InfoFallback | Self::InfoEmpty => "info",
Self::WarnDbUnavailable | Self::WarnSchemaUnsupported { .. } => "warn",
}
}
}
fn read_schema_version(conn: &rusqlite::Connection) -> (String, Option<u32>) {
match conn.query_row(
crate::storage::migrations::SELECT_SCHEMA_VERSION_SQL,
[],
|r| r.get::<_, i64>(0),
) {
Ok(v) => {
let display = format!("v{v}");
let numeric = u32::try_from(v).ok();
(display, numeric)
}
Err(_) => (UNAVAILABLE.to_string(), None),
}
}
fn count_live_memories(conn: &rusqlite::Connection) -> String {
let now = chrono::Utc::now().to_rfc3339();
conn.query_row(
"SELECT COUNT(*) FROM memories WHERE expires_at IS NULL OR expires_at > ?1",
rusqlite::params![now],
|r| r.get::<_, i64>(0),
)
.map_or_else(|_| UNAVAILABLE.to_string(), |v| v.to_string())
}
struct BootManifest {
version: String,
db_path: String,
schema_version: String,
total_memories: String,
tier: String,
embedder: String,
reranker: String,
llm: String,
latency_ms: u128,
namespace: String,
count: usize,
note: String,
status: BootStatus,
schema_supported: bool,
}
impl BootManifest {
fn build(
status: BootStatus,
namespace: &str,
count: usize,
db_path: &Path,
app_config: &AppConfig,
schema_version: String,
total_memories: String,
latency_ms: u128,
schema_supported: bool,
) -> Self {
let feature_tier = app_config.effective_tier(None);
let resolved_llm = app_config.resolve_llm(None, None, None);
let resolved_emb = app_config.resolve_embeddings();
let resolved_rer = app_config.resolve_reranker();
let embedder = if feature_tier.config().embedding_model.is_none() {
"none".to_string()
} else {
resolved_emb.model.clone()
};
let llm = if resolved_llm.is_ollama_native() {
resolved_llm.model.clone()
} else {
resolved_llm.display_label()
};
let reranker = if resolved_rer.enabled || feature_tier.config().cross_encoder {
resolved_rer.model.clone()
} else {
"none".to_string()
};
let note = match status {
BootStatus::OkLoaded => format!(
"loaded {count} memor{plural} from ns={namespace}",
plural = if count == 1 { "y" } else { "ies" }
),
BootStatus::InfoFallback => format!(
"namespace empty; loaded {count} memor{plural} from global Long tier fallback",
plural = if count == 1 { "y" } else { "ies" }
),
BootStatus::InfoEmpty => format!(
"namespace '{namespace}' is empty and no global Long-tier fallback found — \
nothing to load (this is normal on a fresh install)"
),
BootStatus::WarnDbUnavailable => format!(
"db unavailable at {} — proceeding without memory context. \
Run `ai-memory doctor` to diagnose. \
See https://github.com/alphaonedev/ai-memory-mcp/blob/main/docs/integrations/README.md",
db_path.display()
),
BootStatus::WarnSchemaUnsupported { db_schema } => format!(
"db schema v{db_schema} unsupported by binary {bin_ver} \
(supports v{min}..v{max}); proceeding with degraded context. \
Run `ai-memory doctor` and consider upgrading.",
bin_ver = crate::PKG_VERSION,
min = MIN_SUPPORTED_SCHEMA,
max = MAX_SUPPORTED_SCHEMA,
),
};
Self {
version: crate::PKG_VERSION.to_string(),
db_path: db_path.display().to_string(),
schema_version,
total_memories,
tier: feature_tier.as_str().to_string(),
embedder,
reranker,
llm,
latency_ms,
namespace: namespace.to_string(),
count,
note,
status,
schema_supported,
}
}
}
#[allow(clippy::too_many_lines)]
pub fn run(
db_path: &Path,
args: &BootArgs,
app_config: &AppConfig,
out: &mut CliOutput<'_>,
) -> Result<()> {
let start = Instant::now();
let boot_cfg = app_config.effective_boot();
if !boot_cfg.effective_enabled() {
return Ok(());
}
let redact_titles = boot_cfg.effective_redact_titles();
let format = BootFormat::parse(&args.format)?;
let limit = args.limit.clamp(1, 50);
let namespace = resolve_namespace(args);
let conn = match db::open(db_path) {
Ok(c) => c,
Err(e) => {
if !args.quiet {
writeln!(
out.stderr,
"ai-memory boot: db unavailable at {}: {e}",
db_path.display()
)?;
}
if !args.no_header {
let manifest = BootManifest::build(
BootStatus::WarnDbUnavailable,
&namespace,
0,
db_path,
app_config,
UNAVAILABLE.to_string(),
UNAVAILABLE.to_string(),
start.elapsed().as_millis(),
false, );
emit_status_header(out, &manifest, format)?;
}
return Ok(());
}
};
let (schema_version, schema_int) = read_schema_version(&conn);
let total_memories = count_live_memories(&conn);
let schema_supported = schema_int.is_some_and(schema_in_supported_range);
if let Some(v) = schema_int
&& !schema_in_supported_range(v)
{
if !args.no_header {
let manifest = BootManifest::build(
BootStatus::WarnSchemaUnsupported { db_schema: v },
&namespace,
0,
db_path,
app_config,
schema_version,
total_memories,
start.elapsed().as_millis(),
false,
);
emit_status_header(out, &manifest, format)?;
}
return Ok(());
}
let (mems, used_namespace) = fetch_boot_memories(&conn, &namespace, limit)?;
let mems = clamp_to_budget(mems, args.budget_tokens);
let fell_back = !mems.is_empty() && used_namespace.is_empty();
if mems.is_empty() {
if !args.no_header {
let manifest = BootManifest::build(
BootStatus::InfoEmpty,
&namespace,
0,
db_path,
app_config,
schema_version,
total_memories,
start.elapsed().as_millis(),
schema_supported,
);
emit_status_header(out, &manifest, format)?;
}
return Ok(());
}
let displayed_ns = if fell_back {
crate::DEFAULT_NAMESPACE
} else {
&namespace
};
let status = if fell_back {
BootStatus::InfoFallback
} else {
BootStatus::OkLoaded
};
match format {
BootFormat::Json => {
if args.no_header {
writeln!(
out.stdout,
"{}",
serde_json::to_string(&serde_json::json!({
"memories": render_memories_for_emit(&mems, redact_titles)
}))?
)?;
} else {
let manifest = BootManifest::build(
status,
displayed_ns,
mems.len(),
db_path,
app_config,
schema_version,
total_memories,
start.elapsed().as_millis(),
schema_supported,
);
emit_json_with_status(out, &manifest, &mems, fell_back, redact_titles)?;
}
}
BootFormat::Text => {
if !args.no_header {
let manifest = BootManifest::build(
status,
displayed_ns,
mems.len(),
db_path,
app_config,
schema_version,
total_memories,
start.elapsed().as_millis(),
schema_supported,
);
emit_status_header(out, &manifest, format)?;
}
emit_text(out, &mems, redact_titles)?;
}
BootFormat::Toon => {
if !args.no_header {
let manifest = BootManifest::build(
status,
displayed_ns,
mems.len(),
db_path,
app_config,
schema_version,
total_memories,
start.elapsed().as_millis(),
schema_supported,
);
emit_status_header(out, &manifest, format)?;
}
emit_toon(out, &mems, redact_titles)?;
}
}
Ok(())
}
const REDACTED_TITLE: &str = "<redacted>";
fn render_memories_for_emit(mems: &[models::Memory], redact_titles: bool) -> Vec<models::Memory> {
if !redact_titles {
return mems.to_vec();
}
mems.iter()
.map(|m| {
let mut redacted = m.clone();
redacted.title = REDACTED_TITLE.to_string();
redacted
})
.collect()
}
fn emit_status_header(
out: &mut CliOutput<'_>,
manifest: &BootManifest,
format: BootFormat,
) -> Result<()> {
match format {
BootFormat::Json => {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"status": manifest.status.label(),
"version": manifest.version,
"db_path": manifest.db_path,
(field_names::SCHEMA_VERSION): manifest.schema_version,
"schema_supported": manifest.schema_supported,
(field_names::TOTAL_MEMORIES): manifest.total_memories,
"tier": manifest.tier,
"embedder": manifest.embedder,
"reranker": manifest.reranker,
"llm": manifest.llm,
(field_names::LATENCY_MS): manifest.latency_ms,
"namespace": manifest.namespace,
"count": manifest.count,
"note": manifest.note,
})
)?;
}
_ => {
writeln!(out.stdout, "# ai-memory boot: {}", manifest.status.label())?;
writeln!(out.stdout, "# version: {}", manifest.version)?;
writeln!(
out.stdout,
"# db: {} (schema={}, {} memories)",
manifest.db_path, manifest.schema_version, manifest.total_memories
)?;
writeln!(
out.stdout,
"# tier: {} (embedder={}, reranker={}, llm={})",
manifest.tier, manifest.embedder, manifest.reranker, manifest.llm
)?;
writeln!(out.stdout, "# latency: {}ms", manifest.latency_ms)?;
match manifest.status {
BootStatus::OkLoaded => {
writeln!(
out.stdout,
"# namespace: {} (loaded {} memor{})",
manifest.namespace,
manifest.count,
if manifest.count == 1 { "y" } else { "ies" }
)?;
}
BootStatus::InfoFallback => {
writeln!(
out.stdout,
"# namespace: {} (fallback: loaded {} memor{} from global Long tier)",
manifest.namespace,
manifest.count,
if manifest.count == 1 { "y" } else { "ies" }
)?;
}
BootStatus::InfoEmpty => {
writeln!(
out.stdout,
"# namespace: {} (empty — nothing to load; this is normal on a fresh install)",
manifest.namespace
)?;
}
BootStatus::WarnDbUnavailable => {
writeln!(
out.stdout,
"# namespace: {} (db unavailable — see `ai-memory doctor`)",
manifest.namespace
)?;
}
BootStatus::WarnSchemaUnsupported { db_schema } => {
writeln!(
out.stdout,
"# namespace: {} (db schema v{} unsupported by binary {} \
(supports v{}..v{}); proceeding with degraded context. \
Run `ai-memory doctor` and consider upgrading.)",
manifest.namespace,
db_schema,
manifest.version,
MIN_SUPPORTED_SCHEMA,
MAX_SUPPORTED_SCHEMA,
)?;
}
}
}
}
Ok(())
}
fn emit_text(out: &mut CliOutput<'_>, mems: &[models::Memory], redact_titles: bool) -> Result<()> {
for mem in mems {
let age = human_age(&mem.updated_at);
let title: &str = if redact_titles {
REDACTED_TITLE
} else {
&mem.title
};
writeln!(
out.stdout,
"- [{}/{}] {} (ns={}, p={}, {})",
mem.tier,
id_short(&mem.id),
title,
mem.namespace,
mem.priority,
age
)?;
}
Ok(())
}
fn emit_json_with_status(
out: &mut CliOutput<'_>,
manifest: &BootManifest,
mems: &[models::Memory],
fell_back: bool,
redact_titles: bool,
) -> Result<()> {
let rendered = render_memories_for_emit(mems, redact_titles);
let body = serde_json::json!({
"status": manifest.status.label(),
"version": manifest.version,
"db_path": manifest.db_path,
(field_names::SCHEMA_VERSION): manifest.schema_version,
"schema_supported": manifest.schema_supported,
(field_names::TOTAL_MEMORIES): manifest.total_memories,
"tier": manifest.tier,
"embedder": manifest.embedder,
"reranker": manifest.reranker,
"llm": manifest.llm,
(field_names::LATENCY_MS): manifest.latency_ms,
"namespace": manifest.namespace,
"count": manifest.count,
"note": manifest.note,
"fell_back_to_global": fell_back,
"memories": rendered,
});
writeln!(out.stdout, "{}", serde_json::to_string(&body)?)?;
Ok(())
}
fn emit_toon(out: &mut CliOutput<'_>, mems: &[models::Memory], redact_titles: bool) -> Result<()> {
let rendered = render_memories_for_emit(mems, redact_titles);
let body = serde_json::json!({
"memories": rendered,
"count": rendered.len(),
});
let toon_str = toon::memories_to_toon(&body, true);
writeln!(out.stdout, "{toon_str}")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::{TestEnv, seed_memory};
fn default_args() -> BootArgs {
BootArgs {
namespace: None,
limit: 10,
budget_tokens: DEFAULT_BUDGET_TOKENS,
format: "text".to_string(),
no_header: false,
quiet: false,
cwd: None,
}
}
fn default_config() -> AppConfig {
AppConfig::default()
}
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn boot_format_parse_accepts_aliases() {
assert_eq!(BootFormat::parse("text").unwrap(), BootFormat::Text);
assert_eq!(BootFormat::parse("json").unwrap(), BootFormat::Json);
assert_eq!(BootFormat::parse("toon").unwrap(), BootFormat::Toon);
assert_eq!(BootFormat::parse("toon-compact").unwrap(), BootFormat::Toon);
assert_eq!(BootFormat::parse("toon_compact").unwrap(), BootFormat::Toon);
assert!(BootFormat::parse("yaml").is_err());
}
#[test]
fn boot_emits_ok_header_with_loaded_memories() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-x", "first", "content one");
seed_memory(&env.db_path, "ns-x", "second", "content two");
seed_memory(&env.db_path, "ns-y", "elsewhere", "content three");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-x".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("# ai-memory boot: ok"),
"expected ok status header, got: {stdout}"
);
assert!(
stdout.contains("# version:"),
"manifest missing version line: {stdout}"
);
assert!(
stdout.contains("# db:"),
"manifest missing db line: {stdout}"
);
assert!(
stdout.contains("# tier:"),
"manifest missing tier line: {stdout}"
);
assert!(
stdout.contains("# latency:"),
"manifest missing latency line: {stdout}"
);
assert!(
stdout.contains("# namespace:") && stdout.contains("ns-x"),
"namespace line should contain ns-x: {stdout}"
);
assert!(stdout.contains("loaded 2 memories"));
assert!(stdout.contains("first"));
assert!(stdout.contains("second"));
assert!(!stdout.contains("elsewhere"));
}
#[test]
fn boot_header_includes_version() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-v", "row", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-v".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let version = env!("CARGO_PKG_VERSION");
assert!(
stdout.contains(version),
"expected version `{version}` in header: {stdout}"
);
}
#[test]
fn boot_header_includes_db_path() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-d", "row", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-d".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let db_str = db_path.display().to_string();
assert!(
stdout.contains(&db_str),
"expected db path `{db_str}` in header: {stdout}"
);
}
#[test]
fn boot_header_includes_schema_version() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-s", "row", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-s".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("schema=v"),
"expected `schema=vN` in header: {stdout}"
);
}
#[test]
fn boot_header_includes_latency_ms() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-lat", "row", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-lat".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let latency_line = stdout
.lines()
.find(|l| l.contains("latency:"))
.expect("latency line must exist in manifest");
let suffix = latency_line.split("latency:").nth(1).unwrap().trim();
assert!(
suffix.ends_with("ms"),
"latency value should end with `ms`: {suffix}"
);
let num_str = suffix.trim_end_matches("ms");
assert!(
num_str.parse::<u128>().is_ok(),
"latency must parse as integer ms: {num_str}"
);
}
#[test]
fn boot_json_includes_all_manifest_fields() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-jm", "row", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-jm".to_string());
args.format = "json".to_string();
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["namespace"], "ns-jm");
assert_eq!(parsed["count"], 1);
assert_eq!(parsed["fell_back_to_global"], false);
assert!(parsed["memories"].is_array());
for key in [
"version",
"db_path",
"schema_version",
"total_memories",
"tier",
"embedder",
"reranker",
"llm",
"latency_ms",
"note",
] {
assert!(
parsed.get(key).is_some(),
"json output missing manifest field `{key}`: {stdout}"
);
}
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
assert!(parsed["latency_ms"].is_number());
assert!(
parsed["schema_version"]
.as_str()
.unwrap_or("")
.starts_with('v'),
"schema_version should be `vN` form"
);
}
#[test]
fn boot_respects_limit() {
let _g = test_lock();
let mut env = TestEnv::fresh();
for i in 0..5 {
seed_memory(&env.db_path, "ns-l", &format!("m{i}"), "x");
}
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-l".to_string());
args.limit = 2;
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(stdout.contains("loaded 2 memories"));
let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
assert_eq!(row_count, 2, "expected 2 rows, got {row_count}: {stdout}");
}
#[test]
fn boot_no_header_with_flag_suppresses_status() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-h", "row-one", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-h".to_string());
args.no_header = true;
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(!stdout.contains("# ai-memory boot"));
assert!(stdout.contains("row-one"));
}
#[test]
fn boot_json_format_emits_status_and_memories() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-j", "row", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-j".to_string());
args.format = "json".to_string();
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["namespace"], "ns-j");
assert_eq!(parsed["count"], 1);
assert_eq!(parsed["fell_back_to_global"], false);
assert!(parsed["memories"].is_array());
}
#[test]
fn boot_quiet_with_unreachable_db_emits_warn_header_no_stderr() {
let _g = test_lock();
let mut env = TestEnv::fresh();
let bad_path = env
.db_path
.parent()
.unwrap()
.join("subdir/that/does/not/exist/db.sqlite");
let cfg = default_config();
let mut args = default_args();
args.quiet = true;
let mut out = env.output();
run(&bad_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("# ai-memory boot: warn"),
"warn header should always appear under --quiet: {stdout}"
);
assert!(
stdout.contains("db unavailable"),
"header should explain the warning cause: {stdout}"
);
assert!(
stdout.contains("# version:"),
"warn manifest should still carry version: {stdout}"
);
assert!(
stdout.contains(env!("CARGO_PKG_VERSION")),
"warn manifest version should be CARGO_PKG_VERSION: {stdout}"
);
assert!(
stdout.contains("# tier:"),
"warn manifest should still carry tier: {stdout}"
);
assert!(
stdout.contains("# latency:"),
"warn manifest should still carry latency: {stdout}"
);
assert!(
stdout.contains(UNAVAILABLE),
"warn manifest should mark unreachable fields as <unavailable>: {stdout}"
);
assert!(
env.stderr.is_empty(),
"stderr should be silent under --quiet"
);
}
#[test]
fn boot_db_unavailable_without_quiet_writes_to_stderr() {
let _g = test_lock();
let mut env = TestEnv::fresh();
let bad_path = env
.db_path
.parent()
.unwrap()
.join("subdir/that/does/not/exist/db.sqlite");
let cfg = default_config();
let args = default_args();
let mut out = env.output();
run(&bad_path, &args, &cfg, &mut out).unwrap();
let stderr = std::str::from_utf8(&env.stderr).unwrap();
assert!(
stderr.contains("ai-memory boot: db unavailable"),
"stderr should carry the diagnostic without --quiet: {stderr}"
);
}
#[test]
fn boot_quiet_with_no_header_silent_for_legacy_wrappers() {
let _g = test_lock();
let mut env = TestEnv::fresh();
let bad_path = env
.db_path
.parent()
.unwrap()
.join("subdir/that/does/not/exist/db.sqlite");
let cfg = default_config();
let mut args = default_args();
args.quiet = true;
args.no_header = true;
let mut out = env.output();
run(&bad_path, &args, &cfg, &mut out).unwrap();
assert!(env.stdout.is_empty());
assert!(env.stderr.is_empty());
}
#[test]
fn boot_falls_back_to_long_tier_when_namespace_empty() {
let _g = test_lock();
let mut env = TestEnv::fresh();
let id = seed_memory(&env.db_path, "other", "long-tier-row", "x");
let conn = db::open(&env.db_path).unwrap();
conn.execute(
"UPDATE memories SET tier='long' WHERE id=?1",
rusqlite::params![id],
)
.unwrap();
drop(conn);
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("nonexistent-ns".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("# ai-memory boot: info") && stdout.contains("fallback"),
"expected info/fallback status: {stdout}"
);
assert!(stdout.contains("long-tier-row"));
}
#[test]
fn boot_empty_namespace_emits_info_empty_status() {
let _g = test_lock();
let mut env = TestEnv::fresh();
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("nothing-here".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("# ai-memory boot: info")
&& stdout.contains("nothing-here")
&& stdout.contains("empty"),
"info/empty header expected: {stdout}"
);
}
#[test]
fn boot_budget_tokens_clamps_output() {
let _g = test_lock();
let mut env = TestEnv::fresh();
for i in 0..20 {
seed_memory(
&env.db_path,
"ns-budget",
&format!("memory number {i} with a moderate-length title"),
"x",
);
}
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-budget".to_string());
args.limit = 50;
args.budget_tokens = 100;
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
assert!(
row_count >= 1 && row_count < 20,
"budget_tokens=100 should clamp to fewer than 20 rows; got {row_count}\noutput:\n{stdout}"
);
}
#[test]
fn boot_json_warn_status_when_db_unavailable() {
let _g = test_lock();
let mut env = TestEnv::fresh();
let bad_path = env
.db_path
.parent()
.unwrap()
.join("subdir/that/does/not/exist/db.sqlite");
let cfg = default_config();
let mut args = default_args();
args.format = "json".to_string();
args.quiet = true;
let mut out = env.output();
run(&bad_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["status"], "warn");
assert_eq!(parsed["count"], 0);
assert!(parsed["note"].as_str().unwrap().contains("db unavailable"));
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(parsed["schema_version"], UNAVAILABLE);
assert_eq!(parsed["total_memories"], UNAVAILABLE);
assert_eq!(parsed["schema_supported"], false);
}
fn override_schema_version(db_path: &std::path::Path, v: i64) {
let conn = rusqlite::Connection::open(db_path).expect("rusqlite::open");
conn.execute("DELETE FROM schema_version", []).unwrap();
conn.execute(
"INSERT INTO schema_version (version) VALUES (?1)",
rusqlite::params![v],
)
.unwrap();
}
#[test]
fn boot_warns_on_schema_above_max() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-drift", "row", "x");
override_schema_version(&env.db_path, i64::from(MAX_SUPPORTED_SCHEMA) + 1);
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-drift".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("# ai-memory boot: warn"),
"expected warn header for schema drift: {stdout}"
);
assert!(
stdout.contains("unsupported by binary"),
"expected schema-drift message text: {stdout}"
);
assert!(
stdout.contains(&format!(
"v{}..v{}",
MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA
)),
"expected supported range in message: {stdout}"
);
}
#[test]
fn boot_warns_on_schema_below_min() {
assert!(
!schema_in_supported_range(MIN_SUPPORTED_SCHEMA - 1),
"schemas below MIN must be reported as unsupported"
);
}
#[test]
fn schema_below_min_is_unsupported() {
assert!(!schema_in_supported_range(0));
assert!(!schema_in_supported_range(MIN_SUPPORTED_SCHEMA - 1));
assert!(schema_in_supported_range(MIN_SUPPORTED_SCHEMA));
assert!(schema_in_supported_range(MAX_SUPPORTED_SCHEMA));
assert!(!schema_in_supported_range(MAX_SUPPORTED_SCHEMA + 1));
assert!(!schema_in_supported_range(u32::MAX));
}
#[test]
fn boot_ok_for_schema_at_min() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-min", "row", "x");
override_schema_version(&env.db_path, i64::from(MIN_SUPPORTED_SCHEMA));
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-min".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("# ai-memory boot: ok"),
"MIN boundary should be supported (not warn): {stdout}"
);
}
#[test]
fn boot_ok_for_schema_at_max() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-max", "row", "x");
override_schema_version(&env.db_path, i64::from(MAX_SUPPORTED_SCHEMA));
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-max".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("# ai-memory boot: ok"),
"MAX boundary should be supported (not warn): {stdout}"
);
}
#[test]
fn boot_json_includes_schema_supported_flag() {
let _g = test_lock();
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-ssj", "row", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-ssj".to_string());
args.format = "json".to_string();
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
parsed["schema_supported"], true,
"happy path → schema_supported=true: {stdout}"
);
let mut env2 = TestEnv::fresh();
seed_memory(&env2.db_path, "ns-ssj2", "row", "x");
override_schema_version(&env2.db_path, i64::from(MAX_SUPPORTED_SCHEMA) + 1);
let db_path2 = env2.db_path.clone();
let mut args2 = default_args();
args2.namespace = Some("ns-ssj2".to_string());
args2.format = "json".to_string();
let mut out2 = env2.output();
run(&db_path2, &args2, &cfg, &mut out2).unwrap();
let stdout2 = std::str::from_utf8(&env2.stdout).unwrap();
let parsed2: serde_json::Value = serde_json::from_str(stdout2.trim()).unwrap();
assert_eq!(
parsed2["schema_supported"], false,
"drift path → schema_supported=false: {stdout2}"
);
assert_eq!(parsed2["status"], "warn");
}
fn config_with_boot(enabled: Option<bool>, redact_titles: Option<bool>) -> AppConfig {
let mut cfg = AppConfig::default();
cfg.boot = Some(crate::config::BootConfig {
enabled,
redact_titles,
});
cfg
}
#[test]
fn boot_disabled_emits_nothing_at_all() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-silent", "private-title", "secret");
let db_path = env.db_path.clone();
let cfg = config_with_boot(Some(false), None);
let args = default_args();
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
assert!(
env.stdout.is_empty(),
"stdout must be empty when boot is disabled: {:?}",
std::str::from_utf8(&env.stdout)
);
assert!(
env.stderr.is_empty(),
"stderr must be empty when boot is disabled: {:?}",
std::str::from_utf8(&env.stderr)
);
}
#[test]
fn boot_disabled_via_env_var_overrides_config() {
let _g = test_lock();
unsafe {
std::env::set_var("AI_MEMORY_BOOT_ENABLED", "0");
}
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-envoff", "row", "x");
let db_path = env.db_path.clone();
let cfg = config_with_boot(Some(true), None);
let args = default_args();
let mut out = env.output();
let result = run(&db_path, &args, &cfg, &mut out);
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
result.unwrap();
assert!(
env.stdout.is_empty(),
"env-var off must override config: stdout={:?}",
std::str::from_utf8(&env.stdout)
);
assert!(env.stderr.is_empty());
}
#[test]
fn boot_redact_titles_replaces_titles_in_body() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-redact", "secret-subject-alpha", "x");
seed_memory(&env.db_path, "ns-redact", "secret-subject-beta", "y");
let db_path = env.db_path.clone();
let cfg = config_with_boot(Some(true), Some(true));
let mut args = default_args();
args.namespace = Some("ns-redact".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("# ai-memory boot: ok"),
"manifest header should still appear when only redacting titles: {stdout}"
);
let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
assert_eq!(row_count, 2, "expected 2 body rows: {stdout}");
assert!(
stdout.contains(REDACTED_TITLE),
"expected redacted sentinel in body: {stdout}"
);
assert!(
!stdout.contains("secret-subject-alpha"),
"title leaked despite redact_titles=true: {stdout}"
);
assert!(
!stdout.contains("secret-subject-beta"),
"title leaked despite redact_titles=true: {stdout}"
);
}
#[test]
fn boot_redact_titles_keeps_other_fields() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-redact-keep", "private-title", "x");
let db_path = env.db_path.clone();
let cfg = config_with_boot(Some(true), Some(true));
let mut args = default_args();
args.namespace = Some("ns-redact-keep".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("ns-redact-keep"),
"namespace must still surface under redact_titles: {stdout}"
);
let row_line = stdout
.lines()
.find(|l| l.starts_with("- ["))
.expect("body row must exist");
assert!(
row_line.starts_with("- [mid/"),
"tier + id_short prefix must remain: {row_line}"
);
assert!(row_line.contains("p=5"), "priority must remain: {row_line}");
assert!(
row_line.contains(REDACTED_TITLE),
"title slot must carry the redaction sentinel: {row_line}"
);
assert!(
!stdout.contains("private-title"),
"raw title must not leak: {stdout}"
);
}
#[test]
fn boot_default_config_unchanged_behavior() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-default", "visible-title", "x");
let db_path = env.db_path.clone();
let cfg = AppConfig::default(); let mut args = default_args();
args.namespace = Some("ns-default".to_string());
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(
stdout.contains("# ai-memory boot: ok"),
"default config → manifest header: {stdout}"
);
assert!(
stdout.contains("visible-title"),
"default config → title surfaces verbatim: {stdout}"
);
assert!(
!stdout.contains(REDACTED_TITLE),
"default config must NOT redact: {stdout}"
);
}
#[test]
fn boot_toon_format_emits_compact_body_with_header() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-toon", "toon-row", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-toon".to_string());
args.format = "toon".to_string();
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(stdout.contains("# ai-memory boot: ok"));
assert!(stdout.contains("toon-row"));
}
#[test]
fn boot_toon_format_no_header_emits_body_only() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-toon-nh", "row-x", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-toon-nh".to_string());
args.format = "toon".to_string();
args.no_header = true;
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(!stdout.contains("# ai-memory boot"));
assert!(stdout.contains("row-x"));
}
#[test]
fn boot_json_no_header_emits_memories_only() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-json-nh", "json-nh-row", "x");
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.namespace = Some("ns-json-nh".to_string());
args.format = "json".to_string();
args.no_header = true;
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert!(parsed.get("memories").is_some());
assert!(parsed.get("status").is_none());
assert!(parsed.get("version").is_none());
}
#[test]
fn boot_resolve_namespace_with_cwd_override() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let tmp = tempfile::tempdir().unwrap();
let mut env = TestEnv::fresh();
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.cwd = Some(tmp.path().to_path_buf());
let saved_cwd = std::env::current_dir().unwrap();
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
std::env::set_current_dir(&saved_cwd).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
assert!(stdout.contains("# ai-memory boot"));
}
#[test]
fn boot_redact_titles_json_output_replaces_titles() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let mut env = TestEnv::fresh();
seed_memory(&env.db_path, "ns-rj", "private-jt", "x");
let db_path = env.db_path.clone();
let cfg = config_with_boot(Some(true), Some(true));
let mut args = default_args();
args.namespace = Some("ns-rj".to_string());
args.format = "json".to_string();
let mut out = env.output();
run(&db_path, &args, &cfg, &mut out).unwrap();
let stdout = std::str::from_utf8(&env.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
let memories = parsed["memories"].as_array().expect("memories array");
assert_eq!(memories.len(), 1);
assert_eq!(memories[0]["title"].as_str().unwrap(), REDACTED_TITLE);
}
#[test]
fn boot_format_parse_unknown_value_propagates() {
let _g = test_lock();
unsafe {
std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
}
let mut env = TestEnv::fresh();
let db_path = env.db_path.clone();
let cfg = default_config();
let mut args = default_args();
args.format = "xml".to_string();
let mut out = env.output();
let res = run(&db_path, &args, &cfg, &mut out);
assert!(res.is_err());
assert!(res.unwrap_err().to_string().contains("unknown --format"));
}
}