wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
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
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
//! The daemon as a service this machine's supervisor keeps.
//!
//! # Why a machine would want one
//!
//! The daemon starts itself: the first command that needs a worktree brings
//! one up and leaves it running. That is right for a laptop somebody works on
//! and wrong for everything else. A machine that reboots comes back with no
//! daemon, so nothing serves the workbench until somebody opens a terminal and
//! runs a command in the right directory; a build host with no interactive
//! session never gets one at all. And a daemon started from a terminal dies
//! with the session that started it on any system configured to clean up after
//! a logout.
//!
//! So the daemon can also be a service. The supervisor starts it at login,
//! restarts it when it fails, and gives it a cgroup — or a launchd job — that
//! belongs to the daemon rather than to whichever window happened to be open.
//!
//! # One daemon either way
//!
//! Installing a service does not change what the daemon IS. It is the same
//! executable, with the same arguments — [`super::daemon_arguments`], which
//! [`super::ensure_running`] also builds — writing to the same log, publishing
//! the same rendezvous file. A command that needs a daemon finds the one the
//! supervisor started through the rendezvous exactly as it finds one it
//! started itself, and never starts a second.
//!
//! # A declaration, and what is rendered from it
//!
//! The unit file is not the record. [`Record`] is: a small JSON file in the
//! machine directory naming the worktree, the port, the executable and the
//! environment the service was installed with. Every definition is RENDERED
//! from it, which is what lets `enable` and `disable` rewrite a launchd plist
//! without re-deriving what was in it, and what lets [`status`] report that
//! the definition on disk is no longer what the record renders — the state a
//! machine lands in when wsb is rebuilt somewhere else.
//!
//! # What is deliberately baked, and why
//!
//! `PATH`, as it stood when the service was installed. A supervisor does not
//! start a login shell: a systemd user service inherits the systemd manager's
//! environment and a launchd agent inherits launchd's, and neither has the
//! `PATH` an operator's shell has. The daemon runs cargo, xcodebuild and every
//! other tool an operation needs, so a service without this starts a daemon
//! that cannot build anything and says "command not found" to work that
//! succeeds from a terminal. Reinstalling re-bakes it, and [`status`] prints
//! what is in force.
//!
//! The machine directory and `PRJ`, for the same reason. A daemon works out
//! where its state is from its environment ([`super::machine_dir`]), and one
//! started without `PRJ` kept a second, empty machine of its own in `~/.wsb`:
//! a new identity, no pairings, and a daemon that spent minutes reading every
//! agent session into a database nobody looked at. The service is handed the
//! directory the installing command resolved, and the `PRJ` it ran with.

use std::path::{Path, PathBuf};
use std::time::Duration;

use super::launch::Spawn;

/// The label and unit name the service is known by.
///
/// One per machine, not one per worktree, because the daemon is one per
/// machine: it serves every worktree registered with it. A second unit would
/// be a second daemon racing the first for the port and the rendezvous file.
pub const LABEL: &str = "com.machinefabric.wsb-daemon";

/// The systemd unit name, which is the label's own convention rather than
/// launchd's.
pub const UNIT: &str = "wsb-daemon.service";

/// Which supervisor keeps services on this machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Supervisor {
    /// A systemd USER manager — `systemctl --user`. Not the system one: the
    /// daemon holds an operator's credential store and runs as that operator,
    /// and a unit under the system manager would be a service running as root
    /// against a home directory it does not own.
    Systemd,
    /// launchd, as a per-user agent in `~/Library/LaunchAgents`.
    Launchd,
}

impl Supervisor {
    pub fn describe(self) -> &'static str {
        match self {
            Self::Systemd => "systemd (--user)",
            Self::Launchd => "launchd (user agent)",
        }
    }
}

