openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! OpenLatch error codes — `OL-42xx` range.
//!
//! Every user-facing error in this crate is identified by a stable `OL-XXXX`
//! code. The full registry lives in `.claude/rules/error-handling.md` and at
//! `https://docs.openlatch.ai/errors/`. Codes are stable forever — never reuse
//! or reassign a code once published.
//!
//! # Pattern
//!
//! Library code (everything outside `src/main.rs`) uses [`OlError`] via
//! [`thiserror`]. Helpers like [`OlError::auth`] / [`OlError::manifest`] /
//! [`OlError::backend`] keep the call-site terse.
//!
//! # Exit codes
//!
//! Per `.claude/rules/error-handling.md`:
//!
//! | Code | Meaning |
//! |---|---|
//! | 0 | Success |
//! | 1 | User error — invalid args, manifest validation failed |
//! | 2 | Configuration / auth error |
//! | 3 | Network / cloud error |
//! | 4 | Runtime error — HMAC, replay, tool unreachable |
//! | 5 | Partial success |
//! | 130 | SIGINT |
//!
//! [`OlError::exit_code`] maps a code to the right shell exit code.

use thiserror::Error;

/// Public-facing error code surface for `--output json` / `sarif` shaping.
///
/// Stored as a `&'static str` so codes can be used in `match`, embedded in
/// const contexts, and round-tripped through machine-readable output without
/// allocation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ErrorCode {
    /// Stable identifier — e.g. `"OL-4220"`.
    pub code: &'static str,
    /// Public docs URL — e.g. `"https://docs.openlatch.ai/errors/OL-4220"`.
    pub docs_url: &'static str,
}

impl ErrorCode {
    pub const fn new(code: &'static str, docs_url: &'static str) -> Self {
        Self { code, docs_url }
    }
}

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

// ---------------------------------------------------------------------------
// Registry. Order mirrors `.claude/rules/error-handling.md`.
// ---------------------------------------------------------------------------

macro_rules! ol_codes {
    ( $( $name:ident = $code:literal ; )* ) => {
        $(
            #[doc = concat!("`", $code, "` — see https://docs.openlatch.ai/errors/", $code)]
            pub const $name: ErrorCode = ErrorCode::new(
                $code,
                concat!("https://docs.openlatch.ai/errors/", $code),
            );
        )*

        /// Every published code, in registry order. Useful for self-test
        /// (`docs/errors/` page exists for each one).
        pub const ALL_CODES: &[ErrorCode] = &[ $( $name ),* ];
    };
}

