clipmem 0.6.0

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

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

use crate::db::Database;

use crate::cli::commands::notify::notify_app_refresh;

use super::context::{build_context, conflict_message, ensure_supported, select_provider};
use super::launchctl::{
    ensure_parent_dir, launchctl_bootout, launchctl_bootstrap, launchctl_disable, launchctl_enable,
    launchctl_kickstart, run_command_checked, touch_log_file, write_direct_plist,
};
use super::model::*;
use super::status::status_report;

pub(crate) fn setup(db_path: &Path) -> Result<SetupReport> {
    ensure_supported()?;
    let context = build_context(db_path)?;
    let status = status_report(db_path)?;
    let selection = select_provider(&context);
    ensure_no_conflict(&status)?;
    // Setup owns archive creation/migration synchronously so reads issued
    // immediately after a successful setup find a current-schema database
    // even before the watcher process has started.
    drop(Database::open_or_init_and_migrate(db_path).context("initialize archive during setup")?);
    // Setup is intentionally non-mutating for clipboard content: the watcher
    // captures only the next clipboard generation after it starts.
    let seed_capture = SeedCaptureOutcome::NotAttempted;
    let action = start_with_provider(&context, &selection)?;
    bump_service_revision(db_path);
    notify_app_refresh();
    Ok(SetupReport {
        seed_capture,
        action,
    })
}

pub(crate) fn start(db_path: &Path) -> Result<ServiceActionReport> {
    ensure_supported()?;
    let context = build_context(db_path)?;
    let status = status_report(db_path)?;
    let selection = select_provider(&context);
    ensure_no_conflict(&status)?;
    let report = start_with_provider(&context, &selection)?;
    bump_service_revision(db_path);
    notify_app_refresh();
    Ok(report)
}

pub(crate) fn stop(db_path: &Path) -> Result<ServiceActionReport> {
    ensure_supported()?;
    let context = build_context(db_path)?;
    let status = status_report(db_path)?;
    if status.conflict {
        bail!(conflict_message());
    }

    let report = if status.homebrew.installed || status.homebrew.loaded || status.homebrew.running {
        stop_homebrew_provider(&context)?
    } else {
        stop_direct_provider(&context)?
    };
    bump_service_revision(db_path);
    notify_app_refresh();
    Ok(report)
}

pub(crate) fn uninstall(db_path: &Path) -> Result<ServiceActionReport> {
    ensure_supported()?;
    let context = build_context(db_path)?;
    let status = status_report(db_path)?;
    if status.conflict {
        bail!(conflict_message());
    }

    let report = if status.homebrew.installed || status.homebrew.loaded || status.homebrew.running {
        uninstall_homebrew_provider(&context)?
    } else {
        uninstall_direct_provider(&context)?
    };
    bump_service_revision(db_path);
    notify_app_refresh();
    Ok(report)
}

fn ensure_no_conflict(status: &ServiceStatusReport) -> Result<()> {
    if status.conflict {
        bail!(conflict_message());
    }
    Ok(())
}

fn start_with_provider(
    context: &ServiceContext,
    selection: &ProviderSelection,
) -> Result<ServiceActionReport> {
    match selection.provider {
        ServiceProvider::Homebrew => start_homebrew_provider(context, &selection.notes),
        ServiceProvider::Launchagent => start_direct_provider(context, &selection.notes),
    }
}

fn start_homebrew_provider(
    context: &ServiceContext,
    notes: &[String],
) -> Result<ServiceActionReport> {
    let brew = context
        .brew_path
        .as_ref()
        .ok_or_else(|| anyhow!("`brew` is not available on PATH"))?;
    let output = ProcessCommand::new(brew)
        .args(["services", "start", "clipmem"])
        .output()
        .context("run `brew services start clipmem`")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        if brew_services_formula_unavailable(&stderr) {
            let mut fallback_notes = notes.to_vec();
            fallback_notes.push(
                "`brew services` is installed, but the clipmem formula does not expose a service file; using a direct LaunchAgent with the Homebrew binary.".to_string(),
            );
            return start_direct_provider(context, &fallback_notes);
        }

        run_command_checked(Ok(output), "brew services start clipmem")?;
    }
    Ok(ServiceActionReport {
        action: "start",
        provider: ServiceProvider::Homebrew,
        binary_path: context.binary_path.clone(),
        db_path: context.default_db_path.clone(),
        label: HOMEBREW_LABEL,
        notes: notes.to_vec(),
    })
}