/// The supervisor for this machine, or a refusal naming what is missing.
///
/// A platform with neither is told so plainly rather than handed a definition
/// nothing reads. The daemon still starts on demand there, which is what the
/// refusal says.
pub fn supervisor() -> Result<Supervisor, String> {
    if cfg!(target_os = "macos") {
        return Ok(Supervisor::Launchd);
    }
    if cfg!(target_os = "linux") {
        // The USER manager has to be reachable, not merely installed. A
        // container, a chroot and an ssh session without lingering all have
        // systemd on disk and no user bus to talk to, and a unit written for a
        // manager that will never read it is a file that does nothing.
        if user_manager_reachable() {
            return Ok(Supervisor::Systemd);
        }
        return Err(
            "this machine has no reachable systemd user manager, so there is nothing to \
             install a service into.\n  \
             `systemctl --user` must answer; on a machine with no login session that \
             usually means `loginctl enable-linger $USER`.\n  \
             Without a service the daemon still starts by itself with the next command."
                .to_string(),
        );
    }
    Err(format!(
        "wsb does not know how to install a service on {}.\n  \
         The daemon still starts by itself with the next command that needs one.",
        std::env::consts::OS
    ))
}

fn user_manager_reachable() -> bool {
    crate::git_backend::command_without_repo_scope("systemctl")
        .args(["--user", "is-system-running"])
        .output()
        .is_ok_and(|out| {
            // `is-system-running` exits non-zero for "degraded" and "starting",
            // both of which are managers that answer. What matters is that it
            // ANSWERED: a manager with no bus prints "Failed to connect".
            let said = String::from_utf8_lossy(&out.stdout);
            let state = said.trim();
            !state.is_empty() && state != "offline"
        })
}

/// What the service was installed with, and what every definition is rendered
/// from.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Record {
    /// The daemon executable, absolute. Named rather than looked up: the pair
    /// is installed together, and a service that resolved `wsb-daemon` on
    /// `PATH` would start whichever one was installed last.
    pub executable: PathBuf,
    /// The arguments it is started with — [`super::daemon_arguments`], with
    /// the port already resolved to a number.
    pub arguments: Vec<String>,
    /// The worktree the daemon opens first. Others register themselves as
    /// commands are run in them.
    pub worktree_root: PathBuf,
    /// Where its output goes. The same file an on-demand daemon writes to, so
    /// `wsb daemon log` has one answer.
    pub log: PathBuf,
    /// The `PATH` baked into the definition.
    pub path: String,
    /// The machine directory the installing command resolved, which the
    /// daemon is handed as `WSB_MACHINE_DIR`.
    pub machine_dir: PathBuf,
    /// `PRJ` as the installing command had it, when it had one: where the
    /// daemon finds the projects it lists and the GitHub mirror it keeps.
    pub prj: Option<PathBuf>,
    /// Whether the supervisor starts it at login.
    pub autostart: bool,
}

/// Where the record lives: the machine directory, beside the rendezvous file.
pub fn record_path(state_dir: &Path) -> PathBuf {
    state_dir.join("service.json")
}

/// The record, or `None` where no service has been installed.
pub fn record(state_dir: &Path) -> Result<Option<Record>, String> {
    let path = record_path(state_dir);
    match std::fs::read_to_string(&path) {
        Ok(text) => serde_json::from_str(&text).map(Some).map_err(|e| {
            // An older wsb named the daemon's worktree `workspace_root`: that
            // record is `wsb migrate`'s to bring over, not this reader's to
            // guess at.
            if serde_json::from_str::<serde_json::Value>(&text)
                .is_ok_and(|value| value.get("workspace_root").is_some())
            {
                return format!(
                    "{} was written by an older wsb; run `wsb migrate` (with the daemon stopped: \
                     `wsb daemon stop`), then `wsb daemon install`",
                    path.display()
                );
            }
            format!(
                "{} is not a service record this wsb reads ({e}); `wsb daemon install` writes \
                 one, and keeps whether the service starts at login",
                path.display()
            )
        }),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(format!("cannot read {}: {e}", path.display())),
    }
}

fn write_record(state_dir: &Path, record: &Record) -> Result<(), String> {
    let path = record_path(state_dir);
    let text = serde_json::to_string_pretty(record)
        .map_err(|e| format!("cannot write the service record: {e}"))?;
    std::fs::write(&path, format!("{text}\n"))
        .map_err(|e| format!("cannot write {}: {e}", path.display()))
}

