Skip to main content

upgate_presentation/
batch.rs

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}