ol_codes! {
    // OL-4200..4209 — Auth
    OL_4200_TOKEN_EXPIRED                  = "OL-4200";
    OL_4201_KEYRING_UNAVAILABLE            = "OL-4201";
    OL_4202_BROWSER_LAUNCH_FAILED          = "OL-4202";
    OL_4203_PKCE_STATE_MISMATCH            = "OL-4203";
    OL_4204_TOKEN_FILE_UNREADABLE          = "OL-4204";

    // OL-4210..4219 — Manifest validation
    OL_4210_SCHEMA_MISMATCH                = "OL-4210";
    OL_4211_UNKNOWN_THREAT_CATEGORY        = "OL-4211";
    OL_4212_INVALID_ENDPOINT_URL           = "OL-4212";
    OL_4213_INVALID_AGENTS_SUPPORTED       = "OL-4213";
    OL_4214_INVALID_HOOKS_SUPPORTED        = "OL-4214";
    OL_4215_UNKNOWN_AGENT_PLATFORM         = "OL-4215";
    OL_4216_CAPABILITY_MISSING_FIELD       = "OL-4216";

    // OL-4220..4229 — Runtime / webhook
    OL_4220_HMAC_FAILED                    = "OL-4220";
    OL_4221_MALFORMED_BODY                 = "OL-4221";
    OL_4222_BINDING_NOT_CONFIGURED         = "OL-4222";
    OL_4223_VERDICT_TOO_LARGE              = "OL-4223";
    OL_4224_TOOL_UNREACHABLE               = "OL-4224";
    OL_4225_TOOL_5XX                       = "OL-4225";
    OL_4226_TIMESTAMP_SKEW                 = "OL-4226";
    OL_4227_REPLAY_REJECTED                = "OL-4227";
    OL_4228_DEADLINE_EXCEEDED              = "OL-4228";

    // OL-4230..4239 — Backend errors
    OL_4230_BACKEND_4XX                    = "OL-4230";
    OL_4231_BACKEND_5XX                    = "OL-4231";
    OL_4232_BACKEND_UNAUTHORIZED           = "OL-4232";
    OL_4233_BACKEND_FORBIDDEN              = "OL-4233";
    OL_4234_BACKEND_NOT_FOUND              = "OL-4234";
    OL_4235_BACKEND_CONFLICT               = "OL-4235";
    OL_4236_BACKEND_GONE                   = "OL-4236";
    OL_4237_BACKEND_INTERNAL               = "OL-4237";
    OL_4238_BACKEND_BAD_GATEWAY            = "OL-4238";
    OL_4239_BACKEND_UNAVAILABLE            = "OL-4239";

    // OL-4240..4249 — Probe / endpoint validation
    OL_4240_ENDPOINT_NOT_HTTPS             = "OL-4240";
    OL_4241_PRIVATE_IP                     = "OL-4241";
    OL_4242_TLS_TOO_LOW                    = "OL-4242";
    OL_4243_REDIRECT_NOT_FOLLOWED          = "OL-4243";
    OL_4244_SYNTHETIC_PROBE_FAILED         = "OL-4244";
    OL_4245_LATENCY_PROBE_FAILED           = "OL-4245";
    OL_4246_CLOUD_METADATA_IP              = "OL-4246";
    OL_4247_IPV4_MAPPED_V6                 = "OL-4247";

    // OL-4250..4259 — Update / distribution
    OL_4250_UPDATE_FETCH_FAILED            = "OL-4250";
    OL_4251_INTEGRITY_MISMATCH             = "OL-4251";
    OL_4252_NPM_REGISTRY_UNREACHABLE       = "OL-4252";
    OL_4253_UPDATE_APPLY_FAILED            = "OL-4253";
    OL_4254_UPDATE_SIGNATURE_FAILED        = "OL-4254";
    OL_4255_UPDATE_SANITY_FAILED           = "OL-4255";
    OL_4256_UPDATE_SWAP_FS_ERROR           = "OL-4256";
    OL_4257_UPDATE_ROLLED_BACK             = "OL-4257";
    OL_4258_UPDATE_REFUSED_CARGO_INSTALL   = "OL-4258";
    OL_4259_UPDATE_BLOCKED_MIN_SUPPORTED   = "OL-4259";

    // OL-4260..4269 — Telemetry / consent
    OL_4260_SENTRY_INIT_FAILED             = "OL-4260";
    OL_4261_POSTHOG_INIT_FAILED            = "OL-4261";
    OL_4262_CONSENT_FILE_CORRUPT           = "OL-4262";
    OL_4263_CONSENT_WRITE_FAILED           = "OL-4263";
    OL_4264_TELEMETRY_BATCH_FAILED         = "OL-4264";

    // OL-4270..4279 — Local IO / config
    OL_4270_CONFIG_UNREADABLE              = "OL-4270";
    OL_4271_PROFILE_NOT_FOUND              = "OL-4271";
    OL_4272_XDG_DIR_UNWRITABLE             = "OL-4272";
    OL_4273_MANIFEST_UNREADABLE            = "OL-4273";
    OL_4274_MANIFEST_WRITE_FAILED          = "OL-4274";
    OL_4275_EDITOR_LAUNCH_FAILED           = "OL-4275";

    // OL-4280..4289 — Pre-flight slug validation (mapped from platform 409s)
    OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG     = "OL-4280";
    OL_4281_PLATFORM_DUPLICATE_PROVIDER_SLUG = "OL-4281";
    OL_4282_PLATFORM_DUPLICATE_EDITOR_SLUG   = "OL-4282";
    OL_4283_PLATFORM_DUPLICATE_BINDING       = "OL-4283";
    OL_4284_PREFLIGHT_VALIDATION_FAILED      = "OL-4284";

    // OL-4290..4299 — Rate limiting / billing
    OL_4290_RATE_LIMIT                     = "OL-4290";
    OL_4291_TOOL_SUBMISSIONS_RATE_LIMIT    = "OL-4291";
    OL_4292_PROVIDER_SUBMISSIONS_RATE_LIMIT = "OL-4292";
    OL_4293_BINDING_SUBMISSIONS_RATE_LIMIT = "OL-4293";
    OL_4294_PLAN_QUOTA_EXCEEDED            = "OL-4294";

    // OL-4300..4309 — Managed tool process supervision (`listen` mode)
    OL_4300_PROCESS_SPEC_INVALID           = "OL-4300";
    OL_4301_PROCESS_SPAWN_FAILED           = "OL-4301";
    OL_4302_STARTUP_PROBE_TIMEOUT          = "OL-4302";
    OL_4303_RESTART_RATE_LIMIT             = "OL-4303";
    OL_4304_PORT_ALREADY_BOUND             = "OL-4304";
    OL_4305_ORPHAN_RECONCILE_FAILED        = "OL-4305";

    // OL-4320..4328 — Manifest v2 (multi-tool / kind: Tool|Provider split)
    OL_4320_PROVIDER_SCHEMA_INVALID        = "OL-4320";
    OL_4321_TOOL_SCHEMA_INVALID            = "OL-4321";
    OL_4322_AMBIGUOUS_TOOL_REF             = "OL-4322";
    OL_4323_UNRESOLVED_TOOL_REF            = "OL-4323";
    OL_4324_DUPLICATE_TOOL_REGISTRY        = "OL-4324";
    OL_4325_TOOL_PATHS_ZERO_MATCH          = "OL-4325";
    OL_4326_OVERRIDE_COMMAND_CONFLICT      = "OL-4326";
    OL_4327_V1_MANIFEST_REJECTED           = "OL-4327";
    OL_4328_TOOL_MANIFEST_MULTI_EDITOR     = "OL-4328";
}

