dae 0.1.15

A Linux daemon management TUI and CLI with a subdued scrin interface.
mod app;
mod daemon;
mod ui;

use anyhow::{Context, Result, bail};
use app::{App, Cli, CliCommand};
use clap::Parser;

fn main() -> Result<()> {
    let cli = Cli::parse();
    if let Some(command) = cli.command.clone() {
        run_cli_command(&cli, command)
    } else {
        App::new(cli).run()
    }
}

fn run_cli_command(cli: &Cli, command: CliCommand) -> Result<()> {
    match command {
        CliCommand::List => print_daemon_list(cli),
        CliCommand::Status { unit } => print_status(cli, unit.as_deref()),
        CliCommand::Journal { unit } => print_journal(cli, &unit),
        CliCommand::Events { unit } => print_events(cli, &unit),
        CliCommand::Check { unit } => check_unit(&unit),
        CliCommand::Failed => print_failed_services(),
        CliCommand::Active { unit } => run_unit_query(&unit, "is-active"),
        CliCommand::Enabled { unit } => run_unit_query(&unit, "is-enabled"),
        CliCommand::Start { unit } => run_action(&unit, daemon::DaemonAction::Start),
        CliCommand::Stop { unit } => run_action(&unit, daemon::DaemonAction::Stop),
        CliCommand::Restart { unit } => run_action(&unit, daemon::DaemonAction::Restart),
        CliCommand::TryRestart { unit } => run_action(&unit, daemon::DaemonAction::TryRestart),
        CliCommand::Reload { unit } => run_action(&unit, daemon::DaemonAction::Reload),
        CliCommand::ReloadOrRestart { unit } => {
            run_action(&unit, daemon::DaemonAction::ReloadOrRestart)
        }
        CliCommand::Enable { unit } => run_action(&unit, daemon::DaemonAction::Enable),
        CliCommand::Disable { unit } => run_action(&unit, daemon::DaemonAction::Disable),
        CliCommand::Mask { unit } => run_action(&unit, daemon::DaemonAction::Mask),
        CliCommand::Unmask { unit } => run_action(&unit, daemon::DaemonAction::Unmask),
        CliCommand::DaemonReload => run_systemctl(&["daemon-reload"]),
        CliCommand::Term { unit } => run_action(&unit, daemon::DaemonAction::KillTerm),
        CliCommand::Kill { unit } => run_action(&unit, daemon::DaemonAction::KillKill),
    }
}

fn print_daemon_list(cli: &Cli) -> Result<()> {
    let mut app = App::new(cli.clone());
    app.daemons = daemon::load_daemons()?;
    let rows = app.visible_indices();

    println!(
        "{:<44} {:<18} {:>8} {:>10} SIGNAL",
        "UNIT", "STATE", "PID", "MEM"
    );
    for idx in rows {
        let daemon = &app.daemons[idx];
        println!(
            "{:<44} {:<18} {:>8} {:>10} {}",
            daemon.unit,
            daemon.state_label(),
            daemon
                .main_pid
                .map(|pid| pid.to_string())
                .unwrap_or_else(|| "-".to_string()),
            daemon::format_bytes(daemon.memory_bytes),
            daemon.anomaly_summary()
        );
    }
    Ok(())
}

fn print_failed_services() -> Result<()> {
    let daemons = daemon::load_daemons()?;
    let failed = daemons
        .iter()
        .filter(|daemon| daemon.active == "failed" || daemon.severity() > 0)
        .collect::<Vec<_>>();

    if failed.is_empty() {
        println!("no failed or anomalous services");
        return Ok(());
    }

    println!("{:<44} {:<18} {:>8} SIGNAL", "UNIT", "STATE", "SEVERITY");
    for daemon in failed {
        println!(
            "{:<44} {:<18} {:>8} {}",
            daemon.unit,
            daemon.state_label(),
            daemon.severity(),
            daemon.anomaly_summary()
        );
    }
    Ok(())
}

fn check_unit(unit: &str) -> Result<()> {
    let unit = daemon::service_unit(unit);
    let daemons = daemon::load_daemons()?;
    let daemon = daemons
        .iter()
        .find(|daemon| daemon.unit == unit)
        .with_context(|| format!("service {unit} was not found"))?;

    println!("{}", check_line(daemon));
    if daemon.active == "active" && daemon.severity() == 0 {
        Ok(())
    } else {
        bail!(
            "{} is {} with signal {}",
            daemon.unit,
            daemon.state_label(),
            daemon.anomaly_summary()
        )
    }
}

fn check_line(daemon: &daemon::Daemon) -> String {
    format!(
        "{} {} load={} enabled={} pid={} restarts={} memory={} signal={}",
        daemon.unit,
        daemon.state_label(),
        dash(&daemon.load),
        dash(&daemon.unit_file_state),
        daemon
            .main_pid
            .map(|pid| pid.to_string())
            .unwrap_or_else(|| "-".to_string()),
        daemon
            .restarts
            .map(|value| value.to_string())
            .unwrap_or_else(|| "-".to_string()),
        daemon::format_bytes(daemon.memory_bytes),
        daemon.anomaly_summary()
    )
}

