notedthat-write 0.12.1

Shared write path (commit, patch, replace) for NotedThat HTTP API and WebDAV surfaces
Documentation
//! Error types for the shared write path.

use notedthat_core::{Error as CoreError, StorageError};

/// What a write had already done to storage when a later step failed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteEffect {
    /// The bytes are stored under the key.
    Stored,
    /// The key is gone.
    Deleted,
}

impl std::fmt::Display for WriteEffect {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::Stored => "stored",
            Self::Deleted => "deleted",
        })
    }
}

/// Errors returned by shared write operations.
#[derive(Debug, thiserror::Error)]
pub enum WriteError {
    /// Storage-layer failure.
    #[error(transparent)]
    Storage(StorageError),
    /// Upload size exceeded the configured limit.
    #[error("payload too large: {size} bytes (limit {limit})")]
    TooLarge {
        /// Actual byte size.
        size: u64,
        /// Maximum allowed byte size.
        limit: u64,
    },
    /// Path/domain validation failure.
    #[error(transparent)]
    Path(CoreError),
    /// Indexer queue was full while enqueueing an upsert.
    #[error("indexer queue full during upsert")]
    IndexerBackpressureUpsert,
    /// Indexer queue was full while enqueueing a tombstone.
    #[error("indexer queue full during tombstone")]
    IndexerBackpressureTombstone,
    /// The event log refused the change after storage had already taken it.
    ///
    /// Mirrors the indexer backpressure variants: the caller answers 503 with
    /// `Retry-After` and a retried write publishes the event (D38, D55).
    #[error("change event not published after the object was {after}")]
    EventPublishFailed {
        /// What storage had already done by the time publishing failed.
        after: WriteEffect,
    },
    /// Object body exceeds the `NOTEDTHAT_MAX_PATCHABLE_SIZE` limit before or after splice.
    #[error("patch payload too large: {size} bytes (limit {limit})")]
    PatchTooLarge {
        /// Actual byte size.
        size: u64,
        /// Maximum allowed byte size.
        limit: u64,
    },
    /// Requested line range is beyond the end of the object at server-side splice time.
    #[error("line range {first}..{last} out of range (total {total_lines} lines)")]
    PatchLineOutOfRange {
        /// Requested first line.
        first: u64,
        /// Requested last line.
        last: u64,
        /// Total line count in the object.
        total_lines: u64,
        /// Total byte count in the object (for X-Content-Range-Bytes).
        total_bytes: u64,
    },
    /// Invalid range, mode contradiction, or missing If-Match for PATCH.
    #[error("invalid patch request: {message}")]
    PatchInvalidRange {
        /// Human-readable reason.
        message: String,
    },
    /// A body written to `.notedthat/manifest.json` is not the manifest startup
    /// would accept: not a manifest document, naming another knowledge base, or
    /// outside the limits `KbManifest::validate` enforces. The message is the
    /// one the refused boot would have printed.
    #[error("invalid manifest: {message}")]
    InvalidManifest {
        /// Human-readable reason, as startup would report it.
        message: String,
    },
    /// Replace operation found no occurrence of the requested old string.
    #[error("replace: no match found for old_string")]
    ReplaceNoMatch,
    /// Replace operation found multiple occurrences, making single replace ambiguous.
    #[error("replace: found {count} matches; use replace_all to replace them all")]
    ReplaceAmbiguous {
        /// Number of matches found in the object body.
        count: u64,
    },
}

impl From<StorageError> for WriteError {
    fn from(err: StorageError) -> Self {
        Self::Storage(err)
    }
}

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

    #[test]
    fn patch_too_large_displays_size_limit_when_constructed() {
        let err = WriteError::PatchTooLarge {
            size: 200 * 1024 * 1024,
            limit: 100 * 1024 * 1024,
        };

        assert!(err.to_string().contains("too large"));
    }

    #[test]
    fn patch_line_out_of_range_constructs_with_line_and_byte_totals() {
        let err = WriteError::PatchLineOutOfRange {
            first: 999,
            last: 1000,
            total_lines: 20,
            total_bytes: 100,
        };

        assert!(err.to_string().contains("out of range"));
    }

    #[test]
    fn patch_invalid_range_displays_reason_when_constructed() {
        let err = WriteError::PatchInvalidRange {
            message: "test".into(),
        };

        assert!(err.to_string().contains("invalid patch"));
    }

    #[test]
    fn replace_ambiguous_displays_match_count_when_constructed() {
        let err = WriteError::ReplaceAmbiguous { count: 3 };

        assert_eq!(
            err.to_string(),
            "replace: found 3 matches; use replace_all to replace them all"
        );
    }
}