Skip to main content

brows3r_lib/commands/
diff_cmd.rs

1//! Tauri commands for the diff preview / confirmation framework.
2//!
3//! # Commands
4//!
5//! - [`diff_preview_create`] — create a new pending diff; returns [`DiffId`].
6//! - [`diff_preview_cancel`] — cancel a pending diff; voids future confirms.
7//!
8//! # OCP
9//!
10//! The `kind` discriminator in `diff_preview_create` is a string that maps to
11//! a [`DiffPayload`] variant.  Adding a new kind = one new parse branch in the
12//! match below + one new enum variant in `diff/mod.rs`.  Existing kinds are
13//! unaffected.
14
15use serde_json::Value;
16use tauri::State;
17
18use crate::{
19    diff::{DiffId, DiffObjectRef, DiffPayload, DiffStoreHandle},
20    error::AppError,
21    ids::BucketId,
22};
23
24// ---------------------------------------------------------------------------
25// diff_preview_create
26// ---------------------------------------------------------------------------
27
28/// Create a pending diff record and return its [`DiffId`].
29///
30/// # Parameters
31///
32/// - `kind`    — Must be `"storage_class"` in v1.  Other values are rejected
33///               with `AppError::Validation`.
34/// - `payload` — JSON-encoded payload matching the schema for the given kind.
35/// - `store`   — The managed [`DiffStoreHandle`].
36///
37/// # Payload schema (kind = "storage_class")
38///
39/// ```json
40/// {
41///   "targets": [{ "bucket": "my-bucket", "key": "photos/img.jpg" }],
42///   "current": { "photos/img.jpg": "STANDARD" },
43///   "new_class": "GLACIER"
44/// }
45/// ```
46///
47/// # OCP
48///
49/// New kinds are added as new `match` arms.  The `kind` string is the only
50/// discriminator — no other caller code changes.
51#[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
73/// Parse the raw `payload` JSON into [`DiffPayload::StorageClass`].
74fn parse_storage_class_payload(v: Value) -> Result<DiffPayload, AppError> {
75    // targets: Vec<{ bucket, key }>
76    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    // current: HashMap<String, String>
107    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    // new_class: String
125    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// ---------------------------------------------------------------------------
150// diff_preview_cancel
151// ---------------------------------------------------------------------------
152
153/// Cancel a pending diff record, voiding any future confirm attempts.
154///
155/// After cancellation, `object_set_storage_class` (or any other command that
156/// calls `DiffStore::consume`) will receive `None` and must return
157/// `AppError::Validation { hint: "Diff was cancelled or expired" }`.
158///
159/// Returns `AppError::NotFound` when the `diff_id` does not exist.
160#[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// ---------------------------------------------------------------------------
169// Tests
170// ---------------------------------------------------------------------------
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::diff::{DiffPayload, DiffStatus, DiffStore};
176    use std::collections::HashMap;
177
178    // -----------------------------------------------------------------------
179    // parse_storage_class_payload
180    // -----------------------------------------------------------------------
181
182    #[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        // Frontend sends camelCase; accept both.
210        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    // -----------------------------------------------------------------------
244    // diff_preview_cancel — unit test on store directly
245    // -----------------------------------------------------------------------
246
247    #[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    // -----------------------------------------------------------------------
278    // Unknown kind returns Validation error
279    // -----------------------------------------------------------------------
280
281    #[test]
282    fn unknown_kind_returns_validation_error_in_parse_path() {
283        // We simulate the kind check directly since we can't call the async
284        // command without a Tauri State wrapper in unit tests.
285        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}