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 the fastembed cache
138/// to a writable user-owned directory in the plist solves the problem for
139/// every daemon start. Both `FASTEMBED_CACHE_DIR` and `FASTEMBED_CACHE_PATH`
140/// are emitted so the daemon agrees with both fastembed's native env
141/// (`FASTEMBED_CACHE_DIR`) and the alternative name documented in our
142/// install flow / accepted by `resolve_fastembed_cache_dir` (GH #62).
143/// What: returns `[("FASTEMBED_CACHE_DIR", "$HOME/.cache/fastembed"),
144/// ("FASTEMBED_CACHE_PATH", "$HOME/.cache/fastembed")]`, expanding `$HOME`
145/// from the install-time user. If `HOME` is unset (very unusual), returns
146/// an empty list — `resolve_fastembed_cache_dir` will then fall back to
147/// its own logic at daemon startup.
148/// Test: `build_launchd_config_sets_fastembed_cache_dir` covers the happy
149/// path for both env var names.
150#[cfg(target_os = "macos")]
151fn fastembed_env_vars() -> Vec<(String, String)> {
152    if let Some(home) = dirs::home_dir() {
153        let cache = home.join(".cache").join("fastembed");
154        let value = cache.to_string_lossy().into_owned();
155        return vec![
156            ("FASTEMBED_CACHE_DIR".to_string(), value.clone()),
157            ("FASTEMBED_CACHE_PATH".to_string(), value),
158        ];
159    }
160    Vec::new()
161}
162
163#[cfg(target_os = "macos")]
164fn current_exe() -> Result<std::path::PathBuf> {
165    std::env::current_exe().map_err(|e| anyhow::anyhow!("could not resolve current exe: {e}"))
166}
167
168/// `service install` — write the plist without loading it.
169///
170/// Why: operators sometimes want to inspect or hand-edit the plist before
171/// launchd takes ownership. Splitting "install" from "start" gives them that
172/// window without forcing a stop-start dance.
173/// What: resolves the binary path and log directory, then calls
174/// `LaunchdConfig::install()` which writes `~/Library/LaunchAgents/<label>.plist`
175/// and creates the log directory. Does not call `bootstrap`.
176/// Test: integration via `cargo run -p trusty-memory -- service install`.
177#[cfg(target_os = "macos")]
178fn service_install() -> Result<()> {
179    let exe = current_exe()?;
180    let log_dir = launchd_log_dir()?;
181    let cfg = build_launchd_config(exe, log_dir.clone());
182    let plist_path = cfg.plist_path()?;
183    cfg.install()?;
184    println!(
185        "{} Wrote LaunchAgent plist: {}",
186        "✓".green(),
187        plist_path.display()
188    );
189    ensure_fastembed_cache_dir();
190    println!(
191        "  Logs:    {}\n  Start:   {}",
192        log_dir.display().to_string().dimmed(),
193        "trusty-memory service start".cyan(),
194    );
195    Ok(())
196}
197
198/// Ensure the fastembed cache directory exists at install time.
199///
200/// Why: GH #62 — the launchd plist now pins `FASTEMBED_CACHE_PATH` to
201/// `$HOME/.cache/fastembed`, but if that directory does not yet exist the
202/// daemon's first `TextEmbedding::try_new` will still trip over fastembed's
203/// cache-creation path under launchd's restricted environment. Creating the
204/// directory up-front (cheap, no network) guarantees the env var resolves
205/// to a writable path on the very first daemon start. A full model pre-warm
206/// is performed by `trusty-memory setup`; here we only do the minimum
207/// (mkdir -p) so `service install` stays fast and side-effect-light.
208/// What: best-effort `create_dir_all` against `$HOME/.cache/fastembed`.
209/// Failures are logged to stdout as a hint but do not abort install.
210/// Test: side-effecting; covered manually via `trusty-memory service install`.
211#[cfg(target_os = "macos")]
212fn ensure_fastembed_cache_dir() {
213    let Some(home) = dirs::home_dir() else {
214        return;
215    };
216    let cache = home.join(".cache").join("fastembed");
217    match std::fs::create_dir_all(&cache) {
218        Ok(()) => println!(
219            "{} fastembed cache dir ready at {}",
220            "✓".green(),
221            cache.display().to_string().dimmed()
222        ),
223        Err(e) => eprintln!(
224            "  {} could not pre-create {} ({e}); daemon will retry on first request.",
225            "·".dimmed(),
226            cache.display()
227        ),
228    }
229}
230
231/// `service start` — install the plist (if needed) and bootstrap the agent.
232///
233/// Why: the common "I want it running" path should be one command, not two.
234/// `install` + `bootstrap` is idempotent under the shared launchd module
235/// (bootstrap calls bootout first), so calling start repeatedly is safe.
236/// What: writes the plist via `install()`, then loads it into the user's
237/// `gui/<uid>` domain via `bootstrap()`. The agent will start immediately
238/// and restart on non-zero exits per `KeepAlive::OnSuccess`.
239/// Test: integration via `cargo run -p trusty-memory -- service start`.
240#[cfg(target_os = "macos")]
241fn service_start() -> Result<()> {
242    let exe = current_exe()?;
243    let log_dir = launchd_log_dir()?;
244    let cfg = build_launchd_config(exe, log_dir.clone());
245    let plist_path = cfg.plist_path()?;
246    cfg.install()?;
247    println!(
248        "{} Wrote LaunchAgent plist: {}",
249        "✓".green(),
250        plist_path.display()
251    );
252
253    cfg.bootstrap()?;
254    let domain = format!("gui/{}", trusty_common::launchd::current_uid());
255    println!(
256        "{} Loaded {} into {} — daemon will start automatically.",
257        "✓".green(),
258        LAUNCHD_LABEL,
259        domain
260    );
261    println!(
262        "  Logs:    {}\n  Stop:    {}",
263        log_dir.display().to_string().dimmed(),
264        "trusty-memory service stop".cyan(),
265    );
266    Ok(())
267}
268
269/// `service stop` — boot out the agent (stop and unload).
270///
271/// Why: operators need a friendly counterpart to `start` that does not
272/// require remembering the full `launchctl bootout gui/<uid>/<label>`
273/// invocation. The shared launchd module treats "not loaded" as success, so
274/// calling stop on an unloaded agent is also a no-op.
275/// What: builds the same config used by `start`, then calls `bootout()`.
276/// Leaves the plist file in place — re-`start` will reload it.
277/// Test: integration via `cargo run -p trusty-memory -- service stop`.
278#[cfg(target_os = "macos")]
279fn service_stop() -> Result<()> {
280    let exe = current_exe()?;
281    let log_dir = launchd_log_dir()?;
282    let cfg = build_launchd_config(exe, log_dir);
283    cfg.bootout()?;
284    println!(
285        "{} Unloaded {} (plist file preserved at {}).",
286        "✓".green(),
287        LAUNCHD_LABEL,
288        cfg.plist_path()?.display().to_string().dimmed()
289    );
290    Ok(())
291}
292
293/// `service logs` — tail the launchd stdout/stderr log files.
294///
295/// Why: launchd routes the daemon's stdout/stderr to plain files; a friendly
296/// `tail -F` wrapper avoids forcing operators to remember the path.
297/// What: resolves the log directory and execs `tail -F <stdout> <stderr>`.
298/// Emits a hint when neither file exists yet (daemon never started).
299/// Test: side-effecting; covered manually via
300/// `cargo run -p trusty-memory -- service logs`.
301#[cfg(target_os = "macos")]
302fn service_logs() -> Result<()> {
303    let log_dir = launchd_log_dir()?;
304    let stdout = log_dir.join("stdout.log");
305    let stderr = log_dir.join("stderr.log");
306    if !stdout.exists() && !stderr.exists() {
307        eprintln!(
308            "{} No logs at {} yet — start the service first ({}).",
309            "·".dimmed(),
310            log_dir.display(),
311            "trusty-memory service start".cyan()
312        );
313        return Ok(());
314    }
315    let status = std::process::Command::new("tail")
316        .arg("-F")
317        .arg(&stdout)
318        .arg(&stderr)
319        .status()
320        .map_err(|e| anyhow::anyhow!("tail failed: {e}"))?;
321    if !status.success() {
322        anyhow::bail!("tail exited with {status}");
323    }
324    Ok(())
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    /// Why: on non-macOS platforms, every `service` action must surface a
332    /// clear, actionable error instead of silently succeeding or panicking.
333    /// What: invokes `handle_service` with each action and asserts the Err
334    /// message contains the "not supported" sentinel.
335    /// Test: macOS skips this (the actions perform real `launchctl` work).
336    #[cfg(not(target_os = "macos"))]
337    #[test]
338    fn handle_service_errors_on_unsupported_platform() {
339        for action in [
340            ServiceAction::Install,
341            ServiceAction::Start,
342            ServiceAction::Stop,
343            ServiceAction::Logs,
344        ] {
345            let err = handle_service(&action).expect_err("must fail on non-macOS");
346            let msg = format!("{err}");
347            assert!(
348                msg.contains("not supported"),
349                "expected platform error, got: {msg}"
350            );
351        }
352    }
353
354    /// Why: the LaunchdConfig we hand to `trusty_common::launchd` must always
355    /// describe the canonical trusty-memory agent (label, args, restart
356    /// policy). Drift here corrupts every plist that the binary writes.
357    /// What: builds the config with dummy paths and asserts the
358    /// load-bearing fields.
359    /// Test: pure construction, no fs side effects.
360    #[cfg(target_os = "macos")]
361    #[test]
362    fn build_launchd_config_uses_canonical_shape() {
363        use std::path::PathBuf;
364        use trusty_common::launchd::KeepAlive;
365
366        let cfg = build_launchd_config(
367            PathBuf::from("/usr/local/bin/trusty-memory"),
368            PathBuf::from("/tmp/trusty-memory/logs"),
369        );
370        assert_eq!(cfg.label, LAUNCHD_LABEL);
371        assert_eq!(cfg.args, vec!["serve".to_string()]);
372        assert_eq!(cfg.keep_alive, KeepAlive::OnSuccess);
373        assert_eq!(cfg.throttle_interval, 10);
374        // env_vars is allowed to be empty only on hosts without a HOME
375        // (extremely rare); on developer/CI machines HOME is always set
376        // and FASTEMBED_CACHE_DIR must be wired in.
377        if dirs::home_dir().is_some() {
378            assert!(
379                cfg.env_vars.iter().any(|(k, _)| k == "FASTEMBED_CACHE_DIR"),
380                "FASTEMBED_CACHE_DIR must be present in the LaunchAgent plist (GH #58)"
381            );
382        }
383    }
384
385    /// Why: GH #58 — launchd's read-only `TMPDIR` breaks fastembed's first
386    /// model download. The plist installer is the single source of truth
387    /// for the daemon's runtime environment, so the env var must be set
388    /// there. Asserting on `build_launchd_config` (not just
389    /// `fastembed_env_vars`) catches regressions where someone strips the
390    /// env list when refactoring the config builder.
391    /// What: builds the config with dummy paths and asserts the env var is
392    /// present and points under `$HOME/.cache/fastembed`.
393    /// Test: pure construction, no fs side effects.
394    #[cfg(target_os = "macos")]
395    #[test]
396    fn build_launchd_config_sets_fastembed_cache_dir() {
397        use std::path::PathBuf;
398
399        let cfg = build_launchd_config(
400            PathBuf::from("/usr/local/bin/trusty-memory"),
401            PathBuf::from("/tmp/trusty-memory/logs"),
402        );
403        if let Some(home) = dirs::home_dir() {
404            let expected = home
405                .join(".cache")
406                .join("fastembed")
407                .to_string_lossy()
408                .into_owned();
409            let dir_value = cfg
410                .env_vars
411                .iter()
412                .find(|(k, _)| k == "FASTEMBED_CACHE_DIR")
413                .map(|(_, v)| v.clone())
414                .expect("FASTEMBED_CACHE_DIR must be present");
415            assert_eq!(dir_value, expected);
416            // GH #62: also assert FASTEMBED_CACHE_PATH is present and
417            // points to the same path. Both names exist because fastembed
418            // reads `FASTEMBED_CACHE_DIR` natively, while
419            // `resolve_fastembed_cache_dir` (and our docs) prefer the
420            // `FASTEMBED_CACHE_PATH` alias.
421            let path_value = cfg
422                .env_vars
423                .iter()
424                .find(|(k, _)| k == "FASTEMBED_CACHE_PATH")
425                .map(|(_, v)| v.clone())
426                .expect("FASTEMBED_CACHE_PATH must be present (GH #62)");
427            assert_eq!(path_value, expected);
428        }
429    }
430}