filesnap 0.1.0

Git-free file snapshots and rewind: a content-addressed store that puts a directory back the way it was, without a repository and without touching the user's version control.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
//! Unit tests for the facade, concentrating on branches an end-to-end
//! narrative never reaches — because a scenario that reaches a failure branch
//! is a scenario that failed, and nobody writes those as a story.
//!
//! This module exists because `store.rs` had 645 lines and no tests at all,
//! and the defect audit found five of its problems here. It is a child module
//! of `store`, so it can reach the partition path directly to corrupt a
//! record. That is deliberate and is the reason these are not integration
//! tests: `Fixture` refuses to know the layout, and proving what happens to a
//! damaged record means writing one.

#![allow(clippy::unwrap_used)]

use super::*;
use crate::fixture::Fixture;
use crate::fixture::no_rules;
use crate::fixture::rules_for;
use pretty_assertions::assert_eq;

const S: &str = "session-1";

/// Where a session's log lives. Only a test that must damage one needs this.
fn log_path(store: &WorkspaceStore, session: &str) -> PathBuf {
    store.partition.join("refs").join(format!("{session}.json"))
}

/// A session whose log cannot be read is left **exactly as it was**, and one
/// such session does not stop the others from being deleted.
///
/// The tempting alternative is to swallow the read error and remove the log
/// anyway. Nothing then enters the doomed set, so nothing is reclaimed — and
/// the only record of what the session held is gone, so the call cannot even
/// be retried. A delete that reports success having done neither thing is
/// worse than one that says it could not.
///
/// Reclamation is the part that does stop. See the test below.
#[test]
fn a_session_whose_log_cannot_be_read_is_refused_not_half_deleted() {
    let fx = Fixture::new();
    fx.write("a.txt", "one");
    fx.capture(S, "turn-1");
    fx.capture("healthy", "turn-2");

    let store = fx.store();
    let log = log_path(&store, S);
    // Truncated mid-write is what this looks like in the wild.
    std::fs::write(&log, b"{\"version\":1,\"entr").unwrap();

    let outcome = store.delete_sessions(&[S.to_string(), "healthy".to_string()]);

    assert!(
        outcome.refused.iter().any(|(id, _)| id == S),
        "{:?}",
        outcome.refused
    );
    assert!(log.exists(), "the refused session keeps its records");
    assert!(
        !store.session_exists("healthy"),
        "one unreadable session does not block deleting the others"
    );
}

/// An unreadable log defers **reclamation** without failing the deletion, and
/// without guessing that anything is dead.
///
/// Liveness is computed by reading every log there is. Skipping one that will
/// not parse would leave its manifests looking unreferenced, and the prune
/// would remove them — converting a damaged log into snapshots that are
/// actually gone, for a session nobody asked to delete. So an incomplete
/// answer licenses no removal at all.
///
/// It is not an error either. Unreachability is what delete promised and it
/// is already done; reclamation was never part of its success criterion
/// (VIII.3), so the bytes simply wait for the next collection.
#[test]
fn an_unreadable_log_defers_reclamation_without_failing_the_delete() {
    let fx = Fixture::new();
    fx.write("a.txt", "one");
    fx.capture(S, "turn-1");
    fx.capture("healthy", "turn-2");

    let store = fx.store();
    std::fs::write(log_path(&store, S), b"not json at all").unwrap();

    let outcome = store.delete_sessions(&["healthy".to_string()]);

    assert!(!store.session_exists("healthy"), "the promise it can keep");
    assert_eq!(outcome.reclaimed.manifests_removed, 0);
    assert!(
        outcome.sweep_error.is_none(),
        "deferring is not failing — delete has no preconditions (D9): {:?}",
        outcome.sweep_error
    );
    assert!(outcome.refused.is_empty());
}

/// Deleting takes the undo record as well as the log.
///
/// They are two files with two lifetimes: a session's log is what it
/// captured, its restore log is what was handed *to* it. Leaving the second
/// behind strands a GC root, because the sweep reads every file under
/// `restores/` without asking whether the session named still exists.
#[test]
fn deleting_takes_the_undo_record_with_the_log() {
    let fx = Fixture::new();
    fx.write("a.txt", "one");
    fx.capture(S, "turn-1");

    let store = fx.store();
    let target = store.target_for_turn("turn-1").unwrap().unwrap();
    store
        .restore_to(
            S,
            &target,
            RestoreKind::Rewind { undo_for: Some(S) },
            fx.restore_scope(S),
            &no_rules(),
        )
        .unwrap();
    assert!(store.last_restore_target(S).unwrap().is_some());

    store.delete_sessions(&[S.to_string()]);

    assert!(!store.session_exists(S));
    assert_eq!(
        store.last_restore_target(S).unwrap(),
        None,
        "the undo record goes too, or it pins its manifests as a root for good"
    );
}

