blotter-cli 1.1.0

Append-only friction ledger for AI agents: log the cuts that got in the way and the findings worth writing up, find what recurs, promote it into durable fixes, and verify the fixes held.
Documentation
use serde_json::{Value, json};
use std::collections::BTreeMap;
use thiserror::Error;

pub type AppResult<T> = Result<T, AppError>;

#[derive(Debug, Error)]
#[error("{message}")]
pub struct AppError {
    pub code: &'static str,
    pub message: String,
    pub details: Value,
    pub retryable: bool,
    pub suggested_fix: String,
    pub exit_code: i32,
}

/// Single source of truth for every public error code, its exit code, and the
/// description published in `blotter schema`.
pub struct ErrorContract {
    pub code: &'static str,
    pub exit_code: i32,
    pub description: &'static str,
}

/// The published description for exit 65. Three codes map to it, so the string
/// is authored to name all three rather than left to whichever `ERROR_CONTRACT`
/// entry `exit_code_map` happens to insert last (r48). Every 65 entry carries
/// it, so the map cannot drift with the table's order.
pub const EXIT_65_DESCRIPTION: &str =
    "invalid input data, including an ambiguous ID or an unsupported log version";

pub const ERROR_CONTRACT: &[ErrorContract] = &[
    ErrorContract {
        code: "invalid_argument",
        exit_code: 2,
        description: "invalid arguments",
    },
    ErrorContract {
        code: "invalid_input",
        exit_code: 65,
        description: EXIT_65_DESCRIPTION,
    },
    ErrorContract {
        code: "not_found",
        exit_code: 66,
        description: "missing explicit file or unknown ID",
    },
    ErrorContract {
        code: "ambiguous_id",
        exit_code: 65,
        description: EXIT_65_DESCRIPTION,
    },
    ErrorContract {
        code: "unsupported_log_version",
        exit_code: 65,
        description: EXIT_65_DESCRIPTION,
    },
    ErrorContract {
        code: "io_error",
        exit_code: 74,
        description: "I/O error",
    },
    ErrorContract {
        code: "permission_denied",
        exit_code: 77,
        description: "permission denied",
    },
    ErrorContract {
        code: "lock_timeout",
        exit_code: 75,
        description: "lock timeout; retryable",
    },
    ErrorContract {
        code: "config_error",
        exit_code: 78,
        description: "configuration error",
    },
    ErrorContract {
        code: "internal",
        exit_code: 70,
        description: "internal error",
    },
];

/// The refusal text, shared by the error envelope and `doctor`'s
/// `unsupported_version` finding so the two cannot drift. It names the offending
/// line and what was found there, never the path.
pub fn unsupported_log_version_message(line: usize, found_version: Option<&Value>) -> String {
    let found = match found_version {
        Some(value) => format!("found v {value}"),
        None => "record has no v field".to_owned(),
    };
    format!("unsupported log version on line {line}: {found}")
}

pub fn exit_code_for(code: &str) -> i32 {
    ERROR_CONTRACT
        .iter()
        .find(|entry| entry.code == code)
        .map_or(70, |entry| entry.exit_code)
}

pub fn error_codes() -> Vec<&'static str> {
    ERROR_CONTRACT.iter().map(|entry| entry.code).collect()
}

pub fn exit_code_map() -> BTreeMap<i32, &'static str> {
    let mut map = BTreeMap::new();
    map.insert(0, "success or empty result");
    for entry in ERROR_CONTRACT {
        map.insert(entry.exit_code, entry.description);
    }
    map.insert(
        1,
        "command findings: doctor unhealthy, triage clusters, verify recurrences, or retrospect candidates",
    );
    map
}

impl AppError {
    pub fn invalid_argument(message: impl Into<String>, fix: impl Into<String>) -> Self {
        Self::new("invalid_argument", message, false, fix)
    }

    pub fn invalid_input(message: impl Into<String>, fix: impl Into<String>) -> Self {
        Self::new("invalid_input", message, false, fix)
    }

    pub fn not_found(message: impl Into<String>, fix: impl Into<String>) -> Self {
        Self::new("not_found", message, false, fix)
    }

    pub fn ambiguous_id(prefix: &str, candidates: Vec<String>) -> Self {
        let mut error = Self::new(
            "ambiguous_id",
            format!("ID prefix '{prefix}' matches multiple records"),
            false,
            "Use one of the full IDs listed in error.details.candidates.",
        );
        error.details = json!({ "candidates": candidates });
        error
    }

