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"]). Absent and `null`
17    /// both read as no tags.
18    #[serde(default, deserialize_with = "null_as_no_tags")]
19    pub tags: Vec<String>,
20    /// Optional structured metadata. Validated as JSON on read.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub meta: Option<serde_json::Value>,
23    /// Stable record id (`mem-<uuid>`), assigned by [`store_record_in`].
24    /// Absent on pre-T32 legacy lines.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub id: Option<String>,
27    /// Canonical project-scope key. Absent on legacy lines — project-less
28    /// records never match a project scope (fail closed).
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub project: Option<String>,
31    /// Who/what produced this record.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub provenance: Option<MemoryProvenance>,
34    /// Sensitivity class (spec §9.5).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub sensitivity: Option<MemorySensitivity>,
37    /// Retention class (consumed by the unified retention sweep).
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub retention: Option<MemoryRetention>,
40}
41
42/// `"tags": null` says what an absent `tags` says.
43fn null_as_no_tags<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<Vec<String>, D::Error> {
44    Ok(Option::<Vec<String>>::deserialize(reader)?.unwrap_or_default())
45}
46
47/// Provenance of a memory record (spec §9.5).
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct MemoryProvenance {
50    /// Producer: "user", "model", "tool:<name>", "extension:<id>", …
51    pub source: String,
52    /// Session the record was produced in, when known.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub session: Option<String>,
55}
56
57/// Sensitivity class of a memory record (spec §9.5). Enforcement at the
58/// model-visibility boundary lives in the memory tools: `Secret` bodies are
59/// never returned to model context.
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "snake_case")]
62pub enum MemorySensitivity {
63    Normal,
64    Sensitive,
65    Secret,
66}
67
68/// Retention class of a memory record (consumed by spec §9.7 retention).
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "snake_case")]
71pub enum MemoryRetention {
72    /// Kept until explicitly forgotten or swept by disk budget.
73    Standard,
74    /// Expires after the given number of days.
75    MaxAgeDays(u32),
76}
77
78#[derive(Debug, Clone, Default)]
79pub struct MemoryQuery {
80    /// Optional substring match against `content` (case-insensitive).
81    pub content_contains: Option<String>,
82    /// Optional tag prefix; record matches if ANY of its tags has this prefix.
83    pub tag_prefix: Option<String>,
84    /// Inclusive lower bound on `timestamp_ms`.
85    pub since_ms: Option<u64>,
86    /// Inclusive upper bound on `timestamp_ms`.
87    pub until_ms: Option<u64>,
88    /// Maximum number of records to return (most recent first). Default: 50.
89    pub limit: Option<usize>,
90}
91
92/// Default per-query record cap.
93pub const DEFAULT_LIMIT: usize = 50;
94
95/// Maximum content length per record (UTF-8 byte length).
96pub const MAX_CONTENT_BYTES: usize = 16 * 1024;
97
98#[derive(Debug)]
99pub enum MemoryError {
100    InvalidNamespace(String),
101    ContentTooLarge {
102        len: usize,
103        max: usize,
104    },
105    Io(String),
106    Serde(String),
107    /// The record id does not exist IN THIS PROJECT SCOPE (also returned
108    /// for other projects' ids — fail closed without existence disclosure).
109    NotFound(String),
110    /// The project root could not be canonicalized.
111    InvalidProjectRoot(String),
112    /// The line (a note, or the tombstone of a forget) IS in the file, whole,
113    /// and every reader sees it, but a sync after the write failed, so it is
114    /// not confirmed to be on disk. Not a failed save: doing it again makes
115    /// a second note under a new id, and a second forget answers
116    /// [`MemoryError::NotFound`]. Holds the id of the note saved or
117    /// forgotten, when there is one (a record without an id, or the end of
118    /// an import run, has none), and the sync's error.
119    WrittenUnconfirmed { id: Option<String>, cause: String },
120}
121
122impl MemoryError {
123    /// A sync failure after the write, before the caller knows the id.
124    fn unconfirmed(cause: impl std::fmt::Display) -> Self {
125        MemoryError::WrittenUnconfirmed {
126            id: None,
127            cause: cause.to_string(),
128        }
129    }
130
131    /// This error with `id` as the id of an unconfirmed write.
132    fn with_id(self, id: Option<&str>) -> Self {
133        match self {
134            MemoryError::WrittenUnconfirmed { id: None, cause } => {
135                MemoryError::WrittenUnconfirmed {
136                    id: id.map(str::to_string),
137                    cause,
138                }
139            }
140            other => other,
141        }
142    }
143}
144
145impl std::fmt::Display for MemoryError {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            MemoryError::InvalidNamespace(s) => write!(f, "invalid namespace: {s:?}"),
149            MemoryError::ContentTooLarge { len, max } => {
150                write!(f, "content too large: {len} bytes (max {max})")
151            }
152            MemoryError::Io(s) => write!(f, "memory io error: {s}"),
153            MemoryError::Serde(s) => write!(f, "memory serde error: {s}"),
154            MemoryError::NotFound(id) => {
155                write!(f, "memory record not found in this project scope: {id}")
156            }
157            MemoryError::InvalidProjectRoot(s) => write!(f, "invalid project root: {s}"),
158            MemoryError::WrittenUnconfirmed { id: None, cause } => write!(
159                f,
160                "written, but not confirmed on disk (the sync failed: {cause}); \
161                 it is in the store now, so look before doing it again"
162            ),
163            MemoryError::WrittenUnconfirmed {
164                id: Some(id),
165                cause,
166            } => write!(
167                f,
168                "written, but not confirmed on disk (the sync failed: {cause}); \
169                 {id} is in the store now, so fetch {id} to check before doing it again"
170            ),
171        }
172    }
173}
174
175impl std::error::Error for MemoryError {}
176
177impl From<std::io::Error> for MemoryError {
178    fn from(e: std::io::Error) -> Self {
179        MemoryError::Io(e.to_string())
180    }
181}
182
183impl From<serde_json::Error> for MemoryError {
184    fn from(e: serde_json::Error) -> Self {
185        MemoryError::Serde(e.to_string())
186    }
187}
188
189/// Current Unix epoch in milliseconds.
190pub fn now_ms() -> u64 {
191    SystemTime::now()
192        .duration_since(UNIX_EPOCH)
193        .map(|d| d.as_millis() as u64)
194        .unwrap_or(0)
195}
196
197fn validate_namespace(ns: &str) -> Result<(), MemoryError> {
198    if ns.is_empty()
199        || ns.len() > 64
200        || ns.contains('/')
201        || ns.contains('\\')
202        || ns.contains("..")
203        || ns.chars().any(|c| c.is_whitespace())
204    {
205        return Err(MemoryError::InvalidNamespace(ns.to_string()));
206    }
207    Ok(())
208}
209
210/// Path to the memory directory under base. Caller creates dirs lazily.
211pub fn memory_dir() -> PathBuf {
212    crate::config::base_dir().join("memory")
213}
214
215/// Memory directory under an explicit base (test/tool seam).
216pub fn memory_dir_in(base: &Path) -> PathBuf {
217    base.join("memory")
218}
219
220fn namespace_path(dir: &Path, ns: &str) -> PathBuf {
221    dir.join(format!("{ns}.jsonl"))
222}
223
224/// Append one record. Validates namespace, content size. One line is one
225/// `write` to a file opened for append, and the file is synced before this
226/// returns (see `append_jsonl_line`).
227pub fn append(record: &MemoryRecord) -> Result<(), MemoryError> {
228    append_to(&crate::config::base_dir(), record)
229}
230
231pub fn append_to(base: &Path, record: &MemoryRecord) -> Result<(), MemoryError> {
232    append_record(base, record, true)
233}
234
235/// [`append_to`]; with `sync_now` false the sync is left to the caller (an
236/// import, which syncs each file once at the end of its run).
237fn append_record(base: &Path, record: &MemoryRecord, sync_now: bool) -> Result<(), MemoryError> {
238    validate_namespace(&record.namespace)?;
239    if record.content.len() > MAX_CONTENT_BYTES {
240        return Err(MemoryError::ContentTooLarge {
241            len: record.content.len(),
242            max: MAX_CONTENT_BYTES,
243        });
244    }
245    let dir = memory_dir_in(base);
246    let new_dir = !dir.exists();
247    crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
248    if new_dir {
249        // The name of a `memory/` folder made just now is in its parent, and
250        // no sync below reaches that. Before the line is written, so that a
251        // failure here leaves no note behind an error.
252        if let Some(parent) = dir.parent() {
253            sync_dir(parent)?;
254        }
255    }
256    let path = namespace_path(&dir, &record.namespace);
257    let line = serde_json::to_string(record)?;
258    append_jsonl_line(&path, &line, sync_now).map_err(|e| e.with_id(record.id.as_deref()))
259}
260
261/// Append one JSONL line and make it durable before returning: the line goes
262/// to the file whole ([`write_whole_line`]), the file is synced, and then
263/// its directory, on every save. "The file had bytes" does not say that its
264/// name is on disk: another saver may have created it and not synced the
265/// directory yet, a rewrite may have renamed a new file over it, or an
266/// import that did not finish may have made it.
267///
268/// With `sync_now` false nothing is synced here: the caller is an import
269/// run, which syncs each file and its directory once, at its end
270/// ([`ImportRun::finish`]).
271///
272/// An error means the save is NOT acknowledged. It does not mean nothing was
273/// written. [`MemoryError::WrittenUnconfirmed`] says the whole line is in
274/// the file and a sync after it failed; a caller must not treat that as "try
275/// again". After any other error at most a fragment of the line is in the
276/// file (a short write), which readers skip.
277///
278/// Torn-tail healing: if a previous writer died mid-line (the file does not
279/// end in a newline), a leading newline isolates the torn fragment on its
280/// own line. Lenient readers skip the fragment and every later record stays
281/// parseable. Nothing is ever cut off the file, so healing cannot drop a
282/// line, and a line that only lacked its newline becomes a good line.
283///
284/// The file's lock ([`lock_notes_file`]) is held from the look at the last
285/// byte through the sync, and the notes file is opened only once the lock is
286/// had. So no other appender's line can tear between the look and the write,
287/// and no rewrite (sweep, eviction, `retention forget memory`) can replace
288/// the file between this line going in and this call returning: a rewrite
289/// reads the file either before this line or with it whole. If the lock is
290/// not had within [`NOTES_LOCK_WAIT`] the save fails and nothing is written.
291fn append_jsonl_line(path: &Path, line: &str, sync_now: bool) -> Result<(), MemoryError> {
292    let _lock = lock_notes_file(path)?;
293    let needs_heal = match fs::metadata(path) {
294        Ok(meta) if meta.len() > 0 => {
295            use std::io::{Read, Seek, SeekFrom};
296            let mut f = fs::File::open(path)?;
297            f.seek(SeekFrom::End(-1))?;
298            let mut last = [0u8; 1];
299            f.read_exact(&mut last)?;
300            last[0] != b'\n'
301        }
302        _ => false,
303    };
304    let mut f = crate::core::private_fs::open_private_append(path).map_err(std::io::Error::from)?;
305    write_whole_line(&mut f, line, needs_heal)?;
306    if sync_now {
307        // From here on the line is in the file.
308        let synced = sync_file(&f).and_then(|()| match path.parent() {
309            Some(dir) => sync_dir(dir),
310            None => Ok(()),
311        });
312        synced.map_err(MemoryError::unconfirmed)?;
313    }
314    Ok(())
315}
316
317/// How long a save or a rewrite waits for a notes file's lock: the archive
318/// store's number.
319pub(crate) const NOTES_LOCK_WAIT: std::time::Duration =
320    crate::context_archive::PROCESS_LOCK_WAIT;
321/// How often that wait tries again. A save holds the lock for one write and
322/// one sync, so the next try is soon.
323const NOTES_LOCK_POLL: std::time::Duration = std::time::Duration::from_millis(5);
324
325/// The lock file of the notes file at `path`: `<ns>.jsonl.lock` beside it.
326/// Its name does not end in `.jsonl`, so nothing that lists notes files
327/// takes it for one, and it is never renamed or removed: a lock on the notes
328/// file itself would be lost with the inode a rewrite replaces.
329pub(crate) fn notes_lock_path(path: &Path) -> PathBuf {
330    let mut name = path.as_os_str().to_os_string();
331    name.push(".lock");
332    PathBuf::from(name)
333}
334
335/// Take the exclusive lock of the notes file at `path`, waiting up to
336/// [`NOTES_LOCK_WAIT`]. Every appender and every rewriter of a notes file
337/// holds it: an append from its look at the last byte through its sync, a
338/// rewrite from its read through its rename. Dropping the guard releases it.
339///
340/// When the wait runs out this is an error (`WouldBlock`), and the caller
341/// must not go on: a save never appends without the lock and a rewrite never
342/// rewrites without it. The lock is a `flock`, so it holds between threads
343/// of one process as well (each call opens the file anew), and it must not
344/// be taken twice on one call path.
345///
346/// A binary older than this lock does not take it. Beside such a binary a
347/// rewrite can still lose a save, as before.
348pub(crate) fn lock_notes_file(
349    path: &Path,
350) -> std::io::Result<crate::core::lock_file::ExclusiveLock> {
351    let lock_path = notes_lock_path(path);
352    let wait = notes_lock_wait();
353    crate::core::lock_file::lock_within(wait, NOTES_LOCK_POLL, || {
354        let file = open_lock_file(&lock_path).map_err(|e| {
355            std::io::Error::new(
356                e.kind(),
357                format!(
358                    "cannot open the notes lock file {}: {e}; nothing was written",
359                    lock_path.display()
360                ),
361            )
362        })?;
363        crate::core::lock_file::try_lock(file)
364    })?
365    .ok_or_else(|| {
366        std::io::Error::new(
367            std::io::ErrorKind::WouldBlock,
368            format!(
369                "notes file {} is locked by another writer (waited {} ms); nothing was written",
370                path.file_name().unwrap_or_default().to_string_lossy(),
371                wait.as_millis()
372            ),
373        )
374    })
375}
376
377/// Open (creating it, mode 0600, never through a symlink) a lock file. Read
378/// and write access: Windows locks only a handle that has one of them.
379/// The folder is not created. Public for `synaps memory import-notes`, whose
380/// one lock for a whole run is opened as a notes file's lock is.
381pub fn open_lock_file(path: &Path) -> std::io::Result<fs::File> {
382    let mut options = fs::OpenOptions::new();
383    options.read(true).write(true).create(true).truncate(false);
384    #[cfg(unix)]
385    {
386        use std::os::unix::fs::OpenOptionsExt;
387        options
388            .mode(crate::core::private_fs::FILE_MODE)
389            .custom_flags(libc::O_NOFOLLOW);
390    }
391    options.open(path)
392}
393
394#[cfg(test)]
395thread_local! {
396    /// A shorter lock wait for this thread's saves and rewrites.
397    static NOTES_LOCK_WAIT_OVERRIDE: std::cell::Cell<Option<std::time::Duration>> =
398        const { std::cell::Cell::new(None) };
399}
400
401/// Shorten (or with `None` restore) the lock wait of this thread.
402#[cfg(test)]
403pub(crate) fn set_notes_lock_wait(wait: Option<std::time::Duration>) {
404    NOTES_LOCK_WAIT_OVERRIDE.with(|cell| cell.set(wait));
405}
406
407fn notes_lock_wait() -> std::time::Duration {
408    #[cfg(test)]
409    if let Some(wait) = NOTES_LOCK_WAIT_OVERRIDE.with(|cell| cell.get()) {
410        return wait;
411    }
412    NOTES_LOCK_WAIT
413}
414
415/// How many `write` calls one line may take before the append gives up.
416const LINE_WRITE_ATTEMPTS: usize = 3;
417
418/// Write `line` and its newline with ONE `write` call, so that no other
419/// appender's bytes can land inside a line that was acknowledged.
420///
421/// `write_all` is not used: after a short write it sends the REST of the
422/// line in a second call, and whatever another appender wrote in between
423/// would sit in the middle of this line. Here a short write is abandoned
424/// where it is (bytes are never taken back out of the file) and the whole
425/// line is written again, led by a newline that ends the fragment, which a
426/// reader then skips like any torn line. If the fragment was the whole
427/// record short of its newline, the record is in the file twice, same id and
428/// same bytes: a duplicate rather than a line this call cannot vouch for.
429/// A regular file takes a short write when the disk or a quota is full, so
430/// the retry normally fails and the caller gets that error.
431fn write_whole_line<W: Write>(
432    file: &mut W,
433    line: &str,
434    mut lead_newline: bool,
435) -> std::io::Result<()> {
436    let mut attempts = 0;
437    while attempts < LINE_WRITE_ATTEMPTS {
438        let mut out = Vec::with_capacity(line.len() + 2);
439        if lead_newline {
440            out.push(b'\n');
441        }
442        out.extend_from_slice(line.as_bytes());
443        out.push(b'\n');
444        step("write");
445        match file.write(&out) {
446            Ok(n) if n == out.len() => return Ok(()),
447            Ok(n) => {
448                attempts += 1;
449                lead_newline |= n > 0;
450            }
451            // Nothing was written; the same call again.
452            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
453            Err(e) => return Err(e),
454        }
455    }
456    Err(std::io::Error::new(
457        std::io::ErrorKind::WriteZero,
458        "a line could not be appended with one write",
459    ))
460}
461
462/// Sync a notes file: its bytes and its length.
463fn sync_file(file: &fs::File) -> std::io::Result<()> {
464    step("sync file");
465    failing("sync file")?;
466    file.sync_all()
467}
468
469/// Sync a directory, so that the name of a file created in it, or renamed
470/// into it, is on disk.
471fn sync_dir(dir: &Path) -> std::io::Result<()> {
472    step("sync dir");
473    failing("sync dir")?;
474    // Every save syncs the directory, so a directory that cannot be synced
475    // at all must not fail every save, each with its line already in the
476    // file. Which errors say that is `private_fs::sync_dir`'s one rule, and
477    // it logs the one it leaves that may cost a new file's name.
478    crate::core::private_fs::sync_dir(dir)
479}
480
481#[cfg(test)]
482thread_local! {
483    /// The write and sync calls this thread's appends made, in order.
484    static IO_STEPS: std::cell::RefCell<Vec<&'static str>> =
485        const { std::cell::RefCell::new(Vec::new()) };
486}
487
488/// Notes a write or a sync for the tests that check their order.
489#[cfg(test)]
490fn step(name: &'static str) {
491    IO_STEPS.with(|steps| steps.borrow_mut().push(name));
492}
493
494#[cfg(not(test))]
495fn step(_name: &'static str) {}
496
497#[cfg(test)]
498thread_local! {
499    /// The sync step that fails on this thread, for the tests of what a
500    /// caller is told then.
501    static FAILING_STEP: std::cell::Cell<Option<&'static str>> =
502        const { std::cell::Cell::new(None) };
503}
504
505/// An error if a test made this step fail.
506#[cfg(test)]
507fn failing(name: &'static str) -> std::io::Result<()> {
508    if FAILING_STEP.with(|step| step.get()) == Some(name) {
509        return Err(std::io::Error::other(format!("{name} failed (test)")));
510    }
511    Ok(())
512}
513
514#[cfg(not(test))]
515fn failing(_name: &'static str) -> std::io::Result<()> {
516    Ok(())
517}
518
519/// Query records in a namespace, applying filters, returning most-recent-first up to limit.
520pub fn query(namespace: &str, q: &MemoryQuery) -> Result<Vec<MemoryRecord>, MemoryError> {
521    query_in(&crate::config::base_dir(), namespace, q)
522}
523
524pub fn query_in(
525    base: &Path,
526    namespace: &str,
527    q: &MemoryQuery,
528) -> Result<Vec<MemoryRecord>, MemoryError> {
529    validate_namespace(namespace)?;
530    let path = namespace_path(&memory_dir_in(base), namespace);
531    if !path.exists() {
532        return Ok(Vec::new());
533    }
534    let f = fs::File::open(&path)?;
535    let reader = BufReader::new(f);
536    let needle = q.content_contains.as_ref().map(|s| s.to_lowercase());
537    let mut out: Vec<MemoryRecord> = Vec::new();
538    for line in reader.lines() {
539        // The rule of `scan_project_file`: a line that is not UTF-8 is
540        // skipped like one that does not parse; any other read error is
541        // returned. `lines` gives a lasting error again on every call, so
542        // skipping it would never end.
543        let line = match line {
544            Ok(l) => l,
545            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => continue,
546            Err(e) => return Err(e.into()),
547        };
548        if line.trim().is_empty() {
549            continue;
550        }
551        let rec: MemoryRecord = match serde_json::from_str(&line) {
552            Ok(r) => r,
553            Err(_) => continue,
554        };
555        if let Some(since) = q.since_ms {
556            if rec.timestamp_ms < since {
557                continue;
558            }
559        }
560        if let Some(until) = q.until_ms {
561            if rec.timestamp_ms > until {
562                continue;
563            }
564        }
565        if let Some(needle) = &needle {
566            if !rec.content.to_lowercase().contains(needle) {
567                continue;
568            }
569        }
570        if let Some(prefix) = &q.tag_prefix {
571            if !rec.tags.iter().any(|t| t.starts_with(prefix)) {
572                continue;
573            }
574        }
575        out.push(rec);
576    }
577    out.sort_by_key(|b| std::cmp::Reverse(b.timestamp_ms));
578    let limit = q.limit.unwrap_or(DEFAULT_LIMIT);
579    out.truncate(limit);
580    Ok(out)
581}
582
583/// List existing namespaces under the memory dir.
584pub fn list_namespaces() -> Result<Vec<String>, MemoryError> {
585    list_namespaces_in(&crate::config::base_dir())
586}
587
588pub(crate) fn list_namespaces_in(base: &Path) -> Result<Vec<String>, MemoryError> {
589    let dir = memory_dir_in(base);
590    if !dir.exists() {
591        return Ok(Vec::new());
592    }
593    let mut out = Vec::new();
594    for entry in fs::read_dir(&dir)? {
595        let entry = entry?;
596        let path = entry.path();
597        if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
598            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
599                out.push(stem.to_string());
600            }
601        }
602    }
603    out.sort();
604    Ok(out)
605}
606
607/// Build a record with `now_ms()` timestamp.
608pub fn new_record(
609    namespace: impl Into<String>,
610    content: impl Into<String>,
611    tags: Vec<String>,
612    meta: Option<serde_json::Value>,
613) -> MemoryRecord {
614    MemoryRecord {
615        namespace: namespace.into(),
616        timestamp_ms: now_ms(),
617        content: content.into(),
618        tags,
619        meta,
620        id: None,
621        project: None,
622        provenance: None,
623        sensitivity: None,
624        retention: None,
625    }
626}
627
628// ─── Task 32: project-scoped progressive memory (spec §9.5) ─────────────────
629
630/// Host-resolved project identity: the canonical workspace root, digested
631/// into a stable scope key. The HOST constructs this from trusted execution
632/// context — it is never accepted solely from model-authored arguments.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub struct ProjectScope {
635    key: String,
636    root: PathBuf,
637}
638
639impl ProjectScope {
640    /// Resolve a scope from a workspace root. The path is canonicalized so
641    /// every spelling of the same directory yields one project identity.
642    pub fn for_root(root: &Path) -> Result<Self, MemoryError> {
643        let canonical = root
644            .canonicalize()
645            .map_err(|e| MemoryError::InvalidProjectRoot(format!("{}: {e}", root.display())))?;
646        Ok(Self::for_canonical_root(canonical))
647    }
648
649    fn for_canonical_root(canonical: PathBuf) -> Self {
650        use sha2::{Digest, Sha256};
651        let mut hasher = Sha256::new();
652        hasher.update(canonical.as_os_str().as_encoded_bytes());
653        let digest = hasher.finalize();
654        let mut key = String::with_capacity(17);
655        key.push('p');
656        for byte in digest.iter().take(8) {
657            use std::fmt::Write;
658            let _ = write!(key, "{byte:02x}");
659        }
660        Self {
661            key,
662            root: canonical,
663        }
664    }
665
666    /// Accepts exactly `p[0-9a-f]{16}`, excluding the user-wide reserved key.
667    fn validate_repository_key(key: &str) -> Result<(), MemoryError> {
668        if key.len() != 17
669            || !key.starts_with('p')
670            || !key.as_bytes()[1..]
671                .iter()
672                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b))
673            || key == "p0000000000000000"
674        {
675            return Err(MemoryError::InvalidProjectRoot(
676                "invalid or reserved repository scope key".into(),
677            ));
678        }
679        Ok(())
680    }
681
682    /// Stable scope key (`p<16 hex>`).
683    pub fn key(&self) -> &str {
684        &self.key
685    }
686
687    /// Discover the stable project root for a starting directory (CP-13
688    /// fix1 I2): the `SYNAPS_PROJECT_ROOT` env override wins; otherwise a
689    /// bounded upward walk finds the nearest directory containing a `.git`
690    /// entry (DIRECTORY or FILE — worktree-safe, no process spawning, no
691    /// gitdir parsing) or a `.synaps-project` marker; with no marker the
692    /// start directory itself is the root.
693    pub fn discover(start: &Path) -> Result<Self, MemoryError> {
694        let override_root = std::env::var_os("SYNAPS_PROJECT_ROOT").map(std::path::PathBuf::from);
695        Self::discover_with_override(start, override_root.as_deref())
696    }
697
698    /// [`Self::discover`] with an explicit override (env-free seam).
699    pub fn discover_with_override(
700        start: &Path,
701        override_root: Option<&Path>,
702    ) -> Result<Self, MemoryError> {
703        if let Some(root) = override_root {
704            return Self::for_root(root);
705        }
706        const MARKERS: [&str; 2] = [".git", ".synaps-project"];
707        const MAX_ASCENT: usize = 64;
708        let canonical_start = start
709            .canonicalize()
710            .map_err(|e| MemoryError::InvalidProjectRoot(format!("{}: {e}", start.display())))?;
711        let mut dir = canonical_start.as_path();
712        for _ in 0..MAX_ASCENT {
713            if MARKERS
714                .iter()
715                .any(|marker| dir.join(marker).symlink_metadata().is_ok())
716            {
717                return Self::for_root(dir);
718            }
719            match dir.parent() {
720                Some(parent) => dir = parent,
721                None => break,
722            }
723        }
724        Self::for_root(&canonical_start)
725    }
726
727    /// The canonical workspace root.
728    pub fn root(&self) -> &Path {
729        &self.root
730    }
731
732    /// The JSONL namespace holding this project's records.
733    pub fn namespace(&self) -> String {
734        project_namespace(&self.key)
735    }
736}
737
738/// The JSONL namespace of the project with this key.
739fn project_namespace(key: &str) -> String {
740    format!("project-{key}")
741}
742
743/// Input for [`store_record_in`] — explicit provenance, sensitivity, and
744/// retention (spec §9.5: nothing is stored with implicit metadata).
745#[derive(Debug, Clone)]
746pub struct NewMemoryRecord {
747    pub content: String,
748    pub tags: Vec<String>,
749    pub provenance: MemoryProvenance,
750    pub sensitivity: MemorySensitivity,
751    pub retention: MemoryRetention,
752}
753
754/// Bounded search result: descriptor + snippet, NEVER the full body (full
755/// content requires an exact [`fetch_exact_in`]).
756#[derive(Debug, Clone, PartialEq, Eq)]
757pub struct MemoryDescriptor {
758    pub id: String,
759    pub project: String,
760    pub timestamp_ms: u64,
761    pub tags: Vec<String>,
762    /// UTF-8-boundary-bounded head of the content.
763    pub snippet: String,
764    /// Whether the snippet was cut.
765    pub truncated: bool,
766    /// Full content length in bytes (size disclosure only).
767    pub content_bytes: usize,
768    pub sensitivity: MemorySensitivity,
769    pub retention: MemoryRetention,
770}
771
772/// Hard cap on search results per call.
773pub const MAX_SEARCH_LIMIT: usize = 25;
774/// Default search result count.
775pub const DEFAULT_SEARCH_LIMIT: usize = 8;
776/// Hard cap on snippet bytes per descriptor.
777pub const MAX_SNIPPET_BYTES: usize = 400;
778/// Default snippet bytes per descriptor.
779pub const DEFAULT_SNIPPET_BYTES: usize = 160;
780
781/// Project-scoped search query. Limits clamp to the hard caps.
782#[derive(Debug, Clone, Default)]
783pub struct ProjectMemoryQuery {
784    pub content_contains: Option<String>,
785    pub tag_prefix: Option<String>,
786    pub since_ms: Option<u64>,
787    pub until_ms: Option<u64>,
788    pub limit: Option<usize>,
789    pub snippet_bytes: Option<usize>,
790}
791
792/// One parsed line of a project namespace file: a record or a tombstone.
793/// Tombstones are append-only (`{"tombstone":"<id>","timestamp_ms":…}`);
794/// physical deletion belongs to the retention sweep. Legacy readers skip
795/// tombstone lines as malformed records — forward compatible.
796#[derive(Debug, Serialize, Deserialize)]
797struct TombstoneLine {
798    tombstone: String,
799    timestamp_ms: u64,
800}
801
802/// Store one record in a project scope: assigns a stable `mem-<uuid>` id,
803/// binds the scope key, and appends to the project namespace.
804pub fn store_record_in(
805    base: &Path,
806    scope: &ProjectScope,
807    new: NewMemoryRecord,
808) -> Result<MemoryRecord, MemoryError> {
809    let record = MemoryRecord {
810        namespace: scope.namespace(),
811        timestamp_ms: now_ms(),
812        content: new.content,
813        tags: new.tags,
814        meta: None,
815        id: Some(format!("mem-{}", uuid::Uuid::new_v4().simple())),
816        project: Some(scope.key().to_string()),
817        provenance: Some(new.provenance),
818        sensitivity: Some(new.sensitivity),
819        retention: Some(new.retention),
820    };
821    append_to(base, &record)?;
822    Ok(record)
823}
824
825/// What the lines of one project's file amount to: the records that carry
826/// the project's key and an id, in file order, and every id a tombstone line
827/// names. A record whose id is tombstoned is not live, wherever in the file
828/// the tombstone stands.
829struct ProjectLines {
830    records: Vec<MemoryRecord>,
831    tombstoned: std::collections::HashSet<String>,
832}
833
834/// Scan `<base>/memory/project-<key>.jsonl` by a bare project key (a
835/// [`ProjectScope`] needs the project's folder to exist; an import has only
836/// the key). No file is an empty project. Records whose inner `project` key
837/// is not `key` are excluded — fail closed even if a foreign file was copied
838/// into the namespace.
839///
840/// A line that does not parse is skipped, and so is one that is not UTF-8 (a
841/// torn tail can end inside a character). Any other read error is returned:
842/// a scan that carried on past it would answer from part of the file.
843fn scan_project_file(base: &Path, key: &str) -> Result<ProjectLines, MemoryError> {
844    let mut lines = ProjectLines {
845        records: Vec::new(),
846        tombstoned: std::collections::HashSet::new(),
847    };
848    let path = namespace_path(&memory_dir_in(base), &project_namespace(key));
849    let f = match fs::File::open(path) {
850        Ok(f) => f,
851        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(lines),
852        Err(e) => return Err(e.into()),
853    };
854    for line in BufReader::new(f).lines() {
855        let line = match line {
856            Ok(l) => l,
857            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => continue,
858            Err(e) => return Err(e.into()),
859        };
860        if line.trim().is_empty() {
861            continue;
862        }
863        if let Ok(tomb) = serde_json::from_str::<TombstoneLine>(&line) {
864            lines.tombstoned.insert(tomb.tombstone);
865            continue;
866        }
867        let rec: MemoryRecord = match serde_json::from_str(&line) {
868            Ok(r) => r,
869            Err(_) => continue,
870        };
871        // Fail closed: only records carrying THIS project's key participate.
872        if rec.project.as_deref() != Some(key) || rec.id.is_none() {
873            continue;
874        }
875        lines.records.push(rec);
876    }
877    Ok(lines)
878}
879
880/// Load the LIVE (non-tombstoned, scope-matching) records of a project.
881fn load_live_project_records(
882    base: &Path,
883    scope: &ProjectScope,
884) -> Result<Vec<MemoryRecord>, MemoryError> {
885    let ProjectLines {
886        mut records,
887        tombstoned,
888    } = scan_project_file(base, scope.key())?;
889    records.retain(|r| r.id.as_ref().is_some_and(|id| !tombstoned.contains(id)));
890    Ok(records)
891}
892
893/// Project-scoped bounded search: descriptors + snippets, most recent
894/// first, hard-capped result count and snippet bytes.
895pub fn search_project_in(
896    base: &Path,
897    scope: &ProjectScope,
898    q: &ProjectMemoryQuery,
899) -> Result<Vec<MemoryDescriptor>, MemoryError> {
900    let needle = q.content_contains.as_ref().map(|s| s.to_lowercase());
901    let snippet_bytes = q
902        .snippet_bytes
903        .unwrap_or(DEFAULT_SNIPPET_BYTES)
904        .min(MAX_SNIPPET_BYTES);
905    let limit = q
906        .limit
907        .unwrap_or(DEFAULT_SEARCH_LIMIT)
908        .min(MAX_SEARCH_LIMIT);
909
910    let mut records = load_live_project_records(base, scope)?;
911    records.retain(|rec| {
912        let secret = rec.sensitivity == Some(MemorySensitivity::Secret);
913        if let Some(since) = q.since_ms {
914            if rec.timestamp_ms < since {
915                return false;
916            }
917        }
918        if let Some(until) = q.until_ms {
919            if rec.timestamp_ms > until {
920                return false;
921            }
922        }
923        if let Some(needle) = &needle {
924            // Secret bodies never participate in content matching — a
925            // substring probe must not confirm secret content.
926            if secret || !rec.content.to_lowercase().contains(needle) {
927                return false;
928            }
929        }
930        if let Some(prefix) = &q.tag_prefix {
931            if !rec.tags.iter().any(|t| t.starts_with(prefix)) {
932                return false;
933            }
934        }
935        true
936    });
937    records.sort_by_key(|r| std::cmp::Reverse(r.timestamp_ms));
938    records.truncate(limit);
939
940    Ok(records
941        .into_iter()
942        .map(|rec| {
943            let sensitivity = rec.sensitivity.unwrap_or(MemorySensitivity::Normal);
944            // Secret descriptors carry NO body-derived snippet text (spec
945            // §9.5: secret bodies are visible locally only).
946            let (snippet, truncated) = if sensitivity == MemorySensitivity::Secret {
947                (String::new(), false)
948            } else {
949                let bounded = crate::text::BoundedText::new(&rec.content, snippet_bytes);
950                (bounded.text, bounded.truncated)
951            };
952            MemoryDescriptor {
953                id: rec.id.clone().unwrap_or_default(),
954                project: scope.key().to_string(),
955                timestamp_ms: rec.timestamp_ms,
956                tags: rec.tags,
957                snippet,
958                truncated,
959                content_bytes: rec.content.len(),
960                sensitivity,
961                retention: rec.retention.unwrap_or(MemoryRetention::Standard),
962            }
963        })
964        .collect())
965}
966
967/// Fetch exact record ids within a project scope. EVERY id must resolve in
968/// this scope or the whole call fails closed with [`MemoryError::NotFound`]
969/// — other projects' ids are indistinguishable from unknown ids.
970pub fn fetch_exact_in(
971    base: &Path,
972    scope: &ProjectScope,
973    ids: &[&str],
974) -> Result<Vec<MemoryRecord>, MemoryError> {
975    let live = load_live_project_records(base, scope)?;
976    let mut out = Vec::with_capacity(ids.len());
977    for wanted in ids {
978        match live.iter().find(|r| r.id.as_deref() == Some(*wanted)) {
979            Some(rec) => out.push(rec.clone()),
980            None => return Err(MemoryError::NotFound((*wanted).to_string())),
981        }
982    }
983    Ok(out)
984}
985
986/// Tombstone one record in a project scope (append-only forget). Fails
987/// closed with [`MemoryError::NotFound`] for unknown, already-tombstoned,
988/// or other-project ids.
989pub fn forget_in(base: &Path, scope: &ProjectScope, id: &str) -> Result<(), MemoryError> {
990    let live = load_live_project_records(base, scope)?;
991    if !live.iter().any(|r| r.id.as_deref() == Some(id)) {
992        return Err(MemoryError::NotFound(id.to_string()));
993    }
994    let tombstone = TombstoneLine {
995        tombstone: id.to_string(),
996        timestamp_ms: now_ms(),
997    };
998    append_tombstone(base, &scope.namespace(), &tombstone, true)
999}
1000
1001/// Append a tombstone line to a project namespace's file.
1002fn append_tombstone(
1003    base: &Path,
1004    namespace: &str,
1005    tombstone: &TombstoneLine,
1006    sync_now: bool,
1007) -> Result<(), MemoryError> {
1008    let dir = memory_dir_in(base);
1009    crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
1010    let path = namespace_path(&dir, namespace);
1011    let line = serde_json::to_string(tombstone)?;
1012    append_jsonl_line(&path, &line, sync_now).map_err(|e| e.with_id(Some(&tombstone.tombstone)))
1013}
1014
1015/// Path of a project namespace's JSONL store file (index-module seam).
1016pub(crate) fn namespace_file(base: &Path, namespace: &str) -> PathBuf {
1017    namespace_path(&memory_dir_in(base), namespace)
1018}
1019
1020/// The notes file of the project with this key under `base`: where a save,
1021/// a forget and an import of that project's notes write. Only the path:
1022/// nothing is created, and the key is not checked.
1023pub fn project_notes_file(base: &Path, key: &str) -> PathBuf {
1024    namespace_file(base, &project_namespace(key))
1025}
1026
1027// ─── Import from another store ──────────────────────────────────────────────
1028
1029/// The reserved key of user-wide notes, which have no project file to go to.
1030const USER_WIDE_KEY: &str = "p0000000000000000";
1031
1032/// The fields a note line may have: those of [`MemoryRecord`].
1033const RECORD_FIELDS: [&str; 10] = [
1034    "namespace",
1035    "timestamp_ms",
1036    "content",
1037    "tags",
1038    "meta",
1039    "id",
1040    "project",
1041    "provenance",
1042    "sensitivity",
1043    "retention",
1044];
1045
1046/// Where [`import_line_in`] files a line.
1047#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1048pub enum ImportTarget<'a> {
1049    /// Under the project the line names in its own `project`. Nothing in the
1050    /// line changes. A tombstone line names no project and is refused.
1051    AsRecorded,
1052    /// Under this project key (`p` + 16 hex, not the user-wide key). A
1053    /// note's `project` and `namespace` are set to it.
1054    Project(&'a str),
1055}
1056
1057/// What [`import_line_in`] did with one line.
1058#[derive(Debug, Clone, PartialEq, Eq)]
1059pub enum ImportOutcome {
1060    /// Appended: a note, or a tombstone, whose id the file did not have.
1061    Stored,
1062    /// Nothing written: the note is live in the file as given, or (for a
1063    /// tombstone line) the id is already tombstoned there.
1064    AlreadyPresent,
1065    /// Nothing written: a note whose id is tombstoned in the file. An import
1066    /// does not undo a forget.
1067    KeptForgotten,
1068    /// A tombstone appended for an id that was live.
1069    Forgotten,
1070    /// Nothing written. The reason names fields and positions and never
1071    /// quotes a value of the line.
1072    Refused(String),
1073}
1074
1075impl ImportOutcome {
1076    /// The outcome's name in an import report.
1077    pub fn name(&self) -> &'static str {
1078        match self {
1079            Self::Stored => "stored",
1080            Self::AlreadyPresent => "already_present",
1081            Self::KeptForgotten => "kept_forgotten",
1082            Self::Forgotten => "forgotten",
1083            Self::Refused(_) => "refused",
1084        }
1085    }
1086}
1087
1088/// [`import_line_in`]'s answer for one line.
1089#[derive(Debug, Clone, PartialEq, Eq)]
1090pub struct ImportedLine {
1091    pub outcome: ImportOutcome,
1092    /// The line's id, if the line was read far enough to have one.
1093    pub id: Option<String>,
1094    /// The key of the project whose file the line was judged against, if it
1095    /// got that far. For a tombstone line as recorded that was applied to
1096    /// more than one project, the first of them by key.
1097    pub project: Option<String>,
1098    /// Whether the line is a tombstone line rather than a note.
1099    pub tombstone: bool,
1100}
1101
1102/// What one import run knows: each notes file it has touched, read from disk
1103/// once and kept in step with what the run has stored since, in a dry run
1104/// too. That is what makes the lines of one run be judged against each
1105/// other, and a dry run report what the real run then does.
1106///
1107/// A run is a dry run or a real one from [`ImportRun::new`] to its end, so
1108/// what a dry run remembers as stored can never be taken for stored by a
1109/// real one: a preview and the import after it are two runs.
1110///
1111/// End a run with [`ImportRun::finish`], on an early stop too: nothing a
1112/// real run stored is durable before it returns `Ok`. The compiler cannot
1113/// tell a run that was dropped on an early return from one that was
1114/// finished (`#[must_use]` only catches a run that is never bound). So a
1115/// real run that is dropped unfinished syncs what it appended as it goes,
1116/// and logs it if that fails; only the caller's chance to see the error is
1117/// lost.
1118#[derive(Debug)]
1119#[must_use = "end an import run with `finish()`: nothing it stored is durable before that"]
1120pub struct ImportRun {
1121    dry_run: bool,
1122    files: std::collections::HashMap<PathBuf, ImportFile>,
1123    /// Ids of tombstone lines this run refused because no project could be
1124    /// found for them. A note with such an id is refused for the rest of
1125    /// the run: storing it would bring a forgotten note back.
1126    unfiled_tombstones: std::collections::HashSet<String>,
1127    /// Whether the run has read every project's notes file under the base
1128    /// (it does once, the first time a tombstone line has no project).
1129    read_every_project: bool,
1130    finished: bool,
1131}
1132
1133#[derive(Debug)]
1134struct ImportFile {
1135    /// The key of the project whose file this is.
1136    key: String,
1137    /// The file's records by id, by the reader's rule. A file can hold an id
1138    /// more than once.
1139    notes: std::collections::HashMap<String, Vec<MemoryRecord>>,
1140    tombstoned: std::collections::HashSet<String>,
1141    /// Whether this run appended to the file.
1142    appended: bool,
1143}
1144
1145impl ImportRun {
1146    /// A run that writes (`dry_run` false), or one that writes, creates and
1147    /// syncs nothing and gives the outcomes the real run would give from the
1148    /// same files.
1149    pub fn new(dry_run: bool) -> Self {
1150        Self {
1151            dry_run,
1152            files: std::collections::HashMap::new(),
1153            unfiled_tombstones: std::collections::HashSet::new(),
1154            read_every_project: false,
1155            finished: false,
1156        }
1157    }
1158
1159    pub fn is_dry_run(&self) -> bool {
1160        self.dry_run
1161    }
1162
1163    /// The file of project `key`, read the first time the run needs it.
1164    fn file(&mut self, base: &Path, key: &str) -> Result<&mut ImportFile, MemoryError> {
1165        use std::collections::hash_map::Entry;
1166        match self
1167            .files
1168            .entry(namespace_file(base, &project_namespace(key)))
1169        {
1170            Entry::Occupied(known) => Ok(known.into_mut()),
1171            Entry::Vacant(vacant) => {
1172                let lines = scan_project_file(base, key)?;
1173                let mut notes: std::collections::HashMap<String, Vec<MemoryRecord>> =
1174                    std::collections::HashMap::new();
1175                for record in lines.records {
1176                    if let Some(id) = record.id.clone() {
1177                        notes.entry(id).or_default().push(record);
1178                    }
1179                }
1180                Ok(vacant.insert(ImportFile {
1181                    key: key.to_string(),
1182                    notes,
1183                    tombstoned: lines.tombstoned,
1184                    appended: false,
1185                }))
1186            }
1187        }
1188    }
1189
1190    /// Read every `project-<key>.jsonl` under `<base>/memory` that the run
1191    /// has not read yet. Once in a run: a file made later is one the run
1192    /// made, and it has that already. Reads only; no memory folder is no
1193    /// file. An error is a file or a folder that could not be read: the run
1194    /// cannot say that no project holds an id, and must stop.
1195    fn read_every_project(&mut self, base: &Path) -> Result<(), MemoryError> {
1196        if self.read_every_project {
1197            return Ok(());
1198        }
1199        let entries = match fs::read_dir(memory_dir_in(base)) {
1200            Ok(entries) => Some(entries),
1201            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1202            Err(e) => return Err(e.into()),
1203        };
1204        for entry in entries.into_iter().flatten() {
1205            let name = entry?.file_name();
1206            let key = name
1207                .to_str()
1208                .and_then(|name| name.strip_prefix("project-"))
1209                .and_then(|name| name.strip_suffix(".jsonl"));
1210            if let Some(key) = key.filter(|key| ProjectScope::validate_repository_key(key).is_ok())
1211            {
1212                self.file(base, key)?;
1213            }
1214        }
1215        self.read_every_project = true;
1216        Ok(())
1217    }
1218
1219    /// The projects in whose files this run has seen `id`, as a note or as a
1220    /// tombstone, sorted. Only files the run has read: those a line of the
1221    /// run was filed in or judged against, and all of them after
1222    /// [`Self::read_every_project`].
1223    fn projects_holding(&self, id: &str) -> Vec<String> {
1224        let mut keys: Vec<String> = self
1225            .files
1226            .values()
1227            .filter(|file| file.notes.contains_key(id) || file.tombstoned.contains(id))
1228            .map(|file| file.key.clone())
1229            .collect();
1230        keys.sort();
1231        keys
1232    }
1233
1234    /// End the run: sync every file it appended to, then the directory of
1235    /// each. An import's appends are not synced one by one, so nothing the
1236    /// run stored is durable until this returns `Ok`; call it when the run
1237    /// stops early too. Returns the files synced, sorted. A dry run has
1238    /// none.
1239    ///
1240    /// Every file and every directory is tried, also after one failed.
1241    /// An error is [`MemoryError::WrittenUnconfirmed`] for the first sync
1242    /// that failed, naming its file: the lines the run reported as stored
1243    /// are in their files, and that sync failed.
1244    #[must_use = "an import is not durable unless `finish` returned Ok"]
1245    pub fn finish(mut self) -> Result<Vec<PathBuf>, MemoryError> {
1246        // Whatever the sync says, it was tried and the caller is told.
1247        self.finished = true;
1248        self.sync()
1249            .map_err(|(path, e)| MemoryError::unconfirmed(format!("{}: {e}", path.display())))
1250    }
1251
1252    /// Sync every appended file, then every directory of one, trying all of
1253    /// them. The first failure, with the path that failed.
1254    fn sync(&self) -> Result<Vec<PathBuf>, (PathBuf, std::io::Error)> {
1255        let mut synced: Vec<PathBuf> = self
1256            .files
1257            .iter()
1258            .filter(|(_, file)| file.appended)
1259            .map(|(path, _)| path.clone())
1260            .collect();
1261        synced.sort();
1262        let mut first_error: Option<(PathBuf, std::io::Error)> = None;
1263        let mut dirs: std::collections::BTreeSet<&Path> = std::collections::BTreeSet::new();
1264        for path in &synced {
1265            // Opened for append, not for reading: Windows flushes only a
1266            // handle that may write.
1267            let result = fs::OpenOptions::new()
1268                .append(true)
1269                .open(path)
1270                .and_then(|file| sync_file(&file));
1271            if let Err(e) = result {
1272                first_error.get_or_insert((path.clone(), e));
1273            }
1274            dirs.extend(path.parent());
1275        }
1276        for dir in dirs {
1277            if let Err(e) = sync_dir(dir) {
1278                first_error.get_or_insert((dir.to_path_buf(), e));
1279            }
1280        }
1281        match first_error {
1282            Some(error) => Err(error),
1283            None => Ok(synced),
1284        }
1285    }
1286}
1287
1288impl Drop for ImportRun {
1289    /// The net under a run that was not finished (an early return, a `?`):
1290    /// what it appended is synced all the same.
1291    fn drop(&mut self) {
1292        if self.finished || !self.files.values().any(|file| file.appended) {
1293            return;
1294        }
1295        if let Err((path, error)) = self.sync() {
1296            tracing::warn!(
1297                "an import run was dropped unfinished and its sync of {} failed: {error}",
1298                path.display()
1299            );
1300        }
1301    }
1302}
1303
1304/// Import one JSONL line of another store's notes into the plain-file store
1305/// under `base`: a note (a [`MemoryRecord`] line) or a tombstone line
1306/// (`{"tombstone":"<id>","timestamp_ms":…}`).
1307///
1308/// A note must be a JSON object with no field but [`MemoryRecord`]'s, an
1309/// `id` of 1 to 128 bytes without whitespace or control characters, a
1310/// `project` that is a project key, `namespace == "project-" + project`, a
1311/// `timestamp_ms`, and at most [`MAX_CONTENT_BYTES`] of content. It is
1312/// stored as given, except that [`ImportTarget::Project`] sets `project` and
1313/// `namespace`. No field is dropped and no time is made up: a line that
1314/// could only be stored without something it says is refused.
1315///
1316/// Against the target file: a note whose id is tombstoned there is
1317/// [`ImportOutcome::KeptForgotten`]; whose id is live there with the same
1318/// content, tags, time, provenance, sensitivity, retention and meta is
1319/// [`ImportOutcome::AlreadyPresent`], and with anything different is
1320/// refused; otherwise it is appended. A tombstone line is appended unless
1321/// the id is already tombstoned: [`ImportOutcome::Forgotten`] if the id was
1322/// live, [`ImportOutcome::Stored`] if the file did not have it (a later
1323/// import of that note then stays forgotten).
1324///
1325/// A tombstone line names no project. Under [`ImportTarget::AsRecorded`] it
1326/// is applied to the project in whose file this run has seen its id (to
1327/// each, if the run has seen the id in more than one). When the run has seen
1328/// it in none, every project's notes file under the base is read for the id,
1329/// and the line is refused only when none holds it. A note with the id of a tombstone line that was
1330/// refused that way is then refused too, for the rest of the run, whatever
1331/// its target: a forgotten note never comes out of an import live.
1332///
1333/// In a dry run ([`ImportRun::new`]) nothing is written, created or synced,
1334/// and the outcomes are the ones the real run gives from the same files.
1335/// `run` is the state of one run; end the run with [`ImportRun::finish`].
1336///
1337/// `Err` is for what is not the line's fault and should stop the run: an
1338/// I/O error, or a target that is not a project key (the user-wide key is
1339/// not one).
1340pub fn import_line_in(
1341    base: &Path,
1342    line: &str,
1343    target: ImportTarget<'_>,
1344    run: &mut ImportRun,
1345) -> Result<ImportedLine, MemoryError> {
1346    if let ImportTarget::Project(key) = target {
1347        ProjectScope::validate_repository_key(key)?;
1348    }
1349    let mut about = ImportedLine {
1350        outcome: ImportOutcome::Refused(String::new()),
1351        id: None,
1352        project: None,
1353        tombstone: false,
1354    };
1355    let outcome = match parse_import_line(line) {
1356        Err(reason) => ImportOutcome::Refused(reason),
1357        Ok(ImportLine::Tombstone(tombstone)) => {
1358            about.tombstone = true;
1359            import_tombstone(base, tombstone, target, run, &mut about)?
1360        }
1361        Ok(ImportLine::Note(note)) => import_note(base, *note, target, run, &mut about)?,
1362    };
1363    about.outcome = outcome;
1364    Ok(about)
1365}
1366
1367/// Why a note is refused after its tombstone line was.
1368const UNFILED_TOMBSTONE: &str = "its tombstone in this run could not be filed";
1369
1370/// Why a tombstone line is refused as recorded.
1371const UNPLACED_TOMBSTONE: &str = "a tombstone line names no project and no project's notes hold \
1372                                  its id; pass --cwd or --project-key";
1373
1374fn refused(reason: &str) -> Result<ImportOutcome, MemoryError> {
1375    Ok(ImportOutcome::Refused(reason.to_string()))
1376}
1377
1378fn import_note(
1379    base: &Path,
1380    mut note: MemoryRecord,
1381    target: ImportTarget<'_>,
1382    run: &mut ImportRun,
1383    about: &mut ImportedLine,
1384) -> Result<ImportOutcome, MemoryError> {
1385    let Some(id) = note.id.clone() else {
1386        return refused("missing field id");
1387    };
1388    if !valid_import_id(&id) {
1389        return refused("invalid id");
1390    }
1391    about.id = Some(id.clone());
1392    if run.unfiled_tombstones.contains(&id) {
1393        return refused(UNFILED_TOMBSTONE);
1394    }
1395    let Some(recorded) = note.project.clone() else {
1396        return refused("missing field project");
1397    };
1398    if recorded != USER_WIDE_KEY && ProjectScope::validate_repository_key(&recorded).is_err() {
1399        return refused("invalid project");
1400    }
1401    if note.namespace != project_namespace(&recorded) {
1402        return refused("namespace is not project-<project>");
1403    }
1404    if note.content.len() > MAX_CONTENT_BYTES {
1405        return refused(&format!("content is over {MAX_CONTENT_BYTES} bytes"));
1406    }
1407    let key = match target {
1408        ImportTarget::Project(key) => {
1409            note.project = Some(key.to_string());
1410            note.namespace = project_namespace(key);
1411            key.to_string()
1412        }
1413        ImportTarget::AsRecorded if recorded == USER_WIDE_KEY => {
1414            return refused("user-wide note; pass --cwd to file it under a project");
1415        }
1416        ImportTarget::AsRecorded => recorded,
1417    };
1418    about.project = Some(key.clone());
1419    let dry_run = run.dry_run;
1420    let file = run.file(base, &key)?;
1421    if file.tombstoned.contains(&id) {
1422        return Ok(ImportOutcome::KeptForgotten);
1423    }
1424    if let Some(held) = file.notes.get(&id) {
1425        if held.iter().any(|other| same_note(other, &note)) {
1426            return Ok(ImportOutcome::AlreadyPresent);
1427        }
1428        return refused("id already holds a different note");
1429    }
1430    if !dry_run {
1431        append_record(base, &note, false)?;
1432        file.appended = true;
1433    }
1434    file.notes.insert(id, vec![note]);
1435    Ok(ImportOutcome::Stored)
1436}
1437
1438fn import_tombstone(
1439    base: &Path,
1440    tombstone: TombstoneLine,
1441    target: ImportTarget<'_>,
1442    run: &mut ImportRun,
1443    about: &mut ImportedLine,
1444) -> Result<ImportOutcome, MemoryError> {
1445    if !valid_import_id(&tombstone.tombstone) {
1446        return refused("invalid id");
1447    }
1448    about.id = Some(tombstone.tombstone.clone());
1449    let keys = match target {
1450        ImportTarget::Project(key) => vec![key.to_string()],
1451        ImportTarget::AsRecorded => {
1452            let mut keys = run.projects_holding(&tombstone.tombstone);
1453            if keys.is_empty() {
1454                // Not in a file this run has opened. The note may be in the
1455                // file of a project no line of this run has named yet (an
1456                // earlier run stored it): look in every project's file
1457                // before saying that there is none.
1458                run.read_every_project(base)?;
1459                keys = run.projects_holding(&tombstone.tombstone);
1460            }
1461            keys
1462        }
1463    };
1464    if keys.is_empty() {
1465        run.unfiled_tombstones.insert(tombstone.tombstone);
1466        return refused(UNPLACED_TOMBSTONE);
1467    }
1468    about.project = keys.first().cloned();
1469    let dry_run = run.dry_run;
1470    let (mut forgot, mut stored) = (false, false);
1471    for key in &keys {
1472        let file = run.file(base, key)?;
1473        if file.tombstoned.contains(&tombstone.tombstone) {
1474            continue;
1475        }
1476        if !dry_run {
1477            append_tombstone(base, &project_namespace(key), &tombstone, false)?;
1478            file.appended = true;
1479        }
1480        if file.notes.contains_key(&tombstone.tombstone) {
1481            forgot = true;
1482        } else {
1483            stored = true;
1484        }
1485        file.tombstoned.insert(tombstone.tombstone.clone());
1486    }
1487    Ok(if forgot {
1488        ImportOutcome::Forgotten
1489    } else if stored {
1490        ImportOutcome::Stored
1491    } else {
1492        ImportOutcome::AlreadyPresent
1493    })
1494}
1495
1496/// The store's check of a record id that did not come from the store itself
1497/// (an imported line, `retention forget memory`): 1 to 128 bytes, no
1498/// whitespace, no control characters. An id the store makes is `mem-` and a
1499/// UUID; an imported one need not look like that.
1500pub(crate) fn valid_import_id(id: &str) -> bool {
1501    (1..=128).contains(&id.len()) && !id.chars().any(|c| c.is_whitespace() || c.is_control())
1502}
1503
1504/// Whether two notes under one id are the same note: every field but the id
1505/// and the project they are filed under.
1506fn same_note(a: &MemoryRecord, b: &MemoryRecord) -> bool {
1507    a.content == b.content
1508        && a.tags == b.tags
1509        && a.timestamp_ms == b.timestamp_ms
1510        && a.provenance == b.provenance
1511        && a.sensitivity == b.sensitivity
1512        && a.retention == b.retention
1513        && a.meta == b.meta
1514}
1515
1516enum ImportLine {
1517    Note(Box<MemoryRecord>),
1518    Tombstone(TombstoneLine),
1519}
1520
1521/// Read one line as a note or a tombstone line, or say why it is neither.
1522fn parse_import_line(line: &str) -> Result<ImportLine, String> {
1523    let object: serde_json::Map<String, serde_json::Value> =
1524        serde_json::from_str(line).map_err(|e| match e.classify() {
1525            serde_json::error::Category::Data => "not a JSON object".to_string(),
1526            _ => format!("not JSON at column {}", e.column()),
1527        })?;
1528    if object.contains_key("tombstone") {
1529        if let Some(extra) = object
1530            .keys()
1531            .find(|name| !["tombstone", "timestamp_ms"].contains(&name.as_str()))
1532        {
1533            return Err(unknown_field(extra));
1534        }
1535        return serde_json::from_str(line)
1536            .map(ImportLine::Tombstone)
1537            .map_err(|e| unread("tombstone line", &e));
1538    }
1539    if let Some(extra) = object
1540        .keys()
1541        .find(|name| !RECORD_FIELDS.contains(&name.as_str()))
1542    {
1543        return Err(unknown_field(extra));
1544    }
1545    // From the text, not from `object`: the record's own reader refuses a
1546    // field given twice, where a JSON map keeps the last and says nothing.
1547    let note: MemoryRecord = serde_json::from_str(line).map_err(|e| unread("note", &e))?;
1548    // Nothing the line says may be missing from what would be written. This
1549    // is what catches a field inside `provenance` that the record has no
1550    // place for. A `null` says nothing and may go.
1551    let kept = serde_json::to_value(&note)
1552        .map(|kept| without_nulls(&kept))
1553        .map_err(|_| "not a note".to_string())?;
1554    for (name, value) in &object {
1555        if !value.is_null() && kept.get(name) != Some(&without_nulls(value)) {
1556            return Err(format!("{name} holds something a note cannot keep"));
1557        }
1558    }
1559    Ok(ImportLine::Note(Box::new(note)))
1560}
1561
1562/// The reason for a top-level key a line may not have. A key's name is text
1563/// from the line like any value, so it is shown only when it looks like a
1564/// field name (`[a-z_]{1,32}`), and not at all otherwise.
1565fn unknown_field(name: &str) -> String {
1566    let plain = (1..=32).contains(&name.len())
1567        && name.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
1568    if plain {
1569        format!("unknown field {name}")
1570    } else {
1571        "unknown field".to_string()
1572    }
1573}
1574
1575/// Why serde did not read a line, without quoting the line. Its "missing
1576/// field" and "duplicate field" messages name a field of the record and
1577/// nothing else. Any other message can quote a value, so only the place is
1578/// passed on.
1579fn unread(what: &str, e: &serde_json::Error) -> String {
1580    let message = e.to_string();
1581    for kind in ["missing field", "duplicate field"] {
1582        let name = message
1583            .strip_prefix(kind)
1584            .and_then(|rest| rest.split('`').nth(1));
1585        if let Some(name) = name {
1586            return format!("{kind} {name}");
1587        }
1588    }
1589    format!("not a {what}: wrong value at column {}", e.column())
1590}
1591
1592/// `value` without the object members that are `null`, at any depth.
1593fn without_nulls(value: &serde_json::Value) -> serde_json::Value {
1594    use serde_json::Value;
1595    match value {
1596        Value::Object(members) => Value::Object(
1597            members
1598                .iter()
1599                .filter(|(_, member)| !member.is_null())
1600                .map(|(name, member)| (name.clone(), without_nulls(member)))
1601                .collect(),
1602        ),
1603        Value::Array(items) => Value::Array(items.iter().map(without_nulls).collect()),
1604        other => other.clone(),
1605    }
1606}
1607
1608#[cfg(test)]
1609mod tests {
1610    use super::*;
1611    use tempfile::TempDir;
1612
1613    /// A folder's project key is the first 8 bytes of SHA-256 of its canonical path, as
1614    /// hex after `p`. Notes files and archive folders on disk are named by it, so it
1615    /// must never change: these are fixed values, not recomputed here.
1616    #[test]
1617    fn a_folder_maps_to_the_same_project_key_forever() {
1618        for (path, key) in [
1619            ("/fixed/project/root", "p6e2cb4c61e23315c"),
1620            ("/home/haseeb", "pa465e6cee4a607a1"),
1621        ] {
1622            let scope = ProjectScope::for_canonical_root(PathBuf::from(path));
1623            assert_eq!(scope.key(), key, "{path}");
1624        }
1625    }
1626
1627    fn rec(ns: &str, ts: u64, content: &str, tags: Vec<&str>) -> MemoryRecord {
1628        MemoryRecord {
1629            namespace: ns.to_string(),
1630            timestamp_ms: ts,
1631            content: content.to_string(),
1632            tags: tags.into_iter().map(String::from).collect(),
1633            meta: None,
1634            id: None,
1635            project: None,
1636            provenance: None,
1637            sensitivity: None,
1638            retention: None,
1639        }
1640    }
1641
1642    #[test]
1643    fn append_then_query_returns_record() {
1644        let tmp = TempDir::new().unwrap();
1645        let r = rec("ns", 100, "hello world", vec!["@user"]);
1646        append_to(tmp.path(), &r).unwrap();
1647        let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
1648        assert_eq!(got, vec![r]);
1649    }
1650
1651    #[test]
1652    fn query_filters_by_content_contains() {
1653        let tmp = TempDir::new().unwrap();
1654        append_to(tmp.path(), &rec("ns", 100, "Hello World", vec![])).unwrap();
1655        append_to(tmp.path(), &rec("ns", 200, "goodbye", vec![])).unwrap();
1656        let q = MemoryQuery {
1657            content_contains: Some("hello".to_string()),
1658            ..Default::default()
1659        };
1660        let got = query_in(tmp.path(), "ns", &q).unwrap();
1661        assert_eq!(got.len(), 1);
1662        assert_eq!(got[0].content, "Hello World");
1663    }
1664
1665    #[test]
1666    fn query_filters_by_tag_prefix() {
1667        let tmp = TempDir::new().unwrap();
1668        append_to(
1669            tmp.path(),
1670            &rec("ns", 100, "x", vec!["@user", "preference"]),
1671        )
1672        .unwrap();
1673        let got = query_in(
1674            tmp.path(),
1675            "ns",
1676            &MemoryQuery {
1677                tag_prefix: Some("@u".into()),
1678                ..Default::default()
1679            },
1680        )
1681        .unwrap();
1682        assert_eq!(got.len(), 1);
1683        let got = query_in(
1684            tmp.path(),
1685            "ns",
1686            &MemoryQuery {
1687                tag_prefix: Some("@x".into()),
1688                ..Default::default()
1689            },
1690        )
1691        .unwrap();
1692        assert!(got.is_empty());
1693    }
1694
1695    #[test]
1696    fn query_filters_by_time_range() {
1697        let tmp = TempDir::new().unwrap();
1698        append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
1699        append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
1700        append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
1701        let got = query_in(
1702            tmp.path(),
1703            "ns",
1704            &MemoryQuery {
1705                since_ms: Some(150),
1706                until_ms: Some(250),
1707                ..Default::default()
1708            },
1709        )
1710        .unwrap();
1711        assert_eq!(got.len(), 1);
1712        assert_eq!(got[0].timestamp_ms, 200);
1713    }
1714
1715    #[test]
1716    fn query_returns_most_recent_first() {
1717        let tmp = TempDir::new().unwrap();
1718        append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
1719        append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
1720        append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
1721        let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
1722        let ts: Vec<u64> = got.iter().map(|r| r.timestamp_ms).collect();
1723        assert_eq!(ts, vec![300, 200, 100]);
1724    }
1725
1726    #[test]
1727    fn query_respects_limit() {
1728        let tmp = TempDir::new().unwrap();
1729        for i in 1..=5 {
1730            append_to(tmp.path(), &rec("ns", i * 100, "x", vec![])).unwrap();
1731        }
1732        let got = query_in(
1733            tmp.path(),
1734            "ns",
1735            &MemoryQuery {
1736                limit: Some(2),
1737                ..Default::default()
1738            },
1739        )
1740        .unwrap();
1741        assert_eq!(got.len(), 2);
1742        assert_eq!(got[0].timestamp_ms, 500);
1743        assert_eq!(got[1].timestamp_ms, 400);
1744    }
1745
1746    #[test]
1747    fn query_skips_malformed_lines() {
1748        let tmp = TempDir::new().unwrap();
1749        let dir = memory_dir_in(tmp.path());
1750        fs::create_dir_all(&dir).unwrap();
1751        let path = namespace_path(&dir, "ns");
1752        let v1 = serde_json::to_string(&rec("ns", 100, "a", vec![])).unwrap();
1753        let v2 = serde_json::to_string(&rec("ns", 200, "b", vec![])).unwrap();
1754        let body = format!("invalid json\n{v1}\nnot json either\n{v2}\n");
1755        fs::write(&path, body).unwrap();
1756        let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
1757        assert_eq!(got.len(), 2);
1758    }
1759
1760    /// A line that is not UTF-8 is skipped like one that does not parse. A
1761    /// read error that will not go away ends the query with that error:
1762    /// `lines` returns it again on every call, so a loop that skipped it
1763    /// never ended.
1764    #[test]
1765    fn query_skips_a_line_that_is_not_utf8_and_stops_on_a_lasting_read_error() {
1766        let tmp = TempDir::new().unwrap();
1767        append_to(tmp.path(), &rec("ns", 1, "one", vec![])).unwrap();
1768        let path = memory_dir_in(tmp.path()).join("ns.jsonl");
1769        let mut bytes = fs::read(&path).unwrap();
1770        bytes.extend_from_slice(b"{\"content\":\"caf\xc3\n");
1771        fs::write(&path, bytes).unwrap();
1772        append_to(tmp.path(), &rec("ns", 2, "two", vec![])).unwrap();
1773        let found = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
1774        let contents: Vec<&str> = found.iter().map(|r| r.content.as_str()).collect();
1775        assert_eq!(contents, ["two", "one"]);
1776
1777        // A directory where the file should be opens, and every read of it
1778        // fails the same way.
1779        #[cfg(unix)]
1780        {
1781            fs::create_dir(memory_dir_in(tmp.path()).join("dir.jsonl")).unwrap();
1782            let error = query_in(tmp.path(), "dir", &MemoryQuery::default()).unwrap_err();
1783            assert!(matches!(error, MemoryError::Io(_)), "{error}");
1784        }
1785    }
1786
1787    #[test]
1788    fn append_rejects_oversized_content() {
1789        let tmp = TempDir::new().unwrap();
1790        let big = "x".repeat(MAX_CONTENT_BYTES + 1);
1791        let r = rec("ns", 1, &big, vec![]);
1792        match append_to(tmp.path(), &r) {
1793            Err(MemoryError::ContentTooLarge { .. }) => {}
1794            other => panic!("expected ContentTooLarge, got {other:?}"),
1795        }
1796    }
1797
1798    #[test]
1799    fn append_rejects_invalid_namespace() {
1800        let tmp = TempDir::new().unwrap();
1801        let cases = [
1802            "",
1803            "a/b",
1804            "a\\b",
1805            "..",
1806            "a..b",
1807            "has space",
1808            "tab\there",
1809            &"x".repeat(65),
1810        ];
1811        for ns in cases {
1812            let r = rec(ns, 1, "x", vec![]);
1813            match append_to(tmp.path(), &r) {
1814                Err(MemoryError::InvalidNamespace(_)) => {}
1815                other => panic!("expected InvalidNamespace for {ns:?}, got {other:?}"),
1816            }
1817        }
1818    }
1819
1820    #[test]
1821    fn list_namespaces_returns_existing_files() {
1822        let tmp = TempDir::new().unwrap();
1823        append_to(tmp.path(), &rec("alpha", 1, "x", vec![])).unwrap();
1824        append_to(tmp.path(), &rec("beta", 1, "x", vec![])).unwrap();
1825        let got = list_namespaces_in(tmp.path()).unwrap();
1826        assert_eq!(got, vec!["alpha".to_string(), "beta".to_string()]);
1827    }
1828
1829    #[test]
1830    fn list_namespaces_on_missing_dir_returns_empty() {
1831        let tmp = TempDir::new().unwrap();
1832        let got = list_namespaces_in(tmp.path()).unwrap();
1833        assert!(got.is_empty());
1834    }
1835
1836    #[test]
1837    fn query_on_missing_namespace_returns_empty() {
1838        let tmp = TempDir::new().unwrap();
1839        let got = query_in(tmp.path(), "nope", &MemoryQuery::default()).unwrap();
1840        assert!(got.is_empty());
1841    }
1842
1843    // ─── Durable, whole-line appends ────────────────────────────────────────
1844
1845    fn project_scope(tmp: &TempDir) -> ProjectScope {
1846        let root = tmp.path().join("project");
1847        fs::create_dir_all(&root).unwrap();
1848        ProjectScope::for_root(&root).unwrap()
1849    }
1850
1851    fn note(content: &str) -> NewMemoryRecord {
1852        NewMemoryRecord {
1853            content: content.to_string(),
1854            tags: vec!["t".into()],
1855            provenance: MemoryProvenance {
1856                source: "model".into(),
1857                session: None,
1858            },
1859            sensitivity: MemorySensitivity::Normal,
1860            retention: MemoryRetention::Standard,
1861        }
1862    }
1863
1864    /// The write and sync calls this thread's appends made since the last
1865    /// call, in order.
1866    fn take_steps() -> Vec<&'static str> {
1867        IO_STEPS.with(|steps| std::mem::take(&mut *steps.borrow_mut()))
1868    }
1869
1870    /// What this shows: the ORDER of the calls a save makes. One write of the
1871    /// line, then a sync of the file, then a sync of its directory (before
1872    /// all of them, once, a sync of the folder `memory/` was just made in),
1873    /// all before the call returns, and the same for
1874    /// the line a forget appends. What it cannot show: that the bytes are on
1875    /// the platter. A test cannot cut the power, and whether a sync reaches
1876    /// the medium is up to the system and the disk.
1877    #[test]
1878    fn a_save_and_a_forget_are_synced_before_they_return() {
1879        let tmp = TempDir::new().unwrap();
1880        let scope = project_scope(&tmp);
1881        take_steps();
1882
1883        let first = store_record_in(tmp.path(), &scope, note("first")).unwrap();
1884        assert_eq!(
1885            take_steps(),
1886            ["sync dir", "write", "sync file", "sync dir"],
1887            "the first save makes `memory/` and syncs the folder it is in; \
1888             then the line, the file, and the file's directory"
1889        );
1890
1891        store_record_in(tmp.path(), &scope, note("second")).unwrap();
1892        assert_eq!(
1893            take_steps(),
1894            ["write", "sync file", "sync dir"],
1895            "every save syncs the directory: that the file has bytes does not \
1896             say its name is on disk"
1897        );
1898
1899        forget_in(tmp.path(), &scope, first.id.as_deref().unwrap()).unwrap();
1900        assert_eq!(
1901            take_steps(),
1902            ["write", "sync file", "sync dir"],
1903            "a tombstone line is synced like a note"
1904        );
1905
1906        // The plain namespace path (the extension `memory.append` call).
1907        append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
1908        assert_eq!(take_steps(), ["write", "sync file", "sync dir"]);
1909
1910        // A file that is there but empty.
1911        fs::write(memory_dir_in(tmp.path()).join("empty.jsonl"), "").unwrap();
1912        append_to(tmp.path(), &rec("empty", 1, "x", vec![])).unwrap();
1913        assert_eq!(take_steps(), ["write", "sync file", "sync dir"]);
1914    }
1915
1916    /// When a sync fails after the line went in, the caller is told exactly
1917    /// that, not "the save failed": the note (or the forget) is in the file
1918    /// and every reader sees it. An error from before the write is not this
1919    /// one.
1920    #[test]
1921    fn a_sync_that_fails_after_the_write_is_written_unconfirmed() {
1922        struct Failing;
1923        impl Failing {
1924            fn step(name: &'static str) -> Self {
1925                FAILING_STEP.with(|step| step.set(Some(name)));
1926                Failing
1927            }
1928        }
1929        impl Drop for Failing {
1930            fn drop(&mut self) {
1931                FAILING_STEP.with(|step| step.set(None));
1932            }
1933        }
1934        let tmp = TempDir::new().unwrap();
1935        let base = tmp.path();
1936        let scope = project_scope(&tmp);
1937        let kept = store_record_in(base, &scope, note("kept")).unwrap();
1938        let contents = || -> Vec<String> {
1939            let live = load_live_project_records(base, &scope).unwrap();
1940            live.into_iter().map(|record| record.content).collect()
1941        };
1942
1943        for step in ["sync file", "sync dir"] {
1944            let failing = Failing::step(step);
1945            let error = store_record_in(base, &scope, note(step)).unwrap_err();
1946            drop(failing);
1947            // The error carries the id the note has in the file.
1948            let live = load_live_project_records(base, &scope).unwrap();
1949            let saved = live.iter().find(|r| r.content == step).unwrap();
1950            let saved_id = saved.id.clone().unwrap();
1951            assert!(
1952                matches!(&error, MemoryError::WrittenUnconfirmed { id: Some(id), .. } if *id == saved_id),
1953                "{error:?}"
1954            );
1955            let message = error.to_string();
1956            assert!(message.starts_with("written, but not confirmed on disk"), "{message}");
1957            assert!(message.contains(&format!("{step} failed")), "{message}");
1958            assert!(message.contains(&format!("fetch {saved_id} to check")), "{message}");
1959        }
1960        assert_eq!(contents(), ["kept", "sync file", "sync dir"], "both notes are there");
1961
1962        // A forget: the tombstone is in the file, and a second forget says
1963        // not found, which is why the caller must be told.
1964        let id = kept.id.as_deref().unwrap();
1965        let failing = Failing::step("sync file");
1966        let error = forget_in(base, &scope, id).unwrap_err();
1967        drop(failing);
1968        assert!(
1969            matches!(&error, MemoryError::WrittenUnconfirmed { id: Some(got), .. } if got == id),
1970            "{error:?}"
1971        );
1972        assert_eq!(contents(), ["sync file", "sync dir"]);
1973        assert!(matches!(forget_in(base, &scope, id), Err(MemoryError::NotFound(_))));
1974
1975        // The plain namespace path (an extension's `memory.append`).
1976        let failing = Failing::step("sync dir");
1977        let error = append_to(base, &rec("ns", 1, "x", vec![])).unwrap_err();
1978        drop(failing);
1979        // A record without an id: there is none to name.
1980        assert!(
1981            matches!(error, MemoryError::WrittenUnconfirmed { id: None, .. }),
1982            "{error}"
1983        );
1984        assert_eq!(query_in(base, "ns", &MemoryQuery::default()).unwrap().len(), 1);
1985
1986        // An import: its lines are written, and `finish` is where the sync
1987        // fails.
1988        let mut run = ImportRun::new(false);
1989        let line = exported("mem-i", scope.key(), 1, "imported");
1990        import(base, &line, ImportTarget::AsRecorded, &mut run);
1991        let failing = Failing::step("sync file");
1992        let error = run.finish().unwrap_err();
1993        drop(failing);
1994        assert!(
1995            matches!(error, MemoryError::WrittenUnconfirmed { id: None, .. }),
1996            "{error}"
1997        );
1998        assert_eq!(contents(), ["sync file", "sync dir", "imported"]);
1999
2000        // Before the write: the sync of the folder a new `memory/` is in,
2001        // and a lock that is not had. Nothing was written, and the error
2002        // does not say otherwise.
2003        let fresh = TempDir::new().unwrap();
2004        let failing = Failing::step("sync dir");
2005        let error = append_to(fresh.path(), &rec("ns", 1, "x", vec![])).unwrap_err();
2006        drop(failing);
2007        assert!(matches!(error, MemoryError::Io(_)), "{error}");
2008        assert!(!namespace_file(fresh.path(), "ns").exists());
2009    }
2010
2011    /// `finish` tries every file and every directory even after a sync has
2012    /// failed, and the error names the file whose sync failed first.
2013    #[test]
2014    fn finish_syncs_every_file_and_names_the_one_that_failed() {
2015        let tmp = TempDir::new().unwrap();
2016        let other = TempDir::new().unwrap();
2017        let base = tmp.path();
2018        let keys = [
2019            project_scope(&tmp).key().to_string(),
2020            project_scope(&other).key().to_string(),
2021        ];
2022        let mut run = ImportRun::new(false);
2023        for (n, key) in keys.iter().enumerate() {
2024            let line = exported(&format!("mem-{n}"), key, 1, "imported");
2025            let imported = import(base, &line, ImportTarget::AsRecorded, &mut run);
2026            assert_eq!(imported.outcome, ImportOutcome::Stored);
2027        }
2028        let mut paths: Vec<PathBuf> = keys
2029            .iter()
2030            .map(|key| namespace_file(base, &project_namespace(key)))
2031            .collect();
2032        paths.sort();
2033
2034        take_steps();
2035        FAILING_STEP.with(|step| step.set(Some("sync file")));
2036        let error = run.finish().unwrap_err();
2037        FAILING_STEP.with(|step| step.set(None));
2038        let syncs: Vec<&str> = take_steps()
2039            .into_iter()
2040            .filter(|step| step.starts_with("sync"))
2041            .collect();
2042        assert_eq!(syncs, ["sync file", "sync file", "sync dir"], "all tried");
2043        assert!(
2044            matches!(error, MemoryError::WrittenUnconfirmed { id: None, .. }),
2045            "{error}"
2046        );
2047        let message = error.to_string();
2048        assert!(message.contains(&paths[0].display().to_string()), "{message}");
2049        assert!(!message.contains(&paths[1].display().to_string()), "{message}");
2050    }
2051
2052    /// A save that cannot get the file's lock in time fails and writes
2053    /// nothing: it never appends without the lock.
2054    #[test]
2055    fn a_save_that_cannot_get_the_lock_fails_and_writes_nothing() {
2056        let tmp = TempDir::new().unwrap();
2057        let scope = project_scope(&tmp);
2058        store_record_in(tmp.path(), &scope, note("first")).unwrap();
2059        let path = namespace_file(tmp.path(), &project_namespace(scope.key()));
2060        let before = fs::read(&path).unwrap();
2061
2062        let held = lock_notes_file(&path).unwrap();
2063        set_notes_lock_wait(Some(std::time::Duration::from_millis(60)));
2064        let started = std::time::Instant::now();
2065        let refused = store_record_in(tmp.path(), &scope, note("second")).unwrap_err();
2066        // A file that is not there yet is not created either.
2067        let other = append_to(tmp.path(), &rec("other", 1, "x", vec![]));
2068        let _other_lock = lock_notes_file(&namespace_file(tmp.path(), "held")).unwrap();
2069        let unborn = append_to(tmp.path(), &rec("held", 1, "x", vec![])).unwrap_err();
2070        set_notes_lock_wait(None);
2071
2072        assert!(started.elapsed() >= std::time::Duration::from_millis(60));
2073        let message = refused.to_string();
2074        assert!(message.contains("is locked by another writer"), "{message}");
2075        assert!(message.contains("nothing was written"), "{message}");
2076        assert_eq!(fs::read(&path).unwrap(), before);
2077        other.expect("another file's lock is free");
2078        assert!(unborn.to_string().contains("held.jsonl is locked"));
2079        assert!(!namespace_file(tmp.path(), "held").exists());
2080
2081        drop(held);
2082        store_record_in(tmp.path(), &scope, note("second")).unwrap();
2083        assert_eq!(fs::read_to_string(&path).unwrap().lines().count(), 2);
2084    }
2085
2086    /// A file opened for append that another writer shares, as far as a
2087    /// writer can tell: every `write` lands at the end, whole. It takes at
2088    /// most the scripted number of bytes per call, and after its first short
2089    /// write the other writer appends `interloper`.
2090    struct SharedAppendFile {
2091        bytes: Vec<u8>,
2092        accept: std::collections::VecDeque<usize>,
2093        interloper: Option<String>,
2094        calls: Vec<Vec<u8>>,
2095    }
2096
2097    impl SharedAppendFile {
2098        fn taking(accept: &[usize], interloper: Option<String>) -> Self {
2099            Self {
2100                bytes: Vec::new(),
2101                accept: accept.iter().copied().collect(),
2102                interloper,
2103                calls: Vec::new(),
2104            }
2105        }
2106
2107        fn lines(&self) -> Vec<&str> {
2108            std::str::from_utf8(&self.bytes).unwrap().lines().collect()
2109        }
2110    }
2111
2112    impl Write for SharedAppendFile {
2113        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2114            let n = self.accept.pop_front().unwrap_or(buf.len()).min(buf.len());
2115            self.calls.push(buf.to_vec());
2116            self.bytes.extend_from_slice(&buf[..n]);
2117            if n < buf.len() {
2118                if let Some(other) = self.interloper.take() {
2119                    self.bytes.extend_from_slice(other.as_bytes());
2120                }
2121            }
2122            Ok(n)
2123        }
2124
2125        fn flush(&mut self) -> std::io::Result<()> {
2126            Ok(())
2127        }
2128    }
2129
2130    const LINE: &str = r#"{"namespace":"ns","timestamp_ms":1,"content":"mine","tags":[]}"#;
2131    const OTHER: &str = r#"{"namespace":"ns","timestamp_ms":2,"content":"theirs","tags":[]}"#;
2132
2133    #[test]
2134    fn a_line_is_one_write_call() {
2135        let mut file = SharedAppendFile::taking(&[], None);
2136        write_whole_line(&mut file, LINE, false).unwrap();
2137        assert_eq!(file.calls, [format!("{LINE}\n").into_bytes()]);
2138
2139        // The newline that ends a torn tail goes in the same call.
2140        let mut file = SharedAppendFile::taking(&[], None);
2141        write_whole_line(&mut file, LINE, true).unwrap();
2142        assert_eq!(file.calls, [format!("\n{LINE}\n").into_bytes()]);
2143    }
2144
2145    /// What this shows: after a short write the rest of the line is never
2146    /// sent as a second call, so a line another writer appends in between
2147    /// cannot end up inside this one. The writer is a script, not a file: a
2148    /// real file gives a short write only when the disk or a quota is full,
2149    /// which a hermetic test cannot arrange. What it cannot show: how a given
2150    /// file system behaves at that point.
2151    #[test]
2152    fn a_short_write_is_abandoned_and_the_whole_line_written_again() {
2153        // The other writer saw the torn tail and led with a newline.
2154        let mut file = SharedAppendFile::taking(&[20], Some(format!("\n{OTHER}\n")));
2155        write_whole_line(&mut file, LINE, false).unwrap();
2156        assert_eq!(file.lines(), [&LINE[..20], OTHER, "", LINE]);
2157        assert_eq!(
2158            file.calls,
2159            [format!("{LINE}\n").into_bytes(), format!("\n{LINE}\n").into_bytes()],
2160            "each call carries the whole line; none carries a remainder"
2161        );
2162
2163        // The other writer looked before the fragment was there and did not
2164        // lead with a newline. Its line is lost on the fragment, which no
2165        // writer can prevent without a lock; this writer's line is whole.
2166        let mut file = SharedAppendFile::taking(&[20], Some(format!("{OTHER}\n")));
2167        write_whole_line(&mut file, LINE, false).unwrap();
2168        let merged = format!("{}{OTHER}", &LINE[..20]);
2169        assert_eq!(file.lines(), [merged.as_str(), "", LINE]);
2170
2171        // Every reader keeps the good lines and skips the fragment.
2172        for line in file.lines() {
2173            let parsed = serde_json::from_str::<MemoryRecord>(line);
2174            assert_eq!(parsed.is_ok(), line == LINE, "{line:?}");
2175        }
2176    }
2177
2178    #[test]
2179    fn a_line_that_cannot_go_in_one_write_is_an_error_not_a_split_line() {
2180        // Every call is short: the save fails; it is never reported as done.
2181        let mut file = SharedAppendFile::taking(&[5, 5, 5, 5, 5, 5], None);
2182        let err = write_whole_line(&mut file, LINE, false).unwrap_err();
2183        assert_eq!(err.kind(), std::io::ErrorKind::WriteZero);
2184        assert_eq!(file.calls.len(), LINE_WRITE_ATTEMPTS);
2185        assert!(
2186            file.lines().iter().all(|line| line.len() <= 5),
2187            "only fragments, each on a line of its own: {:?}",
2188            file.lines()
2189        );
2190
2191        // A writer that takes nothing fails the same way.
2192        let mut file = SharedAppendFile::taking(&[0, 0, 0, 0], None);
2193        assert!(write_whole_line(&mut file, LINE, false).is_err());
2194        assert!(file.bytes.is_empty());
2195    }
2196
2197    /// What this shows: on the machine and file system the test runs on,
2198    /// writers that each open the file for append and start together do not
2199    /// tear or lose each other's lines, for lines from a few bytes to several
2200    /// pages. Threads with a handle each stand in for processes: the kernel
2201    /// orders appends by file, not by who holds the handle. What it cannot
2202    /// show: the same for every file system (a network one may not keep
2203    /// append writes whole), or what happens when a write is short; the
2204    /// scripted-writer tests above cover that.
2205    #[test]
2206    fn concurrent_appends_keep_every_line_whole_and_lose_none() {
2207        const WRITERS: usize = 8;
2208        const ROUNDS: usize = 24;
2209        let tmp = TempDir::new().unwrap();
2210        let scope = project_scope(&tmp);
2211        let start = std::sync::Barrier::new(WRITERS);
2212        let mut forgotten: Vec<String> = Vec::new();
2213        let mut failures: Vec<String> = Vec::new();
2214        std::thread::scope(|threads| {
2215            let handles: Vec<_> = (0..WRITERS)
2216                .map(|writer| {
2217                    let (base, scope, start) = (tmp.path(), &scope, &start);
2218                    threads.spawn(move || {
2219                        let (mut ids, mut errors) = (Vec::new(), Vec::new());
2220                        for round in 0..ROUNDS {
2221                            // 9 bytes to about 15 KiB, so some lines span
2222                            // several pages.
2223                            let pad = "x".repeat((round % 6) * 3_000);
2224                            let content = format!("w{writer}-r{round}-{pad}");
2225                            // Every writer comes to every round and none
2226                            // panics in between: one that stopped early would
2227                            // leave the others waiting here for good, and a
2228                            // torn line would hang the test, not fail it.
2229                            start.wait();
2230                            let stored = match store_record_in(base, scope, note(&content)) {
2231                                Ok(stored) => stored,
2232                                Err(e) => {
2233                                    errors.push(format!("save w{writer}-r{round}: {e}"));
2234                                    continue;
2235                                }
2236                            };
2237                            if round % 8 == 7 {
2238                                let id = stored.id.unwrap_or_default();
2239                                match forget_in(base, scope, &id) {
2240                                    Ok(()) => ids.push(id),
2241                                    Err(e) => errors.push(format!("forget w{writer}-r{round}: {e}")),
2242                                }
2243                            }
2244                        }
2245                        (ids, errors)
2246                    })
2247                })
2248                .collect();
2249            for handle in handles {
2250                let (ids, errors) = handle.join().unwrap();
2251                forgotten.extend(ids);
2252                failures.extend(errors);
2253            }
2254        });
2255        assert!(
2256            failures.is_empty(),
2257            "a save or a forget failed among concurrent writers: {failures:?}"
2258        );
2259
2260        let raw = fs::read_to_string(namespace_file(tmp.path(), &scope.namespace())).unwrap();
2261        assert!(raw.ends_with('\n'));
2262        let mut contents = std::collections::HashSet::new();
2263        let mut tombstones = std::collections::HashSet::new();
2264        for line in raw.lines().filter(|line| !line.is_empty()) {
2265            if let Ok(tomb) = serde_json::from_str::<TombstoneLine>(line) {
2266                assert!(tombstones.insert(tomb.tombstone), "a tombstone twice");
2267                continue;
2268            }
2269            let record: MemoryRecord = serde_json::from_str(line)
2270                .unwrap_or_else(|e| panic!("a line does not parse ({e}): {:.80}", line));
2271            let head: String = record.content.chars().take(12).collect();
2272            assert!(contents.insert(record.content), "a note twice: {head}");
2273        }
2274        assert_eq!(contents.len(), WRITERS * ROUNDS, "no note is lost");
2275        for writer in 0..WRITERS {
2276            for round in 0..ROUNDS {
2277                let pad = "x".repeat((round % 6) * 3_000);
2278                assert!(contents.contains(&format!("w{writer}-r{round}-{pad}")));
2279            }
2280        }
2281        assert_eq!(forgotten.len(), WRITERS * ROUNDS / 8);
2282        assert_eq!(
2283            tombstones,
2284            forgotten.iter().cloned().collect(),
2285            "no tombstone is lost"
2286        );
2287        let live = load_live_project_records(tmp.path(), &scope).unwrap();
2288        assert_eq!(live.len(), WRITERS * ROUNDS - forgotten.len());
2289    }
2290
2291    // ─── The torn last line ─────────────────────────────────────────────────
2292
2293    /// What this shows: what the next append does with a last line that a
2294    /// dead writer left unfinished, cut inside a multi-byte character. No
2295    /// byte before the tear changes, the fragment ends up alone on a line
2296    /// every reader skips, and the new note is whole. The tear is made by
2297    /// writing the file, not by killing a process mid-write.
2298    #[test]
2299    fn a_torn_last_line_is_fenced_off_and_no_good_line_is_touched() {
2300        let tmp = TempDir::new().unwrap();
2301        let scope = project_scope(&tmp);
2302        let kept = store_record_in(tmp.path(), &scope, note("kept")).unwrap();
2303        let path = namespace_file(tmp.path(), &scope.namespace());
2304        let before = fs::read(&path).unwrap();
2305
2306        let mut whole = MemoryRecord {
2307            content: "caf\u{e9}".into(),
2308            ..kept.clone()
2309        };
2310        whole.id = Some("mem-torn".into());
2311        let torn_line = serde_json::to_string(&whole).unwrap();
2312        let cut = torn_line.find('\u{e9}').unwrap() + 1;
2313        let fragment = &torn_line.as_bytes()[..cut];
2314        assert!(std::str::from_utf8(fragment).is_err(), "cut inside a character");
2315        let mut torn = before.clone();
2316        torn.extend_from_slice(fragment);
2317        fs::write(&path, &torn).unwrap();
2318
2319        let after = store_record_in(tmp.path(), &scope, note("after the tear")).unwrap();
2320
2321        let bytes = fs::read(&path).unwrap();
2322        assert!(bytes.starts_with(&torn), "nothing before the append changed");
2323        let mut expected = torn.clone();
2324        expected.push(b'\n');
2325        expected.extend_from_slice(serde_json::to_string(&after).unwrap().as_bytes());
2326        expected.push(b'\n');
2327        assert_eq!(bytes, expected, "fragment, newline, the new line, newline");
2328
2329        let live = load_live_project_records(tmp.path(), &scope).unwrap();
2330        assert_eq!(live, [kept.clone(), after.clone()]);
2331        let ids = [kept.id.as_deref().unwrap(), after.id.as_deref().unwrap()];
2332        assert_eq!(fetch_exact_in(tmp.path(), &scope, &ids).unwrap().len(), 2);
2333        assert_eq!(
2334            query_in(tmp.path(), &scope.namespace(), &MemoryQuery::default())
2335                .unwrap()
2336                .len(),
2337            2
2338        );
2339    }
2340
2341    /// A last line that is complete but for its newline is a good line. The
2342    /// next append ends it; it is neither dropped nor run into the new one.
2343    #[test]
2344    fn a_last_line_missing_only_its_newline_is_kept() {
2345        let tmp = TempDir::new().unwrap();
2346        let scope = project_scope(&tmp);
2347        let first = store_record_in(tmp.path(), &scope, note("first")).unwrap();
2348        let path = namespace_file(tmp.path(), &scope.namespace());
2349        let mut bytes = fs::read(&path).unwrap();
2350        assert_eq!(bytes.pop(), Some(b'\n'));
2351        fs::write(&path, bytes).unwrap();
2352
2353        let second = store_record_in(tmp.path(), &scope, note("second")).unwrap();
2354
2355        let live = load_live_project_records(tmp.path(), &scope).unwrap();
2356        assert_eq!(live, [first, second]);
2357        let raw = fs::read_to_string(&path).unwrap();
2358        assert_eq!(raw.lines().count(), 2, "no empty line, nothing merged");
2359    }
2360
2361    // ─── Import from another store (cut-spec A3) ───────────────────────────────────
2362
2363    /// A note line as another store exports it, with all ten fields.
2364    fn exported(id: &str, project: &str, timestamp_ms: u64, content: &str) -> String {
2365        serde_json::json!({
2366            "namespace": format!("project-{project}"),
2367            "timestamp_ms": timestamp_ms,
2368            "content": content,
2369            "tags": ["imported", "@user"],
2370            "meta": {"_synaps_repository": {"source_worktree_root": "/elsewhere"}, "blank": null},
2371            "id": id,
2372            "project": project,
2373            "provenance": {"source": "model", "session": "sess-old"},
2374            "sensitivity": "sensitive",
2375            "retention": {"max_age_days": 30},
2376        })
2377        .to_string()
2378    }
2379
2380    fn import(
2381        base: &Path,
2382        line: &str,
2383        target: ImportTarget<'_>,
2384        run: &mut ImportRun,
2385    ) -> ImportedLine {
2386        import_line_in(base, line, target, run).unwrap()
2387    }
2388
2389    fn refusal(imported: &ImportedLine) -> &str {
2390        match &imported.outcome {
2391            ImportOutcome::Refused(reason) => reason,
2392            other => panic!("expected a refusal, got {other:?}"),
2393        }
2394    }
2395
2396    /// cut-spec T-A10.
2397    #[test]
2398    fn import_notes_t_a10() {
2399        let tmp = TempDir::new().unwrap();
2400        let base = tmp.path();
2401        let scope = project_scope(&tmp);
2402        let key = scope.key().to_string();
2403        let here = ImportTarget::Project(&key);
2404        let path = namespace_file(base, &scope.namespace());
2405
2406        // A file with 3 notes; import 8 more.
2407        let old: Vec<String> = (0..3)
2408            .map(|i| {
2409                let stored = store_record_in(base, &scope, note(&format!("old note {i}")));
2410                stored.unwrap().id.unwrap()
2411            })
2412            .collect();
2413        let lines: Vec<String> = (0..8u64)
2414            .map(|i| exported(&format!("mem-new-{i}"), &key, 1_000 + i, &format!("new note {i}")))
2415            .collect();
2416        let mut run = ImportRun::new(false);
2417        for (i, line) in lines.iter().enumerate() {
2418            assert_eq!(
2419                import(base, line, ImportTarget::AsRecorded, &mut run),
2420                ImportedLine {
2421                    outcome: ImportOutcome::Stored,
2422                    id: Some(format!("mem-new-{i}")),
2423                    project: Some(key.clone()),
2424                    tombstone: false,
2425                }
2426            );
2427        }
2428        assert_eq!(run.finish().unwrap(), std::slice::from_ref(&path));
2429
2430        // 11 live; search and fetch find old and new.
2431        assert_eq!(load_live_project_records(base, &scope).unwrap().len(), 11);
2432        let search = |needle: &str| {
2433            let query = ProjectMemoryQuery {
2434                content_contains: Some(needle.into()),
2435                limit: Some(MAX_SEARCH_LIMIT),
2436                ..Default::default()
2437            };
2438            search_project_in(base, &scope, &query).unwrap().len()
2439        };
2440        assert_eq!(
2441            (search("note"), search("old note"), search("new note")),
2442            (11, 3, 8)
2443        );
2444        let mut ids = old.clone();
2445        ids.extend((0..8).map(|i| format!("mem-new-{i}")));
2446        let wanted: Vec<&str> = ids.iter().map(String::as_str).collect();
2447        let fetched = fetch_exact_in(base, &scope, &wanted).unwrap();
2448        assert_eq!(fetched.len(), 11);
2449        // An imported note is its line: every field, and the time it came with.
2450        for (line, stored) in lines.iter().zip(&fetched[3..]) {
2451            assert_eq!(
2452                serde_json::to_value(stored).unwrap(),
2453                serde_json::from_str::<serde_json::Value>(line).unwrap()
2454            );
2455        }
2456
2457        // Import again: 8 already_present, file bytes unchanged.
2458        let before = fs::read(&path).unwrap();
2459        let mut again = ImportRun::new(false);
2460        for line in &lines {
2461            assert_eq!(
2462                import(base, line, ImportTarget::AsRecorded, &mut again).outcome,
2463                ImportOutcome::AlreadyPresent
2464            );
2465        }
2466        // A line with a known id and other content is refused.
2467        let changed = exported("mem-new-3", &key, 1_003, "another text");
2468        assert_eq!(
2469            refusal(&import(base, &changed, here, &mut again)),
2470            "id already holds a different note"
2471        );
2472        assert!(again.finish().unwrap().is_empty());
2473        assert_eq!(fs::read(&path).unwrap(), before);
2474
2475        // A tombstoned id is kept_forgotten.
2476        forget_in(base, &scope, "mem-new-5").unwrap();
2477        let before = fs::read(&path).unwrap();
2478        let mut run = ImportRun::new(false);
2479        assert_eq!(
2480            import(base, &lines[5], here, &mut run).outcome,
2481            ImportOutcome::KeptForgotten
2482        );
2483
2484        // A line with an extra field is refused.
2485        let extra = lines[0].replacen('{', r#"{"colour":"red","#, 1);
2486        assert_eq!(
2487            refusal(&import(base, &extra, here, &mut run)),
2488            "unknown field colour"
2489        );
2490
2491        // The user-wide key is refused: as a line's own project, and as the
2492        // project to file under.
2493        let user_wide = exported("mem-user", USER_WIDE_KEY, 3_000, "a user-wide note");
2494        assert_eq!(
2495            refusal(&import(base, &user_wide, ImportTarget::AsRecorded, &mut run)),
2496            "user-wide note; pass --cwd to file it under a project"
2497        );
2498        let to_user_wide = ImportTarget::Project(USER_WIDE_KEY);
2499        let err = import_line_in(base, &lines[0], to_user_wide, &mut run).unwrap_err();
2500        assert!(matches!(err, MemoryError::InvalidProjectRoot(_)), "{err}");
2501
2502        assert!(run.finish().unwrap().is_empty());
2503        assert_eq!(fs::read(&path).unwrap(), before, "none of these wrote");
2504        assert_eq!(list_namespaces_in(base).unwrap(), [scope.namespace()]);
2505        let mut run = ImportRun::new(false);
2506
2507        // A tombstone line before its note keeps the note out.
2508        let tombstone = r#"{"tombstone":"mem-late","timestamp_ms":77}"#;
2509        let late = exported("mem-late", &key, 2_000, "forgotten before it came");
2510        let first = import(base, tombstone, here, &mut run);
2511        assert_eq!(
2512            (first.outcome, first.tombstone),
2513            (ImportOutcome::Stored, true)
2514        );
2515        assert_eq!(
2516            import(base, &late, here, &mut run).outcome,
2517            ImportOutcome::KeptForgotten
2518        );
2519        run.finish().unwrap();
2520        let mut run = ImportRun::new(false);
2521        assert_eq!(
2522            fs::read_to_string(&path).unwrap().lines().last(),
2523            Some(tombstone),
2524            "the tombstone is stored with the time it came with"
2525        );
2526        assert!(matches!(
2527            fetch_exact_in(base, &scope, &["mem-late"]),
2528            Err(MemoryError::NotFound(_))
2529        ));
2530        // In a later run too.
2531        assert_eq!(
2532            import(base, &late, here, &mut ImportRun::new(false)).outcome,
2533            ImportOutcome::KeptForgotten
2534        );
2535
2536        // Re-homing rewrites `project` and `namespace`, and nothing else.
2537        let elsewhere = "pc4d9f8d15dc3bd8f";
2538        let moved = exported("mem-moved", elsewhere, 4_000, "from another project");
2539        let imported = import(base, &moved, here, &mut run);
2540        assert_eq!(
2541            (imported.outcome, imported.project),
2542            (ImportOutcome::Stored, Some(key.clone()))
2543        );
2544        let stored = fetch_exact_in(base, &scope, &["mem-moved"]).unwrap().remove(0);
2545        let mut expected: MemoryRecord = serde_json::from_str(&moved).unwrap();
2546        expected.project = Some(key.clone());
2547        expected.namespace = scope.namespace();
2548        assert_eq!(stored, expected);
2549        assert!(!namespace_file(base, &project_namespace(elsewhere)).exists());
2550
2551        // A user-wide note is filed under a project when one is named.
2552        assert_eq!(
2553            import(base, &user_wide, here, &mut run).outcome,
2554            ImportOutcome::Stored
2555        );
2556        let filed = fetch_exact_in(base, &scope, &["mem-user"]).unwrap().remove(0);
2557        assert_eq!(filed.project.as_deref(), Some(key.as_str()));
2558        assert_eq!(filed.namespace, scope.namespace());
2559        run.finish().unwrap();
2560    }
2561
2562    /// What this shows: with `dry_run` no byte of the store changes and no
2563    /// file or directory appears, and the outcomes are, line for line, those
2564    /// of the real run made next from the same files. It cannot show the
2565    /// same for a store that another process changes between the two runs.
2566    #[test]
2567    fn a_dry_run_changes_no_byte_and_says_what_the_real_run_then_does() {
2568        let tmp = TempDir::new().unwrap();
2569        let base = tmp.path();
2570        let scope = project_scope(&tmp);
2571        let key = scope.key().to_string();
2572        let here = ImportTarget::Project(&key);
2573        let path = namespace_file(base, &scope.namespace());
2574
2575        // In the file: a live note and a forgotten one.
2576        let mut setup = ImportRun::new(false);
2577        for id in ["mem-live", "mem-gone"] {
2578            import(base, &exported(id, &key, 10, "there before"), here, &mut setup);
2579        }
2580        setup.finish().unwrap();
2581        forget_in(base, &scope, "mem-gone").unwrap();
2582        let before = fs::read(&path).unwrap();
2583
2584        let lines = [
2585            exported("mem-a", &key, 20, "new"),
2586            exported("mem-a", &key, 20, "new"),
2587            exported("mem-a", &key, 20, "not the same"),
2588            exported("mem-live", &key, 10, "there before"),
2589            exported("mem-gone", &key, 10, "there before"),
2590            r#"{"tombstone":"mem-live","timestamp_ms":30}"#.to_string(),
2591            r#"{"tombstone":"mem-live","timestamp_ms":31}"#.to_string(),
2592            exported("mem-live", &key, 10, "there before"),
2593            r#"{"tombstone":"mem-never","timestamp_ms":32}"#.to_string(),
2594            exported("mem-never", &key, 40, "comes after its tombstone"),
2595            "not json".to_string(),
2596        ];
2597        let expected = [
2598            "stored",
2599            "already_present",
2600            "refused",
2601            "already_present",
2602            "kept_forgotten",
2603            "forgotten",
2604            "already_present",
2605            "kept_forgotten",
2606            "stored",
2607            "kept_forgotten",
2608            "refused",
2609        ];
2610        let run_all = |dry_run: bool| -> Vec<ImportedLine> {
2611            let mut run = ImportRun::new(dry_run);
2612            assert_eq!(run.is_dry_run(), dry_run);
2613            let all: Vec<ImportedLine> = lines
2614                .iter()
2615                .map(|line| import_line_in(base, line, here, &mut run).unwrap())
2616                .collect();
2617            assert_eq!(run.finish().unwrap().is_empty(), dry_run);
2618            all
2619        };
2620
2621        take_steps();
2622        let dry = run_all(true);
2623        assert!(take_steps().is_empty(), "a dry run writes and syncs nothing");
2624        assert_eq!(fs::read(&path).unwrap(), before, "byte for byte");
2625        assert_eq!(list_namespaces_in(base).unwrap(), [scope.namespace()]);
2626        let names: Vec<&str> = dry.iter().map(|line| line.outcome.name()).collect();
2627        assert_eq!(names, expected);
2628
2629        let real = run_all(false);
2630        assert_eq!(real, dry, "the real run does what the dry run said");
2631        let live = load_live_project_records(base, &scope).unwrap();
2632        let ids: Vec<&str> = live.iter().filter_map(|r| r.id.as_deref()).collect();
2633        assert_eq!(ids, ["mem-a"], "and the file agrees");
2634
2635        // Where there is no store yet, a dry run makes none.
2636        let empty = TempDir::new().unwrap();
2637        let mut run = ImportRun::new(true);
2638        let imported = import_line_in(empty.path(), &lines[0], here, &mut run).unwrap();
2639        assert_eq!(imported.outcome, ImportOutcome::Stored);
2640        assert!(run.finish().unwrap().is_empty());
2641        assert_eq!(fs::read_dir(empty.path()).unwrap().count(), 0);
2642    }
2643
2644    /// A run cannot be a preview and then an import: the mode is fixed when
2645    /// the run is made, so the import after a preview is a new run, reads
2646    /// the files again and stores what the preview only said it would.
2647    #[test]
2648    fn a_preview_and_the_import_after_it_are_two_runs() {
2649        let tmp = TempDir::new().unwrap();
2650        let base = tmp.path();
2651        let key = "p1111111111111111";
2652        let line = exported("mem-a", key, 1, "x");
2653        for dry_run in [true, true, false] {
2654            let mut run = ImportRun::new(dry_run);
2655            let first = import(base, &line, ImportTarget::AsRecorded, &mut run);
2656            assert_eq!(first.outcome, ImportOutcome::Stored, "dry_run {dry_run}");
2657            // Within one run the line is known, in a dry run too.
2658            let again = import(base, &line, ImportTarget::AsRecorded, &mut run);
2659            assert_eq!(again.outcome, ImportOutcome::AlreadyPresent);
2660            assert_eq!(run.finish().unwrap().len(), usize::from(!dry_run));
2661        }
2662        let text = fs::read_to_string(namespace_file(base, &project_namespace(key))).unwrap();
2663        assert_eq!(text.lines().count(), 1);
2664    }
2665
2666    /// A real run that is dropped without `finish` (an early return) still
2667    /// syncs what it appended; a dry run and a run that stored nothing sync
2668    /// nothing.
2669    #[test]
2670    fn a_run_dropped_unfinished_still_syncs_what_it_appended() {
2671        let tmp = TempDir::new().unwrap();
2672        let base = tmp.path();
2673        let key = "p1111111111111111";
2674        take_steps();
2675        {
2676            let mut run = ImportRun::new(false);
2677            import(base, &exported("mem-a", key, 1, "x"), ImportTarget::AsRecorded, &mut run);
2678            assert_eq!(take_steps(), ["sync dir", "write"]);
2679        }
2680        assert_eq!(take_steps(), ["sync file", "sync dir"], "synced on drop");
2681        {
2682            let mut run = ImportRun::new(false);
2683            import(base, &exported("mem-a", key, 1, "x"), ImportTarget::AsRecorded, &mut run);
2684            let mut dry = ImportRun::new(true);
2685            import(base, &exported("mem-b", key, 1, "x"), ImportTarget::AsRecorded, &mut dry);
2686        }
2687        assert!(take_steps().is_empty(), "nothing appended, nothing synced");
2688        // A finished run is not synced a second time when it goes.
2689        let mut run = ImportRun::new(false);
2690        import(base, &exported("mem-c", key, 1, "x"), ImportTarget::AsRecorded, &mut run);
2691        run.finish().unwrap();
2692        assert_eq!(take_steps(), ["write", "sync file", "sync dir"]);
2693    }
2694
2695    /// What this shows: the order of calls. An import's lines are written
2696    /// without a sync each, and `ImportRun::sync` then syncs every file the
2697    /// run appended to, once, and their directory after them. As for a save,
2698    /// it cannot show that the bytes reached the disk.
2699    #[test]
2700    fn an_import_syncs_each_file_once_at_the_end_of_the_run() {
2701        let tmp = TempDir::new().unwrap();
2702        let base = tmp.path();
2703        let (a, b) = ("p1111111111111111", "p2222222222222222");
2704        let mut run = ImportRun::new(false);
2705        take_steps();
2706        for (i, project) in [a, a, b, a].into_iter().enumerate() {
2707            let line = exported(&format!("mem-{i}"), project, i as u64, "x");
2708            assert_eq!(
2709                import(base, &line, ImportTarget::AsRecorded, &mut run).outcome,
2710                ImportOutcome::Stored
2711            );
2712        }
2713        let tombstone = r#"{"tombstone":"mem-0","timestamp_ms":9}"#;
2714        assert_eq!(
2715            import(base, tombstone, ImportTarget::Project(a), &mut run).outcome,
2716            ImportOutcome::Forgotten
2717        );
2718        assert_eq!(
2719            take_steps(),
2720            ["sync dir", "write", "write", "write", "write", "write"],
2721            "the folder the new `memory/` is in, then five lines and no sync yet"
2722        );
2723
2724        let files = [a, b].map(|key| namespace_file(base, &project_namespace(key)));
2725        assert_eq!(run.finish().unwrap(), files);
2726        assert_eq!(take_steps(), ["sync file", "sync file", "sync dir"]);
2727    }
2728
2729    #[test]
2730    fn a_line_that_could_not_be_kept_whole_is_refused_and_writes_nothing() {
2731        let tmp = TempDir::new().unwrap();
2732        let base = tmp.path();
2733        let key = "p1111111111111111";
2734        let good = exported("mem-x", key, 5, "secret words");
2735        let with = |from: &str, to: &str| {
2736            assert!(good.contains(from), "{from}");
2737            good.replacen(from, to, 1)
2738        };
2739        let long_id = format!(r#""id":"{}""#, "i".repeat(129));
2740        let big = format!(r#""content":"{}""#, "c".repeat(MAX_CONTENT_BYTES + 1));
2741        let long_key = format!(r#"{{"{}":1,"content""#, "secret".repeat(6));
2742        let wrong_value = "not a note: wrong value at column ";
2743        let cases: Vec<(String, &str)> = vec![
2744            // A time is never made up.
2745            (with(r#","timestamp_ms":5"#, ""), "missing field timestamp_ms"),
2746            (with(r#""timestamp_ms":5"#, r#""timestamp_ms":"5""#), wrong_value),
2747            // Nothing is dropped: not a field, not a part of one, not a
2748            // second copy of one.
2749            (with(r#"{"content""#, r#"{"extra":1,"content""#), "unknown field extra"),
2750            // A key is text from the line too: one that does not look like a
2751            // field name is not shown.
2752            (with(r#"{"content""#, r#"{"secret words":1,"content""#), "unknown field"),
2753            (with(r#"{"content""#, r#"{"Secret":1,"content""#), "unknown field"),
2754            (with(r#"{"content""#, r#"{"secret-token-1234":1,"content""#), "unknown field"),
2755            (with(r#"{"content""#, &long_key), "unknown field"),
2756            (with(r#"{"content""#, r#"{"":1,"content""#), "unknown field"),
2757            (
2758                r#"{"tombstone":"mem-x","timestamp_ms":1,"secret words":2}"#.to_string(),
2759                "unknown field",
2760            ),
2761            (
2762                with(r#""source":"model""#, r#""source":"model","host":"x""#),
2763                "provenance holds something a note cannot keep",
2764            ),
2765            (
2766                with(r#"{"content""#, r#"{"content":"first","content""#),
2767                "duplicate field content",
2768            ),
2769            (with(r#""retention":{"max_age_days":30}"#, r#""retention":"forever""#), wrong_value),
2770            (with(r#""id":"mem-x","#, ""), "missing field id"),
2771            (with(r#""id":"mem-x""#, r#""id":"mem x""#), "invalid id"),
2772            (with(r#""id":"mem-x""#, r#""id":"""#), "invalid id"),
2773            (with(r#""id":"mem-x""#, &long_id), "invalid id"),
2774            (with(r#""project":"p1111111111111111","#, ""), "missing field project"),
2775            (
2776                with(r#""project":"p1111111111111111""#, r#""project":"P1111111111111111""#),
2777                "invalid project",
2778            ),
2779            (
2780                with(r#""namespace":"project-p1111111111111111""#, r#""namespace":"notes""#),
2781                "namespace is not project-<project>",
2782            ),
2783            (with(r#""content":"secret words""#, &big), "content is over 16384 bytes"),
2784            (r#"{"content":"secret words""#.to_string(), "not JSON at column "),
2785            (r#"["secret words"]"#.to_string(), "not a JSON object"),
2786            (r#""secret words""#.to_string(), "not a JSON object"),
2787            // Tombstone lines.
2788            (
2789                r#"{"tombstone":"mem-x","timestamp_ms":1,"content":"secret words"}"#.to_string(),
2790                "unknown field content",
2791            ),
2792            (r#"{"tombstone":"mem-x"}"#.to_string(), "missing field timestamp_ms"),
2793            (
2794                r#"{"tombstone":"mem-gone","timestamp_ms":1}"#.to_string(),
2795                "a tombstone line names no project and no project's notes hold its id; \
2796                 pass --cwd or --project-key",
2797            ),
2798        ];
2799        let mut run = ImportRun::new(false);
2800        for (line, expected) in &cases {
2801            let imported = import(base, line, ImportTarget::AsRecorded, &mut run);
2802            let reason = refusal(&imported);
2803            assert_eq!(
2804                reason.trim_end_matches(|c: char| c.is_ascii_digit()),
2805                *expected,
2806                "{line:.70}"
2807            );
2808            let quoted = reason.to_lowercase().contains("secret");
2809            assert!(!quoted, "a reason quotes nothing: {reason}");
2810        }
2811        assert!(!memory_dir_in(base).exists(), "a refused line writes nothing");
2812
2813        // The line they were all made from is a good one.
2814        assert_eq!(
2815            import(base, &good, ImportTarget::AsRecorded, &mut run).outcome,
2816            ImportOutcome::Stored
2817        );
2818        assert_eq!(run.finish().unwrap().len(), 1);
2819    }
2820
2821    /// A tombstone line as recorded whose note an earlier run stored, in a
2822    /// file no line of this run has named: every project's file is read for
2823    /// the id before the line is refused (core-a5-review, finding 1).
2824    #[test]
2825    fn a_tombstone_as_recorded_finds_its_note_in_a_file_the_run_has_not_opened() {
2826        let (a, b) = ("p1111111111111111", "p2222222222222222");
2827        let as_recorded = ImportTarget::AsRecorded;
2828        let tombstone = |id: &str| format!(r#"{{"tombstone":"{id}","timestamp_ms":9}}"#);
2829        let file = |base: &Path, key: &str| namespace_file(base, &project_namespace(key));
2830        let tmp = TempDir::new().unwrap();
2831        let base = tmp.path();
2832        let mut run = ImportRun::new(false);
2833        for (id, key) in [("mem-1", a), ("mem-5", b)] {
2834            let stored = import(base, &exported(id, key, 1, "x"), as_recorded, &mut run);
2835            assert_eq!(stored.outcome, ImportOutcome::Stored);
2836        }
2837        let _ = run.finish().unwrap();
2838        // Not notes files of a project: none of these is read for an id.
2839        let memory = memory_dir_in(base);
2840        fs::write(memory.join("project-p0000000000000000.jsonl"), exported("mem-u", a, 1, "x"))
2841            .unwrap();
2842        fs::write(memory.join("session-notes.jsonl"), "not a note\n").unwrap();
2843        fs::write(memory.join(".import.lock"), "").unwrap();
2844
2845        // The dry run says what the real run says and writes nothing.
2846        for dry_run in [true, false] {
2847            let before = fs::read_to_string(file(base, a)).unwrap();
2848            let mut run = ImportRun::new(dry_run);
2849            let late = import(base, &tombstone("mem-1"), as_recorded, &mut run);
2850            assert_eq!(
2851                (late.outcome, late.project.as_deref()),
2852                (ImportOutcome::Forgotten, Some(a)),
2853                "dry run {dry_run}"
2854            );
2855            // An id that no project's file holds is still refused, and so is
2856            // one that only a file of no project holds.
2857            for id in ["mem-nowhere", "mem-u"] {
2858                let nowhere = import(base, &tombstone(id), as_recorded, &mut run);
2859                assert_eq!(refusal(&nowhere), UNPLACED_TOMBSTONE, "{id}");
2860            }
2861            let files = run.finish().unwrap();
2862            let text = fs::read_to_string(file(base, a)).unwrap();
2863            if dry_run {
2864                assert!(files.is_empty());
2865                assert_eq!(text, before);
2866            } else {
2867                assert_eq!(files, [file(base, a)]);
2868                assert_eq!(text.lines().last(), Some(tombstone("mem-1").as_str()));
2869                assert!(!fs::read_to_string(file(base, b)).unwrap().contains("tombstone"));
2870            }
2871        }
2872        // Again: it is there.
2873        let mut run = ImportRun::new(false);
2874        assert_eq!(
2875            import(base, &tombstone("mem-1"), as_recorded, &mut run).outcome,
2876            ImportOutcome::AlreadyPresent
2877        );
2878        assert!(run.finish().unwrap().is_empty());
2879    }
2880
2881    /// A tombstone line as recorded, in both orders, and the refused case
2882    /// (cut-spec A3, "The one change to the built code").
2883    #[test]
2884    fn a_tombstone_as_recorded_is_filed_where_the_run_saw_its_id_or_keeps_its_note_out() {
2885        let (a, b) = ("p1111111111111111", "p2222222222222222");
2886        let as_recorded = ImportTarget::AsRecorded;
2887        let tombstone = |id: &str| format!(r#"{{"tombstone":"{id}","timestamp_ms":9}}"#);
2888        let file = |base: &Path, key: &str| namespace_file(base, &project_namespace(key));
2889        let live = |base: &Path, key: &str| -> Vec<String> {
2890            let scanned = scan_project_file(base, key).unwrap();
2891            let mut ids: Vec<String> = scanned
2892                .records
2893                .iter()
2894                .filter_map(|record| record.id.clone())
2895                .filter(|id| !scanned.tombstoned.contains(id))
2896                .collect();
2897            ids.sort();
2898            ids
2899        };
2900
2901        for dry_run in [true, false] {
2902            // Note before tombstone: the run has seen the id in project a's
2903            // file, so the tombstone is filed there.
2904            let tmp = TempDir::new().unwrap();
2905            let base = tmp.path();
2906            let mut run = ImportRun::new(dry_run);
2907            for (id, key) in [("mem-1", a), ("mem-2", a), ("mem-3", b)] {
2908                import(base, &exported(id, key, 1, "x"), as_recorded, &mut run);
2909            }
2910            let forgotten = import(base, &tombstone("mem-1"), as_recorded, &mut run);
2911            assert_eq!(
2912                forgotten,
2913                ImportedLine {
2914                    outcome: ImportOutcome::Forgotten,
2915                    id: Some("mem-1".into()),
2916                    project: Some(a.into()),
2917                    tombstone: true,
2918                }
2919            );
2920            // The same tombstone again, and the note again.
2921            assert_eq!(
2922                import(base, &tombstone("mem-1"), as_recorded, &mut run).outcome,
2923                ImportOutcome::AlreadyPresent
2924            );
2925            assert_eq!(
2926                import(base, &exported("mem-1", a, 1, "x"), as_recorded, &mut run).outcome,
2927                ImportOutcome::KeptForgotten
2928            );
2929
2930            // Tombstone before note: the run has not seen the id, so there
2931            // is no project to file it under. Both lines are refused.
2932            let refused = import(base, &tombstone("mem-9"), as_recorded, &mut run);
2933            assert_eq!(
2934                refusal(&refused),
2935                "a tombstone line names no project and no project's notes hold its id; \
2936                 pass --cwd or --project-key"
2937            );
2938            assert_eq!((refused.id.as_deref(), refused.project), (Some("mem-9"), None));
2939            let late = import(base, &exported("mem-9", a, 1, "x"), as_recorded, &mut run);
2940            assert_eq!(refusal(&late), "its tombstone in this run could not be filed");
2941            // Under a named project too: the run cannot tell that this is
2942            // not the note the tombstone forgot.
2943            let late = import(base, &exported("mem-9", a, 1, "x"), ImportTarget::Project(b), &mut run);
2944            assert_eq!(refusal(&late), "its tombstone in this run could not be filed");
2945            // Other notes go on as before.
2946            assert_eq!(
2947                import(base, &exported("mem-4", b, 1, "x"), as_recorded, &mut run).outcome,
2948                ImportOutcome::Stored
2949            );
2950
2951            let files = run.finish().unwrap();
2952            if dry_run {
2953                assert!(files.is_empty());
2954                assert!(!memory_dir_in(base).exists(), "a dry run writes nothing");
2955                continue;
2956            }
2957            assert_eq!(files, [file(base, a), file(base, b)]);
2958            assert_eq!(live(base, a), ["mem-2"]);
2959            assert_eq!(live(base, b), ["mem-3", "mem-4"]);
2960            let text = fs::read_to_string(file(base, a)).unwrap();
2961            assert_eq!(text.lines().last(), Some(tombstone("mem-1").as_str()));
2962            assert_eq!(text.matches("tombstone").count(), 1);
2963            assert!(!text.contains("mem-9"));
2964            assert!(!fs::read_to_string(file(base, b)).unwrap().contains("mem-9"));
2965        }
2966
2967        // Tombstone then note, as recorded, in a store with nothing in it:
2968        // both refused, nothing written (the spec's one test).
2969        let tmp = TempDir::new().unwrap();
2970        let base = tmp.path();
2971        let mut run = ImportRun::new(false);
2972        let first = import(base, &tombstone("mem-1"), as_recorded, &mut run);
2973        let second = import(base, &exported("mem-1", a, 1, "x"), as_recorded, &mut run);
2974        assert_eq!(first.outcome.name(), "refused");
2975        assert_eq!(refusal(&second), "its tombstone in this run could not be filed");
2976        assert!(run.finish().unwrap().is_empty());
2977        assert!(!memory_dir_in(base).exists());
2978        // The refusal belongs to the run: a new run stores the note.
2979        let mut run = ImportRun::new(false);
2980        assert_eq!(
2981            import(base, &exported("mem-1", a, 1, "x"), as_recorded, &mut run).outcome,
2982            ImportOutcome::Stored
2983        );
2984
2985        // An id the run has seen in two projects' files (a note filed twice)
2986        // is forgotten in both.
2987        let again = exported("mem-1", a, 1, "x");
2988        assert_eq!(
2989            import(base, &again, ImportTarget::Project(b), &mut run).outcome,
2990            ImportOutcome::Stored
2991        );
2992        let both = import(base, &tombstone("mem-1"), as_recorded, &mut run);
2993        assert_eq!(
2994            (both.outcome, both.project.as_deref()),
2995            (ImportOutcome::Forgotten, Some(a))
2996        );
2997        run.finish().unwrap();
2998        assert!(live(base, a).is_empty() && live(base, b).is_empty());
2999
3000        // A note that is in a file from an earlier run is seen once a line
3001        // of this run is judged against that file.
3002        let tmp = TempDir::new().unwrap();
3003        let base = tmp.path();
3004        let mut earlier = ImportRun::new(false);
3005        import(base, &exported("mem-1", a, 1, "x"), as_recorded, &mut earlier);
3006        earlier.finish().unwrap();
3007        let mut run = ImportRun::new(false);
3008        assert_eq!(
3009            import(base, &exported("mem-1", a, 1, "x"), as_recorded, &mut run).outcome,
3010            ImportOutcome::AlreadyPresent
3011        );
3012        assert_eq!(
3013            import(base, &tombstone("mem-1"), as_recorded, &mut run).outcome,
3014            ImportOutcome::Forgotten
3015        );
3016        run.finish().unwrap();
3017        assert!(live(base, a).is_empty());
3018    }
3019
3020    /// A `null` and an absent field say the same, and absent `tags` are no
3021    /// tags: such a line is kept, as the record the store's reader makes of
3022    /// it.
3023    #[test]
3024    fn a_null_field_and_absent_tags_are_not_a_loss() {
3025        let tmp = TempDir::new().unwrap();
3026        let scope = project_scope(&tmp);
3027        let key = scope.key();
3028        let line = format!(
3029            r#"{{"namespace":"project-{key}","timestamp_ms":7,"content":"c","id":"mem-n","project":"{key}","tags":null,"meta":null,"provenance":{{"source":"model","session":null}}}}"#
3030        );
3031        let mut run = ImportRun::new(false);
3032        assert_eq!(
3033            import(tmp.path(), &line, ImportTarget::AsRecorded, &mut run).outcome,
3034            ImportOutcome::Stored
3035        );
3036        let stored = fetch_exact_in(tmp.path(), &scope, &["mem-n"]).unwrap().remove(0);
3037        assert_eq!(stored, serde_json::from_str::<MemoryRecord>(&line).unwrap());
3038        assert_eq!(stored.timestamp_ms, 7);
3039        assert!(stored.tags.is_empty(), "`\"tags\": null` is no tags");
3040        // Absent tags as well, and tags that are not a list are still refused.
3041        let absent = line.replace(r#""tags":null,"#, "").replace("mem-n", "mem-o");
3042        assert_eq!(
3043            import(tmp.path(), &absent, ImportTarget::AsRecorded, &mut run).outcome,
3044            ImportOutcome::Stored
3045        );
3046        let wrong = line.replace(r#""tags":null"#, r#""tags":"one""#).replace("mem-n", "mem-p");
3047        assert!(refusal(&import(tmp.path(), &wrong, ImportTarget::AsRecorded, &mut run))
3048            .starts_with("not a note: wrong value at column "));
3049        run.finish().unwrap();
3050    }
3051
3052    /// Private-mode tests (spec §5.4). Umask isolation: see
3053    /// `private_fs::test_support::UmaskGuard` — `#[serial(umask)]` serializes
3054    /// every umask-mutating test in the crate, and the guard restores the old
3055    /// mask on drop (panic-safe).
3056    #[cfg(unix)]
3057    mod private_modes {
3058        use super::*;
3059        use crate::core::private_fs::test_support::UmaskGuard;
3060        use serial_test::serial;
3061        use std::os::unix::fs::PermissionsExt;
3062
3063        fn mode_of(path: &std::path::Path) -> u32 {
3064            fs::metadata(path).unwrap().permissions().mode() & 0o777
3065        }
3066
3067        #[test]
3068        #[serial(umask)]
3069        fn append_creates_0600_file_and_0700_dir_under_permissive_umask() {
3070            let _umask = UmaskGuard::set(0);
3071            let tmp = TempDir::new().unwrap();
3072            append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
3073            let dir = memory_dir_in(tmp.path());
3074            assert_eq!(mode_of(&dir), 0o700, "memory dir must be 0700");
3075            assert_eq!(
3076                mode_of(&dir.join("ns.jsonl")),
3077                0o600,
3078                "memory file must be 0600"
3079            );
3080        }
3081
3082        #[test]
3083        fn append_refuses_symlink_target() {
3084            let tmp = TempDir::new().unwrap();
3085            let dir = memory_dir_in(tmp.path());
3086            fs::create_dir_all(&dir).unwrap();
3087            let victim = tmp.path().join("victim.txt");
3088            fs::write(&victim, "").unwrap();
3089            std::os::unix::fs::symlink(&victim, dir.join("ns.jsonl")).unwrap();
3090            let res = append_to(tmp.path(), &rec("ns", 1, "secret", vec![]));
3091            assert!(res.is_err(), "append through a symlink must fail");
3092            assert_eq!(
3093                fs::read_to_string(&victim).unwrap(),
3094                "",
3095                "no bytes may be written through the planted symlink"
3096            );
3097        }
3098
3099        /// A lock file that cannot be opened (here a symlink, refused by
3100        /// `O_NOFOLLOW`) fails the save, the error names the lock file, and
3101        /// nothing is written.
3102        #[test]
3103        fn a_lock_file_that_cannot_be_opened_is_named_in_the_error() {
3104            let tmp = TempDir::new().unwrap();
3105            let dir = memory_dir_in(tmp.path());
3106            fs::create_dir_all(&dir).unwrap();
3107            let victim = tmp.path().join("victim.txt");
3108            fs::write(&victim, "").unwrap();
3109            let lock = dir.join("ns.jsonl.lock");
3110            std::os::unix::fs::symlink(&victim, &lock).unwrap();
3111            let error = append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap_err();
3112            assert!(matches!(error, MemoryError::Io(_)), "{error}");
3113            let message = error.to_string();
3114            assert!(
3115                message.contains(&format!("cannot open the notes lock file {}", lock.display())),
3116                "{message}"
3117            );
3118            assert!(!dir.join("ns.jsonl").exists());
3119            assert_eq!(fs::read_to_string(&victim).unwrap(), "");
3120        }
3121
3122        #[test]
3123        fn append_repairs_preexisting_broad_mode() {
3124            let tmp = TempDir::new().unwrap();
3125            let dir = memory_dir_in(tmp.path());
3126            fs::create_dir_all(&dir).unwrap();
3127            let file = dir.join("ns.jsonl");
3128            fs::write(&file, "").unwrap();
3129            fs::set_permissions(&file, fs::Permissions::from_mode(0o666)).unwrap();
3130            append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
3131            assert_eq!(
3132                mode_of(&file),
3133                0o600,
3134                "broad pre-existing mode must be repaired"
3135            );
3136        }
3137    }
3138}