#[test]
fn an_empty_delete_touches_nothing() {
    let fx = Fixture::new();
    fx.write("a.txt", "one");
    fx.capture(S, "turn-1");

    let outcome = fx.store().delete_sessions(&[]);

    assert_eq!(outcome.reclaimed, GcStats::default());
    assert!(outcome.refused.is_empty());
    assert!(fx.store().session_exists(S));
}

/// The undo stack drops its **oldest** records when full.
///
/// Draining the wrong end is invisible in every ordinary scenario: fewer
/// rewinds than the cap and the two behave identically. Past the cap the
/// wrong end makes the next undo reverse the *first* rewind rather than the
/// most recent, discarding every state in between while reporting success.
#[test]
fn a_full_undo_stack_forgets_the_oldest_rewind_not_the_newest() {
    let fx = Fixture::new();
    fx.write("a.txt", "v0");
    let store = fx.store();
    fx.capture(S, "turn-0");
    let origin = store.target_for_turn("turn-0").unwrap().unwrap();

    // Each pass leaves a distinct state and rewinds it away, so the undo
    // record pushed on pass i is the only route back to "v{i}".
    let passes = crate::refs::MAX_RESTORE_HISTORY + 3;
    for i in 1..=passes {
        fx.write("a.txt", format!("v{i}"));
        store
            .restore_to(
                S,
                &origin,
                RestoreKind::Rewind { undo_for: Some(S) },
                fx.restore_scope(S),
                &no_rules(),
            )
            .unwrap();
    }
    assert_eq!(fx.read("a.txt"), "v0");

    // Undoing returns to the state the newest rewind replaced.
    let undo = store.last_restore_target(S).unwrap().unwrap();
    store
        .restore_to(
            S,
            &undo,
            RestoreKind::Undo { spending: S },
            fx.restore_scope(S),
            &no_rules(),
        )
        .unwrap();

    assert_eq!(
        fx.read("a.txt"),
        format!("v{passes}"),
        "the record on top is the newest rewind's, not one from beyond the cut"
    );
}

/// A turn resolves to the *last* thing written for it, so a supplemental
/// pre-edit attach becomes the state that turn restores to.
#[test]
fn a_turn_resolves_to_its_most_complete_capture() {
    let fx = Fixture::new();
    fx.write("a.txt", "one");
    let first = fx.capture(S, "turn-1");

    // A path the scan never covered, because it did not exist when the scan
    // ran. The edit hook extends the same turn with it.
    let late = fx.write("late.txt", "pre");
    let supplemental = fx
        .store()
        .attach_pre_edit(
            S,
            "turn-1",
            &late.to_string_lossy(),
            &PreEditImage::Existed(b"pre".to_vec()),
        )
        .unwrap()
        .expect("a path outside the scan attaches");

    assert_ne!(supplemental, first.id);
    assert_eq!(
        fx.store()
            .target_for_turn("turn-1")
            .unwrap()
            .unwrap()
            .manifest_id(),
        supplemental,
        "the turn resolves to the extended capture, not the original"
    );
}

#[test]
fn an_unknown_turn_resolves_to_nothing() {
    let fx = Fixture::new();
    assert_eq!(fx.store().target_for_turn("never-happened").unwrap(), None);
}

/// `attach_pre_edit` returning `Ok(None)` is an ordinary outcome, not a
/// failure: the scan already covered this path, so there is nothing to add.
#[test]
fn attaching_a_path_the_scan_already_covered_adds_nothing() {
    let fx = Fixture::new();
    let a = fx.write("a.txt", "one");
    fx.capture(S, "turn-1");

    assert_eq!(
        fx.store()
            .attach_pre_edit(
                S,
                "turn-1",
                &a.to_string_lossy(),
                &PreEditImage::Existed(b"one".to_vec()),
            )
            .unwrap(),
        None
    );
}

/// A path the turn created is tombstoned once, and the tombstone is what
/// licenses a rewind to remove it again.
#[test]
fn a_created_path_is_tombstoned_once() {
    let fx = Fixture::new();
    let store = fx.store();
    let born = fx.path("born.txt");
    let key = born.to_string_lossy().into_owned();

    let id = store
        .attach_pre_edit(S, "turn-1", &key, &PreEditImage::DidNotExist)
        .unwrap()
        .expect("a created path records that it did not exist");
    assert!(store.manifest(&id).unwrap().absent.contains(&key));

    assert_eq!(
        store
            .attach_pre_edit(S, "turn-1", &key, &PreEditImage::DidNotExist)
            .unwrap(),
        None,
        "the second attach says nothing the first did not"
    );
}

