openlatch-client 0.0.0

The open-source security layer for AI agents — client forwarder
Documentation
/// Error framework for OpenLatch client using the OL-XXXX code format.
///
/// All user-facing errors carry a structured code, message, optional suggestion,
/// and optional docs URL. The Display format follows the D-06/D-07 convention:
///
/// ```text
/// Error: {message} (OL-XXXX)
///
///   Suggestion: {actionable text}
///   Docs: {url}
/// ```
///
/// Suggestion and Docs lines are omitted when the respective field is `None`.
use std::fmt;

/// A structured, user-facing error with an OL-XXXX code.
///
/// # Display format (D-06/D-07)
///
/// ```text
/// Error: {message} (OL-XXXX)
///
///   Suggestion: {actionable text}
///   Docs: {url}
/// ```
#[derive(Debug, Clone)]
pub struct OlError {
    /// The OL-XXXX error code (e.g. "OL-1001").
    pub code: &'static str,
    /// Human-readable, actionable error description.
    pub message: String,
    /// Optional suggestion for how to fix the error.
    pub suggestion: Option<String>,
    /// Optional link to documentation for this error code.
    pub docs_url: Option<String>,
}

impl OlError {
    /// Create a new error with the given code and message.
    pub fn new(code: &'static str, message: impl Into<String>) -> Self {
        Self {
            code,
            message: message.into(),
            suggestion: None,
            docs_url: None,
        }
    }

    /// Attach a suggestion to this error.
    pub fn with_suggestion(mut self, s: impl Into<String>) -> Self {
        self.suggestion = Some(s.into());
        self
    }

    /// Attach a docs URL to this error.
    pub fn with_docs(mut self, url: impl Into<String>) -> Self {
        self.docs_url = Some(url.into());
        self
    }

    /// Build a "bug report" error pre-filled with a GitHub issue URL.
    ///
    /// Use this for unexpected internal errors that indicate a bug in openlatch.
    pub fn bug_report(message: impl Into<String>) -> Self {
        let msg = message.into();
        let url = format!(
            "https://github.com/OpenLatch/openlatch-client/issues/new?title={}&body={}",
            percent_encode(&msg),
            percent_encode("Version: [auto]\nOS: [auto]\n\nDescription:\n"),
        );
        Self {
            code: "OL-9999",
            message: msg,
            suggestion: Some("This is a bug. Please report it.".into()),
            docs_url: Some(url),
        }
    }
}

impl fmt::Display for OlError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Error: {} ({})", self.message, self.code)?;

        let has_suggestion = self.suggestion.is_some();
        let has_docs = self.docs_url.is_some();

        if has_suggestion || has_docs {
            writeln!(f)?;
            writeln!(f)?;
            if let Some(ref s) = self.suggestion {
                writeln!(f, "  Suggestion: {s}")?;
            }
            if let Some(ref url) = self.docs_url {
                write!(f, "  Docs: {url}")?;
            }
        }

        Ok(())
    }
}

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