/// Where the supervisor reads its definition from.
pub fn definition_path(supervisor: Supervisor) -> Result<PathBuf, String> {
    let home = home()?;
    Ok(match supervisor {
        Supervisor::Systemd => config_home()?.join("systemd/user").join(UNIT),
        Supervisor::Launchd => home
            .join("Library/LaunchAgents")
            .join(format!("{LABEL}.plist")),
    })
}

fn home() -> Result<PathBuf, String> {
    std::env::var_os("HOME")
        .filter(|v| !v.is_empty())
        .map(PathBuf::from)
        .ok_or_else(|| "HOME is not set, so there is no user to install a service for".to_string())
}

fn config_home() -> Result<PathBuf, String> {
    match std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
        Some(explicit) => Ok(PathBuf::from(explicit)),
        None => Ok(home()?.join(".config")),
    }
}

/// Put the service on this machine, or bring an installed one up to date.
///
/// Autostart is NOT changed here. Installing says the machine knows how to run
/// the daemon as a service; whether it does so at login is `enable`, said
/// separately, because those are two decisions and an install that quietly
/// made the second one would be one an operator never took.
///
/// Re-installable by design. An wsb built somewhere else, a different port, a
/// changed `PATH` — every one of them is a reason to run this again, and the
/// definition is rewritten from the record rather than merged into.
pub fn install(
    state_dir: &Path,
    worktree_root: &Path,
    with: &Spawn,
) -> Result<Record, String> {
    let supervisor = supervisor()?;
    let executable = super::daemon_executable()?;
    let arguments = super::daemon_arguments(worktree_root, with)?;
    let existing = installed_autostart(state_dir)?;
    let record = Record {
        executable,
        arguments,
        worktree_root: worktree_root.to_path_buf(),
        log: super::log_path(state_dir),
        path: std::env::var("PATH").unwrap_or_default(),
        machine_dir: state_dir.to_path_buf(),
        prj: std::env::var_os("PRJ").filter(|v| !v.is_empty()).map(PathBuf::from),
        // An install over an enabled service keeps it enabled. The operator
        // asked for a newer definition, not for the machine to stop starting
        // it — and a reinstall that silently turned autostart off would be
        // found at the next reboot.
        autostart: existing,
    };
    apply(supervisor, &record)?;
    write_record(state_dir, &record)?;
    Ok(record)
}

/// Whether the service installed here starts at login: false where none is.
///
/// Read from the record as a field rather than as a [`Record`], because an
/// install is what replaces a record an older wsb wrote — one this wsb cannot
/// read whole — and the one thing it carries over is this.
fn installed_autostart(state_dir: &Path) -> Result<bool, String> {
    let path = record_path(state_dir);
    let text = match std::fs::read_to_string(&path) {
        Ok(text) => text,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
        Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
    };
    let value: serde_json::Value = serde_json::from_str(&text)
        .map_err(|e| format!("{} is not JSON: {e}", path.display()))?;
    value
        .get("autostart")
        .and_then(serde_json::Value::as_bool)
        .ok_or_else(|| format!("{} does not say whether the service starts at login", path.display()))
}

/// Start the daemon at login, and now.
///
/// "And now" because the alternative is a command that reports success and
/// changes nothing an operator can see until the next reboot.
pub fn enable(state_dir: &Path) -> Result<Record, String> {
    set_autostart(state_dir, true)
}

/// Stop starting the daemon at login.
///
/// A daemon that is already running keeps running. Stopping it is
/// `wsb daemon stop`, which is a separate act with a separate consequence —
/// the store it holds goes with it — and folding that into "do not start this
/// at login" would end an operator's unlocked session as a side effect of a
/// decision about next Tuesday.
pub fn disable(state_dir: &Path) -> Result<Record, String> {
    set_autostart(state_dir, false)
}

fn set_autostart(state_dir: &Path, autostart: bool) -> Result<Record, String> {
    let supervisor = supervisor()?;
    let Some(mut record) = record(state_dir)? else {
        return Err(format!(
            "no service is installed on this machine, so there is nothing to {}.\n  \
             `wsb daemon install` puts one on it.",
            if autostart { "enable" } else { "disable" }
        ));
    };
    record.autostart = autostart;
    apply(supervisor, &record)?;
    write_record(state_dir, &record)?;
    Ok(record)
}

