1use std::path::{Path, PathBuf};
4
5use globset::{Glob, GlobSet, GlobSetBuilder};
6
7use caliban_common::paths::sanitize_cwd_for_path;
8
9use crate::project_walk::WalkStop;
10
11const DEFAULT_BUDGET_TOKENS: usize = 32_000;
12
13#[allow(clippy::struct_excessive_bools)]
20#[derive(Debug, Clone)]
21pub struct MemoryConfig {
22 pub global_path: Option<PathBuf>,
24 pub project_path: Option<PathBuf>,
27 pub project_walk_root: PathBuf,
29 pub project_walk_stop: WalkStop,
31 pub additional_dirs: Vec<PathBuf>,
34 pub claude_md_excludes: GlobSet,
37 pub additional_directories_claude_md: bool,
40 pub disable_walk: bool,
43 pub approve_imports: bool,
45 pub non_interactive: bool,
49 pub imports_allowlist_path: PathBuf,
51 pub auto_memory_dir: PathBuf,
53 pub max_tokens: usize,
55 pub cap_tokens_auto: Option<usize>,
60 pub cap_tokens_claude_md: Option<usize>,
64 pub disable_auto: bool,
71}
72
73impl MemoryConfig {
74 #[must_use]
92 pub fn from_env(workspace_root: &Path) -> Self {
93 let config_home = xdg_dir("XDG_CONFIG_HOME", dirs::config_dir);
94 let data_home = xdg_dir("XDG_DATA_HOME", dirs::data_local_dir);
95
96 let global_path = config_home.map(|d| d.join("caliban").join("CLAUDE.md"));
97 let project_path = Some(workspace_root.join("CLAUDE.md"));
98
99 let auto_memory_dir = if let Some(dir) = std::env::var_os("CALIBAN_AUTO_MEMORY_DIRECTORY") {
100 PathBuf::from(dir)
101 } else {
102 let auto_memory_root = std::env::var_os("CALIBAN_MEMORY_DIR")
103 .map(PathBuf::from)
104 .or_else(|| data_home.map(|d| d.join("caliban").join("projects")));
105 let slug = sanitize_cwd_for_path(workspace_root);
106 auto_memory_root
107 .unwrap_or_else(|| PathBuf::from("./.caliban/projects"))
108 .join(slug)
109 .join("memory")
110 };
111
112 let max_tokens = std::env::var("CALIBAN_MEMORY_BUDGET_TOKENS")
113 .ok()
114 .and_then(|s| s.parse::<usize>().ok())
115 .unwrap_or(DEFAULT_BUDGET_TOKENS);
116
117 let cap_tokens_auto = std::env::var("CALIBAN_MEMORY_CAP_TOKENS_AUTO")
118 .ok()
119 .and_then(|s| s.parse::<usize>().ok());
120 let cap_tokens_claude_md = std::env::var("CALIBAN_MEMORY_CAP_TOKENS_CLAUDE_MD")
121 .ok()
122 .and_then(|s| s.parse::<usize>().ok());
123
124 let claude_md_excludes =
125 parse_exclude_patterns(std::env::var("CALIBAN_CLAUDE_MD_EXCLUDES").ok().as_deref());
126
127 let imports_allowlist_path = dirs::home_dir()
128 .unwrap_or_else(|| PathBuf::from("."))
129 .join(".caliban")
130 .join("imports-allowlist.json");
131
132 Self {
133 global_path,
134 project_path,
135 project_walk_root: workspace_root.to_path_buf(),
136 project_walk_stop: WalkStop::default(),
137 additional_dirs: Vec::new(),
138 claude_md_excludes,
139 additional_directories_claude_md: env_truthy(
140 "CALIBAN_ADDITIONAL_DIRECTORIES_CLAUDE_MD",
141 ),
142 disable_walk: env_truthy("CALIBAN_DISABLE_CLAUDE_MD_WALK"),
143 approve_imports: env_truthy("CALIBAN_APPROVE_IMPORTS"),
144 non_interactive: false,
145 imports_allowlist_path,
146 auto_memory_dir,
147 max_tokens,
148 cap_tokens_auto,
149 cap_tokens_claude_md,
150 disable_auto: env_truthy("CALIBAN_DISABLE_AUTO_MEMORY"),
151 }
152 }
153}
154
155impl MemoryConfig {
156 #[must_use]
161 pub fn for_test(auto_memory_dir: PathBuf) -> Self {
162 Self {
163 global_path: None,
164 project_path: None,
165 project_walk_root: PathBuf::from("/tmp"),
166 project_walk_stop: WalkStop::default(),
167 additional_dirs: Vec::new(),
168 claude_md_excludes: GlobSet::empty(),
169 additional_directories_claude_md: false,
170 disable_walk: true, approve_imports: false,
172 non_interactive: false,
173 imports_allowlist_path: PathBuf::from("/tmp/.caliban/imports-allowlist.json"),
174 auto_memory_dir,
175 max_tokens: 100_000,
176 cap_tokens_auto: None,
177 cap_tokens_claude_md: None,
178 disable_auto: false,
179 }
180 }
181
182 #[must_use]
186 pub fn with_cap_tokens_auto(mut self, n: usize) -> Self {
187 self.cap_tokens_auto = Some(n);
188 self
189 }
190
191 #[must_use]
193 pub fn with_cap_tokens_claude_md(mut self, n: usize) -> Self {
194 self.cap_tokens_claude_md = Some(n);
195 self
196 }
197
198 #[must_use]
206 pub fn effective_cap(&self, this_cap: usize, other_cap: Option<usize>) -> usize {
207 let other = other_cap.unwrap_or(self.max_tokens);
208 let per_scope_sum = this_cap.saturating_add(other);
209 if per_scope_sum <= self.max_tokens {
210 this_cap
211 } else {
212 ((this_cap as u128) * (self.max_tokens as u128) / (per_scope_sum as u128)) as usize
214 }
215 }
216}
217
218fn env_truthy(key: &str) -> bool {
219 matches!(
220 std::env::var(key).ok().as_deref(),
221 Some("1" | "true" | "TRUE" | "True" | "yes" | "YES"),
222 )
223}
224
225fn parse_exclude_patterns(raw: Option<&str>) -> GlobSet {
228 let mut builder = GlobSetBuilder::new();
229 let Some(s) = raw else {
230 return GlobSet::empty();
231 };
232 for raw in s.split(['\n', ':']) {
233 let pat = raw.trim();
234 if pat.is_empty() {
235 continue;
236 }
237 match Glob::new(pat) {
238 Ok(g) => {
239 builder.add(g);
240 }
241 Err(e) => tracing::warn!(
242 target: caliban_common::tracing_targets::TARGET_MEMORY,
243 pattern = %pat,
244 error = %e,
245 "skipping invalid claude_md_excludes pattern",
246 ),
247 }
248 }
249 builder.build().unwrap_or_else(|e| {
250 tracing::warn!(
251 target: caliban_common::tracing_targets::TARGET_MEMORY,
252 error = %e,
253 "claude_md_excludes globset build failed; using empty matcher",
254 );
255 GlobSet::empty()
256 })
257}
258
259pub fn build_excludes<I, S>(patterns: I) -> std::result::Result<GlobSet, globset::Error>
267where
268 I: IntoIterator<Item = S>,
269 S: AsRef<str>,
270{
271 let mut builder = GlobSetBuilder::new();
272 for p in patterns {
273 builder.add(Glob::new(p.as_ref())?);
274 }
275 builder.build()
276}
277
278fn xdg_dir(env_var: &str, fallback: fn() -> Option<PathBuf>) -> Option<PathBuf> {
281 if let Some(v) = std::env::var_os(env_var)
282 && !v.is_empty()
283 {
284 return Some(PathBuf::from(v));
285 }
286 fallback()
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn default_budget_constant_matches() {
295 assert_eq!(DEFAULT_BUDGET_TOKENS, 32_000);
296 }
297
298 #[test]
299 fn with_cap_tokens_auto_sets_value() {
300 let cfg = MemoryConfig::for_test(PathBuf::from("/tmp/m")).with_cap_tokens_auto(4_096);
301 assert_eq!(cfg.cap_tokens_auto, Some(4_096));
302 }
303
304 #[test]
305 fn effective_cap_returns_raw_when_sum_fits_combined() {
306 let cfg = MemoryConfig::for_test(PathBuf::from("/tmp/m"));
307 assert_eq!(cfg.effective_cap(16_000, Some(16_000)), 16_000);
309 }
310
311 #[test]
312 fn effective_cap_scales_proportionally_when_sum_exceeds_combined() {
313 let cfg = MemoryConfig::for_test(PathBuf::from("/tmp/m"))
314 .with_cap_tokens_auto(20_000)
315 .with_cap_tokens_claude_md(20_000);
316 let cfg = MemoryConfig {
318 max_tokens: 20_000,
319 ..cfg
320 };
321 assert_eq!(cfg.effective_cap(20_000, Some(20_000)), 10_000);
323 }
324
325 #[test]
326 fn effective_cap_treats_missing_other_as_combined_ceiling() {
327 let cfg = MemoryConfig::for_test(PathBuf::from("/tmp/m"));
328 assert_eq!(cfg.effective_cap(50_000, None), 33_333);
332 }
333
334 #[test]
335 fn project_path_joins_workspace_root() {
336 let cfg = MemoryConfig::from_env(Path::new("/tmp/my-workspace"));
337 assert_eq!(
338 cfg.project_path.as_deref(),
339 Some(Path::new("/tmp/my-workspace/CLAUDE.md")),
340 );
341 assert_eq!(
342 cfg.project_walk_root.as_path(),
343 Path::new("/tmp/my-workspace"),
344 );
345 assert_eq!(cfg.project_walk_stop, WalkStop::Both);
346 }
347
348 #[test]
349 fn parse_exclude_patterns_handles_colon_and_newline_lists() {
350 let g = parse_exclude_patterns(Some("node_modules/**\nvendor/**:third_party/**/CLAUDE.md"));
351 assert!(g.is_match("node_modules/foo/CLAUDE.md"));
352 assert!(g.is_match("vendor/x/y/AGENTS.md"));
353 assert!(g.is_match("third_party/lib/CLAUDE.md"));
354 assert!(!g.is_match("src/foo.rs"));
355 }
356
357 #[test]
358 fn parse_exclude_patterns_drops_invalid_patterns_and_empties() {
359 let g = parse_exclude_patterns(Some(""));
360 assert!(g.is_empty());
361 let g2 = parse_exclude_patterns(None);
362 assert!(g2.is_empty());
363 }
364
365 #[test]
366 fn build_excludes_helper_round_trips_patterns() {
367 let g = build_excludes(["a/**", "b/**.md"]).unwrap();
368 assert!(g.is_match("a/x"));
369 assert!(g.is_match("b/x.md"));
370 assert!(!g.is_match("c/x"));
371 }
372}