clipmem 0.5.5

macOS clipboard memory backed by SQLite and searchable from agent runtimes
Documentation
use std::path::Path;
use std::process::Command as ProcessCommand;

use anyhow::{anyhow, bail, Result};

use crate::cli::db_path::default_db_path;

use super::launchctl::{
    brew_services_available, find_executable, home_dir, homebrew_prefix_for_binary,
    trusted_binary_path,
};
use super::model::{
    ProviderSelection, ServiceContext, ServiceProvider, DIRECT_LABEL, HOMEBREW_LABEL,
};

pub(super) fn ensure_supported() -> Result<()> {
    if cfg!(target_os = "macos") {
        Ok(())
    } else {
        bail!("clipmem setup and service commands are only supported on macOS");
    }
}

pub(super) fn build_context(db_path: &Path) -> Result<ServiceContext> {
    let home = home_dir()?;
    let binary_path = trusted_binary_path()?;
    let direct_plist_path = home
        .join("Library/LaunchAgents")
        .join(format!("{DIRECT_LABEL}.plist"));
    let homebrew_plist_path = home
        .join("Library/LaunchAgents")
        .join(format!("{HOMEBREW_LABEL}.plist"));
    let app_support_dir = db_path.parent().ok_or_else(|| {
        anyhow!(
            "database path {} has no parent directory",
            db_path.display()
        )
    })?;
    let log_dir = app_support_dir.join("logs");
    let brew_path = find_executable("brew");
    let homebrew_prefix = homebrew_prefix_for_binary(&binary_path);

    Ok(ServiceContext {
        binary_path,
        db_path: db_path.to_path_buf(),
        default_db_path: default_db_path(),
        direct_plist_path,
        homebrew_plist_path,
        direct_stdout_path: log_dir.join("clipmem.stdout.log"),
        direct_stderr_path: log_dir.join("clipmem.stderr.log"),
        brew_path,
        homebrew_prefix,
    })
}

pub(super) fn select_provider(context: &ServiceContext) -> ProviderSelection {
    let mut notes = Vec::new();
    if context.db_path != context.default_db_path {
        notes.push(format!(
            "Direct LaunchAgent provider will use the explicit database path {}.",
            context.db_path.display()
        ));
        return ProviderSelection {
            provider: ServiceProvider::Launchagent,
            reason: "explicit database paths require the direct per-user LaunchAgent".to_string(),
            notes,
        };
    }

    if let Some(brew_path) = &context.brew_path {
        if context.homebrew_prefix.is_some() {
            if brew_services_available(brew_path) && brew_services_can_manage_clipmem(brew_path) {
                return ProviderSelection {
                    provider: ServiceProvider::Homebrew,
                    reason:
                        "current binary is the Homebrew formula and `brew services` is available"
                            .to_string(),
                    notes,
                };
            }
            notes.push(
                "Homebrew install detected, but `brew services` cannot manage the clipmem formula; falling back to a direct LaunchAgent.".to_string(),
            );
        }
    }

    ProviderSelection {
        provider: ServiceProvider::Launchagent,
        reason: "using the direct per-user LaunchAgent managed by clipmem".to_string(),
        notes,
    }
}

fn brew_services_can_manage_clipmem(brew: &Path) -> bool {
    let Ok(output) = ProcessCommand::new(brew)
        .args(["services", "info", "clipmem", "--json"])
        .output()
    else {
        return false;
    };
    if !output.status.success() {
        return false;
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    stdout.contains("\"schedulable\":true") || stdout.contains("\"schedulable\": true")
}

pub(super) fn conflict_message() -> String {
    "Both the Homebrew service and the direct LaunchAgent are installed. Remove one first with `brew services stop clipmem` or `clipmem service uninstall`.".to_string()
}