triaged 0.2.0

Long-running daemon that owns Triage terminal session state and serves a built-in web client and WebSocket API for PIN-paired remote attach.
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
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
//! Per-user service registration for `triaged`.
//!
//! Registers the daemon to start at login and run in the background, so users
//! don't have to launch it by hand in a terminal:
//!
//! - **macOS** — a LaunchAgent in `~/Library/LaunchAgents`, loaded with
//!   `launchctl`.
//! - **Linux** — a systemd `--user` unit in `~/.config/systemd/user`, enabled
//!   with `systemctl --user`.
//! - **Windows** — a logon Scheduled Task created with `schtasks`.
//!
//! All three run in the *user's* session (not as a system service in session 0)
//! because the daemon owns interactive PTYs and a per-user control socket/pipe.
//!
//! The template builders (`plist_contents`, `systemd_unit_contents`,
//! `schtasks_create_args`) are plain, platform-independent functions so they can
//! be unit-tested on every CI runner; only the load/enable/start calls that
//! actually touch the OS are gated behind `cfg`.

use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};

/// Reverse-DNS label for the macOS LaunchAgent.
#[cfg(any(target_os = "macos", test))]
const SERVICE_LABEL: &str = "com.hyeons-lab.triaged";
/// Short identifier for the systemd unit and the Windows scheduled task.
#[cfg(any(target_os = "linux", target_os = "windows", test))]
const SERVICE_NAME: &str = "triaged";

/// Dispatch a `triaged service <action>` invocation.
pub fn run_cli(action: &str) -> Result<()> {
    match action {
        "install" => platform::install(&ServiceContext::detect()?),
        "uninstall" => platform::uninstall(&ServiceContext::detect()?),
        "start" => platform::start(&ServiceContext::detect()?),
        "stop" => platform::stop(&ServiceContext::detect()?),
        "status" => platform::status(&ServiceContext::detect()?),
        "" | "help" | "-h" | "--help" => {
            print_usage();
            Ok(())
        }
        other => {
            print_usage();
            bail!("unknown `triaged service` action: {other}");
        }
    }
}

fn print_usage() {
    eprintln!(
        "Usage: triaged service <install|uninstall|start|stop|status>\n\
         \n\
         install    register triaged to start at login and start it now\n\
         uninstall  stop triaged and remove the login registration\n\
         start      start the installed service\n\
         stop       stop the installed service\n\
         status     show whether the service is installed and running"
    );
}

/// Paths the service registration is built from.
struct ServiceContext {
    /// Absolute path to the currently running `triaged` binary, embedded into
    /// the unit/plist/task so the service launches the same binary the user ran.
    exe: PathBuf,
}

impl ServiceContext {
    fn detect() -> Result<Self> {
        let exe = std::env::current_exe()
            .context("resolving the triaged executable path for service registration")?;
        Ok(Self { exe })
    }
}

/// `$HOME` as a path. Used to place the LaunchAgent plist (macOS) and the
/// systemd user unit (Linux); the Windows logon task needs no home lookup.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn home_dir() -> Result<PathBuf> {
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .context("neither HOME nor USERPROFILE environment variable is set")?;
    Ok(PathBuf::from(home))
}

/// Directory for the daemon's stdout/stderr logs. Only macOS needs this: the
/// LaunchAgent plist redirects the daemon's streams here. systemd captures
/// stdout via the journal, and the Windows logon task runs the daemon detached
/// (it logs through `triage_core::logging`'s file appender either way).
#[cfg(target_os = "macos")]
fn default_log_dir() -> Result<PathBuf> {
    Ok(home_dir()?.join("Library/Logs/triage"))
}

/// Path of the file the Windows daemon writes its PID to, so `service stop` can
/// target exactly this process instead of every `triaged.exe` the user owns.
/// `launchctl` / `systemctl --user` already track the process on Unix, so this
/// is Windows-only.
#[cfg(target_os = "windows")]
fn pid_file_path() -> Option<PathBuf> {
    let base = std::env::var_os("LOCALAPPDATA")
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var_os("USERPROFILE")
                .map(|home| PathBuf::from(home).join("AppData").join("Local"))
        })?;
    Some(base.join("triage").join("triaged.pid"))
}

/// Record the running daemon's PID for `service stop`. Best-effort: a failure
/// here just means `stop` falls back to killing by image name. Called once at
/// daemon startup. Handover is Unix-only, so on Windows there is exactly one
/// daemon process and the file never goes stale mid-run.
#[cfg(windows)]
pub fn record_running_pid() {
    if let Some(path) = pid_file_path() {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let _ = std::fs::write(&path, std::process::id().to_string());
    }
}

