1use std::collections::BTreeSet;
2use std::time::Duration;
3
4use upgate_domain::{
5 AdvisoryLatestFact, BlockReason, CandidateAgeFact, DelayReason, InstalledTool, ManagerId,
6 ManagerRuleReason, PlanDiagnostics, PlanItem, PlanSelection, PolicyWarning, ScanIssue,
7 ScanItem, ScanReport, SkipReason, UpdateCandidate, UpdatePlan, UpdateSeed, VersionPolicy,
8};
9use upgate_execution::{ExecutionReport, ExecutionStatus, ResolvedExecutionTarget};
10
11use crate::{
12 OutcomeNote, OutcomeRow, OutcomeStatusView, OutcomeTable, OutcomeVersionEmphasis,
13 OutcomeVersionsView, OutcomeVisibility, OutputTheme, notes, render_outcome_table,
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct BatchRenderOptions {
18 pub theme: OutputTheme,
19 pub version_policy: Option<VersionPolicy>,
20}
21
22impl BatchRenderOptions {
23 pub const fn new(theme: OutputTheme) -> Self {
24 Self {
25 theme,
26 version_policy: None,
27 }
28 }
29 pub const fn with_version_policy(mut self, version_policy: VersionPolicy) -> Self {
30 self.version_policy = Some(version_policy);
31 self
32 }
33}
34pub fn apply_execution_report_table(
35 report: &ExecutionReport,
36 plan: &UpdatePlan,
37 selection: &PlanSelection,
38) -> OutcomeTable {
39 let mut rows = unselected_update_rows(plan, selection);
40
41 if report.items.is_empty() && rows.is_empty() {
42 rows.push(
43 OutcomeRow::manager(OutcomeStatusView::Current, report.manager_id.clone())
44 .with_note(OutcomeNote::normal("no selected updates"))
45 .with_visibility(OutcomeVisibility::VerboseOnly),
46 );
47 }
48
49 rows.extend(execution_report_rows(report));
50 OutcomeTable::new(rows)
51}
52pub fn render_batch_table(table: &OutcomeTable, theme: OutputTheme) -> String {
53 let mut rendered = render_outcome_table(table, theme);
54 if !rendered.is_empty() {
55 rendered.push('\n');
56 }
57 rendered
58}
59pub fn manager_error_table(manager_id: &ManagerId, command: &str, detail: &str) -> OutcomeTable {
60 let row = OutcomeRow::manager(OutcomeStatusView::Error, manager_id.clone())
61 .with_note(OutcomeNote::normal(format!("{command} failed: {detail}")));
62 OutcomeTable::new(vec![row])
63}
64pub fn scan_report_table(report: &ScanReport) -> OutcomeTable {
65 let mut rows = report
66 .issues
67 .iter()
68 .map(|issue| scan_issue_row(&report.manager_id, issue))
69 .collect::<Vec<_>>();
70
71 rows.extend(
72 report
73 .items
74 .iter()
75 .map(|item| scan_item_row(&report.manager_id, item)),
76 );
77 OutcomeTable::new(rows)
78}
79
80fn scan_issue_row(manager_id: &ManagerId, issue: &ScanIssue) -> OutcomeRow {
81 OutcomeRow::manager(scan_issue_status(issue), manager_id.clone())
82 .with_note(note_for_scan_issue(issue))
83}
84
85fn scan_item_row(manager_id: &ManagerId, item: &ScanItem) -> OutcomeRow {
86 match item {
87 ScanItem::Installed(tool) => current_scan_row(manager_id, tool),
88 ScanItem::InstalledWithReleaseAge { tool, age } => current_scan_row(manager_id, tool)
89 .with_note(release_age_note(*age).with_visibility(OutcomeVisibility::VerboseOnly)),
90 ScanItem::InstalledWithAudit { tool, age, audit } => {
91 let mut row = current_scan_row(manager_id, tool);
92 if let Some(age) = age {
93 row = row.with_note(
94 release_age_note(*age).with_visibility(OutcomeVisibility::VerboseOnly),
95 );
96 }
97 if let Some(note) = notes::audit_candidate(audit) {
98 row = row.with_note(OutcomeNote::normal(note));
99 }
100 row
101 }
102 ScanItem::Skipped { tool, reason } => OutcomeRow::item(
103 scan_issue_status(reason),
104 manager_id.clone(),
105 tool.package_name.clone(),
106 OutcomeVersionsView::Current {
107 version: tool.installed_version.clone(),
108 },
109 )
110 .with_note(note_for_scan_issue(reason)),
111 }
112}
113
114fn current_scan_row(manager_id: &ManagerId, tool: &InstalledTool) -> OutcomeRow {
115 OutcomeRow::item(
116 OutcomeStatusView::Current,
117 manager_id.clone(),
118 tool.package_name.clone(),
119 OutcomeVersionsView::Current {
120 version: tool.installed_version.clone(),
121 },
122 )
123}
124
125const fn scan_issue_status(issue: &ScanIssue) -> OutcomeStatusView {
126 match issue {
127 ScanIssue::ReleaseLookupFailed { .. }
128 | ScanIssue::DiscoveryFailed { .. }
129 | ScanIssue::MissingReleaseMetadata => OutcomeStatusView::Error,
130 ScanIssue::ExcludedByManagerRule(_) => OutcomeStatusView::Skipped,
131 }
132}
133
134fn note_for_scan_issue(issue: &ScanIssue) -> OutcomeNote {
135 OutcomeNote::normal(scan_issue_text(issue))
136}
137pub fn update_plan_table(plan: &UpdatePlan, options: BatchRenderOptions) -> OutcomeTable {
138 let rows = plan
139 .items
140 .iter()
141 .map(|item| plan_item_row(&plan.manager_id, item, options))
142 .collect::<Vec<_>>();
143 OutcomeTable::new(rows)
144}
145
146fn plan_item_row(
147 manager_id: &ManagerId,
148 item: &PlanItem,
149 options: BatchRenderOptions,
150) -> OutcomeRow {
151 match item {
152 PlanItem::Update { candidate, .. } => update_row(manager_id, candidate, options),
153 PlanItem::Current { installed, .. } => current_no_newer_row(manager_id, installed),
154 PlanItem::Delayed {
155 candidate, reason, ..
156 } => delayed_row(manager_id, candidate, reason, options),
157 PlanItem::Blocked {
158 seed,
159 reason,
160 policy_warnings,
161 diagnostics,
162 ..
163 } => blocked_row(
164 manager_id,
165 seed,
166 reason,
167 policy_warnings,
168 diagnostics,
169 options,
170 ),
171 PlanItem::Skipped {
172 installed, reason, ..
173 } => skipped_plan_row(manager_id, installed, reason),
174 PlanItem::ResolverError {
175 installed, message, ..
176 } => OutcomeRow::item(
177 OutcomeStatusView::Error,
178 manager_id.clone(),
179 installed.package_name.clone(),
180 OutcomeVersionsView::Current {
181 version: installed.installed_version.clone(),
182 },
183 )
184 .with_note(OutcomeNote::normal(message)),
185 }
186}
187
188fn update_row(
189 manager_id: &ManagerId,
190 candidate: &UpdateCandidate,
191 options: BatchRenderOptions,
192) -> OutcomeRow {
193 let mut row = candidate_row(OutcomeStatusView::Update, manager_id, candidate);
194 if let Some(note) = target_release_note(&candidate.diagnostics) {
195 row = row.with_note(note);
196 }
197 if let Some(note) = latest_too_fresh_note(&candidate.diagnostics) {
198 row = row.with_note(note);
199 }
200 row = append_advisory_warning_notes(row, &candidate.diagnostics);
201 row = append_policy_notes(row, &candidate.diagnostics, options.version_policy);
202 append_policy_warning_notes(row, &candidate.policy_warnings)
203}
204
205fn delayed_row(
206 manager_id: &ManagerId,
207 candidate: &UpdateCandidate,
208 reason: &DelayReason,
209 options: BatchRenderOptions,
210) -> OutcomeRow {
211 let mut row = candidate_row(OutcomeStatusView::Delayed, manager_id, candidate);
212 if let Some(note) = target_release_note(&candidate.diagnostics) {
213 row = row.with_note(note);
214 }
215 row = row.with_note(delayed_note(reason, &candidate.diagnostics));
216 row = append_advisory_warning_notes(row, &candidate.diagnostics);
217 row = append_policy_notes(row, &candidate.diagnostics, options.version_policy);
218 append_policy_warning_notes(row, &candidate.policy_warnings)
219}
220
221fn blocked_row(
222 manager_id: &ManagerId,
223 seed: &UpdateSeed,
224 reason: &BlockReason,
225 policy_warnings: &[PolicyWarning],
226 diagnostics: &PlanDiagnostics,
227 options: BatchRenderOptions,
228) -> OutcomeRow {
229 let versions = blocked_target_version(seed, reason, diagnostics).map_or_else(
230 || OutcomeVersionsView::manager_resolved(seed.installed.installed_version.clone()),
231 |target_version| OutcomeVersionsView::Change {
232 from: seed.installed.installed_version.clone(),
233 to: crate::OutcomeTargetView::Known(target_version),
234 emphasis: OutcomeVersionEmphasis::Current,
235 },
236 );
237 let mut row = match reason {
238 BlockReason::VersionPolicy(_) => OutcomeRow::item(
239 OutcomeStatusView::Current,
240 manager_id.clone(),
241 seed.installed.package_name.clone(),
242 OutcomeVersionsView::Current {
243 version: seed.installed.installed_version.clone(),
244 },
245 ),
246 BlockReason::MissingReleaseMetadata => OutcomeRow::item(
247 OutcomeStatusView::Blocked,
248 manager_id.clone(),
249 seed.installed.package_name.clone(),
250 versions,
251 )
252 .with_note(OutcomeNote::normal("missing release metadata")),
253 BlockReason::ReleaseLookupFailed => OutcomeRow::item(
254 OutcomeStatusView::Error,
255 manager_id.clone(),
256 seed.installed.package_name.clone(),
257 versions,
258 )
259 .with_note(OutcomeNote::normal(lookup_failure_text(diagnostics))),
260 BlockReason::AuditVulnerable | BlockReason::AuditLookupFailed => {
261 let note = diagnostics
262 .audit_blocking_target
263 .as_ref()
264 .and_then(notes::audit_candidate)
265 .unwrap_or_else(|| "audit unavailable".to_owned());
266 OutcomeRow::item(
267 OutcomeStatusView::Blocked,
268 manager_id.clone(),
269 seed.installed.package_name.clone(),
270 versions,
271 )
272 .with_note(OutcomeNote::normal(note))
273 }
274 };
275 if matches!(reason, BlockReason::VersionPolicy(_)) {
276 row = append_policy_notes(row, diagnostics, options.version_policy);
277 }
278 row = append_advisory_warning_notes(row, diagnostics);
279 append_policy_warning_notes(row, policy_warnings)
280}
281
282fn current_no_newer_row(manager_id: &ManagerId, installed: &InstalledTool) -> OutcomeRow {
283 OutcomeRow::item(
284 OutcomeStatusView::Current,
285 manager_id.clone(),
286 installed.package_name.clone(),
287 OutcomeVersionsView::Current {
288 version: installed.installed_version.clone(),
289 },
290 )
291 .with_note(OutcomeNote::normal("no newer version found"))
292 .with_visibility(OutcomeVisibility::VerboseOnly)
293}
294
295fn skipped_plan_row(
296 manager_id: &ManagerId,
297 installed: &InstalledTool,
298 reason: &SkipReason,
299) -> OutcomeRow {
300 OutcomeRow::item(
301 OutcomeStatusView::Skipped,
302 manager_id.clone(),
303 installed.package_name.clone(),
304 OutcomeVersionsView::Current {
305 version: installed.installed_version.clone(),
306 },
307 )
308 .with_note(OutcomeNote::normal(notes::skip_reason(reason)))
309}
310
311fn candidate_row(
312 status: OutcomeStatusView,
313 manager_id: &ManagerId,
314 candidate: &UpdateCandidate,
315) -> OutcomeRow {
316 OutcomeRow::item(
317 status,
318 manager_id.clone(),
319 candidate.package_name.clone(),
320 candidate.target_version().map_or_else(
321 || OutcomeVersionsView::manager_resolved(candidate.installed_version.clone()),
322 |target_version| {
323 OutcomeVersionsView::change(
324 candidate.installed_version.clone(),
325 target_version.clone(),
326 )
327 },
328 ),
329 )
330}
331fn unselected_update_rows(plan: &UpdatePlan, selection: &PlanSelection) -> Vec<OutcomeRow> {
332 let selected_ids = selection
333 .selected_items
334 .iter()
335 .map(|item| &item.plan_item_id)
336 .collect::<BTreeSet<_>>();
337 plan.items
338 .iter()
339 .filter_map(|item| match item {
340 PlanItem::Update { id, candidate } if !selected_ids.contains(id) => Some(
341 candidate_row(OutcomeStatusView::Skipped, &plan.manager_id, candidate)
342 .with_note(OutcomeNote::normal("not selected"))
343 .with_visibility(OutcomeVisibility::VerboseOnly),
344 ),
345 _ => None,
346 })
347 .collect()
348}
349
350fn execution_report_rows(report: &ExecutionReport) -> Vec<OutcomeRow> {
351 report
352 .items
353 .iter()
354 .map(|item| {
355 let versions = match &item.target {
356 ResolvedExecutionTarget::Known(target_version) => OutcomeVersionsView::change(
357 item.installed_version.clone(),
358 target_version.clone(),
359 ),
360 ResolvedExecutionTarget::ManagerResolved => {
361 OutcomeVersionsView::manager_resolved(item.installed_version.clone())
362 }
363 };
364 match &item.status {
365 ExecutionStatus::Succeeded { .. } => OutcomeRow::item(
366 OutcomeStatusView::Update,
367 report.manager_id.clone(),
368 item.package_name.clone(),
369 versions,
370 ),
371 ExecutionStatus::Failed { detail, .. } => OutcomeRow::item(
372 OutcomeStatusView::Error,
373 report.manager_id.clone(),
374 item.package_name.clone(),
375 versions,
376 )
377 .with_note(OutcomeNote::normal(detail)),
378 }
379 })
380 .collect()
381}
382
383fn append_policy_notes(
384 row: OutcomeRow,
385 diagnostics: &PlanDiagnostics,
386 version_policy: Option<VersionPolicy>,
387) -> OutcomeRow {
388 let Some(policy) = version_policy else {
389 return row;
390 };
391 if policy == VersionPolicy::None {
392 return row;
393 }
394 if let Some(latest) = latest_policy_blocked_version(diagnostics) {
395 return row.with_note(OutcomeNote::normal(notes::version_blocked_by_policy(
396 latest,
397 )));
398 }
399 row
400}
401
402fn append_policy_warning_notes(mut row: OutcomeRow, warnings: &[PolicyWarning]) -> OutcomeRow {
403 for warning in warnings {
404 row = row.with_note(OutcomeNote::normal(notes::version_policy_warning(*warning)));
405 }
406 row
407}
408
409fn append_advisory_warning_notes(mut row: OutcomeRow, diagnostics: &PlanDiagnostics) -> OutcomeRow {
410 let advisory_failure = diagnostics.advisory_lookup_failure.as_ref().or(
411 match diagnostics.advisory_latest.as_ref() {
412 Some(AdvisoryLatestFact::LookupFailed { error, .. }) => Some(error),
413 _ => None,
414 },
415 );
416 if let Some(error) = advisory_failure {
417 row = row.with_note(OutcomeNote::normal(format!(
418 "advisory latest lookup failed: {}",
419 error.detail
420 )));
421 }
422 row
423}
424
425fn blocked_target_version(
426 seed: &UpdateSeed,
427 reason: &BlockReason,
428 diagnostics: &PlanDiagnostics,
429) -> Option<upgate_domain::VersionText> {
430 if matches!(
431 reason,
432 BlockReason::AuditVulnerable | BlockReason::AuditLookupFailed
433 ) && let Some(candidate) = diagnostics.audit_blocking_candidate.as_ref()
434 {
435 return Some(candidate.version.clone());
436 }
437 seed.target_selection.target_version().cloned()
438}
439
440fn latest_policy_blocked_version(
441 diagnostics: &PlanDiagnostics,
442) -> Option<&upgate_domain::VersionText> {
443 diagnostics
444 .candidates
445 .iter()
446 .find(|candidate| candidate.policy_block_reason.is_some())
447 .map(|candidate| &candidate.version)
448}
449
450fn latest_too_fresh_note(diagnostics: &PlanDiagnostics) -> Option<OutcomeNote> {
451 let latest = latest_too_fresh(diagnostics)?;
452 Some(OutcomeNote::normal(notes::version_too_fresh(
453 &latest.version,
454 )))
455}
456
457fn delayed_note(reason: &DelayReason, diagnostics: &PlanDiagnostics) -> OutcomeNote {
458 match reason {
459 DelayReason::ReleaseTooFresh => {
460 if let Some(target) = diagnostics.selected_target.as_ref() {
461 return OutcomeNote::normal(notes::too_fresh(
462 Some(target.age),
463 diagnostics.required_age,
464 ));
465 }
466 if let Some(latest) = latest_too_fresh(diagnostics) {
467 return OutcomeNote::normal(notes::no_eligible_latest_too_fresh(&latest.version));
468 }
469 OutcomeNote::normal(format!(
470 "no eligible release yet; required age {}",
471 notes::human_age(diagnostics.required_age)
472 ))
473 }
474 }
475}
476
477fn latest_too_fresh(diagnostics: &PlanDiagnostics) -> Option<&CandidateAgeFact> {
478 diagnostics
479 .latest_overall
480 .as_ref()
481 .filter(|latest| latest.age < diagnostics.required_age)
482 .or_else(|| {
483 diagnostics
484 .advisory_latest
485 .as_ref()
486 .and_then(advisory_latest_age_fact)
487 .filter(|latest| latest.age < diagnostics.required_age)
488 })
489}
490
491fn advisory_latest_age_fact(advisory: &AdvisoryLatestFact) -> Option<&CandidateAgeFact> {
492 match advisory {
493 AdvisoryLatestFact::Known {
494 latest_version,
495 candidates,
496 } => candidates
497 .iter()
498 .find(|candidate| &candidate.version == latest_version)
499 .or_else(|| candidates.first()),
500 AdvisoryLatestFact::MissingMetadata { .. } | AdvisoryLatestFact::LookupFailed { .. } => {
501 None
502 }
503 }
504}
505
506fn release_age_note(age: Duration) -> OutcomeNote {
507 OutcomeNote::normal(notes::released(age))
508}
509
510fn target_release_note(diagnostics: &PlanDiagnostics) -> Option<OutcomeNote> {
511 diagnostics
512 .selected_target
513 .as_ref()
514 .map(|target| release_age_note(target.age).with_visibility(OutcomeVisibility::VerboseOnly))
515}
516
517fn lookup_failure_text(diagnostics: &PlanDiagnostics) -> String {
518 diagnostics.lookup_failure.as_ref().map_or_else(
519 || "release lookup failed".to_owned(),
520 |err| err.detail.clone(),
521 )
522}
523
524fn scan_issue_text(issue: &ScanIssue) -> String {
525 match issue {
526 ScanIssue::DiscoveryFailed { detail } | ScanIssue::ReleaseLookupFailed { detail } => {
527 detail.clone()
528 }
529 ScanIssue::MissingReleaseMetadata => "missing release metadata".to_owned(),
530 ScanIssue::ExcludedByManagerRule(reason) => manager_rule_reason_text(reason),
531 }
532}
533
534fn manager_rule_reason_text(reason: &ManagerRuleReason) -> String {
535 match reason {
536 ManagerRuleReason::Dependency => "dependency".to_owned(),
537 ManagerRuleReason::DefaultGem => "default gem".to_owned(),
538 ManagerRuleReason::Other { detail } => detail.clone(),
539 }
540}