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}