1use crate::core::plan::PlanItem;
25use crate::orchestrator::planner::PlannerItem;
26use serde::{Deserialize, Serialize};
27use std::collections::{HashMap, HashSet};
28use std::fmt;
29
30fn is_opaque_target(proposed: &str) -> bool {
40 let p = proposed.trim();
41 p.is_empty() || p == "latest" || p == "installed"
42}
43
44fn is_real_version(s: &str) -> bool {
47 let s = s.trim();
48 !s.is_empty() && s != "installed" && s != "unknown" && s != "not installed"
49}
50
51fn version_reached(installed: &str, target: &str) -> bool {
55 let inst = installed.trim();
56 let tgt = target.trim();
57 if inst == tgt {
58 return true;
59 }
60 inst.starts_with(tgt)
62 && inst
63 .get(tgt.len()..)
64 .map(|s| s.starts_with('+'))
65 .unwrap_or(false)
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "kebab-case")]
75pub enum ApplyStatus {
76 Success,
78 Failed,
80 BlockedByFailure,
82 HeldBack,
84 Skipped,
86}
87
88impl ApplyStatus {
89 pub fn label(self) -> &'static str {
91 match self {
92 ApplyStatus::Success => "success",
93 ApplyStatus::Failed => "failed",
94 ApplyStatus::BlockedByFailure => "blocked-by-failure",
95 ApplyStatus::HeldBack => "held-back",
96 ApplyStatus::Skipped => "skipped",
97 }
98 }
99}
100
101impl fmt::Display for ApplyStatus {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.write_str(self.label())
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
113pub struct ApplyItem {
114 pub component_id: String,
116 pub current_version: String,
118 pub proposed_version: String,
120 pub status: ApplyStatus,
122 pub error_message: String,
124 pub dependencies: Vec<String>,
126 pub was_selected: bool,
128}
129
130impl ApplyItem {
131 pub fn new(
133 component_id: impl Into<String>,
134 current_version: impl Into<String>,
135 proposed_version: impl Into<String>,
136 status: ApplyStatus,
137 ) -> Self {
138 Self {
139 component_id: component_id.into(),
140 current_version: current_version.into(),
141 proposed_version: proposed_version.into(),
142 status,
143 error_message: String::new(),
144 dependencies: Vec::new(),
145 was_selected: true,
146 }
147 }
148
149 pub fn held_back(item: &PlannerItem) -> Self {
151 Self {
152 component_id: item.plan_item.component_id.clone(),
153 current_version: item.plan_item.current_version.clone(),
154 proposed_version: item.plan_item.proposed_version.clone(),
155 status: ApplyStatus::HeldBack,
156 error_message: String::new(),
157 dependencies: item.plan_item.dependencies.clone(),
158 was_selected: false,
159 }
160 }
161
162 pub fn blocked_by_failure(item: &PlannerItem, blocker_id: &str, reason: &str) -> Self {
164 Self {
165 component_id: item.plan_item.component_id.clone(),
166 current_version: item.plan_item.current_version.clone(),
167 proposed_version: item.plan_item.proposed_version.clone(),
168 status: ApplyStatus::BlockedByFailure,
169 error_message: format!("blocked by failed dependency '{blocker_id}': {reason}"),
170 dependencies: item.plan_item.dependencies.clone(),
171 was_selected: true,
172 }
173 }
174
175 pub fn skipped(item: &PlannerItem) -> Self {
177 Self {
178 component_id: item.plan_item.component_id.clone(),
179 current_version: item.plan_item.current_version.clone(),
180 proposed_version: item.plan_item.proposed_version.clone(),
181 status: ApplyStatus::Skipped,
182 error_message: String::new(),
183 dependencies: item.plan_item.dependencies.clone(),
184 was_selected: false,
185 }
186 }
187}
188
189pub trait ApplyExecutor {
205 fn apply_component(&self, component_id: &str, proposed_version: &str) -> Result<(), String>;
209}
210
211pub struct NoOpExecutor;
218
219impl ApplyExecutor for NoOpExecutor {
220 fn apply_component(&self, _component_id: &str, _proposed_version: &str) -> Result<(), String> {
221 Ok(())
222 }
223}
224
225#[derive(Debug, Clone, Default)]
231pub struct ApplyOptions {
232 pub force_fail: HashSet<String>,
234 pub verify_post_install_version: bool,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248pub struct ApplySummary {
249 pub success: Vec<ApplyItem>,
251 pub failed: Vec<ApplyItem>,
253 pub held_back: Vec<ApplyItem>,
255 pub blocked: Vec<ApplyItem>,
257 pub skipped: Vec<ApplyItem>,
259}
260
261impl ApplySummary {
262 pub fn new() -> Self {
264 Self {
265 success: Vec::new(),
266 failed: Vec::new(),
267 held_back: Vec::new(),
268 blocked: Vec::new(),
269 skipped: Vec::new(),
270 }
271 }
272
273 pub fn total(&self) -> usize {
275 self.success.len()
276 + self.failed.len()
277 + self.held_back.len()
278 + self.blocked.len()
279 + self.skipped.len()
280 }
281
282 pub fn has_failures(&self) -> bool {
284 !self.failed.is_empty() || !self.blocked.is_empty()
285 }
286
287 pub fn is_complete_partition(&self) -> bool {
289 let all_ids: HashSet<&str> = self
290 .success
291 .iter()
292 .chain(self.failed.iter())
293 .chain(self.held_back.iter())
294 .chain(self.blocked.iter())
295 .chain(self.skipped.iter())
296 .map(|i| i.component_id.as_str())
297 .collect();
298
299 let total_count = self.success.len()
300 + self.failed.len()
301 + self.held_back.len()
302 + self.blocked.len()
303 + self.skipped.len();
304
305 all_ids.len() == total_count
306 }
307}
308
309impl Default for ApplySummary {
310 fn default() -> Self {
311 Self::new()
312 }
313}
314
315pub struct ApplyEngine {
331 executor: Box<dyn ApplyExecutor>,
333}
334
335impl ApplyEngine {
336 pub fn new(executor: impl ApplyExecutor + 'static) -> Self {
338 Self {
339 executor: Box::new(executor),
340 }
341 }
342
343 pub fn new_noop() -> Self {
345 Self::new(NoOpExecutor)
346 }
347
348 pub fn apply(&self, items: &[PlannerItem], options: &ApplyOptions) -> ApplySummary {
353 let mut summary = ApplySummary::new();
354
355 let (selected, deselected): (Vec<&PlannerItem>, Vec<&PlannerItem>) =
357 items.iter().partition(|i| i.selected);
358
359 for item in &deselected {
361 summary.held_back.push(ApplyItem::held_back(item));
362 }
363
364 let sorted = match dependency_sort(selected.clone()) {
366 Ok(sorted) => sorted,
367 Err(_) => {
368 for item in &selected {
370 let mut apply_item = ApplyItem::new(
371 &item.plan_item.component_id,
372 &item.plan_item.current_version,
373 &item.plan_item.proposed_version,
374 ApplyStatus::Failed,
375 );
376 apply_item.error_message = "dependency cycle detected".to_string();
377 apply_item.dependencies = item.plan_item.dependencies.clone();
378 summary.failed.push(apply_item);
379 }
380 return summary;
381 }
382 };
383
384 let mut failed_ids: HashSet<String> = HashSet::new();
386 let mut failure_reasons: HashMap<String, String> = HashMap::new();
387
388 for item in &sorted {
390 let blocked_by = self.find_failed_dependency(&item.plan_item, &failed_ids);
392
393 if let Some(blocker_id) = blocked_by {
394 let reason = failure_reasons
395 .get(&blocker_id)
396 .cloned()
397 .unwrap_or_default();
398 summary
399 .blocked
400 .push(ApplyItem::blocked_by_failure(item, &blocker_id, &reason));
401 failed_ids.insert(item.plan_item.component_id.clone());
403 failure_reasons.insert(
404 item.plan_item.component_id.clone(),
405 format!("dependency '{blocker_id}' failed"),
406 );
407 continue;
408 }
409
410 let status = if options.force_fail.contains(&item.plan_item.component_id) {
412 Err(format!(
413 "forced failure for component '{}'",
414 item.plan_item.component_id
415 ))
416 } else {
417 self.executor.apply_component(
418 &item.plan_item.component_id,
419 &item.plan_item.proposed_version,
420 )
421 };
422
423 match status {
424 Ok(()) => {
425 let proposed = item.plan_item.proposed_version.as_str();
433 let verify = options.verify_post_install_version;
434 let detected = if !verify || is_opaque_target(proposed) {
435 String::new()
436 } else {
437 crate::platform::registry::get_version(&item.plan_item.component_id)
438 };
439 let advanced = !verify
440 || is_opaque_target(proposed)
441 || !is_real_version(&detected)
442 || version_reached(&detected, proposed);
443 if advanced {
444 let mut apply_item = ApplyItem::new(
445 &item.plan_item.component_id,
446 &item.plan_item.current_version,
447 &item.plan_item.proposed_version,
448 ApplyStatus::Success,
449 );
450 apply_item.dependencies = item.plan_item.dependencies.clone();
451 summary.success.push(apply_item);
452 } else {
453 let mut apply_item = ApplyItem::new(
454 &item.plan_item.component_id,
455 &item.plan_item.current_version,
456 &item.plan_item.proposed_version,
457 ApplyStatus::Failed,
458 );
459 apply_item.error_message = format!(
460 "install reported success but the version did not advance to \
461 '{proposed}' (still '{detected}'). The component may be sealed \
462 (set MLSTACK_UNSEAL_CORE=1 to override) or the installer did not \
463 pin to the target version."
464 );
465 apply_item.dependencies = item.plan_item.dependencies.clone();
466 summary.failed.push(apply_item);
467 failed_ids.insert(item.plan_item.component_id.clone());
468 failure_reasons.insert(
469 item.plan_item.component_id.clone(),
470 "install did not advance the version".to_string(),
471 );
472 }
473 }
474 Err(err) => {
475 let mut apply_item = ApplyItem::new(
476 &item.plan_item.component_id,
477 &item.plan_item.current_version,
478 &item.plan_item.proposed_version,
479 ApplyStatus::Failed,
480 );
481 apply_item.error_message = err.clone();
482 apply_item.dependencies = item.plan_item.dependencies.clone();
483 summary.failed.push(apply_item);
484
485 failed_ids.insert(item.plan_item.component_id.clone());
486 failure_reasons.insert(item.plan_item.component_id.clone(), err);
487 }
488 }
489 }
490
491 summary
492 }
493
494 fn find_failed_dependency(
498 &self,
499 item: &PlanItem,
500 failed_ids: &HashSet<String>,
501 ) -> Option<String> {
502 item.dependencies
503 .iter()
504 .find(|dep| failed_ids.contains(*dep))
505 .cloned()
506 }
507}
508
509fn dependency_sort(items: Vec<&PlannerItem>) -> Result<Vec<&PlannerItem>, String> {
518 let n = items.len();
519 if n == 0 {
520 return Ok(items);
521 }
522
523 let id_to_idx: HashMap<&str, usize> = items
524 .iter()
525 .enumerate()
526 .map(|(i, item)| (item.plan_item.component_id.as_str(), i))
527 .collect();
528
529 let mut in_degree: Vec<usize> = vec![0; n];
531 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
532
533 for (idx, item) in items.iter().enumerate() {
534 for dep_id in &item.plan_item.dependencies {
535 if let Some(&dep_idx) = id_to_idx.get(dep_id.as_str()) {
536 adj[dep_idx].push(idx);
537 in_degree[idx] += 1;
538 }
539 }
540 }
541
542 let mut queue = std::collections::VecDeque::new();
544 for (i, °) in in_degree.iter().enumerate() {
545 if deg == 0 {
546 queue.push_back(i);
547 }
548 }
549
550 let mut sorted_indices = Vec::with_capacity(n);
551 while let Some(idx) = queue.pop_front() {
552 sorted_indices.push(idx);
553 for &neighbor in &adj[idx] {
554 in_degree[neighbor] -= 1;
555 if in_degree[neighbor] == 0 {
556 queue.push_back(neighbor);
557 }
558 }
559 }
560
561 if sorted_indices.len() != n {
562 return Err("circular dependency detected".to_string());
563 }
564
565 let mut sorted: Vec<&PlannerItem> = Vec::with_capacity(n);
567 for idx in sorted_indices {
568 sorted.push(items[idx]);
569 }
570
571 Ok(sorted)
572}
573
574#[cfg(test)]
579mod tests {
580 use super::*;
581 use crate::core::plan::{PlanItem, PlanItemInput};
582 use crate::core::types::ValidationTier;
583 use crate::orchestrator::planner::{PlannerItem, UpdateClassification};
584
585 #[test]
590 fn honesty_guard_helpers_classify_versions() {
591 assert!(is_opaque_target(""));
593 assert!(is_opaque_target("latest"));
594 assert!(is_opaque_target("installed"));
595 assert!(!is_opaque_target("2.13.0"));
596 assert!(!is_opaque_target("25.1"));
597
598 assert!(is_real_version("2.13.0+rocm7.2"));
600 assert!(is_real_version("core_v0.15.0rc7"));
601 assert!(!is_real_version(""));
602 assert!(!is_real_version("installed"));
603 assert!(!is_real_version("unknown"));
604 assert!(!is_real_version("not installed"));
605
606 assert!(version_reached("2.13.0", "2.13.0"));
608 assert!(version_reached("2.13.0+rocm7.2", "2.13.0"));
609 assert!(!version_reached("2.12.1+rocm7.2", "2.13.0"));
611 assert!(!version_reached("core_v0.15.0rc7", "25.1"));
612 }
613
614 fn make_plan_item(id: &str, current: &str, proposed: &str, deps: Vec<&str>) -> PlanItem {
615 PlanItem::new(PlanItemInput {
616 component_id: id.to_string(),
617 current_version: current.to_string(),
618 proposed_version: proposed.to_string(),
619 validation_tier: ValidationTier::Validated,
620 selected: true,
621 rationale: "test".to_string(),
622 dependencies: deps.into_iter().map(|s| s.to_string()).collect(),
623 isolation_safe: true,
624 })
625 }
626
627 fn make_planner_item(
628 id: &str,
629 current: &str,
630 proposed: &str,
631 deps: Vec<&str>,
632 selected: bool,
633 ) -> PlannerItem {
634 PlannerItem {
635 plan_item: make_plan_item(id, current, proposed, deps),
636 classification: UpdateClassification::Safe,
637 visible: true,
638 selected,
639 classification_reason: "test".to_string(),
640 requires_hardware_check: false,
641 min_rocm_version: String::new(),
642 }
643 }
644
645 fn make_planner_item_with_classification(
646 id: &str,
647 current: &str,
648 proposed: &str,
649 deps: Vec<&str>,
650 selected: bool,
651 classification: UpdateClassification,
652 ) -> PlannerItem {
653 PlannerItem {
654 plan_item: make_plan_item(id, current, proposed, deps),
655 classification,
656 visible: true,
657 selected,
658 classification_reason: "test".to_string(),
659 requires_hardware_check: false,
660 min_rocm_version: String::new(),
661 }
662 }
663
664 #[test]
669 fn test_apply_dependency_safe_order_b_before_a() {
670 let items = vec![
672 make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
673 make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
674 ];
675
676 let engine = ApplyEngine::new_noop();
677 let options = ApplyOptions::default();
678 let summary = engine.apply(&items, &options);
679
680 assert_eq!(summary.success.len(), 2);
682 assert_eq!(summary.failed.len(), 0);
683
684 let order: Vec<&str> = summary
686 .success
687 .iter()
688 .map(|i| i.component_id.as_str())
689 .collect();
690 let b_pos = order.iter().position(|&id| id == "b").unwrap();
691 let a_pos = order.iter().position(|&id| id == "a").unwrap();
692 assert!(
693 b_pos < a_pos,
694 "B should be applied before A (B at {b_pos}, A at {a_pos})"
695 );
696 }
697
698 #[test]
699 fn test_apply_dependency_chain_c_before_b_before_a() {
700 let items = vec![
702 make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
703 make_planner_item("b", "1.0.0", "1.1.0", vec!["c"], true),
704 make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
705 ];
706
707 let engine = ApplyEngine::new_noop();
708 let summary = engine.apply(&items, &ApplyOptions::default());
709
710 assert_eq!(summary.success.len(), 3);
711 let order: Vec<&str> = summary
712 .success
713 .iter()
714 .map(|i| i.component_id.as_str())
715 .collect();
716 let c_pos = order.iter().position(|&id| id == "c").unwrap();
717 let b_pos = order.iter().position(|&id| id == "b").unwrap();
718 let a_pos = order.iter().position(|&id| id == "a").unwrap();
719 assert!(c_pos < b_pos, "C before B");
720 assert!(b_pos < a_pos, "B before A");
721 }
722
723 #[test]
728 fn test_apply_failed_guarded_stops_dependents() {
729 let items = vec![
731 make_planner_item_with_classification(
732 "a",
733 "1.0.0",
734 "1.1.0",
735 vec!["b"],
736 true,
737 UpdateClassification::Guarded,
738 ),
739 make_planner_item_with_classification(
740 "b",
741 "1.0.0",
742 "1.1.0",
743 vec![],
744 true,
745 UpdateClassification::Guarded,
746 ),
747 ];
748
749 let engine = ApplyEngine::new_noop();
750 let options = ApplyOptions {
751 force_fail: HashSet::from(["b".to_string()]),
752 ..Default::default()
753 };
754 let summary = engine.apply(&items, &options);
755
756 assert_eq!(summary.failed.len(), 1);
758 assert_eq!(summary.failed[0].component_id, "b");
759
760 assert_eq!(summary.blocked.len(), 1);
762 assert_eq!(summary.blocked[0].component_id, "a");
763 assert_eq!(summary.blocked[0].status, ApplyStatus::BlockedByFailure);
764 assert!(summary.blocked[0].error_message.contains("b"));
765 }
766
767 #[test]
772 fn test_apply_unrelated_continues_after_failure() {
773 let items = vec![
775 make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
776 make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
777 make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
778 make_planner_item("d", "1.0.0", "1.1.0", vec![], true),
779 ];
780
781 let engine = ApplyEngine::new_noop();
782 let options = ApplyOptions {
783 force_fail: HashSet::from(["b".to_string()]),
784 ..Default::default()
785 };
786 let summary = engine.apply(&items, &options);
787
788 assert_eq!(summary.failed.len(), 1);
790 assert_eq!(summary.failed[0].component_id, "b");
791
792 assert_eq!(summary.blocked.len(), 1);
794 assert_eq!(summary.blocked[0].component_id, "a");
795
796 assert_eq!(summary.success.len(), 2);
798 let success_ids: HashSet<&str> = summary
799 .success
800 .iter()
801 .map(|i| i.component_id.as_str())
802 .collect();
803 assert!(success_ids.contains("c"));
804 assert!(success_ids.contains("d"));
805 }
806
807 #[test]
812 fn test_apply_user_can_untick_preselected_component() {
813 let items = vec![
815 make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
816 make_planner_item("b", "1.0.0", "1.1.0", vec![], false),
817 ];
818
819 let engine = ApplyEngine::new_noop();
820 let summary = engine.apply(&items, &ApplyOptions::default());
821
822 assert_eq!(summary.success.len(), 1);
824 assert_eq!(summary.success[0].component_id, "a");
825
826 assert_eq!(summary.held_back.len(), 1);
828 assert_eq!(summary.held_back[0].component_id, "b");
829 assert_eq!(summary.held_back[0].status, ApplyStatus::HeldBack);
830 assert!(!summary.held_back[0].was_selected);
831 }
832
833 #[test]
834 fn test_apply_deselect_all_results_in_noop() {
835 let items = vec![
836 make_planner_item("a", "1.0.0", "1.1.0", vec![], false),
837 make_planner_item("b", "1.0.0", "1.1.0", vec![], false),
838 ];
839
840 let engine = ApplyEngine::new_noop();
841 let summary = engine.apply(&items, &ApplyOptions::default());
842
843 assert_eq!(summary.success.len(), 0);
844 assert_eq!(summary.failed.len(), 0);
845 assert_eq!(summary.held_back.len(), 2);
846 }
847
848 #[test]
853 fn test_apply_status_serde_roundtrip() {
854 let statuses = [
855 ApplyStatus::Success,
856 ApplyStatus::Failed,
857 ApplyStatus::BlockedByFailure,
858 ApplyStatus::HeldBack,
859 ApplyStatus::Skipped,
860 ];
861 for status in &statuses {
862 let json = serde_json::to_string(status).unwrap();
863 let back: ApplyStatus = serde_json::from_str(&json).unwrap();
864 assert_eq!(*status, back);
865 }
866 }
867
868 #[test]
869 fn test_apply_status_serializes_to_kebab_case() {
870 assert_eq!(
871 serde_json::to_string(&ApplyStatus::Success).unwrap(),
872 "\"success\""
873 );
874 assert_eq!(
875 serde_json::to_string(&ApplyStatus::BlockedByFailure).unwrap(),
876 "\"blocked-by-failure\""
877 );
878 assert_eq!(
879 serde_json::to_string(&ApplyStatus::HeldBack).unwrap(),
880 "\"held-back\""
881 );
882 }
883
884 #[test]
889 fn test_apply_summary_complete_partitioning() {
890 let items = vec![
892 make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
893 make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
894 make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
895 make_planner_item("d", "1.0.0", "1.1.0", vec![], false),
896 ];
897
898 let engine = ApplyEngine::new_noop();
899 let options = ApplyOptions {
900 force_fail: HashSet::from(["c".to_string()]),
901 ..Default::default()
902 };
903 let summary = engine.apply(&items, &options);
904
905 assert!(summary.is_complete_partition());
907 assert_eq!(summary.total(), 4);
908 assert_eq!(summary.success.len(), 2);
909 assert_eq!(summary.failed.len(), 1);
910 assert_eq!(summary.held_back.len(), 1);
911 }
912
913 #[test]
918 fn test_apply_summary_has_failures() {
919 let mut summary = ApplySummary::new();
920 assert!(!summary.has_failures());
921
922 summary
923 .success
924 .push(ApplyItem::new("a", "1.0.0", "1.1.0", ApplyStatus::Success));
925 assert!(!summary.has_failures());
926
927 summary
928 .failed
929 .push(ApplyItem::new("b", "1.0.0", "1.1.0", ApplyStatus::Failed));
930 assert!(summary.has_failures());
931 }
932
933 #[test]
938 fn test_dependency_sort_empty() {
939 let items: Vec<&PlannerItem> = vec![];
940 let result = dependency_sort(items);
941 assert!(result.is_ok());
942 assert!(result.unwrap().is_empty());
943 }
944
945 #[test]
946 fn test_dependency_sort_no_deps() {
947 let items = [
948 make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
949 make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
950 ];
951 let result = dependency_sort(items.iter().collect());
952 assert!(result.is_ok());
953 assert_eq!(result.unwrap().len(), 2);
954 }
955
956 #[test]
957 fn test_dependency_sort_cycle_detected() {
958 let items = [
959 make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
960 make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
961 ];
962 let result = dependency_sort(items.iter().collect());
963 assert!(result.is_err());
964 }
965
966 #[test]
971 fn test_apply_item_held_back() {
972 let item = make_planner_item("test", "1.0.0", "1.1.0", vec![], false);
973 let apply_item = ApplyItem::held_back(&item);
974 assert_eq!(apply_item.component_id, "test");
975 assert_eq!(apply_item.status, ApplyStatus::HeldBack);
976 assert!(!apply_item.was_selected);
977 }
978
979 #[test]
980 fn test_apply_item_blocked_by_failure() {
981 let item = make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true);
982 let apply_item = ApplyItem::blocked_by_failure(&item, "b", "install failed");
983 assert_eq!(apply_item.component_id, "a");
984 assert_eq!(apply_item.status, ApplyStatus::BlockedByFailure);
985 assert!(apply_item.error_message.contains("b"));
986 assert!(apply_item.error_message.contains("install failed"));
987 assert!(apply_item.was_selected);
988 }
989
990 #[test]
995 fn test_apply_transitive_dependency_block() {
996 let items = vec![
998 make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
999 make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
1000 make_planner_item("c", "1.0.0", "1.1.0", vec!["b"], true),
1001 ];
1002
1003 let engine = ApplyEngine::new_noop();
1004 let options = ApplyOptions {
1005 force_fail: HashSet::from(["a".to_string()]),
1006 ..Default::default()
1007 };
1008 let summary = engine.apply(&items, &options);
1009
1010 assert_eq!(summary.failed.len(), 1);
1012 assert_eq!(summary.failed[0].component_id, "a");
1013
1014 assert_eq!(summary.blocked.len(), 2);
1016 let blocked_ids: HashSet<&str> = summary
1017 .blocked
1018 .iter()
1019 .map(|i| i.component_id.as_str())
1020 .collect();
1021 assert!(blocked_ids.contains("b"));
1022 assert!(blocked_ids.contains("c"));
1023 }
1024
1025 #[test]
1030 fn test_apply_diamond_dependency() {
1031 let items = vec![
1034 make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
1035 make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
1036 make_planner_item("c", "1.0.0", "1.1.0", vec!["a"], true),
1037 make_planner_item("d", "1.0.0", "1.1.0", vec!["b", "c"], true),
1038 ];
1039
1040 let engine = ApplyEngine::new_noop();
1041 let options = ApplyOptions {
1042 force_fail: HashSet::from(["a".to_string()]),
1043 ..Default::default()
1044 };
1045 let summary = engine.apply(&items, &options);
1046
1047 assert_eq!(summary.failed.len(), 1);
1048 assert_eq!(summary.blocked.len(), 3);
1049 assert_eq!(summary.success.len(), 0);
1050 }
1051
1052 #[test]
1057 fn test_apply_all_succeed() {
1058 let items = vec![
1063 make_planner_item("comp-a", "7.2.0", "7.2.1", vec![], true),
1064 make_planner_item("comp-b", "2.4.0", "2.4.1", vec!["comp-a"], true),
1065 make_planner_item("comp-c", "3.0.0", "3.0.1", vec!["comp-b"], true),
1066 ];
1067
1068 let engine = ApplyEngine::new_noop();
1069 let summary = engine.apply(&items, &ApplyOptions::default());
1070
1071 assert_eq!(summary.success.len(), 3);
1072 assert_eq!(summary.failed.len(), 0);
1073 assert_eq!(summary.blocked.len(), 0);
1074 assert_eq!(summary.held_back.len(), 0);
1075 }
1076
1077 #[test]
1082 fn test_apply_summary_serde_roundtrip() {
1083 let mut summary = ApplySummary::new();
1084 summary
1085 .success
1086 .push(ApplyItem::new("a", "1.0.0", "1.1.0", ApplyStatus::Success));
1087 summary
1088 .failed
1089 .push(ApplyItem::new("b", "1.0.0", "1.1.0", ApplyStatus::Failed));
1090 summary
1091 .held_back
1092 .push(ApplyItem::new("c", "1.0.0", "1.1.0", ApplyStatus::HeldBack));
1093
1094 let json = serde_json::to_string(&summary).unwrap();
1095 let back: ApplySummary = serde_json::from_str(&json).unwrap();
1096 assert_eq!(summary, back);
1097 }
1098
1099 #[test]
1104 fn test_apply_item_serde_roundtrip() {
1105 let item = ApplyItem {
1106 component_id: "pytorch".to_string(),
1107 current_version: "2.4.0".to_string(),
1108 proposed_version: "2.5.0".to_string(),
1109 status: ApplyStatus::Failed,
1110 error_message: "install script returned non-zero exit code".to_string(),
1111 dependencies: vec!["rocm".to_string()],
1112 was_selected: true,
1113 };
1114 let json = serde_json::to_string(&item).unwrap();
1115 let back: ApplyItem = serde_json::from_str(&json).unwrap();
1116 assert_eq!(item, back);
1117 }
1118}