1use std::collections::HashMap;
39use std::path::{Path, PathBuf};
40use std::sync::Mutex;
41
42use super::CommitId;
43use super::filesystem::{make_commit_id, normalise_rel_path};
44use crate::backend::{BackendError, MemBackend};
45use crate::filesystem::changelog::format_rfc3339_utc;
46use crate::provenance::Provenance;
47use crate::vcs::CommitContext;
48
49enum PendingState {
54 Upsert(Vec<u8>),
55 Delete,
56}
57
58#[derive(Default)]
62struct State {
63 committed: HashMap<String, Vec<u8>>,
67 pending: HashMap<String, PendingState>,
70 provenance: Vec<Provenance>,
73 config: Option<Vec<u8>>,
76}
77
78pub struct InMemoryBackend {
81 state: Mutex<State>,
82}
83
84impl InMemoryBackend {
85 pub fn new() -> Self {
88 Self {
89 state: Mutex::new(State::default()),
90 }
91 }
92
93 fn lock(&self) -> Result<std::sync::MutexGuard<'_, State>, BackendError> {
94 self.state
95 .lock()
96 .map_err(|_| BackendError::Other("in-memory backend state poisoned".to_string()))
97 }
98}
99
100impl Default for InMemoryBackend {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl MemBackend for InMemoryBackend {
107 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
108 let state = self.lock()?;
112 Ok(state.committed.keys().map(PathBuf::from).collect())
113 }
114
115 fn read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
116 let key = normalise_rel_path(rel_path)?;
117 let state = self.lock()?;
118 if let Some(staged) = state.pending.get(&key) {
123 return Ok(match staged {
124 PendingState::Upsert(bytes) => Some(bytes.clone()),
125 PendingState::Delete => None,
126 });
127 }
128 Ok(state.committed.get(&key).cloned())
129 }
130
131 fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), BackendError> {
132 let key = normalise_rel_path(rel_path)?;
133 let mut state = self.lock()?;
134 state
135 .pending
136 .insert(key, PendingState::Upsert(content.to_vec()));
137 Ok(())
138 }
139
140 fn delete_entity(&self, rel_path: &Path) -> Result<(), BackendError> {
141 let key = normalise_rel_path(rel_path)?;
142 let mut state = self.lock()?;
143 state.pending.insert(key, PendingState::Delete);
144 Ok(())
145 }
146
147 fn move_entity(&self, from: &Path, to: &Path) -> Result<(), BackendError> {
148 let from_key = normalise_rel_path(from)?;
149 let to_key = normalise_rel_path(to)?;
150 let mut state = self.lock()?;
151
152 let bytes = match state.pending.remove(&from_key) {
156 Some(PendingState::Upsert(b)) => b,
157 Some(PendingState::Delete) => {
158 state.pending.insert(from_key, PendingState::Delete);
159 return Err(BackendError::Other(format!(
160 "move source {} is already pending deletion",
161 from.display()
162 )));
163 }
164 None => match state.committed.get(&from_key) {
165 Some(b) => b.clone(),
166 None => {
167 return Err(BackendError::Other(format!(
168 "move source {} does not exist",
169 from.display()
170 )));
171 }
172 },
173 };
174
175 if matches!(state.pending.get(&to_key), Some(PendingState::Upsert(_))) {
176 return Err(BackendError::Other(format!(
177 "move target {} already has a pending write",
178 to.display()
179 )));
180 }
181 state.pending.insert(from_key, PendingState::Delete);
182 state.pending.insert(to_key, PendingState::Upsert(bytes));
183 Ok(())
184 }
185
186 fn discard_pending(&self) -> Result<(), BackendError> {
187 let mut state = self.lock()?;
188 state.pending.clear();
189 Ok(())
190 }
191
192 fn commit(&self, _message: &str, _ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
193 let mut state = self.lock()?;
194 let ops: Vec<(String, PendingState)> = state.pending.drain().collect();
195 for (key, op) in ops {
196 match op {
197 PendingState::Upsert(bytes) => {
198 state.committed.insert(key, bytes);
199 }
200 PendingState::Delete => {
201 state.committed.remove(&key);
202 }
203 }
204 }
205 Ok(make_commit_id())
206 }
207
208 fn append_provenance(&self, record: &Provenance) -> Result<(), BackendError> {
209 let mut state = self.lock()?;
210 state.provenance.push(record.clone());
211 Ok(())
212 }
213
214 fn read_provenance(&self, cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
215 let state = self.lock()?;
216 let out = state
221 .provenance
222 .iter()
223 .filter(|r| match cursor {
224 Some(c) => format_rfc3339_utc(r.timestamp).as_str() > c,
225 None => true,
226 })
227 .cloned()
228 .collect();
229 Ok(out)
230 }
231
232 fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
233 let state = self.lock()?;
234 Ok(state.config.clone())
235 }
236
237 fn write_mem_config(&self, bytes: &[u8]) -> Result<(), BackendError> {
238 let mut state = self.lock()?;
242 state.config = Some(bytes.to_vec());
243 Ok(())
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::provenance::ProvenanceKind;
251 use crate::vcs::{Actor, ClientId, CommitContext};
252 use std::time::{Duration, UNIX_EPOCH};
253
254 fn ctx<'a>() -> CommitContext<'a> {
255 CommitContext {
256 actor: Actor::Cli,
257 client: Some(ClientId {
258 name: "claude-code".to_string(),
259 version: "0.1.0".to_string(),
260 }),
261 tool: Some("test"),
262 note: None,
263 logical_operation_id: None,
264 entity_ids: None,
265 }
266 }
267
268 #[test]
269 fn write_then_commit_round_trips_in_ram() {
270 let b = InMemoryBackend::new();
271 b.write_entity(Path::new("notes/hello.md"), b"# hi\n")
272 .unwrap();
273 let id = b.commit("c1", &ctx()).unwrap();
274 assert!(!id.is_empty());
275 assert_eq!(
276 b.read_entity(Path::new("notes/hello.md")).unwrap(),
277 Some(b"# hi\n".to_vec())
278 );
279 assert_eq!(
280 b.list_entities().unwrap(),
281 vec![PathBuf::from("notes/hello.md")]
282 );
283 }
284
285 #[test]
286 fn read_sees_pending_write_before_commit() {
287 let b = InMemoryBackend::new();
288 b.write_entity(Path::new("a.md"), b"staged").unwrap();
289 assert_eq!(
291 b.read_entity(Path::new("a.md")).unwrap(),
292 Some(b"staged".to_vec())
293 );
294 assert!(b.list_entities().unwrap().is_empty());
295 }
296
297 #[test]
298 fn delete_removes_committed_path() {
299 let b = InMemoryBackend::new();
300 b.write_entity(Path::new("a.md"), b"a").unwrap();
301 b.write_entity(Path::new("b.md"), b"b").unwrap();
302 b.commit("seed", &ctx()).unwrap();
303 b.delete_entity(Path::new("a.md")).unwrap();
304 b.commit("drop", &ctx()).unwrap();
305 assert_eq!(b.read_entity(Path::new("a.md")).unwrap(), None);
306 assert_eq!(
307 b.read_entity(Path::new("b.md")).unwrap(),
308 Some(b"b".to_vec())
309 );
310 }
311
312 #[test]
313 fn delete_of_missing_path_is_idempotent() {
314 let b = InMemoryBackend::new();
315 b.delete_entity(Path::new("ghost.md")).unwrap();
316 b.commit("noop", &ctx()).unwrap();
317 assert_eq!(b.read_entity(Path::new("ghost.md")).unwrap(), None);
318 }
319
320 #[test]
321 fn move_renames_committed_path() {
322 let b = InMemoryBackend::new();
323 b.write_entity(Path::new("from.md"), b"payload").unwrap();
324 b.commit("seed", &ctx()).unwrap();
325 b.move_entity(Path::new("from.md"), Path::new("nested/to.md"))
326 .unwrap();
327 b.commit("rename", &ctx()).unwrap();
328 assert_eq!(b.read_entity(Path::new("from.md")).unwrap(), None);
329 assert_eq!(
330 b.read_entity(Path::new("nested/to.md")).unwrap(),
331 Some(b"payload".to_vec())
332 );
333 }
334
335 #[test]
336 fn move_carries_pending_upsert_bytes() {
337 let b = InMemoryBackend::new();
338 b.write_entity(Path::new("a.md"), b"alpha").unwrap();
339 b.move_entity(Path::new("a.md"), Path::new("b.md")).unwrap();
340 b.commit("write+move", &ctx()).unwrap();
341 assert_eq!(b.read_entity(Path::new("a.md")).unwrap(), None);
342 assert_eq!(
343 b.read_entity(Path::new("b.md")).unwrap(),
344 Some(b"alpha".to_vec())
345 );
346 }
347
348 #[test]
349 fn move_missing_source_errors() {
350 let b = InMemoryBackend::new();
351 let err = b
352 .move_entity(Path::new("ghost.md"), Path::new("here.md"))
353 .unwrap_err();
354 assert!(matches!(err, BackendError::Other(_)));
355 }
356
357 #[test]
358 fn rejects_path_traversal_absolute_and_empty() {
359 let b = InMemoryBackend::new();
360 assert!(b.write_entity(Path::new("../escape.md"), b"x").is_err());
363 assert!(b.write_entity(Path::new("/etc/passwd"), b"x").is_err());
364 assert!(b.write_entity(Path::new(""), b"x").is_err());
365 }
366
367 #[test]
368 fn discard_pending_drops_staged_writes() {
369 let b = InMemoryBackend::new();
370 b.write_entity(Path::new("a.md"), b"first").unwrap();
371 b.commit("seed", &ctx()).unwrap();
372 b.write_entity(Path::new("a.md"), b"second-uncommitted")
373 .unwrap();
374 b.discard_pending().unwrap();
375 b.commit("after-discard", &ctx()).unwrap();
376 assert_eq!(
378 b.read_entity(Path::new("a.md")).unwrap(),
379 Some(b"first".to_vec())
380 );
381 }
382
383 #[test]
384 fn reports_no_durable_history() {
385 let b = InMemoryBackend::new();
387 assert_eq!(b.current_head().unwrap(), None);
388 }
389
390 #[test]
391 fn mem_config_round_trips() {
392 let b = InMemoryBackend::new();
393 assert_eq!(b.read_mem_config().unwrap(), None);
394 b.write_mem_config(b"{\"schema\":\"default@1.0.0\"}")
395 .unwrap();
396 assert_eq!(
397 b.read_mem_config().unwrap(),
398 Some(b"{\"schema\":\"default@1.0.0\"}".to_vec())
399 );
400 }
401
402 #[test]
403 fn provenance_appends_and_reads_with_cursor() {
404 let b = InMemoryBackend::new();
405 let t0 = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
406 let t1 = UNIX_EPOCH + Duration::from_secs(1_700_000_001);
407 b.append_provenance(&Provenance::new(
408 t0,
409 ProvenanceKind::Create,
410 Some("specs--a".to_string()),
411 Actor::Cli,
412 None,
413 None,
414 ))
415 .unwrap();
416 b.append_provenance(&Provenance::new(
417 t1,
418 ProvenanceKind::Update,
419 Some("specs--a".to_string()),
420 Actor::Cli,
421 None,
422 None,
423 ))
424 .unwrap();
425
426 assert_eq!(b.read_provenance(None).unwrap().len(), 2);
428 let cursor = format_rfc3339_utc(t0);
430 let after = b.read_provenance(Some(&cursor)).unwrap();
431 assert_eq!(after.len(), 1);
432 assert_eq!(after[0].kind, ProvenanceKind::Update);
433 }
434}