/// `tracked_paths` is the union of everything observed, tombstones included.
///
/// It is half of what builds a restore's safety scope, and a path missing
/// from it is a path no plan can ever delete: the safety capture never looks
/// there, so `current.entries` lacks it, and `plan_restore` needs both sides.
#[test]
fn tracked_paths_includes_what_was_looked_for_and_not_found() {
    let fx = Fixture::new();
    fx.write("present.txt", "here");
    fx.capture(S, "turn-1");

    let gone = fx.path("gone.txt").to_string_lossy().into_owned();
    fx.store()
        .attach_pre_edit(S, "turn-1", &gone, &PreEditImage::DidNotExist)
        .unwrap();

    let paths = fx.store().tracked_paths(S).unwrap();
    assert!(paths.contains(&fx.path("present.txt").to_string_lossy().into_owned()));
    assert!(
        paths.contains(&gone),
        "a tombstone is an observation, and the safety scope needs it"
    );
}

/// Disk usage reports this workspace's records, not the content they name.
///
/// Content is shared with every other workspace, so charging it to one would
/// report the same bytes once per reference — a dashboard that adds up to
/// several times the true size.
#[test]
fn disk_usage_measures_records_rather_than_content() {
    let fx = Fixture::new();
    fx.write("big.txt", "x".repeat(200_000));
    fx.capture(S, "turn-1");

    let records = fx.store().records_disk_usage().unwrap();
    assert!(records > 0, "the manifest and log are real files");
    assert!(
        records < 200_000,
        "the 200 kB of content is not charged to the partition: {records}"
    );
}

/// Inheriting a log copies entries through the named turn and nothing after.
#[test]
fn inheriting_a_log_stops_at_the_named_turn() {
    let fx = Fixture::new();
    let store = fx.store();
    for i in 0..4 {
        fx.write("a.txt", format!("v{i}"));
        fx.capture(S, &format!("turn-{i}"));
    }

    assert_eq!(store.inherit_log(S, "fork", "turn-1").unwrap(), 2);
    let inherited: Vec<String> = store
        .thread_history("fork")
        .unwrap()
        .into_iter()
        .map(|(entry, _)| entry.turn_id)
        .collect();
    assert_eq!(inherited, vec!["turn-0".to_string(), "turn-1".to_string()]);
}

/// A fork from a turn the source never had inherits nothing — but still
/// exists.
///
/// The distinction matters to `session_exists`, which is how a caller tells a
/// session that has captured nothing yet from one that was never started.
#[test]
fn a_fork_from_an_unknown_turn_is_empty_but_real() {
    let fx = Fixture::new();
    fx.write("a.txt", "one");
    fx.capture(S, "turn-1");

    let store = fx.store();
    assert_eq!(store.inherit_log(S, "fork", "never-happened").unwrap(), 0);
    assert!(store.session_exists("fork"));
    assert!(store.thread_history("fork").unwrap().is_empty());
}

/// Nothing has moved right after a rewind, so there is no conflict to report.
/// An undo with nothing to undo is likewise quiet rather than an error.
#[test]
fn undo_conflicts_are_empty_when_nothing_moved() {
    let fx = Fixture::new();
    fx.write("a.txt", "one");
    fx.capture(S, "turn-1");
    let store = fx.store();

    assert!(
        store.undo_conflicts(S, &no_rules()).unwrap().is_empty(),
        "no rewind, nothing to conflict with"
    );

    fx.write("a.txt", "two");
    let target = store.target_for_turn("turn-1").unwrap().unwrap();
    store
        .restore_to(
            S,
            &target,
            RestoreKind::Rewind { undo_for: Some(S) },
            fx.restore_scope(S),
            &no_rules(),
        )
        .unwrap();

    assert!(store.undo_conflicts(S, &no_rules()).unwrap().is_empty());
}

/// A file changed after the rewind is reported, because undoing would
/// overwrite that change without mentioning it.
///
/// The undo records are per-session but the files are not, so this is the
/// only thing standing between a concurrent edit and silent loss.
#[test]
fn a_change_made_after_a_rewind_is_reported_as_a_conflict() {
    let fx = Fixture::new();
    fx.write("a.txt", "one");
    fx.capture(S, "turn-1");
    let store = fx.store();

    fx.write("a.txt", "two");
    let target = store.target_for_turn("turn-1").unwrap().unwrap();
    store
        .restore_to(
            S,
            &target,
            RestoreKind::Rewind { undo_for: Some(S) },
            fx.restore_scope(S),
            &no_rules(),
        )
        .unwrap();

    // Somebody else edits the file the rewind just wrote.
    fx.write("a.txt", "three");

    assert_eq!(
        store.undo_conflicts(S, &no_rules()).unwrap(),
        vec![fx.path("a.txt").to_string_lossy().into_owned()]
    );
    assert!(
        store
            .undo_conflicts(S, &rules_for(fx.workspace(), "a.txt"))
            .unwrap()
            .is_empty(),
        "a protected path is not a conflict, because an undo would not touch it"
    );
}

