brows3r_lib/diff/mod.rs
1//! Diff preview / confirmation framework.
2//!
3//! # Overview
4//!
5//! High-impact property edits (storage class change in v1; future: lifecycle,
6//! ACL, metadata bulk-edit) go through a two-phase flow:
7//!
8//! 1. **Create**: caller invokes `diff_preview_create` with a description of
9//! the proposed change → backend persists a [`DiffRecord`] in the
10//! [`DiffStore`] and returns a [`DiffId`].
11//!
12//! 2. **Confirm or Cancel**:
13//! - Confirm: the mutating command (e.g. `object_set_storage_class`) calls
14//! [`DiffStore::consume`] with the id. Consume succeeds only once and
15//! only for records in the `Pending` state.
16//! - Cancel: `diff_preview_cancel` sets the status to `Cancelled`.
17//! Any subsequent `consume` call returns `None`.
18//!
19//! # OCP
20//!
21//! - [`DiffPayload`] is open for new kinds (metadata bulk-edit, ACL change).
22//! Adding a new kind is one new variant here + one new parsing branch in
23//! `diff_cmd.rs` + one new rendering branch in the frontend modal.
24//! - [`DiffStore::consume`] is the single safety gate. Every mutating
25//! command that uses the diff framework must call `consume` — it is the
26//! authoritative check for cancelled/expired/double-confirm rejection.
27
28use std::{
29 collections::HashMap,
30 sync::{Arc, RwLock},
31};
32
33use serde::{Deserialize, Serialize};
34use uuid::Uuid;
35
36use crate::{error::AppError, ids::BucketId};
37
38// ---------------------------------------------------------------------------
39// DiffId
40// ---------------------------------------------------------------------------
41
42/// Opaque diff identifier — a UUID v4 string.
43///
44/// Serialises as a transparent string so the IPC layer sees a bare UUID,
45/// e.g. `"550e8400-e29b-41d4-a716-446655440000"`.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[serde(transparent)]
48pub struct DiffId(String);
49
50impl DiffId {
51 /// Create a fresh random `DiffId`.
52 pub fn new_v4() -> Self {
53 Self(Uuid::new_v4().to_string())
54 }
55
56 /// Borrow the inner string slice.
57 pub fn as_str(&self) -> &str {
58 &self.0
59 }
60}
61
62impl std::fmt::Display for DiffId {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.write_str(&self.0)
65 }
66}
67
68impl From<String> for DiffId {
69 fn from(s: String) -> Self {
70 Self(s)
71 }
72}
73
74impl From<&str> for DiffId {
75 fn from(s: &str) -> Self {
76 Self(s.to_owned())
77 }
78}
79
80// ---------------------------------------------------------------------------
81// ObjectRef (re-used in DiffPayload)
82// ---------------------------------------------------------------------------
83
84/// A reference to a single S3 object used in diff payloads.
85///
86/// Mirrors `commands::objects_cmd::ObjectRef` but lives here to avoid a
87/// circular dependency between `diff` and `commands`.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct DiffObjectRef {
91 pub bucket: BucketId,
92 pub key: String,
93}
94
95// ---------------------------------------------------------------------------
96// DiffPayload
97// ---------------------------------------------------------------------------
98
99/// Describes what change a diff is proposing.
100///
101/// OCP: new variants can be added for future high-impact edits (metadata
102/// bulk-edit, ACL change, lifecycle configuration) without changing the
103/// existing `StorageClass` variant or the store machinery.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105#[serde(tag = "kind", rename_all = "snake_case")]
106#[serde(rename_all_fields = "camelCase")]
107pub enum DiffPayload {
108 /// v1 trigger: change the storage class of one or more objects.
109 StorageClass {
110 /// The objects whose storage class will be changed.
111 targets: Vec<DiffObjectRef>,
112 /// Map of `key → current_storage_class` (from object listing / HEAD).
113 current: HashMap<String, String>,
114 /// The new storage class value (e.g. `"GLACIER"`, `"STANDARD_IA"`).
115 new_class: String,
116 },
117}
118
119// ---------------------------------------------------------------------------
120// DiffStatus
121// ---------------------------------------------------------------------------
122
123/// Lifecycle status of a diff record.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum DiffStatus {
127 /// Created and waiting for a confirm or cancel.
128 Pending,
129 /// The mutating command consumed the diff — change was applied.
130 Confirmed,
131 /// User explicitly cancelled from the diff preview modal.
132 Cancelled,
133 /// TTL elapsed without confirm or cancel.
134 Expired,
135}
136
137// ---------------------------------------------------------------------------
138// DiffRecord
139// ---------------------------------------------------------------------------
140
141/// A persisted diff entry in the [`DiffStore`].
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct DiffRecord {
145 pub id: DiffId,
146 pub payload: DiffPayload,
147 /// Unix timestamp (seconds) when the record was created.
148 pub created_at: i64,
149 /// Unix timestamp (seconds) after which the record is considered expired.
150 pub expires_at: i64,
151 pub status: DiffStatus,
152}
153
154// ---------------------------------------------------------------------------
155// DiffStore
156// ---------------------------------------------------------------------------
157
158/// Default TTL for diff records: 5 minutes.
159pub const DEFAULT_DIFF_TTL_SECS: i64 = 300;
160
161/// In-memory store for pending diff records.
162///
163/// Backed by a `RwLock<HashMap>` so reads (polling, rendering the modal) are
164/// non-blocking. Mutations take a write lock.
165///
166/// The store is intentionally not persisted to disk — if the app restarts,
167/// pending diffs are lost and the user must start the flow again.
168#[derive(Debug, Default)]
169pub struct DiffStore {
170 inner: RwLock<HashMap<DiffId, DiffRecord>>,
171 ttl_secs: i64,
172}
173
174impl DiffStore {
175 /// Create a new store with the default 5-minute TTL.
176 pub fn new() -> Self {
177 Self {
178 inner: RwLock::new(HashMap::new()),
179 ttl_secs: DEFAULT_DIFF_TTL_SECS,
180 }
181 }
182
183 /// Create a store with a custom TTL (useful in tests with a mock clock).
184 pub fn with_ttl(ttl_secs: i64) -> Self {
185 Self {
186 inner: RwLock::new(HashMap::new()),
187 ttl_secs,
188 }
189 }
190
191 // -----------------------------------------------------------------------
192 // Internal timestamp helper — injectable for tests
193 // -----------------------------------------------------------------------
194
195 /// Return current Unix time in seconds.
196 ///
197 /// Using `SystemTime` directly. Test overrides pass explicit timestamps
198 /// through the `create_at` parameter variant.
199 fn now_secs() -> i64 {
200 std::time::SystemTime::now()
201 .duration_since(std::time::UNIX_EPOCH)
202 .unwrap_or_default()
203 .as_secs() as i64
204 }
205
206 // -----------------------------------------------------------------------
207 // Public API
208 // -----------------------------------------------------------------------
209
210 /// Create a new diff record and return its [`DiffId`].
211 ///
212 /// The record starts in [`DiffStatus::Pending`].
213 pub fn create(&self, payload: DiffPayload) -> DiffId {
214 self.create_at(payload, Self::now_secs())
215 }
216
217 /// Create a diff record at an explicit timestamp (test helper).
218 pub fn create_at(&self, payload: DiffPayload, now: i64) -> DiffId {
219 let id = DiffId::new_v4();
220 let record = DiffRecord {
221 id: id.clone(),
222 payload,
223 created_at: now,
224 expires_at: now + self.ttl_secs,
225 status: DiffStatus::Pending,
226 };
227 let mut map = self.inner.write().expect("diff store write lock poisoned");
228 map.insert(id.clone(), record);
229 id
230 }
231
232 /// Set the status of a diff record to [`DiffStatus::Cancelled`].
233 ///
234 /// A cancelled record's `consume` will subsequently return `None` (the
235 /// `Validation` error path in `object_set_storage_class`).
236 ///
237 /// Returns `AppError::NotFound` when the id does not exist.
238 pub fn cancel(&self, id: &DiffId) -> Result<(), AppError> {
239 let mut map = self.inner.write().expect("diff store write lock poisoned");
240 match map.get_mut(id) {
241 Some(record) => {
242 record.status = DiffStatus::Cancelled;
243 Ok(())
244 }
245 None => Err(AppError::NotFound {
246 resource: format!("diff:{}", id.as_str()),
247 }),
248 }
249 }
250
251 /// Consume a diff record on confirmation.
252 ///
253 /// Returns `Some(payload)` exactly once for a record in `Pending` state
254 /// that has not yet expired. Sets the status to `Confirmed`.
255 ///
256 /// Returns `None` when:
257 /// - The record does not exist.
258 /// - The record was cancelled.
259 /// - The record is expired (checked against wall clock).
260 /// - The record was already consumed (double-confirm rejection).
261 pub fn consume(&self, id: &DiffId) -> Option<DiffPayload> {
262 self.consume_at(id, Self::now_secs())
263 }
264
265 /// Consume at an explicit timestamp (test helper).
266 pub fn consume_at(&self, id: &DiffId, now: i64) -> Option<DiffPayload> {
267 let mut map = self.inner.write().expect("diff store write lock poisoned");
268 let record = map.get_mut(id)?;
269
270 // Expire on demand.
271 if record.status == DiffStatus::Pending && now >= record.expires_at {
272 record.status = DiffStatus::Expired;
273 }
274
275 if record.status != DiffStatus::Pending {
276 return None;
277 }
278
279 record.status = DiffStatus::Confirmed;
280 Some(record.payload.clone())
281 }
282
283 /// Read a diff record without consuming it.
284 ///
285 /// Returns `None` when the id does not exist. Does not expire the record.
286 pub fn get(&self, id: &DiffId) -> Option<DiffRecord> {
287 let map = self.inner.read().expect("diff store read lock poisoned");
288 map.get(id).cloned()
289 }
290
291 /// Sweep expired records from the store.
292 ///
293 /// Called periodically by a background task (or on demand in tests).
294 /// Records whose `expires_at` has elapsed are removed rather than merely
295 /// flagged so the map does not grow unbounded.
296 pub fn gc(&self) {
297 self.gc_at(Self::now_secs())
298 }
299
300 /// GC at an explicit timestamp (test helper).
301 pub fn gc_at(&self, now: i64) {
302 let mut map = self.inner.write().expect("diff store write lock poisoned");
303 map.retain(|_, record| {
304 // Keep Confirmed and Cancelled records for a grace period so the
305 // frontend can still read them (e.g. to show "already confirmed").
306 // Only fully expired Pending records are swept immediately.
307 !(record.status == DiffStatus::Pending && now >= record.expires_at)
308 });
309 }
310}
311
312// ---------------------------------------------------------------------------
313// DiffStoreHandle — Arc-wrapped state handle for Tauri
314// ---------------------------------------------------------------------------
315
316/// `Arc`-wrapped [`DiffStore`] managed by Tauri.
317///
318/// Cloneable so it can be passed to multiple commands simultaneously.
319#[derive(Debug, Clone)]
320pub struct DiffStoreHandle {
321 pub inner: Arc<DiffStore>,
322}
323
324impl DiffStoreHandle {
325 pub fn new(store: DiffStore) -> Self {
326 Self {
327 inner: Arc::new(store),
328 }
329 }
330}
331
332impl Default for DiffStoreHandle {
333 fn default() -> Self {
334 Self::new(DiffStore::new())
335 }
336}
337
338// ---------------------------------------------------------------------------
339// Tests
340// ---------------------------------------------------------------------------
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use std::collections::HashMap;
346
347 // -----------------------------------------------------------------------
348 // Fixtures
349 // -----------------------------------------------------------------------
350
351 fn make_storage_class_payload(targets: &[(&str, &str)], new_class: &str) -> DiffPayload {
352 let target_refs = targets
353 .iter()
354 .map(|(bucket, key)| DiffObjectRef {
355 bucket: BucketId::new(bucket.to_string()),
356 key: key.to_string(),
357 })
358 .collect();
359 let current: HashMap<String, String> = targets
360 .iter()
361 .map(|(_, key)| (key.to_string(), "STANDARD".to_string()))
362 .collect();
363 DiffPayload::StorageClass {
364 targets: target_refs,
365 current,
366 new_class: new_class.to_string(),
367 }
368 }
369
370 // -----------------------------------------------------------------------
371 // DiffId
372 // -----------------------------------------------------------------------
373
374 #[test]
375 fn diff_id_new_v4_produces_unique_ids() {
376 let a = DiffId::new_v4();
377 let b = DiffId::new_v4();
378 assert_ne!(a, b);
379 }
380
381 #[test]
382 fn diff_id_serialises_as_transparent_string() {
383 let id = DiffId::from("abc-123");
384 let v = serde_json::to_value(&id).unwrap();
385 assert_eq!(v, "abc-123");
386 }
387
388 #[test]
389 fn diff_id_deserialises_from_string() {
390 let id: DiffId = serde_json::from_str("\"test-id\"").unwrap();
391 assert_eq!(id.as_str(), "test-id");
392 }
393
394 // -----------------------------------------------------------------------
395 // DiffPayload serialisation
396 // -----------------------------------------------------------------------
397
398 #[test]
399 fn diff_payload_storage_class_serialises_with_kind_tag() {
400 let payload = make_storage_class_payload(&[("my-bucket", "photos/img.jpg")], "GLACIER");
401 let v = serde_json::to_value(&payload).unwrap();
402 assert_eq!(v["kind"], "storage_class");
403 assert_eq!(v["newClass"], "GLACIER");
404 assert!(v["targets"].is_array());
405 }
406
407 // -----------------------------------------------------------------------
408 // DiffStore::create
409 // -----------------------------------------------------------------------
410
411 #[test]
412 fn create_returns_unique_ids() {
413 let store = DiffStore::new();
414 let p1 = make_storage_class_payload(&[("b", "k")], "GLACIER");
415 let p2 = make_storage_class_payload(&[("b", "k2")], "STANDARD_IA");
416 let id1 = store.create(p1);
417 let id2 = store.create(p2);
418 assert_ne!(id1, id2);
419 }
420
421 #[test]
422 fn create_stores_record_as_pending() {
423 let store = DiffStore::new();
424 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
425 let id = store.create(p);
426 let record = store.get(&id).expect("record must exist");
427 assert_eq!(record.status, DiffStatus::Pending);
428 }
429
430 #[test]
431 fn create_stores_correct_ttl() {
432 let ttl = 60_i64;
433 let store = DiffStore::with_ttl(ttl);
434 let now = 1_000_000_i64;
435 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
436 let id = store.create_at(p, now);
437 let record = store.get(&id).unwrap();
438 assert_eq!(record.expires_at, now + ttl);
439 }
440
441 // -----------------------------------------------------------------------
442 // DiffStore::consume — happy path
443 // -----------------------------------------------------------------------
444
445 #[test]
446 fn consume_returns_payload_and_marks_confirmed() {
447 let store = DiffStore::new();
448 let p = make_storage_class_payload(&[("bucket", "key.txt")], "GLACIER");
449 let id = store.create(p.clone());
450
451 let consumed = store.consume(&id).expect("consume must succeed");
452 assert_eq!(consumed, p);
453
454 let record = store.get(&id).unwrap();
455 assert_eq!(record.status, DiffStatus::Confirmed);
456 }
457
458 // -----------------------------------------------------------------------
459 // DiffStore::consume — double-confirm rejection
460 // -----------------------------------------------------------------------
461
462 #[test]
463 fn consume_second_call_returns_none() {
464 let store = DiffStore::new();
465 let p = make_storage_class_payload(&[("b", "k")], "STANDARD_IA");
466 let id = store.create(p);
467
468 let first = store.consume(&id);
469 let second = store.consume(&id);
470
471 assert!(first.is_some(), "first consume must succeed");
472 assert!(
473 second.is_none(),
474 "second consume must be rejected (double-confirm)"
475 );
476 }
477
478 // -----------------------------------------------------------------------
479 // DiffStore::cancel
480 // -----------------------------------------------------------------------
481
482 #[test]
483 fn cancel_sets_status_to_cancelled() {
484 let store = DiffStore::new();
485 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
486 let id = store.create(p);
487
488 store.cancel(&id).unwrap();
489
490 let record = store.get(&id).unwrap();
491 assert_eq!(record.status, DiffStatus::Cancelled);
492 }
493
494 #[test]
495 fn consume_after_cancel_returns_none() {
496 let store = DiffStore::new();
497 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
498 let id = store.create(p);
499
500 store.cancel(&id).unwrap();
501 let result = store.consume(&id);
502
503 assert!(result.is_none(), "consume after cancel must return None");
504 }
505
506 #[test]
507 fn cancel_nonexistent_id_returns_not_found() {
508 let store = DiffStore::new();
509 let fake = DiffId::from("does-not-exist");
510 let result = store.cancel(&fake);
511 assert!(matches!(result, Err(AppError::NotFound { .. })));
512 }
513
514 // -----------------------------------------------------------------------
515 // DiffStore expiry
516 // -----------------------------------------------------------------------
517
518 #[test]
519 fn consume_after_expiry_returns_none() {
520 let store = DiffStore::with_ttl(10);
521 let now = 1_000_000_i64;
522 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
523 let id = store.create_at(p, now);
524
525 // Try to consume 11 seconds after creation — TTL was 10 s.
526 let result = store.consume_at(&id, now + 11);
527 assert!(result.is_none(), "consume past TTL must fail");
528
529 let record = store.get(&id).unwrap();
530 assert_eq!(record.status, DiffStatus::Expired);
531 }
532
533 #[test]
534 fn consume_at_exact_expiry_boundary_fails() {
535 let store = DiffStore::with_ttl(10);
536 let now = 1_000_000_i64;
537 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
538 let id = store.create_at(p, now);
539
540 // expires_at = now + 10 → consuming exactly at that second is expired.
541 let result = store.consume_at(&id, now + 10);
542 assert!(result.is_none(), "consume at exact expiry must fail");
543 }
544
545 #[test]
546 fn consume_before_expiry_succeeds() {
547 let store = DiffStore::with_ttl(10);
548 let now = 1_000_000_i64;
549 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
550 let id = store.create_at(p, now);
551
552 let result = store.consume_at(&id, now + 9);
553 assert!(result.is_some(), "consume before expiry must succeed");
554 }
555
556 // -----------------------------------------------------------------------
557 // DiffStore::gc
558 // -----------------------------------------------------------------------
559
560 #[test]
561 fn gc_removes_expired_pending_records() {
562 let store = DiffStore::with_ttl(10);
563 let now = 1_000_000_i64;
564 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
565 let id = store.create_at(p, now);
566
567 // GC past expiry.
568 store.gc_at(now + 11);
569 let record = store.get(&id);
570 assert!(record.is_none(), "expired record must be removed by gc");
571 }
572
573 #[test]
574 fn gc_does_not_remove_confirmed_records() {
575 let store = DiffStore::with_ttl(10);
576 let now = 1_000_000_i64;
577 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
578 let id = store.create_at(p, now);
579
580 store.consume_at(&id, now + 1).unwrap();
581 store.gc_at(now + 11);
582
583 // Confirmed records are kept (so the frontend can still read them).
584 let record = store.get(&id);
585 assert!(record.is_some(), "confirmed record must survive gc");
586 }
587
588 #[test]
589 fn gc_does_not_remove_cancelled_records() {
590 let store = DiffStore::with_ttl(10);
591 let now = 1_000_000_i64;
592 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
593 let id = store.create_at(p, now);
594
595 store.cancel(&id).unwrap();
596 store.gc_at(now + 11);
597
598 // Cancelled records are kept for the same reason.
599 let record = store.get(&id);
600 assert!(record.is_some(), "cancelled record must survive gc");
601 }
602
603 // -----------------------------------------------------------------------
604 // DiffStoreHandle — Arc clone is the same store
605 // -----------------------------------------------------------------------
606
607 #[test]
608 fn diff_store_handle_clones_share_state() {
609 let handle = DiffStoreHandle::default();
610 let clone = handle.clone();
611
612 let p = make_storage_class_payload(&[("b", "k")], "GLACIER");
613 let id = handle.inner.create(p);
614
615 // The clone must see the record created through the original handle.
616 let record = clone.inner.get(&id);
617 assert!(record.is_some(), "cloned handle must see same store");
618 }
619}