// ---------------------------------------------------------------------------
// Pure template builders (unit-tested on every platform)
// ---------------------------------------------------------------------------

/// XML-escape a string for safe inclusion in a `.plist` body. Paths rarely
/// contain these characters, but escaping keeps a `&` in a home directory from
/// producing malformed XML.
#[cfg(any(target_os = "macos", test))]
fn xml_escape(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

/// Seconds launchd waits between respawns of the daemon. Deliberately above
/// launchd's 10s default: a binary that cannot launch at all — e.g. one macOS
/// SIGKILLs for an invalid code signature after an in-place upgrade — is
/// otherwise retried every 10s indefinitely.
#[cfg(any(target_os = "macos", test))]
const THROTTLE_INTERVAL_SECS: u32 = 30;

#[cfg(any(target_os = "macos", test))]
const _: () = assert!(
    THROTTLE_INTERVAL_SECS > 10,
    "the throttle must exceed launchd's 10s default, or it slows nothing down"
);

/// macOS LaunchAgent plist that runs `exe` at load, keeps it alive, and captures
/// stdout/stderr to the given log files.
///
/// `KeepAlive` stays unconditional. Making it conditional on `SuccessfulExit`
/// looks tempting — it would stop launchd respawning the job after the clean
/// exit that ends a handover — but that respawn is load-bearing: it is how
/// supervision returns to a launchd-owned process after a manual handover (see
/// `devlog/000085-fix-daemon-smart-start.md`), and `main`'s refused-teardown
/// path documents that it relies on being respawned regardless of exit status.
#[cfg(any(target_os = "macos", test))]
fn plist_contents(exe: &Path, stdout_log: &Path, stderr_log: &Path) -> String {
    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>{label}</string>
    <key>ProgramArguments</key>
    <array>
        <string>{exe}</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>ThrottleInterval</key>
    <integer>{throttle}</integer>
    <key>ProcessType</key>
    <string>Interactive</string>
    <key>StandardOutPath</key>
    <string>{stdout}</string>
    <key>StandardErrorPath</key>
    <string>{stderr}</string>
</dict>
</plist>
"#,
        label = SERVICE_LABEL,
        exe = xml_escape(&exe.display().to_string()),
        throttle = THROTTLE_INTERVAL_SECS,
        stdout = xml_escape(&stdout_log.display().to_string()),
        stderr = xml_escape(&stderr_log.display().to_string()),
    )
}

/// systemd `--user` unit that runs `exe` and restarts it on failure. `ExecStart`
/// is quoted so a home directory with spaces still parses.
#[cfg(any(target_os = "linux", test))]
fn systemd_unit_contents(exe: &Path) -> String {
    format!(
        "[Unit]\n\
         Description=Triage terminal session daemon\n\
         After=default.target\n\
         \n\
         [Service]\n\
         Type=simple\n\
         ExecStart=\"{exe}\"\n\
         Restart=on-failure\n\
         RestartSec=2\n\
         \n\
         [Install]\n\
         WantedBy=default.target\n",
        exe = exe.display(),
    )
}

