dodot-lib 5.6.0

Core library for dodot dotfiles manager
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
//! Shell-hookup activation surfacing in `up`, `down` and `status`
//! (`docs/proposals/shell-hookup-ergonomics.lex` §2.3).
//!
//! The evidence ladder and the footer strings are unit-tested in
//! `shell::activation`; these tests pin the part users see — which
//! command carries the footer, and which state each situation reaches
//! through the real commands.

use standout_render::OutputMode;

use super::support::make_ctx;
use crate::commands::{status, up};
use crate::fs::Fs;
use crate::paths::Pather;
use crate::render;
use crate::shell::activation;
use crate::testing::TempEnvironment;

/// A pack with one shell source, so `up` has something to deploy and
/// the init script has something to say.
fn env_with_shell_pack() -> TempEnvironment {
    TempEnvironment::builder()
        .pack("vim")
        .file("aliases.sh", "alias vi=vim")
        .done()
        .build()
}

/// Stand in for a shell that sourced the init script at `generation`,
/// running this dodot: the heartbeat it would have left behind.
fn simulate_activation(env: &TempEnvironment, generation: u64) {
    simulate_activation_by(env, generation, activation::running_version());
}

/// The same, for a shell that sourced a script generated by some other
/// dodot — the version-skew shape.
fn simulate_activation_by(env: &TempEnvironment, generation: u64, version: &str) {
    env.fs.mkdir_all(&env.paths.probes_hookup_dir()).unwrap();
    env.fs
        .write_file(
            &env.paths.hookup_heartbeat_path(),
            format!("{generation} {version}").as_bytes(),
        )
        .unwrap();
}

/// The generation the init script on disk carries.
fn script_generation(env: &TempEnvironment) -> u64 {
    activation::read_script_generation(env.fs.as_ref(), env.paths.as_ref()).unwrap()
}

/// A stamp for a shell that sourced `generation` from this dodot.
fn live_stamp(generation: u64) -> activation::EnvStamp {
    activation::EnvStamp {
        generation: Some(generation),
        version: Some(activation::running_version().into()),
    }
}

#[test]
fn up_on_a_fresh_install_ends_with_the_never_activated_warning() {
    let env = env_with_shell_pack();
    let ctx = make_ctx(&env);

    let result = up::up(None, &ctx).unwrap();

    let notice = result
        .shell_hookup
        .expect("a green first up must not stay silent about the missing hookup");
    assert_eq!(notice.state, "never-activated");
    assert_eq!(notice.severity, "warning");
    assert_eq!(notice.evidence, "Never loaded.");
    let hint = notice.hint.expect("never-activated must say what to do");
    assert!(
        hint.contains("dodot-init.sh"),
        "hint must name the manual hook line: {hint}"
    );
}

/// INS01 §5 made a healthy hookup silent after `up`; the footer
/// deliberately supersedes that. A deploy that says nothing about
/// activation is the gap the evidence exists to close.
#[test]
fn up_from_a_live_shell_reports_the_healthy_footer() {
    let env = env_with_shell_pack();

    // First up: deploys and writes generation N.
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    simulate_activation(&env, generation);

    // Second up, from a shell that sourced generation N. The run
    // regenerates the script, but the verdict is judged against the
    // generation this shell could have loaded — otherwise every `up`
    // would tell the user their shell is stale.
    let mut ctx = make_ctx(&env);
    ctx.env_stamp = live_stamp(generation);
    let result = up::up(None, &ctx).unwrap();

    let notice = result.shell_hookup.expect("the footer is unconditional");
    assert_eq!(notice.state, "healthy");
    assert_eq!(
        notice.message,
        "Shell hookup: dodot is sourced in new shells."
    );
    assert_eq!(notice.hint, None);
}

#[test]
fn status_from_a_stale_shell_hints_at_opening_a_new_shell() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);

    // This terminal sourced an older generation and has been open
    // across the deploy.
    simulate_activation(&env, generation - 1);
    let mut ctx = make_ctx(&env);
    ctx.env_stamp = live_stamp(generation - 1);

    let notice = status::status(None, &ctx).unwrap().shell_hookup.unwrap();
    assert_eq!(notice.state, "stale-shell");
    assert_eq!(notice.severity, "info");
    assert_eq!(
        notice.message,
        "Shell hookup: this shell predates your last `dodot up`."
    );
    assert!(notice.hint.unwrap().contains("Open a new shell"));
}

#[test]
fn status_reports_the_healthy_footer_for_a_healthy_hookup() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    simulate_activation(&env, generation);

    let mut ctx = make_ctx(&env);
    ctx.env_stamp = live_stamp(generation);

    let notice = status::status(None, &ctx).unwrap().shell_hookup.unwrap();
    assert_eq!(notice.state, "healthy");
    assert_eq!(notice.severity, "ok");
    assert_eq!(notice.hint, None);
    assert_eq!(
        notice.evidence,
        format!(
            "Last loaded just now by dodot {}.",
            activation::running_version()
        ),
        "line two dates the last activation from the heartbeat's mtime"
    );
}

