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
// SPDX-License-Identifier: MIT OR Apache-2.0
//! v0.1.21 GAP-2026-014 v2: property test for backup cleanup idempotence.
//!
//! `delete_backup_quietly` must be idempotent: calling it on a
//! non-existent path is a no-op (returns Ok(())), and calling it
//! multiple times on the same path always returns Ok(()). This
//! property is exercised by running `write --backup` multiple times
//! (which internally calls `delete_backup_quietly` after each
//! successful write) and verifying that no .bak files accumulate.
mod common;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(5))]
/// Property: running `write --backup` N times (1..=5) must not
/// accumulate .bak files, because each successful write cleans up
/// its backup via `delete_backup_quietly`. This holds regardless
/// of the content written.
#[test]
fn delete_backup_quietly_is_idempotent(
iterations in 1usize..=5,
content in "\\PC{1,256}"
) {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("prop_target.txt");
std::fs::write(&target, "initial\n").expect("seed");
for i in 0..iterations {
let payload = format!("{content}\n{i}\n");
let output = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"write",
"--backup",
"--retention",
"3",
])
.arg(&target)
.write_stdin(payload.as_bytes())
.output()
.expect("write");
prop_assert!(
output.status.success(),
"write iteration {i} should succeed, stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
// After all iterations, no .bak files should remain.
let bak_entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("readdir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("prop_target.txt.bak."))
})
.collect();
prop_assert!(
bak_entries.is_empty(),
"no .bak.* files should accumulate after {} write iterations, found: {:?}",
iterations,
bak_entries.iter().map(|e| e.file_name()).collect::<Vec<_>>()
);
}
/// Property: writing to a path that has no pre-existing .bak
/// (i.e., a non-existent target) is trivially a no-op for the
/// backup cleanup path. The write succeeds, no .bak is created,
/// and `delete_backup_quietly` is never even called.
#[test]
fn write_new_file_creates_no_backup(
filename_suffix in "[a-z]{1,8}",
content in "\\PC{1,128}"
) {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join(format!("new_{filename_suffix}.txt"));
let output = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"write",
"--backup",
"--retention",
"3",
])
.arg(&target)
.write_stdin(content.as_bytes())
.output()
.expect("write");
prop_assert!(
output.status.success(),
"write to new file should succeed, stderr={}",
String::from_utf8_lossy(&output.stderr)
);
// No .bak should be created when the target did not pre-exist.
let bak_entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("readdir")
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
name.to_str()
.is_some_and(|n| n.starts_with(&format!("new_{filename_suffix}.txt.bak.")))
})
.collect();
prop_assert!(
bak_entries.is_empty(),
"no .bak.* should be created for new file, found: {:?}",
bak_entries.iter().map(|e| e.file_name()).collect::<Vec<_>>()
);
}
}
// v0.1.22 — additional property tests gated behind `slow-tests` feature.
// These run 20 cases each and exercise sequential-drift and backup retention
// invariants of the new edit-loop / backup-keep combinations.
/// Property: chaining N `edit` calls with the SAME initial checksum and the
/// `--allow-sequential-drift` flag must always succeed. This is the
/// v0.1.21 sequential-drift property ported to `--allow-sequential-drift`.
#[cfg(feature = "slow-tests")]
#[test]
fn allow_sequential_drift_accepts_any_sequence() {
use proptest::prelude::*;
proptest!(ProptestConfig::with_cases(20), |(
initial in "[a-z]{1,20}",
n_edits in 1u32..5,
)| {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("prop_chain.txt");
std::fs::write(&target, &initial).expect("seed");
// Capture initial checksum
let hash_out = common::atomwrite()
.args(["--workspace", dir.path().to_str().unwrap(), "hash"])
.arg(&target)
.output()
.expect("hash");
let initial_cs = common::parse_ndjson(&hash_out.stdout)[0]["checksum"]
.as_str()
.expect("value")
.to_string();
// Apply N sequential edits reusing the initial checksum
for i in 0..n_edits {
let new_val = format!("{initial}_v{i}");
let output = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"edit",
"--allow-sequential-drift",
"--old",
&initial,
"--new",
&new_val,
"--expect-checksum",
&initial_cs,
])
.arg(&target)
.output()
.expect("run");
prop_assert!(
output.status.success(),
"edit {i} must succeed with --allow-sequential-drift, stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
});
}
/// Property: `write --backup --keep-backup` N times (1..=5) MUST preserve
/// exactly N `.bak.*` files. This is the v0.1.21 GAP-014 v2 invariant
/// ("keep_backup preserves across N writes") from ADR-0038.
///
/// Note: backup filenames embed a timestamp with second-resolution
/// (`.bak.YYYYMMDD_HHMMSS`). When two writes fire within the same wall
/// clock second, they collide on the filename and the second write
/// overwrites the first backup. To make the property deterministic we
/// sleep just over one second between writes.
#[cfg(feature = "slow-tests")]
#[test]
fn keep_backup_preserves_across_n_writes() {
use proptest::prelude::*;
use std::time::Duration;
proptest!(ProptestConfig::with_cases(5), |(
n_writes in 1u32..4,
content in "[a-z]{1,10}",
)| {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("file.txt");
std::fs::write(&target, &content).expect("seed");
for i in 0..n_writes {
let payload = format!("{content}\nv{i}\n");
let output = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"write",
"--backup",
"--keep-backup",
])
.arg(&target)
.write_stdin(payload.as_bytes())
.output()
.expect("write");
prop_assert!(
output.status.success(),
"write {i} failed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
// Sleep >1s to guarantee the next backup filename differs.
std::thread::sleep(Duration::from_millis(1100));
}
let count: usize = std::fs::read_dir(dir.path())
.expect("readdir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("file.txt.bak."))
})
.count();
let expected = n_writes as usize;
prop_assert_eq!(
count,
expected,
"expected {} preserved backups, got {}",
expected,
count
);
});
}