Skip to main content

recursive/storage/
local.rs

1//! Local filesystem implementation of [`StorageBackend`].
2//!
3//! Stores transcripts as JSONL files and memory entries as plain text files,
4//! both under `<workspace>/.recursive/` — identical layout to what Recursive
5//! used before the trait abstraction existed.
6
7use std::path::PathBuf;
8
9use crate::error::{Error, Result};
10use crate::message::Message;
11use crate::storage::StorageBackend;
12use async_trait::async_trait;
13
14/// [`StorageBackend`] backed by the local filesystem.
15///
16/// All data lives under `<workspace>/.recursive/`:
17/// - Transcripts: `sessions/<session_id>.jsonl`
18/// - Memory:      `memory/<key>`
19pub struct LocalStorageBackend {
20    workspace: PathBuf,
21}
22
23impl LocalStorageBackend {
24    /// Create a new backend rooted at `workspace`.
25    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        // sessions directory does not exist yet
186        b.save_transcript("deep-session", &msgs).await.unwrap();
187        assert!(b.transcript_path("deep-session").exists());
188    }
189}