// ---------------------------------------------------------------------------
// Exit codes
// ---------------------------------------------------------------------------

/// Process exit codes per `.claude/rules/error-handling.md`. Stable forever —
/// scripts depend on these.
#[repr(i32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
    Success = 0,
    UserError = 1,
    ConfigOrAuth = 2,
    Network = 3,
    Runtime = 4,
    PartialSuccess = 5,
    Sigint = 130,
}

impl From<ExitCode> for i32 {
    fn from(c: ExitCode) -> i32 {
        c as i32
    }
}

// ---------------------------------------------------------------------------
// OlError — typed error used everywhere except main.rs
// ---------------------------------------------------------------------------

/// Typed library error. Carries an [`ErrorCode`], a human message, an
/// optional remediation suggestion, and an optional structured context blob
/// for `--output json` / `sarif` rendering.
///
/// Constructors that match common patterns (`auth`, `manifest`, `backend`, …)
/// map straight to the right exit-code class. Use the registry constants
/// (e.g. [`OL_4220_HMAC_FAILED`]) to construct ad-hoc cases.
#[derive(Debug, Error)]
#[error("[{code}] {message}", code = code.code, message = message)]
pub struct OlError {
    pub code: ErrorCode,
    pub message: String,
    pub suggestion: Option<String>,
    pub context: Option<serde_json::Value>,
    #[source]
    pub source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
}

