Skip to main content

studio_worker/
service.rs

1//! OS service install/uninstall.  Linux: systemd --user.  macOS: launchd
2//! plist template (written but not loaded — operator runs `launchctl load`).
3//! Windows: schtasks template (written but not registered — operator runs
4//! the printed command).
5//!
6//! All system side-effects (Command::status, fs writes) flow through the
7//! `ServiceOps` trait so the public install/uninstall functions can be
8//! unit-tested without touching the real OS.
9use anyhow::{Context, Result};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use tracing::{info, warn};
13
14const TRACE_TARGET: &str = "studio_worker::service";
15
16/// Outcome of running a single OS command step (e.g. `systemctl
17/// daemon-reload`, `launchctl unload`, `schtasks /Delete`).  Splits the
18/// three observable states so callers can compose them into an overall
19/// activation/deactivation success and so each one emits a distinct
20/// structured tracing event instead of being silently swallowed.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub(crate) enum StepOutcome {
23    /// Command spawned and exited with a zero status.
24    Succeeded,
25    /// Command spawned but exited non-zero (with the captured code if any).
26    Failed { code: Option<i32> },
27    /// Spawn itself failed — tool missing on PATH, permission denied, etc.
28    SpawnFailed,
29}
30
31impl StepOutcome {
32    pub(crate) fn is_success(self) -> bool {
33        matches!(self, StepOutcome::Succeeded)
34    }
35}
36
37/// Pure mapping from a `Command::status()` result onto [`StepOutcome`].
38pub(crate) fn classify_status(status: std::io::Result<std::process::ExitStatus>) -> StepOutcome {
39    match status {
40        Ok(s) if s.success() => StepOutcome::Succeeded,
41        Ok(s) => StepOutcome::Failed { code: s.code() },
42        Err(_) => StepOutcome::SpawnFailed,
43    }
44}
45
46/// Run a single command step and emit a structured tracing event for
47/// the outcome.  Returns the classified [`StepOutcome`] so callers can
48/// chain steps and short-circuit on failure.  Without this every
49/// `RealOps::activate` / `deactivate` step would silently swallow
50/// failures of systemctl / launchctl / schtasks.
51fn run_step(op: &'static str, step: &'static str, mut cmd: Command) -> StepOutcome {
52    let started = std::time::Instant::now();
53    let status = cmd.status();
54    let elapsed_ms = started.elapsed().as_millis() as u64;
55    // Capture the spawn error's text before `classify_status` consumes the
56    // result and drops it; a SpawnFailed otherwise loses its root cause.
57    let spawn_error = match &status {
58        Err(e) => Some(e.to_string()),
59        Ok(_) => None,
60    };
61    let outcome = classify_status(status);
62    match outcome {
63        StepOutcome::Succeeded => {
64            info!(
65                target: TRACE_TARGET,
66                op,
67                step,
68                elapsed_ms,
69                "service step succeeded"
70            );
71        }
72        StepOutcome::Failed { code } => {
73            warn!(
74                target: TRACE_TARGET,
75                op,
76                step,
77                elapsed_ms,
78                exit_code = code,
79                "service step exited non-zero"
80            );
81        }
82        StepOutcome::SpawnFailed => {
83            warn!(
84                target: TRACE_TARGET,
85                op,
86                step,
87                elapsed_ms,
88                error = spawn_error.as_deref().unwrap_or("unknown"),
89                "service step could not be spawned (tool missing on PATH?)"
90            );
91        }
92    }
93    outcome
94}
95
96#[cfg(target_os = "linux")]
97const SERVICE_FILENAME: &str = "minis-studio-worker.service";
98#[cfg(target_os = "macos")]
99const SERVICE_FILENAME: &str = "gg.minis.studio-worker.plist";
100#[cfg(target_os = "windows")]
101const SERVICE_FILENAME: &str = "minis-studio-worker.task.xml";
102
103fn binary_path() -> Result<PathBuf> {
104    std::env::current_exe().context("resolving current executable path")
105}
106
107#[cfg(target_os = "linux")]
108fn default_unit_dir() -> Result<PathBuf> {
109    let dirs =
110        directories::BaseDirs::new().ok_or_else(|| anyhow::anyhow!("cannot resolve user dirs"))?;
111    let path = dirs.config_dir().join("systemd").join("user");
112    std::fs::create_dir_all(&path)?;
113    Ok(path)
114}
115
116#[cfg(target_os = "macos")]
117fn default_unit_dir() -> Result<PathBuf> {
118    let home = std::env::var("HOME").context("HOME not set")?;
119    let path = PathBuf::from(home).join("Library").join("LaunchAgents");
120    std::fs::create_dir_all(&path)?;
121    Ok(path)
122}
123
124#[cfg(target_os = "windows")]
125fn default_unit_dir() -> Result<PathBuf> {
126    let app_data = std::env::var("APPDATA").context("APPDATA not set")?;
127    let path = PathBuf::from(app_data).join("minis-studio-worker");
128    std::fs::create_dir_all(&path)?;
129    Ok(path)
130}
131
132/// Abstraction over the side-effecting parts of install/uninstall so the
133/// install logic itself is fully unit-testable.
134pub trait ServiceOps {
135    fn unit_dir(&self) -> Result<PathBuf>;
136    fn binary_path(&self) -> Result<PathBuf>;
137    /// Activate the unit (systemctl --user enable / launchctl load /
138    /// schtasks /Create).  Implementations return false if the platform
139    /// tool isn't available so install() can still succeed (file
140    /// written, manual activation instructions printed).
141    fn activate(&self, _unit_path: &Path) -> bool {
142        false
143    }
144    fn deactivate(&self, _unit_path: &Path) {}
145}
146
147/// Real, system-touching implementation used by the CLI.
148pub struct RealOps;
149
150impl ServiceOps for RealOps {
151    fn unit_dir(&self) -> Result<PathBuf> {
152        default_unit_dir()
153    }
154
155    fn binary_path(&self) -> Result<PathBuf> {
156        binary_path()
157    }
158
159    #[allow(unused_variables)]
160    fn activate(&self, unit_path: &Path) -> bool {
161        #[cfg(target_os = "linux")]
162        {
163            let mut reload = Command::new("systemctl");
164            reload.args(["--user", "daemon-reload"]);
165            if !run_step("activate", "daemon-reload", reload).is_success() {
166                return false;
167            }
168            let mut enable = Command::new("systemctl");
169            enable.args(["--user", "enable", "--now", SERVICE_FILENAME]);
170            run_step("activate", "enable-now", enable).is_success()
171        }
172        #[cfg(target_os = "macos")]
173        {
174            // Load the LaunchAgent so it runs now and at every login.
175            let mut load = Command::new("launchctl");
176            load.args(["load", "-w", unit_path.to_string_lossy().as_ref()]);
177            run_step("activate", "launchctl-load", load).is_success()
178        }
179        #[cfg(target_os = "windows")]
180        {
181            // Register the scheduled task from the XML we just wrote.
182            let mut create = Command::new("schtasks");
183            create.args([
184                "/Create",
185                "/XML",
186                unit_path.to_string_lossy().as_ref(),
187                "/TN",
188                "MinisStudioWorker",
189                "/F",
190            ]);
191            run_step("activate", "schtasks-create", create).is_success()
192        }
193        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
194        {
195            false
196        }
197    }
198
199    #[allow(unused_variables)]
200    fn deactivate(&self, unit_path: &Path) {
201        #[cfg(target_os = "linux")]
202        {
203            let mut disable = Command::new("systemctl");
204            disable.args(["--user", "disable", "--now", SERVICE_FILENAME]);
205            let _ = run_step("deactivate", "disable-now", disable);
206        }
207        #[cfg(target_os = "macos")]
208        {
209            let mut unload = Command::new("launchctl");
210            unload.args(["unload", unit_path.to_string_lossy().as_ref()]);
211            let _ = run_step("deactivate", "launchctl-unload", unload);
212        }
213        #[cfg(target_os = "windows")]
214        {
215            let mut delete = Command::new("schtasks");
216            delete.args(["/Delete", "/TN", "MinisStudioWorker", "/F"]);
217            let _ = run_step("deactivate", "schtasks-delete", delete);
218        }
219    }
220}
221
222pub fn install(config_path: Option<&str>) -> Result<()> {
223    install_with(&RealOps, config_path)
224}
225
226/// One-shot turnkey finish line: install +
227/// activate the OS service (so the worker runs now and at every login),
228/// then print exactly what the operator needs — the machine name the
229/// studio admin will approve, the studio URL, and the local API
230/// discovery file.  Idempotent: safe to re-run.
231pub fn setup(config_path: Option<&str>) -> Result<()> {
232    setup_with(&RealOps, config_path)
233}
234
235/// The operator-facing summary [`setup`] prints once the service is
236/// installed.  Pure so its wording is unit-tested without touching the
237/// OS.  `discovery_path` is where local clients read the API URL +
238/// token; `machine_name` is what the studio admin sees in the approval
239/// queue.
240pub fn setup_summary(machine_name: &str, api_base_url: &str, discovery_path: &str) -> String {
241    let base = api_base_url.trim_end_matches('/');
242    format!(
243        "\nstudio-worker is installed and running.\n\n\
244         Next step — approve this worker in the studio:\n\
245         \u{2022} open {base}/graphics and find this machine in the workers list\n\
246         \u{2022} it appears as: {machine_name}\n\
247         \u{2022} once an admin approves it, the worker starts claiming jobs automatically\n\n\
248         Local image API (no studio needed):\n\
249         \u{2022} URL + bearer token are written to: {discovery_path}\n\
250         \u{2022} POST /image there to generate locally\n\n\
251         Nothing else to do — models and GPU runtimes download on demand.\n"
252    )
253}
254
255pub fn setup_with<O: ServiceOps>(ops: &O, config_path: Option<&str>) -> Result<()> {
256    let (cfg, path) = crate::config::load(config_path)?;
257
258    // Install + activate the OS service (idempotent — overwrites the
259    // unit and re-enables it).
260    install_with(ops, config_path)?;
261
262    let discovery = crate::config::local_api_discovery_path_for(&path)
263        .map(|p| p.display().to_string())
264        .unwrap_or_else(|| "<config dir>/local-api.json".to_string());
265    print!(
266        "{}",
267        setup_summary(&crate::sys::machine_name(), &cfg.api_base_url, &discovery)
268    );
269    info!(
270        target: TRACE_TARGET,
271        op = "setup",
272        config_path = %path.display(),
273        "setup completed"
274    );
275    Ok(())
276}
277
278pub fn uninstall() -> Result<()> {
279    uninstall_with(&RealOps)
280}
281
282/// Write the unit file using the supplied ops and print manual activation
283/// instructions if the platform tool isn't available.  Public-but-`pub`
284/// so tests in `tests/` can drive it with a fake ops.
285pub fn install_with<O: ServiceOps>(ops: &O, config_path: Option<&str>) -> Result<()> {
286    let bin = ops.binary_path()?;
287    let cfg_arg = config_path
288        .map(|p| format!("--config {p} "))
289        .unwrap_or_default();
290    let dir = ops.unit_dir()?;
291    let path = dir.join(SERVICE_FILENAME);
292
293    let body = render_service(&bin.display().to_string(), &cfg_arg);
294    std::fs::write(&path, &body)
295        .with_context(|| format!("writing service file {}", path.display()))?;
296
297    println!("wrote service unit: {}", path.display());
298
299    let activated = ops.activate(&path);
300    if activated {
301        println!("activated service unit");
302    } else {
303        print_activation_instructions(&path);
304    }
305    info!(
306        target: TRACE_TARGET,
307        op = "install",
308        unit_path = %path.display(),
309        binary_path = %bin.display(),
310        activated,
311        "service install completed"
312    );
313    Ok(())
314}
315
316pub fn uninstall_with<O: ServiceOps>(ops: &O) -> Result<()> {
317    let dir = ops.unit_dir()?;
318    let path = dir.join(SERVICE_FILENAME);
319    ops.deactivate(&path);
320    let removed = if path.exists() {
321        std::fs::remove_file(&path)?;
322        println!("removed service unit: {}", path.display());
323        true
324    } else {
325        println!("no service unit to remove at {}", path.display());
326        false
327    };
328    info!(
329        target: TRACE_TARGET,
330        op = "uninstall",
331        unit_path = %path.display(),
332        removed,
333        "service uninstall completed"
334    );
335    Ok(())
336}
337
338fn print_activation_instructions(path: &Path) {
339    #[cfg(target_os = "linux")]
340    {
341        println!("activate manually:");
342        println!("  systemctl --user daemon-reload");
343        println!("  systemctl --user enable --now {SERVICE_FILENAME}");
344        let _ = path;
345    }
346    #[cfg(target_os = "macos")]
347    println!("load with: launchctl load -w {}", path.display());
348    #[cfg(target_os = "windows")]
349    println!(
350        "register with: schtasks /Create /XML {} /TN MinisStudioWorker",
351        path.display()
352    );
353    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
354    let _ = path;
355}
356
357#[cfg(target_os = "linux")]
358pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
359    format!(
360        r#"[Unit]
361Description=Minis studio worker (pull-based image-generation agent)
362After=network-online.target
363
364[Service]
365Type=simple
366ExecStart={bin} {cfg_arg}run
367Restart=on-failure
368RestartSec=5
369Environment=RUST_LOG=studio_worker=info
370
371[Install]
372WantedBy=default.target
373"#
374    )
375}
376
377#[cfg(target_os = "macos")]
378pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
379    let cfg_args = cfg_arg.trim();
380    let extra = if cfg_args.is_empty() {
381        String::new()
382    } else {
383        cfg_args
384            .split_whitespace()
385            .map(|s| format!("    <string>{}</string>\n", s))
386            .collect::<String>()
387    };
388    format!(
389        r#"<?xml version="1.0" encoding="UTF-8"?>
390<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
391<plist version="1.0">
392<dict>
393  <key>Label</key><string>gg.minis.studio-worker</string>
394  <key>ProgramArguments</key>
395  <array>
396    <string>{bin}</string>
397{extra}    <string>run</string>
398  </array>
399  <key>RunAtLoad</key><true/>
400  <key>KeepAlive</key><true/>
401  <key>EnvironmentVariables</key>
402  <dict><key>RUST_LOG</key><string>studio_worker=info</string></dict>
403</dict>
404</plist>
405"#
406    )
407}
408
409#[cfg(target_os = "windows")]
410pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
411    let args = format!("{cfg_arg}run").trim().to_string();
412    format!(
413        r#"<?xml version="1.0" encoding="UTF-16"?>
414<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
415  <Triggers>
416    <LogonTrigger><Enabled>true</Enabled></LogonTrigger>
417  </Triggers>
418  <Settings>
419    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
420    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
421    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
422    <AllowStartOnDemand>true</AllowStartOnDemand>
423    <Enabled>true</Enabled>
424    <Hidden>false</Hidden>
425    <RestartOnFailure>
426      <Interval>PT1M</Interval>
427      <Count>10</Count>
428    </RestartOnFailure>
429  </Settings>
430  <Actions>
431    <Exec>
432      <Command>{bin}</Command>
433      <Arguments>{args}</Arguments>
434    </Exec>
435  </Actions>
436</Task>
437"#
438    )
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use std::cell::RefCell;
445    use std::path::PathBuf;
446    use tempfile::tempdir;
447
448    struct FakeOps {
449        bin: PathBuf,
450        dir: PathBuf,
451        activate_returns: bool,
452        activate_calls: RefCell<Vec<PathBuf>>,
453        deactivate_calls: RefCell<Vec<PathBuf>>,
454    }
455
456    impl ServiceOps for FakeOps {
457        fn unit_dir(&self) -> Result<PathBuf> {
458            Ok(self.dir.clone())
459        }
460        fn binary_path(&self) -> Result<PathBuf> {
461            Ok(self.bin.clone())
462        }
463        fn activate(&self, unit_path: &Path) -> bool {
464            self.activate_calls
465                .borrow_mut()
466                .push(unit_path.to_path_buf());
467            self.activate_returns
468        }
469        fn deactivate(&self, unit_path: &Path) {
470            self.deactivate_calls
471                .borrow_mut()
472                .push(unit_path.to_path_buf());
473        }
474    }
475
476    #[cfg(target_os = "linux")]
477    #[test]
478    fn linux_render_includes_exec_start_and_install_section() {
479        let rendered = render_service("/usr/bin/studio-worker", "");
480        assert!(rendered.contains("ExecStart=/usr/bin/studio-worker run"));
481        assert!(rendered.contains("[Install]"));
482        assert!(rendered.contains("Restart=on-failure"));
483    }
484
485    #[cfg(target_os = "linux")]
486    #[test]
487    fn linux_render_passes_config_arg() {
488        let rendered = render_service("/usr/bin/studio-worker", "--config /etc/conf.toml ");
489        assert!(rendered.contains("--config /etc/conf.toml run"));
490    }
491
492    #[cfg(target_os = "macos")]
493    #[test]
494    fn macos_render_emits_valid_plist_xml() {
495        let rendered = render_service("/usr/local/bin/studio-worker", "");
496        assert!(rendered.contains("<plist version=\"1.0\">"));
497        assert!(rendered.contains("<string>/usr/local/bin/studio-worker</string>"));
498    }
499
500    #[cfg(target_os = "macos")]
501    #[test]
502    fn macos_render_includes_config_args_when_provided() {
503        let rendered = render_service("/usr/local/bin/studio-worker", "--config /etc/conf.toml ");
504        assert!(rendered.contains("<string>--config</string>"));
505        assert!(rendered.contains("<string>/etc/conf.toml</string>"));
506    }
507
508    #[cfg(target_os = "windows")]
509    #[test]
510    fn windows_render_emits_valid_task_xml() {
511        let rendered = render_service("C:\\worker.exe", "");
512        assert!(rendered.contains("<Command>C:\\worker.exe</Command>"));
513        assert!(rendered.contains("<Arguments>run</Arguments>"));
514    }
515
516    #[test]
517    fn install_with_writes_unit_file_and_succeeds_when_activate_returns_true() {
518        let dir = tempdir().unwrap();
519        let ops = FakeOps {
520            bin: PathBuf::from("/usr/bin/studio-worker"),
521            dir: dir.path().to_path_buf(),
522            activate_returns: true,
523            activate_calls: RefCell::new(Vec::new()),
524            deactivate_calls: RefCell::new(Vec::new()),
525        };
526        install_with(&ops, Some("/etc/conf.toml")).unwrap();
527        let written = dir.path().join(SERVICE_FILENAME);
528        assert!(
529            written.exists(),
530            "unit file should exist at {}",
531            written.display()
532        );
533        let body = std::fs::read_to_string(&written).unwrap();
534        assert!(body.contains("studio-worker"));
535        assert_eq!(ops.activate_calls.borrow().len(), 1);
536        assert_eq!(ops.activate_calls.borrow()[0], written);
537    }
538
539    #[test]
540    fn install_with_falls_back_to_manual_instructions_when_activate_fails() {
541        let dir = tempdir().unwrap();
542        let ops = FakeOps {
543            bin: PathBuf::from("/usr/bin/studio-worker"),
544            dir: dir.path().to_path_buf(),
545            activate_returns: false,
546            activate_calls: RefCell::new(Vec::new()),
547            deactivate_calls: RefCell::new(Vec::new()),
548        };
549        install_with(&ops, None).unwrap();
550        assert!(dir.path().join(SERVICE_FILENAME).exists());
551    }
552
553    #[test]
554    fn uninstall_with_removes_file_and_calls_deactivate() {
555        let dir = tempdir().unwrap();
556        let path = dir.path().join(SERVICE_FILENAME);
557        std::fs::write(&path, "dummy").unwrap();
558        let ops = FakeOps {
559            bin: PathBuf::from("/usr/bin/studio-worker"),
560            dir: dir.path().to_path_buf(),
561            activate_returns: false,
562            activate_calls: RefCell::new(Vec::new()),
563            deactivate_calls: RefCell::new(Vec::new()),
564        };
565        uninstall_with(&ops).unwrap();
566        assert!(!path.exists());
567        assert_eq!(ops.deactivate_calls.borrow().len(), 1);
568    }
569
570    #[test]
571    fn uninstall_with_is_idempotent_when_file_missing() {
572        let dir = tempdir().unwrap();
573        let ops = FakeOps {
574            bin: PathBuf::from("/usr/bin/studio-worker"),
575            dir: dir.path().to_path_buf(),
576            activate_returns: false,
577            activate_calls: RefCell::new(Vec::new()),
578            deactivate_calls: RefCell::new(Vec::new()),
579        };
580        // No file written; uninstall should still succeed.
581        uninstall_with(&ops).unwrap();
582    }
583
584    // -----------------------------------------------------------------
585    // Structured tracing — proves install/uninstall and the per-platform
586    // RealOps steps emit operator-visible breadcrumbs.  Without these,
587    // a failing `systemctl enable --now`, `launchctl unload` or
588    // `schtasks /Delete` would silently leave the worker un-activated
589    // (or still registered) with no log trail to diagnose it.
590    //
591    // The shared `test_support::capture` helper installs one
592    // process-global subscriber + thread-local sink — see that module
593    // for the why (it dodges the tracing callsite-cache flake we used
594    // to hit with `with_default`).
595    // -----------------------------------------------------------------
596
597    use crate::test_support::capture;
598
599    fn fake_ops(dir: PathBuf, activate_returns: bool) -> FakeOps {
600        FakeOps {
601            bin: PathBuf::from("/usr/bin/studio-worker"),
602            dir,
603            activate_returns,
604            activate_calls: RefCell::new(Vec::new()),
605            deactivate_calls: RefCell::new(Vec::new()),
606        }
607    }
608
609    #[test]
610    fn install_with_emits_info_event_with_activated_true_when_activation_succeeds() {
611        let dir = tempdir().unwrap();
612        let dir_path = dir.path().to_path_buf();
613        let logs = capture(move || {
614            install_with(&fake_ops(dir_path, true), None).unwrap();
615        });
616        assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
617        assert!(
618            logs.contains("studio_worker::service"),
619            "expected service target, got: {logs}"
620        );
621        assert!(logs.contains("op=\"install\""), "expected op field: {logs}");
622        assert!(
623            logs.contains("activated=true"),
624            "expected activated=true: {logs}"
625        );
626        assert!(
627            logs.contains(SERVICE_FILENAME),
628            "expected unit_path in log, got: {logs}"
629        );
630    }
631
632    #[test]
633    fn install_with_emits_info_event_with_activated_false_on_manual_fallback() {
634        let dir = tempdir().unwrap();
635        let dir_path = dir.path().to_path_buf();
636        let logs = capture(move || {
637            install_with(&fake_ops(dir_path, false), None).unwrap();
638        });
639        assert!(
640            logs.contains("activated=false"),
641            "expected activated=false: {logs}"
642        );
643    }
644
645    #[test]
646    fn uninstall_with_emits_info_event_with_removed_true_when_file_existed() {
647        let dir = tempdir().unwrap();
648        let path = dir.path().join(SERVICE_FILENAME);
649        std::fs::write(&path, "dummy").unwrap();
650        let dir_path = dir.path().to_path_buf();
651        let logs = capture(move || {
652            uninstall_with(&fake_ops(dir_path, false)).unwrap();
653        });
654        assert!(
655            logs.contains("op=\"uninstall\""),
656            "expected op field: {logs}"
657        );
658        assert!(
659            logs.contains("removed=true"),
660            "expected removed=true: {logs}"
661        );
662    }
663
664    #[test]
665    fn uninstall_with_emits_info_event_with_removed_false_when_file_missing() {
666        let dir = tempdir().unwrap();
667        let dir_path = dir.path().to_path_buf();
668        let logs = capture(move || {
669            uninstall_with(&fake_ops(dir_path, false)).unwrap();
670        });
671        assert!(
672            logs.contains("removed=false"),
673            "expected removed=false: {logs}"
674        );
675    }
676
677    // -----------------------------------------------------------------
678    // StepOutcome — pure helper that classifies the outcome of an OS
679    // command spawned by RealOps so install/uninstall never silently
680    // swallow non-zero exits or missing tools.
681    // -----------------------------------------------------------------
682
683    #[test]
684    fn classify_status_recognises_zero_exit_as_succeeded() {
685        // Spawn the running test binary with `--list` (the cargo test
686        // harness accepts it and exits 0).
687        let status = std::process::Command::new(std::env::current_exe().unwrap())
688            .arg("--list")
689            .stdout(std::process::Stdio::null())
690            .status();
691        assert_eq!(classify_status(status), StepOutcome::Succeeded);
692    }
693
694    #[test]
695    fn classify_status_recognises_non_zero_exit_as_failed() {
696        // The cargo test harness rejects an unknown long flag with a
697        // non-zero exit.
698        let status = std::process::Command::new(std::env::current_exe().unwrap())
699            .arg("--definitely-not-a-real-flag-zzzqx")
700            .stdout(std::process::Stdio::null())
701            .stderr(std::process::Stdio::null())
702            .status();
703        match classify_status(status) {
704            StepOutcome::Failed { .. } => {}
705            other => panic!("expected Failed, got {other:?}"),
706        }
707    }
708
709    #[test]
710    fn classify_status_recognises_spawn_failure() {
711        let status =
712            std::process::Command::new("definitely-not-on-path-zzzqxq-studio-worker").status();
713        assert_eq!(classify_status(status), StepOutcome::SpawnFailed);
714    }
715
716    #[test]
717    fn run_step_spawn_failure_logs_underlying_io_error() {
718        // A spawn failure can be ENOENT (tool missing) *or* a permission
719        // error, a broken interpreter, etc.  The warn must carry the real
720        // OS error so an operator isn't misled by the generic "missing on
721        // PATH?" hint when the true cause is something else.
722        let cmd = std::process::Command::new("definitely-not-on-path-zzzqxq-studio-worker");
723        let logs = capture(move || {
724            assert_eq!(run_step("activate", "smoke", cmd), StepOutcome::SpawnFailed);
725        });
726        assert!(logs.contains("WARN"), "expected WARN event, got: {logs}");
727        assert!(
728            logs.contains("could not be spawned"),
729            "expected spawn-failure message, got: {logs}"
730        );
731        assert!(
732            logs.contains("error="),
733            "expected structured error field, got: {logs}"
734        );
735        // The concrete wording differs per OS — Unix renders "... (os
736        // error 2)", Windows renders `error="program not found"` — so
737        // assert the structured error field carries *some* underlying
738        // detail rather than any one platform's phrasing.
739        let lower = logs.to_lowercase();
740        assert!(
741            lower.contains("os error")
742                || lower.contains("not found")
743                || lower.contains("cannot find"),
744            "expected the underlying io::Error text, got: {logs}"
745        );
746    }
747
748    #[test]
749    fn step_outcome_is_success_only_for_succeeded() {
750        assert!(StepOutcome::Succeeded.is_success());
751        assert!(!StepOutcome::Failed { code: Some(1) }.is_success());
752        assert!(!StepOutcome::Failed { code: None }.is_success());
753        assert!(!StepOutcome::SpawnFailed.is_success());
754    }
755
756    #[test]
757    fn setup_summary_names_machine_studio_and_discovery() {
758        let s = setup_summary(
759            "alices-rig",
760            "https://studio.minis.gg/",
761            "/home/alice/.config/minis-studio-worker/local-api.json",
762        );
763        // What the admin approves.
764        assert!(s.contains("alices-rig"), "must name the machine: {s}");
765        // Studio URL, trailing slash trimmed before the path.
766        assert!(s.contains("https://studio.minis.gg/graphics"), "got: {s}");
767        assert!(
768            !s.contains(".gg//graphics"),
769            "trailing slash not trimmed: {s}"
770        );
771        // Where the local API token lives.
772        assert!(
773            s.contains("local-api.json"),
774            "must point at discovery file: {s}"
775        );
776        // The turnkey promise.
777        assert!(s.contains("download on demand"), "got: {s}");
778    }
779
780    #[test]
781    fn setup_with_installs_the_service_and_prints_guidance() {
782        let cfgdir = tempdir().unwrap();
783        let cfg_path = cfgdir.path().join("config.toml");
784        crate::config::save(&crate::config::Config::default(), &cfg_path).unwrap();
785
786        let unitdir = tempdir().unwrap();
787        let ops = fake_ops(unitdir.path().to_path_buf(), true);
788        let cfg_arg = cfg_path.to_string_lossy().to_string();
789        let logs = capture({
790            let cfg_arg = cfg_arg.clone();
791            move || {
792                setup_with(&ops, Some(&cfg_arg)).unwrap();
793            }
794        });
795        // Service was installed + the setup breadcrumb fired.
796        assert!(
797            logs.contains("op=\"setup\""),
798            "expected setup event: {logs}"
799        );
800        // The unit file landed.
801        assert!(unitdir.path().join(SERVICE_FILENAME).exists());
802    }
803}