use anyhow::{bail, Result};
use std::path::PathBuf;
use std::process::Command;
use crate::lfd::config::{LfdConfig, RuntimeBackend};
use super::{compose, contains_sensitive_service_env, service_environment, write_service_file};
const UNIT_NAME: &str = "lfd.service";
const UNIT_ID: &str = "lfd";
fn unit_path() -> Result<PathBuf> {
let config = dirs::config_dir().ok_or_else(|| anyhow::anyhow!("no config directory"))?;
Ok(config.join("systemd/user").join(UNIT_NAME))
}
pub fn install(config: &LfdConfig, force: bool) -> Result<()> {
if let Some(existing_backend) = installed_backend()? {
if existing_backend != config.runtime_backend {
if !force {
bail!(
"lfd is already installed with `{}` backend. Re-run `lfd install --force` to replace it.",
existing_backend.as_str()
);
}
teardown_backend(existing_backend)?;
}
}
if config.runtime_backend == RuntimeBackend::Compose {
compose::ensure_docker_available()?;
compose::write_managed_compose_file(config)?;
compose::pull()?;
}
let lfd_path = std::env::current_exe()?
.canonicalize()?
.to_string_lossy()
.to_string();
let path_env = std::env::var("PATH").unwrap_or_default();
let env_vars = service_environment(&path_env);
let exec_start = build_exec_start(config.runtime_backend, &lfd_path)?;
let unit_path = unit_path()?;
if let Some(parent) = unit_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = render_unit(&exec_start, &env_vars);
write_service_file(
&unit_path,
&content,
contains_sensitive_service_env(&env_vars),
)?;
println!("Installed {}", unit_path.display());
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
let status = Command::new("systemctl")
.args(["--user", "enable", UNIT_ID])
.status()?;
if status.success() {
println!("Enabled lfd (starts on login)");
}
let status = Command::new("systemctl")
.args(["--user", "start", UNIT_ID])
.status()?;
if status.success() {
println!("lfd service started ({})", config.runtime_backend.as_str());
} else {
eprintln!("Warning: systemctl start failed (exit {})", status);
}
Ok(())
}
pub fn uninstall(_config: &LfdConfig) -> Result<()> {
let backend = installed_backend()?;
let unit_path = unit_path()?;
if !unit_path.exists() {
println!("Not installed");
return Ok(());
}
let _ = Command::new("systemctl")
.args(["--user", "disable", "--now", UNIT_ID])
.status();
std::fs::remove_file(&unit_path)?;
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
println!("Uninstalled lfd");
if let Some(RuntimeBackend::Compose) = backend {
let _ = compose::teardown();
}
Ok(())
}
pub fn start(config: &LfdConfig, force: bool) -> Result<()> {
let unit_path = unit_path()?;
if !unit_path.exists() {
return Err(anyhow::anyhow!(
"lfd is not installed — run `lfd install` first"
));
}
let installed_backend = installed_backend()?.unwrap_or(RuntimeBackend::Native);
if installed_backend != config.runtime_backend {
if !force {
bail!(
"lfd is installed with `{}` backend, but config mode expects `{}`. Run `lfd install --force`.",
installed_backend.as_str(),
config.runtime_backend.as_str()
);
}
return install(config, true);
}
if config.runtime_backend == RuntimeBackend::Compose {
compose::ensure_docker_available()?;
}
let status = Command::new("systemctl")
.args(["--user", "start", UNIT_ID])
.status()?;
if status.success() {
println!("Started lfd ({})", config.runtime_backend.as_str());
} else {
eprintln!("Failed to start lfd (exit {})", status);
}
Ok(())
}
pub fn stop(_config: &LfdConfig) -> Result<()> {
let unit_path = unit_path()?;
if !unit_path.exists() {
return Err(anyhow::anyhow!("lfd is not installed"));
}
let status = Command::new("systemctl")
.args(["--user", "stop", UNIT_ID])
.status()?;
if status.success() {
println!("Stopped lfd");
} else {
eprintln!("Failed to stop lfd (exit {})", status);
}
Ok(())
}
pub fn status(config: &LfdConfig) -> Result<()> {
let unit = unit_path()?;
if !unit.exists() {
println!("manager: systemd (missing)");
println!("backend: {}", config.runtime_backend.as_str());
println!("remediation: run `lfd install`");
return Ok(());
}
let backend = installed_backend()?.unwrap_or(RuntimeBackend::Native);
let output = Command::new("systemctl")
.args(["--user", "is-active", UNIT_ID])
.output()?;
let active = String::from_utf8_lossy(&output.stdout).trim().to_string();
let manager_state = if active.is_empty() {
"unknown"
} else {
active.as_str()
};
println!("manager: systemd ({manager_state})");
println!("backend: {}", backend.as_str());
if backend == RuntimeBackend::Compose {
compose::print_service_status();
}
Ok(())
}
fn render_unit(exec_start: &str, env_vars: &[(String, String)]) -> String {
let env_lines = env_vars
.iter()
.map(|(name, value)| format!("Environment=\"{}={}\"", name, systemd_escape(value)))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"[Unit]
Description=Loopflow Daemon
After=network.target
[Service]
Type=simple
ExecStart={exec_start}
Restart=on-failure
RestartSec=5
{env_lines}
[Install]
WantedBy=default.target
"#
)
}
fn systemd_escape(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn build_exec_start(backend: RuntimeBackend, lfd_path: &str) -> Result<String> {
match backend {
RuntimeBackend::Native => Ok(format!("{lfd_path} serve")),
RuntimeBackend::Compose => {
let mut args = vec!["/usr/bin/env".to_string(), "docker".to_string()];
args.extend(compose::compose_program_args()?);
Ok(args.join(" "))
}
}
}
fn installed_backend() -> Result<Option<RuntimeBackend>> {
let unit = unit_path()?;
if !unit.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(unit)?;
Ok(Some(detect_backend_from_unit(&content)))
}
fn detect_backend_from_unit(content: &str) -> RuntimeBackend {
if content.contains("docker compose") {
RuntimeBackend::Compose
} else {
RuntimeBackend::Native
}
}
fn teardown_backend(backend: RuntimeBackend) -> Result<()> {
if backend == RuntimeBackend::Compose {
compose::teardown()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{detect_backend_from_unit, render_unit, RuntimeBackend};
#[test]
fn detects_compose_backend_from_unit_content() {
let content = "ExecStart=/usr/bin/env docker compose -f /tmp/docker-compose.yml up";
assert_eq!(detect_backend_from_unit(content), RuntimeBackend::Compose);
}
#[test]
fn detects_native_backend_from_unit_content() {
let content = "ExecStart=/usr/local/bin/lfd serve";
assert_eq!(detect_backend_from_unit(content), RuntimeBackend::Native);
}
#[test]
fn render_unit_persists_remote_native_environment() {
let env_vars = vec![
("PATH".to_string(), "/usr/bin:/bin".to_string()),
("LFD_HTTP_ADDR".to_string(), "0.0.0.0:2486".to_string()),
("LFD_AUTH_TOKEN".to_string(), "token\"value".to_string()),
];
let content = render_unit("/usr/local/bin/lfd serve", &env_vars);
assert!(content.contains("Environment=\"LFD_HTTP_ADDR=0.0.0.0:2486\""));
assert!(content.contains("Environment=\"LFD_AUTH_TOKEN=token\\\"value\""));
}
}