/// Take the service off this machine.
///
/// The daemon itself is untouched: a running one goes on running, and the next
/// command that needs one starts it the way it did before there was a service.
pub fn uninstall(state_dir: &Path) -> Result<bool, String> {
    let supervisor = supervisor()?;
    let definition = definition_path(supervisor)?;
    let had = record(state_dir)?.is_some() || definition.exists();
    if !had {
        return Ok(false);
    }
    match supervisor {
        Supervisor::Systemd => {
            // Disabled before the file goes, because `disable` reads the file
            // to know what to unlink.
            let _ = systemctl(&["disable", UNIT]);
            remove(&definition)?;
            systemctl(&["daemon-reload"])?;
        }
        Supervisor::Launchd => {
            let _ = launchctl(&["bootout".to_string(), service_target()?]);
            remove(&definition)?;
        }
    }
    let record = record_path(state_dir);
    if record.exists() {
        remove(&record)?;
    }
    Ok(true)
}

fn remove(path: &Path) -> Result<(), String> {
    match std::fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(format!("cannot remove {}: {e}", path.display())),
    }
}

/// What [`apply`] asks of a supervisor.
///
/// A trait so the order of those requests can be held to what a supervisor
/// actually does with them, by a stand-in that behaves like one. Installing a
/// real service in a test would leave one behind on whichever machine ran it.
trait Control {
    fn systemctl(&mut self, arguments: &[&str]) -> Result<String, String>;
    fn launchctl(&mut self, arguments: &[String]) -> Result<String, String>;
    /// Whether launchd still knows the job at `target`.
    fn launchd_loaded(&mut self, target: &str) -> Result<bool, String>;
}

/// This machine's supervisor.
struct System;

impl Control for System {
    fn systemctl(&mut self, arguments: &[&str]) -> Result<String, String> {
        systemctl(arguments)
    }

    fn launchctl(&mut self, arguments: &[String]) -> Result<String, String> {
        launchctl(arguments)
    }

    fn launchd_loaded(&mut self, target: &str) -> Result<bool, String> {
        let output = crate::git_backend::command_without_repo_scope("launchctl")
            .args(["print", target])
            .output()
            .map_err(|e| format!("cannot run launchctl: {e}"))?;
        // 113 is launchctl's "Could not find service". Any other failure is
        // not an answer to the question, and reading it as "gone" would hand
        // the next step a job launchd may still hold.
        match output.status.code() {
            Some(0) => Ok(true),
            Some(LAUNCHCTL_NO_SUCH_SERVICE) => Ok(false),
            code => Err(format!(
                "launchctl print {target} failed ({}): {}",
                code.map_or("killed by a signal".to_string(), |code| format!("exit {code}")),
                String::from_utf8_lossy(&output.stderr).trim()
            )),
        }
    }
}

const LAUNCHCTL_NO_SUCH_SERVICE: i32 = 113;

/// How long launchd may take to let go of a booted-out job.
///
/// The daemon is sent SIGTERM and shuts down in order, and launchd waits up to
/// its default `ExitTimeOut` of 20 seconds before killing it. Past that and a
/// margin, the job is not leaving.
const BOOTOUT_TIMEOUT: Duration = Duration::from_secs(30);

const BOOTOUT_POLL: Duration = Duration::from_millis(100);

/// Write the definition and tell the supervisor about it.
fn apply(supervisor: Supervisor, record: &Record) -> Result<(), String> {
    apply_with(&mut System, supervisor, record, &definition_path(supervisor)?, BOOTOUT_TIMEOUT)
}

