Skip to main content

bamboo_memory/memory_store/
mod.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet};
2use std::io;
3use std::path::{Path, PathBuf};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8pub mod freshness;
9pub mod paths;
10pub mod recall;
11pub mod store;
12pub mod types;
13
14pub use freshness::{
15    memory_age_days, memory_age_label, memory_freshness_text, render_memory_freshness_note,
16    FreshnessKind,
17};
18pub use paths::{MemoryPathResolver, SESSIONS_DIR, TOPICS_DIR};
19pub use recall::{
20    select_relevant_memories, shortlist_relevant_memories, MemoryRecallCandidate,
21    MemoryRecallOptions, MemoryRecallRerankContext, MemoryRecallSelection, MemoryRecallStrategy,
22};
23pub use store::MemoryStore;
24pub use types::{
25    BlobScanItem, BlobScanReport, CreatedBy, DuplicateCluster, DuplicateClusterMember,
26    DuplicateScanReport, DurableContentLocation, DurableMemoryDocument, DurableMemoryFrontmatter,
27    DurableMemoryRef, DurableMemoryRelations, DurableMemoryRetrieval, DurableMemorySource,
28    DurableMemoryStatus, DurableMemoryType, MemoryConsolidateResult, MemoryContradictionResult,
29    MemoryDuplicateCandidate, MemoryInspectResult, MemoryMergeResult, MemoryPurgeResult,
30    MemoryQueryCursor, MemoryQueryItem, MemoryQueryOptions, MemoryQueryResult, MemoryScope,
31    MemorySplitPiece, MemorySplitResult, SessionState, TemporalGranularity,
32};
33
34pub const MEMORY_SCHEMA_VERSION: u32 = 1;
35pub const DEFAULT_SESSION_TOPIC: &str = "default";
36pub const MAX_SESSION_TOPIC_LEN: usize = 50;
37pub const MAX_MEMORY_TITLE_LEN: usize = 160;
38pub const MAX_MEMORY_TAGS: usize = 32;
39pub const DEFAULT_QUERY_LIMIT: usize = 5;
40pub const MAX_QUERY_LIMIT: usize = 20;
41pub const DEFAULT_MAX_CHARS: usize = 3_000;
42pub const MAX_MAX_CHARS: usize = 6_000;
43pub const WRITE_AUDIT_LOG: &str = "write_audit.jsonl";
44pub const MERGE_AUDIT_LOG: &str = "merge_audit.jsonl";
45pub const PURGE_AUDIT_LOG: &str = "purge_audit.jsonl";
46pub const CONTRADICTION_AUDIT_LOG: &str = "contradiction_audit.jsonl";
47pub const DREAM_VIEW_FILE: &str = "DREAM_NOTEBOOK.md";
48pub const MEMORY_VIEW_FILE: &str = "MEMORY.md";
49pub const RECENT_VIEW_FILE: &str = "RECENT.md";
50pub const STALE_VIEW_FILE: &str = "STALE.md";
51pub const LEXICAL_INDEX_FILE: &str = "lexical.json";
52pub const GRAPH_INDEX_FILE: &str = "graph.json";
53pub const RECENT_INDEX_FILE: &str = "recent.json";
54pub const STALE_CANDIDATES_INDEX_FILE: &str = "stale_candidates.json";
55pub const TAXONOMY_INDEX_FILE: &str = "taxonomy.json";
56
57pub fn validate_session_id(session_id: &str) -> io::Result<&str> {
58    let trimmed = session_id.trim();
59    if trimmed.is_empty() {
60        return Err(io::Error::new(
61            io::ErrorKind::InvalidInput,
62            "session_id cannot be empty",
63        ));
64    }
65    if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
66        return Err(io::Error::new(
67            io::ErrorKind::InvalidInput,
68            "session_id contains invalid path characters",
69        ));
70    }
71    if !trimmed
72        .chars()
73        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.')
74    {
75        return Err(io::Error::new(
76            io::ErrorKind::InvalidInput,
77            "session_id contains unsupported characters",
78        ));
79    }
80    Ok(trimmed)
81}
82
83pub fn validate_session_topic(topic: &str) -> io::Result<&str> {
84    let trimmed = topic.trim();
85    if trimmed.is_empty() {
86        return Err(io::Error::new(
87            io::ErrorKind::InvalidInput,
88            "topic cannot be empty",
89        ));
90    }
91    if trimmed.len() > MAX_SESSION_TOPIC_LEN {
92        return Err(io::Error::new(
93            io::ErrorKind::InvalidInput,
94            format!(
95                "topic name too long (max {} chars, got {})",
96                MAX_SESSION_TOPIC_LEN,
97                trimmed.len()
98            ),
99        ));
100    }
101    if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
102        return Err(io::Error::new(
103            io::ErrorKind::InvalidInput,
104            "topic contains invalid path characters",
105        ));
106    }
107    if !trimmed
108        .chars()
109        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
110    {
111        return Err(io::Error::new(
112            io::ErrorKind::InvalidInput,
113            "topic must contain only alphanumeric, dash, or underscore characters",
114        ));
115    }
116    Ok(trimmed)
117}
118
119pub fn validate_memory_title(title: &str) -> io::Result<&str> {
120    let trimmed = title.trim();
121    if trimmed.is_empty() {
122        return Err(io::Error::new(
123            io::ErrorKind::InvalidInput,
124            "title cannot be empty",
125        ));
126    }
127    if trimmed.chars().count() > MAX_MEMORY_TITLE_LEN {
128        return Err(io::Error::new(
129            io::ErrorKind::InvalidInput,
130            format!("title too long (max {} chars)", MAX_MEMORY_TITLE_LEN),
131        ));
132    }
133    Ok(trimmed)
134}
135
136pub fn normalize_tag(tag: &str) -> Option<String> {
137    let trimmed = tag.trim();
138    if trimmed.is_empty() {
139        return None;
140    }
141    let mut out = String::with_capacity(trimmed.len());
142    let mut prev_dash = false;
143    for ch in trimmed.chars() {
144        let normalized = match ch {
145            'A'..='Z' => ch.to_ascii_lowercase(),
146            'a'..='z' | '0'..='9' => ch,
147            '-' | '_' | ' ' | '.' | '/' => '-',
148            _ => continue,
149        };
150        if normalized == '-' {
151            if prev_dash {
152                continue;
153            }
154            prev_dash = true;
155            out.push(normalized);
156        } else {
157            prev_dash = false;
158            out.push(normalized);
159        }
160    }
161    let normalized = out.trim_matches('-').to_string();
162    (!normalized.is_empty()).then_some(normalized)
163}
164
165pub fn normalize_tags<I, S>(tags: I) -> Vec<String>
166where
167    I: IntoIterator<Item = S>,
168    S: AsRef<str>,
169{
170    let mut seen = BTreeSet::new();
171    for tag in tags {
172        if let Some(tag) = normalize_tag(tag.as_ref()) {
173            seen.insert(tag);
174            if seen.len() >= MAX_MEMORY_TAGS {
175                break;
176            }
177        }
178    }
179    seen.into_iter().collect()
180}
181
182pub fn truncate_chars(value: &str, max_chars: usize) -> (String, bool) {
183    let mut out = String::new();
184    for (count, ch) in value.chars().enumerate() {
185        if count >= max_chars {
186            return (out, true);
187        }
188        out.push(ch);
189    }
190    (out, false)
191}
192
193pub fn count_chars(value: &str) -> usize {
194    value.chars().count()
195}
196
197pub fn now_rfc3339() -> String {
198    Utc::now().to_rfc3339()
199}
200
201pub fn derive_summary(content: &str, max_chars: usize) -> String {
202    let collapsed = content
203        .lines()
204        .map(str::trim)
205        .filter(|line| !line.is_empty())
206        .collect::<Vec<_>>()
207        .join(" ");
208    let (summary, truncated) = truncate_chars(&collapsed, max_chars);
209    if truncated {
210        format!("{}...", summary.trim_end())
211    } else {
212        summary
213    }
214}
215
216pub fn extract_keywords(title: &str, content: &str, tags: &[String]) -> Vec<String> {
217    let mut seen = BTreeSet::new();
218    for tag in tags {
219        if let Some(tag) = normalize_tag(tag) {
220            seen.insert(tag);
221        }
222    }
223
224    let combined = format!("{}\n{}", title, content);
225    let mut current = String::new();
226    for ch in combined.chars() {
227        if ch.is_ascii_alphanumeric() {
228            current.push(ch.to_ascii_lowercase());
229            continue;
230        }
231        if current.len() >= 3 {
232            seen.insert(current.clone());
233        }
234        current.clear();
235    }
236    if current.len() >= 3 {
237        seen.insert(current);
238    }
239
240    seen.into_iter().take(128).collect()
241}
242
243pub fn detect_entities(title: &str, content: &str) -> Vec<String> {
244    let mut entities = BTreeSet::new();
245    for token in format!("{}\n{}", title, content)
246        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '/'))
247    {
248        let trimmed = token.trim();
249        if trimmed.len() < 3 {
250            continue;
251        }
252        let has_upper = trimmed.chars().any(|ch| ch.is_ascii_uppercase());
253        let has_separator = trimmed.contains('-') || trimmed.contains('_') || trimmed.contains('/');
254        if has_upper || has_separator {
255            entities.insert(trimmed.to_string());
256        }
257    }
258    entities.into_iter().take(64).collect()
259}
260
261pub fn sanitize_component(input: &str) -> String {
262    let trimmed = input.trim();
263    if trimmed.is_empty() {
264        return "unknown".to_string();
265    }
266
267    let mut out = String::with_capacity(trimmed.len());
268    let mut prev_dash = false;
269    for ch in trimmed.chars() {
270        let normalized = match ch {
271            'A'..='Z' => ch.to_ascii_lowercase(),
272            'a'..='z' | '0'..='9' => ch,
273            _ => '-',
274        };
275        if normalized == '-' {
276            if prev_dash {
277                continue;
278            }
279            prev_dash = true;
280            out.push('-');
281        } else {
282            prev_dash = false;
283            out.push(normalized);
284        }
285    }
286
287    let out = out.trim_matches('-').to_string();
288    if out.is_empty() {
289        "unknown".to_string()
290    } else {
291        out
292    }
293}
294
295pub fn project_key_from_path(path: &Path) -> String {
296    let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
297
298    if let Some(root) = find_git_root(&canonical) {
299        if let Some(name) = root.file_name().and_then(|value| value.to_str()) {
300            let mut key = sanitize_component(name);
301            if let Some(hash) =
302                short_stable_hash(&bamboo_config::paths::path_to_display_string(&root))
303            {
304                key.push('-');
305                key.push_str(&hash);
306            }
307            return key;
308        }
309    }
310
311    if let Some(name) = canonical.file_name().and_then(|value| value.to_str()) {
312        let mut key = sanitize_component(name);
313        if let Some(hash) =
314            short_stable_hash(&bamboo_config::paths::path_to_display_string(&canonical))
315        {
316            key.push('-');
317            key.push_str(&hash);
318        }
319        return key;
320    }
321
322    let raw = bamboo_config::paths::path_to_display_string(&canonical);
323    format!(
324        "path-{}",
325        short_stable_hash(&raw).unwrap_or_else(|| "unknown".to_string())
326    )
327}
328
329pub fn find_git_root(start: &Path) -> Option<PathBuf> {
330    for ancestor in start.ancestors() {
331        let git_dir = ancestor.join(".git");
332        if git_dir.is_dir() || git_dir.is_file() {
333            return Some(ancestor.to_path_buf());
334        }
335    }
336    None
337}
338
339pub fn short_stable_hash(input: &str) -> Option<String> {
340    use std::hash::{Hash, Hasher};
341
342    let trimmed = input.trim();
343    if trimmed.is_empty() {
344        return None;
345    }
346    let mut hasher = std::collections::hash_map::DefaultHasher::new();
347    trimmed.hash(&mut hasher);
348    Some(format!("{:08x}", (hasher.finish() & 0xffff_ffff) as u32))
349}
350
351pub fn build_yaml_frontmatter(frontmatter: &DurableMemoryFrontmatter) -> io::Result<String> {
352    serde_yaml::to_string(frontmatter).map_err(|error| {
353        io::Error::new(
354            io::ErrorKind::InvalidData,
355            format!("failed to serialize memory frontmatter: {error}"),
356        )
357    })
358}
359
360pub fn parse_markdown_document(content: &str) -> io::Result<(DurableMemoryFrontmatter, String)> {
361    let trimmed = content.trim_start_matches('\u{feff}');
362    let Some(rest) = trimmed.strip_prefix("---\n") else {
363        return Err(io::Error::new(
364            io::ErrorKind::InvalidData,
365            "missing frontmatter start marker",
366        ));
367    };
368    let Some(end_idx) = rest.find("\n---\n") else {
369        return Err(io::Error::new(
370            io::ErrorKind::InvalidData,
371            "missing frontmatter end marker",
372        ));
373    };
374    let yaml = &rest[..end_idx];
375    let body = &rest[end_idx + "\n---\n".len()..];
376    let frontmatter: DurableMemoryFrontmatter = serde_yaml::from_str(yaml).map_err(|error| {
377        io::Error::new(
378            io::ErrorKind::InvalidData,
379            format!("failed to parse memory frontmatter: {error}"),
380        )
381    })?;
382    Ok((frontmatter, body.trim().to_string()))
383}
384
385pub fn render_markdown_document(
386    frontmatter: &DurableMemoryFrontmatter,
387    body: &str,
388) -> io::Result<String> {
389    let yaml = build_yaml_frontmatter(frontmatter)?;
390    Ok(format!("---\n{}---\n\n{}\n", yaml, body.trim()))
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, Default)]
394pub struct LexicalIndex {
395    pub generated_at: String,
396    pub items: Vec<LexicalIndexItem>,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct LexicalIndexItem {
401    pub id: String,
402    pub title: String,
403    pub scope: MemoryScope,
404    pub project_key: Option<String>,
405    pub r#type: DurableMemoryType,
406    pub status: DurableMemoryStatus,
407    pub tags: Vec<String>,
408    pub keywords: Vec<String>,
409    pub entities: Vec<String>,
410    pub updated_at: String,
411    pub created_at: String,
412    pub summary: String,
413    /// Optional temporal granularity carried from the source frontmatter so recall
414    /// can rank by cache-stability without re-reading every document. Back-compat:
415    /// older `lexical.json` index files predate this field and deserialize to `None`.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub granularity: Option<TemporalGranularity>,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize, Default)]
421pub struct RecentIndex {
422    pub generated_at: String,
423    pub items: Vec<RecentIndexItem>,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct RecentIndexItem {
428    pub id: String,
429    pub title: String,
430    pub updated_at: String,
431    pub last_accessed_at: Option<String>,
432    pub status: DurableMemoryStatus,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, Default)]
436pub struct GraphIndex {
437    pub generated_at: String,
438    pub items: Vec<GraphIndexItem>,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct GraphIndexItem {
443    pub id: String,
444    pub related: Vec<String>,
445    pub supersedes: Vec<String>,
446    pub contradicted_by: Vec<String>,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize, Default)]
450pub struct StaleCandidatesIndex {
451    pub generated_at: String,
452    pub items: Vec<StaleCandidateItem>,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct StaleCandidateItem {
457    pub id: String,
458    pub title: String,
459    pub status: DurableMemoryStatus,
460    pub updated_at: String,
461    pub reason: String,
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize, Default)]
465pub struct TaxonomyIndex {
466    pub generated_at: String,
467    pub by_type: BTreeMap<String, usize>,
468    pub by_status: BTreeMap<String, usize>,
469    pub by_scope: BTreeMap<String, usize>,
470    pub total: usize,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct AuditLogEntry {
475    pub timestamp: String,
476    pub action: String,
477    pub scope: MemoryScope,
478    pub memory_id: Option<String>,
479    pub session_id: Option<String>,
480    pub topic: Option<String>,
481    pub summary: String,
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub metadata: Option<serde_json::Value>,
484}
485
486pub fn parse_rfc3339(value: &str) -> Option<DateTime<Utc>> {
487    chrono::DateTime::parse_from_rfc3339(value)
488        .ok()
489        .map(|dt| dt.with_timezone(&Utc))
490}
491
492pub fn sort_memories_desc(memories: &mut [DurableMemoryDocument]) {
493    memories.sort_by(|left, right| {
494        let left_dt =
495            parse_rfc3339(&left.frontmatter.updated_at).unwrap_or(DateTime::<Utc>::MIN_UTC);
496        let right_dt =
497            parse_rfc3339(&right.frontmatter.updated_at).unwrap_or(DateTime::<Utc>::MIN_UTC);
498        right_dt
499            .cmp(&left_dt)
500            .then_with(|| left.frontmatter.id.cmp(&right.frontmatter.id))
501    });
502}
503
504pub fn match_memory_query(
505    doc: &DurableMemoryDocument,
506    query: Option<&str>,
507    filter_types: Option<&HashSet<DurableMemoryType>>,
508    filter_statuses: Option<&HashSet<DurableMemoryStatus>>,
509) -> Option<f64> {
510    if let Some(types) = filter_types {
511        if !types.contains(&doc.frontmatter.r#type) {
512            return None;
513        }
514    }
515    if let Some(statuses) = filter_statuses {
516        if !statuses.contains(&doc.frontmatter.status) {
517            return None;
518        }
519    }
520
521    let Some(query) = query.map(str::trim).filter(|value| !value.is_empty()) else {
522        return Some(1.0);
523    };
524
525    let query_tokens = extract_keywords(query, "", &[]);
526    if query_tokens.is_empty() {
527        return Some(1.0);
528    }
529
530    let title = doc.frontmatter.title.to_ascii_lowercase();
531    let body = doc.body.to_ascii_lowercase();
532    let keywords: HashSet<String> = doc
533        .frontmatter
534        .retrieval
535        .keywords
536        .iter()
537        .map(|value| value.to_ascii_lowercase())
538        .collect();
539    let tags: HashSet<String> = doc
540        .frontmatter
541        .tags
542        .iter()
543        .map(|value| value.to_ascii_lowercase())
544        .collect();
545    let entities: HashSet<String> = doc
546        .frontmatter
547        .retrieval
548        .entities
549        .iter()
550        .map(|value| value.to_ascii_lowercase())
551        .collect();
552
553    let mut score = 0.0;
554    let mut matched_any = false;
555    for token in &query_tokens {
556        let mut token_score = 0.0;
557        if title.contains(token) {
558            token_score += 3.0;
559        }
560        if keywords.contains(token) {
561            token_score += 2.5;
562        }
563        if tags.contains(token) {
564            token_score += 2.0;
565        }
566        if entities.contains(token) {
567            token_score += 1.5;
568        }
569        if body.contains(token) {
570            token_score += 1.0;
571        }
572        if token_score > 0.0 {
573            matched_any = true;
574            score += token_score;
575        }
576    }
577
578    matched_any.then_some(score / query_tokens.len() as f64)
579}
580
581pub fn build_memory_markdown_view(
582    scope: MemoryScope,
583    project_key: Option<&str>,
584    docs: &[DurableMemoryDocument],
585) -> String {
586    let title = match scope {
587        MemoryScope::Global => "# Bamboo Memory Index (Global)".to_string(),
588        MemoryScope::Project => format!(
589            "# Bamboo Memory Index (Project: {})",
590            project_key.unwrap_or("unknown")
591        ),
592        MemoryScope::Session => "# Bamboo Memory Index (Session)".to_string(),
593    };
594    let mut out = String::new();
595    out.push_str(&title);
596    out.push_str("\n\n");
597    if docs.is_empty() {
598        out.push_str("_(empty)_\n");
599        return out;
600    }
601
602    for doc in docs {
603        out.push_str(&format!(
604            "- `{}` {} [{} / {}] updated {}\n",
605            doc.frontmatter.id,
606            doc.frontmatter.title,
607            doc.frontmatter.r#type.as_str(),
608            doc.frontmatter.status.as_str(),
609            doc.frontmatter.updated_at,
610        ));
611        let summary = derive_summary(&doc.body, 160);
612        if !summary.is_empty() {
613            out.push_str(&format!("  - {}\n", summary));
614        }
615    }
616    out
617}
618
619pub fn build_recent_markdown_view(docs: &[DurableMemoryDocument]) -> String {
620    let mut out = String::from("# Recent Memory Updates\n\n");
621    if docs.is_empty() {
622        out.push_str("_(empty)_\n");
623        return out;
624    }
625    for doc in docs.iter().take(20) {
626        out.push_str(&format!(
627            "- `{}` {} — {}\n",
628            doc.frontmatter.id, doc.frontmatter.title, doc.frontmatter.updated_at
629        ));
630    }
631    out
632}
633
634pub fn build_stale_markdown_view(docs: &[DurableMemoryDocument]) -> String {
635    let mut out = String::from("# Stale Memory Candidates\n\n");
636    let stale: Vec<_> = docs
637        .iter()
638        .filter(|doc| doc.frontmatter.status != DurableMemoryStatus::Active)
639        .collect();
640    if stale.is_empty() {
641        out.push_str("_(no stale items)_\n");
642        return out;
643    }
644    for doc in stale {
645        out.push_str(&format!(
646            "- `{}` {} [{}]\n",
647            doc.frontmatter.id,
648            doc.frontmatter.title,
649            doc.frontmatter.status.as_str()
650        ));
651    }
652    out
653}
654
655pub fn build_dream_view(existing: Option<&str>) -> String {
656    match existing.map(str::trim).filter(|value| !value.is_empty()) {
657        Some(value) => value.to_string(),
658        None => "# Bamboo Dream Notebook\n\n_(empty)_\n".to_string(),
659    }
660}
661
662pub fn parse_query_cursor(cursor: Option<&str>) -> usize {
663    cursor
664        .and_then(|raw| raw.rsplit(':').next())
665        .and_then(|raw| raw.parse::<usize>().ok())
666        .unwrap_or(0)
667}
668
669pub fn make_query_cursor(scope: MemoryScope, offset: usize) -> String {
670    format!("{}:{}", scope.as_str(), offset)
671}
672
673pub fn summary_json(items: usize, total: usize) -> String {
674    if total == 0 {
675        "No matching memories found.".to_string()
676    } else {
677        format!("Returned top {} of {} matching memories.", items, total)
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn normalize_tags_dedupes_and_sanitizes() {
687        let tags = normalize_tags(["User Preference", "user-preference", "release/freeze"]);
688        assert_eq!(tags, vec!["release-freeze", "user-preference"]);
689    }
690
691    #[test]
692    fn project_key_from_path_is_stable() {
693        let key = project_key_from_path(Path::new("/tmp/My Project"));
694        assert!(key.starts_with("my-project-"));
695    }
696
697    #[test]
698    fn parse_markdown_document_requires_frontmatter() {
699        let result = parse_markdown_document("plain body");
700        assert!(result.is_err());
701    }
702
703    /// Back-compat: a memory document written before the temporal-granularity
704    /// dimension existed (its frontmatter has no `granularity` key) must still
705    /// parse, with `granularity` defaulting to `None`.
706    #[test]
707    fn legacy_frontmatter_without_granularity_parses_to_none() {
708        let legacy = "---\n\
709id: mem-legacy\n\
710title: Legacy memory\n\
711type: project\n\
712scope: project\n\
713project_key: proj-1\n\
714status: active\n\
715created_at: 2026-01-01T00:00:00Z\n\
716updated_at: 2026-01-01T00:00:00Z\n\
717created_by:\n  kind: session\n\
718updated_by:\n  kind: memory_write\n\
719---\n\
720Legacy body content.\n";
721        let (frontmatter, body) =
722            parse_markdown_document(legacy).expect("legacy document should parse");
723        assert_eq!(frontmatter.id, "mem-legacy");
724        assert_eq!(frontmatter.granularity, None);
725        assert_eq!(body, "Legacy body content.");
726    }
727
728    /// A granularity set on the frontmatter round-trips through render + parse.
729    #[test]
730    fn granularity_round_trips_through_render_and_parse() {
731        let legacy = "---\n\
732id: mem-legacy\n\
733title: Legacy memory\n\
734type: project\n\
735scope: project\n\
736project_key: proj-1\n\
737status: active\n\
738created_at: 2026-01-01T00:00:00Z\n\
739updated_at: 2026-01-01T00:00:00Z\n\
740created_by:\n  kind: session\n\
741updated_by:\n  kind: memory_write\n\
742---\n\
743Body.\n";
744        let (mut frontmatter, body) = parse_markdown_document(legacy).unwrap();
745        frontmatter.granularity = Some(TemporalGranularity::Quarter);
746
747        let rendered = render_markdown_document(&frontmatter, &body).unwrap();
748        assert!(rendered.contains("granularity: quarter"));
749
750        let (reparsed, _) = parse_markdown_document(&rendered).unwrap();
751        assert_eq!(reparsed.granularity, Some(TemporalGranularity::Quarter));
752    }
753
754    /// `None` granularity must not emit a `granularity:` key (skip_serializing_if),
755    /// so existing documents re-rendered after a load keep their on-disk shape.
756    #[test]
757    fn none_granularity_is_omitted_on_render() {
758        let legacy = "---\n\
759id: mem-legacy\n\
760title: Legacy memory\n\
761type: project\n\
762scope: project\n\
763project_key: proj-1\n\
764status: active\n\
765created_at: 2026-01-01T00:00:00Z\n\
766updated_at: 2026-01-01T00:00:00Z\n\
767created_by:\n  kind: session\n\
768updated_by:\n  kind: memory_write\n\
769---\n\
770Body.\n";
771        let (frontmatter, body) = parse_markdown_document(legacy).unwrap();
772        let rendered = render_markdown_document(&frontmatter, &body).unwrap();
773        assert!(!rendered.contains("granularity"));
774    }
775
776    #[test]
777    fn temporal_granularity_parse_is_case_insensitive_and_rejects_unknown() {
778        assert_eq!(
779            TemporalGranularity::parse("Week"),
780            Some(TemporalGranularity::Week)
781        );
782        assert_eq!(
783            TemporalGranularity::parse("  YEAR "),
784            Some(TemporalGranularity::Year)
785        );
786        assert_eq!(TemporalGranularity::parse("decade"), None);
787    }
788
789    #[test]
790    fn cache_stability_rank_orders_coarse_before_fine_and_none_first() {
791        use TemporalGranularity::*;
792        let rank = TemporalGranularity::cache_stability_rank;
793        // None is treated as most stable, then coarsest → finest.
794        assert!(rank(None) < rank(Some(Year)));
795        assert!(rank(Some(Year)) < rank(Some(Quarter)));
796        assert!(rank(Some(Quarter)) < rank(Some(Month)));
797        assert!(rank(Some(Month)) < rank(Some(Week)));
798        assert!(rank(Some(Week)) < rank(Some(Day)));
799    }
800}