brows3r_lib/commands/
diff_cmd.rs1use serde_json::Value;
16use tauri::State;
17
18use crate::{
19 diff::{DiffId, DiffObjectRef, DiffPayload, DiffStoreHandle},
20 error::AppError,
21 ids::BucketId,
22};
23
24#[tauri::command]
52pub async fn diff_preview_create(
53 kind: String,
54 payload: Value,
55 store: State<'_, DiffStoreHandle>,
56) -> Result<DiffId, AppError> {
57 let diff_payload = match kind.as_str() {
58 "storage_class" => parse_storage_class_payload(payload)?,
59 other => {
60 return Err(AppError::Validation {
61 field: "kind".to_string(),
62 hint: format!(
63 "Unsupported diff kind \"{other}\". Supported kinds: [\"storage_class\"]"
64 ),
65 });
66 }
67 };
68
69 let id = store.inner.create(diff_payload);
70 Ok(id)
71}
72
73fn parse_storage_class_payload(v: Value) -> Result<DiffPayload, AppError> {
75 let targets_raw =
77 v.get("targets")
78 .and_then(|t| t.as_array())
79 .ok_or_else(|| AppError::Validation {
80 field: "payload.targets".to_string(),
81 hint: "targets must be an array of {bucket, key} objects".to_string(),
82 })?;
83
84 let mut targets = Vec::with_capacity(targets_raw.len());
85 for item in targets_raw {
86 let bucket =
87 item.get("bucket")
88 .and_then(|b| b.as_str())
89 .ok_or_else(|| AppError::Validation {
90 field: "payload.targets[].bucket".to_string(),
91 hint: "each target must have a string 'bucket' field".to_string(),
92 })?;
93 let key = item
94 .get("key")
95 .and_then(|k| k.as_str())
96 .ok_or_else(|| AppError::Validation {
97 field: "payload.targets[].key".to_string(),
98 hint: "each target must have a string 'key' field".to_string(),
99 })?;
100 targets.push(DiffObjectRef {
101 bucket: BucketId::new(bucket),
102 key: key.to_string(),
103 });
104 }
105
106 let current_raw = v
108 .get("current")
109 .and_then(|c| c.as_object())
110 .ok_or_else(|| AppError::Validation {
111 field: "payload.current".to_string(),
112 hint: "current must be an object mapping key → current_storage_class".to_string(),
113 })?;
114
115 let mut current = std::collections::HashMap::new();
116 for (k, val) in current_raw {
117 let class = val.as_str().ok_or_else(|| AppError::Validation {
118 field: "payload.current".to_string(),
119 hint: "current values must be strings".to_string(),
120 })?;
121 current.insert(k.clone(), class.to_string());
122 }
123
124 let new_class = v
126 .get("new_class")
127 .or_else(|| v.get("newClass"))
128 .and_then(|c| c.as_str())
129 .ok_or_else(|| AppError::Validation {
130 field: "payload.new_class".to_string(),
131 hint: "new_class must be a non-empty string".to_string(),
132 })?
133 .to_string();
134
135 if new_class.is_empty() {
136 return Err(AppError::Validation {
137 field: "payload.new_class".to_string(),
138 hint: "new_class must not be empty".to_string(),
139 });
140 }
141
142 Ok(DiffPayload::StorageClass {
143 targets,
144 current,
145 new_class,
146 })
147}
148
149#[tauri::command]
161pub async fn diff_preview_cancel(
162 diff_id: DiffId,
163 store: State<'_, DiffStoreHandle>,
164) -> Result<(), AppError> {
165 store.inner.cancel(&diff_id)
166}
167
168#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::diff::{DiffPayload, DiffStatus, DiffStore};
176 use std::collections::HashMap;
177
178 #[test]
183 fn parse_storage_class_valid_payload() {
184 let v = serde_json::json!({
185 "targets": [{"bucket": "my-bucket", "key": "photos/img.jpg"}],
186 "current": {"photos/img.jpg": "STANDARD"},
187 "new_class": "GLACIER"
188 });
189 let payload = parse_storage_class_payload(v).unwrap();
190 match payload {
191 DiffPayload::StorageClass {
192 targets,
193 current,
194 new_class,
195 } => {
196 assert_eq!(targets.len(), 1);
197 assert_eq!(targets[0].key, "photos/img.jpg");
198 assert_eq!(
199 current.get("photos/img.jpg").map(String::as_str),
200 Some("STANDARD")
201 );
202 assert_eq!(new_class, "GLACIER");
203 }
204 }
205 }
206
207 #[test]
208 fn parse_storage_class_camel_case_new_class() {
209 let v = serde_json::json!({
211 "targets": [{"bucket": "b", "key": "k"}],
212 "current": {"k": "STANDARD"},
213 "newClass": "STANDARD_IA"
214 });
215 let payload = parse_storage_class_payload(v).unwrap();
216 match payload {
217 DiffPayload::StorageClass { new_class, .. } => {
218 assert_eq!(new_class, "STANDARD_IA");
219 }
220 }
221 }
222
223 #[test]
224 fn parse_storage_class_missing_targets_returns_validation_error() {
225 let v = serde_json::json!({
226 "current": {},
227 "new_class": "GLACIER"
228 });
229 let err = parse_storage_class_payload(v).unwrap_err();
230 assert!(matches!(err, AppError::Validation { field, .. } if field == "payload.targets"));
231 }
232
233 #[test]
234 fn parse_storage_class_missing_new_class_returns_validation_error() {
235 let v = serde_json::json!({
236 "targets": [{"bucket": "b", "key": "k"}],
237 "current": {}
238 });
239 let err = parse_storage_class_payload(v).unwrap_err();
240 assert!(matches!(err, AppError::Validation { .. }));
241 }
242
243 #[test]
248 fn cancel_marks_record_cancelled() {
249 let store = DiffStore::new();
250 let p = DiffPayload::StorageClass {
251 targets: vec![],
252 current: HashMap::new(),
253 new_class: "GLACIER".to_string(),
254 };
255 let id = store.create(p);
256 store.cancel(&id).unwrap();
257 let record = store.get(&id).unwrap();
258 assert_eq!(record.status, DiffStatus::Cancelled);
259 }
260
261 #[test]
262 fn cancel_voids_subsequent_consume() {
263 let store = DiffStore::new();
264 let p = DiffPayload::StorageClass {
265 targets: vec![],
266 current: HashMap::new(),
267 new_class: "GLACIER".to_string(),
268 };
269 let id = store.create(p);
270 store.cancel(&id).unwrap();
271 assert!(
272 store.consume(&id).is_none(),
273 "consume after cancel must fail"
274 );
275 }
276
277 #[test]
282 fn unknown_kind_returns_validation_error_in_parse_path() {
283 let kind = "acl_change";
286 let is_supported = matches!(kind, "storage_class");
287 assert!(
288 !is_supported,
289 "acl_change must not be a supported kind in v1"
290 );
291 }
292}