1use 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
50pub const MIN_SUPPORTED_SCHEMA: u32 = 16;
56
57#[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#[must_use]
101pub fn schema_in_supported_range(v: u32) -> bool {
102 v >= MIN_SUPPORTED_SCHEMA && v <= MAX_SUPPORTED_SCHEMA
103}
104
105const DEFAULT_BUDGET_TOKENS: usize = 4096;
108
109const TOKENS_PER_CHAR: f32 = 0.25;
114
115const UNAVAILABLE: &str = "<unavailable>";
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum BootFormat {
123 Text,
126 Json,
129 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#[derive(Args, Debug)]
151pub struct BootArgs {
152 #[arg(long)]
156 pub namespace: Option<String>,
157 #[arg(long, default_value_t = 10)]
159 pub limit: usize,
160 #[arg(long, default_value_t = DEFAULT_BUDGET_TOKENS)]
164 pub budget_tokens: usize,
165 #[arg(long, default_value = "text")]
167 pub format: String,
168 #[arg(long, default_value_t = false)]
172 pub no_header: bool,
173 #[arg(long, default_value_t = false)]
178 pub quiet: bool,
179 #[arg(long, value_name = "PATH")]
183 pub cwd: Option<std::path::PathBuf>,
184}
185
186fn 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 crate::cli::helpers::resolve_namespace(None)
199}
200
201fn fetch_boot_memories(
206 conn: &rusqlite::Connection,
207 namespace: &str,
208 limit: usize,
209) -> Result<(Vec<models::Memory>, String)> {
210 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 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
244fn 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273enum BootStatus {
274 OkLoaded,
276 InfoFallback,
278 InfoEmpty,
281 WarnDbUnavailable,
286 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
305fn 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 let numeric = u32::try_from(v).ok();
323 (display, numeric)
324 }
325 Err(_) => (UNAVAILABLE.to_string(), None),
326 }
327}
328
329fn 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
342struct 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 note: String,
373 status: BootStatus,
374 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 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 let embedder = if feature_tier.config().embedding_model.is_none() {
415 "none".to_string()
416 } else {
417 resolved_emb.model.clone()
418 };
419
420 let llm = if resolved_llm.is_ollama_native() {
429 resolved_llm.model.clone()
430 } else {
431 resolved_llm.display_label()
432 };
433
434 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#[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 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 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, );
543 emit_status_header(out, &manifest, format)?;
544 }
545 return Ok(());
546 }
547 };
548
549 let (schema_version, schema_int) = read_schema_version(&conn);
552 let total_memories = count_live_memories(&conn);
553
554 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 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
683const REDACTED_TITLE: &str = "<redacted>";
688
689fn 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
707fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
1391 assert_eq!(parsed["schema_version"], UNAVAILABLE);
1392 assert_eq!(parsed["total_memories"], UNAVAILABLE);
1393 assert_eq!(parsed["schema_supported"], false);
1395 }
1396
1397 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 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 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 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 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 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 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 let _g = test_lock();
1576 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 let _g = test_lock();
1603 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 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 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 assert!(
1648 stdout.contains("# ai-memory boot: ok"),
1649 "manifest header should still appear when only redacting titles: {stdout}"
1650 );
1651 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 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 let _g = test_lock();
1674 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 assert!(
1689 stdout.contains("ns-redact-keep"),
1690 "namespace must still surface under redact_titles: {stdout}"
1691 );
1692 let row_line = stdout
1694 .lines()
1695 .find(|l| l.starts_with("- ["))
1696 .expect("body row must exist");
1697 assert!(
1699 row_line.starts_with("- [mid/"),
1700 "tier + id_short prefix must remain: {row_line}"
1701 );
1702 assert!(row_line.contains("p=5"), "priority must remain: {row_line}");
1704 assert!(
1706 row_line.contains(REDACTED_TITLE),
1707 "title slot must carry the redaction sentinel: {row_line}"
1708 );
1709 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 let _g = test_lock();
1722 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(); 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 #[test]
1755 fn boot_toon_format_emits_compact_body_with_header() {
1756 let _g = test_lock();
1759 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 assert!(stdout.contains("# ai-memory boot: ok"));
1775 assert!(stdout.contains("toon-row"));
1777 }
1778
1779 #[test]
1780 fn boot_toon_format_no_header_emits_body_only() {
1781 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 assert!(stdout.contains("row-x"));
1800 }
1801
1802 #[test]
1803 fn boot_json_no_header_emits_memories_only() {
1804 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 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 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 let saved_cwd = std::env::current_dir().unwrap();
1845 let mut out = env.output();
1846 run(&db_path, &args, &cfg, &mut out).unwrap();
1847 std::env::set_current_dir(&saved_cwd).unwrap();
1849 let stdout = std::str::from_utf8(&env.stdout).unwrap();
1850 assert!(stdout.contains("# ai-memory boot"));
1852 }
1853
1854 #[test]
1855 fn boot_redact_titles_json_output_replaces_titles() {
1856 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 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}