#[cfg(feature = "systemd")]
use colored::Colorize;
#[cfg(feature = "systemd")]
use tokio::process::Command;
#[cfg(feature = "systemd")]
use crate::cli::ui;
#[cfg(feature = "systemd")]
use crate::commands::service::load_xbp_config;
#[cfg(feature = "systemd")]
use crate::strategies::{get_all_services, SystemdConfig, XbpConfig};
#[cfg(feature = "systemd")]
use crate::utils::command_exists;
#[cfg(feature = "systemd")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemdServiceState {
Running,
Stopped,
Failed,
Starting,
Stopping,
Unknown,
NotFound,
PermissionDenied,
}
#[cfg(feature = "systemd")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemdServiceStatus {
pub name: String,
pub state: SystemdServiceState,
pub summary: String,
pub detail: Option<String>,
}
#[cfg(feature = "systemd")]
pub struct SystemdRuntime {
available: bool,
}
#[cfg(feature = "systemd")]
impl SystemdRuntime {
pub fn detect() -> Self {
Self {
available: cfg!(target_os = "linux") && command_exists("systemctl"),
}
}
pub fn unavailable() -> Self {
Self { available: false }
}
pub async fn show_status(&self, debug: bool) -> Result<(), String> {
let config = match load_xbp_config().await {
Ok(cfg) => cfg,
Err(_) => return Ok(()),
};
self.show_status_for_config(&config, debug).await
}
pub async fn show_status_for_config(
&self,
config: &XbpConfig,
debug: bool,
) -> Result<(), String> {
if !self.available {
return Ok(());
}
let names = collect_configured_systemd_service_names(config);
if names.is_empty() {
return Ok(());
}
ui::section("Systemd Services");
ui::divider(60);
for name in names {
let status = self.check_systemd_service_status(&name).await?;
render_systemd_status(&status, debug);
}
Ok(())
}
async fn check_systemd_service_status(
&self,
service_name: &str,
) -> Result<SystemdServiceStatus, String> {
let output = Command::new("systemctl")
.arg("status")
.arg(service_name)
.arg("--no-pager")
.output()
.await
.map_err(|e| format!("Failed to run systemctl: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let (state, summary, detail) = classify_systemd_status(&stdout, &stderr);
Ok(SystemdServiceStatus {
name: service_name.to_string(),
state,
summary,
detail,
})
}
}
#[cfg(feature = "systemd")]
pub async fn show_systemd_status(debug: bool) -> Result<(), String> {
SystemdRuntime::detect().show_status(debug).await
}
#[cfg(feature = "systemd")]
pub fn collect_configured_systemd_service_names(config: &XbpConfig) -> Vec<String> {
let mut services = Vec::new();
if let Some(ref name) = config.systemd_service_name {
push_deduped(&mut services, name.clone());
}
for service in get_all_services(config) {
if let Some(ref name) = service.systemd_service_name {
push_deduped(&mut services, name.clone());
}
}
services
}
#[cfg(feature = "systemd")]
pub fn collect_systemd_runtime_settings(config: &XbpConfig) -> Option<SystemdConfig> {
let project = config.systemd.clone();
let service_configs = get_all_services(config);
let mut combined = SystemdConfig::default();
let mut any = false;
if let Some(systemd) = project {
append_runtime_settings(&mut combined, &systemd);
any = true;
}
for service in service_configs {
if let Some(systemd) = service.systemd {
append_runtime_settings(&mut combined, &systemd);
any = true;
}
}
if any {
Some(combined)
} else {
None
}
}
#[cfg(feature = "systemd")]
fn append_runtime_settings(target: &mut SystemdConfig, source: &SystemdConfig) {
merge_unique(&mut target.environment_files, &source.environment_files);
merge_unique(&mut target.config_paths, &source.config_paths);
merge_unique(&mut target.read_write_paths, &source.read_write_paths);
merge_unique(&mut target.runtime_directories, &source.runtime_directories);
merge_unique(&mut target.state_directories, &source.state_directories);
}
#[cfg(feature = "systemd")]
fn merge_unique(target: &mut Vec<String>, source: &[String]) {
for value in source {
push_deduped(target, value.clone());
}
}
#[cfg(feature = "systemd")]
fn push_deduped(target: &mut Vec<String>, value: String) {
if !target.iter().any(|existing| existing == &value) {
target.push(value);
}
}
#[cfg(feature = "systemd")]
fn classify_systemd_status(
stdout: &str,
stderr: &str,
) -> (SystemdServiceState, String, Option<String>) {
let lower_stderr = stderr.to_lowercase();
if lower_stderr.contains("could not be found") || lower_stderr.contains("not loaded") {
return (
SystemdServiceState::NotFound,
"Not Found".to_string(),
Some(stderr.trim().to_string()).filter(|s| !s.is_empty()),
);
}
if lower_stderr.contains("permission denied")
|| lower_stderr.contains("failed to get properties")
{
return (
SystemdServiceState::PermissionDenied,
"Permission Denied".to_string(),
Some(stderr.trim().to_string()).filter(|s| !s.is_empty()),
);
}
let lower_stdout = stdout.to_lowercase();
if lower_stdout.contains("active: active (running)") {
(SystemdServiceState::Running, "Running".to_string(), None)
} else if lower_stdout.contains("active: inactive (dead)") {
(SystemdServiceState::Stopped, "Stopped".to_string(), None)
} else if lower_stdout.contains("active: failed") {
(
SystemdServiceState::Failed,
"Failed".to_string(),
Some(stdout.lines().take(3).collect::<Vec<_>>().join("\n")).filter(|s| !s.is_empty()),
)
} else if lower_stdout.contains("active: activating") {
(SystemdServiceState::Starting, "Starting".to_string(), None)
} else if lower_stdout.contains("active: deactivating") {
(SystemdServiceState::Stopping, "Stopping".to_string(), None)
} else {
(
SystemdServiceState::Unknown,
"Unknown".to_string(),
Some(stdout.lines().take(3).collect::<Vec<_>>().join("\n")).filter(|s| !s.is_empty()),
)
}
}
#[cfg(feature = "systemd")]
fn render_systemd_status(status: &SystemdServiceStatus, debug: bool) {
match status.state {
SystemdServiceState::NotFound => {
ui::status_line(&status.name, &status.summary, false);
if debug {
println!(" Service unit not found in systemd");
}
return;
}
SystemdServiceState::PermissionDenied => {
ui::status_line(&status.name, &status.summary, false);
if debug {
println!(" Run with sudo to see full status");
}
return;
}
_ => {}
}
let ok = matches!(status.state, SystemdServiceState::Running);
let state_label = match status.state {
SystemdServiceState::Running => "Running".green(),
SystemdServiceState::Stopped => "Stopped".dimmed(),
SystemdServiceState::Failed => "Failed".red(),
SystemdServiceState::Starting => "Starting".yellow(),
SystemdServiceState::Stopping => "Stopping".yellow(),
SystemdServiceState::Unknown => "Unknown".yellow(),
SystemdServiceState::NotFound | SystemdServiceState::PermissionDenied => unreachable!(),
};
println!(
" {} {} {}",
if ok {
"✓".bright_green().bold()
} else {
"•".bright_yellow().bold()
},
status.name.bright_white(),
state_label
);
if debug {
if let Some(detail) = &status.detail {
for line in detail.lines().take(3) {
println!(" {}", line.trim());
}
}
}
}
#[cfg(feature = "systemd")]
#[cfg(test)]
mod tests {
use super::*;
use crate::strategies::{ServiceConfig, XbpConfig};
fn base_config() -> XbpConfig {
XbpConfig {
project_name: "demo".to_string(),
version: "0.1.0".to_string(),
port: 3000,
build_dir: "/tmp/demo".to_string(),
app_type: None,
build_command: None,
start_command: None,
install_command: None,
environment: None,
services: Some(vec![
ServiceConfig {
name: "api".to_string(),
target: "rust".to_string(),
branch: "main".to_string(),
port: 8080,
root_directory: None,
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
systemd_service_name: Some("demo-api".to_string()),
systemd: None,
},
ServiceConfig {
name: "worker".to_string(),
target: "rust".to_string(),
branch: "main".to_string(),
port: 8081,
root_directory: None,
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
systemd_service_name: Some("demo-api".to_string()),
systemd: None,
},
]),
systemd_service_name: Some("demo".to_string()),
systemd: Some(SystemdConfig {
environment_files: vec!["/etc/default/demo".to_string()],
config_paths: vec![],
read_write_paths: vec![],
runtime_directories: vec![],
state_directories: vec![],
}),
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
}
}
#[test]
fn collects_service_names_in_config_order_without_duplicates() {
let config = base_config();
let names = collect_configured_systemd_service_names(&config);
assert_eq!(names, vec!["demo".to_string(), "demo-api".to_string()]);
}
#[test]
fn classify_not_found_status_from_stderr() {
let (state, summary, _) =
classify_systemd_status("", "Unit demo.service could not be found.");
assert_eq!(state, SystemdServiceState::NotFound);
assert_eq!(summary, "Not Found");
}
#[tokio::test]
async fn no_configured_services_returns_ok() {
let runtime = SystemdRuntime::detect();
let mut config = base_config();
config.systemd_service_name = None;
config.services = Some(vec![]);
runtime
.show_status_for_config(&config, false)
.await
.expect("no-op");
}
#[tokio::test]
async fn unavailable_runtime_short_circuits_without_error() {
let runtime = SystemdRuntime::unavailable();
let config = base_config();
runtime
.show_status_for_config(&config, false)
.await
.expect("short-circuit");
}
}