recursive/storage/
local.rs1use std::path::PathBuf;
8
9use crate::error::{Error, Result};
10use crate::message::Message;
11use crate::storage::StorageBackend;
12use async_trait::async_trait;
13
14pub struct LocalStorageBackend {
20 workspace: PathBuf,
21}
22
23impl LocalStorageBackend {
24 pub fn new(workspace: PathBuf) -> Self {
26 Self { workspace }
27 }
28
29 fn transcript_path(&self, session_id: &str) -> PathBuf {
30 self.workspace
31 .join(".recursive")
32 .join("sessions")
33 .join(format!("{session_id}.jsonl"))
34 }
35
36 fn memory_path(&self, key: &str) -> PathBuf {
37 self.workspace.join(".recursive").join("memory").join(key)
38 }
39}
40
41#[async_trait]
42impl StorageBackend for LocalStorageBackend {
43 async fn load_transcript(&self, session_id: &str) -> Result<Vec<Message>> {
44 let path = self.transcript_path(session_id);
45 if !path.exists() {
46 return Ok(vec![]);
47 }
48 let content = tokio::fs::read_to_string(&path)
49 .await
50 .map_err(|e| Error::Storage {
51 message: format!("read transcript {path:?}: {e}"),
52 })?;
53 content
54 .lines()
55 .filter(|l| !l.trim().is_empty())
56 .map(|l| {
57 serde_json::from_str(l).map_err(|e| Error::Storage {
58 message: format!("parse transcript line: {e}"),
59 })
60 })
61 .collect()
62 }
63
64 async fn save_transcript(&self, session_id: &str, messages: &[Message]) -> Result<()> {
65 let path = self.transcript_path(session_id);
66 if let Some(parent) = path.parent() {
67 tokio::fs::create_dir_all(parent)
68 .await
69 .map_err(|e| Error::Storage {
70 message: format!("create dir {parent:?}: {e}"),
71 })?;
72 }
73 let mut lines = Vec::with_capacity(messages.len());
74 for m in messages {
75 let line = serde_json::to_string(m).map_err(|e| Error::Storage {
76 message: format!("serialize message: {e}"),
77 })?;
78 lines.push(line);
79 }
80 crate::atomic::atomic_write_async(&path, lines.join("\n").into_bytes())
81 .await
82 .map_err(|e| Error::Storage {
83 message: format!("write transcript {path:?}: {e}"),
84 })
85 }
86
87 async fn load_memory(&self, key: &str) -> Result<Option<String>> {
88 let path = self.memory_path(key);
89 if !path.exists() {
90 return Ok(None);
91 }
92 tokio::fs::read_to_string(&path)
93 .await
94 .map(Some)
95 .map_err(|e| Error::Storage {
96 message: format!("read memory {key}: {e}"),
97 })
98 }
99
100 async fn save_memory(&self, key: &str, value: &str) -> Result<()> {
101 let path = self.memory_path(key);
102 if let Some(parent) = path.parent() {
103 tokio::fs::create_dir_all(parent)
104 .await
105 .map_err(|e| Error::Storage {
106 message: format!("create dir {parent:?}: {e}"),
107 })?;
108 }
109 crate::atomic::atomic_write_async(&path, value.as_bytes().to_vec())
110 .await
111 .map_err(|e| Error::Storage {
112 message: format!("write memory {key}: {e}"),
113 })
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::message::Role;
121 use tempfile::TempDir;
122
123 fn backend() -> (LocalStorageBackend, TempDir) {
124 let dir = TempDir::new().unwrap();
125 let b = LocalStorageBackend::new(dir.path().to_path_buf());
126 (b, dir)
127 }
128
129 fn make_messages() -> Vec<Message> {
130 vec![
131 Message {
132 role: Role::User,
133 content: "hello".into(),
134 tool_calls: vec![],
135 tool_call_id: None,
136 reasoning_content: None,
137 is_compaction_summary: false,
138 },
139 Message {
140 role: Role::Assistant,
141 content: "world".into(),
142 tool_calls: vec![],
143 tool_call_id: None,
144 reasoning_content: None,
145 is_compaction_summary: false,
146 },
147 ]
148 }
149
150 #[tokio::test]
151 async fn save_and_load_transcript_roundtrip() {
152 let (b, _dir) = backend();
153 let msgs = make_messages();
154 b.save_transcript("sess1", &msgs).await.unwrap();
155 let loaded = b.load_transcript("sess1").await.unwrap();
156 assert_eq!(loaded, msgs);
157 }
158
159 #[tokio::test]
160 async fn load_transcript_nonexistent_returns_empty() {
161 let (b, _dir) = backend();
162 let loaded = b.load_transcript("no-such-session").await.unwrap();
163 assert!(loaded.is_empty());
164 }
165
166 #[tokio::test]
167 async fn save_and_load_memory_roundtrip() {
168 let (b, _dir) = backend();
169 b.save_memory("summary.md", "some memory text")
170 .await
171 .unwrap();
172 let val = b.load_memory("summary.md").await.unwrap();
173 assert_eq!(val.as_deref(), Some("some memory text"));
174 }
175
176 #[tokio::test]
177 async fn load_memory_nonexistent_returns_none() {
178 let (b, _dir) = backend();
179 let val = b.load_memory("nonexistent").await.unwrap();
180 assert!(val.is_none());
181 }
182
183 #[tokio::test]
184 async fn save_transcript_creates_parent_dirs() {
185 let (b, _dir) = backend();
186 let msgs = make_messages();
187 b.save_transcript("deep-session", &msgs).await.unwrap();
189 assert!(b.transcript_path("deep-session").exists());
190 }
191}