/// Minimal percent-encoding for URL query parameters.
///
/// Only encodes characters that break URL structure: space, newline, carriage return,
/// ampersand, equals sign, and the hash character. This avoids pulling in a
/// full URL-encoding dependency for a single use in bug_report().
fn percent_encode(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    for c in input.chars() {
        match c {
            ' ' => out.push_str("%20"),
            '\n' => out.push_str("%0A"),
            '\r' => out.push_str("%0D"),
            '&' => out.push_str("%26"),
            '=' => out.push_str("%3D"),
            '#' => out.push_str("%23"),
            other => out.push(other),
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Envelope errors (OL-1000–1099)
// ---------------------------------------------------------------------------

/// Unknown or unsupported agent type in an incoming event.
pub const ERR_UNKNOWN_AGENT: &str = "OL-1001";
/// Event body exceeds the 1 MB size limit.
pub const ERR_EVENT_TOO_LARGE: &str = "OL-1002";
/// Event was deduplicated within the TTL window (informational).
pub const ERR_EVENT_DEDUPED: &str = "OL-1003";

// ---------------------------------------------------------------------------
// Privacy filter errors (OL-1100–1199)
// ---------------------------------------------------------------------------

/// A custom regex pattern in config.toml failed to compile.
pub const ERR_INVALID_REGEX: &str = "OL-1100";

// ---------------------------------------------------------------------------
// Config errors (OL-1300–1399)
// ---------------------------------------------------------------------------

/// The configuration file contains an invalid value.
pub const ERR_INVALID_CONFIG: &str = "OL-1300";
/// A required configuration field is absent and has no default.
pub const ERR_MISSING_CONFIG_FIELD: &str = "OL-1301";

// ---------------------------------------------------------------------------
// Hooks errors (OL-1400–OL-1499)
// ---------------------------------------------------------------------------

/// No supported AI agent was detected on this machine.
pub const ERR_HOOK_AGENT_NOT_FOUND: &str = "OL-1400";
/// Cannot read or write the agent's settings.json (permissions, I/O error).
pub const ERR_HOOK_WRITE_FAILED: &str = "OL-1401";
/// The settings.json file contains malformed JSONC that cannot be parsed.
pub const ERR_HOOK_MALFORMED_JSONC: &str = "OL-1402";
/// Existing non-OpenLatch hooks detected in settings.json (warning, non-blocking).
pub const ERR_HOOK_CONFLICT: &str = "OL-1403";

// ---------------------------------------------------------------------------
// Daemon errors (OL-1500–1599)
// ---------------------------------------------------------------------------

/// The selected port is already in use by another process.
pub const ERR_PORT_IN_USE: &str = "OL-1500";
/// A daemon instance is already running on this machine.
pub const ERR_ALREADY_RUNNING: &str = "OL-1501";
/// Daemon process started but health check failed within timeout.
pub const ERR_DAEMON_START_FAILED: &str = "OL-1502";
/// A newer version of openlatch is available (warning, non-blocking).
pub const ERR_VERSION_OUTDATED: &str = "OL-1503";

// ---------------------------------------------------------------------------
// Bug report sentinel
// ---------------------------------------------------------------------------

/// Code assigned to all internal/unexpected errors routed through bug_report().
pub const ERR_BUG: &str = "OL-9999";

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

    #[test]
    fn test_ol_error_display_full_format() {
        // Test 1: OlError Display output matches D-06/D-07 format
        let err = OlError::new(ERR_UNKNOWN_AGENT, "Unknown agent type 'my-agent'")
            .with_suggestion("Check that your agent type is one of: claude-code, cursor, windsurf")
            .with_docs("https://docs.openlatch.ai/errors/OL-1001");

        let output = format!("{err}");
        assert!(
            output.starts_with("Error: Unknown agent type 'my-agent' (OL-1001)"),
            "Expected error header, got: {output}"
        );
        assert!(
            output.contains("Suggestion: Check that your agent type"),
            "Missing suggestion"
        );
        assert!(
            output.contains("Docs: https://docs.openlatch.ai"),
            "Missing docs URL"
        );
    }

    #[test]
    fn test_ol_error_display_no_suggestion() {
        // Test 2: OlError without suggestion omits the suggestion line
        let err = OlError::new(ERR_EVENT_TOO_LARGE, "Event body exceeds 1 MB limit")
            .with_docs("https://docs.openlatch.ai/errors/OL-1002");

        let output = format!("{err}");
        assert!(
            !output.contains("Suggestion:"),
            "Should not contain suggestion line: {output}"
        );
        assert!(
            output.contains("Docs:"),
            "Should still contain docs line: {output}"
        );
    }

    #[test]
    fn test_ol_error_display_no_docs_url() {
        // Test 3: OlError without docs_url omits the docs line
        let err = OlError::new(ERR_INVALID_REGEX, "Invalid regex pattern")
            .with_suggestion("Fix the regex in your config");

        let output = format!("{err}");
        assert!(
            !output.contains("Docs:"),
            "Should not contain docs line: {output}"
        );
        assert!(
            output.contains("Suggestion:"),
            "Should still contain suggestion: {output}"
        );
    }

    #[test]
    fn test_ol_error_display_no_optional_fields() {
        // Test 3 (extended): OlError with neither suggestion nor docs
        let err = OlError::new(ERR_PORT_IN_USE, "Port 7443 is already in use");

        let output = format!("{err}");
        assert_eq!(output, "Error: Port 7443 is already in use (OL-1500)");
    }

    #[test]
    fn test_error_code_constants_exist() {
        // Test 4: Error code constants exist for each subsystem range
        assert_eq!(ERR_UNKNOWN_AGENT, "OL-1001");
        assert_eq!(ERR_EVENT_TOO_LARGE, "OL-1002");
        assert_eq!(ERR_INVALID_REGEX, "OL-1100");
        assert_eq!(ERR_INVALID_CONFIG, "OL-1300");
        assert_eq!(ERR_MISSING_CONFIG_FIELD, "OL-1301");
        assert_eq!(ERR_EVENT_DEDUPED, "OL-1003");
        assert_eq!(ERR_HOOK_CONFLICT, "OL-1403");
        assert_eq!(ERR_PORT_IN_USE, "OL-1500");
        assert_eq!(ERR_ALREADY_RUNNING, "OL-1501");
        assert_eq!(ERR_DAEMON_START_FAILED, "OL-1502");
        assert_eq!(ERR_VERSION_OUTDATED, "OL-1503");
    }

    #[test]
    fn test_ol_error_implements_std_error() {
        // Test 5: OlError implements std::error::Error trait
        let err = OlError::new(ERR_UNKNOWN_AGENT, "test");
        // Verify the trait bound by using it as &dyn std::error::Error
        let _boxed: Box<dyn std::error::Error> = Box::new(err);
    }

    #[test]
    fn test_bug_report_sets_code_ol_9999() {
        let err = OlError::bug_report("Unexpected panic in envelope module");
        assert_eq!(err.code, "OL-9999");
        assert!(err.suggestion.is_some());
        assert!(err.docs_url.is_some());
        let url = err.docs_url.unwrap();
        assert!(url.contains("github.com/OpenLatch/openlatch-client/issues/new"));
    }

    #[test]
    fn test_percent_encode_spaces_and_newlines() {
        assert_eq!(percent_encode("hello world"), "hello%20world");
        assert_eq!(percent_encode("line1\nline2"), "line1%0Aline2");
    }
}