Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-install/src/post_install.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Post-install steps run back in the unprivileged user process after the
3
//! root phase has placed files: systemd service (re)start, applet panel
4
//! restart, launcher-cache nudge, legacy `~/.local` cleanup, and the COSMIC
5
//! keyboard-shortcut migrate/add. Every step is best-effort (`log::warn!` on
6
//! failure) except the daemon restart, the one failure the app must hear
7
//! about.
8
//!
9
//! `run`'s WHAT-to-do is a pure decision, separated from the HOW: [`plan`]
10
//! takes the coarse facts already known (or cheap to check) before any file
11
//! I/O or process spawn and returns an ordered [`Step`] list with no I/O of
12
//! its own; `run` computes those inputs, calls `plan`, and executes the
13
//! result through a thin per-step match. That seam is the whole decision
14
//! tree's test coverage — see `plan`'s unit tests below — without ever
15
//! needing a command-runner trait or a mocked OS.
16
17
use std::path::Path;
18
19
use crate::errors::InstallError;
20
use crate::stage::Components;
21
22
/// Run `bin` with `args`, discarding output, returning whether it exited 0.
23
/// A spawn failure (binary missing, etc.) also counts as "not ok".
24
0
async fn cmd_ok(bin: &str, args: &[&str]) -> bool {
25
0
    tokio::process::Command::new(bin)
26
0
        .args(args)
27
0
        .status()
28
0
        .await
29
0
        .is_ok_and(|s| s.success())
30
0
}
31
32
/// Whether `bin` resolves on the current `$PATH`.
33
0
fn on_path(bin: &str) -> bool {
34
0
    let path_env = std::env::var("PATH").unwrap_or_default();
35
0
    crate::escalate::which(bin, &path_env).is_some()
36
0
}
37
38
/// One post-install action, in the order [`plan`] returns them. Each variant
39
/// is a single, independent shell-out or filesystem tweak — see `run`'s
40
/// executor `match` for what each one actually does.
41
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42
pub enum Step {
43
    /// `systemctl --user daemon-reload`.
44
    DaemonReload,
45
    /// Remove a legacy `~/.config/systemd/user/super-stt.service` unit that
46
    /// would otherwise shadow the packaged one and keep launching the (now
47
    /// deleted) legacy `~/.local/bin` binary.
48
    RemoveLegacyUnit,
49
    /// `systemctl --user enable super-stt`.
50
    Enable,
51
    /// `systemctl --user restart|start super-stt` (verb picked at execution
52
    /// time, via `is-active`) — the one step whose failure is a hard error.
53
    RestartOrStart,
54
    /// Restart `cosmic-panel` so it picks up the just-updated applet binary.
55
    RestartPanel,
56
    /// Nudge COSMIC's launcher caches (app grid + search backend) to rescan
57
    /// desktop entries.
58
    NudgeLaunchers,
59
    /// Remove pre-`/usr/local` per-user leftovers (bins/desktop files/icons).
60
    CleanupLegacy,
61
    /// Rewrite a legacy `~/.local/bin/stt` shortcut reference, if present.
62
    MigrateShortcut,
63
    /// Interactively offer to add the `Super+Space` shortcut.
64
    PromptShortcut,
65
}
66
67
/// Decide which [`Step`]s to run, and in what order, from facts already known
68
/// before any post-install I/O: `components` (what was just installed or
69
/// updated), `applet_was_installed` (captured *before* the root phase ran —
70
/// the script's `is_update` check), `interactive`, and three environment
71
/// probes the caller (`run`) is expected to have already made:
72
/// `systemctl_available`/`cosmic_available` (`$PATH` lookups) and
73
/// `panel_running` (a `pgrep` check). `panel_running` only matters when
74
/// `components.applet && applet_was_installed` — a caller may cheaply pass
75
/// `true` unconditionally for any other combination and it's simply ignored.
76
///
77
/// Order: systemd install/enable/restart, then the applet's panel restart,
78
/// then the launcher-cache nudge, then legacy cleanup, then the COSMIC
79
/// shortcut. Every file is already placed by the time this runs — that all
80
/// happens earlier, in the root phase — so [`Step::CleanupLegacy`] is a
81
/// single unconditional sweep rather than one pass per component; removing
82
/// files that were never there is a no-op, so the sweep is idempotent
83
/// whatever the selection.
84
#[must_use]
85
#[allow(clippy::fn_params_excessive_bools)] // interface fixed by the design doc: the five booleans are the planner's whole point
86
30
pub fn plan(
87
30
    components: Components,
88
30
    applet_was_installed: bool,
89
30
    interactive: bool,
90
30
    systemctl_available: bool,
91
30
    cosmic_available: bool,
92
30
    panel_running: bool,
93
30
) -> Vec<Step> {
94
30
    let mut steps = Vec::new();
95
96
30
    if components.daemon && 
systemctl_available16
{
97
12
        steps.push(Step::DaemonReload);
98
12
        steps.push(Step::RemoveLegacyUnit);
99
12
        steps.push(Step::Enable);
100
12
        steps.push(Step::RestartOrStart);
101
18
    }
102
103
30
    if components.applet && 
applet_was_installed12
&&
panel_running10
{
104
8
        steps.push(Step::RestartPanel);
105
22
    }
106
107
    // The launcher nudge is skipped only for a daemon-only install: an app
108
    // or applet install both add launcher-visible entries that benefit from
109
    // the rescan.
110
30
    let daemon_only = components.daemon && 
!components.app16
&&
!components.applet10
;
111
30
    if !daemon_only {
112
20
        steps.push(Step::NudgeLaunchers);
113
20
    
}10
114
115
30
    steps.push(Step::CleanupLegacy);
116
117
30
    if components.daemon && 
cosmic_available16
{
118
12
        steps.push(Step::MigrateShortcut);
119
12
        if interactive {
120
6
            steps.push(Step::PromptShortcut);
121
6
        }
122
18
    }
123
124
30
    steps
125
30
}
126
127
0
async fn step_daemon_reload() {
128
0
    if !cmd_ok("systemctl", &["--user", "daemon-reload"]).await {
129
0
        log::warn!("systemctl --user daemon-reload failed");
130
0
    }
131
0
}
132
133
/// A unit left in `~/.config/systemd/user` by an older install takes
134
/// precedence over the packaged one — remove it or systemd keeps launching
135
/// the (now deleted) legacy `~/.local/bin` binary.
136
0
fn step_remove_legacy_unit() {
137
0
    if let Some(home) = dirs::home_dir() {
138
0
        let legacy_unit = home.join(".config/systemd/user/super-stt.service");
139
0
        if legacy_unit.exists() {
140
0
            let _ = std::fs::remove_file(&legacy_unit);
141
0
        }
142
0
    }
143
0
}
144
145
0
async fn step_enable() {
146
0
    if !cmd_ok("systemctl", &["--user", "enable", "super-stt"]).await {
147
0
        log::warn!("systemctl --user enable super-stt failed");
148
0
    }
149
0
}
150
151
/// # Errors
152
/// [`InstallError::PostInstallFailed`] when the final `restart`/`start`
153
/// exits nonzero — the only step in the whole post-install sequence whose
154
/// failure is a hard error.
155
0
async fn step_restart_or_start() -> Result<(), InstallError> {
156
0
    let is_active = cmd_ok(
157
0
        "systemctl",
158
0
        &["--user", "is-active", "--quiet", "super-stt"],
159
0
    )
160
0
    .await;
161
0
    let verb = if is_active { "restart" } else { "start" };
162
0
    if cmd_ok("systemctl", &["--user", verb, "super-stt"]).await {
163
0
        Ok(())
164
    } else {
165
0
        Err(InstallError::PostInstallFailed(format!(
166
0
            "daemon {verb} failed: run `systemctl --user {verb} super-stt` manually"
167
0
        )))
168
    }
169
0
}
170
171
0
async fn step_restart_panel() {
172
0
    if !cmd_ok("pkill", &["-f", "cosmic-panel"]).await {
173
0
        log::warn!("failed to restart cosmic-panel to load the updated applet");
174
0
    }
175
0
}
176
177
/// Nudge COSMIC's launcher caches (app grid + search backend): both scan
178
/// desktop entries at session start and miss entries added to a directory
179
/// they weren't watching. They respawn on demand and rescan.
180
0
async fn step_nudge_launchers() {
181
0
    let _ = tokio::process::Command::new("pkill")
182
0
        .args(["-f", "^cosmic-app-library$"])
183
0
        .status()
184
0
        .await;
185
0
    let _ = tokio::process::Command::new("pkill")
186
0
        .args(["-f", "^cosmic-launcher$"])
187
0
        .status()
188
0
        .await;
189
0
    let _ = tokio::process::Command::new("pkill")
190
0
        .args(["-f", "^pop-launcher( |$)"])
191
0
        .status()
192
0
        .await;
193
0
}
194
195
/// Bins, desktop files, and icons the pre-`/usr/local` per-user install left
196
/// behind — cleared so they don't shadow (or duplicate in launchers) the
197
/// fresh install.
198
0
fn cleanup_legacy() {
199
0
    let Some(home) = dirs::home_dir() else {
200
0
        return;
201
    };
202
203
0
    let bin_dir = home.join(".local/bin");
204
0
    for name in [
205
0
        "super-stt",
206
0
        "super-stt-daemon",
207
0
        "super-stt-cli",
208
0
        "super-stt-consent",
209
0
        "stt",
210
0
        "super-stt-app",
211
0
        "super-stt-cosmic-applet",
212
0
        "super-stt-applet-full",
213
0
        "super-stt-applet-left",
214
0
        "super-stt-applet-right",
215
0
    ] {
216
0
        let _ = std::fs::remove_file(bin_dir.join(name));
217
0
    }
218
219
0
    let apps_dir = home.join(".local/share/applications");
220
0
    for name in [
221
0
        "super-stt-app.desktop",
222
0
        "super-stt-cosmic-applet-full.desktop",
223
0
        "super-stt-cosmic-applet-left.desktop",
224
0
        "super-stt-cosmic-applet-right.desktop",
225
0
    ] {
226
0
        let _ = std::fs::remove_file(apps_dir.join(name));
227
0
    }
228
229
0
    let icons_dir = home.join(".local/share/icons");
230
0
    let _ = std::fs::remove_file(icons_dir.join("super-stt-app.svg"));
231
0
    let _ = std::fs::remove_file(icons_dir.join("hicolor/scalable/apps/super-stt-app.svg"));
232
    let _ =
233
0
        std::fs::remove_file(icons_dir.join("hicolor/scalable/apps/super-stt-cosmic-applet.svg"));
234
235
0
    let _ = std::fs::remove_file(home.join(".local/share/metainfo/super-stt-app.metainfo.xml"));
236
0
}
237
238
/// Rewrite a legacy `<home>/.local/bin/stt ` shortcut command to
239
/// `<prefix>/bin/stt `, since the wrapper no longer lives in the removed
240
/// per-user layout. Returns `None` when `content` doesn't reference the
241
/// legacy path (nothing to migrate).
242
#[must_use]
243
4
pub fn migrate_shortcut_content(content: &str, home: &str, prefix: &str) -> Option<String> {
244
4
    let legacy = format!("{home}/.local/bin/stt ");
245
4
    if !content.contains(&legacy) {
246
2
        return None;
247
2
    }
248
2
    let replacement = format!("{prefix}/bin/stt ");
249
2
    Some(content.replace(&legacy, &replacement))
250
4
}
251
252
/// Build the `Spawn(...)` shortcut entry block for `stt_command`, RON-shaped
253
/// like the rest of the COSMIC shortcuts file.
254
6
fn shortcut_entry(stt_command: &str) -> String {
255
6
    format!(
256
        "    (\n        modifiers: [\n            Super,\n        ],\n        key: \"space\",\n        description: Some(\"Super STT\"),\n    ): Spawn(\"{stt_command}\"),\n"
257
    )
258
6
}
259
260
/// Add a `Super+Space` → `stt_command` shortcut entry to `content` (the
261
/// COSMIC custom-shortcuts file's current text), subject to two coarse
262
/// checks: skip (return `None`) if a "Super STT" entry already exists, or if
263
/// `key: "space"` is already bound to a `Super`-modified shortcut
264
/// (approximated as both substrings appearing anywhere in `content`). Empty or
265
/// `{}`-only content gets the full-file template; otherwise the entry is
266
/// inserted before the final closing brace.
267
#[must_use]
268
10
pub fn shortcut_with_super_stt(content: &str, stt_command: &str) -> Option<String> {
269
10
    if content.contains("Super STT") {
270
2
        return None;
271
8
    }
272
8
    if content.contains("key: \"space\"") && 
content2
.
contains2
("Super") {
273
2
        return None;
274
6
    }
275
276
6
    let entry = shortcut_entry(stt_command);
277
6
    let trimmed = content.trim();
278
6
    if trimmed.is_empty() || 
trimmed == "{}"4
{
279
4
        return Some(format!("{{\n{entry}}}\n"));
280
2
    }
281
282
    // File has content: drop everything from (and including) the final `}`
283
    // and append our entry plus a fresh close — mirrors the script's
284
    // `head -n -1` + heredoc.
285
2
    let last_brace = content.rfind('}')
?0
;
286
2
    let head = &content[..last_brace];
287
2
    Some(format!("{head}{entry}}}\n"))
288
10
}
289
290
/// Read `/dev/tty` for a `[Y/n]`-style answer to `prompt` (echoed to
291
/// stderr first). Defaults to yes on anything but an exact `n`/`N` — same
292
/// as the script's `[[ "$add_shortcut" =~ ^[Nn]$ ]]` check. A `/dev/tty`
293
/// open/read failure also defaults to "no" (never silently proceeds without
294
/// having actually asked).
295
0
fn prompt_yes_no(prompt: &str) -> bool {
296
    use std::io::{BufRead, Write};
297
0
    eprint!("{prompt}");
298
0
    let _ = std::io::stderr().flush();
299
0
    let Ok(tty) = std::fs::File::open("/dev/tty") else {
300
0
        return false;
301
    };
302
0
    let mut line = String::new();
303
0
    if std::io::BufReader::new(tty).read_line(&mut line).is_err() {
304
0
        return false;
305
0
    }
306
0
    !line.trim().eq_ignore_ascii_case("n")
307
0
}
308
309
/// Rewrite a legacy `~/.local/bin/stt` shortcut reference in the COSMIC
310
/// custom-shortcuts file, if present.
311
0
fn migrate_shortcut(prefix: &Path) {
312
0
    let Some(home) = dirs::home_dir() else {
313
0
        return;
314
    };
315
0
    let shortcuts_file =
316
0
        home.join(".config/cosmic/com.system76.CosmicSettings.Shortcuts/v1/custom");
317
0
    if let Ok(content) = std::fs::read_to_string(&shortcuts_file)
318
0
        && let Some(migrated) =
319
0
            migrate_shortcut_content(&content, &home.to_string_lossy(), &prefix.to_string_lossy())
320
0
        && let Err(e) = std::fs::write(&shortcuts_file, migrated)
321
    {
322
0
        log::warn!("failed to migrate COSMIC shortcut: {e}");
323
0
    }
324
0
}
325
326
/// Interactively offer to add the `Super+Space` shortcut.
327
0
fn prompt_shortcut(prefix: &Path) {
328
0
    let Some(home) = dirs::home_dir() else {
329
0
        return;
330
    };
331
0
    let shortcuts_dir = home.join(".config/cosmic/com.system76.CosmicSettings.Shortcuts/v1");
332
0
    let shortcuts_file = shortcuts_dir.join("custom");
333
334
0
    if !prompt_yes_no("Add COSMIC keyboard shortcut (Super+Space)? [Y/n]: ") {
335
0
        return;
336
0
    }
337
338
0
    if let Err(e) = std::fs::create_dir_all(&shortcuts_dir) {
339
0
        log::warn!("failed to create COSMIC shortcuts dir: {e}");
340
0
        return;
341
0
    }
342
0
    let stt_command = format!("{}/bin/stt record --write", prefix.display());
343
0
    let existing = std::fs::read_to_string(&shortcuts_file).unwrap_or_default();
344
0
    if let Some(updated) = shortcut_with_super_stt(&existing, &stt_command)
345
0
        && let Err(e) = std::fs::write(&shortcuts_file, updated)
346
    {
347
0
        log::warn!("failed to write COSMIC shortcut: {e}");
348
0
    }
349
0
}
350
351
/// Everything that happens back in the user process after the root phase has
352
/// placed files. Computes the environment probes [`plan`] needs, gets back
353
/// an ordered [`Step`] list, and executes it through a thin per-step
354
/// `match` — that seam is what makes the whole decision tree ([`plan`]'s
355
/// unit tests) testable without ever touching the filesystem or a real
356
/// shell-out.
357
///
358
/// `applet_was_installed` must be captured *before* the root phase ran (the
359
/// script's `is_update` check) — it decides whether the panel needs
360
/// restarting to pick up a *changed* applet binary, not whether the applet
361
/// is present now.
362
///
363
/// # Errors
364
/// [`InstallError::PostInstallFailed`] only when the daemon restart/start
365
/// itself fails ([`Step::RestartOrStart`]) — every other step is best-effort
366
/// and only logs a warning.
367
0
pub async fn run(
368
0
    components: &Components,
369
0
    applet_was_installed: bool,
370
0
    interactive: bool,
371
0
    prefix: &Path,
372
0
) -> Result<(), InstallError> {
373
0
    let systemctl_available = on_path("systemctl");
374
0
    if components.daemon && !systemctl_available {
375
0
        log::warn!("systemctl not found on PATH; skipping daemon service setup");
376
0
    }
377
0
    let cosmic_available = on_path("cosmic-panel");
378
    // Only worth the `pgrep` shell-out when it could actually matter —
379
    // `plan` re-checks the same `components.applet && applet_was_installed`
380
    // guard itself, so passing `false` when it doesn't apply is equivalent.
381
0
    let panel_running =
382
0
        components.applet && applet_was_installed && cmd_ok("pgrep", &["-f", "cosmic-panel"]).await;
383
384
0
    for step in plan(
385
0
        *components,
386
0
        applet_was_installed,
387
0
        interactive,
388
0
        systemctl_available,
389
0
        cosmic_available,
390
0
        panel_running,
391
    ) {
392
0
        match step {
393
0
            Step::DaemonReload => step_daemon_reload().await,
394
0
            Step::RemoveLegacyUnit => step_remove_legacy_unit(),
395
0
            Step::Enable => step_enable().await,
396
            // C8 INVARIANT: this is the ONLY `?` in this match. Every other
397
            // step function returns `()`, not `Result` — best-effort by
398
            // construction, not merely by convention here — so adding a `?`
399
            // to another arm first requires changing that step's own
400
            // function signature. If you're doing that deliberately,
401
            // update `only_restart_or_start_step_is_a_hard_error` below (it
402
            // pins this match having exactly one `?`, on this arm) and this
403
            // module's doc comment, which documents `RestartOrStart` as the
404
            // sole hard error.
405
0
            Step::RestartOrStart => step_restart_or_start().await?,
406
0
            Step::RestartPanel => step_restart_panel().await,
407
0
            Step::NudgeLaunchers => step_nudge_launchers().await,
408
0
            Step::CleanupLegacy => cleanup_legacy(),
409
0
            Step::MigrateShortcut => migrate_shortcut(prefix),
410
0
            Step::PromptShortcut => prompt_shortcut(prefix),
411
        }
412
    }
413
414
0
    Ok(())
415
0
}
416
417
#[cfg(test)]
418
mod tests {
419
    use super::*;
420
421
    const STT_CMD: &str = "/usr/local/bin/stt record --write";
422
423
    /// C8: pins that `Step::RestartOrStart` is the ONLY hard-error arm in
424
    /// `run`'s per-step executor match. `run` itself can't be driven
425
    /// directly without shelling out to real systemd/COSMIC commands — and
426
    /// several steps (`cleanup_legacy`, `migrate_shortcut`, ...) touch the
427
    /// real `$HOME`, so calling them for real from a test would risk
428
    /// mutating the test-runner's actual machine, not a real seam to test
429
    /// through. Absent an executable seam, this pins the invariant
430
    /// structurally instead: it re-reads this file's own source and counts
431
    /// `?` inside the per-step match, so a future edit that adds a second
432
    /// `?` arm (silently promoting a best-effort step to a hard error)
433
    /// fails this test rather than passing unnoticed. Also scans for
434
    /// `return Err` — the other obvious spelling of a hard error, one a
435
    /// future arm could use without ever touching the `?` count above.
436
    #[test]
437
2
    fn only_restart_or_start_step_is_a_hard_error() {
438
2
        let src = include_str!("post_install.rs");
439
2
        let match_start = src
440
2
            .find("match step {")
441
2
            .expect("run's per-step executor match must exist");
442
2
        let match_end = src[match_start..]
443
2
            .find("\n    }\n")
444
2
            .expect("the per-step match must close");
445
2
        let match_block = &src[match_start..match_start + match_end];
446
        // Strip `//`-comment lines before counting: this very match block
447
        // carries a doc comment that itself mentions the character being
448
        // counted, which would otherwise inflate the count.
449
2
        let code_only: String = match_block
450
2
            .lines()
451
40
            .
filter2
(|line| !line.trim_start().starts_with("//"))
452
2
            .collect::<Vec<_>>()
453
2
            .join("\n");
454
455
2
        assert_eq!(
456
2
            code_only.matches('?').count(),
457
            1,
458
            "expected exactly one `?` in run's executor match (Step::RestartOrStart); \
459
             found a different count — every other step must stay best-effort:\n{code_only}"
460
        );
461
2
        assert!(
462
2
            code_only.contains("Step::RestartOrStart => step_restart_or_start().await?"),
463
            "the one `?` must be on the RestartOrStart arm specifically:\n{code_only}"
464
        );
465
        // `?` isn't the only way an arm could turn hard-error: an arm could
466
        // instead be a block that does `return Err(..)` directly, which
467
        // wouldn't move the `?` count above and would slip past the assert
468
        // just made. Catch that spelling too.
469
2
        assert_eq!(
470
2
            code_only.matches("return Err").count(),
471
            0,
472
            "found a `return Err` in run's executor match outside the `?` \
473
             this test already pins — every other step must stay best-effort:\n{code_only}"
474
        );
475
2
    }
476
477
    #[test]
478
2
    fn migrate_rewrites_legacy_path_only_when_present() {
479
2
        let c = r#"{ (key: "space"): Spawn("/home/u/.local/bin/stt record --write"), }"#;
480
2
        let out = migrate_shortcut_content(c, "/home/u", "/usr/local").unwrap();
481
2
        assert!(out.contains("/usr/local/bin/stt record --write"));
482
2
        assert!(!out.contains(".local/bin/stt "));
483
2
        assert!(migrate_shortcut_content("{}", "/home/u", "/usr/local").is_none());
484
2
    }
485
486
    #[test]
487
2
    fn add_skips_when_super_stt_or_super_space_exists() {
488
2
        assert!(
489
2
            shortcut_with_super_stt(r#"{ description: Some("Super STT") }"#, STT_CMD).is_none()
490
        );
491
2
        let taken = "{\n    (\n        modifiers: [\n            Super,\n        ],\n        key: \"space\",\n        description: Some(\"Other\"),\n    ): Spawn(\"x\"),\n}";
492
2
        assert!(shortcut_with_super_stt(taken, STT_CMD).is_none());
493
2
    }
494
495
    #[test]
496
2
    fn add_writes_full_file_when_empty_and_inserts_before_close_otherwise() {
497
2
        let fresh = shortcut_with_super_stt("", STT_CMD).unwrap();
498
2
        assert!(fresh.starts_with("{"));
499
2
        assert!(fresh.contains("Super STT"));
500
2
        assert!(fresh.trim_end().ends_with("}"));
501
502
        // The literal `{}`-only case (not just a truly empty file) also
503
        // gets the full-file template, per the doc comment's "Empty or
504
        // `{}`-only content" — not just whitespace-empty.
505
2
        let fresh_braces = shortcut_with_super_stt("{}", STT_CMD).unwrap();
506
2
        assert!(fresh_braces.contains("Super STT"));
507
2
        assert_eq!(
508
2
            fresh_braces.matches('}').count(),
509
2
            fresh_braces.matches('{').count()
510
        );
511
512
2
        let existing = "{\n    (\n        modifiers: [\n            Ctrl,\n        ],\n        key: \"t\",\n        description: Some(\"Terminal\"),\n    ): Spawn(\"term\"),\n}";
513
2
        let merged = shortcut_with_super_stt(existing, STT_CMD).unwrap();
514
2
        assert!(merged.contains("Terminal"));
515
2
        assert!(merged.contains("Super STT"));
516
2
        assert_eq!(merged.matches('}').count(), merged.matches('{').count());
517
        // Super STT entry comes after the existing one, before the final close.
518
2
        assert!(merged.rfind("Super STT").unwrap() > merged.find("Terminal").unwrap());
519
2
    }
520
521
    // --- plan(): the post-install decision tree, tested exhaustively so the
522
    // real shell-outs in `run`'s executor never need to be invoked to prove
523
    // the WHAT-to-do logic is right. ---
524
525
6
    fn all_three() -> Components {
526
6
        Components {
527
6
            daemon: true,
528
6
            app: true,
529
6
            applet: true,
530
6
        }
531
6
    }
532
533
6
    fn daemon_only() -> Components {
534
6
        Components {
535
6
            daemon: true,
536
6
            app: false,
537
6
            applet: false,
538
6
        }
539
6
    }
540
541
    #[test]
542
2
    fn plan_daemon_only_skips_launcher_nudge_and_applet_restart() {
543
        // Even with every environment probe favorable (systemctl+cosmic
544
        // available, applet "already installed", panel "running") — none of
545
        // that matters when the applet isn't part of THIS run's components.
546
2
        let steps = plan(daemon_only(), true, false, true, true, true);
547
2
        assert!(!steps.contains(&Step::NudgeLaunchers));
548
2
        assert!(!steps.contains(&Step::RestartPanel));
549
2
    }
550
551
    #[test]
552
2
    fn plan_applet_restart_requires_selected_installed_and_running_all_three() {
553
2
        let applet_only = Components {
554
2
            daemon: false,
555
2
            app: false,
556
2
            applet: true,
557
2
        };
558
2
        assert!(plan(applet_only, true, false, false, false, true).contains(&Step::RestartPanel));
559
        // Not previously installed (a fresh applet install, not an update):
560
        // no restart even if the panel happens to be running.
561
2
        assert!(!plan(applet_only, false, false, false, false, true).contains(&Step::RestartPanel));
562
        // Previously installed, but the panel isn't currently running:
563
        // nothing to restart.
564
2
        assert!(!plan(applet_only, true, false, false, false, false).contains(&Step::RestartPanel));
565
        // Applet not selected THIS run (e.g. a daemon-only update), even
566
        // though it was installed before and the panel is running.
567
2
        assert!(
568
2
            !plan(daemon_only(), true, false, false, false, true).contains(&Step::RestartPanel)
569
        );
570
2
    }
571
572
    #[test]
573
2
    fn plan_shortcut_steps_require_daemon_component_and_cosmic_available() {
574
2
        let daemon = daemon_only();
575
576
        // No cosmic-panel on PATH: no shortcut steps at all, interactive or not.
577
2
        let steps = plan(daemon, false, true, true, false, false);
578
2
        assert!(!steps.contains(&Step::MigrateShortcut));
579
2
        assert!(!steps.contains(&Step::PromptShortcut));
580
581
        // Cosmic available, non-interactive: migrate only (the prompt is
582
        // interactive-only, but migration isn't gated on it at all).
583
2
        let steps = plan(daemon, false, false, true, true, false);
584
2
        assert!(steps.contains(&Step::MigrateShortcut));
585
2
        assert!(!steps.contains(&Step::PromptShortcut));
586
587
        // Cosmic available AND interactive: both steps, prompt after migrate.
588
2
        let steps = plan(daemon, false, true, true, true, false);
589
12
        let 
migrate_at2
=
steps.iter()2
.
position2
(|s| *s == Step::MigrateShortcut);
590
14
        let 
prompt_at2
=
steps.iter()2
.
position2
(|s| *s == Step::PromptShortcut);
591
2
        assert!(migrate_at.is_some() && prompt_at.is_some());
592
2
        assert!(migrate_at < prompt_at);
593
594
        // App-only (no daemon component at all): no shortcut steps even with
595
        // cosmic available and an interactive session.
596
2
        let app_only = Components {
597
2
            daemon: false,
598
2
            app: true,
599
2
            applet: false,
600
2
        };
601
2
        let steps = plan(app_only, false, true, true, true, false);
602
2
        assert!(!steps.contains(&Step::MigrateShortcut));
603
2
        assert!(!steps.contains(&Step::PromptShortcut));
604
2
    }
605
606
    #[test]
607
2
    fn plan_no_systemctl_means_no_systemd_steps() {
608
2
        let steps = plan(all_three(), true, false, false, true, true);
609
2
        assert!(!steps.contains(&Step::DaemonReload));
610
2
        assert!(!steps.contains(&Step::RemoveLegacyUnit));
611
2
        assert!(!steps.contains(&Step::Enable));
612
2
        assert!(!steps.contains(&Step::RestartOrStart));
613
2
    }
614
615
    #[test]
616
2
    fn plan_no_daemon_component_means_no_systemd_steps_even_if_available() {
617
2
        let app_only = Components {
618
2
            daemon: false,
619
2
            app: true,
620
2
            applet: false,
621
2
        };
622
2
        let steps = plan(app_only, false, false, true, false, false);
623
2
        assert!(!steps.iter().any(|s| matches!(
624
4
            s,
625
            Step::DaemonReload | Step::RemoveLegacyUnit | Step::Enable | Step::RestartOrStart
626
        )));
627
2
    }
628
629
    #[test]
630
2
    fn plan_cleanup_legacy_always_runs_regardless_of_every_other_gate() {
631
2
        assert!(
632
2
            plan(Components::default(), false, false, false, false, false)
633
2
                .contains(&Step::CleanupLegacy)
634
        );
635
2
        assert!(plan(all_three(), true, true, true, true, true).contains(&Step::CleanupLegacy));
636
2
    }
637
638
    #[test]
639
2
    fn plan_app_only_nudges_launchers_with_no_systemd_or_shortcut_steps() {
640
2
        let app_only = Components {
641
2
            daemon: false,
642
2
            app: true,
643
2
            applet: false,
644
2
        };
645
2
        let steps = plan(app_only, false, false, true, true, false);
646
2
        assert!(steps.contains(&Step::NudgeLaunchers));
647
2
        assert!(!steps.iter().any(|s| matches!(
648
4
            s,
649
            Step::DaemonReload
650
                | Step::RestartOrStart
651
                | Step::MigrateShortcut
652
                | Step::PromptShortcut
653
        )));
654
2
    }
655
656
    #[test]
657
2
    fn plan_full_update_interactive_uses_the_documented_step_order() {
658
        // Everything on: daemon+app+applet all selected (an "all" update),
659
        // the applet was already installed and its panel is currently
660
        // running, systemctl and cosmic-panel both available, interactive
661
        // session. The documented order for the "all" case: systemd
662
        // install/enable/(re)start, applet panel restart, launcher nudge,
663
        // legacy cleanup, COSMIC shortcut migrate-then-prompt.
664
2
        let steps = plan(all_three(), true, true, true, true, true);
665
2
        assert_eq!(
666
            steps,
667
2
            vec![
668
2
                Step::DaemonReload,
669
2
                Step::RemoveLegacyUnit,
670
2
                Step::Enable,
671
2
                Step::RestartOrStart,
672
2
                Step::RestartPanel,
673
2
                Step::NudgeLaunchers,
674
2
                Step::CleanupLegacy,
675
2
                Step::MigrateShortcut,
676
2
                Step::PromptShortcut,
677
            ]
678
        );
679
2
    }
680
}