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 pub namespace: String,
12 pub timestamp_ms: u64,
14 pub content: String,
16 #[serde(default, deserialize_with = "null_as_no_tags")]
19 pub tags: Vec<String>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub meta: Option<serde_json::Value>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub id: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub project: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub provenance: Option<MemoryProvenance>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub sensitivity: Option<MemorySensitivity>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub retention: Option<MemoryRetention>,
40}
41
42fn 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct MemoryProvenance {
50 pub source: String,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub session: Option<String>,
55}
56
57#[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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "snake_case")]
71pub enum MemoryRetention {
72 Standard,
74 MaxAgeDays(u32),
76}
77
78#[derive(Debug, Clone, Default)]
79pub struct MemoryQuery {
80 pub content_contains: Option<String>,
82 pub tag_prefix: Option<String>,
84 pub since_ms: Option<u64>,
86 pub until_ms: Option<u64>,
88 pub limit: Option<usize>,
90}
91
92pub const DEFAULT_LIMIT: usize = 50;
94
95pub 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 NotFound(String),
110 InvalidProjectRoot(String),
112 WrittenUnconfirmed { id: Option<String>, cause: String },
120}
121
122impl MemoryError {
123 fn unconfirmed(cause: impl std::fmt::Display) -> Self {
125 MemoryError::WrittenUnconfirmed {
126 id: None,
127 cause: cause.to_string(),
128 }
129 }
130
131 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
189pub 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
210pub fn memory_dir() -> PathBuf {
212 crate::config::base_dir().join("memory")
213}
214
215pub 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
224pub 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
235fn 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 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
261fn 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 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
317pub(crate) const NOTES_LOCK_WAIT: std::time::Duration =
320 crate::context_archive::PROCESS_LOCK_WAIT;
321const NOTES_LOCK_POLL: std::time::Duration = std::time::Duration::from_millis(5);
324
325pub(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
335pub(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
377pub 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 static NOTES_LOCK_WAIT_OVERRIDE: std::cell::Cell<Option<std::time::Duration>> =
398 const { std::cell::Cell::new(None) };
399}
400
401#[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
415const LINE_WRITE_ATTEMPTS: usize = 3;
417
418fn 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 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
462fn sync_file(file: &fs::File) -> std::io::Result<()> {
464 step("sync file");
465 failing("sync file")?;
466 file.sync_all()
467}
468
469fn sync_dir(dir: &Path) -> std::io::Result<()> {
472 step("sync dir");
473 failing("sync dir")?;
474 crate::core::private_fs::sync_dir(dir)
479}
480
481#[cfg(test)]
482thread_local! {
483 static IO_STEPS: std::cell::RefCell<Vec<&'static str>> =
485 const { std::cell::RefCell::new(Vec::new()) };
486}
487
488#[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 static FAILING_STEP: std::cell::Cell<Option<&'static str>> =
502 const { std::cell::Cell::new(None) };
503}
504
505#[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
519pub 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 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
583pub 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
607pub 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#[derive(Debug, Clone, PartialEq, Eq)]
634pub struct ProjectScope {
635 key: String,
636 root: PathBuf,
637}
638
639impl ProjectScope {
640 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 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 pub fn key(&self) -> &str {
684 &self.key
685 }
686
687 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 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 pub fn root(&self) -> &Path {
729 &self.root
730 }
731
732 pub fn namespace(&self) -> String {
734 project_namespace(&self.key)
735 }
736}
737
738fn project_namespace(key: &str) -> String {
740 format!("project-{key}")
741}
742
743#[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#[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 pub snippet: String,
764 pub truncated: bool,
766 pub content_bytes: usize,
768 pub sensitivity: MemorySensitivity,
769 pub retention: MemoryRetention,
770}
771
772pub const MAX_SEARCH_LIMIT: usize = 25;
774pub const DEFAULT_SEARCH_LIMIT: usize = 8;
776pub const MAX_SNIPPET_BYTES: usize = 400;
778pub const DEFAULT_SNIPPET_BYTES: usize = 160;
780
781#[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#[derive(Debug, Serialize, Deserialize)]
797struct TombstoneLine {
798 tombstone: String,
799 timestamp_ms: u64,
800}
801
802pub 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
825struct ProjectLines {
830 records: Vec<MemoryRecord>,
831 tombstoned: std::collections::HashSet<String>,
832}
833
834fn 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 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
880fn 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
893pub 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 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 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
967pub 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
986pub 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
1001fn 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
1015pub(crate) fn namespace_file(base: &Path, namespace: &str) -> PathBuf {
1017 namespace_path(&memory_dir_in(base), namespace)
1018}
1019
1020pub fn project_notes_file(base: &Path, key: &str) -> PathBuf {
1024 namespace_file(base, &project_namespace(key))
1025}
1026
1027const USER_WIDE_KEY: &str = "p0000000000000000";
1031
1032const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1048pub enum ImportTarget<'a> {
1049 AsRecorded,
1052 Project(&'a str),
1055}
1056
1057#[derive(Debug, Clone, PartialEq, Eq)]
1059pub enum ImportOutcome {
1060 Stored,
1062 AlreadyPresent,
1065 KeptForgotten,
1068 Forgotten,
1070 Refused(String),
1073}
1074
1075impl ImportOutcome {
1076 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#[derive(Debug, Clone, PartialEq, Eq)]
1090pub struct ImportedLine {
1091 pub outcome: ImportOutcome,
1092 pub id: Option<String>,
1094 pub project: Option<String>,
1098 pub tombstone: bool,
1100}
1101
1102#[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 unfiled_tombstones: std::collections::HashSet<String>,
1127 read_every_project: bool,
1130 finished: bool,
1131}
1132
1133#[derive(Debug)]
1134struct ImportFile {
1135 key: String,
1137 notes: std::collections::HashMap<String, Vec<MemoryRecord>>,
1140 tombstoned: std::collections::HashSet<String>,
1141 appended: bool,
1143}
1144
1145impl ImportRun {
1146 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 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 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 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 #[must_use = "an import is not durable unless `finish` returned Ok"]
1245 pub fn finish(mut self) -> Result<Vec<PathBuf>, MemoryError> {
1246 self.finished = true;
1248 self.sync()
1249 .map_err(|(path, e)| MemoryError::unconfirmed(format!("{}: {e}", path.display())))
1250 }
1251
1252 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 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 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
1304pub 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
1367const UNFILED_TOMBSTONE: &str = "its tombstone in this run could not be filed";
1369
1370const 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, ¬e)) {
1426 return Ok(ImportOutcome::AlreadyPresent);
1427 }
1428 return refused("id already holds a different note");
1429 }
1430 if !dry_run {
1431 append_record(base, ¬e, 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 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
1496pub(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
1504fn 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
1521fn 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 let note: MemoryRecord = serde_json::from_str(line).map_err(|e| unread("note", &e))?;
1548 let kept = serde_json::to_value(¬e)
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
1562fn 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
1575fn 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
1592fn 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 #[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 #[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 #[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 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 fn take_steps() -> Vec<&'static str> {
1867 IO_STEPS.with(|steps| std::mem::take(&mut *steps.borrow_mut()))
1868 }
1869
1870 #[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 append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
1908 assert_eq!(take_steps(), ["write", "sync file", "sync dir"]);
1909
1910 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 #[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 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 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 let failing = Failing::step("sync dir");
1977 let error = append_to(base, &rec("ns", 1, "x", vec![])).unwrap_err();
1978 drop(failing);
1979 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 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 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 #[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 #[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 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 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 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 #[test]
2152 fn a_short_write_is_abandoned_and_the_whole_line_written_again() {
2153 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 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 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 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 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 #[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 let pad = "x".repeat((round % 6) * 3_000);
2224 let content = format!("w{writer}-r{round}-{pad}");
2225 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 #[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 #[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 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 #[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 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 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 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 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 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 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 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 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 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 assert_eq!(
2532 import(base, &late, here, &mut ImportRun::new(false)).outcome,
2533 ImportOutcome::KeptForgotten
2534 );
2535
2536 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 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 #[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 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 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 #[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 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 #[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 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 #[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 (with(r#","timestamp_ms":5"#, ""), "missing field timestamp_ms"),
2746 (with(r#""timestamp_ms":5"#, r#""timestamp_ms":"5""#), wrong_value),
2747 (with(r#"{"content""#, r#"{"extra":1,"content""#), "unknown field extra"),
2750 (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 (
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 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 #[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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 #[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}