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    AppFileAction, AppPlanRequest, PlanningInputVersions, RuntimeEvent, RuntimeObserver,
13};
14
15use super::report;
16
17#[derive(Debug, Default)]
18pub struct AppUpgradeReport {
19    /// Physical files changed, retained for diagnostics and tests.
20    pub updated: usize,
21    /// User-facing app targets changed. Default summaries count this value.
22    pub updated_categories: usize,
23    pub skipped: usize,
24    pub failed: usize,
25    pub user_modified: usize,
26    pub restart_hints: BTreeSet<String>,
27}
28
29pub async fn handle_upgrade_installed(
30    config: &Config,
31    prune_stale: bool,
32    sep: &mut crate::output::SectionSeparator,
33) -> Result<AppUpgradeReport> {
34    handle_upgrade_installed_with_output(config, prune_stale, false, sep).await
35}
36
37pub(crate) async fn handle_upgrade_installed_with_output(
38    config: &Config,
39    prune_stale: bool,
40    verbose: bool,
41    sep: &mut crate::output::SectionSeparator,
42) -> Result<AppUpgradeReport> {
43    handle_upgrade_installed_with_output_with_result_approved(
44        config,
45        prune_stale,
46        verbose,
47        true,
48        sep,
49    )
50    .await
51    .map(|(report, _)| report)
52}
53
54pub(crate) async fn handle_upgrade_installed_with_output_with_result_approved(
55    config: &Config,
56    prune_stale: bool,
57    verbose: bool,
58    yes: bool,
59    sep: &mut crate::output::SectionSeparator,
60) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
61    handle_upgrade_installed_target_with_result_approved(
62        config,
63        None,
64        prune_stale,
65        verbose,
66        yes,
67        sep,
68    )
69    .await
70}
71
72pub(crate) async fn handle_upgrade_installed_with_output_with_result_prepared(
73    config: &Config,
74    prune_stale: bool,
75    verbose: bool,
76    prepared: crate::lifecycle_plan::PreparedLifecyclePlan,
77    sep: &mut crate::output::SectionSeparator,
78) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
79    let mut renderer = TerminalRenderer::stdio_with_separator(sep);
80    handle_upgrade_installed_target_with_prepared_reporter(
81        config,
82        None,
83        prune_stale,
84        verbose,
85        prepared,
86        &mut renderer,
87    )
88    .await
89}
90
91#[cfg(test)]
92pub(crate) async fn handle_upgrade_installed_target_with_result(
93    config: &Config,
94    category_filter: Option<&str>,
95    prune_stale: bool,
96    verbose: bool,
97    sep: &mut crate::output::SectionSeparator,
98) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
99    handle_upgrade_installed_target_with_result_approved(
100        config,
101        category_filter,
102        prune_stale,
103        verbose,
104        true,
105        sep,
106    )
107    .await
108}
109
110pub(crate) async fn handle_upgrade_installed_target_with_result_approved(
111    config: &Config,
112    category_filter: Option<&str>,
113    prune_stale: bool,
114    verbose: bool,
115    yes: bool,
116    sep: &mut crate::output::SectionSeparator,
117) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
118    let mut renderer = TerminalRenderer::stdio_with_separator(sep);
119    handle_upgrade_installed_target_with_reporter(
120        config,
121        category_filter,
122        prune_stale,
123        verbose,
124        yes,
125        &mut renderer,
126    )
127    .await
128}
129
130async fn handle_upgrade_installed_target_with_reporter(
131    config: &Config,
132    category_filter: Option<&str>,
133    prune_stale: bool,
134    verbose: bool,
135    yes: bool,
136    reporter: &mut dyn LifecycleReporter,
137) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
138    let reviewed = crate::lifecycle_plan::review_upgrade_plans(
139        config,
140        [crate::lifecycle_plan::LifecyclePlanRequest::app(
141            AppPlanRequest {
142                operation: LifecycleOperation::Upgrade,
143                target: category_filter.map(str::to_string),
144                force: false,
145                purge: false,
146                prune_stale,
147                input_versions: PlanningInputVersions::default(),
148            },
149            config,
150        )],
151        yes,
152        verbose,
153    )
154    .await?
155    .into_iter()
156    .next()
157    .expect("one reviewed App Plan");
158    let runtime = crate::lifecycle_plan::prepare_runtime(config, &reviewed).await?;
159    handle_upgrade_installed_target_with_prepared_reporter(
160        config,
161        category_filter,
162        prune_stale,
163        verbose,
164        crate::lifecycle_plan::PreparedLifecyclePlan { reviewed, runtime },
165        reporter,
166    )
167    .await
168}
169
170async fn handle_upgrade_installed_target_with_prepared_reporter(
171    config: &Config,
172    _category_filter: Option<&str>,
173    _prune_stale: bool,
174    verbose: bool,
175    prepared: crate::lifecycle_plan::PreparedLifecyclePlan,
176    reporter: &mut dyn LifecycleReporter,
177) -> Result<(AppUpgradeReport, LifecycleResultV1)> {
178    let crate::lifecycle_plan::PreparedLifecyclePlan {
179        reviewed,
180        mut runtime,
181    } = prepared;
182    let env = EnvConfig::load_or_init(config).await?;
183    runtime.context_mut_for_cli().env = env.as_map().clone();
184    let mut observer = UpgradeObserver::default();
185    let mut interaction = TerminalInteraction;
186    let artifact_categories = runtime
187        .app_categories(None)?
188        .into_iter()
189        .filter(|category| category.artifact.is_some())
190        .map(|category| category.name)
191        .collect::<BTreeSet<_>>();
192    let core = match crate::lifecycle_plan::execute_reviewed(
193        config,
194        runtime,
195        reviewed,
196        shine_core::frontend::ExecutionOptions {
197            show_hook_success: verbose,
198        },
199        &mut observer,
200        &mut interaction,
201    )
202    .await?
203    {
204        shine_core::frontend::OperationDetails::AppUpgrade(report) => *report,
205        _ => unreachable!("reviewed operation result type"),
206    };
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}