use std::process::Command;
use super::{ServiceError, ServiceManager};
const SERVICE_LABEL: &str = "com.eggstack.greggd";
#[derive(Debug, Clone)]
pub struct LaunchdManager {
label: String,
domain: Option<String>,
}
impl LaunchdManager {
#[must_use]
pub fn new() -> Self {
Self {
label: SERVICE_LABEL.to_owned(),
domain: None,
}
}
#[must_use]
pub fn with_label(label: impl Into<String>, domain: Option<String>) -> Self {
Self {
label: label.into(),
domain,
}
}
fn domain_target(&self) -> String {
if let Some(ref d) = self.domain {
return d.clone();
}
"system".to_owned()
}
fn service_target(&self) -> String {
format!("{}/{}", self.domain_target(), self.label)
}
#[allow(
clippy::unused_self,
reason = "kept for API consistency with systemd adapter"
)]
fn run_launchctl(&self, args: &[&str]) -> Result<(), ServiceError> {
let output = Command::new("launchctl").args(args).output().map_err(|e| {
ServiceError::ExecFailed {
command: format!("launchctl {}", args.join(" ")),
source: e,
}
})?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
Err(ServiceError::CommandFailed {
command: format!("launchctl {}", args.join(" ")),
exit_status: output.status.code(),
stderr,
})
}
}
pub fn bootstrap(&self, plist_path: &str) -> Result<(), ServiceError> {
let target = self.service_target();
self.run_launchctl(&["bootstrap", &target, plist_path])
}
pub fn bootout(&self) -> Result<(), ServiceError> {
let target = self.service_target();
self.run_launchctl(&["bootout", &target])
}
pub fn kickstart(&self) -> Result<(), ServiceError> {
let target = self.service_target();
self.run_launchctl(&["kickstart", "-k", &target])
}
fn check_loaded(&self) -> Result<bool, ServiceError> {
let output = Command::new("launchctl")
.args(["list"])
.output()
.map_err(|e| ServiceError::ExecFailed {
command: "launchctl list".into(),
source: e,
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
return Err(ServiceError::CommandFailed {
command: "launchctl list".into(),
exit_status: output.status.code(),
stderr,
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().any(|line| {
line.split_whitespace()
.last()
.is_some_and(|field| field == self.label)
}))
}
}
impl Default for LaunchdManager {
fn default() -> Self {
Self::new()
}
}
impl ServiceManager for LaunchdManager {
fn start(&self) -> Result<(), ServiceError> {
self.kickstart()
}
fn stop(&self) -> Result<(), ServiceError> {
self.bootout()
}
fn restart(&self) -> Result<(), ServiceError> {
self.kickstart()
}
fn is_active(&self) -> Result<bool, ServiceError> {
self.check_loaded()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launchd_manager_construction() {
let manager = LaunchdManager::new();
assert_eq!(manager.label, "com.eggstack.greggd");
assert!(manager.domain.is_none());
}
#[test]
fn launchd_manager_with_custom_label() {
let manager = LaunchdManager::with_label("com.test.greggd", Some("system".into()));
assert_eq!(manager.label, "com.test.greggd");
assert_eq!(manager.domain, Some("system".into()));
}
#[test]
fn domain_target_default() {
let manager = LaunchdManager::new();
assert_eq!(manager.domain_target(), "system");
}
#[test]
fn domain_target_custom() {
let manager = LaunchdManager::with_label("test", Some("gui/501".into()));
assert_eq!(manager.domain_target(), "gui/501");
}
#[test]
fn service_target_format() {
let manager = LaunchdManager::new();
assert_eq!(manager.service_target(), "system/com.eggstack.greggd");
}
#[test]
fn launchd_manager_clone() {
let manager = LaunchdManager::new();
let cloned = manager.clone();
assert_eq!(manager.label, cloned.label);
assert_eq!(manager.domain, cloned.domain);
}
#[test]
fn launchd_manager_debug() {
let manager = LaunchdManager::new();
let debug = format!("{manager:?}");
assert!(debug.contains("LaunchdManager"));
}
#[test]
fn launchd_command_uses_argument_arrays() {
let manager = LaunchdManager::new();
let target = manager.service_target();
assert_eq!(target, "system/com.eggstack.greggd");
}
#[test]
fn check_loaded_exact_label_match() {
let label = "com.eggstack.greggd";
let line_with_suffix = " 12345 0 com.eggstack.greggd-test";
let line_exact = " 12345 0 com.eggstack.greggd";
let matches_suffix = line_with_suffix
.split_whitespace()
.last()
.is_some_and(|field| field == label);
let matches_exact = line_exact
.split_whitespace()
.last()
.is_some_and(|field| field == label);
assert!(!matches_suffix, "should not match partial label");
assert!(matches_exact, "should match exact label");
}
#[test]
fn launchd_paths_with_spaces_handled_correctly() {
let manager = LaunchdManager::new();
let target = manager.service_target();
assert!(!target.contains(' '));
}
}