1use crate::error::StorageError;
28use crate::types::StorageReport;
29use serde::{Deserialize, Serialize};
30
31pub const EXPIRING_SOON_LEDGERS: u32 = 17_280;
35
36pub const DEFAULT_SUGGESTED_LEDGERS: u32 = 518_400;
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
50pub struct StorageSnapshot {
51 pub contract_id: String,
53 pub entries: Vec<SnapshotEntry>,
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct SnapshotEntry {
60 pub key: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub label: Option<String>,
65 pub current_ttl: u32,
67}
68
69impl StorageSnapshot {
70 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum DiffStatus {
113 Removed,
116 ExpiringSoon,
119 Unchanged,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct DiffEntry {
126 pub key: String,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub label: Option<String>,
131 pub status: DiffStatus,
133 pub old_ttl: Option<u32>,
136 pub new_ttl: Option<u32>,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
142pub struct SnapshotDiff {
143 pub contract_id: String,
145 pub entries: Vec<DiffEntry>,
147}
148
149impl SnapshotDiff {
150 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
158pub 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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub struct ExtendPlan {
265 pub contract_id: String,
267 pub keys: Vec<String>,
270 pub suggested_ledgers: u32,
277 pub suggested_ledgers_reason: String,
279}
280
281pub 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 let keys: Vec<String> = actionable.iter().map(|e| e.key.clone()).collect();
318
319 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#[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 #[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 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 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), ("k2", EXPIRING_SOON_LEDGERS - 100), ]);
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 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 #[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 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), },
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 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}