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
//! Preserve a malformed `settings.json` before a writer replaces it.
//!
//! Why (#7780): three `prepare_session` writers read
//! `<project>/.claude/settings.json`, coerced anything that did not parse as a
//! JSON object into an empty `{}`, and wrote that back. A file holding
//! `{ broken` therefore came back as tm's own keys alone — every hand-written
//! `permissions`, `env` and `statusLine` entry gone, with no warning and no copy
//! of what had been there. Simply SKIPPING a malformed file was rejected: the
//! project would then launch with no PM-guard hook (#1977). The owner ruled
//! backup-then-rewrite (2026-09-13), so the bytes are preserved and the rewrite
//! still happens.
//! What: one loader those writers call in place of their inline
//! read-parse-or-`{}`. A JSON object is returned as read. Anything else — an
//! unparseable file, or a valid JSON array/string/number — has its original
//! bytes copied to a timestamped `settings.json.malformed-<stamp>` sibling
//! first, is reported at `warn` naming that copy, and the caller then rewrites
//! from `{}`. A whitespace-only file is the one exception — it holds nothing to
//! preserve, so it is treated as absent (#7789). A file that cannot be read at
//! all, or a copy that cannot be written, is an error: a writer never replaces
//! bytes it could not preserve.
//!
//! #7789: the managed-tier writers
//! [`crate::core::standalone::settings_defaults::ensure_settings_defaults`] and
//! `standalone::hooks::write_project_hooks_with` carried the same
//! coerce-to-`{}` read over `<claude_config_dir>/settings.json`, so they call
//! this loader too rather than growing a second copy of the rule.
//! `write_project_hooks_with` is tier-agnostic — it writes whatever settings
//! path it is handed — so the conversion also covers `tm launch`, which routes
//! a managed clone's PROJECT `<clone>/.claude/settings.json` through the same
//! function (`commands::install::write_project_hooks_for_dir`, called from
//! `commands::launch`).
//! Test: `malformed_backup_tests.rs`, `tests_malformed_settings_7780.rs`,
//! `core::standalone::tests_malformed_settings_7789`.
use io;
use ;
use ;
use Value;
use PrepError;
/// The infix every preserved copy carries between the file name and the stamp.
///
/// Why: the name has to read as "this is what your settings file used to be",
/// and must not collide with the `.bak` snapshots
/// [`crate::core::standalone::hooks::backup`] prunes — a malformed original is
/// the one copy that must survive indefinitely.
/// What: `malformed`, rendered as `settings.json.malformed-<stamp>`.
/// Test: `backs_up_unparseable_bytes_under_a_stamped_name`.
const MALFORMED_INFIX: &str = "malformed";
/// How many copies one wall-clock second may hold before the claim gives up.
///
/// Why: the stamp is second-precision, so two rewrites inside one second would
/// collide; the `-N` suffix disambiguates rather than overwriting the first
/// copy. A bound keeps a directory that somehow cannot accept a new file from
/// spinning forever. Mirrors `backup::MAX_SAME_SECOND_SNAPSHOTS`.
/// What: `1000`.
/// Test: `never_overwrites_a_copy_taken_in_the_same_second`.
const MAX_SAME_SECOND_BACKUPS: u32 = 1000;
/// Load the settings object a writer is about to replace, preserving it first
/// when it is not a JSON object.
///
/// Why: see the module doc — this is the whole of #7780's behaviour, in one
/// place, because five writers across two config tiers share the rule and five
/// copies of it is how four of them would keep the old silent-overwrite.
/// What: `{}` for an absent or whitespace-only file (the caller creates it); the
/// parsed value for a JSON object; otherwise the original bytes are copied aside
/// and `{}` is returned so the caller rewrites from scratch. Returns
/// [`PrepError::SettingsBackup`] — and writes nothing — when the file exists but
/// cannot be read, or when the copy cannot be written. `pub(crate)` for the
/// managed-tier callers named in the module doc (#7789); they map the error at
/// their own call site rather than forking the loader.
/// Test: `returns_a_json_object_untouched`, `treats_a_missing_file_as_empty`,
/// `treats_a_whitespace_only_file_as_empty`,
/// `backs_up_unparseable_bytes_under_a_stamped_name`,
/// `backs_up_a_valid_non_object`, `refuses_when_the_copy_cannot_be_written`.
pub
/// Preserve the file a writer is about to replace, without taking its contents.
///
/// Why: a writer that already holds the object it means to write still owes the
/// copy — [`crate::core::session_launch::settings::write_enabled_plugins_with_trust`]
/// planned its merge from a non-object file that was silently discarded. Calling
/// [`load_settings_object`] for its side effect alone reads as dead code to the
/// next editor; DELETING THIS CALL RESTORES #7780's SILENT OVERWRITE for that
/// writer, so the name says what the call is for.
/// What: [`load_settings_object`] with the loaded value dropped — the copy, the
/// warning and the fail-closed refusal are the whole of the contract.
/// Test: `write_enabled_plugins_backs_up_a_malformed_file_before_rewriting_it`,
/// `preserve_if_malformed_copies_aside_and_refuses_when_it_cannot`.
pub
/// [`load_settings_object`] with the clock supplied by the caller.
///
/// Why: the stamp is the copy's identity, so the naming and same-second
/// collision tests need to pin it — reading the real clock would make them race
/// a second boundary. Mirrors `backup::snapshot_then_prune_at`.
/// What: as [`load_settings_object`]; `now` supplies the `YYYYMMDDTHHMMSSZ`
/// stamp. Production reads `Utc::now()` one frame up.
/// Test: `never_overwrites_a_copy_taken_in_the_same_second`.
/// Copy `bytes` to the first free `<name>.malformed-<stamp>` sibling of `settings_path`.
///
/// What: claims the name with an exclusive create (so two concurrent launches
/// cannot both take it), then fills it. A failed fill removes the empty file it
/// just published — an empty copy would read as a valid, and wrong, record of
/// what the file held.
/// Test: `backs_up_unparseable_bytes_under_a_stamped_name`,
/// `never_overwrites_a_copy_taken_in_the_same_second`.
/// Create — exclusively — the first free copy name for `stamp`.
///
/// Why: `exists()` then `write` is a race two launches can lose together, and
/// the loser's copy would be silently overwritten. `create_new` makes claiming
/// the name the same operation as checking it.
/// What: tries `<name>.malformed-<stamp>`, then `<name>.malformed-<stamp>-1`, …
/// up to [`MAX_SAME_SECOND_BACKUPS`]. An `AlreadyExists` moves to the next
/// candidate; any other error is the caller's. A directory sitting on a
/// candidate name also reports `AlreadyExists`, so it is stepped over rather
/// than treated as a failure.
/// Test: `never_overwrites_a_copy_taken_in_the_same_second`,
/// `refuses_when_the_copy_cannot_be_written`.
/// Build the copy path for `settings_path` at `stamp`, `attempt` collisions in.
///
/// What: `attempt` 0 is the bare `<name>.malformed-<stamp>`; every later one
/// appends `-<attempt>` to the stamp.
/// Test: `never_overwrites_a_copy_taken_in_the_same_second`.
/// A fresh empty JSON object — what a writer starts from when nothing usable
/// was on disk.