/// `schtasks /Create` arguments for a logon task that launches `exe` without a
/// visible console window (`cmd /c start "" /b` detaches it from a console).
#[cfg(any(target_os = "windows", test))]
fn schtasks_create_args(exe: &Path) -> Vec<String> {
    let run = format!(r#"cmd /c start "" /b "{}""#, exe.display());
    vec![
        "/Create".to_string(),
        "/TN".to_string(),
        SERVICE_NAME.to_string(),
        "/TR".to_string(),
        run,
        "/SC".to_string(),
        "ONLOGON".to_string(),
        "/RL".to_string(),
        "LIMITED".to_string(),
        "/F".to_string(),
    ]
}

// ---------------------------------------------------------------------------
// Platform side effects
// ---------------------------------------------------------------------------

#[cfg(target_os = "macos")]
mod platform {
    use super::*;
    use std::process::Command;

    fn agent_path() -> Result<PathBuf> {
        Ok(home_dir()?
            .join("Library/LaunchAgents")
            .join(format!("{SERVICE_LABEL}.plist")))
    }

    fn launchctl(args: &[&str]) -> Result<std::process::ExitStatus> {
        Command::new("launchctl")
            .args(args)
            .status()
            .context("running launchctl (is this macOS?)")
    }

    pub(super) fn install(ctx: &ServiceContext) -> Result<()> {
        let plist = agent_path()?;
        if let Some(parent) = plist.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("creating {}", parent.display()))?;
        }
        let log_dir = default_log_dir()?;
        std::fs::create_dir_all(&log_dir)
            .with_context(|| format!("creating {}", log_dir.display()))?;
        let stdout_log = log_dir.join("triaged.out.log");
        let stderr_log = log_dir.join("triaged.err.log");
        std::fs::write(&plist, plist_contents(&ctx.exe, &stdout_log, &stderr_log))
            .with_context(|| format!("writing {}", plist.display()))?;

        // Reload cleanly if a previous agent is already loaded, then load with
        // `-w` so it persists across logins.
        let _ = launchctl(&["unload", &plist.display().to_string()]);
        let status = launchctl(&["load", "-w", &plist.display().to_string()])?;
        if !status.success() {
            bail!(
                "launchctl load failed; the LaunchAgent was written to {} but not loaded",
                plist.display()
            );
        }
        println!(
            "Installed and started triaged LaunchAgent ({SERVICE_LABEL}).\n  plist: {}\n  logs:  {}",
            plist.display(),
            log_dir.display()
        );
        Ok(())
    }

    pub(super) fn uninstall(_ctx: &ServiceContext) -> Result<()> {
        let plist = agent_path()?;
        if plist.exists() {
            let _ = launchctl(&["unload", "-w", &plist.display().to_string()]);
            std::fs::remove_file(&plist)
                .with_context(|| format!("removing {}", plist.display()))?;
            println!("Removed triaged LaunchAgent ({SERVICE_LABEL}).");
        } else {
            println!("triaged LaunchAgent is not installed.");
        }
        Ok(())
    }

    pub(super) fn start(_ctx: &ServiceContext) -> Result<()> {
        let status = launchctl(&["start", SERVICE_LABEL])?;
        if !status.success() {
            bail!("launchctl start failed; is the service installed? (triaged service install)");
        }
        println!("Started triaged.");
        Ok(())
    }

    pub(super) fn stop(_ctx: &ServiceContext) -> Result<()> {
        launchctl(&["stop", SERVICE_LABEL])?;
        println!("Stopped triaged.");
        Ok(())
    }

    pub(super) fn status(_ctx: &ServiceContext) -> Result<()> {
        let status = launchctl(&["list", SERVICE_LABEL])?;
        if !status.success() {
            println!("triaged is not loaded (run: triaged service install).");
        }
        Ok(())
    }
}

#[cfg(target_os = "linux")]
mod platform {
    use super::*;
    use std::process::Command;

    fn unit_name() -> String {
        format!("{SERVICE_NAME}.service")
    }

    fn unit_path() -> Result<PathBuf> {
        Ok(home_dir()?.join(".config/systemd/user").join(unit_name()))
    }

    fn systemctl(args: &[&str]) -> Result<std::process::ExitStatus> {
        Command::new("systemctl")
            .arg("--user")
            .args(args)
            .status()
            .context("running systemctl --user (is systemd available?)")
    }

    pub(super) fn install(ctx: &ServiceContext) -> Result<()> {
        let unit = unit_path()?;
        if let Some(parent) = unit.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("creating {}", parent.display()))?;
        }
        std::fs::write(&unit, systemd_unit_contents(&ctx.exe))
            .with_context(|| format!("writing {}", unit.display()))?;

        systemctl(&["daemon-reload"])?;
        let status = systemctl(&["enable", "--now", &unit_name()])?;
        if !status.success() {
            bail!(
                "systemctl --user enable --now failed; the unit was written to {} but not enabled",
                unit.display()
            );
        }
        println!(
            "Installed and started triaged systemd unit ({}).\n  unit: {}\n\
             Tip: run `loginctl enable-linger {}` to keep triaged running after you log out.",
            unit_name(),
            unit.display(),
            whoami()
        );
        Ok(())
    }

    pub(super) fn uninstall(_ctx: &ServiceContext) -> Result<()> {
        let unit = unit_path()?;
        if unit.exists() {
            let _ = systemctl(&["disable", "--now", &unit_name()]);
            std::fs::remove_file(&unit).with_context(|| format!("removing {}", unit.display()))?;
            systemctl(&["daemon-reload"])?;
            println!("Removed triaged systemd unit ({}).", unit_name());
        } else {
            println!("triaged systemd unit is not installed.");
        }
        Ok(())
    }

    pub(super) fn start(_ctx: &ServiceContext) -> Result<()> {
        let status = systemctl(&["start", &unit_name()])?;
        if !status.success() {
            bail!(
                "systemctl --user start failed; is the service installed? (triaged service install)"
            );
        }
        println!("Started triaged.");
        Ok(())
    }

    pub(super) fn stop(_ctx: &ServiceContext) -> Result<()> {
        systemctl(&["stop", &unit_name()])?;
        println!("Stopped triaged.");
        Ok(())
    }

    pub(super) fn status(_ctx: &ServiceContext) -> Result<()> {
        // `status` exits non-zero when inactive; surface its output regardless.
        systemctl(&["status", &unit_name()])?;
        Ok(())
    }

    fn whoami() -> String {
        std::env::var("USER").unwrap_or_else(|_| "<user>".to_string())
    }
}

