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