Skip to main content

canic_backup/restore/apply/journal/reports/
mod.rs

1use super::{
2    RestoreApplyJournal, RestoreApplyJournalOperation, RestoreApplyOperationKind,
3    RestoreApplyOperationKindCounts, RestoreApplyOperationState,
4};
5use serde::{Deserialize, Serialize};
6
7///
8/// RestoreApplyJournalStatus
9///
10
11#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
12pub struct RestoreApplyJournalStatus {
13    pub status_version: u16,
14    pub backup_id: String,
15    pub ready: bool,
16    pub complete: bool,
17    pub blocked_reasons: Vec<String>,
18    pub operation_count: usize,
19    #[serde(default)]
20    pub operation_counts: RestoreApplyOperationKindCounts,
21    pub operation_counts_supplied: bool,
22    pub progress: RestoreApplyProgressSummary,
23    pub pending_summary: RestoreApplyPendingSummary,
24    pub pending_operations: usize,
25    pub ready_operations: usize,
26    pub blocked_operations: usize,
27    pub completed_operations: usize,
28    pub failed_operations: usize,
29    pub next_ready_sequence: Option<usize>,
30    pub next_ready_operation: Option<RestoreApplyOperationKind>,
31    pub next_transition_sequence: Option<usize>,
32    pub next_transition_state: Option<RestoreApplyOperationState>,
33    pub next_transition_operation: Option<RestoreApplyOperationKind>,
34    pub next_transition_updated_at: Option<String>,
35}
36
37impl RestoreApplyJournalStatus {
38    /// Build a compact status projection from a restore apply journal.
39    #[must_use]
40    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
41        let next_ready = journal.next_ready_operation();
42        let next_transition = journal.next_transition_operation();
43
44        Self {
45            status_version: 1,
46            backup_id: journal.backup_id.clone(),
47            ready: journal.ready,
48            complete: journal.is_complete(),
49            blocked_reasons: journal.blocked_reasons.clone(),
50            operation_count: journal.operation_count,
51            operation_counts: journal.operation_kind_counts(),
52            operation_counts_supplied: journal.operation_counts_supplied(),
53            progress: RestoreApplyProgressSummary::from_journal(journal),
54            pending_summary: RestoreApplyPendingSummary::from_journal(journal),
55            pending_operations: journal.pending_operations,
56            ready_operations: journal.ready_operations,
57            blocked_operations: journal.blocked_operations,
58            completed_operations: journal.completed_operations,
59            failed_operations: journal.failed_operations,
60            next_ready_sequence: next_ready.map(|operation| operation.sequence),
61            next_ready_operation: next_ready.map(|operation| operation.operation.clone()),
62            next_transition_sequence: next_transition.map(|operation| operation.sequence),
63            next_transition_state: next_transition.map(|operation| operation.state.clone()),
64            next_transition_operation: next_transition.map(|operation| operation.operation.clone()),
65            next_transition_updated_at: next_transition
66                .and_then(|operation| operation.state_updated_at.clone()),
67        }
68    }
69}
70
71///
72/// RestoreApplyJournalReport
73///
74
75#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
76#[expect(
77    clippy::struct_excessive_bools,
78    reason = "apply reports intentionally expose stable JSON flags for operators and CI"
79)]
80pub struct RestoreApplyJournalReport {
81    pub report_version: u16,
82    pub backup_id: String,
83    pub outcome: RestoreApplyReportOutcome,
84    pub attention_required: bool,
85    pub ready: bool,
86    pub complete: bool,
87    pub blocked_reasons: Vec<String>,
88    pub operation_count: usize,
89    #[serde(default)]
90    pub operation_counts: RestoreApplyOperationKindCounts,
91    pub operation_counts_supplied: bool,
92    pub progress: RestoreApplyProgressSummary,
93    pub pending_summary: RestoreApplyPendingSummary,
94    pub pending_operations: usize,
95    pub ready_operations: usize,
96    pub blocked_operations: usize,
97    pub completed_operations: usize,
98    pub failed_operations: usize,
99    pub next_transition: Option<RestoreApplyReportOperation>,
100    pub pending: Vec<RestoreApplyReportOperation>,
101    pub failed: Vec<RestoreApplyReportOperation>,
102    pub blocked: Vec<RestoreApplyReportOperation>,
103}
104
105impl RestoreApplyJournalReport {
106    /// Build a compact operator report from a restore apply journal.
107    #[must_use]
108    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
109        let complete = journal.is_complete();
110        let outcome = RestoreApplyReportOutcome::from_journal(journal, complete);
111        let pending = report_operations_with_state(journal, RestoreApplyOperationState::Pending);
112        let failed = report_operations_with_state(journal, RestoreApplyOperationState::Failed);
113        let blocked = report_operations_with_state(journal, RestoreApplyOperationState::Blocked);
114
115        Self {
116            report_version: 1,
117            backup_id: journal.backup_id.clone(),
118            outcome: outcome.clone(),
119            attention_required: outcome.attention_required(),
120            ready: journal.ready,
121            complete,
122            blocked_reasons: journal.blocked_reasons.clone(),
123            operation_count: journal.operation_count,
124            operation_counts: journal.operation_kind_counts(),
125            operation_counts_supplied: journal.operation_counts_supplied(),
126            progress: RestoreApplyProgressSummary::from_journal(journal),
127            pending_summary: RestoreApplyPendingSummary::from_journal(journal),
128            pending_operations: journal.pending_operations,
129            ready_operations: journal.ready_operations,
130            blocked_operations: journal.blocked_operations,
131            completed_operations: journal.completed_operations,
132            failed_operations: journal.failed_operations,
133            next_transition: journal
134                .next_transition_operation()
135                .map(RestoreApplyReportOperation::from_journal_operation),
136            pending,
137            failed,
138            blocked,
139        }
140    }
141}
142
143///
144/// RestoreApplyPendingSummary
145///
146
147#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
148pub struct RestoreApplyPendingSummary {
149    pub pending_operations: usize,
150    pub pending_operation_available: bool,
151    pub pending_sequence: Option<usize>,
152    pub pending_operation: Option<RestoreApplyOperationKind>,
153    pub pending_updated_at: Option<String>,
154    pub pending_updated_at_known: bool,
155}
156
157impl RestoreApplyPendingSummary {
158    /// Build a compact pending-operation summary from a restore apply journal.
159    #[must_use]
160    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
161        let pending = journal
162            .operations
163            .iter()
164            .filter(|operation| operation.state == RestoreApplyOperationState::Pending)
165            .min_by_key(|operation| operation.sequence);
166        let pending_updated_at = pending.and_then(|operation| operation.state_updated_at.clone());
167        let pending_updated_at_known = pending_updated_at
168            .as_deref()
169            .is_some_and(known_state_update_marker);
170
171        Self {
172            pending_operations: journal.pending_operations,
173            pending_operation_available: pending.is_some(),
174            pending_sequence: pending.map(|operation| operation.sequence),
175            pending_operation: pending.map(|operation| operation.operation.clone()),
176            pending_updated_at,
177            pending_updated_at_known,
178        }
179    }
180}
181
182// Return whether a journal update marker can be compared by automation.
183fn known_state_update_marker(value: &str) -> bool {
184    !value.trim().is_empty() && value != "unknown"
185}
186
187///
188/// RestoreApplyProgressSummary
189///
190
191#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192pub struct RestoreApplyProgressSummary {
193    pub operation_count: usize,
194    pub completed_operations: usize,
195    pub remaining_operations: usize,
196    pub transitionable_operations: usize,
197    pub attention_operations: usize,
198    pub completion_basis_points: usize,
199}
200
201impl RestoreApplyProgressSummary {
202    /// Build a compact progress summary from restore apply journal counters.
203    #[must_use]
204    pub const fn from_journal(journal: &RestoreApplyJournal) -> Self {
205        let remaining_operations = journal
206            .operation_count
207            .saturating_sub(journal.completed_operations);
208        let transitionable_operations = journal.ready_operations + journal.pending_operations;
209        let attention_operations =
210            journal.pending_operations + journal.blocked_operations + journal.failed_operations;
211        let completion_basis_points =
212            completion_basis_points(journal.completed_operations, journal.operation_count);
213
214        Self {
215            operation_count: journal.operation_count,
216            completed_operations: journal.completed_operations,
217            remaining_operations,
218            transitionable_operations,
219            attention_operations,
220            completion_basis_points,
221        }
222    }
223}
224
225// Return completion as basis points so JSON stays deterministic and integer-only.
226const fn completion_basis_points(completed_operations: usize, operation_count: usize) -> usize {
227    if operation_count == 0 {
228        return 0;
229    }
230
231    completed_operations.saturating_mul(10_000) / operation_count
232}
233
234///
235/// RestoreApplyReportOutcome
236///
237
238#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
239#[serde(rename_all = "kebab-case")]
240pub enum RestoreApplyReportOutcome {
241    Empty,
242    Complete,
243    Failed,
244    Blocked,
245    Pending,
246    InProgress,
247}
248
249impl RestoreApplyReportOutcome {
250    // Classify the journal into one high-level operator outcome.
251    const fn from_journal(journal: &RestoreApplyJournal, complete: bool) -> Self {
252        if journal.operation_count == 0 {
253            return Self::Empty;
254        }
255        if complete {
256            return Self::Complete;
257        }
258        if journal.failed_operations > 0 {
259            return Self::Failed;
260        }
261        if !journal.ready || journal.blocked_operations > 0 {
262            return Self::Blocked;
263        }
264        if journal.pending_operations > 0 {
265            return Self::Pending;
266        }
267        Self::InProgress
268    }
269
270    // Return whether this outcome needs operator or automation attention.
271    const fn attention_required(&self) -> bool {
272        matches!(self, Self::Failed | Self::Blocked | Self::Pending)
273    }
274}
275
276///
277/// RestoreApplyReportOperation
278///
279
280#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
281pub struct RestoreApplyReportOperation {
282    pub sequence: usize,
283    pub operation: RestoreApplyOperationKind,
284    pub state: RestoreApplyOperationState,
285    pub restore_group: u16,
286    pub phase_order: usize,
287    pub role: String,
288    pub source_canister: String,
289    pub target_canister: String,
290    pub state_updated_at: Option<String>,
291    pub reasons: Vec<String>,
292}
293
294impl RestoreApplyReportOperation {
295    // Build one compact report row from one journal operation.
296    fn from_journal_operation(operation: &RestoreApplyJournalOperation) -> Self {
297        Self {
298            sequence: operation.sequence,
299            operation: operation.operation.clone(),
300            state: operation.state.clone(),
301            restore_group: operation.restore_group,
302            phase_order: operation.phase_order,
303            role: operation.role.clone(),
304            source_canister: operation.source_canister.clone(),
305            target_canister: operation.target_canister.clone(),
306            state_updated_at: operation.state_updated_at.clone(),
307            reasons: operation.blocking_reasons.clone(),
308        }
309    }
310}
311
312// Return compact report rows for operations in one state.
313fn report_operations_with_state(
314    journal: &RestoreApplyJournal,
315    state: RestoreApplyOperationState,
316) -> Vec<RestoreApplyReportOperation> {
317    journal
318        .operations
319        .iter()
320        .filter(|operation| operation.state == state)
321        .map(RestoreApplyReportOperation::from_journal_operation)
322        .collect()
323}
324
325///
326/// RestoreApplyNextOperation
327///
328
329#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
330pub struct RestoreApplyNextOperation {
331    pub response_version: u16,
332    pub backup_id: String,
333    pub ready: bool,
334    pub complete: bool,
335    pub operation_available: bool,
336    pub blocked_reasons: Vec<String>,
337    pub operation: Option<RestoreApplyJournalOperation>,
338}
339
340impl RestoreApplyNextOperation {
341    /// Build a compact next-operation response from a restore apply journal.
342    #[must_use]
343    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
344        let operation = journal.next_transition_operation().cloned();
345
346        Self {
347            response_version: 1,
348            backup_id: journal.backup_id.clone(),
349            ready: journal.ready,
350            complete: journal.is_complete(),
351            operation_available: operation.is_some(),
352            blocked_reasons: journal.blocked_reasons.clone(),
353            operation,
354        }
355    }
356}