clipmem 0.5.3

macOS clipboard memory backed by SQLite and searchable from agent runtimes
Documentation
use clap::error::ErrorKind;

use super::exit::{CliError, CliExitCode};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CliValueError {
    kind: ErrorKind,
    message: String,
}

impl CliValueError {
    pub(super) fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
        Self {
            kind,
            message: message.into(),
        }
    }
}

impl std::fmt::Display for CliValueError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for CliValueError {}

#[derive(Debug)]
pub(in crate::cli) struct UnsupportedFormatError {
    message: String,
}

impl UnsupportedFormatError {
    pub(in crate::cli) fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
        }
    }
}

impl std::fmt::Display for UnsupportedFormatError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for UnsupportedFormatError {}

#[derive(Debug)]
pub(in crate::cli) struct CliCommandError {
    exit_code: CliExitCode,
    message: String,
}

impl CliCommandError {
    fn new(exit_code: CliExitCode, message: impl Into<String>) -> Self {
        Self {
            exit_code,
            message: message.into(),
        }
    }

    const fn exit_code(&self) -> CliExitCode {
        self.exit_code
    }

    fn message(&self) -> &str {
        &self.message
    }
}

impl std::fmt::Display for CliCommandError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for CliCommandError {}

pub(in crate::cli) fn invalid_args_error(message: impl Into<String>) -> anyhow::Error {
    CliCommandError::new(CliExitCode::InvalidArgs, message).into()
}

pub(in crate::cli) fn not_found_error(message: impl Into<String>) -> anyhow::Error {
    CliCommandError::new(CliExitCode::NotFound, message).into()
}

pub(in crate::cli) fn db_error(message: impl Into<String>) -> anyhow::Error {
    CliCommandError::new(CliExitCode::DbError, message).into()
}

pub(in crate::cli) fn platform_error(message: impl Into<String>) -> anyhow::Error {
    CliCommandError::new(CliExitCode::PlatformError, message).into()
}

pub(super) fn value_error(kind: ErrorKind, message: impl Into<String>) -> CliValueError {
    CliValueError::new(kind, message)
}

pub(super) fn clap_error_from_value(error: CliValueError) -> clap::Error {
    clap_error(error.kind, error.message)
}

pub(super) fn clap_error(kind: ErrorKind, message: impl Into<String>) -> clap::Error {
    clap::Command::new("clipmem").error(kind, message.into())
}

pub(super) fn classify_clap_error(error: clap::Error) -> CliError {
    CliError::new(classify_clap_exit_code(error.kind()), error.to_string())
}

pub(super) fn classify_clap_exit_code(kind: ErrorKind) -> CliExitCode {
    match kind {
        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => CliExitCode::Ok,
        _ => CliExitCode::InvalidArgs,
    }
}

pub(super) fn classify_command_error(error: anyhow::Error) -> CliError {
    if let Some(error) = error.downcast_ref::<CliCommandError>() {
        return CliError::new(error.exit_code(), error.message().to_string());
    }

    let message = sanitize_error_message(&error);
    let exit_code = if is_unsupported_format_error(&error, &message) {
        CliExitCode::UnsupportedFormat
    } else if is_not_found_error(&error, &message) {
        CliExitCode::NotFound
    } else if is_platform_error(&error, &message) {
        CliExitCode::PlatformError
    } else if is_invalid_argument_error(&error, &message) {
        CliExitCode::InvalidArgs
    } else if is_db_error(&error, &message) {
        CliExitCode::DbError
    } else {
        CliExitCode::Internal
    };

    CliError::new(exit_code, message)
}

pub(super) fn sanitize_error_message(error: &anyhow::Error) -> String {
    let chain_messages = error
        .chain()
        .map(std::string::ToString::to_string)
        .collect::<Vec<_>>();
    let message = chain_messages
        .first()
        .cloned()
        .unwrap_or_else(|| "command failed".to_string());
    let lower_chain = chain_messages.join(" | ").to_ascii_lowercase();

    if lower_chain.contains("prerelease schema")
        || lower_chain.contains("no such column:")
        || lower_chain.contains("sql logic error")
    {
        "database operation failed; this may be an incompatible prerelease schema. Move the database aside and run `clipmem setup`.".to_string()
    } else if lower_chain.contains("sqlite") {
        "database operation failed; run `clipmem doctor`, and if this is an older prerelease archive, move it aside and run `clipmem setup`.".to_string()
    } else {
        message
    }
}

pub(super) fn is_invalid_argument_error(error: &anyhow::Error, message: &str) -> bool {
    error.downcast_ref::<clap::Error>().is_some()
        || error.downcast_ref::<CliValueError>().is_some()
        || message.contains("cursor does not match")
        || message.contains("cursor is for command")
        || message.contains("cursor mode")
        || message.contains("invalid cursor")
        || message.contains("already exists (pass --force to replace it)")
        || message.contains("symbolic link")
        || message.contains("not a regular file")
}