#[cfg(target_os = "windows")]
mod platform {
    use super::*;
    use std::process::Command;

    fn schtasks(args: &[String]) -> Result<std::process::ExitStatus> {
        Command::new("schtasks")
            .args(args)
            .status()
            .context("running schtasks (is this Windows?)")
    }

    pub(super) fn install(ctx: &ServiceContext) -> Result<()> {
        let status = schtasks(&schtasks_create_args(&ctx.exe))?;
        if !status.success() {
            bail!("schtasks /Create failed; could not register the logon task");
        }
        // Start it now so the user doesn't have to log out and back in.
        let _ = schtasks(&[
            "/Run".to_string(),
            "/TN".to_string(),
            SERVICE_NAME.to_string(),
        ]);
        println!(
            "Installed and started triaged logon task ({SERVICE_NAME}). It will start automatically at each login."
        );
        Ok(())
    }

    pub(super) fn uninstall(_ctx: &ServiceContext) -> Result<()> {
        let _ = stop(_ctx);
        if let Some(path) = pid_file_path() {
            let _ = std::fs::remove_file(path);
        }
        let status = schtasks(&[
            "/Delete".to_string(),
            "/TN".to_string(),
            SERVICE_NAME.to_string(),
            "/F".to_string(),
        ])?;
        if status.success() {
            println!("Removed triaged logon task ({SERVICE_NAME}).");
        } else {
            println!("triaged logon task is not installed.");
        }
        Ok(())
    }

    pub(super) fn start(_ctx: &ServiceContext) -> Result<()> {
        let status = schtasks(&[
            "/Run".to_string(),
            "/TN".to_string(),
            SERVICE_NAME.to_string(),
        ])?;
        if !status.success() {
            bail!("schtasks /Run failed; is the service installed? (triaged service install)");
        }
        println!("Started triaged.");
        Ok(())
    }

    pub(super) fn stop(_ctx: &ServiceContext) -> Result<()> {
        // The logon task launches the daemon detached, so there's no task
        // instance to end — kill the process directly. Prefer the PID the daemon
        // recorded at startup so we stop exactly the service-managed daemon and
        // not a triaged the user started by hand. The PID file can be stale (the
        // daemon was force-killed last time, or its PID was reused), so only the
        // image-name-filtered kill counts as success; otherwise fall back to
        // killing by image name.
        let killed_by_pid = recorded_pid().is_some_and(taskkill_pid);
        if !killed_by_pid {
            // Fall back to killing by image name — but exclude our own PID: this
            // CLI (`triaged service stop` / `uninstall`) is itself a triaged.exe,
            // so a blanket `/IM` would terminate the command mid-run (e.g.
            // `uninstall` would never reach `schtasks /Delete`).
            let _ = Command::new("taskkill")
                .args([
                    "/FI",
                    "IMAGENAME eq triaged.exe",
                    "/FI",
                    &format!("PID ne {}", std::process::id()),
                    "/F",
                ])
                .status();
        }
        // Always drop the PID file so a stale PID is never read again.
        if let Some(path) = pid_file_path() {
            let _ = std::fs::remove_file(path);
        }
        println!("Stopped triaged.");
        Ok(())
    }

    /// Force-kill `pid`, but only if it is actually a `triaged.exe` (the recorded
    /// PID may be stale and reused by an unrelated process). Returns whether a
    /// matching process was killed.
    fn taskkill_pid(pid: u32) -> bool {
        Command::new("taskkill")
            .args([
                "/FI",
                &format!("PID eq {pid}"),
                "/FI",
                "IMAGENAME eq triaged.exe",
                "/F",
            ])
            .status()
            .is_ok_and(|status| status.success())
    }

    /// The PID the running daemon recorded at startup, if the file is present
    /// and parseable.
    fn recorded_pid() -> Option<u32> {
        let path = pid_file_path()?;
        std::fs::read_to_string(path)
            .ok()?
            .trim()
            .parse::<u32>()
            .ok()
    }

