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