Skip to main content

cli/apps/
upgrade.rs

1use anyhow::{Result, anyhow};
2use std::collections::{BTreeMap, BTreeSet};
3
4use crate::config::Config;
5use crate::env::EnvConfig;
6use crate::presentation::{
7    LifecycleReporter, PresentationEvent, TerminalInteraction, TerminalRenderer,
8};
9use shine_core::lifecycle::LifecycleOperation;
10use shine_core::lifecycle::{LifecycleResultV1, LifecycleStatus};
11use shine_core::runtime::{
12    AppApprovedUpgradeOptions, AppFileAction, AppPlanRequest, PlanningInputVersions, RuntimeEvent,
13    RuntimeObserver,
14};
15
16use super::report;
17
18#[derive(Debug, Default)]
19pub struct AppUpgradeReport {
20    /// Physical files changed, retained for diagnostics and tests.
21    pub updated: usize,
22    /// User-facing app targets changed. Default summaries count this value.
23    pub updated_categories: usize,
24    pub skipped: usize,
25    pub failed: usize,
26    pub user_modified: usize,
27    pub restart_hints: BTreeSet<String>,
28}
29
30pub async fn handle_upgrade_installed(
31    config: &Config,
32    prune_stale: bool,
33    sep: &mut crate::output::SectionSeparator,
34) -> Result<AppUpgradeReport> {
35    handle_upgrade_installed_with_output(config, prune_stale, false, sep).await
36}
37
38pub(crate) async fn handle_upgrade_installed_with_output(
39    config: &Config,
40    prune_stale: bool,
41    verbose: bool,
42    sep: &mut crate::output::SectionSeparator,
43) -> Result<AppUpgradeReport> {
44    handle_upgrade_installed_with_output_with_result_approved(
45        config,
46        prune_stale,
47        verbose,
48        true,
49        sep,
50    )
51    .await
52    .map(|(report, _)| report)
53}
54
55pub(crate) async fn handle_upgrade_installed_with_output_with_result_approved(
56    config: &Config,
57    prune_stale: bool,
58    verbose: bool,
59    yes: bool,
60    sep: &mut crate::output::SectionSeparator,
61) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
62    handle_upgrade_installed_target_with_result_approved(
63        config,
64        None,
65        prune_stale,
66        verbose,
67        yes,
68        sep,
69    )
70    .await
71}
72
73pub(crate) async fn handle_upgrade_installed_with_output_with_result_prepared(
74    config: &Config,
75    prune_stale: bool,
76    verbose: bool,
77    prepared: crate::lifecycle_plan::PreparedLifecyclePlan,
78    sep: &mut crate::output::SectionSeparator,
79) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
80    let mut renderer = TerminalRenderer::stdio_with_separator(sep);
81    handle_upgrade_installed_target_with_prepared_reporter(
82        config,
83        None,
84        prune_stale,
85        verbose,
86        prepared,
87        &mut renderer,
88    )
89    .await
90}
91
92#[cfg(test)]
93pub(crate) async fn handle_upgrade_installed_target_with_result(
94    config: &Config,
95    category_filter: Option<&str>,
96    prune_stale: bool,
97    verbose: bool,
98    sep: &mut crate::output::SectionSeparator,
99) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
100    handle_upgrade_installed_target_with_result_approved(
101        config,
102        category_filter,
103        prune_stale,
104        verbose,
105        true,
106        sep,
107    )
108    .await
109}
110
111pub(crate) async fn handle_upgrade_installed_target_with_result_approved(
112    config: &Config,
113    category_filter: Option<&str>,
114    prune_stale: bool,
115    verbose: bool,
116    yes: bool,
117    sep: &mut crate::output::SectionSeparator,
118) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
119    let mut renderer = TerminalRenderer::stdio_with_separator(sep);
120    handle_upgrade_installed_target_with_reporter(
121        config,
122        category_filter,
123        prune_stale,
124        verbose,
125        yes,
126        &mut renderer,
127    )
128    .await
129}
130
131async fn handle_upgrade_installed_target_with_reporter(
132    config: &Config,
133    category_filter: Option<&str>,
134    prune_stale: bool,
135    verbose: bool,
136    yes: bool,
137    reporter: &mut dyn LifecycleReporter,
138) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
139    let reviewed = crate::lifecycle_plan::review_upgrade_plans(
140        config,
141        [crate::lifecycle_plan::LifecyclePlanRequest::app(
142            AppPlanRequest {
143                operation: LifecycleOperation::Upgrade,
144                target: category_filter.map(str::to_string),
145                force: false,
146                purge: false,
147                prune_stale,
148                input_versions: PlanningInputVersions::default(),
149            },
150            config,
151        )],
152        yes,
153        verbose,
154    )
155    .await?
156    .into_iter()
157    .next()
158    .expect("one reviewed App Plan");
159    let runtime = crate::lifecycle_plan::prepare_runtime(config, &reviewed).await?;
160    handle_upgrade_installed_target_with_prepared_reporter(
161        config,
162        category_filter,
163        prune_stale,
164        verbose,
165        crate::lifecycle_plan::PreparedLifecyclePlan { reviewed, runtime },
166        reporter,
167    )
168    .await
169}
170
171async fn handle_upgrade_installed_target_with_prepared_reporter(
172    config: &Config,
173    _category_filter: Option<&str>,
174    _prune_stale: bool,
175    verbose: bool,
176    prepared: crate::lifecycle_plan::PreparedLifecyclePlan,
177    reporter: &mut dyn LifecycleReporter,
178) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
179    let crate::lifecycle_plan::PreparedLifecyclePlan {
180        reviewed,
181        mut runtime,
182    } = prepared;
183    let env = EnvConfig::load_or_init(config).await?;
184    runtime.context_mut_for_cli().env = env.as_map().clone();
185    let mut observer = UpgradeObserver::default();
186    let mut interaction = TerminalInteraction;
187    let artifact_categories = runtime
188        .app_categories(None)?
189        .into_iter()
190        .filter(|category| category.artifact.is_some())
191        .map(|category| category.name)
192        .collect::<BTreeSet<_>>();
193    let core = runtime
194        .upgrade_apps_approved(
195            match &reviewed.request {
196                crate::lifecycle_plan::LifecyclePlanRequest::App(request) => request.clone(),
197                _ => unreachable!("reviewed App Plan"),
198            },
199            &reviewed.approval,
200            AppApprovedUpgradeOptions {
201                show_hook_success: verbose,
202            },
203            &mut observer,
204            &mut interaction,
205        )
206        .await?;
207
208    let mut started = false;
209    let begin = |reporter: &mut dyn LifecycleReporter, started: &mut bool| {
210        if !*started {
211            reporter.emit(PresentationEvent::SectionStart);
212            reporter.emit(PresentationEvent::stdout(report::upgrade_header_text(
213                verbose,
214                core.files.len(),
215            )));
216            *started = true;
217        }
218    };
219    if verbose && !core.files.is_empty() {
220        begin(reporter, &mut started);
221    }
222
223    let mut updated_files = BTreeMap::<String, usize>::new();
224    for file in &core.files {
225        let source = format!("app/{}/{}", file.category, file.source.display());
226        match file.action {
227            AppFileAction::Installed | AppFileAction::BackedUp => {
228                *updated_files.entry(file.category.clone()).or_default() += 1;
229                if verbose {
230                    begin(reporter, &mut started);
231                    reporter.emit(PresentationEvent::stdout(report::install_success_text(
232                        &source,
233                        "",
234                        &file.destination,
235                        config,
236                    )));
237                }
238            }
239            AppFileAction::Removed | AppFileAction::Restored | AppFileAction::Missing => {
240                *updated_files.entry(file.category.clone()).or_default() += 1;
241                begin(reporter, &mut started);
242                reporter.emit(PresentationEvent::stdout(report::stale_removed_text(
243                    config,
244                    &file.destination,
245                    if file.action == AppFileAction::Missing {
246                        "(stale managed file already missing)"
247                    } else {
248                        "(removed stale managed file)"
249                    },
250                )));
251            }
252            AppFileAction::Unchanged if verbose => {
253                begin(reporter, &mut started);
254                reporter.emit(PresentationEvent::stdout(report::up_to_date_text(&source)));
255            }
256            AppFileAction::UserModified => {
257                begin(reporter, &mut started);
258                reporter.emit(PresentationEvent::stderr(report::warning_text(
259                    &source,
260                    "user-modified, skipped",
261                )));
262            }
263            AppFileAction::GeneratorPreserved | AppFileAction::Failed => {
264                begin(reporter, &mut started);
265                let detail = file
266                    .generator_error
267                    .as_ref()
268                    .or(file.error.as_ref())
269                    .cloned()
270                    .unwrap_or_else(|| "upgrade failed".to_string());
271                reporter.emit(PresentationEvent::stderr(report::install_error_text(
272                    &source,
273                    &anyhow!(detail),
274                )));
275            }
276            _ => {}
277        }
278    }
279    if !verbose && !updated_files.is_empty() {
280        begin(reporter, &mut started);
281        for (category, count) in &updated_files {
282            reporter.emit(PresentationEvent::stdout(report::category_updated_text(
283                category, *count,
284            )));
285        }
286    }
287    let changed_categories = core
288        .files
289        .iter()
290        .filter(|file| {
291            matches!(
292                file.action,
293                AppFileAction::Installed
294                    | AppFileAction::BackedUp
295                    | AppFileAction::Removed
296                    | AppFileAction::Restored
297            )
298        })
299        .map(|file| file.category.clone())
300        .collect::<BTreeSet<_>>();
301    for category in report::artifact_apply_categories(&artifact_categories, changed_categories) {
302        begin(reporter, &mut started);
303        reporter.emit(PresentationEvent::stdout(report::artifact_apply_hint_text(
304            &category,
305        )));
306    }
307    for event in observer.events {
308        begin(reporter, &mut started);
309        render_runtime_event(reporter, event);
310    }
311
312    let updated = core
313        .files
314        .iter()
315        .filter(|file| file.status == LifecycleStatus::Changed)
316        .count();
317    let result = AppUpgradeReport {
318        updated,
319        updated_categories: core.updated_categories.len(),
320        skipped: core.skipped,
321        failed: core.failed,
322        user_modified: core.user_modified,
323        restart_hints: core.restart_hints,
324    };
325    Ok((result, core.lifecycle))
326}
327
328#[derive(Default)]
329struct UpgradeObserver {
330    events: Vec<RuntimeEvent>,
331}
332
333impl RuntimeObserver for UpgradeObserver {
334    fn emit(&mut self, event: RuntimeEvent) {
335        self.events.push(event);
336    }
337}
338
339fn render_runtime_event(reporter: &mut dyn LifecycleReporter, event: RuntimeEvent) {
340    match event {
341        RuntimeEvent::Warning { target, detail, .. } => reporter.emit(PresentationEvent::stderr(
342            report::warning_text(target.as_deref().unwrap_or("app"), detail),
343        )),
344        RuntimeEvent::Progress {
345            code: "app_hook_completed",
346            target,
347        } => {
348            reporter.emit(PresentationEvent::stdout(format!(
349                "  {} {}: post-upgrade hook completed",
350                report::symbol("✓"),
351                target.trim_start_matches("app/")
352            )));
353        }
354        RuntimeEvent::ProcessOutput { text, .. } => {
355            for line in text.lines() {
356                reporter.emit(PresentationEvent::stdout(format!(
357                    "     {}",
358                    report::dim(line)
359                )));
360            }
361        }
362        _ => {}
363    }
364}