impl OlError {
    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
        Self {
            code,
            message: message.into(),
            suggestion: None,
            context: None,
            source: None,
        }
    }

    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
        self.suggestion = Some(suggestion.into());
        self
    }

    pub fn with_context(mut self, context: serde_json::Value) -> Self {
        self.context = Some(context);
        self
    }

    pub fn with_source<E>(mut self, source: E) -> Self
    where
        E: std::error::Error + Send + Sync + 'static,
    {
        self.source = Some(Box::new(source));
        self
    }

    /// Compare to a registry code. Avoids the `e.code.code == OL_…  .code`
    /// stringly-typed idiom now that `ErrorCode` derives `PartialEq`.
    pub fn is(&self, code: ErrorCode) -> bool {
        self.code == code
    }

    /// Map the error to the right [`ExitCode`] based on its code range.
    pub fn exit_code(&self) -> ExitCode {
        match self.code.code.as_bytes() {
            // OL-4200-4209 (auth) + OL-4232/4233 (401/403) + OL-4270/4271 (config/profile)
            b"OL-4200" | b"OL-4201" | b"OL-4202" | b"OL-4203" | b"OL-4204" | b"OL-4232"
            | b"OL-4233" | b"OL-4270" | b"OL-4271" | b"OL-4272" => ExitCode::ConfigOrAuth,
            // OL-4230..4239 (backend) - already partially routed above; remainder = network
            b"OL-4230" | b"OL-4231" | b"OL-4234" | b"OL-4235" | b"OL-4236" | b"OL-4237"
            | b"OL-4238" | b"OL-4239" | b"OL-4252" | b"OL-4290" | b"OL-4291" | b"OL-4292"
            | b"OL-4293" | b"OL-4294" => ExitCode::Network,
            // OL-4280..4289 — pre-flight slug collisions are user-input errors
            b"OL-4280" | b"OL-4281" | b"OL-4282" | b"OL-4283" | b"OL-4284" => ExitCode::UserError,
            // OL-4220..4229 (runtime) + probe + tool failures + update apply
            b"OL-4220" | b"OL-4221" | b"OL-4222" | b"OL-4223" | b"OL-4224" | b"OL-4225"
            | b"OL-4226" | b"OL-4227" | b"OL-4228" | b"OL-4240" | b"OL-4241" | b"OL-4242"
            | b"OL-4243" | b"OL-4244" | b"OL-4245" | b"OL-4246" | b"OL-4247" | b"OL-4250"
            | b"OL-4251" | b"OL-4253" | b"OL-4254" | b"OL-4255" | b"OL-4256" | b"OL-4257" => {
                ExitCode::Runtime
            }
            // Cargo-install + min-supported gates surface as PartialSuccess
            // (exit 5) per the auto-update.md contract — the CLI continues to
            // function, an apply was just refused.
            b"OL-4258" | b"OL-4259" => ExitCode::PartialSuccess,
            // OL-4301..4305 — process supervision runtime failures
            b"OL-4301" | b"OL-4302" | b"OL-4303" | b"OL-4304" | b"OL-4305" => ExitCode::Runtime,
            // Default: user error (manifest validation, missing flags, etc.)
            _ => ExitCode::UserError,
        }
    }

    // -- terse constructors -------------------------------------------------

    pub fn auth(message: impl Into<String>) -> Self {
        Self::new(OL_4200_TOKEN_EXPIRED, message)
    }

    pub fn manifest(message: impl Into<String>) -> Self {
        Self::new(OL_4210_SCHEMA_MISMATCH, message)
    }

    pub fn backend(message: impl Into<String>) -> Self {
        Self::new(OL_4230_BACKEND_4XX, message)
    }

    pub fn config(message: impl Into<String>) -> Self {
        Self::new(OL_4270_CONFIG_UNREADABLE, message)
    }
}

impl From<std::io::Error> for OlError {
    fn from(e: std::io::Error) -> Self {
        OlError::new(OL_4270_CONFIG_UNREADABLE, e.to_string()).with_source(e)
    }
}

/// Convenience alias used throughout the crate.
pub type Result<T, E = OlError> = std::result::Result<T, E>;

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn registry_codes_are_unique_and_well_formed() {
        let mut seen = std::collections::BTreeSet::new();
        for c in ALL_CODES {
            assert!(
                c.code.starts_with("OL-42") || c.code.starts_with("OL-43"),
                "out-of-range code: {}",
                c.code
            );
            assert_eq!(c.code.len(), 7, "malformed code: {}", c.code);
            assert!(
                c.docs_url.starts_with("https://docs.openlatch.ai/errors/"),
                "bad docs_url: {}",
                c.docs_url
            );
            assert!(seen.insert(c.code), "duplicate code: {}", c.code);
        }
    }

    #[test]
    fn exit_code_routing() {
        assert_eq!(
            OlError::new(OL_4200_TOKEN_EXPIRED, "x").exit_code(),
            ExitCode::ConfigOrAuth
        );
        assert_eq!(
            OlError::new(OL_4220_HMAC_FAILED, "x").exit_code(),
            ExitCode::Runtime
        );
        assert_eq!(
            OlError::new(OL_4231_BACKEND_5XX, "x").exit_code(),
            ExitCode::Network
        );
        assert_eq!(
            OlError::new(OL_4210_SCHEMA_MISMATCH, "x").exit_code(),
            ExitCode::UserError
        );
    }

    #[test]
    fn display_includes_code_and_message() {
        let e = OlError::new(OL_4220_HMAC_FAILED, "signature mismatch");
        let s = format!("{e}");
        assert!(s.contains("OL-4220"));
        assert!(s.contains("signature mismatch"));
    }
}