/// A declared path is still watched by a **new** tracker on the same session.
///
/// This is D25's whole point. The set used to live only in memory, so a
/// session resuming in another process silently stopped watching everything
/// it had edited — silently, because the manifests already written stayed
/// perfectly valid. What was lost was future observation.
#[test]
fn a_declared_path_survives_the_process_that_declared_it() {
    let fx = Fixture::new();
    let outside = fx.path("declared-by-edit.txt");
    std::fs::write(&outside, "one").unwrap();

    fx.store()
        .declare_paths(S, "turn-1", std::slice::from_ref(&outside))
        .unwrap();

    // A fresh handle is what a resumed session gets.
    assert!(fx.store().declared_paths(S).unwrap().contains(&outside));
}

/// It is still in the safety scope after ageing out of the window.
///
/// The window governs what future captures *watch*, never what a restore may
/// touch. A path missing from `tracked_paths` is one no plan can ever remove,
/// so ageing out must not quietly make a file unremovable.
#[test]
fn a_path_past_the_window_is_still_in_the_safety_scope() {
    let fx = Fixture::new();
    let old = fx.path("edited-long-ago.txt");
    let store = fx.store();
    store
        .declare_paths(S, "turn-0", std::slice::from_ref(&old))
        .unwrap();
    for i in 1..=crate::declared::DECLARED_WINDOW_TURNS {
        store
            .declare_paths(S, &format!("turn-{i}"), &[fx.path("recent.txt")])
            .unwrap();
    }

    assert!(
        !store.declared_paths(S).unwrap().contains(&old),
        "no longer watched"
    );
    assert!(
        store
            .tracked_paths(S)
            .unwrap()
            .contains(&old.to_string_lossy().into_owned()),
        "but still observed, so a restore can still act on it"
    );
}

/// Deleting a session takes its declared set with it — a third file under a
/// third lifetime, and one left behind keeps naming paths nothing owns.
#[test]
fn deleting_a_session_drops_its_declared_set() {
    let fx = Fixture::new();
    fx.write("a.txt", "one");
    fx.capture(S, "turn-1");
    let store = fx.store();
    store
        .declare_paths(S, "turn-1", &[fx.path("edited.txt")])
        .unwrap();

    store.delete_sessions(&[S.to_string()]);
    assert_eq!(store.declared_paths(S).unwrap(), Default::default());
}

/// **An ordinary rewind-then-undo reports no conflicts.**
///
/// A file the rewind *recreated* is absent in the safety capture and present
/// now, which the conflict check read as "someone put this back" — on every
/// round trip, for the file the undo was about to remove on purpose. Crying
/// wolf trains the reader to ignore the warning, and then the one real
/// conflict is ignored too, which is worse than not warning at all.
#[test]
fn a_clean_round_trip_reports_nothing_moved() {
    let fx = Fixture::new();
    fx.write("kept.txt", "v1");
    fx.write("deleted-later.txt", "here");
    fx.capture(S, "turn-1");

    // The agent's turn: change one file, remove another, add a third.
    fx.write("kept.txt", "v2");
    fx.remove("deleted-later.txt");
    fx.write("added.txt", "new");

    let store = fx.store();
    let target = store.target_for_turn("turn-1").unwrap().unwrap();
    store
        .restore_to(
            S,
            &target,
            RestoreKind::Rewind { undo_for: Some(S) },
            fx.restore_scope(S),
            &no_rules(),
        )
        .unwrap();
    assert_eq!(
        fx.read("deleted-later.txt"),
        "here",
        "the rewind put it back"
    );

    assert_eq!(
        store.undo_conflicts(S, &no_rules()).unwrap(),
        Vec::<String>::new(),
        "nothing moved; the rewind's own work was reported as a conflict"
    );
}

/// And the check still fires on a real one: a file the rewind recreated, which
/// somebody then edited.
#[test]
fn editing_what_the_rewind_recreated_is_a_real_conflict() {
    let fx = Fixture::new();
    fx.write("a.txt", "original");
    fx.capture(S, "turn-1");
    fx.remove("a.txt");

    let store = fx.store();
    let target = store.target_for_turn("turn-1").unwrap().unwrap();
    store
        .restore_to(
            S,
            &target,
            RestoreKind::Rewind { undo_for: Some(S) },
            fx.restore_scope(S),
            &no_rules(),
        )
        .unwrap();

    // Somebody edits the file the rewind put back. An undo would delete it.
    fx.write("a.txt", "someone else's work");

    assert_eq!(
        store.undo_conflicts(S, &no_rules()).unwrap(),
        vec![fx.path("a.txt").to_string_lossy().into_owned()]
    );
}