use anyhow::Result;
use clap::Subcommand;
#[cfg(target_os = "macos")]
use colored::Colorize;
#[derive(Debug, Clone, Subcommand)]
pub enum ServiceAction {
Install,
Uninstall,
Status,
Logs,
}
#[cfg(target_os = "macos")]
const LAUNCHD_LABEL: &str = "com.trusty.trusty-search";
pub fn handle_service(action: &ServiceAction) -> Result<()> {
#[cfg(target_os = "macos")]
{
match action {
ServiceAction::Install => service_install(),
ServiceAction::Uninstall => service_uninstall(),
ServiceAction::Status => service_status(),
ServiceAction::Logs => service_logs(),
}
}
#[cfg(not(target_os = "macos"))]
{
let _ = action;
anyhow::bail!(
"`trusty-search service` is not supported on this platform — \
use your distro's service manager (systemd, OpenRC, etc.) directly."
);
}
}
#[cfg(target_os = "macos")]
fn launchd_plist_path() -> Result<std::path::PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve $HOME"))?;
Ok(home
.join("Library")
.join("LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist")))
}
#[cfg(target_os = "macos")]
fn launchd_log_dir() -> Result<std::path::PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve $HOME"))?;
let dir = home.join("Library").join("Logs").join("trusty-search");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
#[cfg(target_os = "macos")]
fn launchd_env_vars_plist() -> String {
use crate::service::PERSISTED_ENV_VARS;
let xml_escape = |s: &str| -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
};
let mut pairs: Vec<String> = Vec::new();
if let Some(home) = dirs::home_dir() {
let hf_home = home.join(".cache").join("huggingface");
let escaped = xml_escape(&hf_home.display().to_string());
pairs.push(format!(
" <key>HF_HOME</key>\n <string>{escaped}</string>"
));
}
for key in PERSISTED_ENV_VARS {
if let Ok(val) = std::env::var(key) {
let escaped = xml_escape(&val);
pairs.push(format!(
" <key>{key}</key>\n <string>{escaped}</string>"
));
}
}
if pairs.is_empty() {
String::new()
} else {
format!(
" <key>EnvironmentVariables</key>\n <dict>\n{}\n </dict>\n",
pairs.join("\n")
)
}
}
#[cfg(target_os = "macos")]
fn launchd_plist_body(exe: &std::path::Path, log_dir: &std::path::Path) -> String {
let exe = exe.display();
let stdout = log_dir.join("stdout.log");
let stderr = log_dir.join("stderr.log");
let env_vars_section = launchd_env_vars_plist();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LAUNCHD_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{exe}</string>
<string>start</string>
<string>--foreground</string>
</array>
{env_vars_section} <key>RunAtLoad</key>
<true/>
<!-- KeepAlive=SuccessfulExit:false means launchd only restarts the daemon
on a non-zero exit. The `start` command exits 0 when a live daemon is
already running (idempotent fast-path); without this, launchd would
immediately re-spawn and crash-loop on the existing lockfile. -->
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>StandardOutPath</key>
<string>{}</string>
<key>StandardErrorPath</key>
<string>{}</string>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
"#,
stdout.display(),
stderr.display(),
)
}
#[cfg(target_os = "macos")]
fn service_install() -> Result<()> {
let exe = std::env::current_exe()
.map_err(|e| anyhow::anyhow!("could not resolve current exe: {e}"))?;
let plist_path = launchd_plist_path()?;
if let Some(parent) = plist_path.parent() {
std::fs::create_dir_all(parent)?;
}
let log_dir = launchd_log_dir()?;
let body = launchd_plist_body(&exe, &log_dir);
std::fs::write(&plist_path, body)
.map_err(|e| anyhow::anyhow!("write {}: {e}", plist_path.display()))?;
println!(
"{} Wrote LaunchAgent plist: {}",
"✓".green(),
plist_path.display()
);
let uid = nix::unistd::getuid().as_raw();
let domain = format!("gui/{uid}");
let _ = std::process::Command::new("launchctl")
.args(["bootout", &domain])
.arg(&plist_path)
.status();
let status = std::process::Command::new("launchctl")
.args(["bootstrap", &domain])
.arg(&plist_path)
.status()
.map_err(|e| anyhow::anyhow!("launchctl bootstrap failed: {e}"))?;
if !status.success() {
anyhow::bail!("launchctl bootstrap exited with {status}");
}
println!(
"{} Loaded {} into {} — daemon will start automatically.",
"✓".green(),
LAUNCHD_LABEL,
domain
);
match crate::commands::log_rotation::install_rotation() {
Ok(()) => println!(
"{} Installed stderr.log rotation (1 MB × 7 archives, daily check)",
"✓".green()
),
Err(e) => eprintln!(
"{} Could not install log rotation ({e}) — run `trusty-search doctor --fix` later",
"⚠".yellow()
),
}
println!(
" Logs: {}\n Status: {}",
log_dir.display().to_string().dimmed(),
"trusty-search service status".cyan(),
);
Ok(())
}
#[cfg(target_os = "macos")]
fn service_uninstall() -> Result<()> {
let plist_path = launchd_plist_path()?;
let uid = nix::unistd::getuid().as_raw();
let domain = format!("gui/{uid}");
if plist_path.exists() {
let _ = std::process::Command::new("launchctl")
.args(["bootout", &domain])
.arg(&plist_path)
.status();
std::fs::remove_file(&plist_path)
.map_err(|e| anyhow::anyhow!("remove {}: {e}", plist_path.display()))?;
println!(
"{} Unloaded and removed {}",
"✓".green(),
plist_path.display()
);
if let Ok(rot_plist) = crate::commands::log_rotation::rotation_plist_path() {
if rot_plist.exists() {
let _ = std::process::Command::new("launchctl")
.args(["bootout", &domain])
.arg(&rot_plist)
.status();
let _ = std::fs::remove_file(&rot_plist);
}
}
if let Ok(conf) = crate::commands::log_rotation::newsyslog_conf_path() {
let _ = std::fs::remove_file(&conf);
}
} else {
println!(
"{} {} not installed — nothing to do",
"·".dimmed(),
plist_path.display()
);
}
Ok(())
}
#[cfg(target_os = "macos")]
fn service_status() -> Result<()> {
let uid = nix::unistd::getuid().as_raw();
let target = format!("gui/{uid}/{LAUNCHD_LABEL}");
let output = std::process::Command::new("launchctl")
.args(["print", &target])
.output()
.map_err(|e| anyhow::anyhow!("launchctl print failed: {e}"))?;
if output.status.success() {
println!("{}", String::from_utf8_lossy(&output.stdout));
} else {
eprintln!(" Install with: trusty-search service install");
anyhow::bail!(
"{} is not loaded ({})",
target,
String::from_utf8_lossy(&output.stderr).trim()
);
}
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()
);
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(())
}