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()?;
116 Ok(state
117 .committed
118 .keys()
119 .filter(|k| k.ends_with(".md") && !k.starts_with(".memstead/"))
120 .map(PathBuf::from)
121 .collect())
122 }
123
124 fn read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
125 let key = normalise_rel_path(rel_path)?;
126 let state = self.lock()?;
127 if let Some(staged) = state.pending.get(&key) {
132 return Ok(match staged {
133 PendingState::Upsert(bytes) => Some(bytes.clone()),
134 PendingState::Delete => None,
135 });
136 }
137 Ok(state.committed.get(&key).cloned())
138 }
139
140 fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), BackendError> {
141 let key = normalise_rel_path(rel_path)?;
142 let mut state = self.lock()?;
143 state
144 .pending
145 .insert(key, PendingState::Upsert(content.to_vec()));
146 Ok(())
147 }
148
149 fn delete_entity(&self, rel_path: &Path) -> Result<(), BackendError> {
150 let key = normalise_rel_path(rel_path)?;
151 let mut state = self.lock()?;
152 state.pending.insert(key, PendingState::Delete);
153 Ok(())
154 }
155
156 fn move_entity(&self, from: &Path, to: &Path) -> Result<(), BackendError> {
157 let from_key = normalise_rel_path(from)?;
158 let to_key = normalise_rel_path(to)?;
159 let mut state = self.lock()?;
160
161 let bytes = match state.pending.remove(&from_key) {
165 Some(PendingState::Upsert(b)) => b,
166 Some(PendingState::Delete) => {
167 state.pending.insert(from_key, PendingState::Delete);
168 return Err(BackendError::Other(format!(
169 "move source {} is already pending deletion",
170 from.display()
171 )));
172 }
173 None => match state.committed.get(&from_key) {
174 Some(b) => b.clone(),
175 None => {
176 return Err(BackendError::Other(format!(
177 "move source {} does not exist",
178 from.display()
179 )));
180 }
181 },
182 };
183
184 if matches!(state.pending.get(&to_key), Some(PendingState::Upsert(_))) {
185 return Err(BackendError::Other(format!(
186 "move target {} already has a pending write",
187 to.display()
188 )));
189 }
190 state.pending.insert(from_key, PendingState::Delete);
191 state.pending.insert(to_key, PendingState::Upsert(bytes));
192 Ok(())
193 }
194
195 fn discard_pending(&self) -> Result<(), BackendError> {
196 let mut state = self.lock()?;
197 state.pending.clear();
198 Ok(())
199 }
200
201 fn commit(&self, _message: &str, _ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
202 let mut state = self.lock()?;
203 let ops: Vec<(String, PendingState)> = state.pending.drain().collect();
204 for (key, op) in ops {
205 match op {
206 PendingState::Upsert(bytes) => {
207 state.committed.insert(key, bytes);
208 }
209 PendingState::Delete => {
210 state.committed.remove(&key);
211 }
212 }
213 }
214 Ok(make_commit_id())
215 }
216
217 fn append_provenance(&self, record: &Provenance) -> Result<(), BackendError> {
218 let mut state = self.lock()?;
219 state.provenance.push(record.clone());
220 Ok(())
221 }
222
223 fn read_provenance(&self, cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
224 let state = self.lock()?;
225 let out = state
230 .provenance
231 .iter()
232 .filter(|r| match cursor {
233 Some(c) => format_rfc3339_utc(r.timestamp).as_str() > c,
234 None => true,
235 })
236 .cloned()
237 .collect();
238 Ok(out)
239 }
240
241 fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
242 self.read_entity(Path::new(crate::anchor::ANCHOR_SIDECAR_PATH))
246 }
247
248 fn write_anchors_sidecar(&self, bytes: &[u8]) -> Result<(), BackendError> {
249 self.write_entity(Path::new(crate::anchor::ANCHOR_SIDECAR_PATH), bytes)
253 }
254
255 fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
256 let state = self.lock()?;
257 Ok(state.config.clone())
258 }
259
260 fn write_mem_config(&self, bytes: &[u8]) -> Result<(), BackendError> {
261 let mut state = self.lock()?;
265 state.config = Some(bytes.to_vec());
266 Ok(())
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::provenance::ProvenanceKind;
274 use crate::vcs::{Actor, ClientId, CommitContext};
275 use std::time::{Duration, UNIX_EPOCH};
276
277 fn ctx<'a>() -> CommitContext<'a> {
278 CommitContext {
279 actor: Actor::Cli,
280 client: Some(ClientId {
281 name: "claude-code".to_string(),
282 version: "0.1.0".to_string(),
283 }),
284 tool: Some("test"),
285 note: None,
286 role: Default::default(),
287 identity: None,
288 logical_operation_id: None,
289 entity_ids: None,
290 }
291 }
292
293 #[test]
294 fn write_then_commit_round_trips_in_ram() {
295 let b = InMemoryBackend::new();
296 b.write_entity(Path::new("notes/hello.md"), b"# hi\n")
297 .unwrap();
298 let id = b.commit("c1", &ctx()).unwrap();
299 assert!(!id.is_empty());
300 assert_eq!(
301 b.read_entity(Path::new("notes/hello.md")).unwrap(),
302 Some(b"# hi\n".to_vec())
303 );
304 assert_eq!(
305 b.list_entities().unwrap(),
306 vec![PathBuf::from("notes/hello.md")]
307 );
308 }
309
310 #[test]
311 fn read_sees_pending_write_before_commit() {
312 let b = InMemoryBackend::new();
313 b.write_entity(Path::new("a.md"), b"staged").unwrap();
314 assert_eq!(
316 b.read_entity(Path::new("a.md")).unwrap(),
317 Some(b"staged".to_vec())
318 );
319 assert!(b.list_entities().unwrap().is_empty());
320 }
321
322 #[test]
323 fn delete_removes_committed_path() {
324 let b = InMemoryBackend::new();
325 b.write_entity(Path::new("a.md"), b"a").unwrap();
326 b.write_entity(Path::new("b.md"), b"b").unwrap();
327 b.commit("seed", &ctx()).unwrap();
328 b.delete_entity(Path::new("a.md")).unwrap();
329 b.commit("drop", &ctx()).unwrap();
330 assert_eq!(b.read_entity(Path::new("a.md")).unwrap(), None);
331 assert_eq!(
332 b.read_entity(Path::new("b.md")).unwrap(),
333 Some(b"b".to_vec())
334 );
335 }
336
337 #[test]
338 fn delete_of_missing_path_is_idempotent() {
339 let b = InMemoryBackend::new();
340 b.delete_entity(Path::new("ghost.md")).unwrap();
341 b.commit("noop", &ctx()).unwrap();
342 assert_eq!(b.read_entity(Path::new("ghost.md")).unwrap(), None);
343 }
344
345 #[test]
346 fn move_renames_committed_path() {
347 let b = InMemoryBackend::new();
348 b.write_entity(Path::new("from.md"), b"payload").unwrap();
349 b.commit("seed", &ctx()).unwrap();
350 b.move_entity(Path::new("from.md"), Path::new("nested/to.md"))
351 .unwrap();
352 b.commit("rename", &ctx()).unwrap();
353 assert_eq!(b.read_entity(Path::new("from.md")).unwrap(), None);
354 assert_eq!(
355 b.read_entity(Path::new("nested/to.md")).unwrap(),
356 Some(b"payload".to_vec())
357 );
358 }
359
360 #[test]
361 fn move_carries_pending_upsert_bytes() {
362 let b = InMemoryBackend::new();
363 b.write_entity(Path::new("a.md"), b"alpha").unwrap();
364 b.move_entity(Path::new("a.md"), Path::new("b.md")).unwrap();
365 b.commit("write+move", &ctx()).unwrap();
366 assert_eq!(b.read_entity(Path::new("a.md")).unwrap(), None);
367 assert_eq!(
368 b.read_entity(Path::new("b.md")).unwrap(),
369 Some(b"alpha".to_vec())
370 );
371 }
372
373 #[test]
374 fn move_missing_source_errors() {
375 let b = InMemoryBackend::new();
376 let err = b
377 .move_entity(Path::new("ghost.md"), Path::new("here.md"))
378 .unwrap_err();
379 assert!(matches!(err, BackendError::Other(_)));
380 }
381
382 #[test]
383 fn rejects_path_traversal_absolute_and_empty() {
384 let b = InMemoryBackend::new();
385 assert!(b.write_entity(Path::new("../escape.md"), b"x").is_err());
388 assert!(b.write_entity(Path::new("/etc/passwd"), b"x").is_err());
389 assert!(b.write_entity(Path::new(""), b"x").is_err());
390 }
391
392 #[test]
393 fn discard_pending_drops_staged_writes() {
394 let b = InMemoryBackend::new();
395 b.write_entity(Path::new("a.md"), b"first").unwrap();
396 b.commit("seed", &ctx()).unwrap();
397 b.write_entity(Path::new("a.md"), b"second-uncommitted")
398 .unwrap();
399 b.discard_pending().unwrap();
400 b.commit("after-discard", &ctx()).unwrap();
401 assert_eq!(
403 b.read_entity(Path::new("a.md")).unwrap(),
404 Some(b"first".to_vec())
405 );
406 }
407
408 #[test]
409 fn reports_no_durable_history() {
410 let b = InMemoryBackend::new();
412 assert_eq!(b.current_head().unwrap(), None);
413 }
414
415 #[test]
416 fn mem_config_round_trips() {
417 let b = InMemoryBackend::new();
418 assert_eq!(b.read_mem_config().unwrap(), None);
419 b.write_mem_config(b"{\"schema\":\"default@1.0.0\"}")
420 .unwrap();
421 assert_eq!(
422 b.read_mem_config().unwrap(),
423 Some(b"{\"schema\":\"default@1.0.0\"}".to_vec())
424 );
425 }
426
427 #[test]
428 fn anchors_sidecar_round_trips_and_is_not_listed_as_entity() {
429 let b = InMemoryBackend::new();
430 assert_eq!(b.read_anchors_sidecar().unwrap(), None);
432 b.write_entity(Path::new("x.md"), b"# x\n").unwrap();
434 b.write_anchors_sidecar(b"{\"version\":1,\"entities\":{}}")
435 .unwrap();
436 assert_eq!(
438 b.read_anchors_sidecar().unwrap(),
439 Some(b"{\"version\":1,\"entities\":{}}".to_vec())
440 );
441 b.commit("seed+anchors", &ctx()).unwrap();
442 assert_eq!(
444 b.read_anchors_sidecar().unwrap(),
445 Some(b"{\"version\":1,\"entities\":{}}".to_vec())
446 );
447 assert_eq!(
449 b.list_entities().unwrap(),
450 vec![PathBuf::from("x.md")],
451 "anchors sidecar must not list as an entity"
452 );
453 }
454
455 #[test]
456 fn provenance_appends_and_reads_with_cursor() {
457 let b = InMemoryBackend::new();
458 let t0 = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
459 let t1 = UNIX_EPOCH + Duration::from_secs(1_700_000_001);
460 b.append_provenance(&Provenance::new(
461 t0,
462 ProvenanceKind::Create,
463 Some("specs--a".to_string()),
464 Actor::Cli,
465 None,
466 None,
467 ))
468 .unwrap();
469 b.append_provenance(&Provenance::new(
470 t1,
471 ProvenanceKind::Update,
472 Some("specs--a".to_string()),
473 Actor::Cli,
474 None,
475 None,
476 ))
477 .unwrap();
478
479 assert_eq!(b.read_provenance(None).unwrap().len(), 2);
481 let cursor = format_rfc3339_utc(t0);
483 let after = b.read_provenance(Some(&cursor)).unwrap();
484 assert_eq!(after.len(), 1);
485 assert_eq!(after[0].kind, ProvenanceKind::Update);
486 }
487}