regy 0.1.0

Private-by-default desktop agent for the Regy web interface
use thiserror::Error;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
    SessionNotFound,
    SessionAlreadyExists,
    CommandDenied,
    CwdDenied,
    PtyStartFailed,
    PtyKillFailed,
    PtyReadFailed,
    PtyWriteFailed,
    ResizeFailed,
    BackendDisconnected,
    SnapshotBusy,
    InvalidMessage,
    HistoryNotConfigured,
    HistoryReadFailed,
    StaleTranscriptCursor,
    TranscriptSnapshotTooLarge,
    HistoryWriteFailed,
    ConversationNotFound,
    ConversationActive,
    CatalogNotConfigured,
    CatalogAuthenticationFailed,
    CatalogRateLimited,
    CatalogUnavailable,
    CatalogResponseInvalid,
    SkillNotFound,
    SkillInvalid,
    SkillAlreadyInstalled,
    SkillConflict,
    SkillScopeDenied,
    SkillDownloadFailed,
    SkillFilesystemFailed,
    PathDenied,
    DirectoryNotFound,
    NotADirectory,
    PairingStorageFailed,
    PairingAuthenticationFailed,
}

impl ErrorCode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::SessionNotFound => "SESSION_NOT_FOUND",
            Self::SessionAlreadyExists => "SESSION_ALREADY_EXISTS",
            Self::CommandDenied => "COMMAND_DENIED",
            Self::CwdDenied => "CWD_DENIED",
            Self::PtyStartFailed => "PTY_START_FAILED",
            Self::PtyKillFailed => "PTY_KILL_FAILED",
            Self::PtyReadFailed => "PTY_READ_FAILED",
            Self::PtyWriteFailed => "PTY_WRITE_FAILED",
            Self::ResizeFailed => "RESIZE_FAILED",
            Self::BackendDisconnected => "BACKEND_DISCONNECTED",
            Self::SnapshotBusy => "SNAPSHOT_BUSY",
            Self::InvalidMessage => "INVALID_MESSAGE",
            Self::HistoryNotConfigured => "HISTORY_NOT_CONFIGURED",
            Self::HistoryReadFailed => "HISTORY_READ_FAILED",
            Self::StaleTranscriptCursor => "STALE_TRANSCRIPT_CURSOR",
            Self::TranscriptSnapshotTooLarge => "TRANSCRIPT_SNAPSHOT_TOO_LARGE",
            Self::HistoryWriteFailed => "HISTORY_WRITE_FAILED",
            Self::ConversationNotFound => "CONVERSATION_NOT_FOUND",
            Self::ConversationActive => "CONVERSATION_ACTIVE",
            Self::CatalogNotConfigured => "CATALOG_NOT_CONFIGURED",
            Self::CatalogAuthenticationFailed => "CATALOG_AUTHENTICATION_FAILED",
            Self::CatalogRateLimited => "CATALOG_RATE_LIMITED",
            Self::CatalogUnavailable => "CATALOG_UNAVAILABLE",
            Self::CatalogResponseInvalid => "CATALOG_RESPONSE_INVALID",
            Self::SkillNotFound => "SKILL_NOT_FOUND",
            Self::SkillInvalid => "SKILL_INVALID",
            Self::SkillAlreadyInstalled => "SKILL_ALREADY_INSTALLED",
            Self::SkillConflict => "SKILL_CONFLICT",
            Self::SkillScopeDenied => "SKILL_SCOPE_DENIED",
            Self::SkillDownloadFailed => "SKILL_DOWNLOAD_FAILED",
            Self::SkillFilesystemFailed => "SKILL_FILESYSTEM_FAILED",
            Self::PathDenied => "PATH_DENIED",
            Self::DirectoryNotFound => "DIRECTORY_NOT_FOUND",
            Self::NotADirectory => "NOT_A_DIRECTORY",
            Self::PairingStorageFailed => "PAIRING_STORAGE_FAILED",
            Self::PairingAuthenticationFailed => "PAIRING_AUTHENTICATION_FAILED",
        }
    }
}

#[derive(Debug, Error)]
#[error("{code:?}: {message}")]
pub struct AgentError {
    code: ErrorCode,
    message: String,
}

impl AgentError {
    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
        Self {
            code,
            message: message.into(),
        }
    }

    pub fn code(&self) -> ErrorCode {
        self.code
    }

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

pub type AgentResult<T> = Result<T, AgentError>;

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

    #[test]
    fn error_code_serializes_as_protocol_string() {
        assert_eq!(ErrorCode::InvalidMessage.as_str(), "INVALID_MESSAGE");
        assert_eq!(ErrorCode::PtyKillFailed.as_str(), "PTY_KILL_FAILED");
        assert_eq!(ErrorCode::SnapshotBusy.as_str(), "SNAPSHOT_BUSY");
        assert_eq!(
            ErrorCode::StaleTranscriptCursor.as_str(),
            "STALE_TRANSCRIPT_CURSOR"
        );
        assert_eq!(
            ErrorCode::TranscriptSnapshotTooLarge.as_str(),
            "TRANSCRIPT_SNAPSHOT_TOO_LARGE"
        );
        assert_eq!(
            ErrorCode::ConversationActive.as_str(),
            "CONVERSATION_ACTIVE"
        );
    }

    #[test]
    fn skill_error_codes_have_stable_protocol_strings() {
        let cases = [
            (ErrorCode::CatalogNotConfigured, "CATALOG_NOT_CONFIGURED"),
            (
                ErrorCode::CatalogAuthenticationFailed,
                "CATALOG_AUTHENTICATION_FAILED",
            ),
            (ErrorCode::CatalogRateLimited, "CATALOG_RATE_LIMITED"),
            (ErrorCode::CatalogUnavailable, "CATALOG_UNAVAILABLE"),
            (
                ErrorCode::CatalogResponseInvalid,
                "CATALOG_RESPONSE_INVALID",
            ),
            (ErrorCode::SkillNotFound, "SKILL_NOT_FOUND"),
            (ErrorCode::SkillInvalid, "SKILL_INVALID"),
            (ErrorCode::SkillAlreadyInstalled, "SKILL_ALREADY_INSTALLED"),
            (ErrorCode::SkillConflict, "SKILL_CONFLICT"),
            (ErrorCode::SkillScopeDenied, "SKILL_SCOPE_DENIED"),
            (ErrorCode::SkillDownloadFailed, "SKILL_DOWNLOAD_FAILED"),
            (ErrorCode::SkillFilesystemFailed, "SKILL_FILESYSTEM_FAILED"),
        ];

        for (code, expected) in cases {
            assert_eq!(code.as_str(), expected);
        }
    }
}