    /// The 0.15 → 1.0.0 upgrade refusal (r48, r49, r50). The message names the
    /// offending line and what was found there and never the path: `sweep`
    /// prefixes its warning with the path, and the resolved path is carried in
    /// `details.file` and in `suggested_fix`. `found_version` is present
    /// verbatim for any `v` other than the integer 2 — `null` included — and
    /// omitted only when the key was absent, so absent and wrong are told apart
    /// by key presence.
    pub fn unsupported_log_version(
        path: &std::path::Path,
        line: usize,
        found_version: Option<&Value>,
    ) -> Self {
        let mut error = Self::new(
            "unsupported_log_version",
            unsupported_log_version_message(line, found_version),
            false,
            format!(
                "Rename {} to a path that does not yet exist, then run `blotter add` to create a fresh v2 log.",
                path.display()
            ),
        );
        error.details = json!({ "file": path.to_string_lossy(), "line": line });
        if let Some(value) = found_version {
            error.details["found_version"] = value.clone();
        }
        error
    }

    pub fn config(message: impl Into<String>, fix: impl Into<String>) -> Self {
        Self::new("config_error", message, false, fix)
    }

    pub fn lock_timeout(path: &std::path::Path) -> Self {
        Self::new(
            "lock_timeout",
            format!(
                "timed out waiting for the blotter file lock: {}",
                path.display()
            ),
            true,
            "Retry the same command after the other blotter process finishes.",
        )
    }

    pub fn internal(message: impl Into<String>) -> Self {
        Self::new(
            "internal",
            message,
            false,
            "Run `blotter doctor`; if the problem persists, report the command and blotter version.",
        )
    }

    pub fn from_io(error: std::io::Error, path: &std::path::Path) -> Self {
        match error.kind() {
            std::io::ErrorKind::PermissionDenied => Self::new(
                "permission_denied",
                format!("permission denied for {}: {error}", path.display()),
                false,
                "Choose a writable path with --file or correct the file permissions.",
            ),
            _ => Self::new(
                "io_error",
                format!("I/O error for {}: {error}", path.display()),
                false,
                "Check that the path exists and its filesystem is available, then retry.",
            ),
        }
    }

    /// Error mapping for opening an existing blotter log file. This is where
    /// `NotFound` becomes `not_found` / 66, and where a directory rejected by
    /// an append-mode open is normalized to the same `invalid_input` / 65 that
    /// the opened-handle regular-file check returns for other object types.
    pub fn from_log_open(error: std::io::Error, path: &std::path::Path) -> Self {
        match error.kind() {
            std::io::ErrorKind::NotFound => Self::new(
                "not_found",
                format!("blotter file not found: {}", path.display()),
                false,
                "Run `blotter add` to create the file or pass an existing --file PATH.",
            ),
            std::io::ErrorKind::IsADirectory => Self::invalid_input(
                format!("blotter file is not a regular file: {}", path.display()),
                "Point --file PATH or BLOTTER_FILE at a regular JSONL file; FIFOs and devices are not accepted.",
            ),
            _ => Self::from_io(error, path),
        }
    }

    /// Error mapping for reading an explicit `sweep --registry` file. The flag
    /// names a file as explicitly as `--file` does, so a missing path is
    /// `not_found` / 66 rather than the `io_error` / 74 `from_io` gives. A path
    /// that exists but can never be read as a registry — a directory, or bytes
    /// that are not UTF-8 — is `invalid_input` / 65 for the same reason the log
    /// path is: the input is wrong, not the filesystem.
    pub fn from_registry_file(error: std::io::Error, path: &std::path::Path) -> Self {
        match error.kind() {
            std::io::ErrorKind::NotFound => Self::new(
                "not_found",
                format!("sweep registry file not found: {}", path.display()),
                false,
                "Pass an existing registry file to --registry PATH.",
            ),
            std::io::ErrorKind::InvalidData => Self::invalid_input(
                format!("sweep registry file is not valid UTF-8: {}", path.display()),
                "Save the registry as UTF-8 text with one path per line, then retry.",
            ),
            std::io::ErrorKind::IsADirectory => Self::invalid_input(
                format!(
                    "sweep registry file is not a regular file: {}",
                    path.display()
                ),
                "Pass a UTF-8 text file with one repository path per line to --registry PATH.",
            ),
            _ => Self::from_io(error, path),
        }
    }

