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 tokio::fs::write(&path, lines.join("\n"))
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 tokio::fs::write(&path, value)
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 },
138 Message {
139 role: Role::Assistant,
140 content: "world".into(),
141 tool_calls: vec![],
142 tool_call_id: None,
143 reasoning_content: None,
144 },
145 ]
146 }
147
148 #[tokio::test]
149 async fn save_and_load_transcript_roundtrip() {
150 let (b, _dir) = backend();
151 let msgs = make_messages();
152 b.save_transcript("sess1", &msgs).await.unwrap();
153 let loaded = b.load_transcript("sess1").await.unwrap();
154 assert_eq!(loaded, msgs);
155 }
156
157 #[tokio::test]
158 async fn load_transcript_nonexistent_returns_empty() {
159 let (b, _dir) = backend();
160 let loaded = b.load_transcript("no-such-session").await.unwrap();
161 assert!(loaded.is_empty());
162 }
163
164 #[tokio::test]
165 async fn save_and_load_memory_roundtrip() {
166 let (b, _dir) = backend();
167 b.save_memory("summary.md", "some memory text")
168 .await
169 .unwrap();
170 let val = b.load_memory("summary.md").await.unwrap();
171 assert_eq!(val.as_deref(), Some("some memory text"));
172 }
173
174 #[tokio::test]
175 async fn load_memory_nonexistent_returns_none() {
176 let (b, _dir) = backend();
177 let val = b.load_memory("nonexistent").await.unwrap();
178 assert!(val.is_none());
179 }
180
181 #[tokio::test]
182 async fn save_transcript_creates_parent_dirs() {
183 let (b, _dir) = backend();
184 let msgs = make_messages();
185 b.save_transcript("deep-session", &msgs).await.unwrap();
187 assert!(b.transcript_path("deep-session").exists());
188 }
189}