1use std::fmt::Write as _;
4use std::path::{Path, PathBuf};
5
6use crate::auto::strip_html_comments;
7use crate::config::MemoryConfig;
8use crate::error::{MemoryError, Result};
9use crate::prefix::{MemoryPrefix, ProjectTier, TierFile, TierKind};
10use crate::project_imports::{
11 ApprovalMode, ImportAllowlist, ImportState, canonical_or, resolve_imports,
12};
13use crate::project_walk::walk_ancestors;
14use crate::rules::scan_caliban_rules;
15
16const MAX_FILE_BYTES: usize = 256 * 1024;
19
20const AUTO_MAX_LINES: usize = 200;
22
23const AUTO_MAX_BYTES: usize = 25 * 1024;
26
27#[must_use]
29pub fn estimate_tokens(body: &str) -> usize {
30 body.chars().count() / 4
31}
32
33const SEED_MEMORY_MD: &str = "# Memory index\n\n_No memories yet. Add entries below as `- [title](slug.md) — one-line summary`._\n";
35
36const CONVENTIONS_BLOCK: &str = concat!(
39 "\n<!-- caliban: auto-memory conventions follow; do not delete -->\n",
40 "Write to this index when you learn something durable about the user, project, or environment. ",
41 "One topic per file, slug in kebab-case. Do not save transient task state, debug traces, or ",
42 "facts already in the repo. Keep this file ≤ 200 lines.\n",
43);
44
45pub async fn load(config: &MemoryConfig) -> Result<MemoryPrefix> {
56 let auto_disabled = config.disable_auto;
57
58 let auto_md = if auto_disabled {
61 None
62 } else {
63 Some(ensure_auto_memory(&config.auto_memory_dir).await?)
64 };
65
66 let global = read_optional(config.global_path.as_deref()).await?;
67 let auto_raw = if let Some(p) = auto_md.as_deref() {
68 read_optional_with_caps(Some(p), AUTO_MAX_LINES, AUTO_MAX_BYTES).await?
69 } else {
70 None
71 };
72
73 let global = global.map(post_process_static);
74 let auto = auto_raw.map(|mut t| {
77 if !t.body.contains("caliban: auto-memory conventions follow") {
78 if !t.body.ends_with('\n') {
79 t.body.push('\n');
80 }
81 t.body.push_str(CONVENTIONS_BLOCK);
82 }
83 t.body = strip_html_comments(&t.body);
84 t.estimated_tokens = estimate_tokens(&t.body);
85 t
86 });
87
88 let (project_legacy, project_tier) = if config.disable_walk {
91 let legacy = read_optional(config.project_path.as_deref())
92 .await?
93 .map(post_process_static);
94 (legacy, None)
95 } else {
96 let project_tier = build_project_tier(config).await?;
97 let legacy = project_tier.to_legacy_tier();
98 (legacy, Some(project_tier))
99 };
100
101 let mut prefix = MemoryPrefix {
102 global,
103 project: project_legacy,
104 project_tier,
105 auto,
106 estimated_tokens: 0,
107 truncated: false,
108 };
109
110 enforce_caps_and_budget(&mut prefix, config);
111 prefix.estimated_tokens = prefix.global.as_ref().map_or(0, |t| t.estimated_tokens)
112 + prefix.project.as_ref().map_or(0, |t| t.estimated_tokens)
113 + prefix.auto.as_ref().map_or(0, |t| t.estimated_tokens);
114
115 Ok(prefix)
116}
117
118async fn build_project_tier(config: &MemoryConfig) -> Result<ProjectTier> {
120 let mut tier = ProjectTier::default();
121
122 let mut walked = walk_ancestors(
123 &config.project_walk_root,
124 config.project_walk_stop,
125 &config.claude_md_excludes,
126 );
127 if config.additional_directories_claude_md {
128 for dir in &config.additional_dirs {
129 let extra = walk_ancestors(dir, config.project_walk_stop, &config.claude_md_excludes);
130 walked.extend(extra);
131 }
132 }
133
134 let approval_root = walked
139 .first()
140 .and_then(|p| p.parent().map(Path::to_path_buf))
141 .unwrap_or_else(|| config.project_walk_root.clone());
142
143 let allowlist = ImportAllowlist::load(&config.imports_allowlist_path).unwrap_or_default();
145 let approval = approval_mode_for(config);
146 let mut state = ImportState::new(approval_root, approval)
147 .with_allowlist(allowlist, Some(config.imports_allowlist_path.clone()));
148
149 for path in walked {
150 let Some(body) = read_capped(&path).await? else {
151 continue;
152 };
153 let resolved = resolve_imports(&body, &path, &mut state);
154 let stripped = strip_html_comments(&resolved);
155 let estimated_tokens = estimate_tokens(&stripped);
156 for imp in &state.loaded {
159 if tier.imports.iter().any(|f| canonical_or(&f.path) == *imp) {
160 continue;
161 }
162 if imp == &canonical_or(&path) {
163 continue;
164 }
165 tier.imports.push(TierFile {
169 path: imp.clone(),
170 body: String::new(),
171 estimated_tokens: 0,
172 truncated_bytes: 0,
173 });
174 }
175 tier.base_files.push(TierFile {
176 path,
177 body: stripped,
178 estimated_tokens,
179 truncated_bytes: 0,
180 });
181 }
182
183 let rule_set = scan_caliban_rules(&config.project_walk_root);
186 for rule in rule_set.always_active() {
187 let resolved = resolve_imports(&rule.body, &rule.path, &mut state);
188 let stripped = strip_html_comments(&resolved);
189 let estimated_tokens = estimate_tokens(&stripped);
190 tier.active_rules.push(TierFile {
191 path: rule.path.clone(),
192 body: stripped,
193 estimated_tokens,
194 truncated_bytes: 0,
195 });
196 }
197
198 Ok(tier)
199}
200
201fn approval_mode_for(config: &MemoryConfig) -> ApprovalMode<'static> {
202 if config.approve_imports {
203 ApprovalMode::AutoAllow
204 } else if config.non_interactive {
205 ApprovalMode::AutoDeny
206 } else {
207 ApprovalMode::AutoDeny
211 }
212}
213
214async fn read_capped(path: &Path) -> Result<Option<String>> {
215 match tokio::fs::metadata(path).await {
216 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
217 Err(e) => {
218 return Err(MemoryError::Io {
219 path: path.to_path_buf(),
220 source: e,
221 });
222 }
223 Ok(md) if !md.is_file() => return Ok(None),
224 Ok(_) => {}
225 }
226 let raw = tokio::fs::read(path).await.map_err(|e| MemoryError::Io {
227 path: path.to_path_buf(),
228 source: e,
229 })?;
230 let clamped = if raw.len() > MAX_FILE_BYTES {
231 &raw[..MAX_FILE_BYTES]
232 } else {
233 &raw[..]
234 };
235 Ok(Some(String::from_utf8_lossy(clamped).into_owned()))
236}
237
238fn post_process_static(mut t: TierFile) -> TierFile {
242 t.body = strip_html_comments(&t.body);
243 t.estimated_tokens = estimate_tokens(&t.body);
244 t
245}
246
247async fn read_optional(path: Option<&Path>) -> Result<Option<TierFile>> {
248 let Some(path) = path else { return Ok(None) };
249 match tokio::fs::metadata(path).await {
250 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
251 Err(e) => {
252 return Err(MemoryError::Io {
253 path: path.to_path_buf(),
254 source: e,
255 });
256 }
257 Ok(_) => {}
258 }
259 let raw = tokio::fs::read(path).await.map_err(|e| MemoryError::Io {
260 path: path.to_path_buf(),
261 source: e,
262 })?;
263 let truncated_bytes = raw.len().saturating_sub(MAX_FILE_BYTES);
264 let clamped = if truncated_bytes > 0 {
265 &raw[..MAX_FILE_BYTES]
266 } else {
267 &raw[..]
268 };
269 let body = String::from_utf8_lossy(clamped).into_owned();
270 Ok(Some(TierFile {
271 path: path.to_path_buf(),
272 estimated_tokens: estimate_tokens(&body),
273 body,
274 truncated_bytes,
275 }))
276}
277
278async fn read_optional_with_caps(
282 path: Option<&Path>,
283 max_lines: usize,
284 max_bytes: usize,
285) -> Result<Option<TierFile>> {
286 let Some(path) = path else { return Ok(None) };
287 match tokio::fs::metadata(path).await {
288 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
289 Err(e) => {
290 return Err(MemoryError::Io {
291 path: path.to_path_buf(),
292 source: e,
293 });
294 }
295 Ok(_) => {}
296 }
297 let raw_bytes = tokio::fs::read(path).await.map_err(|e| MemoryError::Io {
298 path: path.to_path_buf(),
299 source: e,
300 })?;
301 let raw = String::from_utf8_lossy(&raw_bytes).into_owned();
302 let total_bytes = raw.len();
303
304 let mut kept = String::new();
307 for (lines_used, line) in raw.split_inclusive('\n').enumerate() {
308 if lines_used >= max_lines {
309 break;
310 }
311 if kept.len() + line.len() > max_bytes {
312 break;
313 }
314 kept.push_str(line);
315 }
316 let truncated_bytes = total_bytes.saturating_sub(kept.len());
317
318 Ok(Some(TierFile {
319 path: path.to_path_buf(),
320 estimated_tokens: estimate_tokens(&kept),
321 body: kept,
322 truncated_bytes,
323 }))
324}
325
326async fn ensure_auto_memory(dir: &Path) -> Result<PathBuf> {
327 tokio::fs::create_dir_all(dir)
328 .await
329 .map_err(|e| MemoryError::AutoMemorySeed {
330 path: dir.to_path_buf(),
331 source: e,
332 })?;
333 let memory_md = dir.join("MEMORY.md");
334 if tokio::fs::try_exists(&memory_md).await.unwrap_or(false) {
335 return Ok(memory_md);
336 }
337 tokio::fs::write(&memory_md, SEED_MEMORY_MD)
338 .await
339 .map_err(|e| MemoryError::AutoMemorySeed {
340 path: memory_md.clone(),
341 source: e,
342 })?;
343 Ok(memory_md)
344}
345
346fn enforce_caps_and_budget(prefix: &mut MemoryPrefix, config: &MemoryConfig) {
356 if let Some(cap) = config.cap_tokens_auto {
357 let effective = config.effective_cap(cap, config.cap_tokens_claude_md);
358 if prefix
359 .auto
360 .as_ref()
361 .is_some_and(|t| t.estimated_tokens > effective)
362 {
363 truncate_tier(prefix, TierKind::Auto, effective);
364 prefix.truncated = true;
365 }
366 }
367 if let Some(cap) = config.cap_tokens_claude_md {
368 let effective = config.effective_cap(cap, config.cap_tokens_auto);
369 let global_t = prefix.global.as_ref().map_or(0, |t| t.estimated_tokens);
370 let project_t = prefix.project.as_ref().map_or(0, |t| t.estimated_tokens);
371 if global_t + project_t > effective {
372 let project_allowance = effective.saturating_sub(global_t);
374 if project_allowance < project_t {
375 truncate_tier(prefix, TierKind::Project, project_allowance);
376 prefix.truncated = true;
377 }
378 let project_after = prefix.project.as_ref().map_or(0, |t| t.estimated_tokens);
379 let global_allowance = effective.saturating_sub(project_after);
380 if global_allowance < global_t {
381 truncate_tier(prefix, TierKind::Global, global_allowance);
382 prefix.truncated = true;
383 }
384 }
385 }
386 enforce_budget(prefix, config.max_tokens);
387}
388
389fn enforce_budget(prefix: &mut MemoryPrefix, max_tokens: usize) {
393 let total = total_tokens(prefix);
394 if total <= max_tokens {
395 return;
396 }
397
398 for kind in [TierKind::Auto, TierKind::Project, TierKind::Global] {
400 if total_tokens(prefix) <= max_tokens {
401 return;
402 }
403 let allowance = max_tokens.saturating_sub(other_tokens(prefix, kind));
404 truncate_tier(prefix, kind, allowance);
405 prefix.truncated = true;
406 }
407
408 if total_tokens(prefix) > max_tokens
412 && let Some(g) = prefix.global.as_ref()
413 {
414 tracing::warn!(
415 target: caliban_common::tracing_targets::TARGET_MEMORY,
416 path = %g.path.display(),
417 estimated_tokens = g.estimated_tokens,
418 cap = max_tokens,
419 "global memory file exceeds budget even after truncation",
420 );
421 }
422}
423
424fn total_tokens(p: &MemoryPrefix) -> usize {
425 p.global.as_ref().map_or(0, |t| t.estimated_tokens)
426 + p.project.as_ref().map_or(0, |t| t.estimated_tokens)
427 + p.auto.as_ref().map_or(0, |t| t.estimated_tokens)
428}
429
430fn other_tokens(p: &MemoryPrefix, exclude: TierKind) -> usize {
431 let mut sum = 0;
432 if !matches!(exclude, TierKind::Global)
433 && let Some(t) = p.global.as_ref()
434 {
435 sum += t.estimated_tokens;
436 }
437 if !matches!(exclude, TierKind::Project)
438 && let Some(t) = p.project.as_ref()
439 {
440 sum += t.estimated_tokens;
441 }
442 if !matches!(exclude, TierKind::Auto)
443 && let Some(t) = p.auto.as_ref()
444 {
445 sum += t.estimated_tokens;
446 }
447 sum
448}
449
450const MARKER_RESERVE_BYTES: usize = 128;
453
454fn truncate_tier(prefix: &mut MemoryPrefix, kind: TierKind, max_tokens: usize) {
455 let slot: &mut Option<TierFile> = match kind {
456 TierKind::Global => &mut prefix.global,
457 TierKind::Project => &mut prefix.project,
458 TierKind::Auto => &mut prefix.auto,
459 };
460 let Some(tier) = slot.as_mut() else { return };
461 if tier.estimated_tokens <= max_tokens {
462 return;
463 }
464 let target_bytes = max_tokens
466 .saturating_mul(4)
467 .saturating_sub(MARKER_RESERVE_BYTES);
468 let original_len = tier.body.len();
469 if target_bytes >= original_len {
470 return;
471 }
472 let cut = tier.body[..target_bytes]
474 .rfind('\n')
475 .map_or(target_bytes, |i| i + 1);
476 let mut new_body = tier.body[..cut].to_string();
477 let shed = original_len - cut;
478 let _ = writeln!(
479 new_body,
480 "\n[truncated: {shed} bytes over budget; raise CALIBAN_MEMORY_BUDGET_TOKENS or trim]",
481 );
482 tier.truncated_bytes = shed;
483 tier.body = new_body;
484 tier.estimated_tokens = estimate_tokens(&tier.body);
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::prefix::{MemoryPrefix, TierFile, TierKind};
491
492 fn tier(body: &str) -> TierFile {
493 TierFile {
494 path: std::path::PathBuf::from("/tmp/x.md"),
495 estimated_tokens: estimate_tokens(body),
496 body: body.to_string(),
497 truncated_bytes: 0,
498 }
499 }
500
501 #[test]
502 fn estimate_tokens_uses_chars_div_4() {
503 assert_eq!(estimate_tokens(""), 0);
504 assert_eq!(estimate_tokens("abc"), 0);
505 assert_eq!(estimate_tokens("abcd"), 1);
506 assert_eq!(estimate_tokens(&"a".repeat(40)), 10);
507 }
508
509 #[test]
510 fn budget_under_cap_no_truncation() {
511 let mut p = MemoryPrefix {
512 global: Some(tier("hi")),
513 project: Some(tier("there")),
514 auto: Some(tier("again")),
515 ..MemoryPrefix::default()
516 };
517 enforce_budget(&mut p, 8_000);
518 assert!(!p.truncated);
519 assert_eq!(p.global.unwrap().truncated_bytes, 0);
520 assert_eq!(p.project.unwrap().truncated_bytes, 0);
521 assert_eq!(p.auto.unwrap().truncated_bytes, 0);
522 }
523
524 #[test]
525 fn budget_truncates_auto_first() {
526 let small = "x".repeat(100); let big_auto = "line\n".repeat(2_000); let mut p = MemoryPrefix {
529 global: Some(tier(&small)),
530 project: Some(tier(&small)),
531 auto: Some(tier(&big_auto)),
532 ..MemoryPrefix::default()
533 };
534 enforce_budget(&mut p, 200);
535 assert!(p.truncated);
536 assert!(p.auto.as_ref().unwrap().truncated_bytes > 0);
537 assert_eq!(p.global.as_ref().unwrap().truncated_bytes, 0);
539 assert_eq!(p.project.as_ref().unwrap().truncated_bytes, 0);
540 }
541
542 #[test]
543 fn truncate_cuts_on_line_boundary() {
544 let mut body = String::new();
545 for i in 0..100 {
546 writeln!(body, "line {i:03}").unwrap();
547 }
548 let mut p = MemoryPrefix {
549 global: None,
550 project: None,
551 auto: Some(tier(&body)),
552 ..MemoryPrefix::default()
553 };
554 enforce_budget(&mut p, 20);
555 let cut_body = &p.auto.as_ref().unwrap().body;
556 for line in cut_body.lines().take_while(|l| l.starts_with("line ")) {
559 assert!(line.len() == "line NNN".len(), "non-boundary cut: {line:?}");
560 }
561 assert!(cut_body.contains("[truncated:"));
562 }
563
564 #[test]
565 fn budget_truncates_global_when_only_one_tier_present() {
566 let big = "g".repeat(10_000);
567 let mut p = MemoryPrefix {
568 global: Some(tier(&big)),
569 project: None,
570 auto: None,
571 ..MemoryPrefix::default()
572 };
573 enforce_budget(&mut p, 500);
574 assert!(p.truncated);
575 assert!(p.global.unwrap().truncated_bytes > 0);
576 }
577
578 #[test]
579 fn other_tokens_excludes_correct_tier() {
580 let p = MemoryPrefix {
581 global: Some(tier(&"a".repeat(40))), project: Some(tier(&"b".repeat(80))), auto: Some(tier(&"c".repeat(120))), ..MemoryPrefix::default()
585 };
586 assert_eq!(other_tokens(&p, TierKind::Auto), 30);
587 assert_eq!(other_tokens(&p, TierKind::Project), 40);
588 assert_eq!(other_tokens(&p, TierKind::Global), 50);
589 }
590
591 #[test]
592 fn enforce_caps_truncates_auto_to_per_scope_cap() {
593 let big_auto = "x".repeat(4_000); let mut p = MemoryPrefix {
595 global: Some(tier("hi")),
596 project: Some(tier("there")),
597 auto: Some(tier(&big_auto)),
598 ..MemoryPrefix::default()
599 };
600 let cfg = MemoryConfig {
601 cap_tokens_auto: Some(100),
602 ..MemoryConfig::for_test(std::path::PathBuf::from("/tmp/m"))
603 };
604 enforce_caps_and_budget(&mut p, &cfg);
605 assert!(p.truncated);
606 let auto = p.auto.as_ref().unwrap();
607 let auto_tokens = auto.estimated_tokens;
608 assert!(
609 auto_tokens <= 100,
610 "auto cap not honored: {auto_tokens} > 100"
611 );
612 }
613
614 #[test]
615 fn enforce_caps_truncates_project_first_then_global_under_claude_md_cap() {
616 let small = "x".repeat(40); let big_project = "p".repeat(4_000); let big_global = "g".repeat(4_000); let mut p = MemoryPrefix {
620 global: Some(tier(&big_global)),
621 project: Some(tier(&big_project)),
622 auto: Some(tier(&small)),
623 ..MemoryPrefix::default()
624 };
625 let cfg = MemoryConfig {
626 cap_tokens_claude_md: Some(500),
627 ..MemoryConfig::for_test(std::path::PathBuf::from("/tmp/m"))
628 };
629 enforce_caps_and_budget(&mut p, &cfg);
630 assert!(p.truncated);
631 let global_t = p.global.as_ref().map_or(0, |t| t.estimated_tokens);
632 let project_t = p.project.as_ref().map_or(0, |t| t.estimated_tokens);
633 assert!(
634 global_t + project_t <= 500,
635 "claude_md cap not honored: {global_t} + {project_t} > 500",
636 );
637 assert!(
643 project_t == 0 || project_t < global_t,
644 "project should be truncated first: project={project_t} global={global_t}",
645 );
646 }
647
648 #[test]
649 fn enforce_caps_proportional_scaling_when_per_scope_sum_exceeds_combined() {
650 let mut big_auto = String::new();
652 for _ in 0..20_000 {
653 big_auto.push_str("aaaa\n");
654 } let mut big_md = String::new();
656 for _ in 0..20_000 {
657 big_md.push_str("bbbb\n");
658 } let mut p = MemoryPrefix {
660 global: Some(tier(&big_md)),
661 project: None,
662 auto: Some(tier(&big_auto)),
663 ..MemoryPrefix::default()
664 };
665 let cfg = MemoryConfig {
666 max_tokens: 20_000,
667 cap_tokens_auto: Some(20_000),
668 cap_tokens_claude_md: Some(20_000),
669 ..MemoryConfig::for_test(std::path::PathBuf::from("/tmp/m"))
670 };
671 enforce_caps_and_budget(&mut p, &cfg);
672 let auto_t = p.auto.as_ref().unwrap().estimated_tokens;
675 let global_t = p.global.as_ref().unwrap().estimated_tokens;
676 assert!(auto_t <= 10_000, "auto effective cap: {auto_t}");
677 assert!(global_t <= 10_000, "global effective cap: {global_t}");
678 assert!(auto_t + global_t <= 20_000);
679 }
680
681 #[tokio::test]
682 async fn auto_load_caps_at_two_hundred_lines() {
683 let tmp = tempfile::TempDir::new().unwrap();
684 let dir = tmp.path().join("memory");
685 std::fs::create_dir_all(&dir).unwrap();
686 let mut body = String::new();
687 for i in 0..400 {
688 writeln!(body, "line-{i:04}").unwrap();
689 }
690 std::fs::write(dir.join("MEMORY.md"), &body).unwrap();
691
692 let cfg = MemoryConfig::for_test(dir.clone());
693 let p = load(&cfg).await.unwrap();
694 let auto = p.auto.as_ref().expect("auto loaded");
695 let kept_lines = auto.body.lines().filter(|l| l.starts_with("line-")).count();
697 assert_eq!(kept_lines, AUTO_MAX_LINES);
698 assert!(auto.truncated_bytes > 0);
699 }
700
701 #[tokio::test]
702 async fn auto_load_caps_at_byte_ceiling() {
703 let tmp = tempfile::TempDir::new().unwrap();
704 let dir = tmp.path().join("memory");
705 std::fs::create_dir_all(&dir).unwrap();
706 let mut body = String::new();
708 for i in 0..10 {
709 let chunk = "x".repeat(5_000);
710 writeln!(body, "{i}-{chunk}").unwrap();
711 }
712 std::fs::write(dir.join("MEMORY.md"), &body).unwrap();
713
714 let cfg = MemoryConfig::for_test(dir.clone());
715 let p = load(&cfg).await.unwrap();
716 let auto = p.auto.as_ref().expect("auto loaded");
717 assert!(auto.truncated_bytes > 0);
721 let lines_with_x = auto.body.lines().filter(|l| l.contains("xxxx")).count();
722 assert!(
723 lines_with_x < 10,
724 "expected truncation, got {lines_with_x} lines"
725 );
726 }
727
728 #[tokio::test]
729 async fn html_comments_stripped_from_auto_splice() {
730 let tmp = tempfile::TempDir::new().unwrap();
731 let dir = tmp.path().join("memory");
732 std::fs::create_dir_all(&dir).unwrap();
733 std::fs::write(
734 dir.join("MEMORY.md"),
735 "# Memory index\n<!-- secret comment -->\n- [foo](foo.md) — user: visible\n",
736 )
737 .unwrap();
738 let cfg = MemoryConfig::for_test(dir.clone());
739 let p = load(&cfg).await.unwrap();
740 let auto = p.auto.as_ref().unwrap();
741 assert!(!auto.body.contains("secret comment"));
742 assert!(auto.body.contains("[foo](foo.md)"));
743 }
744
745 #[allow(unsafe_code)]
759 fn set_env(key: &str, value: Option<&str>) {
760 match value {
761 Some(v) => unsafe { std::env::set_var(key, v) },
763 None => unsafe { std::env::remove_var(key) },
765 }
766 }
767
768 struct EnvGuard {
769 key: &'static str,
770 prior: Option<std::ffi::OsString>,
771 }
772
773 impl Drop for EnvGuard {
774 fn drop(&mut self) {
775 set_env(self.key, self.prior.as_ref().and_then(|s| s.to_str()));
776 }
777 }
778
779 fn env_guard(key: &'static str) -> EnvGuard {
780 EnvGuard {
781 key,
782 prior: std::env::var_os(key),
783 }
784 }
785
786 #[tokio::test]
787 async fn disable_auto_config_skips_auto_tier() {
788 let tmp = tempfile::TempDir::new().unwrap();
789 let dir = tmp.path().join("memory");
790 std::fs::create_dir_all(&dir).unwrap();
792 std::fs::write(dir.join("MEMORY.md"), "# Memory index\n").unwrap();
793
794 let mut cfg = MemoryConfig::for_test(dir.clone());
799 cfg.disable_auto = true;
800 let p = load(&cfg).await.unwrap();
801 assert!(p.auto.is_none(), "auto tier should be dropped");
802 }
803
804 #[test]
805 fn config_honors_auto_memory_directory_override() {
806 let _g1 = env_guard("CALIBAN_AUTO_MEMORY_DIRECTORY");
807 set_env(
808 "CALIBAN_AUTO_MEMORY_DIRECTORY",
809 Some("/tmp/custom-auto-mem-xyz"),
810 );
811 let cfg = MemoryConfig::from_env(std::path::Path::new("/tmp/whatever"));
812 assert_eq!(
813 cfg.auto_memory_dir,
814 std::path::PathBuf::from("/tmp/custom-auto-mem-xyz")
815 );
816 }
817}