1use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18use std::path::PathBuf;
19use std::sync::{Arc, Mutex};
20
21use crate::error::{Error, Result};
22use crate::llm::ToolSpec;
23use crate::memory::{EmbeddingProvider, MemoryEntry, NoopEmbedding, NoopVectorStore, VectorStore};
24use crate::tools::Tool;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Note {
29 pub id: String,
30 #[serde(default)]
31 pub tags: Vec<String>,
32 pub text: String,
33 pub ts: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct MemoryStore {
39 pub notes: Vec<Note>,
40}
41
42impl MemoryStore {
43 fn load(path: &std::path::Path) -> Result<Self> {
45 if !path.exists() {
46 return Ok(Self::default());
47 }
48 let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
49 name: "memory".into(),
50 call_id: None,
51 message: format!("failed to read memory file: {e}"),
52 })?;
53 serde_json::from_str(&raw).map_err(|e| Error::Tool {
54 name: "memory".into(),
55 call_id: None,
56 message: format!("malformed memory file: {e}"),
57 })
58 }
59
60 fn save(&self, path: &std::path::Path) -> Result<()> {
62 if let Some(parent) = path.parent() {
63 std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
64 name: "memory".into(),
65 call_id: None,
66 message: format!("failed to create memory directory: {e}"),
67 })?;
68 }
69 let raw = serde_json::to_string_pretty(self).map_err(|e| Error::Tool {
70 name: "memory".into(),
71 call_id: None,
72 message: format!("failed to serialize memory: {e}"),
73 })?;
74 std::fs::write(path, raw).map_err(|e| Error::Tool {
75 name: "memory".into(),
76 call_id: None,
77 message: format!("failed to write memory file: {e}"),
78 })?;
79 Ok(())
80 }
81
82 fn next_id(&self) -> String {
84 let max = self
85 .notes
86 .iter()
87 .filter_map(|n| n.id.strip_prefix('N'))
88 .filter_map(|s| s.parse::<u32>().ok())
89 .max()
90 .unwrap_or(0);
91 format!("N{}", max + 1)
92 }
93
94 fn add(&mut self, text: String, tags: Vec<String>) -> String {
96 let id = self.next_id();
97 let ts = chrono_now_rfc3339();
98 self.notes.push(Note {
99 id: id.clone(),
100 tags,
101 text,
102 ts,
103 });
104 id
105 }
106
107 fn remove(&mut self, id: &str) -> bool {
109 let before = self.notes.len();
110 self.notes.retain(|n| n.id != id);
111 self.notes.len() < before
112 }
113
114 fn search(&self, query: Option<&str>, tag: Option<&str>, limit: usize) -> Vec<&Note> {
117 let mut results: Vec<&Note> = self
118 .notes
119 .iter()
120 .filter(|n| {
121 let matches_query = query.map_or(true, |q| {
122 let q_lower = q.to_lowercase();
123 n.text.to_lowercase().contains(&q_lower)
124 || n.tags.iter().any(|t| t.to_lowercase().contains(&q_lower))
125 });
126 let matches_tag = tag.map_or(true, |t| n.tags.iter().any(|nt| nt == t));
127 matches_query && matches_tag
128 })
129 .collect();
130 results.reverse();
132 results.truncate(limit);
133 results
134 }
135}
136
137fn chrono_now_rfc3339() -> String {
139 let dur = std::time::SystemTime::now()
141 .duration_since(std::time::UNIX_EPOCH)
142 .unwrap_or_default();
143 let secs = dur.as_secs();
144 let days = secs / 86400;
146 let time_secs = secs % 86400;
147 let hours = time_secs / 3600;
148 let minutes = (time_secs % 3600) / 60;
149 let seconds = time_secs % 60;
150
151 let (year, month, day) = days_to_date(days);
153 format!(
154 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
155 year, month, day, hours, minutes, seconds
156 )
157}
158
159fn days_to_date(mut days: u64) -> (u64, u64, u64) {
162 let mut year: u64 = 1970;
164 loop {
165 let days_in_year = if is_leap(year) { 366 } else { 365 };
166 if days < days_in_year {
167 break;
168 }
169 days -= days_in_year;
170 year += 1;
171 }
172 let months_days: [u64; 12] = if is_leap(year) {
173 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
174 } else {
175 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
176 };
177 let mut month: u64 = 1;
178 for &md in &months_days {
179 if days < md {
180 break;
181 }
182 days -= md;
183 month += 1;
184 }
185 let day = days + 1; (year, month, day)
187}
188
189fn is_leap(year: u64) -> bool {
190 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
191}
192
193pub fn memory_path(workspace: &std::path::Path) -> PathBuf {
195 if std::env::var("RECURSIVE_MEMORY_GLOBAL").as_deref() == Ok("1") {
196 if let Some(home) = std::env::var_os("HOME") {
197 return PathBuf::from(home).join(".recursive").join("memory.json");
198 }
199 }
200 workspace.join(".recursive").join("memory.json")
201}
202
203pub fn load_memory(workspace: &std::path::Path) -> Result<MemoryStore> {
205 let path = memory_path(workspace);
206 MemoryStore::load(&path)
207}
208
209pub fn memory_summary(workspace: &std::path::Path, limit: usize) -> String {
218 let store = match load_memory(workspace) {
219 Ok(s) => s,
220 Err(_) => return String::new(),
221 };
222 if store.notes.is_empty() {
223 return String::new();
224 }
225 let mut lines: Vec<String> = Vec::new();
226 lines.push("# Memory Index".to_string());
227 lines.push(format!(
228 "Top {} most recent notes. Use `recall` with the note ID to read full content.",
229 limit
230 ));
231 let mut notes: Vec<&Note> = store.notes.iter().collect();
233 notes.reverse();
234 for note in notes.iter().take(limit) {
235 let hook = if note.text.len() > 80 {
237 format!("{}...", crate::truncate_str(¬e.text, 77))
238 } else {
239 note.text.clone()
240 };
241 let hook = hook.replace('\n', " ");
243 lines.push(format!("- [{}](recall) — {}", note.id, hook));
246 }
247 lines.join("\n")
248}
249
250pub struct Remember {
255 workspace: PathBuf,
256 lock: Mutex<()>,
258 vector_store: Arc<dyn VectorStore>,
260 embedding_provider: Arc<dyn EmbeddingProvider>,
262}
263
264impl Remember {
265 pub fn new(workspace: impl Into<PathBuf>) -> Self {
266 Self {
267 workspace: workspace.into(),
268 lock: Mutex::new(()),
269 vector_store: Arc::new(NoopVectorStore::new()),
270 embedding_provider: Arc::new(NoopEmbedding),
271 }
272 }
273
274 pub fn with_vector_store(
279 mut self,
280 store: Arc<dyn VectorStore>,
281 embedding: Arc<dyn EmbeddingProvider>,
282 ) -> Self {
283 self.vector_store = store;
284 self.embedding_provider = embedding;
285 self
286 }
287}
288
289#[async_trait]
290impl Tool for Remember {
291 fn spec(&self) -> ToolSpec {
292 ToolSpec {
293 name: "remember".into(),
294 description: "Save a note to persistent memory. The note will be available in future sessions via `recall` or injected into the system prompt.".into(),
295 parameters: json!({
296 "type": "object",
297 "properties": {
298 "text": {
299 "type": "string",
300 "description": "The note text to remember"
301 },
302 "tags": {
303 "type": "array",
304 "items": {"type": "string"},
305 "description": "Optional tags for categorising the note"
306 }
307 },
308 "required": ["text"]
309 }),
310 }
311 }
312
313 fn is_deferred(&self) -> bool {
314 true
315 }
316
317 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
318 crate::tools::ToolSideEffect::Mutating
319 }
320
321 async fn execute(&self, arguments: Value) -> Result<String> {
322 let text = arguments["text"]
323 .as_str()
324 .ok_or_else(|| Error::BadToolArgs {
325 name: "remember".into(),
326 message: "missing required parameter: text".to_string(),
327 })?
328 .to_string();
329
330 let tags: Vec<String> = arguments["tags"]
331 .as_array()
332 .map(|arr| {
333 arr.iter()
334 .filter_map(|v| v.as_str().map(String::from))
335 .collect()
336 })
337 .unwrap_or_default();
338
339 let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
340 let path = memory_path(&self.workspace);
341 let mut file_store = MemoryStore::load(&path)?;
342 let id = file_store.add(text.clone(), tags.clone());
343 file_store.save(&path)?;
344 drop(_guard);
345
346 let ts = chrono::Utc::now().to_rfc3339();
348 let entry = MemoryEntry {
349 id: id.clone(),
350 text: text.clone(),
351 tags,
352 ts,
353 };
354 let vector = self.embedding_provider.embed(&text).await;
355 if let Err(e) = self.vector_store.upsert(&entry, vector).await {
356 tracing::warn!(error = %e, note_id = %id, "remember: vector upsert failed");
357 }
358
359 Ok(format!("saved note {id}"))
360 }
361}
362
363pub struct Recall {
364 workspace: PathBuf,
365 vector_store: Arc<dyn VectorStore>,
367 embedding_provider: Arc<dyn EmbeddingProvider>,
369}
370
371impl Recall {
372 pub fn new(workspace: impl Into<PathBuf>) -> Self {
373 Self {
374 workspace: workspace.into(),
375 vector_store: Arc::new(NoopVectorStore::new()),
376 embedding_provider: Arc::new(NoopEmbedding),
377 }
378 }
379
380 pub fn with_vector_store(
385 mut self,
386 store: Arc<dyn VectorStore>,
387 embedding: Arc<dyn EmbeddingProvider>,
388 ) -> Self {
389 self.vector_store = store;
390 self.embedding_provider = embedding;
391 self
392 }
393}
394
395#[async_trait]
396impl Tool for Recall {
397 fn is_deferred(&self) -> bool {
398 true
399 }
400
401 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
402 crate::tools::ToolSideEffect::ReadOnly
403 }
404
405 fn spec(&self) -> ToolSpec {
406 ToolSpec {
407 name: "recall".into(),
408 description: "Search persistent memory for notes matching a query or tag. Returns up to `limit` results, most recent first.".into(),
409 parameters: json!({
410 "type": "object",
411 "properties": {
412 "query": {
413 "type": "string",
414 "description": "Case-insensitive substring to search for in note text or tags"
415 },
416 "tag": {
417 "type": "string",
418 "description": "Exact tag to filter by"
419 },
420 "limit": {
421 "type": "integer",
422 "description": "Maximum number of results (default 10)",
423 "default": 10
424 }
425 }
426 }),
427 }
428 }
429
430 async fn execute(&self, arguments: Value) -> Result<String> {
431 let query = arguments["query"].as_str().unwrap_or("");
432 let tag = arguments["tag"].as_str();
433 let limit = arguments["limit"].as_i64().unwrap_or(10) as usize;
434
435 let query_vec = self.embedding_provider.embed(query).await;
438 let use_vector = !query_vec.is_empty();
439
440 if use_vector || tag.is_none() {
441 match self.vector_store.search(query_vec, query, limit).await {
443 Ok(entries) if !entries.is_empty() => {
444 let filtered: Vec<_> = entries
446 .iter()
447 .filter(|e| tag.map_or(true, |t| e.tags.iter().any(|et| et == t)))
448 .take(limit)
449 .collect();
450 if !filtered.is_empty() {
451 let lines: Vec<String> = filtered
452 .iter()
453 .map(|e| {
454 let tags_str = if e.tags.is_empty() {
455 String::new()
456 } else {
457 format!(" [{}]", e.tags.join(","))
458 };
459 format!("{}{} {}", e.id, tags_str, e.text)
460 })
461 .collect();
462 return Ok(lines.join("\n"));
463 }
464 }
465 Ok(_) => {}
466 Err(e) => {
467 tracing::warn!(error = %e, "recall: vector search failed, falling back to file");
468 }
469 }
470 }
471
472 let path = memory_path(&self.workspace);
474 let file_store = MemoryStore::load(&path)?;
475 let query_opt = if query.is_empty() { None } else { Some(query) };
476 let results = file_store.search(query_opt, tag, limit);
477
478 if results.is_empty() {
479 return Ok("no matching notes found".to_string());
480 }
481
482 let lines: Vec<String> = results
483 .iter()
484 .map(|n| {
485 let tags_str = if n.tags.is_empty() {
486 String::new()
487 } else {
488 format!(" [{}]", n.tags.join(","))
489 };
490 format!("{}{} {}", n.id, tags_str, n.text)
491 })
492 .collect();
493
494 Ok(lines.join("\n"))
495 }
496}
497
498pub struct Forget {
499 workspace: PathBuf,
500 lock: Mutex<()>,
501}
502
503impl Forget {
504 pub fn new(workspace: impl Into<PathBuf>) -> Self {
505 Self {
506 workspace: workspace.into(),
507 lock: Mutex::new(()),
508 }
509 }
510}
511
512#[async_trait]
513impl Tool for Forget {
514 fn is_deferred(&self) -> bool {
515 true
516 }
517
518 fn spec(&self) -> ToolSpec {
519 ToolSpec {
520 name: "forget".into(),
521 description: "Remove a note from persistent memory by its ID.".into(),
522 parameters: json!({
523 "type": "object",
524 "properties": {
525 "id": {
526 "type": "string",
527 "description": "The ID of the note to remove (e.g. N3)"
528 }
529 },
530 "required": ["id"]
531 }),
532 }
533 }
534
535 async fn execute(&self, arguments: Value) -> Result<String> {
536 let id = arguments["id"]
537 .as_str()
538 .ok_or_else(|| Error::BadToolArgs {
539 name: "forget".into(),
540 message: "missing required parameter: id".to_string(),
541 })?
542 .to_string();
543
544 let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
545 let path = memory_path(&self.workspace);
546 let mut store = MemoryStore::load(&path)?;
547 if store.remove(&id) {
548 store.save(&path)?;
549 Ok(format!("removed {id}"))
550 } else {
551 Ok(format!("no such id: {id}"))
552 }
553 }
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct ScratchpadEntry {
563 pub key: String,
564 pub value: String,
565}
566
567#[derive(Debug, Clone, Serialize, Deserialize, Default)]
569pub struct Scratchpad {
570 pub entries: Vec<ScratchpadEntry>,
571}
572
573impl Scratchpad {
574 fn load(path: &std::path::Path) -> Result<Self> {
576 if !path.exists() {
577 return Ok(Self::default());
578 }
579 let raw = std::fs::read_to_string(path).map_err(|e| Error::Tool {
580 name: "scratchpad".into(),
581 call_id: None,
582 message: format!("failed to read scratchpad file: {e}"),
583 })?;
584 serde_json::from_str(&raw).map_err(|e| Error::Tool {
585 name: "scratchpad".into(),
586 call_id: None,
587 message: format!("malformed scratchpad file: {e}"),
588 })
589 }
590
591 fn save(&self, path: &std::path::Path) -> Result<()> {
593 if let Some(parent) = path.parent() {
594 std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
595 name: "scratchpad".into(),
596 call_id: None,
597 message: format!("failed to create scratchpad directory: {e}"),
598 })?;
599 }
600 let raw = serde_json::to_string_pretty(self).map_err(|e| Error::Tool {
601 name: "scratchpad".into(),
602 call_id: None,
603 message: format!("failed to serialize scratchpad: {e}"),
604 })?;
605 std::fs::write(path, raw).map_err(|e| Error::Tool {
606 name: "scratchpad".into(),
607 call_id: None,
608 message: format!("failed to write scratchpad file: {e}"),
609 })?;
610 Ok(())
611 }
612
613 fn set(&mut self, key: String, value: String) {
615 if let Some(existing) = self.entries.iter_mut().find(|e| e.key == key) {
617 existing.value = value;
618 } else {
619 self.entries.push(ScratchpadEntry { key, value });
620 }
621 }
622
623 fn get(&self, key: &str) -> Option<&str> {
625 self.entries
626 .iter()
627 .find(|e| e.key == key)
628 .map(|e| e.value.as_str())
629 }
630
631 fn delete(&mut self, key: &str) -> bool {
633 let before = self.entries.len();
634 self.entries.retain(|e| e.key != key);
635 self.entries.len() < before
636 }
637
638 fn keys(&self) -> Vec<&str> {
640 self.entries.iter().map(|e| e.key.as_str()).collect()
641 }
642}
643
644pub fn scratchpad_path(workspace: &std::path::Path) -> PathBuf {
648 crate::paths::user_scratchpad_path(workspace)
649 .unwrap_or_else(|_| workspace.join(".recursive").join("scratchpad.json"))
650}
651
652pub fn load_scratchpad(workspace: &std::path::Path) -> Result<Scratchpad> {
654 let path = scratchpad_path(workspace);
655 Scratchpad::load(&path)
656}
657
658pub fn scratchpad_summary(workspace: &std::path::Path) -> String {
662 let pad = match load_scratchpad(workspace) {
663 Ok(p) => p,
664 Err(_) => return String::new(),
665 };
666 if pad.entries.is_empty() {
667 return String::new();
668 }
669 let mut lines: Vec<String> = Vec::new();
670 lines.push("# Working Memory (scratchpad)".to_string());
671 for entry in &pad.entries {
672 let value_preview = if entry.value.len() > 200 {
674 format!("{}...", crate::truncate_str(&entry.value, 197))
675 } else {
676 entry.value.clone()
677 };
678 lines.push(format!("- {}: {}", entry.key, value_preview));
679 }
680 lines.join("\n")
681}
682
683pub fn migrate_scratchpad(_workspace: &std::path::Path) -> Result<()> {
686 Ok(())
688}
689
690pub struct WorkingMemoryTool {
695 workspace: PathBuf,
696 lock: Mutex<()>,
697}
698
699impl WorkingMemoryTool {
700 pub fn new(workspace: impl Into<PathBuf>) -> Self {
701 Self {
702 workspace: workspace.into(),
703 lock: Mutex::new(()),
704 }
705 }
706}
707
708#[async_trait]
709impl Tool for WorkingMemoryTool {
710 fn is_deferred(&self) -> bool {
711 true
712 }
713
714 fn spec(&self) -> ToolSpec {
715 ToolSpec {
716 name: "scratchpad_set".into(),
717 description: "Store a value in working memory (scratchpad) under a key. Use this to remember intermediate results, decisions, or context across steps. The scratchpad contents are injected into the system prompt.".into(),
718 parameters: json!({
719 "type": "object",
720 "properties": {
721 "key": {
722 "type": "string",
723 "description": "The key to store under"
724 },
725 "value": {
726 "type": "string",
727 "description": "The value to store"
728 }
729 },
730 "required": ["key", "value"]
731 }),
732 }
733 }
734
735 async fn execute(&self, arguments: Value) -> Result<String> {
736 let key = arguments["key"]
737 .as_str()
738 .ok_or_else(|| Error::BadToolArgs {
739 name: "scratchpad_set".into(),
740 message: "missing required parameter: key".to_string(),
741 })?
742 .to_string();
743 let value = arguments["value"]
744 .as_str()
745 .ok_or_else(|| Error::BadToolArgs {
746 name: "scratchpad_set".into(),
747 message: "missing required parameter: value".to_string(),
748 })?
749 .to_string();
750
751 let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
752 let path = scratchpad_path(&self.workspace);
753 let mut pad = Scratchpad::load(&path)?;
754 pad.set(key.clone(), value);
755 pad.save(&path)?;
756 Ok(format!("scratchpad key '{key}' set"))
757 }
758}
759
760pub struct ScratchpadGet {
764 workspace: PathBuf,
765}
766
767impl ScratchpadGet {
768 pub fn new(workspace: impl Into<PathBuf>) -> Self {
769 Self {
770 workspace: workspace.into(),
771 }
772 }
773}
774
775#[async_trait]
776impl Tool for ScratchpadGet {
777 fn is_deferred(&self) -> bool {
778 true
779 }
780
781 fn spec(&self) -> ToolSpec {
782 ToolSpec {
783 name: "scratchpad_get".into(),
784 description: "Retrieve a value from working memory (scratchpad) by key.".into(),
785 parameters: json!({
786 "type": "object",
787 "properties": {
788 "key": {
789 "type": "string",
790 "description": "The key to retrieve"
791 }
792 },
793 "required": ["key"]
794 }),
795 }
796 }
797
798 async fn execute(&self, arguments: Value) -> Result<String> {
799 let key = arguments["key"]
800 .as_str()
801 .ok_or_else(|| Error::BadToolArgs {
802 name: "scratchpad_get".into(),
803 message: "missing required parameter: key".to_string(),
804 })?
805 .to_string();
806
807 let path = scratchpad_path(&self.workspace);
808 let pad = Scratchpad::load(&path)?;
809 match pad.get(&key) {
810 Some(value) => Ok(value.to_string()),
811 None => Ok(format!("no such key: {key}")),
812 }
813 }
814}
815
816pub struct ScratchpadDelete {
817 workspace: PathBuf,
818 lock: Mutex<()>,
819}
820
821impl ScratchpadDelete {
822 pub fn new(workspace: impl Into<PathBuf>) -> Self {
823 Self {
824 workspace: workspace.into(),
825 lock: Mutex::new(()),
826 }
827 }
828}
829
830#[async_trait]
831impl Tool for ScratchpadDelete {
832 fn is_deferred(&self) -> bool {
833 true
834 }
835
836 fn spec(&self) -> ToolSpec {
837 ToolSpec {
838 name: "scratchpad_delete".into(),
839 description: "Delete a key from working memory (scratchpad).".into(),
840 parameters: json!({
841 "type": "object",
842 "properties": {
843 "key": {
844 "type": "string",
845 "description": "The key to delete"
846 }
847 },
848 "required": ["key"]
849 }),
850 }
851 }
852
853 async fn execute(&self, arguments: Value) -> Result<String> {
854 let key = arguments["key"]
855 .as_str()
856 .ok_or_else(|| Error::BadToolArgs {
857 name: "scratchpad_delete".into(),
858 message: "missing required parameter: key".to_string(),
859 })?
860 .to_string();
861
862 let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
863 let path = scratchpad_path(&self.workspace);
864 let mut pad = Scratchpad::load(&path)?;
865 if pad.delete(&key) {
866 pad.save(&path)?;
867 Ok(format!("scratchpad key '{key}' deleted"))
868 } else {
869 Ok(format!("no such key: {key}"))
870 }
871 }
872}
873
874pub struct ScratchpadList {
875 workspace: PathBuf,
876}
877
878impl ScratchpadList {
879 pub fn new(workspace: impl Into<PathBuf>) -> Self {
880 Self {
881 workspace: workspace.into(),
882 }
883 }
884}
885
886#[async_trait]
887impl Tool for ScratchpadList {
888 fn is_deferred(&self) -> bool {
889 true
890 }
891
892 fn spec(&self) -> ToolSpec {
893 ToolSpec {
894 name: "scratchpad_list".into(),
895 description: "List all keys currently stored in working memory (scratchpad).".into(),
896 parameters: json!({
897 "type": "object",
898 "properties": {}
899 }),
900 }
901 }
902
903 async fn execute(&self, _arguments: Value) -> Result<String> {
904 let path = scratchpad_path(&self.workspace);
905 let pad = Scratchpad::load(&path)?;
906 let keys = pad.keys();
907 if keys.is_empty() {
908 return Ok("scratchpad is empty".to_string());
909 }
910 Ok(keys.join("\n"))
911 }
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917
918 fn fresh_store() -> MemoryStore {
921 MemoryStore::default()
922 }
923
924 #[test]
927 fn load_returns_empty_store_when_file_missing() {
928 let store = MemoryStore::load(std::path::Path::new("/nonexistent/path/memory.json"))
929 .expect("missing file should return empty store, not an error");
930 assert!(store.notes.is_empty());
931 }
932
933 #[test]
934 fn load_parses_existing_file() {
935 let tmp = tempfile::NamedTempFile::new().unwrap();
936 let json =
937 r#"{"notes":[{"id":"N1","tags":["rust"],"text":"hello","ts":"2026-01-01T00:00:00Z"}]}"#;
938 std::fs::write(tmp.path(), json).unwrap();
939 let store = MemoryStore::load(tmp.path()).expect("valid file must load");
940 assert_eq!(store.notes.len(), 1);
941 assert_eq!(store.notes[0].id, "N1");
942 assert_eq!(store.notes[0].text, "hello");
943 }
944
945 #[test]
948 fn save_and_reload_round_trip() {
949 let tmp = tempfile::NamedTempFile::new().unwrap();
950 let mut store = fresh_store();
951 store.add("round-trip text".into(), vec!["rt".into()]);
952 store.save(tmp.path()).expect("save must succeed");
953
954 let loaded = MemoryStore::load(tmp.path()).expect("reload must succeed");
955 assert_eq!(loaded.notes.len(), 1);
956 assert_eq!(loaded.notes[0].text, "round-trip text");
957 }
958
959 #[test]
962 fn next_id_on_empty_store_is_n1() {
963 assert_eq!(fresh_store().next_id(), "N1");
964 }
965
966 #[test]
967 fn next_id_increments_past_existing_notes() {
968 let mut store = fresh_store();
969 store.add("first".into(), vec![]);
970 store.add("second".into(), vec![]);
971 assert_eq!(store.next_id(), "N3");
972 }
973
974 #[test]
977 fn add_returns_the_assigned_id() {
978 let mut store = fresh_store();
979 let id = store.add("my note".into(), vec!["tag1".into()]);
980 assert_eq!(id, "N1", "first add must return N1");
981 assert_eq!(store.notes[0].id, "N1");
982 assert_eq!(store.notes[0].text, "my note");
983 }
984
985 #[test]
986 fn add_second_note_gets_n2() {
987 let mut store = fresh_store();
988 store.add("a".into(), vec![]);
989 let id2 = store.add("b".into(), vec![]);
990 assert_eq!(id2, "N2");
991 assert_eq!(store.notes.len(), 2);
992 }
993
994 #[test]
997 fn remove_existing_note_returns_true() {
998 let mut store = fresh_store();
999 let id = store.add("to remove".into(), vec![]);
1000 assert!(store.remove(&id), "remove existing must return true");
1001 assert!(store.notes.is_empty(), "note must actually be gone");
1002 }
1003
1004 #[test]
1005 fn remove_absent_note_returns_false() {
1006 let mut store = fresh_store();
1007 assert!(!store.remove("N999"), "remove absent must return false");
1008 }
1009
1010 #[test]
1011 fn remove_only_removes_target_note() {
1012 let mut store = fresh_store();
1013 let id1 = store.add("keep".into(), vec![]);
1014 let id2 = store.add("remove me".into(), vec![]);
1015 assert!(store.remove(&id2));
1016 assert_eq!(store.notes.len(), 1);
1017 assert_eq!(store.notes[0].id, id1);
1018 }
1019
1020 #[test]
1023 fn search_finds_by_text_substring() {
1024 let mut store = fresh_store();
1025 store.add("hello world".into(), vec![]);
1026 store.add("goodbye".into(), vec![]);
1027 let results = store.search(Some("hello"), None, 10);
1028 assert_eq!(results.len(), 1);
1029 assert!(results[0].text.contains("hello"));
1030 }
1031
1032 #[test]
1033 fn search_finds_by_tag_in_text_or_tag_field() {
1034 let mut store = fresh_store();
1035 store.add("general note".into(), vec!["special-tag".into()]);
1037 let results = store.search(Some("special-tag"), None, 10);
1039 assert_eq!(
1040 results.len(),
1041 1,
1042 "search must find query match in tags via || branch"
1043 );
1044 }
1045
1046 #[test]
1047 fn search_exact_tag_filter() {
1048 let mut store = fresh_store();
1049 store.add("rust note".into(), vec!["rust".into()]);
1050 store.add("go note".into(), vec!["go".into()]);
1051 let results = store.search(None, Some("rust"), 10);
1052 assert_eq!(results.len(), 1);
1053 assert_eq!(results[0].tags[0], "rust");
1054 }
1055
1056 #[test]
1057 fn search_limit_applies() {
1058 let mut store = fresh_store();
1059 for i in 0..5 {
1060 store.add(format!("note {i}"), vec![]);
1061 }
1062 let results = store.search(None, None, 3);
1063 assert_eq!(results.len(), 3);
1064 }
1065
1066 #[test]
1067 fn search_no_match_returns_empty() {
1068 let mut store = fresh_store();
1069 store.add("hello".into(), vec![]);
1070 let results = store.search(Some("zzz_no_match"), None, 10);
1071 assert!(results.is_empty());
1072 }
1073
1074 #[test]
1077 fn chrono_now_rfc3339_is_nonempty_and_not_placeholder() {
1078 let ts = chrono_now_rfc3339();
1079 assert!(!ts.is_empty(), "timestamp must not be empty");
1080 assert_ne!(ts, "xyzzy", "timestamp must not be placeholder");
1081 assert!(
1083 ts.len() >= 20 && ts.ends_with('Z'),
1084 "timestamp must end with Z; got: {ts}"
1085 );
1086 assert!(
1087 ts.contains('T'),
1088 "timestamp must contain T separator; got: {ts}"
1089 );
1090 }
1091
1092 #[test]
1093 fn chrono_now_rfc3339_year_is_plausible() {
1094 let ts = chrono_now_rfc3339();
1095 let year: u32 = ts[..4].parse().expect("first 4 chars must be a year");
1096 assert!(year >= 2024, "year must be >= 2024; got {year}");
1097 }
1098
1099 #[test]
1102 fn days_to_date_day_zero_is_unix_epoch() {
1103 assert_eq!(days_to_date(0), (1970, 1, 1));
1104 }
1105
1106 #[test]
1107 fn days_to_date_day_365_is_1971_01_01() {
1108 assert_eq!(days_to_date(365), (1971, 1, 1));
1109 }
1110
1111 #[test]
1112 fn days_to_date_leap_year_day_60_is_feb_29() {
1113 assert_eq!(days_to_date(730 + 59), (1972, 2, 29));
1116 }
1117
1118 #[test]
1119 fn days_to_date_known_date_2026_07_06() {
1120 let (y, m, d) = days_to_date(20640);
1122 assert_eq!(y, 2026);
1123 assert_eq!(m, 7);
1124 assert_eq!(d, 6);
1125 }
1126
1127 #[test]
1130 fn is_leap_regular_leap_year() {
1131 assert!(is_leap(1972));
1133 assert!(is_leap(2024));
1134 }
1135
1136 #[test]
1137 fn is_leap_century_non_leap() {
1138 assert!(!is_leap(1900));
1140 assert!(!is_leap(1800));
1141 }
1142
1143 #[test]
1144 fn is_leap_400_year_is_leap() {
1145 assert!(is_leap(2000));
1147 assert!(is_leap(1600));
1148 }
1149
1150 #[test]
1151 fn is_leap_ordinary_non_leap() {
1152 assert!(!is_leap(1971));
1154 assert!(!is_leap(2023));
1155 }
1156
1157 #[test]
1160 fn chrono_now_rfc3339_minutes_and_seconds_in_range() {
1161 let ts = chrono_now_rfc3339();
1162 let mm: u32 = ts[14..16].parse().expect("minutes must parse");
1164 let ss: u32 = ts[17..19].parse().expect("seconds must parse");
1165 assert!(mm < 60, "minutes out of range: {mm}");
1166 assert!(ss < 60, "seconds out of range: {ss}");
1167 }
1168
1169 fn fresh_scratchpad() -> Scratchpad {
1172 Scratchpad::default()
1173 }
1174
1175 #[test]
1176 fn scratchpad_set_inserts_new_entry() {
1177 let mut pad = fresh_scratchpad();
1179 pad.set("k1".into(), "v1".into());
1180 assert_eq!(pad.get("k1"), Some("v1"));
1181 }
1182
1183 #[test]
1184 fn scratchpad_set_updates_existing_entry() {
1185 let mut pad = fresh_scratchpad();
1187 pad.set("key".into(), "first".into());
1188 pad.set("key".into(), "second".into());
1189 assert_eq!(
1190 pad.get("key"),
1191 Some("second"),
1192 "set must update existing key"
1193 );
1194 assert_eq!(
1195 pad.entries.len(),
1196 1,
1197 "update must not add a duplicate entry"
1198 );
1199 }
1200
1201 #[test]
1202 fn scratchpad_get_returns_none_for_missing_key() {
1203 let pad = fresh_scratchpad();
1205 assert!(pad.get("nonexistent").is_none());
1206 }
1207
1208 #[test]
1209 fn scratchpad_delete_returns_true_for_existing_key() {
1210 let mut pad = fresh_scratchpad();
1212 pad.set("k".into(), "v".into());
1213 assert!(pad.delete("k"), "delete must return true when key existed");
1214 assert!(pad.get("k").is_none(), "key must be gone after delete");
1215 }
1216
1217 #[test]
1218 fn scratchpad_delete_returns_false_for_missing_key() {
1219 let mut pad = fresh_scratchpad();
1221 assert!(
1222 !pad.delete("ghost"),
1223 "delete must return false for absent key"
1224 );
1225 }
1226
1227 #[test]
1228 fn scratchpad_keys_returns_all_keys_in_order() {
1229 let mut pad = fresh_scratchpad();
1231 pad.set("alpha".into(), "1".into());
1232 pad.set("beta".into(), "2".into());
1233 let keys = pad.keys();
1234 assert_eq!(keys, vec!["alpha", "beta"]);
1235 }
1236
1237 #[test]
1238 fn scratchpad_summary_returns_empty_for_no_entries() {
1239 let tmp = crate::test_util::IsolatedWorkspace::new();
1241 let summary = scratchpad_summary(tmp.path());
1242 assert!(
1243 summary.is_empty(),
1244 "empty scratchpad must produce empty summary, got: {summary}"
1245 );
1246 }
1247
1248 #[test]
1249 fn scratchpad_summary_truncates_long_values() {
1250 let tmp = crate::test_util::IsolatedWorkspace::new();
1252 let path = scratchpad_path(tmp.path());
1253 let mut pad = Scratchpad::default();
1254 pad.set("long_key".into(), "X".repeat(300));
1255 pad.save(&path).unwrap();
1256
1257 let summary = scratchpad_summary(tmp.path());
1258 assert!(
1259 !summary.contains(&"X".repeat(300)),
1260 "value > 200 chars must be truncated in summary"
1261 );
1262 assert!(
1263 summary.contains("..."),
1264 "truncated value must end with '...': {summary}"
1265 );
1266 }
1267
1268 #[test]
1271 fn memory_summary_returns_empty_for_no_notes() {
1272 let tmp = crate::test_util::IsolatedWorkspace::new();
1273 let summary = memory_summary(tmp.path(), 10);
1274 assert!(
1275 summary.is_empty(),
1276 "empty memory must produce empty summary, got: {summary}"
1277 );
1278 }
1279
1280 #[test]
1281 fn memory_summary_uses_index_format() {
1282 let tmp = crate::test_util::IsolatedWorkspace::new();
1283 let path = memory_path(tmp.path());
1284 let mut store = MemoryStore::default();
1285 store.add("test note content".into(), vec!["tag1".into()]);
1286 store.save(&path).unwrap();
1287
1288 let summary = memory_summary(tmp.path(), 10);
1289 assert!(
1290 summary.contains("# Memory Index"),
1291 "summary must start with '# Memory Index'"
1292 );
1293 assert!(
1294 summary.contains("- [N1](recall) — test note content"),
1295 "summary must use index format: '- [ID](recall) — hook'"
1296 );
1297 assert!(
1298 !summary.contains("[tag1]"),
1299 "index format should not inline tags (they're in the full note)"
1300 );
1301 }
1302
1303 #[test]
1304 fn memory_summary_truncates_long_hooks() {
1305 let tmp = crate::test_util::IsolatedWorkspace::new();
1306 let path = memory_path(tmp.path());
1307 let mut store = MemoryStore::default();
1308 store.add("X".repeat(200), vec![]);
1309 store.save(&path).unwrap();
1310
1311 let summary = memory_summary(tmp.path(), 10);
1312 assert!(
1313 summary.contains("..."),
1314 "long hook must be truncated with '...'"
1315 );
1316 assert!(
1318 summary.contains("- [N1](recall) — "),
1319 "truncated hook must still use index format"
1320 );
1321 }
1322
1323 #[test]
1324 fn memory_summary_replaces_newlines_in_hook() {
1325 let tmp = crate::test_util::IsolatedWorkspace::new();
1326 let path = memory_path(tmp.path());
1327 let mut store = MemoryStore::default();
1328 store.add("line1\nline2\nline3".into(), vec![]);
1329 store.save(&path).unwrap();
1330
1331 let summary = memory_summary(tmp.path(), 10);
1332 let index_line = summary
1334 .lines()
1335 .find(|l| l.starts_with("- [N"))
1336 .expect("should find an index line");
1337 assert!(
1338 !index_line.contains('\n'),
1339 "newlines in hook must be replaced with spaces to keep index compact"
1340 );
1341 assert!(
1342 index_line.contains("line1 line2 line3") || index_line.contains("line1 line2"),
1343 "hook content should be preserved with spaces instead of newlines"
1344 );
1345 }
1346
1347 #[test]
1348 fn memory_summary_respects_limit() {
1349 let tmp = crate::test_util::IsolatedWorkspace::new();
1350 let path = memory_path(tmp.path());
1351 let mut store = MemoryStore::default();
1352 for i in 0..10 {
1353 store.add(format!("note {}", i), vec![]);
1354 }
1355 store.save(&path).unwrap();
1356
1357 let summary = memory_summary(tmp.path(), 3);
1358 let entry_count = summary.matches("- [N").count();
1360 assert_eq!(entry_count, 3, "summary must respect limit parameter");
1361 }
1362
1363 #[test]
1364 fn memory_summary_most_recent_first() {
1365 let tmp = crate::test_util::IsolatedWorkspace::new();
1366 let path = memory_path(tmp.path());
1367 let mut store = MemoryStore::default();
1368 store.add("first".into(), vec![]);
1369 store.add("second".into(), vec![]);
1370 store.add("third".into(), vec![]);
1371 store.save(&path).unwrap();
1372
1373 let summary = memory_summary(tmp.path(), 10);
1374 let lines: Vec<&str> = summary.lines().collect();
1375 let mut index_lines = Vec::new();
1377 for line in lines {
1378 if line.starts_with("- [N") {
1379 index_lines.push(line);
1380 }
1381 }
1382 assert!(
1384 index_lines[0].contains("N3"),
1385 "most recent note (N3) should appear first"
1386 );
1387 assert!(
1388 index_lines[2].contains("N1"),
1389 "oldest note (N1) should appear last"
1390 );
1391 }
1392}