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