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