Skip to main content

agent_core/memory/
store.rs

1use std::fs;
2use std::io::{BufRead, BufReader, Write};
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct MemoryRecord {
10    /// Caller-supplied namespace, e.g. "session-notes" or "<plugin-id>".
11    pub namespace: String,
12    /// Unix epoch milliseconds.
13    pub timestamp_ms: u64,
14    /// Free-form text content.
15    pub content: String,
16    /// Optional tag list (e.g. ["@user", "preference"]).
17    #[serde(default)]
18    pub tags: Vec<String>,
19    /// Optional structured metadata. Validated as JSON on read.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub meta: Option<serde_json::Value>,
22    /// Stable record id (`mem-<uuid>`), assigned by [`store_record_in`].
23    /// Absent on pre-T32 legacy lines.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub id: Option<String>,
26    /// Canonical project-scope key. Absent on legacy lines — project-less
27    /// records never match a project scope (fail closed).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub project: Option<String>,
30    /// Who/what produced this record.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub provenance: Option<MemoryProvenance>,
33    /// Sensitivity class (spec §9.5).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub sensitivity: Option<MemorySensitivity>,
36    /// Retention class (consumed by the unified retention sweep).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub retention: Option<MemoryRetention>,
39}
40
41/// Provenance of a memory record (spec §9.5).
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct MemoryProvenance {
44    /// Producer: "user", "model", "tool:<name>", "extension:<id>", …
45    pub source: String,
46    /// Session the record was produced in, when known.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub session: Option<String>,
49}
50
51/// Sensitivity class of a memory record (spec §9.5). Enforcement at the
52/// model-visibility boundary lives in the memory tools: `Secret` bodies are
53/// never returned to model context.
54#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56pub enum MemorySensitivity {
57    Normal,
58    Sensitive,
59    Secret,
60}
61
62/// Retention class of a memory record (consumed by spec §9.7 retention).
63#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum MemoryRetention {
66    /// Kept until explicitly forgotten or swept by disk budget.
67    Standard,
68    /// Expires after the given number of days.
69    MaxAgeDays(u32),
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct MemoryQuery {
74    /// Optional substring match against `content` (case-insensitive).
75    pub content_contains: Option<String>,
76    /// Optional tag prefix; record matches if ANY of its tags has this prefix.
77    pub tag_prefix: Option<String>,
78    /// Inclusive lower bound on `timestamp_ms`.
79    pub since_ms: Option<u64>,
80    /// Inclusive upper bound on `timestamp_ms`.
81    pub until_ms: Option<u64>,
82    /// Maximum number of records to return (most recent first). Default: 50.
83    pub limit: Option<usize>,
84}
85
86/// Default per-query record cap.
87pub const DEFAULT_LIMIT: usize = 50;
88
89/// Maximum content length per record (UTF-8 byte length).
90pub const MAX_CONTENT_BYTES: usize = 16 * 1024;
91
92#[derive(Debug)]
93pub enum MemoryError {
94    InvalidNamespace(String),
95    ContentTooLarge {
96        len: usize,
97        max: usize,
98    },
99    Io(String),
100    Serde(String),
101    /// The record id does not exist IN THIS PROJECT SCOPE (also returned
102    /// for other projects' ids — fail closed without existence disclosure).
103    NotFound(String),
104    /// The project root could not be canonicalized.
105    InvalidProjectRoot(String),
106}
107
108impl std::fmt::Display for MemoryError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            MemoryError::InvalidNamespace(s) => write!(f, "invalid namespace: {s:?}"),
112            MemoryError::ContentTooLarge { len, max } => {
113                write!(f, "content too large: {len} bytes (max {max})")
114            }
115            MemoryError::Io(s) => write!(f, "memory io error: {s}"),
116            MemoryError::Serde(s) => write!(f, "memory serde error: {s}"),
117            MemoryError::NotFound(id) => {
118                write!(f, "memory record not found in this project scope: {id}")
119            }
120            MemoryError::InvalidProjectRoot(s) => write!(f, "invalid project root: {s}"),
121        }
122    }
123}
124
125impl std::error::Error for MemoryError {}
126
127impl From<std::io::Error> for MemoryError {
128    fn from(e: std::io::Error) -> Self {
129        MemoryError::Io(e.to_string())
130    }
131}
132
133impl From<serde_json::Error> for MemoryError {
134    fn from(e: serde_json::Error) -> Self {
135        MemoryError::Serde(e.to_string())
136    }
137}
138
139/// Current Unix epoch in milliseconds.
140pub fn now_ms() -> u64 {
141    SystemTime::now()
142        .duration_since(UNIX_EPOCH)
143        .map(|d| d.as_millis() as u64)
144        .unwrap_or(0)
145}
146
147fn validate_namespace(ns: &str) -> Result<(), MemoryError> {
148    if ns.is_empty()
149        || ns.len() > 64
150        || ns.contains('/')
151        || ns.contains('\\')
152        || ns.contains("..")
153        || ns.chars().any(|c| c.is_whitespace())
154    {
155        return Err(MemoryError::InvalidNamespace(ns.to_string()));
156    }
157    Ok(())
158}
159
160/// Path to the memory directory under base. Caller creates dirs lazily.
161pub fn memory_dir() -> PathBuf {
162    crate::config::base_dir().join("memory")
163}
164
165/// Memory directory under an explicit base (test/tool seam).
166pub fn memory_dir_in(base: &Path) -> PathBuf {
167    base.join("memory")
168}
169
170fn namespace_path(dir: &Path, ns: &str) -> PathBuf {
171    dir.join(format!("{ns}.jsonl"))
172}
173
174/// Append one record. Validates namespace, content size. Atomic per-line via O_APPEND.
175pub fn append(record: &MemoryRecord) -> Result<(), MemoryError> {
176    append_to(&crate::config::base_dir(), record)
177}
178
179pub fn append_to(base: &Path, record: &MemoryRecord) -> Result<(), MemoryError> {
180    validate_namespace(&record.namespace)?;
181    if record.content.len() > MAX_CONTENT_BYTES {
182        return Err(MemoryError::ContentTooLarge {
183            len: record.content.len(),
184            max: MAX_CONTENT_BYTES,
185        });
186    }
187    let dir = memory_dir_in(base);
188    crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
189    let path = namespace_path(&dir, &record.namespace);
190    let line = serde_json::to_string(record)?;
191    append_jsonl_line(&path, &line)
192}
193
194/// Append one JSONL line with torn-tail healing: if a previous writer
195/// crashed mid-line (file does not end in a newline), a leading newline
196/// isolates the torn fragment on its own line — lenient readers skip the
197/// fragment and every subsequent record stays parseable.
198fn append_jsonl_line(path: &Path, line: &str) -> Result<(), MemoryError> {
199    let needs_heal = match fs::metadata(path) {
200        Ok(meta) if meta.len() > 0 => {
201            use std::io::{Read, Seek, SeekFrom};
202            let mut f = fs::File::open(path)?;
203            f.seek(SeekFrom::End(-1))?;
204            let mut last = [0u8; 1];
205            f.read_exact(&mut last)?;
206            last[0] != b'\n'
207        }
208        _ => false,
209    };
210    let mut f = crate::core::private_fs::open_private_append(path).map_err(std::io::Error::from)?;
211    let mut out = String::with_capacity(line.len() + 2);
212    if needs_heal {
213        out.push('\n');
214    }
215    out.push_str(line);
216    out.push('\n');
217    f.write_all(out.as_bytes())?;
218    Ok(())
219}
220
221/// Query records in a namespace, applying filters, returning most-recent-first up to limit.
222pub fn query(namespace: &str, q: &MemoryQuery) -> Result<Vec<MemoryRecord>, MemoryError> {
223    query_in(&crate::config::base_dir(), namespace, q)
224}
225
226pub fn query_in(
227    base: &Path,
228    namespace: &str,
229    q: &MemoryQuery,
230) -> Result<Vec<MemoryRecord>, MemoryError> {
231    validate_namespace(namespace)?;
232    let path = namespace_path(&memory_dir_in(base), namespace);
233    if !path.exists() {
234        return Ok(Vec::new());
235    }
236    let f = fs::File::open(&path)?;
237    let reader = BufReader::new(f);
238    let needle = q.content_contains.as_ref().map(|s| s.to_lowercase());
239    let mut out: Vec<MemoryRecord> = Vec::new();
240    for line in reader.lines() {
241        let line = match line {
242            Ok(l) => l,
243            Err(_) => continue,
244        };
245        if line.trim().is_empty() {
246            continue;
247        }
248        let rec: MemoryRecord = match serde_json::from_str(&line) {
249            Ok(r) => r,
250            Err(_) => continue,
251        };
252        if let Some(since) = q.since_ms {
253            if rec.timestamp_ms < since {
254                continue;
255            }
256        }
257        if let Some(until) = q.until_ms {
258            if rec.timestamp_ms > until {
259                continue;
260            }
261        }
262        if let Some(needle) = &needle {
263            if !rec.content.to_lowercase().contains(needle) {
264                continue;
265            }
266        }
267        if let Some(prefix) = &q.tag_prefix {
268            if !rec.tags.iter().any(|t| t.starts_with(prefix)) {
269                continue;
270            }
271        }
272        out.push(rec);
273    }
274    out.sort_by_key(|b| std::cmp::Reverse(b.timestamp_ms));
275    let limit = q.limit.unwrap_or(DEFAULT_LIMIT);
276    out.truncate(limit);
277    Ok(out)
278}
279
280/// List existing namespaces under the memory dir.
281pub fn list_namespaces() -> Result<Vec<String>, MemoryError> {
282    list_namespaces_in(&crate::config::base_dir())
283}
284
285pub(crate) fn list_namespaces_in(base: &Path) -> Result<Vec<String>, MemoryError> {
286    let dir = memory_dir_in(base);
287    if !dir.exists() {
288        return Ok(Vec::new());
289    }
290    let mut out = Vec::new();
291    for entry in fs::read_dir(&dir)? {
292        let entry = entry?;
293        let path = entry.path();
294        if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
295            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
296                out.push(stem.to_string());
297            }
298        }
299    }
300    out.sort();
301    Ok(out)
302}
303
304/// Build a record with `now_ms()` timestamp.
305pub fn new_record(
306    namespace: impl Into<String>,
307    content: impl Into<String>,
308    tags: Vec<String>,
309    meta: Option<serde_json::Value>,
310) -> MemoryRecord {
311    MemoryRecord {
312        namespace: namespace.into(),
313        timestamp_ms: now_ms(),
314        content: content.into(),
315        tags,
316        meta,
317        id: None,
318        project: None,
319        provenance: None,
320        sensitivity: None,
321        retention: None,
322    }
323}
324
325// ─── Task 32: project-scoped progressive memory (spec §9.5) ─────────────────
326
327/// Host-resolved project identity: the canonical workspace root, digested
328/// into a stable scope key. The HOST constructs this from trusted execution
329/// context — it is never accepted solely from model-authored arguments.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct ProjectScope {
332    key: String,
333    root: PathBuf,
334}
335
336impl ProjectScope {
337    /// Resolve a scope from a workspace root. The path is canonicalized so
338    /// every spelling of the same directory yields one project identity.
339    pub fn for_root(root: &Path) -> Result<Self, MemoryError> {
340        let canonical = root
341            .canonicalize()
342            .map_err(|e| MemoryError::InvalidProjectRoot(format!("{}: {e}", root.display())))?;
343        use sha2::{Digest, Sha256};
344        let mut hasher = Sha256::new();
345        hasher.update(canonical.as_os_str().as_encoded_bytes());
346        let digest = hasher.finalize();
347        let mut key = String::with_capacity(17);
348        key.push('p');
349        for byte in digest.iter().take(8) {
350            use std::fmt::Write;
351            let _ = write!(key, "{byte:02x}");
352        }
353        Ok(Self {
354            key,
355            root: canonical,
356        })
357    }
358
359    /// Stable scope key (`p<16 hex>`).
360    pub fn key(&self) -> &str {
361        &self.key
362    }
363
364    /// Discover the stable project root for a starting directory (CP-13
365    /// fix1 I2): the `SYNAPS_PROJECT_ROOT` env override wins; otherwise a
366    /// bounded upward walk finds the nearest directory containing a `.git`
367    /// entry (DIRECTORY or FILE — worktree-safe, no process spawning, no
368    /// gitdir parsing) or a `.synaps-project` marker; with no marker the
369    /// start directory itself is the root.
370    pub fn discover(start: &Path) -> Result<Self, MemoryError> {
371        let override_root = std::env::var_os("SYNAPS_PROJECT_ROOT").map(std::path::PathBuf::from);
372        Self::discover_with_override(start, override_root.as_deref())
373    }
374
375    /// [`Self::discover`] with an explicit override (env-free seam).
376    pub fn discover_with_override(
377        start: &Path,
378        override_root: Option<&Path>,
379    ) -> Result<Self, MemoryError> {
380        if let Some(root) = override_root {
381            return Self::for_root(root);
382        }
383        const MARKERS: [&str; 2] = [".git", ".synaps-project"];
384        const MAX_ASCENT: usize = 64;
385        let canonical_start = start
386            .canonicalize()
387            .map_err(|e| MemoryError::InvalidProjectRoot(format!("{}: {e}", start.display())))?;
388        let mut dir = canonical_start.as_path();
389        for _ in 0..MAX_ASCENT {
390            if MARKERS
391                .iter()
392                .any(|marker| dir.join(marker).symlink_metadata().is_ok())
393            {
394                return Self::for_root(dir);
395            }
396            match dir.parent() {
397                Some(parent) => dir = parent,
398                None => break,
399            }
400        }
401        Self::for_root(&canonical_start)
402    }
403
404    /// The canonical workspace root.
405    pub fn root(&self) -> &Path {
406        &self.root
407    }
408
409    /// The JSONL namespace holding this project's records.
410    pub fn namespace(&self) -> String {
411        format!("project-{}", self.key)
412    }
413}
414
415/// Input for [`store_record_in`] — explicit provenance, sensitivity, and
416/// retention (spec §9.5: nothing is stored with implicit metadata).
417#[derive(Debug, Clone)]
418pub struct NewMemoryRecord {
419    pub content: String,
420    pub tags: Vec<String>,
421    pub provenance: MemoryProvenance,
422    pub sensitivity: MemorySensitivity,
423    pub retention: MemoryRetention,
424}
425
426/// Bounded search result: descriptor + snippet, NEVER the full body (full
427/// content requires an exact [`fetch_exact_in`]).
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct MemoryDescriptor {
430    pub id: String,
431    pub project: String,
432    pub timestamp_ms: u64,
433    pub tags: Vec<String>,
434    /// UTF-8-boundary-bounded head of the content.
435    pub snippet: String,
436    /// Whether the snippet was cut.
437    pub truncated: bool,
438    /// Full content length in bytes (size disclosure only).
439    pub content_bytes: usize,
440    pub sensitivity: MemorySensitivity,
441    pub retention: MemoryRetention,
442}
443
444/// Hard cap on search results per call.
445pub const MAX_SEARCH_LIMIT: usize = 25;
446/// Default search result count.
447pub const DEFAULT_SEARCH_LIMIT: usize = 8;
448/// Hard cap on snippet bytes per descriptor.
449pub const MAX_SNIPPET_BYTES: usize = 400;
450/// Default snippet bytes per descriptor.
451pub const DEFAULT_SNIPPET_BYTES: usize = 160;
452
453/// Project-scoped search query. Limits clamp to the hard caps.
454#[derive(Debug, Clone, Default)]
455pub struct ProjectMemoryQuery {
456    pub content_contains: Option<String>,
457    pub tag_prefix: Option<String>,
458    pub since_ms: Option<u64>,
459    pub until_ms: Option<u64>,
460    pub limit: Option<usize>,
461    pub snippet_bytes: Option<usize>,
462}
463
464/// One parsed line of a project namespace file: a record or a tombstone.
465/// Tombstones are append-only (`{"tombstone":"<id>","timestamp_ms":…}`);
466/// physical deletion belongs to the retention sweep. Legacy readers skip
467/// tombstone lines as malformed records — forward compatible.
468#[derive(Debug, Serialize, Deserialize)]
469struct TombstoneLine {
470    tombstone: String,
471    timestamp_ms: u64,
472}
473
474/// Store one record in a project scope: assigns a stable `mem-<uuid>` id,
475/// binds the scope key, and appends to the project namespace.
476pub fn store_record_in(
477    base: &Path,
478    scope: &ProjectScope,
479    new: NewMemoryRecord,
480) -> Result<MemoryRecord, MemoryError> {
481    let record = MemoryRecord {
482        namespace: scope.namespace(),
483        timestamp_ms: now_ms(),
484        content: new.content,
485        tags: new.tags,
486        meta: None,
487        id: Some(format!("mem-{}", uuid::Uuid::new_v4().simple())),
488        project: Some(scope.key().to_string()),
489        provenance: Some(new.provenance),
490        sensitivity: Some(new.sensitivity),
491        retention: Some(new.retention),
492    };
493    append_to(base, &record)?;
494    Ok(record)
495}
496
497/// Load the LIVE (non-tombstoned, scope-matching) records of a project.
498/// Records whose inner `project` key does not match the scope are excluded
499/// — fail closed even if a foreign file was copied into the namespace.
500fn load_live_project_records(
501    base: &Path,
502    scope: &ProjectScope,
503) -> Result<Vec<MemoryRecord>, MemoryError> {
504    let path = namespace_path(&memory_dir_in(base), &scope.namespace());
505    if !path.exists() {
506        return Ok(Vec::new());
507    }
508    let f = fs::File::open(&path)?;
509    let reader = BufReader::new(f);
510    let mut live: Vec<MemoryRecord> = Vec::new();
511    let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
512    for line in reader.lines() {
513        let line = match line {
514            Ok(l) => l,
515            Err(_) => continue,
516        };
517        if line.trim().is_empty() {
518            continue;
519        }
520        if let Ok(tomb) = serde_json::from_str::<TombstoneLine>(&line) {
521            tombstoned.insert(tomb.tombstone);
522            continue;
523        }
524        let rec: MemoryRecord = match serde_json::from_str(&line) {
525            Ok(r) => r,
526            Err(_) => continue,
527        };
528        // Fail closed: only records carrying THIS scope's key participate.
529        if rec.project.as_deref() != Some(scope.key()) || rec.id.is_none() {
530            continue;
531        }
532        live.push(rec);
533    }
534    live.retain(|r| {
535        r.id.as_ref()
536            .map(|id| !tombstoned.contains(id))
537            .unwrap_or(false)
538    });
539    Ok(live)
540}
541
542/// Project-scoped bounded search: descriptors + snippets, most recent
543/// first, hard-capped result count and snippet bytes.
544pub fn search_project_in(
545    base: &Path,
546    scope: &ProjectScope,
547    q: &ProjectMemoryQuery,
548) -> Result<Vec<MemoryDescriptor>, MemoryError> {
549    let needle = q.content_contains.as_ref().map(|s| s.to_lowercase());
550    let snippet_bytes = q
551        .snippet_bytes
552        .unwrap_or(DEFAULT_SNIPPET_BYTES)
553        .min(MAX_SNIPPET_BYTES);
554    let limit = q
555        .limit
556        .unwrap_or(DEFAULT_SEARCH_LIMIT)
557        .min(MAX_SEARCH_LIMIT);
558
559    let mut records = load_live_project_records(base, scope)?;
560    records.retain(|rec| {
561        let secret = rec.sensitivity == Some(MemorySensitivity::Secret);
562        if let Some(since) = q.since_ms {
563            if rec.timestamp_ms < since {
564                return false;
565            }
566        }
567        if let Some(until) = q.until_ms {
568            if rec.timestamp_ms > until {
569                return false;
570            }
571        }
572        if let Some(needle) = &needle {
573            // Secret bodies never participate in content matching — a
574            // substring probe must not confirm secret content.
575            if secret || !rec.content.to_lowercase().contains(needle) {
576                return false;
577            }
578        }
579        if let Some(prefix) = &q.tag_prefix {
580            if !rec.tags.iter().any(|t| t.starts_with(prefix)) {
581                return false;
582            }
583        }
584        true
585    });
586    records.sort_by_key(|r| std::cmp::Reverse(r.timestamp_ms));
587    records.truncate(limit);
588
589    Ok(records
590        .into_iter()
591        .map(|rec| {
592            let sensitivity = rec.sensitivity.unwrap_or(MemorySensitivity::Normal);
593            // Secret descriptors carry NO body-derived snippet text (spec
594            // §9.5: secret bodies are visible locally only).
595            let (snippet, truncated) = if sensitivity == MemorySensitivity::Secret {
596                (String::new(), false)
597            } else {
598                let bounded = crate::text::BoundedText::new(&rec.content, snippet_bytes);
599                (bounded.text, bounded.truncated)
600            };
601            MemoryDescriptor {
602                id: rec.id.clone().unwrap_or_default(),
603                project: scope.key().to_string(),
604                timestamp_ms: rec.timestamp_ms,
605                tags: rec.tags,
606                snippet,
607                truncated,
608                content_bytes: rec.content.len(),
609                sensitivity,
610                retention: rec.retention.unwrap_or(MemoryRetention::Standard),
611            }
612        })
613        .collect())
614}
615
616/// Fetch exact record ids within a project scope. EVERY id must resolve in
617/// this scope or the whole call fails closed with [`MemoryError::NotFound`]
618/// — other projects' ids are indistinguishable from unknown ids.
619pub fn fetch_exact_in(
620    base: &Path,
621    scope: &ProjectScope,
622    ids: &[&str],
623) -> Result<Vec<MemoryRecord>, MemoryError> {
624    let live = load_live_project_records(base, scope)?;
625    let mut out = Vec::with_capacity(ids.len());
626    for wanted in ids {
627        match live.iter().find(|r| r.id.as_deref() == Some(*wanted)) {
628            Some(rec) => out.push(rec.clone()),
629            None => return Err(MemoryError::NotFound((*wanted).to_string())),
630        }
631    }
632    Ok(out)
633}
634
635/// Tombstone one record in a project scope (append-only forget). Fails
636/// closed with [`MemoryError::NotFound`] for unknown, already-tombstoned,
637/// or other-project ids.
638pub fn forget_in(base: &Path, scope: &ProjectScope, id: &str) -> Result<(), MemoryError> {
639    let live = load_live_project_records(base, scope)?;
640    if !live.iter().any(|r| r.id.as_deref() == Some(id)) {
641        return Err(MemoryError::NotFound(id.to_string()));
642    }
643    let dir = memory_dir_in(base);
644    crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
645    let path = namespace_path(&dir, &scope.namespace());
646    let line = serde_json::to_string(&TombstoneLine {
647        tombstone: id.to_string(),
648        timestamp_ms: now_ms(),
649    })?;
650    append_jsonl_line(&path, &line)
651}
652
653/// Path of a project namespace's JSONL store file (index-module seam).
654pub(crate) fn namespace_file(base: &Path, namespace: &str) -> PathBuf {
655    namespace_path(&memory_dir_in(base), namespace)
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use tempfile::TempDir;
662
663    fn rec(ns: &str, ts: u64, content: &str, tags: Vec<&str>) -> MemoryRecord {
664        MemoryRecord {
665            namespace: ns.to_string(),
666            timestamp_ms: ts,
667            content: content.to_string(),
668            tags: tags.into_iter().map(String::from).collect(),
669            meta: None,
670            id: None,
671            project: None,
672            provenance: None,
673            sensitivity: None,
674            retention: None,
675        }
676    }
677
678    #[test]
679    fn append_then_query_returns_record() {
680        let tmp = TempDir::new().unwrap();
681        let r = rec("ns", 100, "hello world", vec!["@user"]);
682        append_to(tmp.path(), &r).unwrap();
683        let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
684        assert_eq!(got, vec![r]);
685    }
686
687    #[test]
688    fn query_filters_by_content_contains() {
689        let tmp = TempDir::new().unwrap();
690        append_to(tmp.path(), &rec("ns", 100, "Hello World", vec![])).unwrap();
691        append_to(tmp.path(), &rec("ns", 200, "goodbye", vec![])).unwrap();
692        let q = MemoryQuery {
693            content_contains: Some("hello".to_string()),
694            ..Default::default()
695        };
696        let got = query_in(tmp.path(), "ns", &q).unwrap();
697        assert_eq!(got.len(), 1);
698        assert_eq!(got[0].content, "Hello World");
699    }
700
701    #[test]
702    fn query_filters_by_tag_prefix() {
703        let tmp = TempDir::new().unwrap();
704        append_to(
705            tmp.path(),
706            &rec("ns", 100, "x", vec!["@user", "preference"]),
707        )
708        .unwrap();
709        let got = query_in(
710            tmp.path(),
711            "ns",
712            &MemoryQuery {
713                tag_prefix: Some("@u".into()),
714                ..Default::default()
715            },
716        )
717        .unwrap();
718        assert_eq!(got.len(), 1);
719        let got = query_in(
720            tmp.path(),
721            "ns",
722            &MemoryQuery {
723                tag_prefix: Some("@x".into()),
724                ..Default::default()
725            },
726        )
727        .unwrap();
728        assert!(got.is_empty());
729    }
730
731    #[test]
732    fn query_filters_by_time_range() {
733        let tmp = TempDir::new().unwrap();
734        append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
735        append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
736        append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
737        let got = query_in(
738            tmp.path(),
739            "ns",
740            &MemoryQuery {
741                since_ms: Some(150),
742                until_ms: Some(250),
743                ..Default::default()
744            },
745        )
746        .unwrap();
747        assert_eq!(got.len(), 1);
748        assert_eq!(got[0].timestamp_ms, 200);
749    }
750
751    #[test]
752    fn query_returns_most_recent_first() {
753        let tmp = TempDir::new().unwrap();
754        append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
755        append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
756        append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
757        let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
758        let ts: Vec<u64> = got.iter().map(|r| r.timestamp_ms).collect();
759        assert_eq!(ts, vec![300, 200, 100]);
760    }
761
762    #[test]
763    fn query_respects_limit() {
764        let tmp = TempDir::new().unwrap();
765        for i in 1..=5 {
766            append_to(tmp.path(), &rec("ns", i * 100, "x", vec![])).unwrap();
767        }
768        let got = query_in(
769            tmp.path(),
770            "ns",
771            &MemoryQuery {
772                limit: Some(2),
773                ..Default::default()
774            },
775        )
776        .unwrap();
777        assert_eq!(got.len(), 2);
778        assert_eq!(got[0].timestamp_ms, 500);
779        assert_eq!(got[1].timestamp_ms, 400);
780    }
781
782    #[test]
783    fn query_skips_malformed_lines() {
784        let tmp = TempDir::new().unwrap();
785        let dir = memory_dir_in(tmp.path());
786        fs::create_dir_all(&dir).unwrap();
787        let path = namespace_path(&dir, "ns");
788        let v1 = serde_json::to_string(&rec("ns", 100, "a", vec![])).unwrap();
789        let v2 = serde_json::to_string(&rec("ns", 200, "b", vec![])).unwrap();
790        let body = format!("invalid json\n{v1}\nnot json either\n{v2}\n");
791        fs::write(&path, body).unwrap();
792        let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
793        assert_eq!(got.len(), 2);
794    }
795
796    #[test]
797    fn append_rejects_oversized_content() {
798        let tmp = TempDir::new().unwrap();
799        let big = "x".repeat(MAX_CONTENT_BYTES + 1);
800        let r = rec("ns", 1, &big, vec![]);
801        match append_to(tmp.path(), &r) {
802            Err(MemoryError::ContentTooLarge { .. }) => {}
803            other => panic!("expected ContentTooLarge, got {other:?}"),
804        }
805    }
806
807    #[test]
808    fn append_rejects_invalid_namespace() {
809        let tmp = TempDir::new().unwrap();
810        let cases = [
811            "",
812            "a/b",
813            "a\\b",
814            "..",
815            "a..b",
816            "has space",
817            "tab\there",
818            &"x".repeat(65),
819        ];
820        for ns in cases {
821            let r = rec(ns, 1, "x", vec![]);
822            match append_to(tmp.path(), &r) {
823                Err(MemoryError::InvalidNamespace(_)) => {}
824                other => panic!("expected InvalidNamespace for {ns:?}, got {other:?}"),
825            }
826        }
827    }
828
829    #[test]
830    fn list_namespaces_returns_existing_files() {
831        let tmp = TempDir::new().unwrap();
832        append_to(tmp.path(), &rec("alpha", 1, "x", vec![])).unwrap();
833        append_to(tmp.path(), &rec("beta", 1, "x", vec![])).unwrap();
834        let got = list_namespaces_in(tmp.path()).unwrap();
835        assert_eq!(got, vec!["alpha".to_string(), "beta".to_string()]);
836    }
837
838    #[test]
839    fn list_namespaces_on_missing_dir_returns_empty() {
840        let tmp = TempDir::new().unwrap();
841        let got = list_namespaces_in(tmp.path()).unwrap();
842        assert!(got.is_empty());
843    }
844
845    #[test]
846    fn query_on_missing_namespace_returns_empty() {
847        let tmp = TempDir::new().unwrap();
848        let got = query_in(tmp.path(), "nope", &MemoryQuery::default()).unwrap();
849        assert!(got.is_empty());
850    }
851
852    /// Private-mode tests (spec §5.4). Umask isolation: see
853    /// `private_fs::test_support::UmaskGuard` — `#[serial(umask)]` serializes
854    /// every umask-mutating test in the crate, and the guard restores the old
855    /// mask on drop (panic-safe).
856    #[cfg(unix)]
857    mod private_modes {
858        use super::*;
859        use crate::core::private_fs::test_support::UmaskGuard;
860        use serial_test::serial;
861        use std::os::unix::fs::PermissionsExt;
862
863        fn mode_of(path: &std::path::Path) -> u32 {
864            fs::metadata(path).unwrap().permissions().mode() & 0o777
865        }
866
867        #[test]
868        #[serial(umask)]
869        fn append_creates_0600_file_and_0700_dir_under_permissive_umask() {
870            let _umask = UmaskGuard::set(0);
871            let tmp = TempDir::new().unwrap();
872            append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
873            let dir = memory_dir_in(tmp.path());
874            assert_eq!(mode_of(&dir), 0o700, "memory dir must be 0700");
875            assert_eq!(
876                mode_of(&dir.join("ns.jsonl")),
877                0o600,
878                "memory file must be 0600"
879            );
880        }
881
882        #[test]
883        fn append_refuses_symlink_target() {
884            let tmp = TempDir::new().unwrap();
885            let dir = memory_dir_in(tmp.path());
886            fs::create_dir_all(&dir).unwrap();
887            let victim = tmp.path().join("victim.txt");
888            fs::write(&victim, "").unwrap();
889            std::os::unix::fs::symlink(&victim, dir.join("ns.jsonl")).unwrap();
890            let res = append_to(tmp.path(), &rec("ns", 1, "secret", vec![]));
891            assert!(res.is_err(), "append through a symlink must fail");
892            assert_eq!(
893                fs::read_to_string(&victim).unwrap(),
894                "",
895                "no bytes may be written through the planted symlink"
896            );
897        }
898
899        #[test]
900        fn append_repairs_preexisting_broad_mode() {
901            let tmp = TempDir::new().unwrap();
902            let dir = memory_dir_in(tmp.path());
903            fs::create_dir_all(&dir).unwrap();
904            let file = dir.join("ns.jsonl");
905            fs::write(&file, "").unwrap();
906            fs::set_permissions(&file, fs::Permissions::from_mode(0o666)).unwrap();
907            append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
908            assert_eq!(
909                mode_of(&file),
910                0o600,
911                "broad pre-existing mode must be repaired"
912            );
913        }
914    }
915}