fn brew_services_formula_unavailable(stderr: &str) -> bool {
    stderr.contains("has not implemented #plist")
        || stderr.contains("has not implemented #service")
        || stderr.contains("provided a locatable service file")
}

fn stop_homebrew_provider(context: &ServiceContext) -> Result<ServiceActionReport> {
    let brew = context
        .brew_path
        .as_ref()
        .ok_or_else(|| anyhow!("`brew` is not available on PATH"))?;
    run_command_checked(
        ProcessCommand::new(brew)
            .args(["services", "stop", "clipmem"])
            .output(),
        "brew services stop clipmem",
    )?;
    Ok(ServiceActionReport {
        action: "stop",
        provider: ServiceProvider::Homebrew,
        binary_path: context.binary_path.clone(),
        db_path: context.default_db_path.clone(),
        label: HOMEBREW_LABEL,
        notes: vec!["The Homebrew formula remains installed.".to_string()],
    })
}

fn uninstall_homebrew_provider(context: &ServiceContext) -> Result<ServiceActionReport> {
    let mut report = stop_homebrew_provider(context)?;
    report.action = "uninstall";
    report.notes.push(
        "Only the Homebrew-managed service was removed; the formula stays installed.".to_string(),
    );
    Ok(report)
}

fn start_direct_provider(
    context: &ServiceContext,
    notes: &[String],
) -> Result<ServiceActionReport> {
    ensure_parent_dir(&context.direct_plist_path)?;
    ensure_parent_dir(&context.direct_stdout_path)?;
    touch_log_file(&context.direct_stdout_path)?;
    touch_log_file(&context.direct_stderr_path)?;
    write_direct_plist(context)?;
    launchctl_bootout(DIRECT_LABEL)?;
    launchctl_enable(DIRECT_LABEL)?;
    launchctl_bootstrap(&context.direct_plist_path)?;
    launchctl_kickstart(DIRECT_LABEL)?;
    Ok(ServiceActionReport {
        action: "start",
        provider: ServiceProvider::Launchagent,
        binary_path: context.binary_path.clone(),
        db_path: context.db_path.clone(),
        label: DIRECT_LABEL,
        notes: notes.to_vec(),
    })
}

fn stop_direct_provider(context: &ServiceContext) -> Result<ServiceActionReport> {
    launchctl_bootout(DIRECT_LABEL)?;
    launchctl_disable(DIRECT_LABEL)?;
    Ok(ServiceActionReport {
        action: "stop",
        provider: ServiceProvider::Launchagent,
        binary_path: context.binary_path.clone(),
        db_path: context.db_path.clone(),
        label: DIRECT_LABEL,
        notes: vec!["The LaunchAgent plist remains installed.".to_string()],
    })
}

fn uninstall_direct_provider(context: &ServiceContext) -> Result<ServiceActionReport> {
    launchctl_bootout(DIRECT_LABEL)?;
    launchctl_disable(DIRECT_LABEL)?;
    let mut notes = Vec::new();
    match fs::remove_file(&context.direct_plist_path) {
        Ok(()) => notes.push("Removed the direct LaunchAgent plist.".to_string()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            notes.push("The direct LaunchAgent plist was already absent.".to_string());
        }
        Err(error) => {
            return Err(error).with_context(|| {
                format!(
                    "remove direct LaunchAgent plist {}",
                    context.direct_plist_path.display()
                )
            });
        }
    }
    Ok(ServiceActionReport {
        action: "uninstall",
        provider: ServiceProvider::Launchagent,
        binary_path: context.binary_path.clone(),
        db_path: context.db_path.clone(),
        label: DIRECT_LABEL,
        notes,
    })
}

pub(super) fn bump_service_revision(db_path: &Path) {
    if let Ok(db) = Database::open_read_write_current(db_path) {
        let _ = db.bump_service_revision();
    }
}