Skip to main content

rustfs_cli/output/
v3.rs

1//! Shared JSON output v3 envelopes for newly versioned command families.
2
3use serde::Serialize;
4
5use crate::exit_code::ExitCode;
6
7const VERSIONED_OBJECTS_FAMILY: &str = "versioned_objects";
8const LOCKS_FAMILY: &str = "locks";
9const BUCKET_OPERATIONS_FAMILY: &str = "bucket_operations";
10
11#[derive(Debug, Serialize)]
12pub struct V3SuccessEnvelope<T> {
13    schema_version: u8,
14    #[serde(rename = "type")]
15    family: &'static str,
16    status: &'static str,
17    data: T,
18}
19
20impl<T> V3SuccessEnvelope<T> {
21    pub fn versioned_objects(data: T) -> Self {
22        Self {
23            schema_version: 3,
24            family: VERSIONED_OBJECTS_FAMILY,
25            status: "success",
26            data,
27        }
28    }
29
30    pub fn locks(data: T) -> Self {
31        Self {
32            schema_version: 3,
33            family: LOCKS_FAMILY,
34            status: "success",
35            data,
36        }
37    }
38
39    pub fn bucket_operations(data: T) -> Self {
40        Self {
41            schema_version: 3,
42            family: BUCKET_OPERATIONS_FAMILY,
43            status: "success",
44            data,
45        }
46    }
47}
48
49#[derive(Debug, Serialize)]
50pub struct V3ErrorEnvelope {
51    schema_version: u8,
52    #[serde(rename = "type")]
53    family: &'static str,
54    status: &'static str,
55    error: V3ErrorDetail,
56}
57
58impl V3ErrorEnvelope {
59    pub fn versioned_objects(
60        code: ExitCode,
61        message: impl Into<String>,
62        capability: Option<&str>,
63    ) -> Self {
64        Self {
65            schema_version: 3,
66            family: VERSIONED_OBJECTS_FAMILY,
67            status: "error",
68            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
69        }
70    }
71
72    pub fn locks(code: ExitCode, message: impl Into<String>, capability: Option<&str>) -> Self {
73        Self {
74            schema_version: 3,
75            family: LOCKS_FAMILY,
76            status: "error",
77            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
78        }
79    }
80
81    pub fn bucket_operations(
82        code: ExitCode,
83        message: impl Into<String>,
84        capability: Option<&str>,
85    ) -> Self {
86        Self {
87            schema_version: 3,
88            family: BUCKET_OPERATIONS_FAMILY,
89            status: "error",
90            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
91        }
92    }
93}
94
95#[derive(Debug, Serialize)]
96pub struct V3PartialErrorEnvelope<T> {
97    schema_version: u8,
98    #[serde(rename = "type")]
99    family: &'static str,
100    status: &'static str,
101    error: V3ErrorDetail,
102    data: T,
103}
104
105impl<T> V3PartialErrorEnvelope<T> {
106    pub fn versioned_objects(
107        code: ExitCode,
108        message: impl Into<String>,
109        capability: Option<&str>,
110        data: T,
111    ) -> Self {
112        Self {
113            schema_version: 3,
114            family: VERSIONED_OBJECTS_FAMILY,
115            status: "error",
116            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
117            data,
118        }
119    }
120
121    pub fn bucket_operations(
122        code: ExitCode,
123        message: impl Into<String>,
124        capability: Option<&str>,
125        data: T,
126    ) -> Self {
127        Self {
128            schema_version: 3,
129            family: BUCKET_OPERATIONS_FAMILY,
130            status: "error",
131            error: V3ErrorDetail::from_exit_code(code, message.into(), capability),
132            data,
133        }
134    }
135}
136
137#[derive(Debug, Serialize)]
138#[serde(untagged)]
139enum V3ErrorDetail {
140    Standard(V3StandardError),
141    Unsupported(V3UnsupportedError),
142}
143
144impl V3ErrorDetail {
145    fn from_exit_code(code: ExitCode, message: String, capability: Option<&str>) -> Self {
146        let (error_type, retryable, suggestion) = match code {
147            ExitCode::UnsupportedFeature => {
148                return Self::Unsupported(V3UnsupportedError {
149                    error_type: "unsupported_feature",
150                    message,
151                    retryable: false,
152                    capability: capability.unwrap_or("versioned_objects").to_string(),
153                    server: None,
154                    suggestion: Some(
155                        "Verify that the target RustFS version supports this operation."
156                            .to_string(),
157                    ),
158                });
159            }
160            ExitCode::Success => ("general_error", false, None),
161            ExitCode::GeneralError => ("general_error", false, None),
162            ExitCode::UsageError => (
163                "usage_error",
164                false,
165                Some("Review the command arguments and retry.".to_string()),
166            ),
167            ExitCode::NetworkError => (
168                "network_error",
169                true,
170                Some("Verify the endpoint and network connectivity, then retry.".to_string()),
171            ),
172            ExitCode::AuthError => (
173                "auth_error",
174                false,
175                Some("Verify the alias credentials and permissions, then retry.".to_string()),
176            ),
177            ExitCode::NotFound => (
178                "not_found",
179                false,
180                Some("Check the bucket, object key, and version ID, then retry.".to_string()),
181            ),
182            ExitCode::Conflict => (
183                "conflict",
184                false,
185                Some("Review the version state or retention policy, then retry.".to_string()),
186            ),
187            ExitCode::Interrupted => (
188                "interrupted",
189                true,
190                Some("Retry if the operation still needs to complete.".to_string()),
191            ),
192        };
193
194        Self::Standard(V3StandardError {
195            error_type,
196            message,
197            retryable,
198            suggestion,
199        })
200    }
201}
202
203#[derive(Debug, Serialize)]
204struct V3StandardError {
205    #[serde(rename = "type")]
206    error_type: &'static str,
207    message: String,
208    retryable: bool,
209    suggestion: Option<String>,
210}
211
212#[derive(Debug, Serialize)]
213struct V3UnsupportedError {
214    #[serde(rename = "type")]
215    error_type: &'static str,
216    message: String,
217    retryable: bool,
218    capability: String,
219    server: Option<String>,
220    suggestion: Option<String>,
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn versioned_success_uses_the_v3_envelope() {
229        let value = serde_json::to_value(V3SuccessEnvelope::versioned_objects(
230            serde_json::json!({ "operation": "copy" }),
231        ))
232        .expect("serialize v3 success envelope");
233
234        assert_eq!(value["schema_version"], 3);
235        assert_eq!(value["type"], "versioned_objects");
236        assert_eq!(value["status"], "success");
237    }
238
239    #[test]
240    fn versioned_errors_use_stable_error_kinds() {
241        let value = serde_json::to_value(V3ErrorEnvelope::versioned_objects(
242            ExitCode::AuthError,
243            "Access denied",
244            None,
245        ))
246        .expect("serialize v3 error envelope");
247
248        assert_eq!(value["status"], "error");
249        assert_eq!(value["error"]["type"], "auth_error");
250        assert_eq!(value["error"]["retryable"], false);
251    }
252
253    #[test]
254    fn lock_success_and_errors_use_the_locks_family() {
255        let success = serde_json::to_value(V3SuccessEnvelope::locks(
256            serde_json::json!({ "operation": "retention_info" }),
257        ))
258        .expect("serialize lock success envelope");
259        let error = serde_json::to_value(V3ErrorEnvelope::locks(
260            ExitCode::Conflict,
261            "Compliance retention cannot be shortened",
262            Some("object_retention"),
263        ))
264        .expect("serialize lock error envelope");
265
266        assert_eq!(success["type"], "locks");
267        assert_eq!(error["type"], "locks");
268        assert_eq!(error["error"]["type"], "conflict");
269    }
270
271    #[test]
272    fn bucket_operation_envelopes_preserve_partial_stage_data() {
273        let success = serde_json::to_value(V3SuccessEnvelope::bucket_operations(
274            serde_json::json!({ "operation": "create" }),
275        ))
276        .expect("serialize bucket operation success");
277        let partial = serde_json::to_value(V3PartialErrorEnvelope::bucket_operations(
278            ExitCode::Conflict,
279            "Versioning verification failed",
280            Some("bucket_versioning"),
281            serde_json::json!({ "failed_stage": "verify_versioning" }),
282        ))
283        .expect("serialize bucket operation partial failure");
284
285        assert_eq!(success["type"], "bucket_operations");
286        assert_eq!(partial["type"], "bucket_operations");
287        assert_eq!(partial["data"]["failed_stage"], "verify_versioning");
288    }
289}