    pub(super) fn status(_ctx: &ServiceContext) -> Result<()> {
        let status = schtasks(&[
            "/Query".to_string(),
            "/TN".to_string(),
            SERVICE_NAME.to_string(),
        ])?;
        if !status.success() {
            println!("triaged logon task is not installed (run: triaged service install).");
        }
        Ok(())
    }
}

#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
mod platform {
    use super::*;

    fn unsupported() -> Result<()> {
        bail!(
            "`triaged service` is not supported on this platform; run `triaged` directly to start the daemon"
        )
    }

    pub(super) fn install(_ctx: &ServiceContext) -> Result<()> {
        unsupported()
    }
    pub(super) fn uninstall(_ctx: &ServiceContext) -> Result<()> {
        unsupported()
    }
    pub(super) fn start(_ctx: &ServiceContext) -> Result<()> {
        unsupported()
    }
    pub(super) fn stop(_ctx: &ServiceContext) -> Result<()> {
        unsupported()
    }
    pub(super) fn status(_ctx: &ServiceContext) -> Result<()> {
        unsupported()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn plist_embeds_exe_and_logs() {
        let body = plist_contents(
            Path::new("/usr/local/bin/triaged"),
            Path::new("/tmp/out.log"),
            Path::new("/tmp/err.log"),
        );
        assert!(body.contains("<string>com.hyeons-lab.triaged</string>"));
        assert!(body.contains("<string>/usr/local/bin/triaged</string>"));
        assert!(body.contains("<string>/tmp/out.log</string>"));
        assert!(body.contains("<string>/tmp/err.log</string>"));
        assert!(body.contains("<key>RunAtLoad</key>"));
        assert!(body.contains("<key>KeepAlive</key>"));
    }

    /// The daemon must stay supervised unconditionally, but a binary launchd
    /// cannot start — one macOS SIGKILLs for an invalid code signature after an
    /// in-place upgrade — must not be retried at launchd's 10s default forever.
    #[test]
    fn plist_keeps_alive_unconditionally_and_throttles_respawns() {
        let body = plist_contents(
            Path::new("/usr/local/bin/triaged"),
            Path::new("/tmp/out.log"),
            Path::new("/tmp/err.log"),
        );

        // KeepAlive must stay unconditional. Making it depend on SuccessfulExit
        // would stop launchd respawning the job after a handover's clean exit,
        // which is how supervision returns to a launchd-owned process.
        assert!(
            body.contains("<key>KeepAlive</key>\n    <true/>"),
            "KeepAlive must be unconditional <true/>: {body}"
        );

        // The value must belong to the ThrottleInterval key, not merely appear
        // somewhere in the plist. (That it exceeds launchd's 10s default is
        // pinned by a compile-time assertion on the constant itself.)
        let throttle = body
            .split_once("<key>ThrottleInterval</key>")
            .expect("plist declares ThrottleInterval")
            .1;
        assert!(
            throttle
                .trim_start()
                .starts_with(&format!("<integer>{THROTTLE_INTERVAL_SECS}</integer>")),
            "ThrottleInterval must be followed by its value: {throttle}"
        );
    }

    #[test]
    fn plist_escapes_xml_metacharacters() {
        let body = plist_contents(
            Path::new("/home/a&b/triaged"),
            Path::new("/tmp/out.log"),
            Path::new("/tmp/err.log"),
        );
        assert!(body.contains("/home/a&amp;b/triaged"));
        assert!(!body.contains("a&b/triaged"));
    }

    #[test]
    fn systemd_unit_quotes_execstart_and_restarts() {
        let unit = systemd_unit_contents(Path::new("/home/me/.cargo/bin/triaged"));
        assert!(unit.contains("ExecStart=\"/home/me/.cargo/bin/triaged\""));
        assert!(unit.contains("Restart=on-failure"));
        assert!(unit.contains("WantedBy=default.target"));
    }

    #[test]
    fn schtasks_args_create_a_windowless_logon_task() {
        let args = schtasks_create_args(Path::new(r"C:\Users\me\triaged.exe"));
        assert_eq!(args[0], "/Create");
        // The task name and logon schedule are present.
        let joined = args.join(" ");
        assert!(joined.contains("/TN triaged"));
        assert!(joined.contains("/SC ONLOGON"));
        // The run command detaches from a console so no window flashes at logon.
        assert!(
            args.iter()
                .any(|a| a.contains(r#"start "" /b "C:\Users\me\triaged.exe""#))
        );
    }
}