    pub fn from_evidence_file(error: std::io::Error, path: &std::path::Path) -> Self {
        match error.kind() {
            std::io::ErrorKind::NotFound => Self::new(
                "not_found",
                format!("stderr evidence file not found: {}", path.display()),
                false,
                "Pass an existing regular UTF-8 file to --stderr-file PATH.",
            ),
            std::io::ErrorKind::PermissionDenied => Self::new(
                "permission_denied",
                format!(
                    "permission denied reading stderr evidence file {}: {error}",
                    path.display()
                ),
                false,
                "Grant read permission to the stderr evidence file or pass a readable --stderr-file PATH.",
            ),
            _ => Self::new(
                "io_error",
                format!(
                    "I/O error reading stderr evidence file {}: {error}",
                    path.display()
                ),
                false,
                "Check that --stderr-file PATH is a readable regular file, then retry.",
            ),
        }
    }

    /// A leftover repair backup blocks a copy-and-swap. Naming the file keeps
    /// the retry from reporting the collision instead of the original failure.
    pub fn stale_backup(path: &std::path::Path) -> Self {
        Self::new(
            "io_error",
            format!("backup path already exists: {}", path.display()),
            false,
            format!(
                "Remove or rename the leftover backup {}; it is from an aborted repair, not a completed one, then retry.",
                path.display()
            ),
        )
    }

    fn new(
        code: &'static str,
        message: impl Into<String>,
        retryable: bool,
        suggested_fix: impl Into<String>,
    ) -> Self {
        Self {
            code,
            message: message.into(),
            details: json!({}),
            retryable,
            suggested_fix: suggested_fix.into(),
            exit_code: exit_code_for(code),
        }
    }
}

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

    #[test]
    fn io_not_found_maps_to_io_error_74() {
        let error = std::io::Error::new(ErrorKind::NotFound, "missing");
        let err = AppError::from_io(error, std::path::Path::new("/tmp/x"));
        assert_eq!(err.code, "io_error");
        assert_eq!(err.exit_code, 74);
    }

    #[test]
    fn log_open_not_found_maps_to_not_found_66() {
        let error = std::io::Error::new(ErrorKind::NotFound, "missing");
        let err = AppError::from_log_open(error, std::path::Path::new("/tmp/x"));
        assert_eq!(err.code, "not_found");
        assert_eq!(err.exit_code, 66);
    }

    #[test]
    fn log_open_directory_maps_to_invalid_input_65() {
        let error = std::io::Error::new(ErrorKind::IsADirectory, "is a directory");
        let err = AppError::from_log_open(error, std::path::Path::new("/tmp/log-dir"));
        assert_eq!(err.code, "invalid_input");
        assert_eq!(err.exit_code, 65);
        assert!(err.message.contains("not a regular file"));
    }

    #[test]
    fn registry_not_found_maps_to_not_found_66() {
        let error = std::io::Error::new(ErrorKind::NotFound, "missing");
        let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/x"));
        assert_eq!(err.code, "not_found");
        assert_eq!(err.exit_code, 66);
    }

    #[test]
    fn registry_permission_denied_maps_to_permission_denied_77() {
        let error = std::io::Error::new(ErrorKind::PermissionDenied, "denied");
        let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/x"));
        assert_eq!(err.code, "permission_denied");
        assert_eq!(err.exit_code, 77);
    }

    #[test]
    fn registry_invalid_data_maps_to_invalid_input_65() {
        let error =
            std::io::Error::new(ErrorKind::InvalidData, "stream did not contain valid UTF-8");
        let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/repos.txt"));
        assert_eq!(err.code, "invalid_input");
        assert_eq!(err.exit_code, 65);
        assert!(err.message.contains("not valid UTF-8"));
    }

    #[test]
    fn registry_directory_maps_to_invalid_input_65() {
        let error = std::io::Error::new(ErrorKind::IsADirectory, "is a directory");
        let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/repos"));
        assert_eq!(err.code, "invalid_input");
        assert_eq!(err.exit_code, 65);
        assert!(err.message.contains("not a regular file"));
    }
}