Skip to main content

upgate_presentation/
selection_view.rs

1use upgate_domain::{
2    AdvisoryLatestFact, AuditLookupResult, BlockReason, CandidateAgeFact, CandidateEvaluationFact,
3    DelayReason, ManagerId, MissingMetadataKind, PackageName, PlanDiagnostics, PlanItem,
4    PlanItemId, PolicyBlockReason, PolicyWarning, ReleaseLookupError, SkipReason, UpdateCandidate,
5    UpdatePlan, UpdateSeed, UpdateSelectionPolicy, VersionText,
6};
7
8use std::time::Duration;
9
10use crate::notes;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SelectionView {
14    pub manager_id: ManagerId,
15    pub rows: Vec<SelectionRow>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SelectionRow {
20    pub plan_item_id: PlanItemId,
21    pub package_name: PackageName,
22    pub installed_version: VersionText,
23    pub target_version: Option<VersionText>,
24    pub status: SelectionRowStatus,
25    pub default_visibility: SelectionRowVisibility,
26    pub notes: Vec<CandidateNotePart>,
27    pub initially_selected: bool,
28    pub target_options: Vec<TargetOption>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum SelectionRowStatus {
33    Update,
34    Current,
35    Delayed,
36    Blocked,
37    Skipped,
38    ResolverError,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum SelectionRowVisibility {
43    Visible,
44    HiddenUntilViewAll,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48/// Typed action option shown in the details picker for a real plan row.
49///
50/// Despite the current name, this is not display text. Each variant maps back
51/// to a typed apply selection.
52pub enum TargetOption {
53    /// Apply the plan's normal recommended target.
54    Recommended {
55        target_version: VersionText,
56        note_parts: Vec<CandidateNotePart>,
57    },
58    /// Apply the planned candidate even though normal gates did not select it.
59    ForcedCandidate {
60        target_version: VersionText,
61        note_parts: Vec<CandidateNotePart>,
62    },
63    /// Apply a specific exact target produced from typed candidate diagnostics.
64    AlternateExact {
65        target_version: VersionText,
66        note_parts: Vec<CandidateNotePart>,
67    },
68    /// Let the manager choose the final target for this selected tool.
69    ManagerResolved { note_parts: Vec<CandidateNotePart> },
70}
71
72impl TargetOption {
73    pub const fn target_version(&self) -> Option<&VersionText> {
74        match self {
75            Self::Recommended { target_version, .. }
76            | Self::ForcedCandidate { target_version, .. }
77            | Self::AlternateExact { target_version, .. } => Some(target_version),
78            Self::ManagerResolved { .. } => None,
79        }
80    }
81    pub fn note_parts(&self) -> &[CandidateNotePart] {
82        match self {
83            Self::Recommended { note_parts, .. }
84            | Self::ForcedCandidate { note_parts, .. }
85            | Self::AlternateExact { note_parts, .. }
86            | Self::ManagerResolved { note_parts } => note_parts,
87        }
88    }
89    pub fn has_violation(&self) -> bool {
90        self.note_parts()
91            .iter()
92            .any(CandidateNotePart::is_violation)
93    }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct CandidateNotePart {
98    pub kind: CandidateNoteKind,
99    pub tone: CandidateNoteTone,
100}
101
102impl CandidateNotePart {
103    pub const fn normal(kind: CandidateNoteKind) -> Self {
104        Self {
105            kind,
106            tone: CandidateNoteTone::Normal,
107        }
108    }
109    pub const fn violation(kind: CandidateNoteKind) -> Self {
110        Self {
111            kind,
112            tone: CandidateNoteTone::Violation,
113        }
114    }
115    pub const fn is_violation(&self) -> bool {
116        matches!(self.tone, CandidateNoteTone::Violation)
117    }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum CandidateNoteTone {
122    Normal,
123    Violation,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum CandidateNoteKind {
128    Released {
129        age: Duration,
130    },
131    TooFresh {
132        version: Option<VersionText>,
133        age: Option<Duration>,
134        required_age: Duration,
135    },
136    VersionPolicyBlocked {
137        version: VersionText,
138        reason: PolicyBlockReason,
139    },
140    PolicyWarning(PolicyWarning),
141    MissingReleaseMetadata,
142    ReleaseLookupFailed {
143        error: Option<ReleaseLookupError>,
144    },
145    AuditVulnerable {
146        findings: Vec<upgate_domain::AuditFinding>,
147    },
148    AuditLookupFailed {
149        detail: String,
150    },
151    AdvisoryLookupFailed {
152        error: ReleaseLookupError,
153    },
154    Skipped(SkipReason),
155    ResolverError {
156        message: String,
157    },
158}
159pub fn selection_view(
160    plan: &UpdatePlan,
161    selection_policy: &UpdateSelectionPolicy,
162) -> SelectionView {
163    let rows = plan
164        .items
165        .iter()
166        .map(|item| selection_row(item, selection_policy))
167        .collect();
168
169    SelectionView {
170        manager_id: plan.manager_id.clone(),
171        rows,
172    }
173}
174
175#[expect(clippy::too_many_lines)]
176fn selection_row(item: &PlanItem, selection_policy: &UpdateSelectionPolicy) -> SelectionRow {
177    match item {
178        PlanItem::Update { id, candidate } => {
179            let selected = selection_policy.includes(&candidate.package_name);
180            let notes = update_notes(candidate);
181            let target_options =
182                primary_target_options(candidate, notes.clone(), TargetOptionKind::Recommended);
183            SelectionRow {
184                plan_item_id: id.clone(),
185                package_name: candidate.package_name.clone(),
186                installed_version: candidate.installed_version.clone(),
187                target_version: candidate.target_version().cloned(),
188                status: SelectionRowStatus::Update,
189                default_visibility: SelectionRowVisibility::Visible,
190                notes,
191                initially_selected: selected,
192                target_options,
193            }
194        }
195        PlanItem::Current { id, installed } => SelectionRow {
196            plan_item_id: id.clone(),
197            package_name: installed.package_name.clone(),
198            installed_version: installed.installed_version.clone(),
199            target_version: None,
200            status: SelectionRowStatus::Current,
201            default_visibility: SelectionRowVisibility::HiddenUntilViewAll,
202            notes: Vec::new(),
203            initially_selected: false,
204            target_options: Vec::new(),
205        },
206        PlanItem::Delayed {
207            id,
208            candidate,
209            reason,
210        } => {
211            let notes = delayed_notes(reason, &candidate.diagnostics);
212            let target_options = delayed_target_options(candidate, notes.clone());
213            SelectionRow {
214                plan_item_id: id.clone(),
215                package_name: candidate.package_name.clone(),
216                installed_version: candidate.installed_version.clone(),
217                target_version: candidate.target_version().cloned(),
218                status: SelectionRowStatus::Delayed,
219                default_visibility: if candidate.target_version().is_some() {
220                    SelectionRowVisibility::Visible
221                } else {
222                    SelectionRowVisibility::HiddenUntilViewAll
223                },
224                notes,
225                initially_selected: false,
226                target_options,
227            }
228        }
229        PlanItem::Blocked {
230            id,
231            seed,
232            reason,
233            policy_warnings,
234            diagnostics,
235        } => {
236            let notes = blocked_notes(reason, policy_warnings, diagnostics);
237            let target_options = blocked_target_options(seed, reason, notes.clone(), diagnostics);
238            let target_version = blocked_target_version(seed, reason, diagnostics);
239            let default_visibility = if target_version.is_some() {
240                SelectionRowVisibility::Visible
241            } else {
242                SelectionRowVisibility::HiddenUntilViewAll
243            };
244            SelectionRow {
245                plan_item_id: id.clone(),
246                package_name: seed.installed.package_name.clone(),
247                installed_version: seed.installed.installed_version.clone(),
248                target_version,
249                status: SelectionRowStatus::Blocked,
250                default_visibility,
251                notes,
252                initially_selected: false,
253                target_options,
254            }
255        }
256        PlanItem::Skipped {
257            id,
258            installed,
259            reason,
260        } => SelectionRow {
261            plan_item_id: id.clone(),
262            package_name: installed.package_name.clone(),
263            installed_version: installed.installed_version.clone(),
264            target_version: None,
265            status: SelectionRowStatus::Skipped,
266            default_visibility: SelectionRowVisibility::HiddenUntilViewAll,
267            notes: vec![CandidateNotePart::normal(CandidateNoteKind::Skipped(
268                reason.clone(),
269            ))],
270            initially_selected: false,
271            target_options: Vec::new(),
272        },
273        PlanItem::ResolverError {
274            id,
275            installed,
276            message,
277        } => SelectionRow {
278            plan_item_id: id.clone(),
279            package_name: installed.package_name.clone(),
280            installed_version: installed.installed_version.clone(),
281            target_version: None,
282            status: SelectionRowStatus::ResolverError,
283            default_visibility: SelectionRowVisibility::HiddenUntilViewAll,
284            notes: vec![CandidateNotePart::violation(
285                CandidateNoteKind::ResolverError {
286                    message: message.clone(),
287                },
288            )],
289            initially_selected: false,
290            target_options: Vec::new(),
291        },
292    }
293}
294
295fn delayed_target_options(
296    candidate: &UpdateCandidate,
297    notes: Vec<CandidateNotePart>,
298) -> Vec<TargetOption> {
299    if candidate.execution_support.supports_age_bypass() {
300        primary_target_options(candidate, notes, TargetOptionKind::ForcedCandidate)
301    } else {
302        Vec::new()
303    }
304}
305
306fn blocked_target_options(
307    seed: &UpdateSeed,
308    reason: &BlockReason,
309    notes: Vec<CandidateNotePart>,
310    diagnostics: &PlanDiagnostics,
311) -> Vec<TargetOption> {
312    if matches!(reason, BlockReason::MissingReleaseMetadata)
313        && diagnostics.missing_metadata == Some(MissingMetadataKind::SelectedUpdate)
314        && seed.execution_support.supports_manager_resolved_target()
315    {
316        return vec![TargetOption::ManagerResolved { note_parts: notes }];
317    }
318
319    let Some(target_version) = blocked_target_version(seed, reason, diagnostics) else {
320        return match reason {
321            BlockReason::VersionPolicy(_) | BlockReason::MissingReleaseMetadata
322                if seed.execution_support.supports_manager_resolved_target() =>
323            {
324                vec![TargetOption::ManagerResolved { note_parts: notes }]
325            }
326            _ => Vec::new(),
327        };
328    };
329
330    if !matches!(
331        reason,
332        BlockReason::VersionPolicy(_)
333            | BlockReason::AuditVulnerable
334            | BlockReason::AuditLookupFailed
335    ) || !blocked_target_can_be_forced(seed, reason)
336    {
337        return Vec::new();
338    }
339
340    let candidate = UpdateCandidate::new(
341        seed.installed.tool_id.clone(),
342        seed.installed.package_name.clone(),
343        seed.installed.installed_version.clone(),
344        target_version.clone(),
345        seed.version_scheme,
346        seed.execution_support,
347    )
348    .with_execution_target_kind(seed.execution_target_kind)
349    .with_diagnostics(diagnostics.clone());
350
351    target_options_for_known_primary(
352        &candidate,
353        target_version,
354        notes,
355        TargetOptionKind::ForcedCandidate,
356    )
357}
358
359const fn blocked_target_can_be_forced(seed: &UpdateSeed, reason: &BlockReason) -> bool {
360    seed.execution_support.supports_age_bypass()
361        || (matches!(
362            reason,
363            BlockReason::AuditVulnerable | BlockReason::AuditLookupFailed
364        ) && seed.execution_support.supports_native_target())
365}
366
367fn blocked_target_version(
368    seed: &UpdateSeed,
369    reason: &BlockReason,
370    diagnostics: &PlanDiagnostics,
371) -> Option<VersionText> {
372    if matches!(
373        reason,
374        BlockReason::AuditVulnerable | BlockReason::AuditLookupFailed
375    ) && let Some(candidate) = diagnostics.audit_blocking_candidate.as_ref()
376    {
377        return Some(candidate.version.clone());
378    }
379    seed.target_selection.target_version().cloned()
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383enum TargetOptionKind {
384    Recommended,
385    ForcedCandidate,
386}
387
388fn primary_target_options(
389    candidate: &UpdateCandidate,
390    notes: Vec<CandidateNotePart>,
391    kind: TargetOptionKind,
392) -> Vec<TargetOption> {
393    let Some(target_version) = candidate.target_version().cloned() else {
394        if candidate
395            .execution_support
396            .supports_manager_resolved_target()
397        {
398            return vec![TargetOption::ManagerResolved { note_parts: notes }];
399        }
400        return Vec::new();
401    };
402
403    target_options_for_known_primary(candidate, target_version, notes, kind)
404}
405
406fn target_options_for_known_primary(
407    candidate: &UpdateCandidate,
408    target_version: VersionText,
409    notes: Vec<CandidateNotePart>,
410    kind: TargetOptionKind,
411) -> Vec<TargetOption> {
412    let mut options = if candidate.execution_support.supports_exact_target() {
413        exact_target_options(candidate)
414            .into_iter()
415            .map(|option| match option {
416                TargetOption::AlternateExact {
417                    target_version: exact_target,
418                    note_parts,
419                } if exact_target == target_version => {
420                    primary_option(kind, exact_target, note_parts)
421                }
422                option => option,
423            })
424            .collect::<Vec<_>>()
425    } else {
426        Vec::new()
427    };
428
429    if options.is_empty() {
430        options.push(primary_option(kind, target_version, notes));
431    }
432
433    options
434}
435
436const fn primary_option(
437    kind: TargetOptionKind,
438    target_version: VersionText,
439    note_parts: Vec<CandidateNotePart>,
440) -> TargetOption {
441    match kind {
442        TargetOptionKind::Recommended => TargetOption::Recommended {
443            target_version,
444            note_parts,
445        },
446        TargetOptionKind::ForcedCandidate => TargetOption::ForcedCandidate {
447            target_version,
448            note_parts,
449        },
450    }
451}
452
453fn exact_target_options(candidate: &UpdateCandidate) -> Vec<TargetOption> {
454    candidate
455        .diagnostics
456        .candidates
457        .iter()
458        .map(|evaluated| TargetOption::AlternateExact {
459            target_version: evaluated.version.clone(),
460            note_parts: candidate_evaluation_notes(evaluated, candidate.diagnostics.required_age),
461        })
462        .collect()
463}
464
465fn update_notes(candidate: &UpdateCandidate) -> Vec<CandidateNotePart> {
466    let mut notes = Vec::new();
467    if let Some(target) = candidate.diagnostics.selected_target.as_ref() {
468        notes.push(CandidateNotePart::normal(CandidateNoteKind::Released {
469            age: target.age,
470        }));
471    }
472    if let Some(latest) = latest_too_fresh(&candidate.diagnostics) {
473        notes.push(CandidateNotePart::normal(CandidateNoteKind::TooFresh {
474            version: Some(latest.version.clone()),
475            age: Some(latest.age),
476            required_age: candidate.diagnostics.required_age,
477        }));
478    }
479    notes.extend(policy_notes(&candidate.diagnostics));
480    notes.extend(advisory_warning_notes(&candidate.diagnostics));
481    notes.extend(
482        candidate
483            .policy_warnings
484            .iter()
485            .copied()
486            .map(|warning| CandidateNotePart::normal(CandidateNoteKind::PolicyWarning(warning))),
487    );
488    notes
489}
490
491fn candidate_evaluation_notes(
492    candidate: &CandidateEvaluationFact,
493    required_age: Duration,
494) -> Vec<CandidateNotePart> {
495    let mut notes = Vec::new();
496    match candidate.age {
497        Some(age) if candidate.age_allowed => {
498            notes.push(CandidateNotePart::normal(CandidateNoteKind::Released {
499                age,
500            }));
501        }
502        Some(age) => {
503            notes.push(CandidateNotePart::violation(CandidateNoteKind::TooFresh {
504                version: None,
505                age: Some(age),
506                required_age,
507            }));
508        }
509        None => {}
510    }
511    if let Some(reason) = candidate.policy_block_reason {
512        notes.push(CandidateNotePart::violation(
513            CandidateNoteKind::VersionPolicyBlocked {
514                version: candidate.version.clone(),
515                reason,
516            },
517        ));
518    }
519    if let Some(warning) = candidate.policy_warning {
520        notes.push(CandidateNotePart::normal(CandidateNoteKind::PolicyWarning(
521            warning,
522        )));
523    }
524    if let Some(audit) = candidate.audit.as_ref() {
525        notes.extend(audit_notes(audit));
526    }
527    notes
528}
529
530fn delayed_notes(reason: &DelayReason, diagnostics: &PlanDiagnostics) -> Vec<CandidateNotePart> {
531    match reason {
532        DelayReason::ReleaseTooFresh => {
533            let mut notes = vec![CandidateNotePart::violation(CandidateNoteKind::TooFresh {
534                version: None,
535                age: diagnostics
536                    .selected_target
537                    .as_ref()
538                    .map(|target| target.age),
539                required_age: diagnostics.required_age,
540            })];
541            notes.extend(advisory_warning_notes(diagnostics));
542            notes
543        }
544    }
545}
546
547fn blocked_notes(
548    reason: &BlockReason,
549    policy_warnings: &[PolicyWarning],
550    diagnostics: &PlanDiagnostics,
551) -> Vec<CandidateNotePart> {
552    let mut notes = match reason {
553        BlockReason::MissingReleaseMetadata => {
554            vec![CandidateNotePart::normal(
555                CandidateNoteKind::MissingReleaseMetadata,
556            )]
557        }
558        BlockReason::ReleaseLookupFailed => {
559            vec![CandidateNotePart::violation(
560                CandidateNoteKind::ReleaseLookupFailed {
561                    error: diagnostics.lookup_failure.clone(),
562                },
563            )]
564        }
565        BlockReason::AuditVulnerable | BlockReason::AuditLookupFailed => diagnostics
566            .audit_blocking_target
567            .as_ref()
568            .map_or_else(Vec::new, audit_notes),
569        BlockReason::VersionPolicy(_) => Vec::new(),
570    };
571    notes.extend(policy_notes(diagnostics));
572    notes.extend(advisory_warning_notes(diagnostics));
573    notes.extend(
574        policy_warnings
575            .iter()
576            .copied()
577            .map(|warning| CandidateNotePart::normal(CandidateNoteKind::PolicyWarning(warning))),
578    );
579    notes
580}
581
582fn audit_notes(audit: &AuditLookupResult) -> Vec<CandidateNotePart> {
583    match audit {
584        AuditLookupResult::Clean => Vec::new(),
585        AuditLookupResult::Vulnerable { findings } => {
586            vec![CandidateNotePart::violation(
587                CandidateNoteKind::AuditVulnerable {
588                    findings: findings.clone(),
589                },
590            )]
591        }
592        AuditLookupResult::LookupFailed { detail } => {
593            vec![CandidateNotePart::violation(
594                CandidateNoteKind::AuditLookupFailed {
595                    detail: detail.clone(),
596                },
597            )]
598        }
599    }
600}
601
602fn policy_notes(diagnostics: &PlanDiagnostics) -> Vec<CandidateNotePart> {
603    diagnostics
604        .candidates
605        .iter()
606        .find_map(|candidate| {
607            let reason = candidate.policy_block_reason?;
608            Some(vec![CandidateNotePart::violation(
609                CandidateNoteKind::VersionPolicyBlocked {
610                    version: candidate.version.clone(),
611                    reason,
612                },
613            )])
614        })
615        .unwrap_or_default()
616}
617
618fn advisory_warning_notes(diagnostics: &PlanDiagnostics) -> Vec<CandidateNotePart> {
619    let mut notes = Vec::new();
620    if let Some(error) = diagnostics.advisory_lookup_failure.as_ref() {
621        notes.push(CandidateNotePart::normal(
622            CandidateNoteKind::AdvisoryLookupFailed {
623                error: error.clone(),
624            },
625        ));
626    }
627    if let Some(AdvisoryLatestFact::LookupFailed { error, .. }) =
628        diagnostics.advisory_latest.as_ref()
629    {
630        notes.push(CandidateNotePart::normal(
631            CandidateNoteKind::AdvisoryLookupFailed {
632                error: error.clone(),
633            },
634        ));
635    }
636    notes
637}
638
639fn latest_too_fresh(diagnostics: &PlanDiagnostics) -> Option<&CandidateAgeFact> {
640    diagnostics
641        .latest_overall
642        .as_ref()
643        .filter(|latest| latest.age < diagnostics.required_age)
644        .or_else(|| {
645            diagnostics
646                .advisory_latest
647                .as_ref()
648                .and_then(advisory_latest_age_fact)
649                .filter(|latest| latest.age < diagnostics.required_age)
650        })
651}
652
653fn advisory_latest_age_fact(advisory: &AdvisoryLatestFact) -> Option<&CandidateAgeFact> {
654    match advisory {
655        AdvisoryLatestFact::Known {
656            latest_version,
657            candidates,
658        } => candidates
659            .iter()
660            .find(|candidate| &candidate.version == latest_version)
661            .or_else(|| candidates.first()),
662        AdvisoryLatestFact::MissingMetadata { .. } | AdvisoryLatestFact::LookupFailed { .. } => {
663            None
664        }
665    }
666}
667
668pub(crate) fn note_part_text(part: &CandidateNotePart) -> String {
669    match &part.kind {
670        CandidateNoteKind::Released { age } => notes::released(*age),
671        CandidateNoteKind::TooFresh {
672            version,
673            age,
674            required_age,
675        } => version.as_ref().map_or_else(
676            || notes::too_fresh(*age, *required_age),
677            notes::version_too_fresh,
678        ),
679        CandidateNoteKind::VersionPolicyBlocked { version, .. } => {
680            notes::version_blocked_by_policy(version)
681        }
682        CandidateNoteKind::PolicyWarning(warning) => notes::policy_warning(*warning).to_owned(),
683        CandidateNoteKind::MissingReleaseMetadata => "missing release metadata".to_owned(),
684        CandidateNoteKind::ReleaseLookupFailed { error } => error.as_ref().map_or_else(
685            || "release lookup failed".to_owned(),
686            |error| format!("release lookup failed: {}", error.detail),
687        ),
688        CandidateNoteKind::AuditVulnerable { findings } => notes::vulnerability_note(findings),
689        CandidateNoteKind::AuditLookupFailed { .. } => "audit unavailable".to_owned(),
690        CandidateNoteKind::AdvisoryLookupFailed { error } => {
691            format!("advisory latest lookup failed: {}", error.detail)
692        }
693        CandidateNoteKind::Skipped(reason) => notes::skip_reason(reason).to_owned(),
694        CandidateNoteKind::ResolverError { message } => message.clone(),
695    }
696}