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 pub updated: usize,
21 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}