Skip to main content

shine_core/
lifecycle.rs

1//! Frontend-neutral structured lifecycle results.
2//!
3//! The contract intentionally carries canonical identities and stable codes,
4//! not raw logs, content, environment values, secrets, or machine-private
5//! destination paths.
6
7use 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    /// Canonical lifecycle identity, for example `app/ghostty`.
62    pub target: String,
63    /// Logical resource name relative to the target, never an absolute
64    /// destination path.
65    #[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    /// Stable safe codes only. Raw error messages do not belong in this
71    /// reusable contract.
72    #[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}