1use serde::{Deserialize, Serialize};
8
9pub const LIFECYCLE_RESULT_SCHEMA_VERSION: u32 = 1;
10
11#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum LifecycleOperation {
14 Install,
15 Update,
16 Upgrade,
17 Uninstall,
18}
19
20#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum LifecycleStatus {
23 Changed,
24 Unchanged,
25 Pending,
26 Previewed,
27 Skipped,
28 Preserved,
29 Conflict,
30 Failed,
31}
32
33#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum LifecycleEffect {
36 ResourceWritten,
37 ResourceRemoved,
38 ResourceWritePreviewed,
39 ResourceRemovePreviewed,
40 ReceiptWritten,
41 ReceiptRemoved,
42 ReceiptWritePreviewed,
43 ReceiptRemovePreviewed,
44 CacheWritten,
45 CacheRemoved,
46 CachePurged,
47 CacheWritePreviewed,
48 CacheRemovePreviewed,
49 CodeExecuted,
50 CodeExecutionPreviewed,
51 BackupCreated,
52 BackupRestored,
53 ManagedKeysRemoved,
54 ManagedResourcePreserved,
55 UserResourcePreserved,
56 UserModificationOverridden,
57}
58
59#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
60pub struct LifecycleOutcomeV1 {
61 pub target: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
66 pub resource: Option<String>,
67 pub status: LifecycleStatus,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub effects: Vec<LifecycleEffect>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub diagnostic_codes: Vec<String>,
74}
75
76impl LifecycleOutcomeV1 {
77 pub fn new(
78 target: impl Into<String>,
79 resource: Option<impl Into<String>>,
80 status: LifecycleStatus,
81 effects: impl IntoIterator<Item = LifecycleEffect>,
82 ) -> Self {
83 Self {
84 target: target.into(),
85 resource: resource.map(Into::into),
86 status,
87 effects: effects.into_iter().collect(),
88 diagnostic_codes: Vec::new(),
89 }
90 }
91
92 pub fn with_diagnostic_code(mut self, code: impl Into<String>) -> Self {
93 self.diagnostic_codes.push(code.into());
94 self
95 }
96}
97
98#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
99pub struct LifecycleResultV1 {
100 pub schema_version: u32,
101 pub operation: LifecycleOperation,
102 pub dry_run: bool,
103 pub outcomes: Vec<LifecycleOutcomeV1>,
104}
105
106impl LifecycleResultV1 {
107 pub fn new(operation: LifecycleOperation, dry_run: bool) -> Self {
108 Self {
109 schema_version: LIFECYCLE_RESULT_SCHEMA_VERSION,
110 operation,
111 dry_run,
112 outcomes: Vec::new(),
113 }
114 }
115
116 pub fn push(&mut self, outcome: LifecycleOutcomeV1) {
117 self.outcomes.push(outcome);
118 }
119
120 pub fn summary(&self) -> LifecycleSummaryV1 {
121 let mut summary = LifecycleSummaryV1::default();
122 for outcome in &self.outcomes {
123 match outcome.status {
124 LifecycleStatus::Changed => summary.changed += 1,
125 LifecycleStatus::Unchanged => summary.unchanged += 1,
126 LifecycleStatus::Pending => summary.pending += 1,
127 LifecycleStatus::Previewed => summary.previewed += 1,
128 LifecycleStatus::Skipped => summary.skipped += 1,
129 LifecycleStatus::Preserved => summary.preserved += 1,
130 LifecycleStatus::Conflict => summary.conflicts += 1,
131 LifecycleStatus::Failed => summary.failed += 1,
132 }
133 }
134 summary
135 }
136}
137
138#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
139pub struct LifecycleSummaryV1 {
140 pub changed: usize,
141 pub unchanged: usize,
142 pub pending: usize,
143 pub previewed: usize,
144 pub skipped: usize,
145 pub preserved: usize,
146 pub conflicts: usize,
147 pub failed: usize,
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn summary_counts_each_status_without_persisted_counters() {
156 let mut result = LifecycleResultV1::new(LifecycleOperation::Install, false);
157 for status in [
158 LifecycleStatus::Changed,
159 LifecycleStatus::Unchanged,
160 LifecycleStatus::Pending,
161 LifecycleStatus::Previewed,
162 LifecycleStatus::Skipped,
163 LifecycleStatus::Preserved,
164 LifecycleStatus::Conflict,
165 LifecycleStatus::Failed,
166 ] {
167 result.push(LifecycleOutcomeV1::new(
168 "app/sample",
169 Some("sample.toml"),
170 status,
171 [],
172 ));
173 }
174
175 assert_eq!(
176 result.summary(),
177 LifecycleSummaryV1 {
178 changed: 1,
179 unchanged: 1,
180 pending: 1,
181 previewed: 1,
182 skipped: 1,
183 preserved: 1,
184 conflicts: 1,
185 failed: 1,
186 }
187 );
188 }
189
190 #[test]
191 fn pending_and_cross_domain_effect_spellings_are_stable() {
192 let mut result = LifecycleResultV1::new(LifecycleOperation::Update, false);
193 result.push(LifecycleOutcomeV1::new(
194 "shell/tools/tool",
195 None::<String>,
196 LifecycleStatus::Pending,
197 [
198 LifecycleEffect::CacheWritePreviewed,
199 LifecycleEffect::ReceiptWritePreviewed,
200 LifecycleEffect::CodeExecutionPreviewed,
201 ],
202 ));
203
204 let encoded = toml::to_string(&result).unwrap();
205 assert!(encoded.contains("status = \"pending\""));
206 assert!(encoded.contains("\"cache-write-previewed\""));
207 assert!(encoded.contains("\"receipt-write-previewed\""));
208 assert!(encoded.contains("\"code-execution-previewed\""));
209 }
210}