use anyhow::Result;
use clap::Subcommand;
#[cfg(target_os = "macos")]
use colored::Colorize;
#[derive(Debug, Clone, Subcommand)]
pub enum ServiceAction {
Install,
Start,
Stop,
Logs,
}
#[cfg(target_os = "macos")]
pub const LAUNCHD_LABEL: &str = "com.trusty.memory";
pub fn handle_service(action: &ServiceAction) -> Result<()> {
#[cfg(target_os = "macos")]
{
match action {
ServiceAction::Install => service_install(),
ServiceAction::Start => service_start(),
ServiceAction::Stop => service_stop(),
ServiceAction::Logs => service_logs(),
}
}
#[cfg(not(target_os = "macos"))]
{
let _ = action;
anyhow::bail!(
"`trusty-memory service` is not supported on this platform — \
use your distro's service manager (systemd, OpenRC, etc.) directly."
);
}
}
#[cfg(target_os = "macos")]
pub(crate) fn launchd_log_dir() -> Result<std::path::PathBuf> {
let data =
dirs::data_dir().ok_or_else(|| anyhow::anyhow!("could not resolve user data directory"))?;
let dir = data.join("trusty-memory").join("logs");
std::fs::create_dir_all(&dir)
.map_err(|e| anyhow::anyhow!("create log dir {}: {e}", dir.display()))?;
Ok(dir)
}
#[cfg(target_os = "macos")]
pub(crate) fn build_launchd_config(
exe: std::path::PathBuf,
log_dir: std::path::PathBuf,
) -> trusty_common::launchd::LaunchdConfig {
use trusty_common::launchd::{KeepAlive, LaunchdConfig};
LaunchdConfig {
label: LAUNCHD_LABEL.to_string(),
exe_path: exe,
args: vec!["serve".to_string(), "--foreground".to_string()],
log_dir,
keep_alive: KeepAlive::OnSuccess,
throttle_interval: 10,
env_vars: fastembed_env_vars(),
}
}
#[cfg(target_os = "macos")]
fn fastembed_env_vars() -> Vec<(String, String)> {
if let Some(home) = dirs::home_dir() {
let cache = home.join(".cache").join("fastembed");
let value = cache.to_string_lossy().into_owned();
return vec![
("FASTEMBED_CACHE_DIR".to_string(), value.clone()),
("FASTEMBED_CACHE_PATH".to_string(), value),
];
}
Vec::new()
}
#[cfg(target_os = "macos")]
fn current_exe() -> Result<std::path::PathBuf> {
std::env::current_exe().map_err(|e| anyhow::anyhow!("could not resolve current exe: {e}"))
}
#[cfg(target_os = "macos")]
fn service_install() -> Result<()> {
let exe = current_exe()?;
let log_dir = launchd_log_dir()?;
let cfg = build_launchd_config(exe, log_dir.clone());
let plist_path = cfg.plist_path()?;
cfg.install()?;
println!(
"{} Wrote LaunchAgent plist: {}",
"✓".green(),
plist_path.display()
);
ensure_fastembed_cache_dir();
println!(
" Logs: {}\n Start: {}",
log_dir.display().to_string().dimmed(),
"trusty-memory service start".cyan(),
);
Ok(())
}
#[cfg(target_os = "macos")]
fn ensure_fastembed_cache_dir() {
let Some(home) = dirs::home_dir() else {
return;
};
let cache = home.join(".cache").join("fastembed");
match std::fs::create_dir_all(&cache) {
Ok(()) => println!(
"{} fastembed cache dir ready at {}",
"✓".green(),
cache.display().to_string().dimmed()
),
Err(e) => eprintln!(
" {} could not pre-create {} ({e}); daemon will retry on first request.",
"·".dimmed(),
cache.display()
),
}
}
#[cfg(target_os = "macos")]
fn service_start() -> Result<()> {
let exe = current_exe()?;
let log_dir = launchd_log_dir()?;
let cfg = build_launchd_config(exe, log_dir.clone());
let plist_path = cfg.plist_path()?;
cfg.install()?;
println!(
"{} Wrote LaunchAgent plist: {}",
"✓".green(),
plist_path.display()
);
cfg.bootstrap()?;
let domain = format!("gui/{}", trusty_common::launchd::current_uid());
println!(
"{} Loaded {} into {} — daemon will start automatically.",
"✓".green(),
LAUNCHD_LABEL,
domain
);
println!(
" Logs: {}\n Stop: {}",
log_dir.display().to_string().dimmed(),
"trusty-memory service stop".cyan(),
);
Ok(())
}
#[cfg(target_os = "macos")]
fn service_stop() -> Result<()> {
let exe = current_exe()?;
let log_dir = launchd_log_dir()?;
let cfg = build_launchd_config(exe, log_dir);
cfg.bootout()?;
println!(
"{} Unloaded {} (plist file preserved at {}).",
"✓".green(),
LAUNCHD_LABEL,
cfg.plist_path()?.display().to_string().dimmed()
);
Ok(())
}
#[cfg(target_os = "macos")]
fn service_logs() -> Result<()> {
let log_dir = launchd_log_dir()?;
let stdout = log_dir.join("stdout.log");
let stderr = log_dir.join("stderr.log");
if !stdout.exists() && !stderr.exists() {
eprintln!(
"{} No logs at {} yet — start the service first ({}).",
"·".dimmed(),
log_dir.display(),
"trusty-memory service start".cyan()
);
return Ok(());
}
let status = std::process::Command::new("tail")
.arg("-F")
.arg(&stdout)
.arg(&stderr)
.status()
.map_err(|e| anyhow::anyhow!("tail failed: {e}"))?;
if !status.success() {
anyhow::bail!("tail exited with {status}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_os = "macos"))]
#[test]
fn handle_service_errors_on_unsupported_platform() {
for action in [
ServiceAction::Install,
ServiceAction::Start,
ServiceAction::Stop,
ServiceAction::Logs,
] {
let err = handle_service(&action).expect_err("must fail on non-macOS");
let msg = format!("{err}");
assert!(
msg.contains("not supported"),
"expected platform error, got: {msg}"
);
}
}
#[cfg(target_os = "macos")]
#[test]
fn build_launchd_config_uses_canonical_shape() {
use std::path::PathBuf;
use trusty_common::launchd::KeepAlive;
let cfg = build_launchd_config(
PathBuf::from("/usr/local/bin/trusty-memory"),
PathBuf::from("/tmp/trusty-memory/logs"),
);
assert_eq!(cfg.label, LAUNCHD_LABEL);
assert_eq!(
cfg.args,
vec!["serve".to_string(), "--foreground".to_string()],
"launchd plist must invoke `serve --foreground` (issue #132) so \
launchd supervises the daemon PID directly instead of \
re-launching the self-spawning parent on every exit"
);
assert_eq!(cfg.keep_alive, KeepAlive::OnSuccess);
assert_eq!(cfg.throttle_interval, 10);
if dirs::home_dir().is_some() {
assert!(
cfg.env_vars.iter().any(|(k, _)| k == "FASTEMBED_CACHE_DIR"),
"FASTEMBED_CACHE_DIR must be present in the LaunchAgent plist (GH #58)"
);
}
}
#[cfg(target_os = "macos")]
#[test]
fn build_launchd_config_sets_fastembed_cache_dir() {
use std::path::PathBuf;
let cfg = build_launchd_config(
PathBuf::from("/usr/local/bin/trusty-memory"),
PathBuf::from("/tmp/trusty-memory/logs"),
);
if let Some(home) = dirs::home_dir() {
let expected = home
.join(".cache")
.join("fastembed")
.to_string_lossy()
.into_owned();
let dir_value = cfg
.env_vars
.iter()
.find(|(k, _)| k == "FASTEMBED_CACHE_DIR")
.map(|(_, v)| v.clone())
.expect("FASTEMBED_CACHE_DIR must be present");
assert_eq!(dir_value, expected);
let path_value = cfg
.env_vars
.iter()
.find(|(k, _)| k == "FASTEMBED_CACHE_PATH")
.map(|(_, v)| v.clone())
.expect("FASTEMBED_CACHE_PATH must be present (GH #62)");
assert_eq!(path_value, expected);
}
}
}