fn dash(value: &str) -> &str {
    if value.is_empty() { "-" } else { value }
}

fn print_status(cli: &Cli, unit: Option<&str>) -> Result<()> {
    if let Some(unit) = unit {
        return print_unit_status(unit);
    }

    print_status_overview(cli)
}

fn print_unit_status(unit: &str) -> Result<()> {
    let unit = daemon::service_unit(unit);
    let report = daemon::status_report(&unit)?;
    print_report(&report)
}

fn print_status_overview(cli: &Cli) -> Result<()> {
    let mut app = App::new(cli.clone());
    app.daemons = daemon::load_daemons()?;
    let active = app
        .daemons
        .iter()
        .filter(|daemon| daemon.active == "active")
        .count();
    let running = app
        .daemons
        .iter()
        .filter(|daemon| daemon.active == "active" && daemon.sub == "running")
        .count();
    let failed = app
        .daemons
        .iter()
        .filter(|daemon| daemon.active == "failed")
        .count();
    let anomalous = app
        .daemons
        .iter()
        .filter(|daemon| daemon.severity() > 0)
        .count();

    println!(
        "services total={} active={} running={} failed={} anomalous={}",
        app.daemons.len(),
        active,
        running,
        failed,
        anomalous
    );

    let rows = app
        .visible_indices()
        .into_iter()
        .filter(|idx| {
            let daemon = &app.daemons[*idx];
            daemon.active == "active" && daemon.sub == "running"
        })
        .collect::<Vec<_>>();

    if rows.is_empty() {
        println!("no running services in the current view");
        return Ok(());
    }

    println!(
        "{:<44} {:<18} {:>8} {:>10} {:>8} SIGNAL",
        "UNIT", "STATE", "PID", "MEM", "RESTARTS"
    );
    for idx in rows {
        let daemon = &app.daemons[idx];
        println!("{}", status_overview_line(daemon));
    }

    Ok(())
}

fn status_overview_line(daemon: &daemon::Daemon) -> String {
    format!(
        "{:<44} {:<18} {:>8} {:>10} {:>8} {}",
        daemon.unit,
        daemon.state_label(),
        daemon
            .main_pid
            .map(|pid| pid.to_string())
            .unwrap_or_else(|| "-".to_string()),
        daemon::format_bytes(daemon.memory_bytes),
        daemon
            .restarts
            .map(|value| value.to_string())
            .unwrap_or_else(|| "-".to_string()),
        daemon.anomaly_summary()
    )
}

fn print_journal(cli: &Cli, unit: &str) -> Result<()> {
    let unit = daemon::service_unit(unit);
    for line in daemon::load_journal(&unit, cli.lookback_hours, cli.journal_lines)? {
        println!("{line}");
    }
    Ok(())
}

fn print_events(cli: &Cli, unit: &str) -> Result<()> {
    let unit = daemon::service_unit(unit);
    let journal = daemon::load_journal(&unit, cli.lookback_hours, cli.journal_lines)?;
    for line in daemon::notable_events(&journal) {
        println!("{line}");
    }
    Ok(())
}

fn run_unit_query(unit: &str, command: &str) -> Result<()> {
    let unit = daemon::service_unit(unit);
    run_systemctl(&[command, &unit])
}

fn run_action(unit: &str, action: daemon::DaemonAction) -> Result<()> {
    let unit = daemon::service_unit(unit);
    let report = daemon::apply_action(&unit, action)?;
    print_report(&report)
}

fn run_systemctl(args: &[&str]) -> Result<()> {
    let report = daemon::run_systemctl(args)?;
    print_report(&report)
}

fn print_report(report: &daemon::CommandReport) -> Result<()> {
    if !report.output.is_empty() {
        println!("{}", report.output);
    }
    if report.ok {
        Ok(())
    } else {
        anyhow::bail!("{} failed", report.command)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn check_line_is_script_friendly() {
        let daemon = daemon::Daemon {
            unit: "demo.service".to_string(),
            load: "loaded".to_string(),
            active: "active".to_string(),
            sub: "running".to_string(),
            main_pid: Some(42),
            restarts: Some(1),
            memory_bytes: Some(2048),
            unit_file_state: "enabled".to_string(),
            ..daemon::Daemon::default()
        };

        assert_eq!(
            check_line(&daemon),
            "demo.service active/running load=loaded enabled=enabled pid=42 restarts=1 memory=2.0 KiB signal=ok"
        );
    }

    #[test]
    fn status_overview_line_has_running_service_data() {
        let daemon = daemon::Daemon {
            unit: "demo.service".to_string(),
            active: "active".to_string(),
            sub: "running".to_string(),
            main_pid: Some(42),
            restarts: Some(2),
            memory_bytes: Some(4096),
            ..daemon::Daemon::default()
        };

        let line = status_overview_line(&daemon);
        assert!(line.contains("demo.service"));
        assert!(line.contains("active/running"));
        assert!(line.contains("42"));
        assert!(line.contains("4.0 KiB"));
        assert!(line.ends_with("ok"));
    }
}