fn apply_with(
    control: &mut impl Control,
    supervisor: Supervisor,
    record: &Record,
    definition: &Path,
    bootout_within: Duration,
) -> Result<(), String> {
    if let Some(parent) = definition.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
    }
    let rendered = render(supervisor, record);
    let previous = match std::fs::read_to_string(definition) {
        Ok(text) => Some(text),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
        Err(e) => return Err(format!("cannot read {}: {e}", definition.display())),
    };
    std::fs::write(definition, &rendered)
        .map_err(|e| format!("cannot write {}: {e}", definition.display()))?;

    match supervisor {
        Supervisor::Systemd => {
            control.systemctl(&["daemon-reload"])?;
            // A running unit goes on running the definition it was started
            // from, whatever `daemon-reload` read: a reinstall that changed the
            // executable or the environment would report success and change
            // nothing until the next login. `try-restart` restarts a running
            // unit and leaves a stopped one stopped, which is the next step's
            // decision. An unchanged definition is not restarted — `disable`
            // never changes the unit, and must not end the daemon.
            if previous.as_deref() != Some(rendered.as_str()) {
                control.systemctl(&["try-restart", UNIT])?;
            }
            if record.autostart {
                // `--now` starts it as well, which is what makes enabling
                // visible immediately rather than at the next login.
                control.systemctl(&["enable", "--now", UNIT])?;
            } else {
                control.systemctl(&["disable", UNIT])?;
            }
        }
        Supervisor::Launchd => {
            let target = service_target()?;
            // Booted out first, always: launchd reads the plist when the job
            // is bootstrapped and not again, so a rewritten file has no effect
            // on a loaded job. A job that was not loaded makes this fail, and
            // that failure is not one — hence the discarded result.
            let _ = control.launchctl(&["bootout".to_string(), target.clone()]);
            // `bootout` returns while the daemon is still shutting down, and
            // launchd refuses to bootstrap a label it still holds ("Bootstrap
            // failed: 5: Input/output error"). Asked straight away, an install
            // over a running service failed every time.
            wait_until_booted_out(control, &target, bootout_within)?;
            if record.autostart {
                control.launchctl(&["bootstrap".to_string(), domain()?, definition_string(definition)])?;
                // `RunAtLoad` starts it on bootstrap, but a job that launchd
                // considers already started would not be. Kickstart says
                // "running now" without caring which it was.
                control.launchctl(&["kickstart".to_string(), target])?;
            }
            // Not bootstrapped at all when autostart is off. launchd has no
            // "loaded but not at login" state that survives a reboot, so the
            // honest rendering of "do not start this" is a plist on disk that
            // nothing has been asked to load.
        }
    }
    Ok(())
}

fn wait_until_booted_out(control: &mut impl Control, target: &str, within: Duration) -> Result<(), String> {
    let deadline = std::time::Instant::now() + within;
    while control.launchd_loaded(target)? {
        if std::time::Instant::now() >= deadline {
            return Err(format!(
                "launchd still holds {LABEL} {}s after it was booted out, so the new definition \
                 cannot be loaded in its place.\n  \
                 `launchctl print {target}` shows what it is doing; once it is gone, run this again.",
                within.as_secs()
            ));
        }
        std::thread::sleep(BOOTOUT_POLL);
    }
    Ok(())
}

fn definition_string(path: &Path) -> String {
    path.display().to_string()
}

fn domain() -> Result<String, String> {
    Ok(format!("gui/{}", uid()))
}

fn service_target() -> Result<String, String> {
    Ok(format!("gui/{}/{LABEL}", uid()))
}

#[cfg(unix)]
fn uid() -> u32 {
    // SAFETY: `getuid` takes no arguments, touches no memory and cannot fail.
    unsafe { libc::getuid() }
}

#[cfg(not(unix))]
fn uid() -> u32 {
    0
}

/// The definition a supervisor reads, rendered from the record.
///
/// Public because it is what a test compares: the same record must render the
/// same bytes, and the arguments in it must be the ones the daemon is started
/// with on demand.
pub fn render(supervisor: Supervisor, record: &Record) -> String {
    match supervisor {
        Supervisor::Systemd => render_unit(record),
        Supervisor::Launchd => render_plist(record),
    }
}

