rustfs-cli 0.1.30

A Rust S3 CLI client for S3-compatible object storage
Documentation
//! Shared JSON output v3 envelopes for newly versioned command families.

use serde::Serialize;

use crate::exit_code::ExitCode;

const VERSIONED_OBJECTS_FAMILY: &str = "versioned_objects";
const LOCKS_FAMILY: &str = "locks";
const BUCKET_OPERATIONS_FAMILY: &str = "bucket_operations";

#[derive(Debug, Serialize)]
pub struct V3SuccessEnvelope<T> {
    schema_version: u8,
    #[serde(rename = "type")]
    family: &'static str,
    status: &'static str,
    data: T,
}

impl<T> V3SuccessEnvelope<T> {
    pub fn versioned_objects(data: T) -> Self {
        Self {
            schema_version: 3,
            family: VERSIONED_OBJECTS_FAMILY,
            status: "success",
            data,
        }
    }

    pub fn locks(data: T) -> Self {
        Self {
            schema_version: 3,
            family: LOCKS_FAMILY,
            status: "success",
            data,
        }
    }

    pub fn bucket_operations(data: T) -> Self {
        Self {
            schema_version: 3,
            family: BUCKET_OPERATIONS_FAMILY,
            status: "success",
            data,
        }
    }
}

#[derive(Debug, Serialize)]
pub struct V3ErrorEnvelope {
    schema_version: u8,
    #[serde(rename = "type")]
    family: &'static str,
    status: &'static str,
    error: V3ErrorDetail,
}

impl V3ErrorEnvelope {
    pub fn versioned_objects(
        code: ExitCode,
        message: impl Into<String>,
        capability: Option<&str>,
    ) -> Self {
        Self {
            schema_version: 3,
            family: VERSIONED_OBJECTS_FAMILY,
            status: "error",
            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
        }
    }

    pub fn locks(code: ExitCode, message: impl Into<String>, capability: Option<&str>) -> Self {
        Self {
            schema_version: 3,
            family: LOCKS_FAMILY,
            status: "error",
            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
        }
    }

    pub fn bucket_operations(
        code: ExitCode,
        message: impl Into<String>,
        capability: Option<&str>,
    ) -> Self {
        Self {
            schema_version: 3,
            family: BUCKET_OPERATIONS_FAMILY,
            status: "error",
            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
        }
    }
}

#[derive(Debug, Serialize)]
pub struct V3PartialErrorEnvelope<T> {
    schema_version: u8,
    #[serde(rename = "type")]
    family: &'static str,
    status: &'static str,
    error: V3ErrorDetail,
    data: T,
}

impl<T> V3PartialErrorEnvelope<T> {
    pub fn versioned_objects(
        code: ExitCode,
        message: impl Into<String>,
        capability: Option<&str>,
        data: T,
    ) -> Self {
        Self {
            schema_version: 3,
            family: VERSIONED_OBJECTS_FAMILY,
            status: "error",
            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
            data,
        }
    }

    pub fn bucket_operations(
        code: ExitCode,
        message: impl Into<String>,
        capability: Option<&str>,
        data: T,
    ) -> Self {
        Self {
            schema_version: 3,
            family: BUCKET_OPERATIONS_FAMILY,
            status: "error",
            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
            data,
        }
    }
}

#[derive(Debug, Serialize)]
#[serde(untagged)]
enum V3ErrorDetail {
    Standard(V3StandardError),
    Unsupported(V3UnsupportedError),
}

