Skip to main content

trusty_memory/commands/
service.rs

1//! Handler for `trusty-memory service` (macOS launchd integration).
2//!
3//! Why: launchd is the canonical way to keep a long-lived foreground daemon
4//! alive on macOS — it survives logout, restarts on crash, and integrates with
5//! `launchctl` for diagnostics. Wrapping the plist mechanics in `service`
6//! subcommands keeps users from having to hand-edit XML. This mirrors the
7//! pattern used by `trusty-search service`, sharing the
8//! [`trusty_common::launchd`] implementation so the two tools cannot drift.
9//! What: macOS routes to `service_install` / `service_start` / `service_stop`
10//! / `service_logs`. Non-macOS prints a "not supported" error and exits 1.
11//! Test: on Linux, every action returns Err with the platform message; on
12//! macOS, `service install` writes the plist without loading it, `start`
13//! bootstraps it, `stop` boots it out, and `logs` tails the log files.
14
15use anyhow::Result;
16use clap::Subcommand;
17#[cfg(target_os = "macos")]
18use colored::Colorize;
19
20/// Subcommands for `trusty-memory service` (macOS launchd integration).
21///
22/// Why: the four lifecycle actions (install, start, stop, logs) are the
23/// minimum surface needed to manage a launchd-backed daemon without
24/// hand-editing plists or shelling out to `launchctl` directly.
25/// What: a clap-derived enum dispatched by [`handle_service`].
26/// Test: clap's `--help` enumerates all four; integration via
27/// `cargo run -p trusty-memory -- service --help`.
28#[derive(Debug, Clone, Subcommand)]
29pub enum ServiceAction {
30    /// Install the LaunchAgent plist (does not load it).
31    Install,
32    /// Install and load the LaunchAgent (start the daemon).
33    Start,
34    /// Unload the LaunchAgent (stop the daemon).
35    Stop,
36    /// Tail the launchd stdout / stderr logs.
37    Logs,
38}
39
40/// Reverse-DNS label for the LaunchAgent.
41///
42/// Why: launchd identifies agents by their `Label`, which must also be the
43/// plist filename's stem. Centralising the constant keeps install / start /
44/// stop in lockstep.
45/// What: `com.trusty.memory` — matches the naming convention used by
46/// `trusty-search` (`com.trusty.trusty-search`) and follows reverse-DNS.
47/// Test: covered indirectly by `service install` integration runs.
48#[cfg(target_os = "macos")]
49pub const LAUNCHD_LABEL: &str = "com.trusty.memory";
50
51/// Dispatch a `trusty-memory service <action>` invocation.
52///
53/// Why: the binary's `main.rs` should not contain `#[cfg]` blocks — it
54/// always calls this function and lets the module decide what is and isn't
55/// supported on the current platform.
56/// What: on macOS, dispatches to the per-action helper. On every other
57/// platform, returns an error with a friendly message pointing operators to
58/// their native service manager.
59/// Test: on Linux CI, asserts the Err message contains "not supported".
60pub fn handle_service(action: &ServiceAction) -> Result<()> {
61    #[cfg(target_os = "macos")]
62    {
63        match action {
64            ServiceAction::Install => service_install(),
65            ServiceAction::Start => service_start(),
66            ServiceAction::Stop => service_stop(),
67            ServiceAction::Logs => service_logs(),
68        }
69    }
70    #[cfg(not(target_os = "macos"))]
71    {
72        let _ = action;
73        anyhow::bail!(
74            "`trusty-memory service` is not supported on this platform — \
75             use your distro's service manager (systemd, OpenRC, etc.) directly."
76        );
77    }
78}
79
80/// Resolve the log directory for the launchd-managed daemon.
81///
82/// Why: launchd writes `stdout` and `stderr` to files we declare in the
83/// plist, and they need a real directory before the daemon can start.
84/// Centralising the path keeps install / logs in agreement.
85/// What: `<data_dir>/trusty-memory/logs`, where `<data_dir>` comes from
86/// `dirs::data_dir()` (`~/Library/Application Support` on macOS). Creates
87/// the directory if it does not already exist.
88/// Test: covered indirectly by `service install` integration runs.
89#[cfg(target_os = "macos")]
90pub(crate) fn launchd_log_dir() -> Result<std::path::PathBuf> {
91    let data =
92        dirs::data_dir().ok_or_else(|| anyhow::anyhow!("could not resolve user data directory"))?;
93    let dir = data.join("trusty-memory").join("logs");
94    std::fs::create_dir_all(&dir)
95        .map_err(|e| anyhow::anyhow!("create log dir {}: {e}", dir.display()))?;
96    Ok(dir)
97}
98
99/// Build the shared `LaunchdConfig` describing the trusty-memory agent.
100///
101/// Why: install / start / stop all need the same plist label, log paths,
102/// and arg vector. Building it in one place keeps them in sync and lets the
103/// shared [`trusty_common::launchd`] module own the XML rendering and the
104/// `launchctl` glue.
105/// What: assembles a [`trusty_common::launchd::LaunchdConfig`] pointing at
106/// the current binary with `serve` as the single argument; uses
107/// `KeepAlive::OnSuccess` so a clean shutdown does not crash-loop. Also
108/// injects `FASTEMBED_CACHE_DIR=$HOME/.cache/fastembed` so the embedder
109/// model download does not try to write into launchd's read-only sandbox
110/// `TMPDIR` (GH #58).
111/// Test: `build_launchd_config_sets_fastembed_cache_dir` asserts the env
112/// var is wired in. End-to-end exercised via `service install` /
113/// `service start`.
114#[cfg(target_os = "macos")]
115pub(crate) fn build_launchd_config(
116    exe: std::path::PathBuf,
117    log_dir: std::path::PathBuf,
118) -> trusty_common::launchd::LaunchdConfig {
119    use trusty_common::launchd::{KeepAlive, LaunchdConfig};
120    LaunchdConfig {
121        label: LAUNCHD_LABEL.to_string(),
122        exe_path: exe,
123        args: vec!["serve".to_string()],
124        log_dir,
125        keep_alive: KeepAlive::OnSuccess,
126        throttle_interval: 10,
127        env_vars: fastembed_env_vars(),
128    }
129}
130
131/// Build the env var list embedded into the LaunchAgent plist.
132///
133/// Why: launchd's per-agent `TMPDIR` is a sandboxed `/var/folders/.../T/`
134/// path that is **read-only** for the agent's UID. fastembed's default
135/// model retrieval path is derived from that `TMPDIR`, so the first
136/// `TextEmbedding::try_new` call fails with `EROFS (os error 30)` and the
137/// daemon never reaches a ready state (GH #58). Pinning
138/// `FASTEMBED_CACHE_DIR` to a writable user-owned directory in the plist
139/// solves the problem for every daemon start.
140/// What: returns `[("FASTEMBED_CACHE_DIR", "$HOME/.cache/fastembed")]`,
141/// expanding `$HOME` from the install-time user. If `HOME` is unset (very
142/// unusual), returns an empty list — `resolve_fastembed_cache_dir` will
143/// then fall back to its own logic at daemon startup.
144/// Test: `build_launchd_config_sets_fastembed_cache_dir` covers the happy
145/// path.
146#[cfg(target_os = "macos")]
147fn fastembed_env_vars() -> Vec<(String, String)> {
148    if let Some(home) = dirs::home_dir() {
149        let cache = home.join(".cache").join("fastembed");
150        return vec![(
151            "FASTEMBED_CACHE_DIR".to_string(),
152            cache.to_string_lossy().into_owned(),
153        )];
154    }
155    Vec::new()
156}
157
158#[cfg(target_os = "macos")]
159fn current_exe() -> Result<std::path::PathBuf> {
160    std::env::current_exe().map_err(|e| anyhow::anyhow!("could not resolve current exe: {e}"))
161}
162
163/// `service install` — write the plist without loading it.
164///
165/// Why: operators sometimes want to inspect or hand-edit the plist before
166/// launchd takes ownership. Splitting "install" from "start" gives them that
167/// window without forcing a stop-start dance.
168/// What: resolves the binary path and log directory, then calls
169/// `LaunchdConfig::install()` which writes `~/Library/LaunchAgents/<label>.plist`
170/// and creates the log directory. Does not call `bootstrap`.
171/// Test: integration via `cargo run -p trusty-memory -- service install`.
172#[cfg(target_os = "macos")]
173fn service_install() -> Result<()> {
174    let exe = current_exe()?;
175    let log_dir = launchd_log_dir()?;
176    let cfg = build_launchd_config(exe, log_dir.clone());
177    let plist_path = cfg.plist_path()?;
178    cfg.install()?;
179    println!(
180        "{} Wrote LaunchAgent plist: {}",
181        "✓".green(),
182        plist_path.display()
183    );
184    println!(
185        "  Logs:    {}\n  Start:   {}",
186        log_dir.display().to_string().dimmed(),
187        "trusty-memory service start".cyan(),
188    );
189    Ok(())
190}
191
192/// `service start` — install the plist (if needed) and bootstrap the agent.
193///
194/// Why: the common "I want it running" path should be one command, not two.
195/// `install` + `bootstrap` is idempotent under the shared launchd module
196/// (bootstrap calls bootout first), so calling start repeatedly is safe.
197/// What: writes the plist via `install()`, then loads it into the user's
198/// `gui/<uid>` domain via `bootstrap()`. The agent will start immediately
199/// and restart on non-zero exits per `KeepAlive::OnSuccess`.
200/// Test: integration via `cargo run -p trusty-memory -- service start`.
201#[cfg(target_os = "macos")]
202fn service_start() -> Result<()> {
203    let exe = current_exe()?;
204    let log_dir = launchd_log_dir()?;
205    let cfg = build_launchd_config(exe, log_dir.clone());
206    let plist_path = cfg.plist_path()?;
207    cfg.install()?;
208    println!(
209        "{} Wrote LaunchAgent plist: {}",
210        "✓".green(),
211        plist_path.display()
212    );
213
214    cfg.bootstrap()?;
215    let domain = format!("gui/{}", trusty_common::launchd::current_uid());
216    println!(
217        "{} Loaded {} into {} — daemon will start automatically.",
218        "✓".green(),
219        LAUNCHD_LABEL,
220        domain
221    );
222    println!(
223        "  Logs:    {}\n  Stop:    {}",
224        log_dir.display().to_string().dimmed(),
225        "trusty-memory service stop".cyan(),
226    );
227    Ok(())
228}
229
230/// `service stop` — boot out the agent (stop and unload).
231///
232/// Why: operators need a friendly counterpart to `start` that does not
233/// require remembering the full `launchctl bootout gui/<uid>/<label>`
234/// invocation. The shared launchd module treats "not loaded" as success, so
235/// calling stop on an unloaded agent is also a no-op.
236/// What: builds the same config used by `start`, then calls `bootout()`.
237/// Leaves the plist file in place — re-`start` will reload it.
238/// Test: integration via `cargo run -p trusty-memory -- service stop`.
239#[cfg(target_os = "macos")]
240fn service_stop() -> Result<()> {
241    let exe = current_exe()?;
242    let log_dir = launchd_log_dir()?;
243    let cfg = build_launchd_config(exe, log_dir);
244    cfg.bootout()?;
245    println!(
246        "{} Unloaded {} (plist file preserved at {}).",
247        "✓".green(),
248        LAUNCHD_LABEL,
249        cfg.plist_path()?.display().to_string().dimmed()
250    );
251    Ok(())
252}
253
254/// `service logs` — tail the launchd stdout/stderr log files.
255///
256/// Why: launchd routes the daemon's stdout/stderr to plain files; a friendly
257/// `tail -F` wrapper avoids forcing operators to remember the path.
258/// What: resolves the log directory and execs `tail -F <stdout> <stderr>`.
259/// Emits a hint when neither file exists yet (daemon never started).
260/// Test: side-effecting; covered manually via
261/// `cargo run -p trusty-memory -- service logs`.
262#[cfg(target_os = "macos")]
263fn service_logs() -> Result<()> {
264    let log_dir = launchd_log_dir()?;
265    let stdout = log_dir.join("stdout.log");
266    let stderr = log_dir.join("stderr.log");
267    if !stdout.exists() && !stderr.exists() {
268        eprintln!(
269            "{} No logs at {} yet — start the service first ({}).",
270            "·".dimmed(),
271            log_dir.display(),
272            "trusty-memory service start".cyan()
273        );
274        return Ok(());
275    }
276    let status = std::process::Command::new("tail")
277        .arg("-F")
278        .arg(&stdout)
279        .arg(&stderr)
280        .status()
281        .map_err(|e| anyhow::anyhow!("tail failed: {e}"))?;
282    if !status.success() {
283        anyhow::bail!("tail exited with {status}");
284    }
285    Ok(())
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    /// Why: on non-macOS platforms, every `service` action must surface a
293    /// clear, actionable error instead of silently succeeding or panicking.
294    /// What: invokes `handle_service` with each action and asserts the Err
295    /// message contains the "not supported" sentinel.
296    /// Test: macOS skips this (the actions perform real `launchctl` work).
297    #[cfg(not(target_os = "macos"))]
298    #[test]
299    fn handle_service_errors_on_unsupported_platform() {
300        for action in [
301            ServiceAction::Install,
302            ServiceAction::Start,
303            ServiceAction::Stop,
304            ServiceAction::Logs,
305        ] {
306            let err = handle_service(&action).expect_err("must fail on non-macOS");
307            let msg = format!("{err}");
308            assert!(
309                msg.contains("not supported"),
310                "expected platform error, got: {msg}"
311            );
312        }
313    }
314
315    /// Why: the LaunchdConfig we hand to `trusty_common::launchd` must always
316    /// describe the canonical trusty-memory agent (label, args, restart
317    /// policy). Drift here corrupts every plist that the binary writes.
318    /// What: builds the config with dummy paths and asserts the
319    /// load-bearing fields.
320    /// Test: pure construction, no fs side effects.
321    #[cfg(target_os = "macos")]
322    #[test]
323    fn build_launchd_config_uses_canonical_shape() {
324        use std::path::PathBuf;
325        use trusty_common::launchd::KeepAlive;
326
327        let cfg = build_launchd_config(
328            PathBuf::from("/usr/local/bin/trusty-memory"),
329            PathBuf::from("/tmp/trusty-memory/logs"),
330        );
331        assert_eq!(cfg.label, LAUNCHD_LABEL);
332        assert_eq!(cfg.args, vec!["serve".to_string()]);
333        assert_eq!(cfg.keep_alive, KeepAlive::OnSuccess);
334        assert_eq!(cfg.throttle_interval, 10);
335        // env_vars is allowed to be empty only on hosts without a HOME
336        // (extremely rare); on developer/CI machines HOME is always set
337        // and FASTEMBED_CACHE_DIR must be wired in.
338        if dirs::home_dir().is_some() {
339            assert!(
340                cfg.env_vars.iter().any(|(k, _)| k == "FASTEMBED_CACHE_DIR"),
341                "FASTEMBED_CACHE_DIR must be present in the LaunchAgent plist (GH #58)"
342            );
343        }
344    }
345
346    /// Why: GH #58 — launchd's read-only `TMPDIR` breaks fastembed's first
347    /// model download. The plist installer is the single source of truth
348    /// for the daemon's runtime environment, so the env var must be set
349    /// there. Asserting on `build_launchd_config` (not just
350    /// `fastembed_env_vars`) catches regressions where someone strips the
351    /// env list when refactoring the config builder.
352    /// What: builds the config with dummy paths and asserts the env var is
353    /// present and points under `$HOME/.cache/fastembed`.
354    /// Test: pure construction, no fs side effects.
355    #[cfg(target_os = "macos")]
356    #[test]
357    fn build_launchd_config_sets_fastembed_cache_dir() {
358        use std::path::PathBuf;
359
360        let cfg = build_launchd_config(
361            PathBuf::from("/usr/local/bin/trusty-memory"),
362            PathBuf::from("/tmp/trusty-memory/logs"),
363        );
364        if let Some(home) = dirs::home_dir() {
365            let expected = home
366                .join(".cache")
367                .join("fastembed")
368                .to_string_lossy()
369                .into_owned();
370            let value = cfg
371                .env_vars
372                .iter()
373                .find(|(k, _)| k == "FASTEMBED_CACHE_DIR")
374                .map(|(_, v)| v.clone())
375                .expect("FASTEMBED_CACHE_DIR must be present");
376            assert_eq!(value, expected);
377        }
378    }
379}