fn render_unit(record: &Record) -> String {
    let mut exec = vec![systemd_quote(&record.executable.display().to_string())];
    exec.extend(record.arguments.iter().map(|argument| systemd_quote(argument)));
    let log = record.log.display();
    format!(
        "# Written by `wsb daemon install`, rendered from the machine's service\n\
         # record. Edits here are lost the next time that is written.\n\
         [Unit]\n\
         Description=wsb daemon for {worktree}\n\
         After=default.target\n\
         \n\
         [Service]\n\
         Type=simple\n\
         ExecStart={exec}\n\
         # Failures only. `wsb daemon stop` exits zero, and a supervisor that\n\
         # started a new daemon on top of a deliberate stop would make the\n\
         # command impossible to obey.\n\
         Restart=on-failure\n\
         RestartSec=5\n\
         # A supervisor does not start a login shell. Without this the daemon\n\
         # has the manager's PATH, and every operation that needs a toolchain\n\
         # fails with 'command not found' on a machine where it is installed.\n\
         Environment=PATH={path}\n\
         # Where its state is and where the projects are, as the installing\n\
         # command resolved them: a daemon that worked them out again without\n\
         # PRJ kept a second, empty machine of its own.\n\
         {environment}\
         # The same file an on-demand daemon writes to, so `wsb daemon log`\n\
         # has one answer whichever started it.\n\
         StandardOutput=append:{log}\n\
         StandardError=append:{log}\n\
         # The daemon is holding every other surface open; a build it started\n\
         # can be run again. systemd-oomd is asked to look elsewhere first.\n\
         ManagedOOMPreference=avoid\n\
         \n\
         [Install]\n\
         WantedBy=default.target\n",
        worktree = record.worktree_root.display(),
        exec = exec.join(" "),
        path = record.path,
        environment = state_environment(record)
            .iter()
            .map(|(name, value)| format!("Environment={}\n", systemd_quote(&format!("{name}={value}"))))
            .collect::<String>(),
    )
}

/// One systemd command-line word.
///
/// systemd splits `ExecStart` on whitespace and understands its own quoting,
/// so a worktree under a path with a space in it is the difference between a
/// daemon that starts and one whose unit will not even load.
fn systemd_quote(word: &str) -> String {
    let plain = !word.is_empty()
        && word
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || "-_./:=@+,".contains(c));
    if plain {
        return word.to_string();
    }
    let escaped = word.replace('\\', "\\\\").replace('"', "\\\"");
    format!("\"{escaped}\"")
}

fn render_plist(record: &Record) -> String {
    let mut arguments = vec![record.executable.display().to_string()];
    arguments.extend(record.arguments.iter().cloned());
    let program: String = arguments
        .iter()
        .map(|argument| format!("    <string>{}</string>\n", xml(argument)))
        .collect();
    let log = xml(&record.log.display().to_string());
    format!(
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
         <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
         <!-- Written by `wsb daemon install`, rendered from the machine's\n\
              service record. Edits here are lost the next time it is written. -->\n\
         <plist version=\"1.0\">\n\
         <dict>\n\
         \x20 <key>Label</key>\n\
         \x20 <string>{LABEL}</string>\n\
         \x20 <key>ProgramArguments</key>\n\
         \x20 <array>\n{program}\
         \x20 </array>\n\
         \x20 <key>RunAtLoad</key>\n\
         \x20 <{run}/>\n\
         \x20 <!-- Failures only: a deliberate `wsb daemon stop` exits zero. -->\n\
         \x20 <key>KeepAlive</key>\n\
         \x20 <dict>\n\
         \x20   <key>SuccessfulExit</key>\n\
         \x20   <false/>\n\
         \x20 </dict>\n\
         \x20 <!-- launchd does not start a login shell. Without this the daemon\n\
         \x20      cannot find the toolchains every operation needs. -->\n\
         \x20 <key>EnvironmentVariables</key>\n\
         \x20 <dict>\n\
         \x20   <key>PATH</key>\n\
         \x20   <string>{path}</string>\n\
         {environment}\
         \x20 </dict>\n\
         \x20 <key>StandardOutPath</key>\n\
         \x20 <string>{log}</string>\n\
         \x20 <key>StandardErrorPath</key>\n\
         \x20 <string>{log}</string>\n\
         \x20 <!-- Standard, not Background: launchd throttles a Background job's\n\
         \x20      processor and disk, and the daemon then took minutes to start\n\
         \x20      and seconds for every answer, while it serves a page somebody\n\
         \x20      is waiting on and runs the builds they asked for. -->\n\
         \x20 <key>ProcessType</key>\n\
         \x20 <string>Standard</string>\n\
         </dict>\n\
         </plist>\n",
        run = if record.autostart { "true" } else { "false" },
        path = xml(&record.path),
        environment = state_environment(record)
            .iter()
            .map(|(name, value)| {
                format!("    <key>{name}</key>\n    <string>{}</string>\n", xml(value))
            })
            .collect::<String>(),
    )
}

