Skip to main content

sdkt_storage/
snapshot.rs

1//! Snapshot-diffing and TTL extension-plan derivation for contract storage.
2//!
3//! # Overview
4//!
5//! A [`StorageSnapshot`] is a point-in-time capture of a contract's storage
6//! entries (key + TTL), obtained from [`crate::StorageReport`] or built by the
7//! CLI directly from an RPC response.
8//!
9//! [`diff_snapshots`] compares an **old** (baseline) snapshot against a **new**
10//! (current) one and classifies each entry as:
11//! - [`DiffStatus::Removed`]  — entry present in old, absent in new (expired or
12//!   otherwise gone).
13//! - [`DiffStatus::ExpiringSoon`] — entry still live in new but with a TTL below
14//!   [`EXPIRING_SOON_LEDGERS`] (≈ 1 day).
15//! - [`DiffStatus::Unchanged`] — entry live and above the threshold.
16//!
17//! [`derive_extend_plan`] turns the diff result into a non-mutating
18//! [`ExtendPlan`]: the set of ledger keys that need remediation, the contract
19//! they belong to, and a suggested `--ledgers` value the operator can pass
20//! directly to `sdkt storage extend`.
21//!
22//! # Guarantees
23//! - No RPC mutation methods are called in this module.
24//! - An empty diff (no removed or expiring entries) produces an [`ExtendPlan`]
25//!   with an empty key list and exits cleanly.
26
27use crate::error::StorageError;
28use crate::types::StorageReport;
29use serde::{Deserialize, Serialize};
30
31/// Threshold in ledgers below which a live entry is flagged as "expiring soon".
32/// ~1 day at 5 s/ledger (17 280 ledgers).  Matches the constant used in
33/// [`crate::analyzer`] so the diff is consistent with the storage analyzer.
34pub const EXPIRING_SOON_LEDGERS: u32 = 17_280;
35
36/// Default suggested ledger horizon used when deriving an extend plan and the
37/// remaining TTL gives no useful signal.  Equivalent to ~30 days at 5 s/ledger.
38pub const DEFAULT_SUGGESTED_LEDGERS: u32 = 518_400;
39
40// ---------------------------------------------------------------------------
41// StorageSnapshot
42// ---------------------------------------------------------------------------
43
44/// A point-in-time capture of one contract's storage entries.
45///
46/// The snapshot intentionally stores only the data needed for diffing
47/// (`key` + `current_ttl`).  Callers may build it from a [`StorageReport`]
48/// via [`StorageSnapshot::from_report`], or construct it directly in tests.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
50pub struct StorageSnapshot {
51    /// The on-chain contract identifier (C… StrKey or hex).
52    pub contract_id: String,
53    /// Entries captured at the time this snapshot was taken.
54    pub entries: Vec<SnapshotEntry>,
55}
56
57/// A single entry inside a [`StorageSnapshot`].
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct SnapshotEntry {
60    /// Base64 XDR encoded `LedgerKey`.
61    pub key: String,
62    /// Readable ABI-derived key label, when available.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub label: Option<String>,
65    /// TTL in ledgers relative to the ledger at snapshot time.
66    pub current_ttl: u32,
67}
68
69impl StorageSnapshot {
70    /// Construct a snapshot from a [`StorageReport`] produced by the analyzer.
71    ///
72    /// Returns an error if `report.total_entries > 0` but `report.entries` is empty,
73    /// indicating a legacy or summary-only report without per-entry detail.
74    pub fn from_report(report: &StorageReport) -> Result<Self, StorageError> {
75        if report.total_entries > 0 && report.entries.is_empty() {
76            return Err(StorageError::Parse(format!(
77                "Snapshot report for contract '{}' has total_entries ({}) > 0 but entries array is empty (legacy or summary-only report without per-entry detail)",
78                report.contract_id, report.total_entries
79            )));
80        }
81        Ok(Self {
82            contract_id: report.contract_id.clone(),
83            entries: report
84                .entries
85                .iter()
86                .map(|e| SnapshotEntry {
87                    key: e.key.clone(),
88                    label: e.label.clone(),
89                    current_ttl: e.current_ttl,
90                })
91                .collect(),
92        })
93    }
94
95    /// Build a snapshot from raw `(key, ttl)` pairs (useful in tests and CLIs
96    /// that already have the data from RPC without going through the analyzer).
97    pub fn from_entries(contract_id: impl Into<String>, entries: Vec<SnapshotEntry>) -> Self {
98        Self {
99            contract_id: contract_id.into(),
100            entries,
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Diff types
107// ---------------------------------------------------------------------------
108
109/// Classification of a single entry in a snapshot diff.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum DiffStatus {
113    /// Entry was present in the old snapshot but is absent in the new one.
114    /// This means it expired or was otherwise removed from the ledger.
115    Removed,
116    /// Entry is still live in the new snapshot but its TTL is below
117    /// [`EXPIRING_SOON_LEDGERS`].  Action is recommended before it expires.
118    ExpiringSoon,
119    /// Entry is still live and its TTL is above the expiring-soon threshold.
120    Unchanged,
121}
122
123/// A single entry in a [`SnapshotDiff`].
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct DiffEntry {
126    /// Base64 XDR encoded `LedgerKey`.
127    pub key: String,
128    /// Readable ABI-derived key label, when available.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub label: Option<String>,
131    /// How this entry changed between the two snapshots.
132    pub status: DiffStatus,
133    /// TTL in the old snapshot (`None` for entries that are `Removed` and were
134    /// not observed at the new point in time).
135    pub old_ttl: Option<u32>,
136    /// TTL in the new snapshot (`None` for `Removed` entries).
137    pub new_ttl: Option<u32>,
138}
139
140/// The complete result of diffing two [`StorageSnapshot`]s.
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
142pub struct SnapshotDiff {
143    /// Contract these snapshots describe.
144    pub contract_id: String,
145    /// All entries across both snapshots.
146    pub entries: Vec<DiffEntry>,
147}
148
149impl SnapshotDiff {
150    /// Return only the entries that need remediation (removed or expiring soon).
151    pub fn actionable(&self) -> impl Iterator<Item = &DiffEntry> {
152        self.entries
153            .iter()
154            .filter(|e| matches!(e.status, DiffStatus::Removed | DiffStatus::ExpiringSoon))
155    }
156}
157
158// ---------------------------------------------------------------------------
159// diff_snapshots
160// ---------------------------------------------------------------------------
161
162/// Diff an **old** (baseline) snapshot against a **new** (current) one.
163///
164/// Entries are keyed by their base64 XDR `LedgerKey`.  The diff is computed as
165/// follows:
166///
167/// 1. Every key in the old snapshot that is absent in the new snapshot →
168///    [`DiffStatus::Removed`].
169/// 2. Every key in the new snapshot whose TTL < [`EXPIRING_SOON_LEDGERS`] →
170///    [`DiffStatus::ExpiringSoon`].
171/// 3. All other keys present in the new snapshot → [`DiffStatus::Unchanged`].
172///
173/// Keys that appear only in the new snapshot (added entries) are reported as
174/// [`DiffStatus::Unchanged`] (or `ExpiringSoon` if their TTL is low).  There is
175/// intentionally no `Added` status because the diff is used exclusively to drive
176/// remediation of *at-risk* entries.
177pub fn diff_snapshots(
178    old: &StorageSnapshot,
179    new: &StorageSnapshot,
180) -> Result<SnapshotDiff, StorageError> {
181    if old.contract_id != new.contract_id {
182        return Err(StorageError::ContractIdMismatch {
183            old: old.contract_id.clone(),
184            new: new.contract_id.clone(),
185        });
186    }
187
188    use std::collections::HashMap;
189
190    let old_map: HashMap<&str, &SnapshotEntry> =
191        old.entries.iter().map(|e| (e.key.as_str(), e)).collect();
192
193    let new_map: HashMap<&str, &SnapshotEntry> =
194        new.entries.iter().map(|e| (e.key.as_str(), e)).collect();
195
196    let mut entries: Vec<DiffEntry> = Vec::new();
197
198    // Entries present in old — may be removed or still alive.
199    for (key, &old_entry) in &old_map {
200        let old_ttl = old_entry.current_ttl;
201        if let Some(&new_entry) = new_map.get(key) {
202            let new_ttl = new_entry.current_ttl;
203            let status = if new_ttl < EXPIRING_SOON_LEDGERS {
204                DiffStatus::ExpiringSoon
205            } else {
206                DiffStatus::Unchanged
207            };
208            entries.push(DiffEntry {
209                key: key.to_string(),
210                label: new_entry.label.clone().or_else(|| old_entry.label.clone()),
211                status,
212                old_ttl: Some(old_ttl),
213                new_ttl: Some(new_ttl),
214            });
215        } else {
216            entries.push(DiffEntry {
217                key: key.to_string(),
218                label: old_entry.label.clone(),
219                status: DiffStatus::Removed,
220                old_ttl: Some(old_ttl),
221                new_ttl: None,
222            });
223        }
224    }
225
226    // Entries that are new (only present in new snapshot).
227    for (key, &new_entry) in &new_map {
228        if !old_map.contains_key(key) {
229            let new_ttl = new_entry.current_ttl;
230            let status = if new_ttl < EXPIRING_SOON_LEDGERS {
231                DiffStatus::ExpiringSoon
232            } else {
233                DiffStatus::Unchanged
234            };
235            entries.push(DiffEntry {
236                key: key.to_string(),
237                label: new_entry.label.clone(),
238                status,
239                old_ttl: None,
240                new_ttl: Some(new_ttl),
241            });
242        }
243    }
244
245    // Stable sort so output is deterministic (by key, then by status).
246    entries.sort_by(|a, b| a.key.cmp(&b.key));
247
248    Ok(SnapshotDiff {
249        contract_id: old.contract_id.clone(),
250        entries,
251    })
252}
253
254// ---------------------------------------------------------------------------
255// ExtendPlan
256// ---------------------------------------------------------------------------
257
258/// A non-mutating remediation plan derived from a [`SnapshotDiff`].
259///
260/// The plan identifies which ledger keys need TTL extension and suggests a
261/// `--ledgers` value the operator can pass directly to `sdkt storage extend`.
262/// **Nothing is signed or submitted.**
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub struct ExtendPlan {
265    /// Contract whose storage needs remediation.
266    pub contract_id: String,
267    /// Ledger keys (base64 XDR) that should be included in the extend footprint.
268    /// Empty when there is nothing to remediate.
269    pub keys: Vec<String>,
270    /// Suggested value for `--ledgers` (relative TTL extension).
271    ///
272    /// When there are actionable entries the suggestion is
273    /// `max(DEFAULT_SUGGESTED_LEDGERS, min_remaining_ttl + DEFAULT_SUGGESTED_LEDGERS)`
274    /// rounded to [`DEFAULT_SUGGESTED_LEDGERS`] when there are no remaining TTL
275    /// signals (e.g. all entries are `Removed`).
276    pub suggested_ledgers: u32,
277    /// Human-readable reason explaining how `suggested_ledgers` was chosen.
278    pub suggested_ledgers_reason: String,
279}
280
281// ---------------------------------------------------------------------------
282// derive_extend_plan
283// ---------------------------------------------------------------------------
284
285/// Derive a non-mutating [`ExtendPlan`] from a [`SnapshotDiff`].
286///
287/// Only the `Removed` and `ExpiringSoon` entries contribute to the plan; no
288/// transaction is built, signed, or submitted.
289///
290/// # Empty plan
291/// When the diff has no actionable entries the returned plan has an empty
292/// `keys` list, `suggested_ledgers` of [`DEFAULT_SUGGESTED_LEDGERS`], and a
293/// clear reason string.  The caller should exit 0 in this case.
294///
295/// # Suggested ledger horizon
296/// The heuristic is:
297/// - Collect the `new_ttl` of all `ExpiringSoon` entries (removed entries have
298///   no remaining TTL).
299/// - Compute the minimum remaining TTL across those entries.
300/// - Suggest `min_remaining_ttl + DEFAULT_SUGGESTED_LEDGERS` so the extension
301///   carries entries comfortably past the threshold.
302/// - When there are no `ExpiringSoon` entries (only `Removed`), default to
303///   [`DEFAULT_SUGGESTED_LEDGERS`].
304pub fn derive_extend_plan(diff: &SnapshotDiff) -> ExtendPlan {
305    let actionable: Vec<&DiffEntry> = diff.actionable().collect();
306
307    if actionable.is_empty() {
308        return ExtendPlan {
309            contract_id: diff.contract_id.clone(),
310            keys: vec![],
311            suggested_ledgers: DEFAULT_SUGGESTED_LEDGERS,
312            suggested_ledgers_reason: "No actionable entries; nothing to extend.".into(),
313        };
314    }
315
316    // Collect the keys for the plan.
317    let keys: Vec<String> = actionable.iter().map(|e| e.key.clone()).collect();
318
319    // Derive suggested ledgers from the minimum remaining TTL of expiring-soon
320    // entries.  Removed entries have no remaining TTL and are excluded.
321    let min_remaining: Option<u32> = actionable
322        .iter()
323        .filter_map(|e| {
324            if e.status == DiffStatus::ExpiringSoon {
325                e.new_ttl
326            } else {
327                None
328            }
329        })
330        .min();
331
332    let (suggested_ledgers, suggested_ledgers_reason) = match min_remaining {
333        Some(min_ttl) => {
334            let suggested = min_ttl.saturating_add(DEFAULT_SUGGESTED_LEDGERS);
335            (
336                suggested,
337                format!(
338                    "Minimum remaining TTL of expiring entries is {} ledgers; \
339                     suggested {} (min_ttl + {} default horizon).",
340                    min_ttl, suggested, DEFAULT_SUGGESTED_LEDGERS
341                ),
342            )
343        }
344        None => (
345            DEFAULT_SUGGESTED_LEDGERS,
346            format!(
347                "All actionable entries are removed (no remaining TTL); \
348                 defaulting to {} ledgers (~30 days).",
349                DEFAULT_SUGGESTED_LEDGERS
350            ),
351        ),
352    };
353
354    ExtendPlan {
355        contract_id: diff.contract_id.clone(),
356        keys,
357        suggested_ledgers,
358        suggested_ledgers_reason,
359    }
360}
361
362// ---------------------------------------------------------------------------
363// Unit tests
364// ---------------------------------------------------------------------------
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    const CONTRACT: &str = "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC";
371
372    fn snap(entries: Vec<(&str, u32)>) -> StorageSnapshot {
373        StorageSnapshot::from_entries(
374            CONTRACT,
375            entries
376                .into_iter()
377                .map(|(k, ttl)| SnapshotEntry {
378                    key: k.to_string(),
379                    label: None,
380                    current_ttl: ttl,
381                })
382                .collect(),
383        )
384    }
385
386    // -----------------------------------------------------------------------
387    // diff_snapshots
388    // -----------------------------------------------------------------------
389
390    #[test]
391    fn diff_mismatched_contract_ids_returns_error() {
392        let old = StorageSnapshot {
393            contract_id: "CCONTRACTA".to_string(),
394            entries: vec![],
395        };
396        let new = StorageSnapshot {
397            contract_id: "CCONTRACTB".to_string(),
398            entries: vec![],
399        };
400        let err = diff_snapshots(&old, &new).unwrap_err();
401        assert!(matches!(
402            err,
403            StorageError::ContractIdMismatch { old, new }
404                if old == "CCONTRACTA" && new == "CCONTRACTB"
405        ));
406    }
407
408    #[test]
409    fn diff_empty_old_and_new_is_empty() {
410        let old = snap(vec![]);
411        let new = snap(vec![]);
412        let diff = diff_snapshots(&old, &new).unwrap();
413        assert!(diff.entries.is_empty());
414    }
415
416    #[test]
417    fn diff_all_unchanged_when_ttl_above_threshold() {
418        let ttl = EXPIRING_SOON_LEDGERS + 1;
419        let old = snap(vec![("keyA", ttl), ("keyB", ttl)]);
420        let new = snap(vec![("keyA", ttl), ("keyB", ttl)]);
421        let diff = diff_snapshots(&old, &new).unwrap();
422        assert_eq!(diff.entries.len(), 2);
423        assert!(diff
424            .entries
425            .iter()
426            .all(|e| e.status == DiffStatus::Unchanged));
427        assert!(diff.actionable().count() == 0);
428    }
429
430    #[test]
431    fn diff_removed_entry_is_flagged() {
432        let old = snap(vec![("keyA", 50_000), ("keyB", 50_000)]);
433        let new = snap(vec![("keyA", 49_000)]);
434        let diff = diff_snapshots(&old, &new).unwrap();
435
436        let removed: Vec<_> = diff
437            .entries
438            .iter()
439            .filter(|e| e.status == DiffStatus::Removed)
440            .collect();
441        assert_eq!(removed.len(), 1);
442        assert_eq!(removed[0].key, "keyB");
443        assert_eq!(removed[0].old_ttl, Some(50_000));
444        assert_eq!(removed[0].new_ttl, None);
445    }
446
447    #[test]
448    fn diff_expiring_soon_entry_is_flagged() {
449        let old = snap(vec![("keyA", 50_000)]);
450        let new = snap(vec![("keyA", EXPIRING_SOON_LEDGERS - 1)]);
451        let diff = diff_snapshots(&old, &new).unwrap();
452
453        assert_eq!(diff.entries.len(), 1);
454        assert_eq!(diff.entries[0].status, DiffStatus::ExpiringSoon);
455        assert_eq!(diff.entries[0].new_ttl, Some(EXPIRING_SOON_LEDGERS - 1));
456    }
457
458    #[test]
459    fn diff_boundary_ttl_equals_threshold_is_expiring_soon() {
460        // TTL == EXPIRING_SOON_LEDGERS is NOT below the threshold, so it is
461        // Unchanged.  The test documents the boundary precisely.
462        let old = snap(vec![("keyA", 50_000)]);
463        let new = snap(vec![("keyA", EXPIRING_SOON_LEDGERS)]);
464        let diff = diff_snapshots(&old, &new).unwrap();
465        // Exactly at threshold → Unchanged (strict less-than in the check).
466        assert_eq!(diff.entries[0].status, DiffStatus::Unchanged);
467    }
468
469    #[test]
470    fn diff_n_entries_mixed_statuses() {
471        let old = snap(vec![("k1", 100_000), ("k2", 100_000), ("k3", 100_000)]);
472        let new = snap(vec![
473            ("k1", 100_000), // unchanged
474            ("k2", EXPIRING_SOON_LEDGERS - 100), // expiring soon
475                             // k3 removed
476        ]);
477        let diff = diff_snapshots(&old, &new).unwrap();
478        assert_eq!(diff.entries.len(), 3);
479
480        let by_key: std::collections::HashMap<_, _> = diff
481            .entries
482            .iter()
483            .map(|e| (e.key.as_str(), e.status))
484            .collect();
485        assert_eq!(by_key["k1"], DiffStatus::Unchanged);
486        assert_eq!(by_key["k2"], DiffStatus::ExpiringSoon);
487        assert_eq!(by_key["k3"], DiffStatus::Removed);
488    }
489
490    #[test]
491    fn diff_output_is_deterministic() {
492        let old = snap(vec![("zz", 50_000), ("aa", 50_000)]);
493        let new = snap(vec![("zz", 50_000), ("aa", 50_000)]);
494        let d1 = diff_snapshots(&old, &new).unwrap();
495        let d2 = diff_snapshots(&old, &new).unwrap();
496        assert_eq!(d1, d2);
497        // Keys should be sorted.
498        assert_eq!(d1.entries[0].key, "aa");
499        assert_eq!(d1.entries[1].key, "zz");
500    }
501
502    #[test]
503    fn diff_preserves_label_without_using_it_as_identity() {
504        let mut old = snap(vec![("keyA", 50_000)]);
505        let mut new = snap(vec![("keyA", 3_000)]);
506        old.entries[0].label = Some("old label".into());
507        new.entries[0].label = Some("DataKey::Balance(u32)".into());
508        let labeled = diff_snapshots(&old, &new).unwrap();
509        assert_eq!(labeled.entries.len(), 1);
510        assert_eq!(labeled.entries[0].key, "keyA");
511        assert_eq!(
512            labeled.entries[0].label.as_deref(),
513            Some("DataKey::Balance(u32)")
514        );
515        old.entries[0].label = None;
516        new.entries[0].label = None;
517        let plain = diff_snapshots(&old, &new).unwrap();
518        assert_eq!(plain.entries[0].status, labeled.entries[0].status);
519        assert_eq!(plain.entries[0].old_ttl, labeled.entries[0].old_ttl);
520        assert_eq!(plain.entries[0].new_ttl, labeled.entries[0].new_ttl);
521    }
522
523    // -----------------------------------------------------------------------
524    // derive_extend_plan
525    // -----------------------------------------------------------------------
526
527    #[test]
528    fn plan_empty_diff_produces_empty_plan() {
529        let diff = SnapshotDiff {
530            contract_id: CONTRACT.to_string(),
531            entries: vec![],
532        };
533        let plan = derive_extend_plan(&diff);
534        assert!(plan.keys.is_empty());
535        assert_eq!(plan.suggested_ledgers, DEFAULT_SUGGESTED_LEDGERS);
536        assert!(plan.suggested_ledgers_reason.contains("nothing to extend"));
537    }
538
539    #[test]
540    fn plan_one_removed_entry() {
541        let diff = SnapshotDiff {
542            contract_id: CONTRACT.to_string(),
543            entries: vec![DiffEntry {
544                key: "k1".to_string(),
545                label: None,
546                status: DiffStatus::Removed,
547                old_ttl: Some(50_000),
548                new_ttl: None,
549            }],
550        };
551        let plan = derive_extend_plan(&diff);
552        assert_eq!(plan.keys, vec!["k1"]);
553        // No remaining TTL → default horizon.
554        assert_eq!(plan.suggested_ledgers, DEFAULT_SUGGESTED_LEDGERS);
555        assert!(plan.suggested_ledgers_reason.contains("removed"));
556    }
557
558    #[test]
559    fn plan_one_expiring_entry_scales_with_remaining_ttl() {
560        let remaining = 5_000u32;
561        let diff = SnapshotDiff {
562            contract_id: CONTRACT.to_string(),
563            entries: vec![DiffEntry {
564                key: "k1".to_string(),
565                label: None,
566                status: DiffStatus::ExpiringSoon,
567                old_ttl: Some(50_000),
568                new_ttl: Some(remaining),
569            }],
570        };
571        let plan = derive_extend_plan(&diff);
572        assert_eq!(plan.keys, vec!["k1"]);
573        assert_eq!(
574            plan.suggested_ledgers,
575            remaining + DEFAULT_SUGGESTED_LEDGERS
576        );
577        assert!(plan
578            .suggested_ledgers_reason
579            .contains(&remaining.to_string()));
580    }
581
582    #[test]
583    fn plan_n_entries_uses_minimum_remaining_ttl() {
584        let diff = SnapshotDiff {
585            contract_id: CONTRACT.to_string(),
586            entries: vec![
587                DiffEntry {
588                    key: "k1".to_string(),
589                    label: None,
590                    status: DiffStatus::ExpiringSoon,
591                    old_ttl: Some(50_000),
592                    new_ttl: Some(10_000),
593                },
594                DiffEntry {
595                    key: "k2".to_string(),
596                    label: None,
597                    status: DiffStatus::ExpiringSoon,
598                    old_ttl: Some(50_000),
599                    new_ttl: Some(1_000), // minimum
600                },
601                DiffEntry {
602                    key: "k3".to_string(),
603                    label: None,
604                    status: DiffStatus::Removed,
605                    old_ttl: Some(50_000),
606                    new_ttl: None,
607                },
608            ],
609        };
610        let plan = derive_extend_plan(&diff);
611        assert_eq!(plan.keys.len(), 3);
612        // min remaining = 1000; suggestion = 1000 + DEFAULT_SUGGESTED_LEDGERS
613        assert_eq!(plan.suggested_ledgers, 1_000 + DEFAULT_SUGGESTED_LEDGERS);
614    }
615
616    #[test]
617    fn plan_unchanged_entries_not_included() {
618        let diff = SnapshotDiff {
619            contract_id: CONTRACT.to_string(),
620            entries: vec![
621                DiffEntry {
622                    key: "unchanged".to_string(),
623                    label: None,
624                    status: DiffStatus::Unchanged,
625                    old_ttl: Some(100_000),
626                    new_ttl: Some(99_000),
627                },
628                DiffEntry {
629                    key: "expiring".to_string(),
630                    label: None,
631                    status: DiffStatus::ExpiringSoon,
632                    old_ttl: Some(20_000),
633                    new_ttl: Some(5_000),
634                },
635            ],
636        };
637        let plan = derive_extend_plan(&diff);
638        assert_eq!(plan.keys, vec!["expiring"]);
639    }
640
641    #[test]
642    fn plan_json_has_stable_field_names() {
643        let plan = ExtendPlan {
644            contract_id: CONTRACT.to_string(),
645            keys: vec!["k1".to_string(), "k2".to_string()],
646            suggested_ledgers: 518_400,
647            suggested_ledgers_reason: "test".to_string(),
648        };
649        let json = serde_json::to_string(&plan).unwrap();
650        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
651        assert_eq!(v["contract_id"], CONTRACT);
652        assert_eq!(v["suggested_ledgers"], 518_400);
653        assert!(v["keys"].is_array());
654        assert_eq!(v["keys"].as_array().unwrap().len(), 2);
655        assert!(v["suggested_ledgers_reason"].is_string());
656    }
657
658    #[test]
659    fn snapshot_from_report_preserves_all_entries() {
660        use crate::types::{StorageClass, StorageEntry, StorageReport};
661        let report = StorageReport {
662            contract_id: CONTRACT.to_string(),
663            total_entries: 2,
664            instance_entries: 1,
665            persistent_entries: 1,
666            temporary_entries: 0,
667            other_entries: 0,
668            total_size_bytes: None,
669            ttl_summary: None,
670            entries: vec![
671                StorageEntry {
672                    key: "key1".to_string(),
673                    label: None,
674                    class: StorageClass::Instance,
675                    current_ttl: 10_000,
676                    days_remaining: 0,
677                    extension_cost_stroops: 0,
678                },
679                StorageEntry {
680                    key: "key2".to_string(),
681                    label: None,
682                    class: StorageClass::Persistent,
683                    current_ttl: 5_000,
684                    days_remaining: 0,
685                    extension_cost_stroops: 0,
686                },
687            ],
688        };
689        let snap = StorageSnapshot::from_report(&report).unwrap();
690        assert_eq!(snap.contract_id, CONTRACT);
691        assert_eq!(snap.entries.len(), 2);
692        assert_eq!(snap.entries[0].key, "key1");
693        assert_eq!(snap.entries[0].current_ttl, 10_000);
694    }
695
696    #[test]
697    fn snapshot_from_report_rejects_empty_entries_when_total_entries_positive() {
698        use crate::types::StorageReport;
699        let report = StorageReport {
700            contract_id: CONTRACT.to_string(),
701            total_entries: 3,
702            ..Default::default()
703        };
704        let err = StorageSnapshot::from_report(&report).unwrap_err();
705        assert!(matches!(err, StorageError::Parse(msg) if msg.contains("total_entries (3) > 0")));
706    }
707}