Skip to main content

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}