1use crate::cli::CliOutput;
40use crate::cli::helpers::{auto_namespace, human_age, id_short};
41use crate::config::AppConfig;
42use crate::{db, models, toon};
43use anyhow::Result;
44use clap::Args;
45use models::Tier;
46use std::path::Path;
47use std::time::Instant;
48
49pub const MIN_SUPPORTED_SCHEMA: u32 = 16;
55
56pub const MAX_SUPPORTED_SCHEMA: u32 = 20;
62
63#[must_use]
70pub fn schema_in_supported_range(v: u32) -> bool {
71 v >= MIN_SUPPORTED_SCHEMA && v <= MAX_SUPPORTED_SCHEMA
72}
73
74const DEFAULT_BUDGET_TOKENS: usize = 4096;
77
78const TOKENS_PER_CHAR: f32 = 0.25;
83
84const UNAVAILABLE: &str = "<unavailable>";
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum BootFormat {
92 Text,
95 Json,
98 Toon,
101}
102
103impl BootFormat {
104 fn parse(s: &str) -> Result<Self> {
105 match s {
106 "text" => Ok(Self::Text),
107 "json" => Ok(Self::Json),
108 "toon" | "toon-compact" | "toon_compact" => Ok(Self::Toon),
109 other => Err(anyhow::anyhow!(
110 "unknown --format value: {other} (expected: text | json | toon)"
111 )),
112 }
113 }
114}
115
116#[derive(Args, Debug)]
120pub struct BootArgs {
121 #[arg(long)]
125 pub namespace: Option<String>,
126 #[arg(long, default_value_t = 10)]
128 pub limit: usize,
129 #[arg(long, default_value_t = DEFAULT_BUDGET_TOKENS)]
133 pub budget_tokens: usize,
134 #[arg(long, default_value = "text")]
136 pub format: String,
137 #[arg(long, default_value_t = false)]
141 pub no_header: bool,
142 #[arg(long, default_value_t = false)]
147 pub quiet: bool,
148 #[arg(long, value_name = "PATH")]
152 pub cwd: Option<std::path::PathBuf>,
153}
154
155fn resolve_namespace(args: &BootArgs) -> String {
159 if let Some(ref ns) = args.namespace {
160 return ns.clone();
161 }
162 if let Some(ref cwd) = args.cwd {
163 let _ = std::env::set_current_dir(cwd);
164 }
165 auto_namespace()
166}
167
168fn fetch_boot_memories(
173 conn: &rusqlite::Connection,
174 namespace: &str,
175 limit: usize,
176) -> Result<(Vec<models::Memory>, String)> {
177 let primary = db::list(
179 conn,
180 Some(namespace),
181 None,
182 limit,
183 0,
184 None,
185 None,
186 None,
187 None,
188 None,
189 )?;
190 if !primary.is_empty() {
191 return Ok((primary, namespace.to_string()));
192 }
193 let fallback = db::list(
197 conn,
198 None,
199 Some(&Tier::Long),
200 limit,
201 0,
202 None,
203 None,
204 None,
205 None,
206 None,
207 )?;
208 Ok((fallback, String::new()))
209}
210
211fn clamp_to_budget(mems: Vec<models::Memory>, budget_tokens: usize) -> Vec<models::Memory> {
215 if budget_tokens == 0 || mems.is_empty() {
216 return mems;
217 }
218 let mut chars_so_far: usize = 0;
219 let mut out = Vec::with_capacity(mems.len());
220 for (idx, mem) in mems.into_iter().enumerate() {
221 let row_chars = mem.title.len() + mem.namespace.len() + 80;
225 let projected_tokens =
226 ((chars_so_far + row_chars) as f32 * TOKENS_PER_CHAR).ceil() as usize;
227 if idx > 0 && projected_tokens > budget_tokens {
228 break;
229 }
230 chars_so_far += row_chars;
231 out.push(mem);
232 }
233 out
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240enum BootStatus {
241 OkLoaded,
243 InfoFallback,
245 InfoEmpty,
248 WarnDbUnavailable,
253 WarnSchemaUnsupported { db_schema: u32 },
260}
261
262impl BootStatus {
263 fn label(self) -> &'static str {
264 match self {
265 Self::OkLoaded => "ok",
266 Self::InfoFallback | Self::InfoEmpty => "info",
267 Self::WarnDbUnavailable | Self::WarnSchemaUnsupported { .. } => "warn",
268 }
269 }
270}
271
272fn read_schema_version(conn: &rusqlite::Connection) -> (String, Option<u32>) {
279 match conn.query_row(
280 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
281 [],
282 |r| r.get::<_, i64>(0),
283 ) {
284 Ok(v) => {
285 let display = format!("v{v}");
286 let numeric = u32::try_from(v).ok();
290 (display, numeric)
291 }
292 Err(_) => (UNAVAILABLE.to_string(), None),
293 }
294}
295
296fn count_live_memories(conn: &rusqlite::Connection) -> String {
300 let now = chrono::Utc::now().to_rfc3339();
301 conn.query_row(
302 "SELECT COUNT(*) FROM memories WHERE expires_at IS NULL OR expires_at > ?1",
303 rusqlite::params![now],
304 |r| r.get::<_, i64>(0),
305 )
306 .map_or_else(|_| UNAVAILABLE.to_string(), |v| v.to_string())
307}
308
309struct BootManifest {
326 version: String,
327 db_path: String,
328 schema_version: String,
329 total_memories: String,
330 tier: String,
331 embedder: String,
332 reranker: String,
333 llm: String,
334 latency_ms: u128,
335 namespace: String,
336 count: usize,
337 note: String,
340 status: BootStatus,
341 schema_supported: bool,
349}
350
351impl BootManifest {
352 fn build(
353 status: BootStatus,
354 namespace: &str,
355 count: usize,
356 db_path: &Path,
357 app_config: &AppConfig,
358 schema_version: String,
359 total_memories: String,
360 latency_ms: u128,
361 schema_supported: bool,
362 ) -> Self {
363 let feature_tier = app_config.effective_tier(None);
367 let tier_config = feature_tier.config();
368 let embedder = tier_config
369 .embedding_model
370 .map_or_else(|| "none".to_string(), |m| m.hf_model_id().to_string());
371 let llm = tier_config
372 .llm_model
373 .map_or_else(|| "none".to_string(), |m| m.ollama_model_id().to_string());
374 let reranker = if tier_config.cross_encoder {
375 "ms-marco-MiniLM-L-6-v2".to_string()
376 } else {
377 "none".to_string()
378 };
379
380 let note = match status {
381 BootStatus::OkLoaded => format!(
382 "loaded {count} memor{plural} from ns={namespace}",
383 plural = if count == 1 { "y" } else { "ies" }
384 ),
385 BootStatus::InfoFallback => format!(
386 "namespace empty; loaded {count} memor{plural} from global Long tier fallback",
387 plural = if count == 1 { "y" } else { "ies" }
388 ),
389 BootStatus::InfoEmpty => format!(
390 "namespace '{namespace}' is empty and no global Long-tier fallback found — \
391 nothing to load (this is normal on a fresh install)"
392 ),
393 BootStatus::WarnDbUnavailable => format!(
394 "db unavailable at {} — proceeding without memory context. \
395 Run `ai-memory doctor` to diagnose. \
396 See https://github.com/alphaonedev/ai-memory-mcp/blob/main/docs/integrations/README.md",
397 db_path.display()
398 ),
399 BootStatus::WarnSchemaUnsupported { db_schema } => format!(
400 "db schema v{db_schema} unsupported by binary {bin_ver} \
401 (supports v{min}..v{max}); proceeding with degraded context. \
402 Run `ai-memory doctor` and consider upgrading.",
403 bin_ver = env!("CARGO_PKG_VERSION"),
404 min = MIN_SUPPORTED_SCHEMA,
405 max = MAX_SUPPORTED_SCHEMA,
406 ),
407 };
408
409 Self {
410 version: env!("CARGO_PKG_VERSION").to_string(),
411 db_path: db_path.display().to_string(),
412 schema_version,
413 total_memories,
414 tier: feature_tier.as_str().to_string(),
415 embedder,
416 reranker,
417 llm,
418 latency_ms,
419 namespace: namespace.to_string(),
420 count,
421 note,
422 status,
423 schema_supported,
424 }
425 }
426}
427
428#[allow(clippy::too_many_lines)]
430pub fn run(
431 db_path: &Path,
432 args: &BootArgs,
433 app_config: &AppConfig,
434 out: &mut CliOutput<'_>,
435) -> Result<()> {
436 let start = Instant::now();
437
438 let boot_cfg = app_config.effective_boot();
446 if !boot_cfg.effective_enabled() {
447 return Ok(());
448 }
449 let redact_titles = boot_cfg.effective_redact_titles();
450
451 let format = BootFormat::parse(&args.format)?;
452 let limit = args.limit.clamp(1, 50);
453 let namespace = resolve_namespace(args);
454
455 let conn = match db::open(db_path) {
459 Ok(c) => c,
460 Err(e) => {
461 if !args.quiet {
462 writeln!(
463 out.stderr,
464 "ai-memory boot: db unavailable at {}: {e}",
465 db_path.display()
466 )?;
467 }
468 if !args.no_header {
469 let manifest = BootManifest::build(
470 BootStatus::WarnDbUnavailable,
471 &namespace,
472 0,
473 db_path,
474 app_config,
475 UNAVAILABLE.to_string(),
476 UNAVAILABLE.to_string(),
477 start.elapsed().as_millis(),
478 false, );
480 emit_status_header(out, &manifest, format)?;
481 }
482 return Ok(());
483 }
484 };
485
486 let (schema_version, schema_int) = read_schema_version(&conn);
489 let total_memories = count_live_memories(&conn);
490
491 let schema_supported = schema_int.is_some_and(schema_in_supported_range);
499 if let Some(v) = schema_int
500 && !schema_in_supported_range(v)
501 {
502 if !args.no_header {
503 let manifest = BootManifest::build(
504 BootStatus::WarnSchemaUnsupported { db_schema: v },
505 &namespace,
506 0,
507 db_path,
508 app_config,
509 schema_version,
510 total_memories,
511 start.elapsed().as_millis(),
512 false,
513 );
514 emit_status_header(out, &manifest, format)?;
515 }
516 return Ok(());
517 }
518
519 let (mems, used_namespace) = fetch_boot_memories(&conn, &namespace, limit)?;
520 let mems = clamp_to_budget(mems, args.budget_tokens);
521 let fell_back = !mems.is_empty() && used_namespace.is_empty();
522
523 if mems.is_empty() {
524 if !args.no_header {
525 let manifest = BootManifest::build(
526 BootStatus::InfoEmpty,
527 &namespace,
528 0,
529 db_path,
530 app_config,
531 schema_version,
532 total_memories,
533 start.elapsed().as_millis(),
534 schema_supported,
535 );
536 emit_status_header(out, &manifest, format)?;
537 }
538 return Ok(());
539 }
540
541 let displayed_ns = if fell_back { "global" } else { &namespace };
542 let status = if fell_back {
543 BootStatus::InfoFallback
544 } else {
545 BootStatus::OkLoaded
546 };
547
548 match format {
549 BootFormat::Json => {
550 if args.no_header {
555 writeln!(
556 out.stdout,
557 "{}",
558 serde_json::to_string(&serde_json::json!({
559 "memories": render_memories_for_emit(&mems, redact_titles)
560 }))?
561 )?;
562 } else {
563 let manifest = BootManifest::build(
564 status,
565 displayed_ns,
566 mems.len(),
567 db_path,
568 app_config,
569 schema_version,
570 total_memories,
571 start.elapsed().as_millis(),
572 schema_supported,
573 );
574 emit_json_with_status(out, &manifest, &mems, fell_back, redact_titles)?;
575 }
576 }
577 BootFormat::Text => {
578 if !args.no_header {
579 let manifest = BootManifest::build(
580 status,
581 displayed_ns,
582 mems.len(),
583 db_path,
584 app_config,
585 schema_version,
586 total_memories,
587 start.elapsed().as_millis(),
588 schema_supported,
589 );
590 emit_status_header(out, &manifest, format)?;
591 }
592 emit_text(out, &mems, redact_titles)?;
593 }
594 BootFormat::Toon => {
595 if !args.no_header {
596 let manifest = BootManifest::build(
597 status,
598 displayed_ns,
599 mems.len(),
600 db_path,
601 app_config,
602 schema_version,
603 total_memories,
604 start.elapsed().as_millis(),
605 schema_supported,
606 );
607 emit_status_header(out, &manifest, format)?;
608 }
609 emit_toon(out, &mems, redact_titles)?;
610 }
611 }
612
613 Ok(())
614}
615
616const REDACTED_TITLE: &str = "<redacted>";
621
622fn render_memories_for_emit(mems: &[models::Memory], redact_titles: bool) -> Vec<models::Memory> {
628 if !redact_titles {
629 return mems.to_vec();
630 }
631 mems.iter()
632 .map(|m| {
633 let mut redacted = m.clone();
634 redacted.title = REDACTED_TITLE.to_string();
635 redacted
636 })
637 .collect()
638}
639
640fn emit_status_header(
659 out: &mut CliOutput<'_>,
660 manifest: &BootManifest,
661 format: BootFormat,
662) -> Result<()> {
663 match format {
664 BootFormat::Json => {
665 writeln!(
666 out.stdout,
667 "{}",
668 serde_json::json!({
669 "status": manifest.status.label(),
670 "version": manifest.version,
671 "db_path": manifest.db_path,
672 "schema_version": manifest.schema_version,
673 "schema_supported": manifest.schema_supported,
674 "total_memories": manifest.total_memories,
675 "tier": manifest.tier,
676 "embedder": manifest.embedder,
677 "reranker": manifest.reranker,
678 "llm": manifest.llm,
679 "latency_ms": manifest.latency_ms,
680 "namespace": manifest.namespace,
681 "count": manifest.count,
682 "note": manifest.note,
683 })
684 )?;
685 }
686 _ => {
687 writeln!(out.stdout, "# ai-memory boot: {}", manifest.status.label())?;
691 writeln!(out.stdout, "# version: {}", manifest.version)?;
692 writeln!(
693 out.stdout,
694 "# db: {} (schema={}, {} memories)",
695 manifest.db_path, manifest.schema_version, manifest.total_memories
696 )?;
697 writeln!(
698 out.stdout,
699 "# tier: {} (embedder={}, reranker={}, llm={})",
700 manifest.tier, manifest.embedder, manifest.reranker, manifest.llm
701 )?;
702 writeln!(out.stdout, "# latency: {}ms", manifest.latency_ms)?;
703 match manifest.status {
707 BootStatus::OkLoaded => {
708 writeln!(
709 out.stdout,
710 "# namespace: {} (loaded {} memor{})",
711 manifest.namespace,
712 manifest.count,
713 if manifest.count == 1 { "y" } else { "ies" }
714 )?;
715 }
716 BootStatus::InfoFallback => {
717 writeln!(
718 out.stdout,
719 "# namespace: {} (fallback: loaded {} memor{} from global Long tier)",
720 manifest.namespace,
721 manifest.count,
722 if manifest.count == 1 { "y" } else { "ies" }
723 )?;
724 }
725 BootStatus::InfoEmpty => {
726 writeln!(
727 out.stdout,
728 "# namespace: {} (empty — nothing to load; this is normal on a fresh install)",
729 manifest.namespace
730 )?;
731 }
732 BootStatus::WarnDbUnavailable => {
733 writeln!(
734 out.stdout,
735 "# namespace: {} (db unavailable — see `ai-memory doctor`)",
736 manifest.namespace
737 )?;
738 }
739 BootStatus::WarnSchemaUnsupported { db_schema } => {
740 writeln!(
746 out.stdout,
747 "# namespace: {} (db schema v{} unsupported by binary {} \
748 (supports v{}..v{}); proceeding with degraded context. \
749 Run `ai-memory doctor` and consider upgrading.)",
750 manifest.namespace,
751 db_schema,
752 manifest.version,
753 MIN_SUPPORTED_SCHEMA,
754 MAX_SUPPORTED_SCHEMA,
755 )?;
756 }
757 }
758 }
759 }
760 Ok(())
761}
762
763fn emit_text(out: &mut CliOutput<'_>, mems: &[models::Memory], redact_titles: bool) -> Result<()> {
764 for mem in mems {
765 let age = human_age(&mem.updated_at);
766 let title: &str = if redact_titles {
773 REDACTED_TITLE
774 } else {
775 &mem.title
776 };
777 writeln!(
778 out.stdout,
779 "- [{}/{}] {} (ns={}, p={}, {})",
780 mem.tier,
781 id_short(&mem.id),
782 title,
783 mem.namespace,
784 mem.priority,
785 age
786 )?;
787 }
788 Ok(())
789}
790
791fn emit_json_with_status(
792 out: &mut CliOutput<'_>,
793 manifest: &BootManifest,
794 mems: &[models::Memory],
795 fell_back: bool,
796 redact_titles: bool,
797) -> Result<()> {
798 let rendered = render_memories_for_emit(mems, redact_titles);
803 let body = serde_json::json!({
804 "status": manifest.status.label(),
805 "version": manifest.version,
806 "db_path": manifest.db_path,
807 "schema_version": manifest.schema_version,
808 "schema_supported": manifest.schema_supported,
809 "total_memories": manifest.total_memories,
810 "tier": manifest.tier,
811 "embedder": manifest.embedder,
812 "reranker": manifest.reranker,
813 "llm": manifest.llm,
814 "latency_ms": manifest.latency_ms,
815 "namespace": manifest.namespace,
816 "count": manifest.count,
817 "note": manifest.note,
818 "fell_back_to_global": fell_back,
819 "memories": rendered,
820 });
821 writeln!(out.stdout, "{}", serde_json::to_string(&body)?)?;
822 Ok(())
823}
824
825fn emit_toon(out: &mut CliOutput<'_>, mems: &[models::Memory], redact_titles: bool) -> Result<()> {
826 let rendered = render_memories_for_emit(mems, redact_titles);
830 let body = serde_json::json!({
831 "memories": rendered,
832 "count": rendered.len(),
833 });
834 let toon_str = toon::memories_to_toon(&body, true);
835 writeln!(out.stdout, "{toon_str}")?;
836 Ok(())
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842 use crate::cli::test_utils::{TestEnv, seed_memory};
843
844 fn default_args() -> BootArgs {
845 BootArgs {
846 namespace: None,
847 limit: 10,
848 budget_tokens: DEFAULT_BUDGET_TOKENS,
849 format: "text".to_string(),
850 no_header: false,
851 quiet: false,
852 cwd: None,
853 }
854 }
855
856 fn default_config() -> AppConfig {
857 AppConfig::default()
858 }
859
860 fn test_lock() -> std::sync::MutexGuard<'static, ()> {
867 use std::sync::{Mutex, OnceLock};
868 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
869 LOCK.get_or_init(|| Mutex::new(()))
870 .lock()
871 .unwrap_or_else(std::sync::PoisonError::into_inner)
872 }
873
874 #[test]
875 fn boot_format_parse_accepts_aliases() {
876 assert_eq!(BootFormat::parse("text").unwrap(), BootFormat::Text);
877 assert_eq!(BootFormat::parse("json").unwrap(), BootFormat::Json);
878 assert_eq!(BootFormat::parse("toon").unwrap(), BootFormat::Toon);
879 assert_eq!(BootFormat::parse("toon-compact").unwrap(), BootFormat::Toon);
880 assert_eq!(BootFormat::parse("toon_compact").unwrap(), BootFormat::Toon);
881 assert!(BootFormat::parse("yaml").is_err());
882 }
883
884 #[test]
885 fn boot_emits_ok_header_with_loaded_memories() {
886 let _g = test_lock();
887 let mut env = TestEnv::fresh();
888 seed_memory(&env.db_path, "ns-x", "first", "content one");
889 seed_memory(&env.db_path, "ns-x", "second", "content two");
890 seed_memory(&env.db_path, "ns-y", "elsewhere", "content three");
891 let db_path = env.db_path.clone();
892 let cfg = default_config();
893 let mut args = default_args();
894 args.namespace = Some("ns-x".to_string());
895 let mut out = env.output();
896 run(&db_path, &args, &cfg, &mut out).unwrap();
897 let stdout = std::str::from_utf8(&env.stdout).unwrap();
898 assert!(
900 stdout.contains("# ai-memory boot: ok"),
901 "expected ok status header, got: {stdout}"
902 );
903 assert!(
904 stdout.contains("# version:"),
905 "manifest missing version line: {stdout}"
906 );
907 assert!(
908 stdout.contains("# db:"),
909 "manifest missing db line: {stdout}"
910 );
911 assert!(
912 stdout.contains("# tier:"),
913 "manifest missing tier line: {stdout}"
914 );
915 assert!(
916 stdout.contains("# latency:"),
917 "manifest missing latency line: {stdout}"
918 );
919 assert!(
920 stdout.contains("# namespace:") && stdout.contains("ns-x"),
921 "namespace line should contain ns-x: {stdout}"
922 );
923 assert!(stdout.contains("loaded 2 memories"));
924 assert!(stdout.contains("first"));
925 assert!(stdout.contains("second"));
926 assert!(!stdout.contains("elsewhere"));
927 }
928
929 #[test]
930 fn boot_header_includes_version() {
931 let _g = test_lock();
932 let mut env = TestEnv::fresh();
933 seed_memory(&env.db_path, "ns-v", "row", "x");
934 let db_path = env.db_path.clone();
935 let cfg = default_config();
936 let mut args = default_args();
937 args.namespace = Some("ns-v".to_string());
938 let mut out = env.output();
939 run(&db_path, &args, &cfg, &mut out).unwrap();
940 let stdout = std::str::from_utf8(&env.stdout).unwrap();
941 let version = env!("CARGO_PKG_VERSION");
944 assert!(
945 stdout.contains(version),
946 "expected version `{version}` in header: {stdout}"
947 );
948 }
949
950 #[test]
951 fn boot_header_includes_db_path() {
952 let _g = test_lock();
953 let mut env = TestEnv::fresh();
954 seed_memory(&env.db_path, "ns-d", "row", "x");
955 let db_path = env.db_path.clone();
956 let cfg = default_config();
957 let mut args = default_args();
958 args.namespace = Some("ns-d".to_string());
959 let mut out = env.output();
960 run(&db_path, &args, &cfg, &mut out).unwrap();
961 let stdout = std::str::from_utf8(&env.stdout).unwrap();
962 let db_str = db_path.display().to_string();
963 assert!(
964 stdout.contains(&db_str),
965 "expected db path `{db_str}` in header: {stdout}"
966 );
967 }
968
969 #[test]
970 fn boot_header_includes_schema_version() {
971 let _g = test_lock();
972 let mut env = TestEnv::fresh();
973 seed_memory(&env.db_path, "ns-s", "row", "x");
974 let db_path = env.db_path.clone();
975 let cfg = default_config();
976 let mut args = default_args();
977 args.namespace = Some("ns-s".to_string());
978 let mut out = env.output();
979 run(&db_path, &args, &cfg, &mut out).unwrap();
980 let stdout = std::str::from_utf8(&env.stdout).unwrap();
981 assert!(
982 stdout.contains("schema=v"),
983 "expected `schema=vN` in header: {stdout}"
984 );
985 }
986
987 #[test]
988 fn boot_header_includes_latency_ms() {
989 let _g = test_lock();
990 let mut env = TestEnv::fresh();
991 seed_memory(&env.db_path, "ns-lat", "row", "x");
992 let db_path = env.db_path.clone();
993 let cfg = default_config();
994 let mut args = default_args();
995 args.namespace = Some("ns-lat".to_string());
996 let mut out = env.output();
997 run(&db_path, &args, &cfg, &mut out).unwrap();
998 let stdout = std::str::from_utf8(&env.stdout).unwrap();
999 let latency_line = stdout
1002 .lines()
1003 .find(|l| l.contains("latency:"))
1004 .expect("latency line must exist in manifest");
1005 let suffix = latency_line.split("latency:").nth(1).unwrap().trim();
1006 assert!(
1007 suffix.ends_with("ms"),
1008 "latency value should end with `ms`: {suffix}"
1009 );
1010 let num_str = suffix.trim_end_matches("ms");
1011 assert!(
1012 num_str.parse::<u128>().is_ok(),
1013 "latency must parse as integer ms: {num_str}"
1014 );
1015 }
1016
1017 #[test]
1018 fn boot_json_includes_all_manifest_fields() {
1019 let _g = test_lock();
1020 let mut env = TestEnv::fresh();
1021 seed_memory(&env.db_path, "ns-jm", "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-jm".to_string());
1026 args.format = "json".to_string();
1027 let mut out = env.output();
1028 run(&db_path, &args, &cfg, &mut out).unwrap();
1029 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1030 let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1031 assert_eq!(parsed["status"], "ok");
1033 assert_eq!(parsed["namespace"], "ns-jm");
1034 assert_eq!(parsed["count"], 1);
1035 assert_eq!(parsed["fell_back_to_global"], false);
1036 assert!(parsed["memories"].is_array());
1037 for key in [
1039 "version",
1040 "db_path",
1041 "schema_version",
1042 "total_memories",
1043 "tier",
1044 "embedder",
1045 "reranker",
1046 "llm",
1047 "latency_ms",
1048 "note",
1049 ] {
1050 assert!(
1051 parsed.get(key).is_some(),
1052 "json output missing manifest field `{key}`: {stdout}"
1053 );
1054 }
1055 assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
1056 assert!(parsed["latency_ms"].is_number());
1057 assert!(
1058 parsed["schema_version"]
1059 .as_str()
1060 .unwrap_or("")
1061 .starts_with('v'),
1062 "schema_version should be `vN` form"
1063 );
1064 }
1065
1066 #[test]
1067 fn boot_respects_limit() {
1068 let _g = test_lock();
1069 let mut env = TestEnv::fresh();
1070 for i in 0..5 {
1071 seed_memory(&env.db_path, "ns-l", &format!("m{i}"), "x");
1072 }
1073 let db_path = env.db_path.clone();
1074 let cfg = default_config();
1075 let mut args = default_args();
1076 args.namespace = Some("ns-l".to_string());
1077 args.limit = 2;
1078 let mut out = env.output();
1079 run(&db_path, &args, &cfg, &mut out).unwrap();
1080 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1081 assert!(stdout.contains("loaded 2 memories"));
1082 let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
1083 assert_eq!(row_count, 2, "expected 2 rows, got {row_count}: {stdout}");
1084 }
1085
1086 #[test]
1087 fn boot_no_header_with_flag_suppresses_status() {
1088 let _g = test_lock();
1089 let mut env = TestEnv::fresh();
1090 seed_memory(&env.db_path, "ns-h", "row-one", "x");
1091 let db_path = env.db_path.clone();
1092 let cfg = default_config();
1093 let mut args = default_args();
1094 args.namespace = Some("ns-h".to_string());
1095 args.no_header = true;
1096 let mut out = env.output();
1097 run(&db_path, &args, &cfg, &mut out).unwrap();
1098 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1099 assert!(!stdout.contains("# ai-memory boot"));
1100 assert!(stdout.contains("row-one"));
1101 }
1102
1103 #[test]
1104 fn boot_json_format_emits_status_and_memories() {
1105 let _g = test_lock();
1106 let mut env = TestEnv::fresh();
1107 seed_memory(&env.db_path, "ns-j", "row", "x");
1108 let db_path = env.db_path.clone();
1109 let cfg = default_config();
1110 let mut args = default_args();
1111 args.namespace = Some("ns-j".to_string());
1112 args.format = "json".to_string();
1113 let mut out = env.output();
1114 run(&db_path, &args, &cfg, &mut out).unwrap();
1115 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1116 let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1117 assert_eq!(parsed["status"], "ok");
1118 assert_eq!(parsed["namespace"], "ns-j");
1119 assert_eq!(parsed["count"], 1);
1120 assert_eq!(parsed["fell_back_to_global"], false);
1121 assert!(parsed["memories"].is_array());
1122 }
1123
1124 #[test]
1125 fn boot_quiet_with_unreachable_db_emits_warn_header_no_stderr() {
1126 let _g = test_lock();
1132 let mut env = TestEnv::fresh();
1133 let bad_path = env
1134 .db_path
1135 .parent()
1136 .unwrap()
1137 .join("subdir/that/does/not/exist/db.sqlite");
1138 let cfg = default_config();
1139 let mut args = default_args();
1140 args.quiet = true;
1141 let mut out = env.output();
1142 run(&bad_path, &args, &cfg, &mut out).unwrap();
1143 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1144 assert!(
1145 stdout.contains("# ai-memory boot: warn"),
1146 "warn header should always appear under --quiet: {stdout}"
1147 );
1148 assert!(
1149 stdout.contains("db unavailable"),
1150 "header should explain the warning cause: {stdout}"
1151 );
1152 assert!(
1154 stdout.contains("# version:"),
1155 "warn manifest should still carry version: {stdout}"
1156 );
1157 assert!(
1158 stdout.contains(env!("CARGO_PKG_VERSION")),
1159 "warn manifest version should be CARGO_PKG_VERSION: {stdout}"
1160 );
1161 assert!(
1162 stdout.contains("# tier:"),
1163 "warn manifest should still carry tier: {stdout}"
1164 );
1165 assert!(
1166 stdout.contains("# latency:"),
1167 "warn manifest should still carry latency: {stdout}"
1168 );
1169 assert!(
1171 stdout.contains(UNAVAILABLE),
1172 "warn manifest should mark unreachable fields as <unavailable>: {stdout}"
1173 );
1174 assert!(
1175 env.stderr.is_empty(),
1176 "stderr should be silent under --quiet"
1177 );
1178 }
1179
1180 #[test]
1181 fn boot_db_unavailable_without_quiet_writes_to_stderr() {
1182 let _g = test_lock();
1183 let mut env = TestEnv::fresh();
1184 let bad_path = env
1185 .db_path
1186 .parent()
1187 .unwrap()
1188 .join("subdir/that/does/not/exist/db.sqlite");
1189 let cfg = default_config();
1190 let args = default_args();
1191 let mut out = env.output();
1193 run(&bad_path, &args, &cfg, &mut out).unwrap();
1194 let stderr = std::str::from_utf8(&env.stderr).unwrap();
1195 assert!(
1196 stderr.contains("ai-memory boot: db unavailable"),
1197 "stderr should carry the diagnostic without --quiet: {stderr}"
1198 );
1199 }
1200
1201 #[test]
1202 fn boot_quiet_with_no_header_silent_for_legacy_wrappers() {
1203 let _g = test_lock();
1206 let mut env = TestEnv::fresh();
1207 let bad_path = env
1208 .db_path
1209 .parent()
1210 .unwrap()
1211 .join("subdir/that/does/not/exist/db.sqlite");
1212 let cfg = default_config();
1213 let mut args = default_args();
1214 args.quiet = true;
1215 args.no_header = true;
1216 let mut out = env.output();
1217 run(&bad_path, &args, &cfg, &mut out).unwrap();
1218 assert!(env.stdout.is_empty());
1219 assert!(env.stderr.is_empty());
1220 }
1221
1222 #[test]
1223 fn boot_falls_back_to_long_tier_when_namespace_empty() {
1224 let _g = test_lock();
1225 let mut env = TestEnv::fresh();
1226 let id = seed_memory(&env.db_path, "other", "long-tier-row", "x");
1227 let conn = db::open(&env.db_path).unwrap();
1228 conn.execute(
1229 "UPDATE memories SET tier='long' WHERE id=?1",
1230 rusqlite::params![id],
1231 )
1232 .unwrap();
1233 drop(conn);
1234 let db_path = env.db_path.clone();
1235 let cfg = default_config();
1236 let mut args = default_args();
1237 args.namespace = Some("nonexistent-ns".to_string());
1238 let mut out = env.output();
1239 run(&db_path, &args, &cfg, &mut out).unwrap();
1240 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1241 assert!(
1242 stdout.contains("# ai-memory boot: info") && stdout.contains("fallback"),
1243 "expected info/fallback status: {stdout}"
1244 );
1245 assert!(stdout.contains("long-tier-row"));
1246 }
1247
1248 #[test]
1249 fn boot_empty_namespace_emits_info_empty_status() {
1250 let _g = test_lock();
1251 let mut env = TestEnv::fresh();
1252 let db_path = env.db_path.clone();
1253 let cfg = default_config();
1254 let mut args = default_args();
1255 args.namespace = Some("nothing-here".to_string());
1256 let mut out = env.output();
1257 run(&db_path, &args, &cfg, &mut out).unwrap();
1258 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1259 assert!(
1260 stdout.contains("# ai-memory boot: info")
1261 && stdout.contains("nothing-here")
1262 && stdout.contains("empty"),
1263 "info/empty header expected: {stdout}"
1264 );
1265 }
1266
1267 #[test]
1268 fn boot_budget_tokens_clamps_output() {
1269 let _g = test_lock();
1270 let mut env = TestEnv::fresh();
1271 for i in 0..20 {
1272 seed_memory(
1273 &env.db_path,
1274 "ns-budget",
1275 &format!("memory number {i} with a moderate-length title"),
1276 "x",
1277 );
1278 }
1279 let db_path = env.db_path.clone();
1280 let cfg = default_config();
1281 let mut args = default_args();
1282 args.namespace = Some("ns-budget".to_string());
1283 args.limit = 50;
1284 args.budget_tokens = 100;
1285 let mut out = env.output();
1286 run(&db_path, &args, &cfg, &mut out).unwrap();
1287 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1288 let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
1289 assert!(
1290 row_count >= 1 && row_count < 20,
1291 "budget_tokens=100 should clamp to fewer than 20 rows; got {row_count}\noutput:\n{stdout}"
1292 );
1293 }
1294
1295 #[test]
1296 fn boot_json_warn_status_when_db_unavailable() {
1297 let _g = test_lock();
1298 let mut env = TestEnv::fresh();
1299 let bad_path = env
1300 .db_path
1301 .parent()
1302 .unwrap()
1303 .join("subdir/that/does/not/exist/db.sqlite");
1304 let cfg = default_config();
1305 let mut args = default_args();
1306 args.format = "json".to_string();
1307 args.quiet = true;
1308 let mut out = env.output();
1309 run(&bad_path, &args, &cfg, &mut out).unwrap();
1310 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1311 let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1312 assert_eq!(parsed["status"], "warn");
1313 assert_eq!(parsed["count"], 0);
1314 assert!(parsed["note"].as_str().unwrap().contains("db unavailable"));
1315 assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
1318 assert_eq!(parsed["schema_version"], UNAVAILABLE);
1319 assert_eq!(parsed["total_memories"], UNAVAILABLE);
1320 assert_eq!(parsed["schema_supported"], false);
1322 }
1323
1324 fn override_schema_version(db_path: &std::path::Path, v: i64) {
1338 let conn = rusqlite::Connection::open(db_path).expect("rusqlite::open");
1339 conn.execute("DELETE FROM schema_version", []).unwrap();
1340 conn.execute(
1341 "INSERT INTO schema_version (version) VALUES (?1)",
1342 rusqlite::params![v],
1343 )
1344 .unwrap();
1345 }
1346
1347 #[test]
1348 fn boot_warns_on_schema_above_max() {
1349 let _g = test_lock();
1350 let mut env = TestEnv::fresh();
1351 seed_memory(&env.db_path, "ns-drift", "row", "x");
1352 override_schema_version(&env.db_path, i64::from(MAX_SUPPORTED_SCHEMA) + 1);
1356 let db_path = env.db_path.clone();
1357 let cfg = default_config();
1358 let mut args = default_args();
1359 args.namespace = Some("ns-drift".to_string());
1360 let mut out = env.output();
1361 run(&db_path, &args, &cfg, &mut out).unwrap();
1362 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1363 assert!(
1364 stdout.contains("# ai-memory boot: warn"),
1365 "expected warn header for schema drift: {stdout}"
1366 );
1367 assert!(
1368 stdout.contains("unsupported by binary"),
1369 "expected schema-drift message text: {stdout}"
1370 );
1371 assert!(
1372 stdout.contains(&format!(
1373 "v{}..v{}",
1374 MIN_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA
1375 )),
1376 "expected supported range in message: {stdout}"
1377 );
1378 }
1379
1380 #[test]
1381 fn boot_warns_on_schema_below_min() {
1382 assert!(
1391 !schema_in_supported_range(MIN_SUPPORTED_SCHEMA - 1),
1392 "schemas below MIN must be reported as unsupported"
1393 );
1394 }
1395
1396 #[test]
1397 fn schema_below_min_is_unsupported() {
1398 assert!(!schema_in_supported_range(0));
1401 assert!(!schema_in_supported_range(MIN_SUPPORTED_SCHEMA - 1));
1402 assert!(schema_in_supported_range(MIN_SUPPORTED_SCHEMA));
1403 assert!(schema_in_supported_range(MAX_SUPPORTED_SCHEMA));
1404 assert!(!schema_in_supported_range(MAX_SUPPORTED_SCHEMA + 1));
1405 assert!(!schema_in_supported_range(u32::MAX));
1406 }
1407
1408 #[test]
1409 fn boot_ok_for_schema_at_min() {
1410 let _g = test_lock();
1411 let mut env = TestEnv::fresh();
1412 seed_memory(&env.db_path, "ns-min", "row", "x");
1413 override_schema_version(&env.db_path, i64::from(MIN_SUPPORTED_SCHEMA));
1414 let db_path = env.db_path.clone();
1415 let cfg = default_config();
1416 let mut args = default_args();
1417 args.namespace = Some("ns-min".to_string());
1418 let mut out = env.output();
1419 run(&db_path, &args, &cfg, &mut out).unwrap();
1420 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1421 assert!(
1422 stdout.contains("# ai-memory boot: ok"),
1423 "MIN boundary should be supported (not warn): {stdout}"
1424 );
1425 }
1426
1427 #[test]
1428 fn boot_ok_for_schema_at_max() {
1429 let _g = test_lock();
1430 let mut env = TestEnv::fresh();
1431 seed_memory(&env.db_path, "ns-max", "row", "x");
1432 override_schema_version(&env.db_path, i64::from(MAX_SUPPORTED_SCHEMA));
1433 let db_path = env.db_path.clone();
1434 let cfg = default_config();
1435 let mut args = default_args();
1436 args.namespace = Some("ns-max".to_string());
1437 let mut out = env.output();
1438 run(&db_path, &args, &cfg, &mut out).unwrap();
1439 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1440 assert!(
1441 stdout.contains("# ai-memory boot: ok"),
1442 "MAX boundary should be supported (not warn): {stdout}"
1443 );
1444 }
1445
1446 #[test]
1447 fn boot_json_includes_schema_supported_flag() {
1448 let _g = test_lock();
1450 let mut env = TestEnv::fresh();
1451 seed_memory(&env.db_path, "ns-ssj", "row", "x");
1452 let db_path = env.db_path.clone();
1453 let cfg = default_config();
1454 let mut args = default_args();
1455 args.namespace = Some("ns-ssj".to_string());
1456 args.format = "json".to_string();
1457 let mut out = env.output();
1458 run(&db_path, &args, &cfg, &mut out).unwrap();
1459 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1460 let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
1461 assert_eq!(
1462 parsed["schema_supported"], true,
1463 "happy path → schema_supported=true: {stdout}"
1464 );
1465
1466 let mut env2 = TestEnv::fresh();
1468 seed_memory(&env2.db_path, "ns-ssj2", "row", "x");
1469 override_schema_version(&env2.db_path, i64::from(MAX_SUPPORTED_SCHEMA) + 1);
1470 let db_path2 = env2.db_path.clone();
1471 let mut args2 = default_args();
1472 args2.namespace = Some("ns-ssj2".to_string());
1473 args2.format = "json".to_string();
1474 let mut out2 = env2.output();
1475 run(&db_path2, &args2, &cfg, &mut out2).unwrap();
1476 let stdout2 = std::str::from_utf8(&env2.stdout).unwrap();
1477 let parsed2: serde_json::Value = serde_json::from_str(stdout2.trim()).unwrap();
1478 assert_eq!(
1479 parsed2["schema_supported"], false,
1480 "drift path → schema_supported=false: {stdout2}"
1481 );
1482 assert_eq!(parsed2["status"], "warn");
1483 }
1484
1485 fn config_with_boot(enabled: Option<bool>, redact_titles: Option<bool>) -> AppConfig {
1490 let mut cfg = AppConfig::default();
1491 cfg.boot = Some(crate::config::BootConfig {
1492 enabled,
1493 redact_titles,
1494 });
1495 cfg
1496 }
1497
1498 #[test]
1499 fn boot_disabled_emits_nothing_at_all() {
1500 let _g = test_lock();
1503 unsafe {
1505 std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1506 }
1507 let mut env = TestEnv::fresh();
1508 seed_memory(&env.db_path, "ns-silent", "private-title", "secret");
1509 let db_path = env.db_path.clone();
1510 let cfg = config_with_boot(Some(false), None);
1511 let args = default_args();
1512 let mut out = env.output();
1513 run(&db_path, &args, &cfg, &mut out).unwrap();
1514 assert!(
1515 env.stdout.is_empty(),
1516 "stdout must be empty when boot is disabled: {:?}",
1517 std::str::from_utf8(&env.stdout)
1518 );
1519 assert!(
1520 env.stderr.is_empty(),
1521 "stderr must be empty when boot is disabled: {:?}",
1522 std::str::from_utf8(&env.stderr)
1523 );
1524 }
1525
1526 #[test]
1527 fn boot_disabled_via_env_var_overrides_config() {
1528 let _g = test_lock();
1530 unsafe {
1532 std::env::set_var("AI_MEMORY_BOOT_ENABLED", "0");
1533 }
1534 let mut env = TestEnv::fresh();
1535 seed_memory(&env.db_path, "ns-envoff", "row", "x");
1536 let db_path = env.db_path.clone();
1537 let cfg = config_with_boot(Some(true), None);
1538 let args = default_args();
1539 let mut out = env.output();
1540 let result = run(&db_path, &args, &cfg, &mut out);
1541 unsafe {
1545 std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1546 }
1547 result.unwrap();
1548 assert!(
1549 env.stdout.is_empty(),
1550 "env-var off must override config: stdout={:?}",
1551 std::str::from_utf8(&env.stdout)
1552 );
1553 assert!(env.stderr.is_empty());
1554 }
1555
1556 #[test]
1557 fn boot_redact_titles_replaces_titles_in_body() {
1558 let _g = test_lock();
1559 unsafe {
1561 std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1562 }
1563 let mut env = TestEnv::fresh();
1564 seed_memory(&env.db_path, "ns-redact", "secret-subject-alpha", "x");
1565 seed_memory(&env.db_path, "ns-redact", "secret-subject-beta", "y");
1566 let db_path = env.db_path.clone();
1567 let cfg = config_with_boot(Some(true), Some(true));
1568 let mut args = default_args();
1569 args.namespace = Some("ns-redact".to_string());
1570 let mut out = env.output();
1571 run(&db_path, &args, &cfg, &mut out).unwrap();
1572 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1573 assert!(
1575 stdout.contains("# ai-memory boot: ok"),
1576 "manifest header should still appear when only redacting titles: {stdout}"
1577 );
1578 let row_count = stdout.lines().filter(|l| l.starts_with("- [")).count();
1580 assert_eq!(row_count, 2, "expected 2 body rows: {stdout}");
1581 assert!(
1582 stdout.contains(REDACTED_TITLE),
1583 "expected redacted sentinel in body: {stdout}"
1584 );
1585 assert!(
1587 !stdout.contains("secret-subject-alpha"),
1588 "title leaked despite redact_titles=true: {stdout}"
1589 );
1590 assert!(
1591 !stdout.contains("secret-subject-beta"),
1592 "title leaked despite redact_titles=true: {stdout}"
1593 );
1594 }
1595
1596 #[test]
1597 fn boot_redact_titles_keeps_other_fields() {
1598 let _g = test_lock();
1601 unsafe {
1603 std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1604 }
1605 let mut env = TestEnv::fresh();
1606 seed_memory(&env.db_path, "ns-redact-keep", "private-title", "x");
1607 let db_path = env.db_path.clone();
1608 let cfg = config_with_boot(Some(true), Some(true));
1609 let mut args = default_args();
1610 args.namespace = Some("ns-redact-keep".to_string());
1611 let mut out = env.output();
1612 run(&db_path, &args, &cfg, &mut out).unwrap();
1613 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1614 assert!(
1616 stdout.contains("ns-redact-keep"),
1617 "namespace must still surface under redact_titles: {stdout}"
1618 );
1619 let row_line = stdout
1621 .lines()
1622 .find(|l| l.starts_with("- ["))
1623 .expect("body row must exist");
1624 assert!(
1626 row_line.starts_with("- [mid/"),
1627 "tier + id_short prefix must remain: {row_line}"
1628 );
1629 assert!(row_line.contains("p=5"), "priority must remain: {row_line}");
1631 assert!(
1633 row_line.contains(REDACTED_TITLE),
1634 "title slot must carry the redaction sentinel: {row_line}"
1635 );
1636 assert!(
1638 !stdout.contains("private-title"),
1639 "raw title must not leak: {stdout}"
1640 );
1641 }
1642
1643 #[test]
1644 fn boot_default_config_unchanged_behavior() {
1645 let _g = test_lock();
1649 unsafe {
1651 std::env::remove_var("AI_MEMORY_BOOT_ENABLED");
1652 }
1653 let mut env = TestEnv::fresh();
1654 seed_memory(&env.db_path, "ns-default", "visible-title", "x");
1655 let db_path = env.db_path.clone();
1656 let cfg = AppConfig::default(); let mut args = default_args();
1658 args.namespace = Some("ns-default".to_string());
1659 let mut out = env.output();
1660 run(&db_path, &args, &cfg, &mut out).unwrap();
1661 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1662 assert!(
1663 stdout.contains("# ai-memory boot: ok"),
1664 "default config → manifest header: {stdout}"
1665 );
1666 assert!(
1667 stdout.contains("visible-title"),
1668 "default config → title surfaces verbatim: {stdout}"
1669 );
1670 assert!(
1671 !stdout.contains(REDACTED_TITLE),
1672 "default config must NOT redact: {stdout}"
1673 );
1674 }
1675}