/// Where the daemon's state is and where the projects are, as variables: the
/// machine directory always, `PRJ` when the install had one.
fn state_environment(record: &Record) -> Vec<(&'static str, String)> {
    let mut environment = vec![(super::machine::MACHINE_DIR_ENV, record.machine_dir.display().to_string())];
    if let Some(prj) = &record.prj {
        environment.push(("PRJ", prj.display().to_string()));
    }
    environment
}

/// One string, safe inside a plist element.
fn xml(text: &str) -> String {
    text.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

/// What this machine's supervisor says about the service.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Status {
    pub supervisor: Supervisor,
    pub definition: PathBuf,
    /// Whether a record and a definition are both there.
    pub installed: bool,
    /// Whether the supervisor starts it at login.
    pub autostart: bool,
    /// Whether the supervisor is running it right now, and as what.
    pub active: bool,
    pub pid: Option<u32>,
    /// Set when the definition on disk is not what the record renders — an
    /// wsb rebuilt elsewhere, a hand-edited unit, a `PATH` that has moved on.
    pub drifted: bool,
    /// systemd only: whether this user's services survive a logout. Without
    /// lingering, "at login" means exactly that, and a headless machine that
    /// nobody logs into never starts the daemon at all.
    pub linger: Option<bool>,
}

/// Whether what a supervisor would read is still what the record says.
///
/// Taken as text rather than read here, so the rule is testable without a
/// supervisor, a home directory or a file: this is a comparison, and the
/// reason it exists is that the answer must be no after wsb is rebuilt
/// somewhere else — the unit still names the old executable, which is gone.
///
/// A record with no definition on disk is NOT drift. That is a half-install,
/// which `installed` already reports, and calling it drift would send an
/// operator to `install` with the wrong reason for going.
pub fn drifted(supervisor: Supervisor, record: &Record, on_disk: Option<&str>) -> bool {
    match on_disk {
        Some(text) => text != render(supervisor, record),
        None => false,
    }
}

/// What is installed and what the supervisor is doing with it.
///
/// Reported from the supervisor rather than from the record wherever the
/// supervisor is the one who knows: whether the job is running is not a fact
/// about a file wsb wrote.
pub fn status(state_dir: &Path) -> Result<Status, String> {
    let supervisor = supervisor()?;
    let definition = definition_path(supervisor)?;
    let record = record(state_dir)?;
    let installed = record.is_some() && definition.exists();
    let drifted = match &record {
        Some(record) => drifted(
            supervisor,
            record,
            std::fs::read_to_string(&definition).ok().as_deref(),
        ),
        None => false,
    };
    let (active, pid) = running(supervisor);
    Ok(Status {
        supervisor,
        definition,
        installed,
        autostart: record.map(|record| record.autostart).unwrap_or(false),
        active,
        pid,
        drifted,
        linger: match supervisor {
            Supervisor::Systemd => linger(),
            Supervisor::Launchd => None,
        },
    })
}