pub(super) fn is_not_found_error(error: &anyhow::Error, message: &str) -> bool {
    if error
        .chain()
        .any(|cause| cause.downcast_ref::<rusqlite::Error>().is_some())
    {
        return false;
    }

    message.contains(" was not found")
        || message.contains("representation not found")
        || message.starts_with("get failed for snapshot ")
        || message.contains("Missing /")
        || message.starts_with("Missing ")
}

pub(super) fn is_unsupported_format_error(_error: &anyhow::Error, message: &str) -> bool {
    _error.downcast_ref::<UnsupportedFormatError>().is_some()
        || message.contains("unsupported format")
}

pub(super) fn is_platform_error(error: &anyhow::Error, message: &str) -> bool {
    message.contains("clipboard capture is only supported on macOS")
        || message.contains("clipboard restore is only supported on macOS")
        || message.contains("setup and service commands are only supported on macOS")
        || message.contains("capture failed")
        || message.contains("capture-once clipboard read failed")
        || message.contains("restore failed")
        || message.contains("read clipboard change count failed")
        || error.chain().any(|cause| {
            let cause = cause.to_string();
            cause.contains("clipboard capture is only supported on macOS")
                || cause.contains("clipboard restore is only supported on macOS")
        })
}

pub(super) fn is_db_error(error: &anyhow::Error, message: &str) -> bool {
    error.downcast_ref::<rusqlite::Error>().is_some()
        || error
            .chain()
            .any(|cause| cause.downcast_ref::<rusqlite::Error>().is_some())
        || message.contains("database")
        || message.contains("SQLite")
        || message.contains("failed to open database")
        || message.contains("failed to write")
        || message.contains("query failed")
}

#[cfg(test)]
mod tests {
    use anyhow::anyhow;
    use clap::error::ErrorKind;

    use super::{
        classify_clap_exit_code, classify_command_error, db_error, invalid_args_error, is_db_error,
        is_not_found_error, is_platform_error, sanitize_error_message, CliCommandError,
        CliExitCode, UnsupportedFormatError,
    };

    #[test]
    fn classify_command_error_preserves_explicit_cli_command_error_codes() {
        let error = CliCommandError::new(CliExitCode::NotFound, "snapshot 42 was not found");

        let classified = classify_command_error(error.into());

        assert_eq!(classified.exit_code(), CliExitCode::NotFound);
        assert_eq!(classified.message(), "snapshot 42 was not found");
    }

    #[test]
    fn classify_clap_exit_code_treats_help_and_version_as_ok() {
        assert_eq!(
            classify_clap_exit_code(ErrorKind::DisplayHelp),
            CliExitCode::Ok
        );
        assert_eq!(
            classify_clap_exit_code(ErrorKind::DisplayVersion),
            CliExitCode::Ok
        );
        assert_eq!(
            classify_clap_exit_code(ErrorKind::UnknownArgument),
            CliExitCode::InvalidArgs
        );
    }

    #[test]
    fn sanitize_error_message_rewrites_sqlite_schema_failures() {
        let error = anyhow!("no such column: capture_events.legacy_column")
            .context("query failed while reading prerelease schema");

        let message = sanitize_error_message(&error);

        assert!(message.contains("incompatible prerelease schema"));
        assert!(message.contains("clipmem setup"));
    }

    #[test]
    fn sanitize_error_message_keeps_non_database_messages() {
        let error = invalid_args_error("cursor does not match the active search filters");

        assert_eq!(
            sanitize_error_message(&error),
            "cursor does not match the active search filters"
        );
    }

    #[test]
    fn rusqlite_errors_are_db_errors_not_not_found_errors() {
        let error: anyhow::Error = rusqlite::Error::QueryReturnedNoRows.into();
        let message = "snapshot was not found";

        assert!(is_db_error(&error, message));
        assert!(!is_not_found_error(&error, message));
        assert_eq!(
            classify_command_error(error).exit_code(),
            CliExitCode::DbError
        );
    }

    #[test]
    fn platform_error_detection_checks_error_chain() {
        let error = anyhow!("clipboard restore is only supported on macOS")
            .context("restore failed for snapshot 42");
        let message = sanitize_error_message(&error);

        assert!(is_platform_error(&error, &message));
        assert_eq!(
            classify_command_error(error).exit_code(),
            CliExitCode::PlatformError
        );
    }

    #[test]
    fn unsupported_format_error_is_classified_before_invalid_args() {
        let error = UnsupportedFormatError::new(
            "export only supports `text`, `json`, and `human` output, got `jsonl`",
        );

        let classified = classify_command_error(error.into());

        assert_eq!(classified.exit_code(), CliExitCode::UnsupportedFormat);
    }

    #[test]
    fn explicit_error_helpers_set_expected_exit_codes() {
        assert_eq!(
            classify_command_error(invalid_args_error("bad flag")).exit_code(),
            CliExitCode::InvalidArgs
        );
        assert_eq!(
            classify_command_error(db_error("database write failed")).exit_code(),
            CliExitCode::DbError
        );
    }
}