1use crate::core::plan::PlanItem;
25use crate::orchestrator::planner::PlannerItem;
26use serde::{Deserialize, Serialize};
27use std::collections::{HashMap, HashSet};
28use std::fmt;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum ApplyStatus {
38 Success,
40 Failed,
42 BlockedByFailure,
44 HeldBack,
46 Skipped,
48}
49
50impl ApplyStatus {
51 pub fn label(self) -> &'static str {
53 match self {
54 ApplyStatus::Success => "success",
55 ApplyStatus::Failed => "failed",
56 ApplyStatus::BlockedByFailure => "blocked-by-failure",
57 ApplyStatus::HeldBack => "held-back",
58 ApplyStatus::Skipped => "skipped",
59 }
60 }
61}
62
63impl fmt::Display for ApplyStatus {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 f.write_str(self.label())
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct ApplyItem {
76 pub component_id: String,
78 pub current_version: String,
80 pub proposed_version: String,
82 pub status: ApplyStatus,
84 pub error_message: String,
86 pub dependencies: Vec<String>,
88 pub was_selected: bool,
90}
91
92impl ApplyItem {
93 pub fn new(
95 component_id: impl Into<String>,
96 current_version: impl Into<String>,
97 proposed_version: impl Into<String>,
98 status: ApplyStatus,
99 ) -> Self {
100 Self {
101 component_id: component_id.into(),
102 current_version: current_version.into(),
103 proposed_version: proposed_version.into(),
104 status,
105 error_message: String::new(),
106 dependencies: Vec::new(),
107 was_selected: true,
108 }
109 }
110
111 pub fn held_back(item: &PlannerItem) -> Self {
113 Self {
114 component_id: item.plan_item.component_id.clone(),
115 current_version: item.plan_item.current_version.clone(),
116 proposed_version: item.plan_item.proposed_version.clone(),
117 status: ApplyStatus::HeldBack,
118 error_message: String::new(),
119 dependencies: item.plan_item.dependencies.clone(),
120 was_selected: false,
121 }
122 }
123
124 pub fn blocked_by_failure(item: &PlannerItem, blocker_id: &str, reason: &str) -> Self {
126 Self {
127 component_id: item.plan_item.component_id.clone(),
128 current_version: item.plan_item.current_version.clone(),
129 proposed_version: item.plan_item.proposed_version.clone(),
130 status: ApplyStatus::BlockedByFailure,
131 error_message: format!("blocked by failed dependency '{blocker_id}': {reason}"),
132 dependencies: item.plan_item.dependencies.clone(),
133 was_selected: true,
134 }
135 }
136
137 pub fn skipped(item: &PlannerItem) -> Self {
139 Self {
140 component_id: item.plan_item.component_id.clone(),
141 current_version: item.plan_item.current_version.clone(),
142 proposed_version: item.plan_item.proposed_version.clone(),
143 status: ApplyStatus::Skipped,
144 error_message: String::new(),
145 dependencies: item.plan_item.dependencies.clone(),
146 was_selected: false,
147 }
148 }
149}
150
151pub trait ApplyExecutor: Send + Sync {
160 fn apply_component(&self, component_id: &str, proposed_version: &str) -> Result<(), String>;
164}
165
166pub struct NoOpExecutor;
173
174impl ApplyExecutor for NoOpExecutor {
175 fn apply_component(&self, _component_id: &str, _proposed_version: &str) -> Result<(), String> {
176 Ok(())
177 }
178}
179
180#[derive(Debug, Clone, Default)]
186pub struct ApplyOptions {
187 pub force_fail: HashSet<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198pub struct ApplySummary {
199 pub success: Vec<ApplyItem>,
201 pub failed: Vec<ApplyItem>,
203 pub held_back: Vec<ApplyItem>,
205 pub blocked: Vec<ApplyItem>,
207 pub skipped: Vec<ApplyItem>,
209}
210
211impl ApplySummary {
212 pub fn new() -> Self {
214 Self {
215 success: Vec::new(),
216 failed: Vec::new(),
217 held_back: Vec::new(),
218 blocked: Vec::new(),
219 skipped: Vec::new(),
220 }
221 }
222
223 pub fn total(&self) -> usize {
225 self.success.len()
226 + self.failed.len()
227 + self.held_back.len()
228 + self.blocked.len()
229 + self.skipped.len()
230 }
231
232 pub fn has_failures(&self) -> bool {
234 !self.failed.is_empty() || !self.blocked.is_empty()
235 }
236
237 pub fn is_complete_partition(&self) -> bool {
239 let all_ids: HashSet<&str> = self
240 .success
241 .iter()
242 .chain(self.failed.iter())
243 .chain(self.held_back.iter())
244 .chain(self.blocked.iter())
245 .chain(self.skipped.iter())
246 .map(|i| i.component_id.as_str())
247 .collect();
248
249 let total_count = self.success.len()
250 + self.failed.len()
251 + self.held_back.len()
252 + self.blocked.len()
253 + self.skipped.len();
254
255 all_ids.len() == total_count
256 }
257}
258
259impl Default for ApplySummary {
260 fn default() -> Self {
261 Self::new()
262 }
263}
264
265pub struct ApplyEngine {
281 executor: Box<dyn ApplyExecutor>,
283}
284
285impl ApplyEngine {
286 pub fn new(executor: impl ApplyExecutor + 'static) -> Self {
288 Self {
289 executor: Box::new(executor),
290 }
291 }
292
293 pub fn new_noop() -> Self {
295 Self::new(NoOpExecutor)
296 }
297
298 pub fn apply(&self, items: &[PlannerItem], options: &ApplyOptions) -> ApplySummary {
303 let mut summary = ApplySummary::new();
304
305 let (selected, deselected): (Vec<&PlannerItem>, Vec<&PlannerItem>) =
307 items.iter().partition(|i| i.selected);
308
309 for item in &deselected {
311 summary.held_back.push(ApplyItem::held_back(item));
312 }
313
314 let sorted = match dependency_sort(selected.clone()) {
316 Ok(sorted) => sorted,
317 Err(_) => {
318 for item in &selected {
320 let mut apply_item = ApplyItem::new(
321 &item.plan_item.component_id,
322 &item.plan_item.current_version,
323 &item.plan_item.proposed_version,
324 ApplyStatus::Failed,
325 );
326 apply_item.error_message = "dependency cycle detected".to_string();
327 apply_item.dependencies = item.plan_item.dependencies.clone();
328 summary.failed.push(apply_item);
329 }
330 return summary;
331 }
332 };
333
334 let mut failed_ids: HashSet<String> = HashSet::new();
336 let mut failure_reasons: HashMap<String, String> = HashMap::new();
337
338 for item in &sorted {
340 let blocked_by = self.find_failed_dependency(&item.plan_item, &failed_ids);
342
343 if let Some(blocker_id) = blocked_by {
344 let reason = failure_reasons
345 .get(&blocker_id)
346 .cloned()
347 .unwrap_or_default();
348 summary
349 .blocked
350 .push(ApplyItem::blocked_by_failure(item, &blocker_id, &reason));
351 failed_ids.insert(item.plan_item.component_id.clone());
353 failure_reasons.insert(
354 item.plan_item.component_id.clone(),
355 format!("dependency '{blocker_id}' failed"),
356 );
357 continue;
358 }
359
360 let status = if options.force_fail.contains(&item.plan_item.component_id) {
362 Err(format!(
363 "forced failure for component '{}'",
364 item.plan_item.component_id
365 ))
366 } else {
367 self.executor.apply_component(
368 &item.plan_item.component_id,
369 &item.plan_item.proposed_version,
370 )
371 };
372
373 match status {
374 Ok(()) => {
375 let mut apply_item = ApplyItem::new(
376 &item.plan_item.component_id,
377 &item.plan_item.current_version,
378 &item.plan_item.proposed_version,
379 ApplyStatus::Success,
380 );
381 apply_item.dependencies = item.plan_item.dependencies.clone();
382 summary.success.push(apply_item);
383 }
384 Err(err) => {
385 let mut apply_item = ApplyItem::new(
386 &item.plan_item.component_id,
387 &item.plan_item.current_version,
388 &item.plan_item.proposed_version,
389 ApplyStatus::Failed,
390 );
391 apply_item.error_message = err.clone();
392 apply_item.dependencies = item.plan_item.dependencies.clone();
393 summary.failed.push(apply_item);
394
395 failed_ids.insert(item.plan_item.component_id.clone());
396 failure_reasons.insert(item.plan_item.component_id.clone(), err);
397 }
398 }
399 }
400
401 summary
402 }
403
404 fn find_failed_dependency(
408 &self,
409 item: &PlanItem,
410 failed_ids: &HashSet<String>,
411 ) -> Option<String> {
412 item.dependencies
413 .iter()
414 .find(|dep| failed_ids.contains(*dep))
415 .cloned()
416 }
417}
418
419fn dependency_sort(items: Vec<&PlannerItem>) -> Result<Vec<&PlannerItem>, String> {
428 let n = items.len();
429 if n == 0 {
430 return Ok(items);
431 }
432
433 let id_to_idx: HashMap<&str, usize> = items
434 .iter()
435 .enumerate()
436 .map(|(i, item)| (item.plan_item.component_id.as_str(), i))
437 .collect();
438
439 let mut in_degree: Vec<usize> = vec![0; n];
441 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
442
443 for (idx, item) in items.iter().enumerate() {
444 for dep_id in &item.plan_item.dependencies {
445 if let Some(&dep_idx) = id_to_idx.get(dep_id.as_str()) {
446 adj[dep_idx].push(idx);
447 in_degree[idx] += 1;
448 }
449 }
450 }
451
452 let mut queue = std::collections::VecDeque::new();
454 for (i, °) in in_degree.iter().enumerate() {
455 if deg == 0 {
456 queue.push_back(i);
457 }
458 }
459
460 let mut sorted_indices = Vec::with_capacity(n);
461 while let Some(idx) = queue.pop_front() {
462 sorted_indices.push(idx);
463 for &neighbor in &adj[idx] {
464 in_degree[neighbor] -= 1;
465 if in_degree[neighbor] == 0 {
466 queue.push_back(neighbor);
467 }
468 }
469 }
470
471 if sorted_indices.len() != n {
472 return Err("circular dependency detected".to_string());
473 }
474
475 let mut sorted: Vec<&PlannerItem> = Vec::with_capacity(n);
477 for idx in sorted_indices {
478 sorted.push(items[idx]);
479 }
480
481 Ok(sorted)
482}
483
484#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::core::plan::PlanItem;
492 use crate::core::types::ValidationTier;
493 use crate::orchestrator::planner::{PlannerItem, UpdateClassification};
494
495 fn make_plan_item(id: &str, current: &str, proposed: &str, deps: Vec<&str>) -> PlanItem {
500 PlanItem::new(
501 id,
502 current,
503 proposed,
504 ValidationTier::Validated,
505 true,
506 "test",
507 deps.into_iter().map(|s| s.to_string()).collect(),
508 true,
509 )
510 }
511
512 fn make_planner_item(
513 id: &str,
514 current: &str,
515 proposed: &str,
516 deps: Vec<&str>,
517 selected: bool,
518 ) -> PlannerItem {
519 PlannerItem {
520 plan_item: make_plan_item(id, current, proposed, deps),
521 classification: UpdateClassification::Safe,
522 visible: true,
523 selected,
524 classification_reason: "test".to_string(),
525 requires_hardware_check: false,
526 min_rocm_version: String::new(),
527 }
528 }
529
530 fn make_planner_item_with_classification(
531 id: &str,
532 current: &str,
533 proposed: &str,
534 deps: Vec<&str>,
535 selected: bool,
536 classification: UpdateClassification,
537 ) -> PlannerItem {
538 PlannerItem {
539 plan_item: make_plan_item(id, current, proposed, deps),
540 classification,
541 visible: true,
542 selected,
543 classification_reason: "test".to_string(),
544 requires_hardware_check: false,
545 min_rocm_version: String::new(),
546 }
547 }
548
549 #[test]
554 fn test_apply_dependency_safe_order_b_before_a() {
555 let items = vec![
557 make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
558 make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
559 ];
560
561 let engine = ApplyEngine::new_noop();
562 let options = ApplyOptions::default();
563 let summary = engine.apply(&items, &options);
564
565 assert_eq!(summary.success.len(), 2);
567 assert_eq!(summary.failed.len(), 0);
568
569 let order: Vec<&str> = summary
571 .success
572 .iter()
573 .map(|i| i.component_id.as_str())
574 .collect();
575 let b_pos = order.iter().position(|&id| id == "b").unwrap();
576 let a_pos = order.iter().position(|&id| id == "a").unwrap();
577 assert!(
578 b_pos < a_pos,
579 "B should be applied before A (B at {b_pos}, A at {a_pos})"
580 );
581 }
582
583 #[test]
584 fn test_apply_dependency_chain_c_before_b_before_a() {
585 let items = vec![
587 make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
588 make_planner_item("b", "1.0.0", "1.1.0", vec!["c"], true),
589 make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
590 ];
591
592 let engine = ApplyEngine::new_noop();
593 let summary = engine.apply(&items, &ApplyOptions::default());
594
595 assert_eq!(summary.success.len(), 3);
596 let order: Vec<&str> = summary
597 .success
598 .iter()
599 .map(|i| i.component_id.as_str())
600 .collect();
601 let c_pos = order.iter().position(|&id| id == "c").unwrap();
602 let b_pos = order.iter().position(|&id| id == "b").unwrap();
603 let a_pos = order.iter().position(|&id| id == "a").unwrap();
604 assert!(c_pos < b_pos, "C before B");
605 assert!(b_pos < a_pos, "B before A");
606 }
607
608 #[test]
613 fn test_apply_failed_guarded_stops_dependents() {
614 let items = vec![
616 make_planner_item_with_classification(
617 "a",
618 "1.0.0",
619 "1.1.0",
620 vec!["b"],
621 true,
622 UpdateClassification::Guarded,
623 ),
624 make_planner_item_with_classification(
625 "b",
626 "1.0.0",
627 "1.1.0",
628 vec![],
629 true,
630 UpdateClassification::Guarded,
631 ),
632 ];
633
634 let engine = ApplyEngine::new_noop();
635 let options = ApplyOptions {
636 force_fail: HashSet::from(["b".to_string()]),
637 };
638 let summary = engine.apply(&items, &options);
639
640 assert_eq!(summary.failed.len(), 1);
642 assert_eq!(summary.failed[0].component_id, "b");
643
644 assert_eq!(summary.blocked.len(), 1);
646 assert_eq!(summary.blocked[0].component_id, "a");
647 assert_eq!(summary.blocked[0].status, ApplyStatus::BlockedByFailure);
648 assert!(summary.blocked[0].error_message.contains("b"));
649 }
650
651 #[test]
656 fn test_apply_unrelated_continues_after_failure() {
657 let items = vec![
659 make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
660 make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
661 make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
662 make_planner_item("d", "1.0.0", "1.1.0", vec![], true),
663 ];
664
665 let engine = ApplyEngine::new_noop();
666 let options = ApplyOptions {
667 force_fail: HashSet::from(["b".to_string()]),
668 };
669 let summary = engine.apply(&items, &options);
670
671 assert_eq!(summary.failed.len(), 1);
673 assert_eq!(summary.failed[0].component_id, "b");
674
675 assert_eq!(summary.blocked.len(), 1);
677 assert_eq!(summary.blocked[0].component_id, "a");
678
679 assert_eq!(summary.success.len(), 2);
681 let success_ids: HashSet<&str> = summary
682 .success
683 .iter()
684 .map(|i| i.component_id.as_str())
685 .collect();
686 assert!(success_ids.contains("c"));
687 assert!(success_ids.contains("d"));
688 }
689
690 #[test]
695 fn test_apply_user_can_untick_preselected_component() {
696 let items = vec![
698 make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
699 make_planner_item("b", "1.0.0", "1.1.0", vec![], false),
700 ];
701
702 let engine = ApplyEngine::new_noop();
703 let summary = engine.apply(&items, &ApplyOptions::default());
704
705 assert_eq!(summary.success.len(), 1);
707 assert_eq!(summary.success[0].component_id, "a");
708
709 assert_eq!(summary.held_back.len(), 1);
711 assert_eq!(summary.held_back[0].component_id, "b");
712 assert_eq!(summary.held_back[0].status, ApplyStatus::HeldBack);
713 assert!(!summary.held_back[0].was_selected);
714 }
715
716 #[test]
717 fn test_apply_deselect_all_results_in_noop() {
718 let items = vec![
719 make_planner_item("a", "1.0.0", "1.1.0", vec![], false),
720 make_planner_item("b", "1.0.0", "1.1.0", vec![], false),
721 ];
722
723 let engine = ApplyEngine::new_noop();
724 let summary = engine.apply(&items, &ApplyOptions::default());
725
726 assert_eq!(summary.success.len(), 0);
727 assert_eq!(summary.failed.len(), 0);
728 assert_eq!(summary.held_back.len(), 2);
729 }
730
731 #[test]
736 fn test_apply_status_serde_roundtrip() {
737 let statuses = [
738 ApplyStatus::Success,
739 ApplyStatus::Failed,
740 ApplyStatus::BlockedByFailure,
741 ApplyStatus::HeldBack,
742 ApplyStatus::Skipped,
743 ];
744 for status in &statuses {
745 let json = serde_json::to_string(status).unwrap();
746 let back: ApplyStatus = serde_json::from_str(&json).unwrap();
747 assert_eq!(*status, back);
748 }
749 }
750
751 #[test]
752 fn test_apply_status_serializes_to_kebab_case() {
753 assert_eq!(
754 serde_json::to_string(&ApplyStatus::Success).unwrap(),
755 "\"success\""
756 );
757 assert_eq!(
758 serde_json::to_string(&ApplyStatus::BlockedByFailure).unwrap(),
759 "\"blocked-by-failure\""
760 );
761 assert_eq!(
762 serde_json::to_string(&ApplyStatus::HeldBack).unwrap(),
763 "\"held-back\""
764 );
765 }
766
767 #[test]
772 fn test_apply_summary_complete_partitioning() {
773 let items = vec![
775 make_planner_item("a", "1.0.0", "1.1.0", vec![], 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![], false),
779 ];
780
781 let engine = ApplyEngine::new_noop();
782 let options = ApplyOptions {
783 force_fail: HashSet::from(["c".to_string()]),
784 };
785 let summary = engine.apply(&items, &options);
786
787 assert!(summary.is_complete_partition());
789 assert_eq!(summary.total(), 4);
790 assert_eq!(summary.success.len(), 2);
791 assert_eq!(summary.failed.len(), 1);
792 assert_eq!(summary.held_back.len(), 1);
793 }
794
795 #[test]
800 fn test_apply_summary_has_failures() {
801 let mut summary = ApplySummary::new();
802 assert!(!summary.has_failures());
803
804 summary
805 .success
806 .push(ApplyItem::new("a", "1.0.0", "1.1.0", ApplyStatus::Success));
807 assert!(!summary.has_failures());
808
809 summary
810 .failed
811 .push(ApplyItem::new("b", "1.0.0", "1.1.0", ApplyStatus::Failed));
812 assert!(summary.has_failures());
813 }
814
815 #[test]
820 fn test_dependency_sort_empty() {
821 let items: Vec<&PlannerItem> = vec![];
822 let result = dependency_sort(items);
823 assert!(result.is_ok());
824 assert!(result.unwrap().is_empty());
825 }
826
827 #[test]
828 fn test_dependency_sort_no_deps() {
829 let items = [
830 make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
831 make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
832 ];
833 let result = dependency_sort(items.iter().collect());
834 assert!(result.is_ok());
835 assert_eq!(result.unwrap().len(), 2);
836 }
837
838 #[test]
839 fn test_dependency_sort_cycle_detected() {
840 let items = [
841 make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
842 make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
843 ];
844 let result = dependency_sort(items.iter().collect());
845 assert!(result.is_err());
846 }
847
848 #[test]
853 fn test_apply_item_held_back() {
854 let item = make_planner_item("test", "1.0.0", "1.1.0", vec![], false);
855 let apply_item = ApplyItem::held_back(&item);
856 assert_eq!(apply_item.component_id, "test");
857 assert_eq!(apply_item.status, ApplyStatus::HeldBack);
858 assert!(!apply_item.was_selected);
859 }
860
861 #[test]
862 fn test_apply_item_blocked_by_failure() {
863 let item = make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true);
864 let apply_item = ApplyItem::blocked_by_failure(&item, "b", "install failed");
865 assert_eq!(apply_item.component_id, "a");
866 assert_eq!(apply_item.status, ApplyStatus::BlockedByFailure);
867 assert!(apply_item.error_message.contains("b"));
868 assert!(apply_item.error_message.contains("install failed"));
869 assert!(apply_item.was_selected);
870 }
871
872 #[test]
877 fn test_apply_transitive_dependency_block() {
878 let items = vec![
880 make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
881 make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
882 make_planner_item("c", "1.0.0", "1.1.0", vec!["b"], true),
883 ];
884
885 let engine = ApplyEngine::new_noop();
886 let options = ApplyOptions {
887 force_fail: HashSet::from(["a".to_string()]),
888 };
889 let summary = engine.apply(&items, &options);
890
891 assert_eq!(summary.failed.len(), 1);
893 assert_eq!(summary.failed[0].component_id, "a");
894
895 assert_eq!(summary.blocked.len(), 2);
897 let blocked_ids: HashSet<&str> = summary
898 .blocked
899 .iter()
900 .map(|i| i.component_id.as_str())
901 .collect();
902 assert!(blocked_ids.contains("b"));
903 assert!(blocked_ids.contains("c"));
904 }
905
906 #[test]
911 fn test_apply_diamond_dependency() {
912 let items = vec![
915 make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
916 make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
917 make_planner_item("c", "1.0.0", "1.1.0", vec!["a"], true),
918 make_planner_item("d", "1.0.0", "1.1.0", vec!["b", "c"], true),
919 ];
920
921 let engine = ApplyEngine::new_noop();
922 let options = ApplyOptions {
923 force_fail: HashSet::from(["a".to_string()]),
924 };
925 let summary = engine.apply(&items, &options);
926
927 assert_eq!(summary.failed.len(), 1);
928 assert_eq!(summary.blocked.len(), 3);
929 assert_eq!(summary.success.len(), 0);
930 }
931
932 #[test]
937 fn test_apply_all_succeed() {
938 let items = vec![
939 make_planner_item("rocm", "7.2.0", "7.2.1", vec![], true),
940 make_planner_item("pytorch", "2.4.0", "2.4.1", vec!["rocm"], true),
941 make_planner_item("triton", "3.0.0", "3.0.1", vec!["pytorch"], true),
942 ];
943
944 let engine = ApplyEngine::new_noop();
945 let summary = engine.apply(&items, &ApplyOptions::default());
946
947 assert_eq!(summary.success.len(), 3);
948 assert_eq!(summary.failed.len(), 0);
949 assert_eq!(summary.blocked.len(), 0);
950 assert_eq!(summary.held_back.len(), 0);
951 }
952
953 #[test]
958 fn test_apply_summary_serde_roundtrip() {
959 let mut summary = ApplySummary::new();
960 summary
961 .success
962 .push(ApplyItem::new("a", "1.0.0", "1.1.0", ApplyStatus::Success));
963 summary
964 .failed
965 .push(ApplyItem::new("b", "1.0.0", "1.1.0", ApplyStatus::Failed));
966 summary
967 .held_back
968 .push(ApplyItem::new("c", "1.0.0", "1.1.0", ApplyStatus::HeldBack));
969
970 let json = serde_json::to_string(&summary).unwrap();
971 let back: ApplySummary = serde_json::from_str(&json).unwrap();
972 assert_eq!(summary, back);
973 }
974
975 #[test]
980 fn test_apply_item_serde_roundtrip() {
981 let item = ApplyItem {
982 component_id: "pytorch".to_string(),
983 current_version: "2.4.0".to_string(),
984 proposed_version: "2.5.0".to_string(),
985 status: ApplyStatus::Failed,
986 error_message: "install script returned non-zero exit code".to_string(),
987 dependencies: vec!["rocm".to_string()],
988 was_selected: true,
989 };
990 let json = serde_json::to_string(&item).unwrap();
991 let back: ApplyItem = serde_json::from_str(&json).unwrap();
992 assert_eq!(item, back);
993 }
994}