/// The state the whole workstream exists to make visible: shells are
/// sourcing dodot, and it is not the dodot the user is running.
#[test]
fn status_reports_version_skew_naming_both_versions() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    // A shell sourced the current generation — but from a 5.0.0 script.
    simulate_activation_by(&env, generation, "5.0.0");

    let notice = status::status(None, &make_ctx(&env))
        .unwrap()
        .shell_hookup
        .unwrap();

    assert_eq!(notice.state, "version-skew");
    assert_eq!(
        notice.severity, "warning",
        "a user mid-upgrade sees this transiently — it is not an error"
    );
    assert_eq!(
        notice.message,
        "Shell hookup: your shells load a different dodot."
    );
    assert_eq!(
        notice.evidence,
        format!(
            "Last loaded just now by dodot 5.0.0 — you are running {}.",
            activation::running_version()
        )
    );
}

/// A pre-RCS01 heartbeat — no version field — is what every upgrading
/// user has on disk. It reads as a bound, not as "unknown", and gets
/// through the whole command path without panicking. Whether that
/// bound also *is* skew depends on the running release (see
/// `EvidenceVersion::is`), so this pins the rendering, not the state.
#[test]
fn status_reads_a_planted_pre_version_heartbeat_as_the_bound() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    env.fs.mkdir_all(&env.paths.probes_hookup_dir()).unwrap();
    env.fs
        .write_file(
            &env.paths.hookup_heartbeat_path(),
            generation.to_string().as_bytes(),
        )
        .unwrap();

    let notice = status::status(None, &make_ctx(&env))
        .unwrap()
        .shell_hookup
        .unwrap();

    assert!(
        notice.evidence.contains("dodot ≤5.5.1"),
        "a version-less heartbeat is bounded, not unknown: {}",
        notice.evidence
    );
}

/// A shell that never sourced init (cron, an editor task runner) is
/// not evidence of a broken hookup as long as some shell activated at
/// the current generation — but only while dodot is *not* attached to
/// a terminal. A detached process cannot tell "I am not a shell
/// session" apart from a shell session, so it defers to the heartbeat.
#[test]
fn status_stays_healthy_when_the_heartbeat_is_current_but_this_process_has_no_stamp() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    simulate_activation(&env, generation);

    let notice = status::status(None, &make_ctx(&env))
        .unwrap()
        .shell_hookup
        .unwrap();
    assert_eq!(notice.state, "healthy");
}

/// The dead-hookup primary case (#279): the hookup used to work (the
/// heartbeat holds the current generation), then broke — and the shell
/// the user is typing in (tty attached, no stamp) demonstrably did not
/// load dodot. The old high-water-mark heartbeat must not keep
/// certifying "ok"; the rc scan supplies the diagnosis: the hook is
/// absent from the rc file, so name the file and the fix.
#[test]
fn status_from_a_tty_shell_without_a_stamp_reports_the_dead_hookup() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    // Some shell activated back when the hookup still worked…
    simulate_activation(&env, generation);

    // …but the terminal in front of the user sourced nothing.
    let mut ctx = make_ctx(&env);
    ctx.tty = true;
    ctx.shell_env = crate::shell::ShellEnv {
        shell: Some("/bin/zsh".into()),
        zdotdir: None,
    };
    // No ~/.zshrc with a hook exists in this home: the hookup is dead.

    let notice = status::status(None, &ctx).unwrap().shell_hookup.unwrap();
    assert_eq!(notice.state, "shell-not-loaded");
    assert_eq!(notice.severity, "warning");
    assert_eq!(
        notice.message, "Shell hookup: this shell did not load dodot.",
        "the message states what is literally true"
    );
    let hint = notice.hint.unwrap();
    assert!(
        hint.contains("~/.zshrc") && hint.contains("dodot install --write"),
        "an absent hook names the rc file and the fix: {hint}"
    );
    assert!(
        !hint.contains("new shell"),
        "no new shell fixes a missing hook: {hint}"
    );
}

/// The second-order case (#279): after `up` has bumped the generation,
/// the heartbeat ages into Old — but when the hook is absent from the
/// rc, "open a new shell" is wrong advice. The rc scan decides.
#[test]
fn status_after_an_up_does_not_advise_a_new_shell_when_the_hook_is_gone() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    // The heartbeat predates the current generation: the classic
    // stale-shell evidence shape.
    simulate_activation(&env, generation - 1);

    let mut ctx = make_ctx(&env);
    ctx.tty = true;
    ctx.shell_env = crate::shell::ShellEnv {
        shell: Some("/bin/zsh".into()),
        zdotdir: None,
    };

    let notice = status::status(None, &ctx).unwrap().shell_hookup.unwrap();
    assert_eq!(notice.state, "shell-not-loaded");
    let hint = notice.hint.unwrap();
    assert!(
        !hint.contains("new shell"),
        "a missing hook must not get stale-shell advice: {hint}"
    );
    assert!(hint.contains("dodot install --write"), "hint: {hint}");
}

