//! Applying `memory` calls: each action, the routing by the session's state, notes, and every //! refusal leaving the files as they were (M5a spec, section 5). Do not edit. #[path = "support/tmp.rs"] mod tmp; use bxcore::{DataClass, MemoryArgs, MemoryFile, Timestamp}; use memoryd::edit::{Context, apply}; use memoryd::files::Store; use memoryd::notes::{self, Kind}; use tmp::TempDir; struct Fixture { dir: TempDir, store: Store, } impl Fixture { fn new(tag: &str) -> Fixture { let dir = TempDir::new(tag); let starter = dir.write("etc/soul.md", "You are Boxmaker.\n"); let store = Store::new(&dir.path().join("memory")); store.init(&starter).unwrap(); Fixture { dir, store } } fn file(&self, name: &str) -> String { std::fs::read_to_string(self.dir.path().join("memory").join(name)).unwrap() } fn call(&self, json: &str, untrusted: bool) -> Result { let args: MemoryArgs = serde_json::from_str(json).unwrap(); apply(&self.store, &args, &ctx(untrusted)).map(|o| o.answer) } fn seed(&self, entries: &[&str]) { let list: Vec = entries.iter().map(|e| e.to_string()).collect(); self.store.write_entries(MemoryFile::Memory, &list).unwrap(); } } fn ctx(untrusted: bool) -> Context { Context { session: "mm-s0000000000000000000000001".to_string(), call: 4, taint: DataClass::Private, untrusted, now: Timestamp::parse("2026-09-24T20:14:37.123Z").unwrap(), } } #[test] fn add_replace_and_remove_in_a_clean_session() { let f = Fixture::new("edit-basic"); assert_eq!( f.call( r#"{"action":"add","file":"memory","text":" Works on boxmaker. "}"#, false ), Ok( "memory.md updated (18 of 2,200 characters); it takes effect in the next session" .to_string() ) ); f.call( r#"{"action":"add","file":"memory","text":"Owner prefers UTC."}"#, false, ) .unwrap(); assert_eq!( f.file("memory.md"), "Works on boxmaker.\n§\nOwner prefers UTC.\n" ); f.call(r#"{"action":"replace","file":"memory","old":"Owner prefers UTC.","text":"Owner prefers UTC, always."}"#, false).unwrap(); f.call( r#"{"action":"remove","file":"memory","old":"Works on boxmaker."}"#, false, ) .unwrap(); assert_eq!(f.file("memory.md"), "Owner prefers UTC, always.\n"); let notes = notes::parse(&f.file("notes.md")); assert_eq!(notes.len(), 1); assert_eq!(notes[0].kind, Kind::Dropped(MemoryFile::Memory)); assert_eq!(notes[0].text, "Works on boxmaker."); assert_eq!( notes[0].header(), "§ dropped 2026-09-24T20:14Z from memory.md in session mm-s0000000000000000000000001" ); assert_eq!(f.file("user.md"), "", "the other file is untouched"); } #[test] fn a_note_is_labelled_by_the_session() { let f = Fixture::new("edit-note"); assert_eq!( f.call(r#"{"action":"note","text":"The launch moved."}"#, false), Ok("saved in notes.md".to_string()) ); assert_eq!( f.call( r#"{"action":"note","text":"example.org says March."}"#, true ), Ok( "saved in notes.md, marked untrusted: this session has read untrusted content" .to_string() ) ); assert_eq!( f.file("notes.md"), "§ noted 2026-09-24T20:14Z in session mm-s0000000000000000000000001 · private\nThe launch moved.\n§ noted 2026-09-24T20:14Z in session mm-s0000000000000000000000001 · private · untrusted\nexample.org says March.\n" ); let notes = notes::parse(&f.file("notes.md")); assert_eq!( notes[1].kind, Kind::Noted { class: DataClass::Private, untrusted: true } ); assert_eq!(f.file("memory.md"), ""); } #[test] fn an_untrusted_session_can_take_notes_but_not_change_identity() { let f = Fixture::new("edit-untrusted"); f.seed(&["Works on boxmaker."]); assert_eq!( f.call( r#"{"action":"add","file":"user","text":"Owner lives in Oslo."}"#, true ), Ok("not added to user.md: this session has read untrusted content, so it was saved in notes.md as an untrusted note".to_string()) ); assert_eq!(f.file("user.md"), ""); assert!( f.file("notes.md") .ends_with("· private · untrusted\nOwner lives in Oslo.\n") ); for json in [ r#"{"action":"replace","file":"memory","old":"Works on boxmaker.","text":"x"}"#, r#"{"action":"remove","file":"memory","old":"Works on boxmaker."}"#, r#"{"action":"consolidate","file":"memory","entries":[{"old":"Works on boxmaker.","drop":true}]}"#, ] { assert_eq!( f.call(json, true), Err("this session has read untrusted content; it can take notes but not change memory.md".to_string()), "{json}" ); } assert_eq!(f.file("memory.md"), "Works on boxmaker.\n"); } #[test] fn consolidate_keeps_rewrites_moves_drops_and_adds() { let f = Fixture::new("edit-consolidate"); f.seed(&["a", "b", "c", "d"]); let answer = f.call( r#"{"action":"consolidate","file":"memory","entries":[{"old":"d","keep":true},{"old":"b","text":"B"},{"old":"a","move":true},{"old":"c","drop":true},{"text":"e"}]}"#, false, ); assert_eq!( answer, Ok( "memory.md updated (9 of 2,200 characters); it takes effect in the next session" .to_string() ) ); assert_eq!(f.file("memory.md"), "d\n§\nB\n§\ne\n"); let notes = notes::parse(&f.file("notes.md")); let kinds: Vec<(Kind, &str)> = notes .iter() .map(|n| (n.kind.clone(), n.text.as_str())) .collect(); assert_eq!( kinds, [ (Kind::Moved(MemoryFile::Memory), "a"), (Kind::Dropped(MemoryFile::Memory), "c") ] ); } #[test] fn consolidate_must_name_every_entry_once() { let f = Fixture::new("edit-consolidate-bad"); f.seed(&["a", "b"]); for (json, why) in [ ( r#"{"action":"consolidate","file":"memory","entries":[{"old":"a","keep":true}]}"#, "consolidate must name every entry of memory.md; it leaves out 1", ), ( r#"{"action":"consolidate","file":"memory","entries":[{"old":"a","keep":true},{"old":"a","move":true},{"old":"b","keep":true}]}"#, "consolidate names an entry of memory.md twice", ), ( r#"{"action":"consolidate","file":"memory","entries":[{"old":"a","keep":true},{"old":"z","keep":true}]}"#, "no entry of memory.md is exactly this text; the file may have changed; nothing was changed", ), ( r#"{"action":"consolidate","file":"memory","entries":[{"old":"a","keep":true},{"old":"b","text":"a"}]}"#, "memory.md would hold the same entry twice", ), ] { assert_eq!(f.call(json, false), Err(why.to_string()), "{json}"); } assert_eq!(f.file("memory.md"), "a\n§\nb\n"); assert_eq!(f.file("notes.md"), "", "nothing moved when refused"); } #[test] fn refusals_change_nothing() { let f = Fixture::new("edit-refused"); f.seed(&["Works on boxmaker."]); for (json, why) in [ ( r#"{"action":"add","file":"memory","text":"Works on boxmaker."}"#, "memory.md already has this entry".to_string(), ), ( r#"{"action":"replace","file":"memory","old":"Works on boxmaker!","text":"x"}"#, "no entry of memory.md is exactly this text; the file may have changed; nothing was changed".to_string(), ), ( r#"{"action":"add","file":"memory","text":"icon \ue000 here"}"#, "memory refused this entry: it contains a private-use character".to_string(), ), ( r#"{"action":"note","text":"zero\u200bwidth"}"#, "memory refused this entry: it contains an invisible or direction-changing character" .to_string(), ), ( r#"{"action":"add","file":"memory","text":""}"#, "an entry cannot be empty".to_string(), ), ] { assert_eq!(f.call(json, false), Err(why), "{json}"); } assert_eq!(f.file("memory.md"), "Works on boxmaker.\n"); assert_eq!(f.file("notes.md"), ""); } #[test] fn the_limit_counts_the_whole_file() { let f = Fixture::new("edit-limit"); let big = "x".repeat(1_370); f.call( &format!(r#"{{"action":"add","file":"user","text":"{big}"}}"#), false, ) .unwrap(); assert_eq!( f.call(r#"{"action":"add","file":"user","text":"abc"}"#, false), Err( "user.md would be 1,376 of 1,375 characters; use consolidate to move entries to notes" .to_string() ) ); assert_eq!( f.call(r#"{"action":"add","file":"user","text":"ab"}"#, false), Ok( "user.md updated (1,375 of 1,375 characters); it takes effect in the next session" .to_string() ), "exactly the limit is allowed" ); } #[test] fn a_note_takes_the_class_of_its_session() { let f = Fixture::new("edit-class"); let args: MemoryArgs = serde_json::from_str(r#"{"action":"note","text":"The code is 42."}"#).unwrap(); let mut secret = ctx(false); secret.taint = DataClass::Secret; apply(&f.store, &args, &secret).unwrap(); assert_eq!( notes::parse(&f.file("notes.md"))[0].kind, Kind::Noted { class: DataClass::Secret, untrusted: false } ); }