kbolt-core 0.1.2

Core engine for kbolt local-first retrieval
Documentation
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(not(test))]
use std::process::Command;

use kbolt_types::{KboltError, ScheduleBackend, ScheduleDefinition};

use crate::Result;

#[cfg(any(target_os = "macos", test))]
pub(crate) mod launchd;
#[cfg(any(target_os = "linux", test))]
pub(crate) mod systemd;

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct BackendInspection {
    pub drifted_ids: HashSet<String>,
    pub orphan_ids: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CommandOutput {
    pub success: bool,
    pub stdout: String,
    pub stderr: String,
}

pub(crate) trait CommandRunner {
    fn run(&self, program: &str, args: &[&str]) -> Result<CommandOutput>;
}

#[cfg(not(test))]
struct ProcessCommandRunner;

#[cfg(not(test))]
impl CommandRunner for ProcessCommandRunner {
    fn run(&self, program: &str, args: &[&str]) -> Result<CommandOutput> {
        let output = Command::new(program).args(args).output()?;
        Ok(CommandOutput {
            success: output.status.success(),
            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        })
    }
}

#[cfg(test)]
struct NoopCommandRunner;

#[cfg(test)]
impl CommandRunner for NoopCommandRunner {
    fn run(&self, _program: &str, _args: &[&str]) -> Result<CommandOutput> {
        Ok(CommandOutput {
            success: true,
            stdout: String::new(),
            stderr: String::new(),
        })
    }
}

pub(crate) fn current_schedule_backend() -> Result<ScheduleBackend> {
    #[cfg(target_os = "macos")]
    {
        Ok(ScheduleBackend::Launchd)
    }

    #[cfg(target_os = "linux")]
    {
        Ok(ScheduleBackend::SystemdUser)
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    {
        Err(
            KboltError::InvalidInput("schedule is not supported on this platform".to_string())
                .into(),
        )
    }
}

pub(crate) fn inspect_schedule_backend(
    config_dir: &Path,
    cache_dir: &Path,
    schedules: &[ScheduleDefinition],
) -> Result<BackendInspection> {
    let executable = current_executable_path()?;
    let runner = default_command_runner();
    #[cfg(target_os = "macos")]
    {
        let paths = launchd_paths(config_dir, cache_dir)?;
        return launchd::inspect_launchd(&paths, schedules, &executable, runner);
    }

    #[cfg(target_os = "linux")]
    {
        let paths = systemd_user_paths(config_dir)?;
        return systemd::inspect_systemd_user(&paths, schedules, &executable, runner);
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    {
        let _ = (config_dir, cache_dir, schedules, executable, runner);
        Err(
            KboltError::InvalidInput("schedule is not supported on this platform".to_string())
                .into(),
        )
    }
}

pub(crate) fn reconcile_schedule_backend(
    config_dir: &Path,
    cache_dir: &Path,
    schedules: &[ScheduleDefinition],
) -> Result<ScheduleBackend> {
    let executable = current_executable_path()?;
    let runner = default_command_runner();
    #[cfg(target_os = "macos")]
    {
        let paths = launchd_paths(config_dir, cache_dir)?;
        launchd::reconcile_launchd(&paths, schedules, &executable, runner)?;
        return Ok(ScheduleBackend::Launchd);
    }

    #[cfg(target_os = "linux")]
    {
        let paths = systemd_user_paths(config_dir)?;
        systemd::reconcile_systemd_user(&paths, schedules, &executable, runner)?;
        return Ok(ScheduleBackend::SystemdUser);
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    {
        let _ = (config_dir, cache_dir, schedules, executable, runner);
        Err(
            KboltError::InvalidInput("schedule is not supported on this platform".to_string())
                .into(),
        )
    }
}

pub(crate) fn write_if_changed(path: &Path, content: &str) -> Result<bool> {
    let needs_write = match fs::read_to_string(path) {
        Ok(existing) => existing != content,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
        Err(err) => return Err(err.into()),
    };

    if !needs_write {
        return Ok(false);
    }

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }

    let tmp_path = path.with_extension("tmp");
    fs::write(&tmp_path, content)?;
    fs::rename(tmp_path, path)?;
    Ok(true)
}

pub(crate) fn current_executable_path() -> Result<PathBuf> {
    std::env::current_exe().map_err(Into::into)
}

pub(crate) fn command_failure(program: &str, args: &[&str], output: &CommandOutput) -> KboltError {
    let joined_args = args.join(" ");
    let stderr = output.stderr.trim();
    let stdout = output.stdout.trim();

    let detail = if !stderr.is_empty() {
        stderr.to_string()
    } else if !stdout.is_empty() {
        stdout.to_string()
    } else {
        "command returned a non-zero exit status".to_string()
    };

    KboltError::Internal(format!("`{program} {joined_args}` failed: {detail}"))
}

#[cfg(target_os = "macos")]
fn launchd_paths(_config_dir: &Path, cache_dir: &Path) -> Result<launchd::LaunchdPaths> {
    #[cfg(test)]
    {
        Ok(launchd::LaunchdPaths {
            agents_dir: _config_dir.join("launchd/LaunchAgents"),
            log_dir: cache_dir.join("schedules/logs"),
            domain: "gui/test".to_string(),
        })
    }

    #[cfg(not(test))]
    {
        let home_dir = dirs::home_dir()
            .ok_or_else(|| KboltError::Config("unable to determine home directory".to_string()))?;
        Ok(launchd::LaunchdPaths {
            agents_dir: home_dir.join("Library/LaunchAgents"),
            log_dir: cache_dir.join("schedules/logs"),
            domain: format!("gui/{}", current_uid()?),
        })
    }
}

#[cfg(target_os = "linux")]
fn systemd_user_paths(config_dir: &Path) -> Result<systemd::SystemdUserPaths> {
    #[cfg(test)]
    {
        Ok(systemd::SystemdUserPaths {
            unit_dir: config_dir.join("systemd/user"),
        })
    }

    #[cfg(not(test))]
    {
        let base = dirs::config_dir().ok_or_else(|| {
            KboltError::Config("unable to determine user config directory".to_string())
        })?;
        Ok(systemd::SystemdUserPaths {
            unit_dir: base.join("systemd/user"),
        })
    }
}

#[cfg(not(test))]
fn default_command_runner() -> &'static dyn CommandRunner {
    static RUNNER: ProcessCommandRunner = ProcessCommandRunner;
    &RUNNER
}

#[cfg(test)]
fn default_command_runner() -> &'static dyn CommandRunner {
    static RUNNER: NoopCommandRunner = NoopCommandRunner;
    &RUNNER
}

#[cfg(target_os = "macos")]
#[cfg(not(test))]
fn current_uid() -> Result<String> {
    let output = Command::new("id").arg("-u").output()?;
    if !output.status.success() {
        return Err(KboltError::Internal("failed to determine current uid".to_string()).into());
    }

    let uid = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if uid.is_empty() {
        return Err(KboltError::Internal("failed to determine current uid".to_string()).into());
    }

    Ok(uid)
}