//! The arguments of `recall` and `memory`: decoding, unknown keys refused, and the shape checks //! (M5a spec, section 5). Do not edit. use bxcore::memory::{MAX_ENTRY, MAX_NOTE, entry_problem}; use bxcore::{ConsolidateEntry, EntryAction, MemoryArgs, MemoryFile, RecallArgs}; fn memory(json: &str) -> Result { serde_json::from_str::(json).map_err(|e| e.to_string()) } #[test] fn recall_decodes_with_a_default_k_and_checks_its_bounds() { let args: RecallArgs = serde_json::from_str(r#"{"query":"launch window"}"#).unwrap(); assert_eq!(args.k, 5); assert_eq!(args.check(), Ok(())); for (json, ok) in [ (r#"{"query":"x","k":10}"#, true), (r#"{"query":"x","k":11}"#, false), (r#"{"query":"x","k":0}"#, false), (r#"{"query":" ","k":1}"#, false), ] { let args: RecallArgs = serde_json::from_str(json).unwrap(); assert_eq!(args.check().is_ok(), ok, "{json}"); } let long = RecallArgs { query: "é".repeat(500), k: 1, }; assert_eq!(long.check(), Ok(()), "500 characters, not bytes"); let longer = RecallArgs { query: "é".repeat(501), k: 1, }; assert!(longer.check().is_err()); assert!(serde_json::from_str::(r#"{"query":"x","limit":3}"#).is_err()); } #[test] fn every_memory_action_decodes() { assert_eq!( memory(r#"{"action":"add","file":"memory","text":"Owner prefers UTC."}"#), Ok(MemoryArgs::Add { file: MemoryFile::Memory, text: "Owner prefers UTC.".to_string() }) ); assert_eq!( memory(r#"{"action":"replace","file":"user","old":"a","text":"b"}"#), Ok(MemoryArgs::Replace { file: MemoryFile::User, old: "a".to_string(), text: "b".to_string() }) ); assert_eq!( memory(r#"{"action":"remove","file":"user","old":"a"}"#), Ok(MemoryArgs::Remove { file: MemoryFile::User, old: "a".to_string() }) ); assert_eq!( memory(r#"{"action":"note","text":"example.org says March."}"#), Ok(MemoryArgs::Note { text: "example.org says March.".to_string() }) ); let consolidate = memory( r#"{"action":"consolidate","file":"memory","entries":[{"old":"a","keep":true},{"old":"b","text":"B"},{"old":"c","move":true},{"old":"d","drop":true},{"text":"e"}]}"#, ) .unwrap(); let MemoryArgs::Consolidate { file, entries } = consolidate else { panic!("not a consolidate") }; assert_eq!(file, MemoryFile::Memory); let actions: Vec = entries.iter().map(|e| e.action().unwrap()).collect(); assert_eq!( actions, [ EntryAction::Keep("a".to_string()), EntryAction::Rewrite { old: "b".to_string(), text: "B".to_string() }, EntryAction::Move("c".to_string()), EntryAction::Drop("d".to_string()), EntryAction::New("e".to_string()), ] ); } #[test] fn unknown_actions_files_and_keys_are_refused() { for json in [ r#"{"action":"forget","file":"memory"}"#, r#"{"action":"add","file":"soul","text":"x"}"#, r#"{"action":"add","file":"memory","text":"x","why":"y"}"#, r#"{"action":"note","text":"x","file":"memory"}"#, r#"{"action":"consolidate","file":"memory","entries":[{"old":"a","keep":true,"why":1}]}"#, r#"{"file":"memory","text":"x"}"#, ] { assert!(memory(json).is_err(), "{json}"); } } #[test] fn a_consolidate_line_has_exactly_one_action() { let line = |old: Option<&str>, keep: bool, text: Option<&str>, moved: bool, drop: bool| { ConsolidateEntry { old: old.map(str::to_string), keep, text: text.map(str::to_string), moved, drop, } }; for bad in [ line(Some("a"), false, None, false, false), line(Some("a"), true, None, true, false), line(Some("a"), true, Some("b"), false, false), line(Some("a"), false, Some("b"), false, true), line(None, false, None, false, false), line(None, true, Some("b"), false, false), ] { assert!(bad.action().is_err(), "{bad:?}"); } let empty = MemoryArgs::Consolidate { file: MemoryFile::User, entries: vec![], }; assert!(empty.check().is_err()); } #[test] fn entries_are_not_empty_not_too_long_and_hold_no_separator_line() { assert_eq!(entry_problem("fine", MAX_ENTRY), None); assert_eq!(entry_problem("two\nlines", MAX_ENTRY), None); assert_eq!(entry_problem("a § in a line is fine", MAX_ENTRY), None); assert!(entry_problem("", MAX_ENTRY).is_some()); assert!(entry_problem(" \n\t", MAX_ENTRY).is_some()); assert!(entry_problem("before\n§\nafter", MAX_ENTRY).is_some()); assert!(entry_problem("before\n § \nafter", MAX_ENTRY).is_some()); // A line that starts with the separator would read as a header in notes.md (review of // 2026-09-24). let forged = "before\n§ moved 2026-09-24T20:14Z from memory.md in session mm-s1\nafter"; let problem = entry_problem(forged, MAX_ENTRY).unwrap(); assert!(problem.contains("starts with §"), "{problem}"); assert!(entry_problem("§ at the start", MAX_ENTRY).is_some()); assert!(entry_problem("before\n §x\nafter", MAX_ENTRY).is_some()); assert_eq!( entry_problem("before\nit costs 5 §\nafter", MAX_ENTRY), None ); assert_eq!(entry_problem(&"é".repeat(MAX_ENTRY), MAX_ENTRY), None); assert!(entry_problem(&"é".repeat(MAX_ENTRY + 1), MAX_ENTRY).is_some()); let note = MemoryArgs::Note { text: "x".repeat(MAX_NOTE + 1), }; assert!(note.check().is_err()); let rewrite = MemoryArgs::Replace { file: MemoryFile::Memory, old: "fine".to_string(), text: "a\n§\nb".to_string(), }; assert!(rewrite.check().is_err()); } #[test] fn the_files_names_and_limits() { assert_eq!( (MemoryFile::Memory.name(), MemoryFile::Memory.limit()), ("memory.md", 2_200) ); assert_eq!( (MemoryFile::User.name(), MemoryFile::User.limit()), ("user.md", 1_375) ); }