1use std::fmt::Write as _;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TierFileSource {
9 Global,
11 Walk,
13 Import,
15 Nested,
17 Rule,
19 Auto,
21 LegacyProject,
23}
24
25impl TierFileSource {
26 #[must_use]
28 pub const fn as_str(self) -> &'static str {
29 match self {
30 Self::Global => "global",
31 Self::Walk => "walk",
32 Self::Import => "import",
33 Self::Nested => "nested",
34 Self::Rule => "rule",
35 Self::Auto => "auto",
36 Self::LegacyProject => "legacy",
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TierFile {
44 pub path: PathBuf,
46 pub body: String,
48 pub estimated_tokens: usize,
50 pub truncated_bytes: usize,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum TierKind {
57 Global,
59 Project,
61 Auto,
63}
64
65impl TierKind {
66 #[must_use]
68 pub const fn tag(self) -> &'static str {
69 match self {
70 Self::Global => "global-claude-md",
71 Self::Project => "project-claude-md",
72 Self::Auto => "auto-memory-index",
73 }
74 }
75
76 #[must_use]
78 pub const fn label(self) -> &'static str {
79 match self {
80 Self::Global => "global",
81 Self::Project => "project",
82 Self::Auto => "auto",
83 }
84 }
85}
86
87#[derive(Debug, Clone, Default)]
90pub struct ProjectTier {
91 pub base_files: Vec<TierFile>,
93 pub imports: Vec<TierFile>,
97 pub active_rules: Vec<TierFile>,
99 pub nested: Vec<TierFile>,
101}
102
103impl ProjectTier {
104 #[must_use]
106 pub fn estimated_tokens(&self) -> usize {
107 self.base_files
108 .iter()
109 .map(|t| t.estimated_tokens)
110 .sum::<usize>()
111 + self
112 .imports
113 .iter()
114 .map(|t| t.estimated_tokens)
115 .sum::<usize>()
116 + self
117 .active_rules
118 .iter()
119 .map(|t| t.estimated_tokens)
120 .sum::<usize>()
121 + self
122 .nested
123 .iter()
124 .map(|t| t.estimated_tokens)
125 .sum::<usize>()
126 }
127
128 #[must_use]
132 pub fn to_legacy_tier(&self) -> Option<TierFile> {
133 if self.base_files.is_empty() && self.active_rules.is_empty() {
134 return None;
135 }
136 let mut body = String::new();
137 let mut tokens = 0usize;
138 for f in &self.base_files {
139 let _ = writeln!(
140 body,
141 "<project-claude-md path=\"{}\" source=\"walk\">",
142 f.path.display(),
143 );
144 body.push_str(&f.body);
145 if !body.ends_with('\n') {
146 body.push('\n');
147 }
148 body.push_str("</project-claude-md>\n\n");
149 tokens = tokens.saturating_add(f.estimated_tokens);
150 }
151 for r in &self.active_rules {
152 let _ = writeln!(
153 body,
154 "<project-rule path=\"{}\" source=\"rule\">",
155 r.path.display(),
156 );
157 body.push_str(&r.body);
158 if !body.ends_with('\n') {
159 body.push('\n');
160 }
161 body.push_str("</project-rule>\n\n");
162 tokens = tokens.saturating_add(r.estimated_tokens);
163 }
164 let path = self
165 .base_files
166 .first()
167 .or_else(|| self.active_rules.first())
168 .map(|t| t.path.clone())
169 .unwrap_or_default();
170 Some(TierFile {
171 path,
172 body,
173 estimated_tokens: tokens,
174 truncated_bytes: 0,
175 })
176 }
177}
178
179#[derive(Debug, Clone, Default)]
184pub struct MemoryPrefix {
185 pub global: Option<TierFile>,
187 pub project: Option<TierFile>,
191 pub project_tier: Option<ProjectTier>,
193 pub auto: Option<TierFile>,
195 pub estimated_tokens: usize,
197 pub truncated: bool,
199}
200
201impl MemoryPrefix {
202 #[must_use]
206 pub fn splice_into(&self, default_body: &str) -> String {
207 let mut out = String::new();
208 for (kind, tier) in [
209 (TierKind::Global, self.global.as_ref()),
210 (TierKind::Project, self.project.as_ref()),
211 (TierKind::Auto, self.auto.as_ref()),
212 ] {
213 let Some(tier) = tier else { continue };
214 out.push('<');
215 out.push_str(kind.tag());
216 out.push_str(" path=\"");
217 out.push_str(&tier.path.display().to_string());
218 out.push_str("\">\n");
219 out.push_str(&tier.body);
220 if !tier.body.ends_with('\n') {
221 out.push('\n');
222 }
223 out.push_str("</");
224 out.push_str(kind.tag());
225 out.push_str(">\n\n");
226 }
227 out.push_str(default_body);
228 out
229 }
230
231 #[must_use]
233 pub fn summary_lines(&self) -> Vec<String> {
234 let mut out = Vec::with_capacity(6);
235 for (kind, tier) in [
236 (TierKind::Global, self.global.as_ref()),
237 (TierKind::Project, self.project.as_ref()),
238 (TierKind::Auto, self.auto.as_ref()),
239 ] {
240 match tier {
241 Some(t) => out.push(format!(
242 " {:<8} {} ({} tokens{})",
243 kind.label(),
244 t.path.display(),
245 t.estimated_tokens,
246 if t.truncated_bytes > 0 {
247 format!(", truncated {} bytes", t.truncated_bytes)
248 } else {
249 String::new()
250 },
251 )),
252 None => out.push(format!(" {:<8} (missing)", kind.label())),
253 }
254 }
255 if let Some(pt) = self.project_tier.as_ref() {
256 for f in &pt.base_files {
257 out.push(format!(
258 " walk {} ({} tokens)",
259 f.path.display(),
260 f.estimated_tokens,
261 ));
262 }
263 for f in &pt.imports {
264 out.push(format!(
265 " import {} ({} tokens)",
266 f.path.display(),
267 f.estimated_tokens,
268 ));
269 }
270 for f in &pt.active_rules {
271 out.push(format!(
272 " rule {} ({} tokens)",
273 f.path.display(),
274 f.estimated_tokens,
275 ));
276 }
277 for f in &pt.nested {
278 out.push(format!(
279 " nested {} ({} tokens)",
280 f.path.display(),
281 f.estimated_tokens,
282 ));
283 }
284 }
285 out
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 fn tier(label: TierKind, body: &str) -> TierFile {
294 TierFile {
295 path: PathBuf::from(format!("/tmp/{}.md", label.label())),
296 estimated_tokens: body.len() / 4,
297 body: body.to_string(),
298 truncated_bytes: 0,
299 }
300 }
301
302 fn raw_tier(path: &str, body: &str) -> TierFile {
303 TierFile {
304 path: PathBuf::from(path),
305 estimated_tokens: body.len() / 4,
306 body: body.to_string(),
307 truncated_bytes: 0,
308 }
309 }
310
311 #[test]
312 fn splice_into_orders_tiers_correctly() {
313 let p = MemoryPrefix {
314 global: Some(tier(TierKind::Global, "GLOBAL")),
315 project: Some(tier(TierKind::Project, "PROJECT")),
316 auto: Some(tier(TierKind::Auto, "AUTO")),
317 ..MemoryPrefix::default()
318 };
319 let out = p.splice_into("BODY");
320 let g = out.find("GLOBAL").expect("global present");
321 let pj = out.find("PROJECT").expect("project present");
322 let a = out.find("AUTO").expect("auto present");
323 let b = out.find("BODY").expect("body present");
324 assert!(g < pj && pj < a && a < b, "wrong order: {out}");
325 }
326
327 #[test]
328 fn splice_into_omits_missing_tiers() {
329 let p = MemoryPrefix {
330 global: None,
331 project: Some(tier(TierKind::Project, "PROJECT")),
332 auto: None,
333 ..MemoryPrefix::default()
334 };
335 let out = p.splice_into("BODY");
336 assert!(!out.contains("global-claude-md"));
337 assert!(!out.contains("auto-memory-index"));
338 assert!(out.contains("project-claude-md"));
339 assert!(out.contains("BODY"));
340 }
341
342 #[test]
343 fn splice_into_preserves_default_body() {
344 let p = MemoryPrefix::default();
345 let out = p.splice_into("the default body verbatim");
346 assert_eq!(out, "the default body verbatim");
347 }
348
349 #[test]
350 fn summary_lines_show_missing_tiers() {
351 let p = MemoryPrefix {
352 global: None,
353 project: Some(tier(TierKind::Project, "x")),
354 auto: None,
355 ..MemoryPrefix::default()
356 };
357 let lines = p.summary_lines();
358 assert_eq!(lines.len(), 3);
359 assert!(lines[0].contains("(missing)"));
360 assert!(lines[1].contains("project"));
361 assert!(lines[2].contains("(missing)"));
362 }
363
364 #[test]
365 fn project_tier_flattens_walk_and_rules_into_legacy_tier() {
366 let pt = ProjectTier {
367 base_files: vec![
368 raw_tier("/tmp/root/CLAUDE.md", "ROOT-BODY"),
369 raw_tier("/tmp/root/sub/CLAUDE.md", "SUB-BODY"),
370 ],
371 active_rules: vec![raw_tier("/tmp/root/.caliban/rules/x.md", "RULE-BODY")],
372 ..ProjectTier::default()
373 };
374 let flat = pt.to_legacy_tier().expect("flat tier built");
375 assert!(flat.body.contains("ROOT-BODY"));
376 assert!(flat.body.contains("SUB-BODY"));
377 assert!(flat.body.contains("RULE-BODY"));
378 assert!(flat.body.contains("project-claude-md"));
379 assert!(flat.body.contains("project-rule"));
380 assert!(flat.body.find("ROOT-BODY").unwrap() < flat.body.find("SUB-BODY").unwrap(),);
382 }
383
384 #[test]
385 fn project_tier_empty_returns_none_legacy_tier() {
386 let pt = ProjectTier::default();
387 assert!(pt.to_legacy_tier().is_none());
388 }
389}