/// When the hook *is* wired in the rc, a tty shell without a stamp is
/// most plausibly a shell opened before the hook landed: the advice
/// stays "open a new one", phrased as what the evidence supports.
#[test]
fn status_from_a_tty_shell_with_the_hook_wired_advises_a_new_shell() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    simulate_activation(&env, generation);

    let hook = activation::hook_line(&env.paths.init_script_path(), env.paths.home_dir());
    env.fs
        .write_file(&env.paths.home_dir().join(".zshrc"), hook.as_bytes())
        .unwrap();

    let mut ctx = make_ctx(&env);
    ctx.tty = true;
    ctx.shell_env = crate::shell::ShellEnv {
        shell: Some("/bin/zsh".into()),
        zdotdir: None,
    };

    let notice = status::status(None, &ctx).unwrap().shell_hookup.unwrap();
    assert_eq!(notice.state, "shell-not-loaded");
    assert_eq!(notice.severity, "info");
    assert!(
        notice.hint.unwrap().contains("new shell"),
        "hook present: an old shell is the likely story"
    );
}

/// A live tty shell keeps its healthy footer: the tty signal only
/// breaks the tie when the stamp is absent.
#[test]
fn status_from_a_live_tty_shell_stays_healthy() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    simulate_activation(&env, generation);

    let mut ctx = make_ctx(&env);
    ctx.tty = true;
    ctx.env_stamp = live_stamp(generation);

    let notice = status::status(None, &ctx).unwrap().shell_hookup.unwrap();
    assert_eq!(notice.state, "healthy");
}

/// Story 5: after `down` the hook is still wired and the script it
/// sources now deploys nothing. Reporting a healthy hookup there is
/// technically true and practically misleading, so the footer reports
/// the script's content instead — no `down` special case.
#[test]
fn down_reports_the_wired_but_empty_script() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    simulate_activation(&env, generation);

    let mut ctx = make_ctx(&env);
    ctx.env_stamp = live_stamp(generation);
    let result = crate::commands::down::down(None, &ctx).unwrap();

    let notice = result
        .shell_hookup
        .expect("`down` carries the footer like every other render");
    assert_eq!(notice.state, "empty-script");
    assert_eq!(
        notice.message,
        "Shell hookup: wired, but no packs are deployed."
    );
}

/// The same rule, reached a different way: a repository where every
/// pack is ignored generates the same contribution-less script, and
/// `up` says so rather than claiming a healthy deployment.
#[test]
fn an_all_ignored_repo_reports_the_wired_but_empty_script() {
    let env = TempEnvironment::builder()
        .pack("vim")
        .file("aliases.sh", "alias vi=vim")
        .file(".dodotignore", "")
        .done()
        .build();

    // One `up` to write the script, then a shell that sourced it.
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    simulate_activation(&env, generation);

    let mut ctx = make_ctx(&env);
    ctx.env_stamp = live_stamp(generation);
    let notice = status::status(None, &ctx).unwrap().shell_hookup.unwrap();

    assert_eq!(notice.state, "empty-script");
}

#[test]
fn the_footer_renders_both_lines() {
    let env = env_with_shell_pack();
    let result = up::up(None, &make_ctx(&env)).unwrap();

    let text = render::render("pack-status", &result, OutputMode::Text).unwrap();
    assert!(
        text.contains("Shell hookup: no shell has loaded dodot yet."),
        "line one must reach rendered output: {text}"
    );
    assert!(
        text.contains("Never loaded."),
        "line two must reach rendered output: {text}"
    );
    assert!(
        text.contains("dodot install --write") && text.contains("dodot-init.sh"),
        "the fix — the command and the manual line — must reach rendered output: {text}"
    );

    let tagged = render::render("pack-status", &result, OutputMode::TermDebug).unwrap();
    assert!(
        tagged.contains("[warning]⚠ Shell hookup: no shell has loaded dodot yet.[/warning]"),
        "never-activated renders in the warning style: {tagged}"
    );
    assert!(
        tagged.contains("[dim]Never loaded.[/dim]"),
        "the evidence line renders dimmed: {tagged}"
    );
}

/// Line one is colour-coded by state, line two is always dimmed — the
/// footer's whole shape (spec §2.3).
#[test]
fn a_healthy_footer_renders_in_the_ok_style() {
    let env = env_with_shell_pack();
    up::up(None, &make_ctx(&env)).unwrap();
    let generation = script_generation(&env);
    simulate_activation(&env, generation);

    let mut ctx = make_ctx(&env);
    ctx.env_stamp = live_stamp(generation);
    let result = status::status(None, &ctx).unwrap();

    let tagged = render::render("pack-status", &result, OutputMode::TermDebug).unwrap();
    assert!(
        tagged.contains("[deployed]✓ Shell hookup: dodot is sourced in new shells.[/deployed]"),
        "a working hookup reads as working: {tagged}"
    );
    assert!(
        tagged.contains("[dim]Last loaded just now by dodot"),
        "the evidence line renders dimmed: {tagged}"
    );
}