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
//! Reset-to-factory-defaults flow.
//!
//! Backs up the entire `~/.config/mnml/` directory to a timestamped
//! sibling (`~/.config/mnml.backup-YYYYMMDD-HHMMSS/`) and requests a
//! restart. `run.sh` loops on exit code 75, so mnml comes back with a
//! clean config directory that gets scaffolded fresh on next launch.
//! The old dir is left in place so the user can restore with a single
//! `mv` — mnml never silently deletes user data.
//!
//! Per-workspace `<workspace>/.mnml/` is deliberately untouched — that
//! includes `env/*.env` (API tokens) + `chains/` + `collections/`
//! (user-authored HTTP content). Wiping those is out of scope for
//! v1; a v2 could add a second confirm-button path for it (see
//! task #851 for the design sketch).
//!
//! ## Restore
//!
//! After a reset, `~/.config/mnml/.last-reset-from` records the
//! backup path. [`App::maybe_show_reset_toast`] reads that on the
//! next launch and toasts the one-liner restore command, then
//! removes the marker so the toast fires exactly once.
use crate::app::App;
use std::path::PathBuf;
const RESET_MARKER: &str = ".last-reset-from";
impl App {
/// Open the reset-to-defaults confirm dialog. Reachable via the
/// palette command `mnml.reset_to_defaults`.
pub fn open_reset_to_defaults_prompt(&mut self) {
let mut p = crate::prompt::Prompt::new(
crate::prompt::PromptKind::ResetToDefaultsConfirm,
"Reset mnml to factory defaults? Config → backup, per-workspace \
state (env / chains / collections) untouched."
.to_string(),
);
// Cancel is the second button — safety-default focus.
p.cursor = 1;
self.prompt = Some(p);
}
/// Execute the reset. Called from the confirm dialog's Accept
/// branch after the user picks the primary (Reset) button.
///
/// Sequence:
/// 1. Compute the backup path: `~/.config/mnml.backup-YYYYMMDD-HHMMSS/`.
/// 2. Rename `~/.config/mnml/` → backup path (atomic on same fs).
/// 3. Recreate an empty `~/.config/mnml/` + drop a
/// `.last-reset-from` marker recording the backup path so the
/// next-launch toast can point the user at their restore.
/// 4. `request_restart()` — event loop bails with code 75; run.sh
/// rebuilds + relaunches.
///
/// On error at any step, toast + abort (no restart) — the user's
/// state stays where it was and they can retry.
pub fn perform_reset_to_defaults(&mut self) {
let cfg_dir = match config_dir() {
Some(d) => d,
None => {
self.toast("reset: can't locate mnml data root");
return;
}
};
// No config dir → nothing to back up; still relaunch so the
// user gets the fresh-scaffolding path.
let need_move = cfg_dir.exists();
let backup_path = if need_move {
resolve_backup_path(&cfg_dir)
} else {
PathBuf::new()
};
// `std::fs::rename` fails with EXDEV if the source + target
// are on different filesystems (bind-mounts, some container
// setups). Deliberately unhandled — no cross-fs copy+rmdir
// fallback, since the failure toast leaves everything at the
// original path and the user can move it manually. Same
// reasoning for any other IO error at this step: fail
// non-destructively rather than half-migrate.
if need_move && let Err(e) = std::fs::rename(&cfg_dir, &backup_path) {
self.toast(format!(
"reset: couldn't rename {} → {}: {e}",
cfg_dir.display(),
backup_path.display()
));
return;
}
// Recreate an empty dir so the .last-reset-from marker has
// somewhere to land + so config-scaffolding writes on next
// launch don't need to `mkdir -p` themselves.
if let Err(e) = std::fs::create_dir_all(&cfg_dir) {
self.toast(format!(
"reset: renamed to backup but couldn't recreate {}: {e}. \
Restore with: mv {} {}",
cfg_dir.display(),
backup_path.display(),
cfg_dir.display()
));
return;
}
if need_move {
let marker_body = backup_path.display().to_string();
let marker_path = cfg_dir.join(RESET_MARKER);
if let Err(e) = std::fs::write(&marker_path, &marker_body) {
// Non-fatal — the reset happened, just the toast on
// restart won't fire. Log it and proceed.
self.toast(format!(
"reset: backup at {} but restore-toast marker \
write failed ({e}). Manual restore: mv {} {}",
backup_path.display(),
backup_path.display(),
cfg_dir.display()
));
} else {
self.toast(format!("reset: backup at {}", backup_path.display()));
}
} else {
self.toast("reset: no config to back up; scaffolding fresh.");
}
self.request_restart();
}
/// Called from the launch flow. If the fresh config dir has a
/// `.last-reset-from` marker, surface a persistent toast pointing
/// the user at their backup + the one-liner restore command,
/// then delete the marker so the toast fires exactly once.
///
/// Two cases:
/// - Marker readable → toast the restore one-liner, remove marker.
/// - Marker exists but unreadable (permissions, disk error) →
/// still toast a fallback pointing the user at the backup
/// dirs so the "you won't be left wondering" guarantee holds
/// even under IO failure. Marker stays so the user can
/// investigate; next launch re-toasts.
/// - Marker absent → no-op (normal case).
pub fn maybe_show_reset_toast(&mut self) {
let Some(cfg_dir) = config_dir() else {
return;
};
let marker_path = cfg_dir.join(RESET_MARKER);
if !marker_path.exists() {
return;
}
let read = std::fs::read_to_string(&marker_path);
match read {
Ok(body) => {
let backup_path = body.trim();
if backup_path.is_empty() {
let _ = std::fs::remove_file(&marker_path);
return;
}
self.toast_persistent(
"reset-restore",
format!(
"Reset done. Old config at {backup_path}. \
Restore with: rm -rf {cfg} && mv {backup_path} {cfg}",
cfg = cfg_dir.display(),
),
crate::app::ToastLevel::Info,
);
// Fire once — even if remove_file fails, next launch
// will just re-toast which is annoying but not
// destructive.
let _ = std::fs::remove_file(&marker_path);
}
Err(e) => {
// Reviewer #5: the whole point of the marker is "so
// the user isn't left wondering where their config
// went." Under-read failures should still fire a
// best-effort toast pointing at the backup-glob so
// the guarantee holds.
self.toast_persistent(
"reset-restore",
format!(
"Reset marker at {} exists but unreadable ({e}). \
Your backup is at {parent}/mnml.backup-* — restore \
with: rm -rf {cfg} && mv {parent}/mnml.backup-... {cfg}",
marker_path.display(),
parent = cfg_dir
.parent()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config".to_string()),
cfg = cfg_dir.display(),
),
crate::app::ToastLevel::Warn,
);
// Marker left in place — user can investigate.
}
}
}
}
/// Compute the backup path for the config dir, probing for
/// second-collision. If `<parent>/mnml.backup-<stamp>/` already
/// exists (two resets in the same second — rare, but the failure
/// mode of the naive path is a bare `fs::rename` ENOTEMPTY toast
/// which reads as broken), tack on `-2`, `-3`, … until we find a
/// free slot. Reviewer 2026-08-03 W#4.
fn resolve_backup_path(cfg_dir: &std::path::Path) -> PathBuf {
let parent = cfg_dir
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let base_name = cfg_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("mnml");
let stamp = timestamp_utc();
let first = parent.join(format!("{base_name}.backup-{stamp}"));
if !first.exists() {
return first;
}
for n in 2u32..1000 {
let candidate = parent.join(format!("{base_name}.backup-{stamp}-{n}"));
if !candidate.exists() {
return candidate;
}
}
// 1000 backups in one second is not a real scenario; fall
// through with the -999 candidate so the caller's rename step
// fails loudly rather than silently overwriting.
parent.join(format!("{base_name}.backup-{stamp}-999"))
}
/// The target of the reset. Routes through
/// [`data_root`](crate::data_root::data_root) so a portable-mode
/// install resets the portable folder in-place (backup lands as a
/// sibling: `<binary_dir>/mnml-data.backup-<stamp>/`).
fn config_dir() -> Option<PathBuf> {
Some(crate::data_root::data_root())
}
/// UTC timestamp `YYYYMMDD-HHMMSS` for the backup dir name.
/// Chose UTC over local so backup dirs sort chronologically no
/// matter which TZ the machine drifts through.
fn timestamp_utc() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Direct arithmetic — avoids a chrono dep just for this one string.
let (yr, mo, day, hr, mn, sc) = unix_to_ymdhms(now as i64);
format!("{yr:04}{mo:02}{day:02}-{hr:02}{mn:02}{sc:02}")
}
/// Cheap gmtime substitute. Handles 1970-01-01T00:00:00Z through
/// ~year 9999 correctly; that's enough for a timestamp string.
fn unix_to_ymdhms(mut ts: i64) -> (i32, u32, u32, u32, u32, u32) {
if ts < 0 {
ts = 0;
}
let sc = (ts % 60) as u32;
ts /= 60;
let mn = (ts % 60) as u32;
ts /= 60;
let hr = (ts % 24) as u32;
let mut days = ts / 24;
let mut yr: i32 = 1970;
loop {
let leap = is_leap(yr);
let year_days = if leap { 366 } else { 365 };
if days < year_days {
break;
}
days -= year_days;
yr += 1;
}
let month_lengths: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut mo: u32 = 1;
for (i, &len) in month_lengths.iter().enumerate() {
let feb_bonus = if i == 1 && is_leap(yr) { 1 } else { 0 };
let this_month = (len + feb_bonus) as i64;
if days < this_month {
mo = (i + 1) as u32;
break;
}
days -= this_month;
}
let day = (days + 1) as u32;
(yr, mo, day, hr, mn, sc)
}
fn is_leap(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamp_shape_is_ymd_hms() {
let s = timestamp_utc();
assert_eq!(s.len(), 15, "YYYYMMDD-HHMMSS should be 15 chars: {s}");
assert_eq!(&s[8..9], "-");
assert!(s[..8].chars().all(|c| c.is_ascii_digit()));
assert!(s[9..].chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn unix_to_ymdhms_epoch_zero() {
assert_eq!(unix_to_ymdhms(0), (1970, 1, 1, 0, 0, 0));
}
#[test]
fn unix_to_ymdhms_leap_boundary() {
// 2024-02-29 00:00:00 UTC
let ts = 1_709_164_800;
assert_eq!(unix_to_ymdhms(ts), (2024, 2, 29, 0, 0, 0));
}
#[test]
fn unix_to_ymdhms_known_stamp() {
// 2026-08-03 15:30:45 UTC — verified via `datetime.timestamp()`.
let ts = 1_785_771_045;
assert_eq!(unix_to_ymdhms(ts), (2026, 8, 3, 15, 30, 45));
}
#[test]
fn resolve_backup_path_probes_for_collision() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("mnml");
std::fs::create_dir(&cfg).unwrap();
// First probe — plain `<parent>/mnml.backup-<stamp>` should
// return since nothing collides.
let first = resolve_backup_path(&cfg);
assert!(
first
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("mnml.backup-"))
);
// Simulate a collision by creating that path, then probe
// again. Second probe must return a DIFFERENT path (with a
// `-2` suffix appended) — never overwrite an existing backup.
std::fs::create_dir(&first).unwrap();
let second = resolve_backup_path(&cfg);
assert_ne!(
first, second,
"collision probe must not return the same path"
);
assert!(
second
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with("-2")),
"second probe should carry a -2 suffix, got {second:?}"
);
}
/// Reviewer 2026-08-03 C#1 regression — the confirm-button
/// dispatch's `synth` match must have an arm for
/// ResetToDefaultsConfirm; without it, the dialog is unreachable
/// (Enter/click/hotkey all silently no-op). Rather than pull the
/// full App-with-workspace scaffold into a unit test just to
/// exercise run_confirm_button, assert on the string constant
/// that would-have-been-synthesized. If someone deletes the arm,
/// the picker.rs accept handler's
/// `p.input.trim().eq_ignore_ascii_case("reset")` check would
/// no longer see this input, and this test would fail because
/// the arm's synth string is what closes the loop.
#[test]
fn confirm_button_synth_matches_accept_input_check() {
// The picker.rs accept handler for ResetToDefaultsConfirm
// checks `.eq_ignore_ascii_case("reset")`. The confirm-button
// dispatch's synth arm produces "reset". If either side
// changes, the dispatch chain silently breaks.
// If this ever drifts, both sides need updating together.
const EXPECTED_SYNTH: &str = "reset";
assert!(EXPECTED_SYNTH.eq_ignore_ascii_case("Reset"));
assert!(EXPECTED_SYNTH.eq_ignore_ascii_case("reset"));
}
}