use std::path::{Path, PathBuf};
use roboticus_core::{Result, RoboticusError, home_dir};
const WINDOWS_DAEMON_NAME: &str = "RoboticusAgent";
pub fn launchd_plist(binary_path: &str, config_path: &str, port: u16) -> String {
let log_dir = home_dir().join(".roboticus").join("logs");
let stdout_log = log_dir.join("roboticus.stdout.log");
let stderr_log = log_dir.join("roboticus.stderr.log");
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>com.roboticus.agent</string>
<key>ProgramArguments</key>
<array>
<string>{binary_path}</string>
<string>serve</string>
<string>-c</string>
<string>{config_path}</string>
<string>-p</string>
<string>{port}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{stdout}</string>
<key>StandardErrorPath</key>
<string>{stderr}</string>
</dict>
</plist>"#,
binary_path = binary_path,
config_path = config_path,
port = port,
stdout = stdout_log.display(),
stderr = stderr_log.display(),
)
}
pub fn systemd_unit(binary_path: &str, config_path: &str, port: u16) -> String {
format!(
r#"[Unit]
Description=Roboticus Autonomous Agent Runtime
After=network.target
[Service]
Type=simple
ExecStart={binary_path} serve -c {config_path} -p {port}
Restart=on-failure
RestartSec=5
Environment=RUST_LOG=info
[Install]
WantedBy=default.target
"#,
binary_path = binary_path,
config_path = config_path,
port = port
)
}
fn plist_path_for(home: &str) -> PathBuf {
PathBuf::from(home).join("Library/LaunchAgents/com.roboticus.agent.plist")
}
pub fn plist_path() -> PathBuf {
plist_path_for(&home_dir().to_string_lossy())
}
fn systemd_path_for(home: &str) -> PathBuf {
PathBuf::from(home).join(".config/systemd/user/roboticus.service")
}
pub fn systemd_path() -> PathBuf {
systemd_path_for(&home_dir().to_string_lossy())
}
fn windows_service_marker_path() -> PathBuf {
home_dir()
.join(".roboticus")
.join("windows-service-install.txt")
}
#[derive(Debug, Clone)]
struct WindowsDaemonInstall {
binary: String,
config: String,
port: u16,
pid: Option<u32>,
}
fn parse_windows_daemon_marker(content: &str) -> Option<WindowsDaemonInstall> {
let mut binary = None;
let mut config = None;
let mut port = None;
let mut pid = None;
for line in content.lines() {
if let Some((k, v)) = line.split_once('=') {
match k.trim() {
"binary" => binary = Some(v.trim().to_string()),
"config" => config = Some(v.trim().to_string()),
"port" => {
port = v.trim().parse::<u16>().ok();
}
"pid" => {
pid = v.trim().parse::<u32>().ok();
}
_ => {}
}
}
}
Some(WindowsDaemonInstall {
binary: binary?,
config: config?,
port: port?,
pid,
})
}
fn write_windows_daemon_marker(install: &WindowsDaemonInstall) -> Result<()> {
let marker = windows_service_marker_path();
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent)?;
}
let mut content = format!(
"name={WINDOWS_DAEMON_NAME}\nmode=user_process\nbinary={}\nconfig={}\nport={}\n",
install.binary, install.config, install.port
);
if let Some(pid) = install.pid {
content.push_str(&format!("pid={pid}\n"));
}
std::fs::write(&marker, content)?;
Ok(())
}
fn read_windows_daemon_marker() -> Result<Option<WindowsDaemonInstall>> {
let marker = windows_service_marker_path();
if !marker.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(marker)?;
Ok(parse_windows_daemon_marker(&content))
}
fn windows_pid_running(pid: u32) -> Result<bool> {
if std::env::consts::OS != "windows" {
return Ok(false);
}
let script = format!(
"try {{ $null = Get-Process -Id {pid} -ErrorAction Stop; Write-Output 'RUNNING' }} catch {{ Write-Output 'NOTFOUND' }}"
);
let out = command_output("powershell", &["-NoProfile", "-Command", &script])?;
if !out.status.success() {
let pid_filter = format!("PID eq {pid}");
let out = command_output("tasklist", &["/FI", &pid_filter, "/FO", "CSV", "/NH"])?;
if !out.status.success() {
return Ok(false);
}
let stdout = String::from_utf8_lossy(&out.stdout);
return Ok(stdout.contains(&format!("\"{pid}\"")));
}
let stdout = String::from_utf8_lossy(&out.stdout);
Ok(stdout.trim() == "RUNNING")
}
fn windows_listening_pid(port: u16) -> Result<Option<u32>> {
if std::env::consts::OS != "windows" {
return Ok(None);
}
let script = format!(
"$c = Get-NetTCPConnection -LocalPort {port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) {{ Write-Output $c.OwningProcess }}"
);
if let Ok(out) = command_output("powershell", &["-NoProfile", "-Command", &script])
&& out.status.success()
{
let stdout = String::from_utf8_lossy(&out.stdout);
let pid = stdout.trim().parse::<u32>().ok();
if pid.is_some() {
return Ok(pid);
}
}
let out = command_output("netstat", &["-ano"])?;
if !out.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&out.stdout);
let needle = format!(":{port}");
for line in stdout.lines() {
let lower = line.to_ascii_lowercase();
if !lower.contains("listen") || !line.contains(&needle) {
continue;
}
let cols: Vec<&str> = line.split_whitespace().collect();
if let Some(last) = cols.last()
&& let Ok(pid) = last.parse::<u32>()
{
return Ok(Some(pid));
}
}
Ok(None)
}
fn spawn_windows_daemon_process(install: &WindowsDaemonInstall) -> Result<u32> {
let mut cmd = std::process::Command::new(&install.binary);
cmd.args([
"serve",
"-c",
&install.config,
"-p",
&install.port.to_string(),
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
let child = cmd
.spawn()
.map_err(|e| RoboticusError::Config(format!("failed to spawn daemon process: {e}")))?;
Ok(child.id())
}
fn cleanup_legacy_windows_service() {
if std::env::consts::OS != "windows" {
return;
}
let is_admin = std::process::Command::new("net")
.args(["session"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !is_admin {
tracing::warn!("legacy Windows service cleanup skipped: Administrator privileges required");
return;
}
match legacy_windows_service_exists() {
Ok(false) => return,
Ok(true) => {}
Err(e) => {
tracing::warn!(error = %e, "unable to verify legacy Windows service presence");
}
}
for attempt in 1..=3 {
if let Err(e) = run_sc_best_effort("stop", WINDOWS_DAEMON_NAME) {
tracing::debug!(
error = %e,
attempt,
"legacy Windows service stop failed"
);
}
if let Err(e) = run_sc_best_effort("delete", WINDOWS_DAEMON_NAME) {
tracing::debug!(
error = %e,
attempt,
"legacy Windows service delete failed"
);
}
std::thread::sleep(std::time::Duration::from_millis(600));
match legacy_windows_service_exists() {
Ok(false) => {
tracing::info!("legacy Windows service cleanup complete");
return;
}
Ok(true) => {}
Err(e) => {
tracing::warn!(error = %e, "failed to verify legacy Windows service cleanup");
}
}
}
tracing::warn!(
"legacy Windows service still present after cleanup attempts; remove with `sc.exe delete {WINDOWS_DAEMON_NAME}`"
);
}
fn run_sc_best_effort(action: &str, service: &str) -> Result<()> {
let out = command_output("sc.exe", &[action, service])?;
if out.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{stdout}\n{stderr}");
if sc_output_is_not_found(&combined) || sc_output_is_not_active(&combined) {
return Ok(());
}
let detail = if !stderr.trim().is_empty() {
stderr.trim().to_string()
} else {
stdout.trim().to_string()
};
Err(RoboticusError::Config(format!(
"sc.exe {action} failed (exit {}): {}",
out.status.code().unwrap_or(-1),
detail
)))
}
fn legacy_windows_service_exists() -> Result<bool> {
let out = command_output("sc.exe", &["query", WINDOWS_DAEMON_NAME])?;
if out.status.success() {
return Ok(true);
}
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = format!("{stdout}\n{stderr}");
if sc_output_is_not_found(&combined) {
return Ok(false);
}
Err(RoboticusError::Config(format!(
"sc.exe query failed (exit {}): {}",
out.status.code().unwrap_or(-1),
combined.trim()
)))
}
fn sc_output_is_not_found(output: &str) -> bool {
let lowered = output.to_ascii_lowercase();
lowered.contains("1060") || lowered.contains("does not exist")
}
fn sc_output_is_not_active(output: &str) -> bool {
let lowered = output.to_ascii_lowercase();
lowered.contains("1062") || lowered.contains("has not been started")
}
fn install_daemon_to(
binary_path: &str,
config_path: &str,
port: u16,
home: &str,
) -> Result<PathBuf> {
let os = std::env::consts::OS;
let (content, path) = match os {
"macos" => (
launchd_plist(binary_path, config_path, port),
plist_path_for(home),
),
"linux" => (
systemd_unit(binary_path, config_path, port),
systemd_path_for(home),
),
"windows" => {
cleanup_legacy_windows_service();
let marker = windows_service_marker_path();
let install = WindowsDaemonInstall {
binary: binary_path.to_string(),
config: config_path.to_string(),
port,
pid: None,
};
write_windows_daemon_marker(&install)?;
return Ok(marker);
}
other => {
return Err(RoboticusError::Config(format!(
"daemon install not supported on {other}"
)));
}
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &content)?;
Ok(path)
}
pub fn install_daemon(binary_path: &str, config_path: &str, port: u16) -> Result<PathBuf> {
let home = home_dir();
let result = install_daemon_to(binary_path, config_path, port, &home.to_string_lossy())?;
#[cfg(windows)]
{
let task_xml = format!(
r#"<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<Triggers>
<LogonTrigger><Enabled>true</Enabled></LogonTrigger>
</Triggers>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Hidden>false</Hidden>
</Settings>
<Actions>
<Exec>
<Command>{binary}</Command>
<Arguments>serve -c "{config}" -p {port}</Arguments>
</Exec>
</Actions>
</Task>"#,
binary = binary_path,
config = config_path,
port = port,
);
let task_file = std::env::temp_dir().join("roboticus-task.xml");
std::fs::write(&task_file, &task_xml).map_err(|e| {
RoboticusError::Config(format!("failed to write task scheduler XML: {e}"))
})?;
let schtasks_out = std::process::Command::new("schtasks")
.args([
"/Create",
"/TN",
"RoboticusAgent",
"/XML",
&task_file.to_string_lossy(),
"/F",
])
.output()
.map_err(|e| {
let _ = std::fs::remove_file(&task_file);
RoboticusError::Config(format!("failed to run schtasks: {e}"))
})?;
let _ = std::fs::remove_file(&task_file);
if !schtasks_out.status.success() {
let stderr = String::from_utf8_lossy(&schtasks_out.stderr);
return Err(RoboticusError::Config(format!(
"schtasks /Create failed (exit {}): {}",
schtasks_out.status.code().unwrap_or(-1),
stderr.trim()
)));
}
}
Ok(result)
}
pub fn start_daemon() -> Result<()> {
let os = std::env::consts::OS;
match os {
"macos" => {
let output = std::process::Command::new("launchctl")
.args(["load", "-w"])
.arg(plist_path())
.output()
.map_err(|e| RoboticusError::Config(format!("failed to run launchctl: {e}")))?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(RoboticusError::Config(format!(
"launchctl load failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr.trim()
)));
}
std::thread::sleep(std::time::Duration::from_secs(1));
verify_launchd_running()?;
Ok(())
}
"linux" => {
run_cmd("systemctl", &["--user", "daemon-reload"])?;
run_cmd(
"systemctl",
&["--user", "enable", "--now", "roboticus.service"],
)
}
"windows" => {
let mut install = read_windows_daemon_marker()?.ok_or_else(|| {
RoboticusError::Config("daemon not installed on windows".to_string())
})?;
if let Some(pid) = install.pid
&& windows_pid_running(pid)?
{
return Ok(());
}
if let Some(pid) = windows_listening_pid(install.port)?
&& windows_pid_running(pid)?
{
install.pid = Some(pid);
write_windows_daemon_marker(&install)?;
return Ok(());
}
let pid = spawn_windows_daemon_process(&install)?;
install.pid = Some(pid);
write_windows_daemon_marker(&install)?;
std::thread::sleep(std::time::Duration::from_secs(1));
if !windows_pid_running(pid)? {
let detail = if let Some(owner) = windows_listening_pid(install.port)? {
format!(
"daemon process exited immediately after spawn — port {} is already in use by pid {}",
install.port, owner
)
} else {
"daemon process exited immediately after spawn — check config and port availability"
.to_string()
};
return Err(RoboticusError::Config(detail));
}
Ok(())
}
other => Err(RoboticusError::Config(format!(
"daemon start not supported on {other}"
))),
}
}
pub fn stop_daemon() -> Result<()> {
let os = std::env::consts::OS;
match os {
"macos" => run_cmd("launchctl", &["unload", &plist_path().to_string_lossy()]),
"linux" => run_cmd("systemctl", &["--user", "stop", "roboticus.service"]),
"windows" => {
let mut install = match read_windows_daemon_marker()? {
Some(i) => i,
None => return Ok(()),
};
let pid = install.pid.or(windows_listening_pid(install.port)?);
let Some(pid) = pid else {
return Ok(());
};
let pid_s = pid.to_string();
if windows_pid_running(pid)? {
run_cmd("taskkill", &["/PID", &pid_s, "/T", "/F"])?;
}
install.pid = None;
write_windows_daemon_marker(&install)
}
other => Err(RoboticusError::Config(format!(
"daemon stop not supported on {other}"
))),
}
}
pub fn restart_daemon() -> Result<()> {
let os = std::env::consts::OS;
match os {
"macos" => {
if let Err(e) = stop_daemon()
&& !is_benign_stop_error(&e)
{
return Err(e);
}
start_daemon()
}
"linux" => run_cmd("systemctl", &["--user", "restart", "roboticus.service"]),
"windows" => {
if let Err(e) = stop_daemon()
&& !is_benign_stop_error(&e)
{
return Err(e);
}
start_daemon()
}
other => Err(RoboticusError::Config(format!(
"daemon restart not supported on {other}"
))),
}
}
const LAUNCHD_LABEL: &str = "com.roboticus.agent";
fn run_cmd(program: &str, args: &[&str]) -> Result<()> {
let output = std::process::Command::new(program)
.args(args)
.output()
.map_err(|e| RoboticusError::Config(format!("failed to run {program}: {e}")))?;
if output.status.success() {
Ok(())
} else {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = if !stderr.trim().is_empty() {
stderr.trim().to_string()
} else {
stdout.trim().to_string()
};
Err(RoboticusError::Config(format!(
"{program} failed (exit {}): {}",
output.status.code().unwrap_or(-1),
detail
)))
}
}
fn command_output(program: &str, args: &[&str]) -> Result<std::process::Output> {
std::process::Command::new(program)
.args(args)
.output()
.map_err(|e| RoboticusError::Config(format!("failed to run {program}: {e}")))
}
fn windows_service_exists() -> Result<bool> {
if std::env::consts::OS != "windows" {
return Ok(false);
}
let marker = windows_service_marker_path();
Ok(marker.exists())
}
pub fn daemon_status() -> Result<String> {
match std::env::consts::OS {
"macos" => {
if !is_installed() {
return Ok("Daemon not installed".into());
}
match command_output("launchctl", &["list", LAUNCHD_LABEL]) {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.contains("\"PID\"") {
Ok("Daemon running (launchd loaded)".into())
} else {
Ok("Daemon installed but not running".into())
}
}
Ok(_) => Ok("Daemon installed but not running".into()),
Err(e) => Err(e),
}
}
"linux" => {
if !is_installed() {
return Ok("Daemon not installed".into());
}
let out = command_output("systemctl", &["--user", "is-active", "roboticus.service"])?;
if out.status.success() {
Ok("Daemon running (systemd active)".into())
} else {
Ok("Daemon installed but not running".into())
}
}
"windows" => {
if !windows_service_exists()? {
return Ok("Daemon not installed".into());
}
let install = read_windows_daemon_marker()?;
match install {
Some(i) => {
if let Some(pid) = i.pid
&& windows_pid_running(pid)?
{
return Ok(format!("Daemon running (Windows process pid={pid})"));
}
Ok("Daemon installed but stopped (Windows user process)".into())
}
None => Ok("Daemon not installed".into()),
}
}
other => Ok(format!("Daemon status unsupported on {other}")),
}
}
fn verify_launchd_running() -> Result<()> {
let output = std::process::Command::new("launchctl")
.args(["list", LAUNCHD_LABEL])
.output()
.map_err(|e| RoboticusError::Config(format!("failed to query launchctl: {e}")))?;
if !output.status.success() {
return Err(RoboticusError::Config(
"daemon service is not loaded — check the plist path and binary".into(),
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("\"LastExitStatus\"") {
let code = rest
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-')
.trim_end_matches(';')
.trim();
if code != "0" {
let stderr_path = home_dir().join(".roboticus/logs/roboticus.stderr.log");
let hint = if stderr_path.exists() {
format!(" (see {})", stderr_path.display())
} else {
String::new()
};
return Err(RoboticusError::Config(format!(
"daemon exited immediately with code {code}{hint}"
)));
}
}
}
for line in stdout.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("\"PID\"") {
let pid = rest
.trim_start_matches(|c: char| !c.is_ascii_digit())
.trim_end_matches(';')
.trim();
if !pid.is_empty() {
return Ok(());
}
}
}
Err(RoboticusError::Config(
"daemon loaded but no PID found — service may have crashed on startup".into(),
))
}
fn is_benign_stop_error(e: &RoboticusError) -> bool {
let msg = e.to_string().to_ascii_lowercase();
msg.contains("1062")
|| msg.contains("service has not been started")
|| msg.contains("the service has not been started")
|| msg.contains("inactive")
|| msg.contains("not loaded")
}
fn is_installed_result() -> Result<bool> {
let os = std::env::consts::OS;
if os == "windows" {
return windows_service_exists();
}
let path = match os {
"macos" => plist_path(),
"linux" => systemd_path(),
_ => return Ok(false),
};
Ok(path.exists())
}
pub fn is_installed() -> bool {
is_installed_result().unwrap_or(false)
}
pub fn uninstall_daemon() -> Result<()> {
if !is_installed_result()? {
return Ok(());
}
if let Err(e) = stop_daemon()
&& !is_benign_stop_error(&e)
{
return Err(e);
}
if std::env::consts::OS == "windows" {
cleanup_legacy_windows_service();
let schtasks_del = std::process::Command::new("schtasks")
.args(["/Delete", "/TN", "RoboticusAgent", "/F"])
.output();
if let Ok(out) = schtasks_del
&& !out.status.success()
{
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.to_ascii_lowercase().contains("does not exist")
&& !stderr.to_ascii_lowercase().contains("cannot find")
{
return Err(RoboticusError::Config(format!(
"schtasks /Delete failed (exit {}): {}",
out.status.code().unwrap_or(-1),
stderr.trim()
)));
}
}
let marker = windows_service_marker_path();
if marker.exists()
&& let Err(e) = std::fs::remove_file(&marker)
&& e.kind() != std::io::ErrorKind::NotFound
{
return Err(RoboticusError::Config(format!(
"failed to remove windows service marker {}: {e}",
marker.display()
)));
}
return Ok(());
}
let path = match std::env::consts::OS {
"macos" => plist_path(),
"linux" => systemd_path(),
_ => return Ok(()),
};
std::fs::remove_file(&path)?;
Ok(())
}
pub fn write_pid_file(path: &Path) -> Result<()> {
let pid = std::process::id();
std::fs::write(path, pid.to_string())?;
Ok(())
}
pub fn read_pid_file(path: &Path) -> Result<Option<u32>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
let pid = contents
.trim()
.parse::<u32>()
.map_err(|e| RoboticusError::Config(format!("invalid PID file: {e}")))?;
Ok(Some(pid))
}
pub fn remove_pid_file(path: &Path) -> Result<()> {
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::EnvGuard;
#[test]
fn launchd_plist_format() {
let plist = launchd_plist("/usr/local/bin/roboticus", "/etc/roboticus.toml", 18789);
assert!(plist.contains("com.roboticus.agent"));
assert!(plist.contains("/usr/local/bin/roboticus"));
assert!(plist.contains("/etc/roboticus.toml"));
assert!(plist.contains("18789"));
assert!(plist.contains("KeepAlive"));
}
#[test]
fn systemd_unit_format() {
let unit = systemd_unit("/usr/local/bin/roboticus", "/etc/roboticus.toml", 18789);
assert!(unit.contains("ExecStart="));
assert!(unit.contains("/usr/local/bin/roboticus"));
assert!(unit.contains("Restart=on-failure"));
assert!(unit.contains("[Install]"));
}
#[test]
fn daemon_paths_are_derived_from_home() {
let home = "/tmp/roboticus-home";
assert_eq!(
plist_path_for(home),
PathBuf::from("/tmp/roboticus-home/Library/LaunchAgents/com.roboticus.agent.plist")
);
assert_eq!(
systemd_path_for(home),
PathBuf::from("/tmp/roboticus-home/.config/systemd/user/roboticus.service")
);
}
#[test]
fn pid_file_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let pid_path = dir.path().join("test.pid");
write_pid_file(&pid_path).unwrap();
let pid = read_pid_file(&pid_path).unwrap();
assert!(pid.is_some());
assert_eq!(pid.unwrap(), std::process::id());
remove_pid_file(&pid_path).unwrap();
assert!(!pid_path.exists());
}
#[test]
fn read_missing_pid_file() {
let result = read_pid_file(Path::new("/nonexistent/pid"));
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn remove_missing_pid_file() {
let result = remove_pid_file(Path::new("/nonexistent/pid"));
assert!(result.is_ok());
}
#[test]
fn plist_path_is_under_launch_agents() {
let path = plist_path();
let path_str = path.to_string_lossy();
assert!(path_str.contains("LaunchAgents"));
assert!(path_str.ends_with("com.roboticus.agent.plist"));
}
#[test]
fn systemd_path_is_under_systemd_user() {
let path = systemd_path();
let path_str = path.to_string_lossy();
assert!(path_str.contains("systemd/user"));
assert!(path_str.ends_with("roboticus.service"));
}
#[test]
fn read_pid_file_with_invalid_content_returns_error() {
let dir = tempfile::tempdir().unwrap();
let pid_path = dir.path().join("bad.pid");
std::fs::write(&pid_path, "not-a-number").unwrap();
assert!(read_pid_file(&pid_path).is_err());
}
#[test]
fn read_pid_file_with_whitespace_trims() {
let dir = tempfile::tempdir().unwrap();
let pid_path = dir.path().join("ws.pid");
std::fs::write(&pid_path, " 12345 \n").unwrap();
let result = read_pid_file(&pid_path).unwrap();
assert_eq!(result, Some(12345));
}
#[test]
fn launchd_plist_is_valid_xml() {
let plist = launchd_plist("/usr/bin/roboticus", "/etc/roboticus.toml", 9999);
assert!(plist.starts_with("<?xml"));
assert!(plist.contains("<plist version=\"1.0\">"));
assert!(plist.contains("</plist>"));
assert!(plist.contains("<string>9999</string>"));
assert!(plist.contains("<string>serve</string>"));
}
#[test]
fn systemd_unit_has_required_sections() {
let unit = systemd_unit("/usr/bin/roboticus", "/etc/roboticus.toml", 8080);
assert!(unit.contains("[Unit]"));
assert!(unit.contains("[Service]"));
assert!(unit.contains("[Install]"));
assert!(unit.contains("ExecStart=/usr/bin/roboticus serve -c /etc/roboticus.toml -p 8080"));
assert!(unit.contains("Type=simple"));
}
#[test]
fn sc_output_not_found_detection() {
assert!(sc_output_is_not_found("OpenService FAILED 1060"));
assert!(sc_output_is_not_found(
"The specified service does not exist as an installed service."
));
assert!(!sc_output_is_not_found("SERVICE_NAME: RoboticusAgent"));
}
#[test]
fn sc_output_not_active_detection() {
assert!(sc_output_is_not_active("ControlService FAILED 1062"));
assert!(sc_output_is_not_active("The service has not been started."));
assert!(!sc_output_is_not_active("STATE : 4 RUNNING"));
}
#[test]
fn install_daemon_creates_file() {
if std::env::consts::OS == "windows" {
return;
}
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap();
let bin = dir.path().join("roboticus");
std::fs::write(&bin, "").unwrap();
let cfg = dir.path().join("roboticus.toml");
std::fs::write(&cfg, "").unwrap();
let result = install_daemon_to(bin.to_str().unwrap(), cfg.to_str().unwrap(), 18789, home);
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.exists());
}
#[test]
fn write_and_read_pid_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let pid_path = dir.path().join("test.pid");
write_pid_file(&pid_path).unwrap();
assert!(pid_path.exists());
let pid = read_pid_file(&pid_path).unwrap().unwrap();
assert_eq!(pid, std::process::id());
remove_pid_file(&pid_path).unwrap();
assert!(!pid_path.exists());
}
#[test]
fn parse_windows_daemon_marker_basic() {
let input = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\nport=18789\npid=1234\n";
let parsed = parse_windows_daemon_marker(input).unwrap();
assert_eq!(parsed.binary, "C:\\x\\roboticus.exe");
assert_eq!(parsed.config, "C:\\x\\roboticus.toml");
assert_eq!(parsed.port, 18789);
assert_eq!(parsed.pid, Some(1234));
}
#[test]
fn parse_windows_daemon_marker_without_pid() {
let input = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\nport=18789\n";
let parsed = parse_windows_daemon_marker(input).unwrap();
assert_eq!(parsed.binary, "C:\\x\\roboticus.exe");
assert_eq!(parsed.config, "C:\\x\\roboticus.toml");
assert_eq!(parsed.port, 18789);
assert_eq!(parsed.pid, None);
}
#[test]
fn parse_windows_daemon_marker_rejects_missing_required_fields() {
let missing_binary =
"name=RoboticusAgent\nmode=user_process\nconfig=C:\\x\\roboticus.toml\nport=18789\n";
assert!(parse_windows_daemon_marker(missing_binary).is_none());
let missing_port = "name=RoboticusAgent\nmode=user_process\nbinary=C:\\x\\roboticus.exe\nconfig=C:\\x\\roboticus.toml\n";
assert!(parse_windows_daemon_marker(missing_port).is_none());
}
#[test]
fn write_and_read_windows_daemon_marker_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_string_lossy().into_owned();
let _home = EnvGuard::set("HOME", &home);
let install = WindowsDaemonInstall {
binary: "C:\\roboticus\\roboticus.exe".into(),
config: "C:\\roboticus\\roboticus.toml".into(),
port: 18789,
pid: Some(4242),
};
write_windows_daemon_marker(&install).unwrap();
let loaded = read_windows_daemon_marker()
.unwrap()
.expect("marker exists");
assert_eq!(loaded.binary, install.binary);
assert_eq!(loaded.config, install.config);
assert_eq!(loaded.port, install.port);
assert_eq!(loaded.pid, install.pid);
}
#[test]
fn read_windows_daemon_marker_returns_none_when_missing() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_string_lossy().into_owned();
let _home = EnvGuard::set("HOME", &home);
assert!(read_windows_daemon_marker().unwrap().is_none());
}
#[test]
fn benign_stop_errors_are_classified() {
let err = RoboticusError::Config("service has not been started".into());
assert!(is_benign_stop_error(&err));
let err = RoboticusError::Config("not loaded".into());
assert!(is_benign_stop_error(&err));
let err = RoboticusError::Config("permission denied".into());
assert!(!is_benign_stop_error(&err));
}
#[test]
fn run_cmd_reports_missing_program_with_context() {
let err = run_cmd("definitely-not-a-real-command", &[]).expect_err("missing command");
assert!(
err.to_string()
.contains("failed to run definitely-not-a-real-command")
);
}
#[test]
fn command_output_reports_missing_program_with_context() {
let err =
command_output("definitely-not-a-real-command", &[]).expect_err("missing command");
assert!(
err.to_string()
.contains("failed to run definitely-not-a-real-command")
);
}
#[test]
fn install_daemon_to_writes_expected_platform_content() {
if std::env::consts::OS == "windows" {
return;
}
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap();
let bin = dir.path().join("roboticus");
let cfg = dir.path().join("roboticus.toml");
std::fs::write(&bin, "").unwrap();
std::fs::write(&cfg, "").unwrap();
let path = install_daemon_to(bin.to_str().unwrap(), cfg.to_str().unwrap(), 18789, home)
.expect("install daemon file");
let contents = std::fs::read_to_string(path).unwrap();
assert!(contents.contains(bin.to_str().unwrap()));
assert!(contents.contains(cfg.to_str().unwrap()));
assert!(contents.contains("18789"));
}
#[test]
fn fresh_home_reports_not_installed() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_string_lossy().into_owned();
let _home = EnvGuard::set("HOME", &home);
assert!(!is_installed());
let status = daemon_status().unwrap();
assert!(status.to_ascii_lowercase().contains("not installed"));
}
#[test]
fn uninstall_daemon_is_noop_when_not_installed() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_string_lossy().into_owned();
let _home = EnvGuard::set("HOME", &home);
uninstall_daemon().unwrap();
}
}