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}