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
//! Atomic persistence for the SM conversation state file (DOC-14 §7.4).
//!
//! Why: §7.4 requires the live conversation (`compressed_context` + verbatim
//! `recent_rounds` + counters) to persist to a daemon state file at
//! `~/.trusty-mpm/sm/conversation-<conv_id>.json` so a conversation survives a
//! daemon restart intact (the connection-safe restart convention, CLAUDE.md
//! #534). The write must be ATOMIC — a crash mid-write must never leave a
//! truncated, unparseable state file — so we write to a temp file in the same
//! directory and rename it over the target (rename is atomic on the same
//! filesystem). The storage ROOT is injectable so tests use a `tempdir` instead
//! of touching the real home directory, mirroring SM-4's pattern.
//! What: [`ConversationStore`] owns a root directory and maps a `conv_id` to its
//! JSON path; [`ConversationStore::save`] serialises an [`SmConversation`] via
//! write-tmp-then-rename, and [`ConversationStore::load`] reconstructs it. A
//! missing file loads as a fresh conversation (so first-touch is not an error).
//! Errors are a structured `thiserror` enum (library convention).
//! Test: `persist_tests.rs` covers round-trip identity, atomic overwrite, the
//! missing-file → fresh path, conv-id filename sanitisation, and a malformed-file
//! error.
use ;
use ;
use ;
use SmConversation;
/// Process-wide monotonic counter that uniquifies temp-file names per `save`.
///
/// Why: two `save` calls for the SAME `conv_id` in the same process (and within
/// the same nanosecond, or on a coarse clock) would otherwise generate identical
/// temp paths and race — one write could clobber the other's temp file mid-flight
/// and produce a torn read before either rename. A monotonic counter combined
/// with the wall-clock nanos guarantees a distinct temp path for every call.
/// What: incremented once per `save`; the value is mixed into the temp filename.
/// Test: `concurrent_saves_use_distinct_temp_paths` (distinct paths) and
/// `concurrent_saves_same_conv_id_do_not_corrupt` (no torn file).
static TEMP_FILE_COUNTER: AtomicU64 = new;
/// Subdirectory under the data root that holds SM conversation state files.
///
/// Why: §7.4 nests state files under `~/.trusty-mpm/sm/`; isolating the segment
/// keeps it consistent with the SM memory subtree and easy to change.
/// What: the `"sm"` path segment joined under the injected root.
/// Test: `store_path_is_under_sm_subdir`.
pub const SM_STATE_SUBDIR: &str = "sm";
/// Structured errors for conversation persistence (library → `thiserror`).
///
/// Why: SM-5 is library code; per workspace convention I/O and (de)serialisation
/// failures surface as a typed, matchable error rather than `unwrap()`/`panic!`.
/// A failed save must not crash the daemon — the caller logs and proceeds.
/// What: distinguishes filesystem failures (`Io`) from JSON (de)serialisation
/// failures (`Serde`), each tagged with the `conv_id` for actionable logs.
/// Test: `load_rejects_malformed_file` asserts the `Serde` variant; happy-path
/// tests assert `Ok`.
/// Result alias for conversation-store operations.
pub type ConversationStoreResult<T> = Result;
/// Atomic, root-injectable store for SM conversation state files (§7.4).
///
/// Why: centralises the path layout and the write-tmp-then-rename atomicity in
/// one tested type, and makes the storage root a constructor argument so tests
/// never write to `~/.trusty-mpm`. The daemon (SM-7) builds it once from the real
/// data dir; tests build it from a `tempdir`.
/// What: holds the `<root>/sm/` directory under which each conversation persists
/// as `conversation-<conv_id>.json`. The `conv_id` is sanitised into a safe
/// filename so an adversarial id can't escape the directory.
/// Test: `persist_tests.rs`.
/// Wall-clock nanoseconds since the Unix epoch, for temp-file uniqueness.
///
/// Why: combined with the pid and a process-monotonic counter, the nanosecond
/// timestamp makes a temp filename overwhelmingly unlikely to collide across
/// processes; the counter handles the (common) case where two same-process saves
/// land in the same nanosecond on a coarse clock.
/// What: returns `SystemTime::now()` as nanos since the epoch, falling back to `0`
/// if the clock is somehow before the epoch (the counter still guarantees
/// per-call uniqueness within a process, so a `0` fallback is safe).
/// Test: exercised indirectly by `concurrent_saves_use_distinct_temp_paths`.
/// Sanitise a conversation id into a safe single-path-segment filename stem.
///
/// Why: a `conv_id` is operator/caller-supplied; without sanitisation a value
/// like `../../etc/x` or one containing path separators could write outside the
/// `sm/` directory. Restricting to a conservative character set makes path
/// traversal impossible by construction.
/// What: keeps ASCII alphanumerics, `-`, and `_`; replaces every other byte with
/// `_`. An empty result falls back to `"default"` so there is always a filename.
/// Test: `conv_id_is_sanitised`.