impl V3ErrorDetail {
    fn from_exit_code(code: ExitCode, message: String, capability: Option<&str>) -> Self {
        let (error_type, retryable, suggestion) = match code {
            ExitCode::UnsupportedFeature => {
                return Self::Unsupported(V3UnsupportedError {
                    error_type: "unsupported_feature",
                    message,
                    retryable: false,
                    capability: capability.unwrap_or("versioned_objects").to_string(),
                    server: None,
                    suggestion: Some(
                        "Verify that the target RustFS version supports this operation."
                            .to_string(),
                    ),
                });
            }
            ExitCode::Success => ("general_error", false, None),
            ExitCode::GeneralError => ("general_error", false, None),
            ExitCode::UsageError => (
                "usage_error",
                false,
                Some("Review the command arguments and retry.".to_string()),
            ),
            ExitCode::NetworkError => (
                "network_error",
                true,
                Some("Verify the endpoint and network connectivity, then retry.".to_string()),
            ),
            ExitCode::AuthError => (
                "auth_error",
                false,
                Some("Verify the alias credentials and permissions, then retry.".to_string()),
            ),
            ExitCode::NotFound => (
                "not_found",
                false,
                Some("Check the bucket, object key, and version ID, then retry.".to_string()),
            ),
            ExitCode::Conflict => (
                "conflict",
                false,
                Some("Review the version state or retention policy, then retry.".to_string()),
            ),
            ExitCode::Interrupted => (
                "interrupted",
                true,
                Some("Retry if the operation still needs to complete.".to_string()),
            ),
        };

        Self::Standard(V3StandardError {
            error_type,
            message,
            retryable,
            suggestion,
        })
    }
}

#[derive(Debug, Serialize)]
struct V3StandardError {
    #[serde(rename = "type")]
    error_type: &'static str,
    message: String,
    retryable: bool,
    suggestion: Option<String>,
}

#[derive(Debug, Serialize)]
struct V3UnsupportedError {
    #[serde(rename = "type")]
    error_type: &'static str,
    message: String,
    retryable: bool,
    capability: String,
    server: Option<String>,
    suggestion: Option<String>,
}

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

    #[test]
    fn versioned_success_uses_the_v3_envelope() {
        let value = serde_json::to_value(V3SuccessEnvelope::versioned_objects(
            serde_json::json!({ "operation": "copy" }),
        ))
        .expect("serialize v3 success envelope");

        assert_eq!(value["schema_version"], 3);
        assert_eq!(value["type"], "versioned_objects");
        assert_eq!(value["status"], "success");
    }

    #[test]
    fn versioned_errors_use_stable_error_kinds() {
        let value = serde_json::to_value(V3ErrorEnvelope::versioned_objects(
            ExitCode::AuthError,
            "Access denied",
            None,
        ))
        .expect("serialize v3 error envelope");

        assert_eq!(value["status"], "error");
        assert_eq!(value["error"]["type"], "auth_error");
        assert_eq!(value["error"]["retryable"], false);
    }

    #[test]
    fn lock_success_and_errors_use_the_locks_family() {
        let success = serde_json::to_value(V3SuccessEnvelope::locks(
            serde_json::json!({ "operation": "retention_info" }),
        ))
        .expect("serialize lock success envelope");
        let error = serde_json::to_value(V3ErrorEnvelope::locks(
            ExitCode::Conflict,
            "Compliance retention cannot be shortened",
            Some("object_retention"),
        ))
        .expect("serialize lock error envelope");

        assert_eq!(success["type"], "locks");
        assert_eq!(error["type"], "locks");
        assert_eq!(error["error"]["type"], "conflict");
    }

    #[test]
    fn bucket_operation_envelopes_preserve_partial_stage_data() {
        let success = serde_json::to_value(V3SuccessEnvelope::bucket_operations(
            serde_json::json!({ "operation": "create" }),
        ))
        .expect("serialize bucket operation success");
        let partial = serde_json::to_value(V3PartialErrorEnvelope::bucket_operations(
            ExitCode::Conflict,
            "Versioning verification failed",
            Some("bucket_versioning"),
            serde_json::json!({ "failed_stage": "verify_versioning" }),
        ))
        .expect("serialize bucket operation partial failure");

        assert_eq!(success["type"], "bucket_operations");
        assert_eq!(partial["type"], "bucket_operations");
        assert_eq!(partial["data"]["failed_stage"], "verify_versioning");
    }
}