fn running(supervisor: Supervisor) -> (bool, Option<u32>) {
    match supervisor {
        Supervisor::Systemd => {
            let Ok(output) = crate::git_backend::command_without_repo_scope("systemctl")
                .args(["--user", "show", UNIT, "--property=ActiveState,MainPID"])
                .output()
            else {
                return (false, None);
            };
            let said = String::from_utf8_lossy(&output.stdout);
            let mut active = false;
            let mut pid = None;
            for line in said.lines() {
                if let Some(state) = line.strip_prefix("ActiveState=") {
                    active = state == "active" || state == "activating";
                }
                if let Some(number) = line.strip_prefix("MainPID=") {
                    pid = number.parse::<u32>().ok().filter(|pid| *pid != 0);
                }
            }
            (active, pid)
        }
        Supervisor::Launchd => {
            let Ok(target) = service_target() else {
                return (false, None);
            };
            let Ok(output) = crate::git_backend::command_without_repo_scope("launchctl")
                .args(["print", &target])
                .output()
            else {
                return (false, None);
            };
            if !output.status.success() {
                return (false, None);
            }
            let said = String::from_utf8_lossy(&output.stdout);
            let pid = said.lines().find_map(|line| {
                line.trim()
                    .strip_prefix("pid = ")
                    .and_then(|number| number.trim().parse::<u32>().ok())
            });
            let state_running = said
                .lines()
                .any(|line| line.trim() == "state = running");
            (pid.is_some() || state_running, pid)
        }
    }
}

fn linger() -> Option<bool> {
    let user = std::env::var("USER").ok()?;
    let output = crate::git_backend::command_without_repo_scope("loginctl")
        .args(["show-user", &user, "--property=Linger"])
        .output()
        .ok()?;
    let said = String::from_utf8_lossy(&output.stdout);
    said.lines()
        .find_map(|line| line.strip_prefix("Linger="))
        .map(|value| value.trim() == "yes")
}

/// Ask the supervisor to restart the service it is keeping.
///
/// Used by `wsb daemon restart` when a service is active: stopping the daemon
/// out from under a supervisor that is watching it means the supervisor
/// decides what happens next, and a command line starting a replacement
/// alongside is how a machine ends up with two.
pub fn restart() -> Result<(), String> {
    match supervisor()? {
        Supervisor::Systemd => systemctl(&["restart", UNIT]).map(|_| ()),
        Supervisor::Launchd => {
            launchctl(&["kickstart".to_string(), "-k".to_string(), service_target()?]).map(|_| ())
        }
    }
}

fn systemctl(arguments: &[&str]) -> Result<String, String> {
    let mut full = vec!["--user"];
    full.extend_from_slice(arguments);
    run("systemctl", &full.iter().map(|a| a.to_string()).collect::<Vec<_>>())
}

fn launchctl(arguments: &[String]) -> Result<String, String> {
    run("launchctl", arguments)
}

fn run(program: &str, arguments: &[String]) -> Result<String, String> {
    let output = crate::git_backend::command_without_repo_scope(program)
        .args(arguments)
        .output()
        .map_err(|e| format!("cannot run {program}: {e}"))?;
    if !output.status.success() {
        let said = String::from_utf8_lossy(&output.stderr);
        let said = said.trim();
        return Err(format!(
            "{program} {} failed{}",
            arguments.join(" "),
            if said.is_empty() {
                String::new()
            } else {
                format!(": {said}")
            }
        ));
    }
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

/// The spawn settings a service is installed with.
///
/// Loopback, exactly as an on-demand start uses
/// — with the port RESOLVED rather than probed at every start, because a
/// definition has to name a number. An operator who wants a particular one
/// says so; otherwise the first free port from the default is taken once, here,
/// and kept.
pub fn spawn_for_service(port: Option<u16>, peer_port: Option<u16>) -> Spawn {
    Spawn {
        listen: super::LOOPBACK,
        http_port: match port {
            Some(port) => super::Port::Exact(port),
            None => super::Port::FirstFreeFrom(super::DEFAULT_HTTP_PORT),
        },
        // Nothing to outlive. A service belongs to the machine, not to the
        // command that installed it.
        parent_pid: None,
        debug: false,
        peer_port: peer_port.unwrap_or(crate::peer::DEFAULT_PEER_PORT),
    }
}

/// How long to wait for a service the supervisor has just been asked to start.
pub const START_TIMEOUT: Duration = Duration::from_secs(20);

#[cfg(test)]
#[path = "service_tests.rs"]
mod tests;