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)]
48pub enum TargetOption {
53 Recommended {
55 target_version: VersionText,
56 note_parts: Vec<CandidateNotePart>,
57 },
58 ForcedCandidate {
60 target_version: VersionText,
61 note_parts: Vec<CandidateNotePart>,
62 },
63 AlternateExact {
65 target_version: VersionText,
66 note_parts: Vec<CandidateNotePart>,
67 },
68 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}