1use crate::core::manifest::{Manifest, ManifestComponent};
31use crate::core::plan::{PlanItem, PlanItemInput};
32use crate::core::types::{ExecutorKind, ValidationTier};
33use serde::{Deserialize, Serialize};
34use std::collections::{HashMap, HashSet};
35use std::fmt;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum UpdateClassification {
45 Safe,
47 Guarded,
49 Blocked,
51 Candidate,
53 Experimental,
55}
56
57impl UpdateClassification {
58 pub fn label(self) -> &'static str {
60 match self {
61 UpdateClassification::Safe => "safe",
62 UpdateClassification::Guarded => "guarded",
63 UpdateClassification::Blocked => "blocked",
64 UpdateClassification::Candidate => "candidate",
65 UpdateClassification::Experimental => "experimental",
66 }
67 }
68
69 pub fn is_visible(self) -> bool {
71 !matches!(
72 self,
73 UpdateClassification::Blocked | UpdateClassification::Experimental
74 )
75 }
76
77 pub fn is_preselected(self) -> bool {
79 matches!(self, UpdateClassification::Safe)
80 }
81}
82
83impl fmt::Display for UpdateClassification {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 f.write_str(self.label())
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98pub struct PlannerItem {
99 #[serde(flatten)]
101 pub plan_item: PlanItem,
102 pub classification: UpdateClassification,
104 pub visible: bool,
106 pub selected: bool,
108 pub classification_reason: String,
110 pub requires_hardware_check: bool,
112 pub min_rocm_version: String,
114}
115
116#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
122pub struct CompatibilityContext {
123 pub rocm_version: String,
125 pub rocm_channel: String,
127 pub available_executors: HashSet<ExecutorKind>,
129 pub installed_components: HashSet<String>,
131 pub installed_versions: HashMap<String, String>,
133 pub gpu_architecture: String,
135 pub runtime_version: String,
137}
138
139impl CompatibilityContext {
140 pub fn new() -> Self {
142 Self::default()
143 }
144
145 pub fn is_installed(&self, component_id: &str) -> bool {
147 self.installed_components.contains(component_id)
148 }
149
150 pub fn installed_version(&self, component_id: &str) -> Option<&str> {
152 self.installed_versions
153 .get(component_id)
154 .map(|s| s.as_str())
155 }
156}
157
158#[derive(Debug, Clone, Default)]
164pub struct PlannerOptions {
165 pub scan_only: bool,
167 pub all_safe: bool,
169 pub include_experimental: bool,
171 pub target_components: Vec<String>,
173 pub json_output: bool,
175}
176
177#[derive(Debug, Clone, PartialEq)]
183pub enum PlannerError {
184 ComponentBlocked {
186 component_id: String,
187 reason: String,
188 },
189 ComponentUnknown { component_id: String },
191 RuntimeTooOld { current: String, required: String },
193 DependencyConflict {
195 component_id: String,
196 dependency_id: String,
197 reason: String,
198 },
199}
200
201impl fmt::Display for PlannerError {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 match self {
204 PlannerError::ComponentBlocked {
205 component_id,
206 reason,
207 } => {
208 write!(f, "component '{component_id}' is blocked: {reason}")
209 }
210 PlannerError::ComponentUnknown { component_id } => {
211 write!(f, "unknown component: '{component_id}'")
212 }
213 PlannerError::RuntimeTooOld { current, required } => {
214 write!(
215 f,
216 "runtime version {current} is too old; requires {required}. Run `rusty upgrade` first."
217 )
218 }
219 PlannerError::DependencyConflict {
220 component_id,
221 dependency_id,
222 reason,
223 } => {
224 write!(
225 f,
226 "dependency conflict for '{component_id}' on '{dependency_id}': {reason}"
227 )
228 }
229 }
230 }
231}
232
233impl std::error::Error for PlannerError {}
234
235pub struct UpdatePlanner;
249
250impl UpdatePlanner {
251 pub fn new() -> Self {
253 Self
254 }
255
256 pub fn build_plan(
261 &self,
262 manifest: &Manifest,
263 context: &CompatibilityContext,
264 options: &PlannerOptions,
265 ) -> Result<Vec<PlannerItem>, PlannerError> {
266 self.check_runtime_version(manifest, context)?;
268
269 if !options.target_components.is_empty() {
270 for target in &options.target_components {
271 if !manifest.components.iter().any(|c| c.id == *target) {
272 return Err(PlannerError::ComponentUnknown {
273 component_id: target.clone(),
274 });
275 }
276 }
277 }
278
279 let target_set: HashSet<&str> = options
280 .target_components
281 .iter()
282 .map(|s| s.as_str())
283 .collect();
284 let required_deps = self.collect_required_dependencies(manifest, &target_set);
285 let rocm_available_or_planned = !context.rocm_version.is_empty()
286 || target_set.contains("rocm")
287 || required_deps.contains("rocm");
288 let planned_rocm_version = manifest
289 .components
290 .iter()
291 .find(|c| {
292 c.id == "rocm" && (target_set.contains("rocm") || required_deps.contains("rocm"))
293 })
294 .map(|c| c.version.as_str());
295
296 let mut items = Vec::new();
297
298 for component in &manifest.components {
299 if !options.target_components.is_empty()
301 && !target_set.contains(component.id.as_str())
302 && !required_deps.contains(component.id.as_str())
303 {
304 continue;
305 }
306
307 let classification = self.classify_update(
309 component,
310 context,
311 rocm_available_or_planned,
312 planned_rocm_version,
313 );
314
315 if classification == UpdateClassification::Blocked {
317 if target_set.contains(component.id.as_str())
319 || required_deps.contains(component.id.as_str())
320 {
321 let reason = self.block_reason(
322 component,
323 context,
324 rocm_available_or_planned,
325 planned_rocm_version,
326 );
327 return Err(PlannerError::ComponentBlocked {
328 component_id: component.id.clone(),
329 reason,
330 });
331 }
332 items.push(self.build_planner_item(
334 component,
335 classification,
336 context,
337 rocm_available_or_planned,
338 planned_rocm_version,
339 ));
340 continue;
341 }
342
343 if classification == UpdateClassification::Experimental && !options.include_experimental
345 {
346 continue;
347 }
348
349 items.push(self.build_planner_item(
350 component,
351 classification,
352 context,
353 rocm_available_or_planned,
354 planned_rocm_version,
355 ));
356 }
357
358 if options.all_safe {
361 if options.include_experimental {
362 items.retain(|i| {
363 if target_set.contains(i.plan_item.component_id.as_str()) {
364 return true;
365 }
366 matches!(
367 i.classification,
368 UpdateClassification::Safe | UpdateClassification::Experimental
369 )
370 });
371 } else {
372 items.retain(|i| {
373 target_set.contains(i.plan_item.component_id.as_str())
374 || i.classification == UpdateClassification::Safe
375 });
376 }
377 }
378
379 if !target_set.is_empty() {
381 for item in &mut items {
382 if target_set.contains(item.plan_item.component_id.as_str()) {
383 item.selected = true;
384 item.plan_item.selected = true;
385 }
386 }
387 }
388
389 self.enforce_dependency_rules(&mut items)?;
391
392 Ok(items)
393 }
394
395 fn classify_update(
397 &self,
398 component: &ManifestComponent,
399 context: &CompatibilityContext,
400 rocm_available_or_planned: bool,
401 planned_rocm_version: Option<&str>,
402 ) -> UpdateClassification {
403 if component.validation_tier == ValidationTier::Experimental {
405 return UpdateClassification::Experimental;
406 }
407
408 if component.validation_tier == ValidationTier::Blocked {
410 return UpdateClassification::Blocked;
411 }
412
413 if !self.is_hardware_compatible(component, context) {
415 return UpdateClassification::Blocked;
416 }
417
418 if !self.is_executor_available(component, context) {
420 return UpdateClassification::Blocked;
421 }
422
423 if !self.is_rocm_compatible(
425 component,
426 context,
427 rocm_available_or_planned,
428 planned_rocm_version,
429 ) {
430 return UpdateClassification::Blocked;
431 }
432
433 let current_version = context.installed_version(&component.id).unwrap_or("");
435
436 if current_version.is_empty() {
438 return UpdateClassification::Candidate;
439 }
440
441 if current_version == component.version {
443 return UpdateClassification::Safe;
444 }
445
446 match self.version_bump_level(current_version, &component.version) {
448 BumpLevel::Patch => {
449 if component.validation_tier == ValidationTier::Validated {
451 UpdateClassification::Safe
452 } else {
453 UpdateClassification::Guarded
454 }
455 }
456 BumpLevel::Minor => UpdateClassification::Guarded,
457 BumpLevel::Major => UpdateClassification::Candidate,
458 BumpLevel::Unknown => {
459 UpdateClassification::Guarded
461 }
462 }
463 }
464
465 fn build_planner_item(
467 &self,
468 component: &ManifestComponent,
469 classification: UpdateClassification,
470 context: &CompatibilityContext,
471 rocm_available_or_planned: bool,
472 planned_rocm_version: Option<&str>,
473 ) -> PlannerItem {
474 let current_version = context
475 .installed_version(&component.id)
476 .unwrap_or("")
477 .to_string();
478
479 let visible = classification.is_visible();
480 let selected = classification.is_preselected() && current_version != component.version;
481 let isolation_safe = matches!(classification, UpdateClassification::Safe);
482
483 let classification_reason = self.classification_reason(
484 component,
485 classification,
486 context,
487 rocm_available_or_planned,
488 planned_rocm_version,
489 );
490
491 let dependencies = self.dependencies_for_component(component);
492
493 PlannerItem {
494 plan_item: PlanItem::new(PlanItemInput {
495 component_id: component.id.clone(),
496 current_version: current_version.clone(),
497 proposed_version: component.version.clone(),
498 validation_tier: component.validation_tier,
499 selected,
500 rationale: classification_reason.clone(),
501 dependencies,
502 isolation_safe,
503 }),
504 classification,
505 visible,
506 selected,
507 classification_reason,
508 requires_hardware_check: self.requires_hardware_check(&component.id),
509 min_rocm_version: component.min_rocm_version.clone(),
510 }
511 }
512
513 fn is_hardware_compatible(
515 &self,
516 component: &ManifestComponent,
517 context: &CompatibilityContext,
518 ) -> bool {
519 if !component.compatible_channels.is_empty()
521 && self.requires_rocm(&component.id)
522 && !component
523 .compatible_channels
524 .iter()
525 .any(|ch| ch.eq_ignore_ascii_case(&context.rocm_channel))
526 {
527 return false;
528 }
529 true
530 }
531
532 fn is_executor_available(
534 &self,
535 _component: &ManifestComponent,
536 context: &CompatibilityContext,
537 ) -> bool {
538 context
540 .available_executors
541 .contains(&ExecutorKind::LegacyScript)
542 || context.available_executors.contains(&ExecutorKind::Rust)
543 }
544
545 fn is_rocm_compatible(
547 &self,
548 component: &ManifestComponent,
549 context: &CompatibilityContext,
550 rocm_available_or_planned: bool,
551 planned_rocm_version: Option<&str>,
552 ) -> bool {
553 if !self.requires_rocm(&component.id) {
555 return true;
556 }
557
558 if component.id == "rocm" {
560 return true;
561 }
562
563 if context.rocm_version.is_empty() && !rocm_available_or_planned {
565 return false;
566 }
567
568 let effective_rocm_version = self.effective_rocm_version(context, planned_rocm_version);
570 if !component.min_rocm_version.is_empty()
571 && !effective_rocm_version.is_empty()
572 && !version_gte(&effective_rocm_version, &component.min_rocm_version)
573 {
574 return false;
575 }
576
577 if !component.compatible_channels.is_empty()
579 && !component
580 .compatible_channels
581 .iter()
582 .any(|ch| ch.eq_ignore_ascii_case(&context.rocm_channel))
583 {
584 return false;
585 }
586
587 true
588 }
589
590 fn requires_rocm(&self, component_id: &str) -> bool {
592 matches!(
593 component_id,
594 "rocm"
595 | "rocm-smi"
596 | "pytorch"
597 | "triton"
598 | "onnx"
599 | "migraphx"
600 | "flash-attn"
601 | "rccl"
602 | "vllm"
603 | "aiter"
604 | "deepspeed"
605 | "bitsandbytes"
606 | "llama-cpp"
607 )
608 }
609
610 fn requires_hardware_check(&self, component_id: &str) -> bool {
612 self.requires_rocm(component_id)
613 }
614
615 fn block_reason(
617 &self,
618 component: &ManifestComponent,
619 context: &CompatibilityContext,
620 rocm_available_or_planned: bool,
621 planned_rocm_version: Option<&str>,
622 ) -> String {
623 if component.validation_tier == ValidationTier::Blocked {
624 return "component is marked as blocked in manifest".to_string();
625 }
626
627 if !self.is_hardware_compatible(component, context) {
628 return format!(
629 "incompatible with ROCm channel '{}' (requires: {})",
630 context.rocm_channel,
631 component.compatible_channels.join(", ")
632 );
633 }
634
635 if !self.is_executor_available(component, context) {
636 return "no available executor for this platform".to_string();
637 }
638
639 if !self.is_rocm_compatible(
640 component,
641 context,
642 rocm_available_or_planned,
643 planned_rocm_version,
644 ) {
645 if context.rocm_version.is_empty() && !rocm_available_or_planned {
646 return "requires ROCm but ROCm is not installed".to_string();
647 }
648 let effective_rocm_version = self.effective_rocm_version(context, planned_rocm_version);
649 if !component.min_rocm_version.is_empty() {
650 if effective_rocm_version.is_empty() {
651 return format!(
652 "requires ROCm >= {} and will rely on the planned ROCm install",
653 component.min_rocm_version
654 );
655 }
656 return format!(
657 "requires ROCm >= {} but effective plan version is {}",
658 component.min_rocm_version, effective_rocm_version
659 );
660 }
661 return format!(
662 "incompatible with effective ROCm version ({})",
663 effective_rocm_version
664 );
665 }
666
667 "unknown reason".to_string()
668 }
669
670 fn classification_reason(
672 &self,
673 component: &ManifestComponent,
674 classification: UpdateClassification,
675 context: &CompatibilityContext,
676 rocm_available_or_planned: bool,
677 planned_rocm_version: Option<&str>,
678 ) -> String {
679 let current = context.installed_version(&component.id).unwrap_or("");
680
681 match classification {
682 UpdateClassification::Safe => {
683 if current == component.version {
684 format!("already up to date (v{})", component.version)
685 } else {
686 format!(
687 "patch update {} → {} (validated, no new deps)",
688 current, component.version
689 )
690 }
691 }
692 UpdateClassification::Guarded => {
693 format!(
694 "minor update {} → {} (new dependencies or config changes)",
695 current, component.version
696 )
697 }
698 UpdateClassification::Blocked => self.block_reason(
699 component,
700 context,
701 rocm_available_or_planned,
702 planned_rocm_version,
703 ),
704 UpdateClassification::Candidate => {
705 if current.is_empty() {
706 format!("new install v{}", component.version)
707 } else {
708 format!(
709 "major update {} → {} (compatible)",
710 current, component.version
711 )
712 }
713 }
714 UpdateClassification::Experimental => {
715 format!("experimental component v{}", component.version)
716 }
717 }
718 }
719
720 fn dependencies_for_component(&self, component: &ManifestComponent) -> Vec<String> {
722 if !component.dependencies.is_empty() {
723 return component.dependencies.clone();
724 }
725 self.derive_dependencies(&component.id)
726 }
727
728 fn derive_dependencies(&self, component_id: &str) -> Vec<String> {
730 match component_id {
731 "pytorch" => vec!["rocm".to_string()],
732 "triton" => vec!["pytorch".to_string()],
733 "flash-attn" => vec!["pytorch".to_string()],
734 "deepspeed" => vec!["pytorch".to_string()],
735 "vllm" => vec!["pytorch".to_string()],
736 "megatron" => vec!["pytorch".to_string(), "mpi4py".to_string()],
737 "onnx" => vec!["rocm".to_string()],
738 "migraphx" => vec!["rocm".to_string()],
739 "bitsandbytes" => vec!["pytorch".to_string()],
740 "aiter" => vec!["pytorch".to_string()],
741 "mpi4py" => vec![],
742 "wandb" => vec![],
743 "comfyui" => vec!["pytorch".to_string()],
744 "vllm-studio" => vec!["vllm".to_string()],
745 "textgen" => vec!["pytorch".to_string()],
746 "rocm" => vec![],
747 "rocm-smi" => vec!["rocm".to_string()],
748 "llama-cpp" => vec!["rocm".to_string()],
749 "permanent-env" => vec![],
750 _ => vec![],
751 }
752 }
753
754 fn collect_required_dependencies(
755 &self,
756 manifest: &Manifest,
757 targets: &HashSet<&str>,
758 ) -> HashSet<String> {
759 let mut required = HashSet::new();
760 let mut stack: Vec<String> = targets.iter().map(|s| (*s).to_string()).collect();
761
762 while let Some(component) = stack.pop() {
763 let deps = manifest
764 .components
765 .iter()
766 .find(|c| c.id == component)
767 .map(|c| self.dependencies_for_component(c))
768 .unwrap_or_else(|| self.derive_dependencies(&component));
769 for dep in deps {
770 if required.insert(dep.clone()) {
771 stack.push(dep);
772 }
773 }
774 }
775
776 for target in targets {
777 required.remove(*target);
778 }
779
780 required
781 }
782
783 fn effective_rocm_version(
784 &self,
785 context: &CompatibilityContext,
786 planned_rocm_version: Option<&str>,
787 ) -> String {
788 match planned_rocm_version {
789 Some(planned) if context.rocm_version.is_empty() => planned.to_string(),
790 Some(planned) if version_gte(planned, &context.rocm_version) => planned.to_string(),
791 _ => context.rocm_version.clone(),
792 }
793 }
794
795 fn version_bump_level(&self, current: &str, proposed: &str) -> BumpLevel {
797 let cur_parts = parse_version_parts(current);
798 let pro_parts = parse_version_parts(proposed);
799
800 match (cur_parts, pro_parts) {
801 (Some(cur), Some(pro)) if !cur.is_empty() && !pro.is_empty() => {
802 let len = cur.len().max(pro.len());
803 let mut cmp = std::cmp::Ordering::Equal;
804 for idx in 0..len {
805 let cur_part = *cur.get(idx).unwrap_or(&0);
806 let pro_part = *pro.get(idx).unwrap_or(&0);
807 cmp = pro_part.cmp(&cur_part);
808 if cmp != std::cmp::Ordering::Equal {
809 break;
810 }
811 }
812
813 match cmp {
814 std::cmp::Ordering::Less => BumpLevel::Unknown,
815 std::cmp::Ordering::Equal => BumpLevel::Patch,
816 std::cmp::Ordering::Greater => {
817 if pro.first().copied().unwrap_or(0) > cur.first().copied().unwrap_or(0) {
818 BumpLevel::Major
819 } else if pro.get(1).copied().unwrap_or(0)
820 > cur.get(1).copied().unwrap_or(0)
821 {
822 BumpLevel::Minor
823 } else {
824 BumpLevel::Patch
825 }
826 }
827 }
828 }
829 _ => BumpLevel::Unknown,
830 }
831 }
832
833 fn check_runtime_version(
835 &self,
836 manifest: &Manifest,
837 context: &CompatibilityContext,
838 ) -> Result<(), PlannerError> {
839 if manifest.min_runtime_version.is_empty() {
840 return Ok(());
841 }
842
843 if context.runtime_version.is_empty() {
844 return Ok(());
845 }
846
847 if !version_gte(&context.runtime_version, &manifest.min_runtime_version) {
848 return Err(PlannerError::RuntimeTooOld {
849 current: context.runtime_version.clone(),
850 required: manifest.min_runtime_version.clone(),
851 });
852 }
853
854 Ok(())
855 }
856
857 fn enforce_dependency_rules(&self, items: &mut [PlannerItem]) -> Result<(), PlannerError> {
862 let mut changed = true;
865 while changed {
866 changed = false;
867
868 let deps_to_select: Vec<String> = items
870 .iter()
871 .filter(|i| i.selected)
872 .flat_map(|i| i.plan_item.dependencies.clone())
873 .collect();
874
875 for dep_id in &deps_to_select {
876 if let Some(dep_item) = items
877 .iter_mut()
878 .find(|i| i.plan_item.component_id == *dep_id)
879 {
880 if !dep_item.selected
881 && dep_item.classification != UpdateClassification::Blocked
882 {
883 dep_item.selected = true;
884 changed = true;
885 }
886 }
887 }
888 }
889
890 Ok(())
891 }
892}
893
894impl Default for UpdatePlanner {
895 fn default() -> Self {
896 Self::new()
897 }
898}
899
900#[derive(Debug, Clone, Copy, PartialEq, Eq)]
906enum BumpLevel {
907 Patch,
908 Minor,
909 Major,
910 Unknown,
911}
912
913fn parse_version_parts(version: &str) -> Option<Vec<u32>> {
915 let base = version.split('-').next()?;
916 base.split('.')
917 .map(|s| s.parse::<u32>().ok())
918 .collect::<Option<Vec<_>>>()
919}
920
921fn version_gte(actual: &str, required: &str) -> bool {
925 let act = parse_version_parts(actual).unwrap_or_default();
926 let req = parse_version_parts(required).unwrap_or_default();
927
928 let max_len = act.len().max(req.len());
929 for i in 0..max_len {
930 let a = act.get(i).copied().unwrap_or(0);
931 let r = req.get(i).copied().unwrap_or(0);
932 if a > r {
933 return true;
934 }
935 if a < r {
936 return false;
937 }
938 }
939 true }
941
942#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
948pub struct ScanOutput {
949 pub installed: Vec<InstalledComponent>,
951 pub manifest_source: String,
953 pub rocm_channel: String,
955 pub rocm_version: String,
957 pub gpu_architecture: String,
959}
960
961#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
963pub struct InstalledComponent {
964 pub id: String,
965 pub version: String,
966 pub status: String,
967}
968
969#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
975pub struct PlanOutput {
976 pub plan: Vec<PlannerItemOutput>,
978 pub summary: PlanSummary,
980}
981
982#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
984pub struct PlannerItemOutput {
985 pub component_id: String,
986 pub current_version: String,
987 pub proposed_version: String,
988 pub classification: String,
989 pub risk_tier: String,
990 pub selected: bool,
991 pub visible: bool,
992 pub rationale: String,
993 pub dependencies: Vec<String>,
994}
995
996impl From<&PlannerItem> for PlannerItemOutput {
997 fn from(item: &PlannerItem) -> Self {
998 Self {
999 component_id: item.plan_item.component_id.clone(),
1000 current_version: item.plan_item.current_version.clone(),
1001 proposed_version: item.plan_item.proposed_version.clone(),
1002 classification: item.classification.label().to_string(),
1003 risk_tier: item.plan_item.risk_tier.label().to_string(),
1004 selected: item.selected,
1005 visible: item.visible,
1006 rationale: item.plan_item.rationale.clone(),
1007 dependencies: item.plan_item.dependencies.clone(),
1008 }
1009 }
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1014pub struct PlanSummary {
1015 pub total: usize,
1016 pub safe: usize,
1017 pub guarded: usize,
1018 pub candidate: usize,
1019 pub experimental: usize,
1020 pub blocked: usize,
1021 pub selected: usize,
1022}
1023
1024impl PlanSummary {
1025 pub fn from_items(items: &[PlannerItem]) -> Self {
1027 Self {
1028 total: items.len(),
1029 safe: items
1030 .iter()
1031 .filter(|i| i.classification == UpdateClassification::Safe)
1032 .count(),
1033 guarded: items
1034 .iter()
1035 .filter(|i| i.classification == UpdateClassification::Guarded)
1036 .count(),
1037 candidate: items
1038 .iter()
1039 .filter(|i| i.classification == UpdateClassification::Candidate)
1040 .count(),
1041 experimental: items
1042 .iter()
1043 .filter(|i| i.classification == UpdateClassification::Experimental)
1044 .count(),
1045 blocked: items
1046 .iter()
1047 .filter(|i| i.classification == UpdateClassification::Blocked)
1048 .count(),
1049 selected: items.iter().filter(|i| i.selected).count(),
1050 }
1051 }
1052}
1053
1054#[cfg(test)]
1059mod tests {
1060 use super::*;
1061 use crate::core::manifest::{Manifest, ManifestComponent};
1062 use crate::core::types::{Category, ValidationTier};
1063
1064 fn make_component(id: &str, version: &str, tier: ValidationTier) -> ManifestComponent {
1069 ManifestComponent {
1070 id: id.to_string(),
1071 version: version.to_string(),
1072 script: format!("install_{id}.sh"),
1073 category: Category::Core,
1074 validation_tier: tier,
1075 min_rocm_version: String::new(),
1076 compatible_channels: vec![],
1077 dependencies: vec![],
1078 }
1079 }
1080
1081 fn make_manifest(components: Vec<ManifestComponent>) -> Manifest {
1082 Manifest {
1083 schema_version: 2,
1084 sequence: 1,
1085 generated_at: String::new(),
1086 expires_at: None,
1087 min_runtime_version: String::new(),
1088 components,
1089 signature: None,
1090 }
1091 }
1092
1093 fn make_context() -> CompatibilityContext {
1094 let mut ctx = CompatibilityContext::new();
1095 ctx.rocm_version = "7.2.1".to_string();
1096 ctx.rocm_channel = "latest".to_string();
1097 ctx.gpu_architecture = "gfx1100".to_string();
1098 ctx.available_executors = HashSet::from([
1099 ExecutorKind::LegacyScript,
1100 ExecutorKind::Rust,
1101 ExecutorKind::ExternalPackageManager,
1102 ]);
1103 ctx
1104 }
1105
1106 fn planner() -> UpdatePlanner {
1107 UpdatePlanner::new()
1108 }
1109
1110 #[test]
1115 fn test_safe_classification_patch_bump_validated() {
1116 let mut context = make_context();
1117 context
1118 .installed_versions
1119 .insert("pytorch".to_string(), "2.4.0".to_string());
1120 context.installed_components.insert("pytorch".to_string());
1121
1122 let component = make_component("pytorch", "2.4.1", ValidationTier::Validated);
1123 let classification =
1124 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1125 assert_eq!(classification, UpdateClassification::Safe);
1126 }
1127
1128 #[test]
1129 fn test_safe_classification_same_version_reinstall() {
1130 let mut context = make_context();
1131 context
1132 .installed_versions
1133 .insert("pytorch".to_string(), "2.4.0".to_string());
1134 context.installed_components.insert("pytorch".to_string());
1135
1136 let component = make_component("pytorch", "2.4.0", ValidationTier::Validated);
1137 let classification =
1138 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1139 assert_eq!(classification, UpdateClassification::Safe);
1140 }
1141
1142 #[test]
1147 fn test_guarded_classification_minor_bump() {
1148 let mut context = make_context();
1149 context
1150 .installed_versions
1151 .insert("pytorch".to_string(), "2.4.0".to_string());
1152 context.installed_components.insert("pytorch".to_string());
1153
1154 let component = make_component("pytorch", "2.5.0", ValidationTier::Validated);
1155 let classification =
1156 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1157 assert_eq!(classification, UpdateClassification::Guarded);
1158 }
1159
1160 #[test]
1161 fn test_guarded_classification_patch_bump_candidate_tier() {
1162 let mut context = make_context();
1163 context
1164 .installed_versions
1165 .insert("triton".to_string(), "3.0.0".to_string());
1166 context.installed_components.insert("triton".to_string());
1167
1168 let component = make_component("triton", "3.0.1", ValidationTier::Candidate);
1170 let classification =
1171 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1172 assert_eq!(classification, UpdateClassification::Guarded);
1173 }
1174
1175 #[test]
1180 fn test_blocked_classification_missing_executor() {
1181 let mut context = CompatibilityContext::new();
1182 context.rocm_version = "7.2.1".to_string();
1184
1185 let component = make_component("pytorch", "2.4.0", ValidationTier::Validated);
1186 let classification =
1187 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1188 assert_eq!(classification, UpdateClassification::Blocked);
1189 }
1190
1191 #[test]
1192 fn test_blocked_classification_blocked_tier() {
1193 let context = make_context();
1194 let component = make_component("bad-pkg", "1.0.0", ValidationTier::Blocked);
1195 let classification =
1196 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1197 assert_eq!(classification, UpdateClassification::Blocked);
1198 }
1199
1200 #[test]
1205 fn test_candidate_classification_major_bump() {
1206 let mut context = make_context();
1207 context
1208 .installed_versions
1209 .insert("pytorch".to_string(), "2.4.0".to_string());
1210 context.installed_components.insert("pytorch".to_string());
1211
1212 let component = make_component("pytorch", "3.0.0", ValidationTier::Validated);
1213 let classification =
1214 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1215 assert_eq!(classification, UpdateClassification::Candidate);
1216 }
1217
1218 #[test]
1219 fn test_candidate_classification_new_install() {
1220 let context = make_context();
1221 let component = make_component("triton", "3.1.0", ValidationTier::Validated);
1223 let classification =
1224 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1225 assert_eq!(classification, UpdateClassification::Candidate);
1226 }
1227
1228 #[test]
1233 fn test_experimental_classification() {
1234 let context = make_context();
1235 let component = make_component("deepspeed", "0.15.0", ValidationTier::Experimental);
1236 let classification =
1237 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1238 assert_eq!(classification, UpdateClassification::Experimental);
1239 }
1240
1241 #[test]
1242 fn test_experimental_hidden_without_flag() {
1243 let context = make_context();
1244 let manifest = make_manifest(vec![make_component(
1245 "deepspeed",
1246 "0.15.0",
1247 ValidationTier::Experimental,
1248 )]);
1249
1250 let options = PlannerOptions {
1251 include_experimental: false,
1252 ..Default::default()
1253 };
1254
1255 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1256 assert!(
1257 items.is_empty(),
1258 "Experimental items should be hidden without flag"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_experimental_visible_with_flag() {
1264 let context = make_context();
1265 let manifest = make_manifest(vec![make_component(
1266 "deepspeed",
1267 "0.15.0",
1268 ValidationTier::Experimental,
1269 )]);
1270
1271 let options = PlannerOptions {
1272 include_experimental: true,
1273 ..Default::default()
1274 };
1275
1276 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1277 assert_eq!(items.len(), 1);
1278 assert_eq!(items[0].classification, UpdateClassification::Experimental);
1279 }
1280
1281 #[test]
1286 fn test_safe_updates_preselected() {
1287 let mut context = make_context();
1288 context
1289 .installed_versions
1290 .insert("pytorch".to_string(), "2.4.0".to_string());
1291 context.installed_components.insert("pytorch".to_string());
1292
1293 let manifest = make_manifest(vec![make_component(
1294 "pytorch",
1295 "2.4.1",
1296 ValidationTier::Validated,
1297 )]);
1298
1299 let options = PlannerOptions::default();
1300 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1301
1302 assert_eq!(items.len(), 1);
1303 assert!(items[0].selected, "Safe updates should be preselected");
1304 }
1305
1306 #[test]
1311 fn test_guarded_updates_not_preselected() {
1312 let mut context = make_context();
1313 context
1314 .installed_versions
1315 .insert("pytorch".to_string(), "2.4.0".to_string());
1316 context.installed_components.insert("pytorch".to_string());
1317
1318 let manifest = make_manifest(vec![make_component(
1319 "pytorch",
1320 "2.5.0",
1321 ValidationTier::Validated,
1322 )]);
1323
1324 let options = PlannerOptions::default();
1325 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1326
1327 assert_eq!(items.len(), 1);
1328 assert!(
1329 !items[0].selected,
1330 "Guarded updates should NOT be preselected"
1331 );
1332 }
1333
1334 #[test]
1339 fn test_candidate_updates_visible_not_preselected() {
1340 let mut context = make_context();
1341 context
1342 .installed_versions
1343 .insert("pytorch".to_string(), "2.4.0".to_string());
1344 context.installed_components.insert("pytorch".to_string());
1345
1346 let manifest = make_manifest(vec![make_component(
1347 "pytorch",
1348 "3.0.0",
1349 ValidationTier::Validated,
1350 )]);
1351
1352 let options = PlannerOptions::default();
1353 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1354
1355 assert_eq!(items.len(), 1);
1356 assert!(items[0].visible, "Candidate updates should be visible");
1357 assert!(
1358 !items[0].selected,
1359 "Candidate updates should NOT be preselected"
1360 );
1361 }
1362
1363 #[test]
1373 fn test_blocked_not_in_default_plan() {
1374 let context = make_context();
1375 let manifest = make_manifest(vec![make_component(
1376 "bad-pkg",
1377 "1.0.0",
1378 ValidationTier::Blocked,
1379 )]);
1380
1381 let options = PlannerOptions::default();
1382 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1383
1384 assert_eq!(items.len(), 1);
1386 assert!(!items[0].visible);
1387 assert!(!items[0].selected);
1388 }
1389
1390 #[test]
1391 fn test_blocked_explicit_target_fails() {
1392 let context = make_context();
1393 let manifest = make_manifest(vec![make_component(
1394 "bad-pkg",
1395 "1.0.0",
1396 ValidationTier::Blocked,
1397 )]);
1398
1399 let options = PlannerOptions {
1400 target_components: vec!["bad-pkg".to_string()],
1401 ..Default::default()
1402 };
1403
1404 let result = planner().build_plan(&manifest, &context, &options);
1405 assert!(result.is_err());
1406 match result.unwrap_err() {
1407 PlannerError::ComponentBlocked {
1408 component_id,
1409 reason,
1410 } => {
1411 assert_eq!(component_id, "bad-pkg");
1412 assert!(!reason.is_empty());
1413 }
1414 other => panic!("Expected ComponentBlocked, got: {other:?}"),
1415 }
1416 }
1417
1418 #[test]
1423 fn test_dependency_auto_selected() {
1424 let mut context = make_context();
1425 context
1426 .installed_versions
1427 .insert("rocm".to_string(), "7.2.0".to_string());
1428 context.installed_components.insert("rocm".to_string());
1429 context
1430 .installed_versions
1431 .insert("pytorch".to_string(), "2.4.0".to_string());
1432 context.installed_components.insert("pytorch".to_string());
1433
1434 let manifest = make_manifest(vec![
1435 make_component("rocm", "7.2.1", ValidationTier::Validated), make_component("pytorch", "2.4.1", ValidationTier::Validated), ]);
1438
1439 let options = PlannerOptions::default();
1440 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1441
1442 let rocm_item = items
1444 .iter()
1445 .find(|i| i.plan_item.component_id == "rocm")
1446 .unwrap();
1447 let pytorch_item = items
1448 .iter()
1449 .find(|i| i.plan_item.component_id == "pytorch")
1450 .unwrap();
1451
1452 assert!(rocm_item.selected, "ROCm should be selected (safe)");
1453 assert!(pytorch_item.selected, "PyTorch should be selected (safe)");
1454 }
1455
1456 #[test]
1461 fn test_scan_only_produces_plan() {
1462 let mut context = make_context();
1463 context
1464 .installed_versions
1465 .insert("pytorch".to_string(), "2.4.0".to_string());
1466 context.installed_components.insert("pytorch".to_string());
1467
1468 let manifest = make_manifest(vec![make_component(
1469 "pytorch",
1470 "2.4.1",
1471 ValidationTier::Validated,
1472 )]);
1473
1474 let options = PlannerOptions {
1475 scan_only: true,
1476 ..Default::default()
1477 };
1478
1479 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1480 assert_eq!(items.len(), 1);
1481 }
1483
1484 #[test]
1489 fn test_all_safe_filters_to_safe_only() {
1490 let mut context = make_context();
1491 context
1492 .installed_versions
1493 .insert("pytorch".to_string(), "2.4.0".to_string());
1494 context.installed_components.insert("pytorch".to_string());
1495 context
1496 .installed_versions
1497 .insert("triton".to_string(), "3.0.0".to_string());
1498 context.installed_components.insert("triton".to_string());
1499
1500 let manifest = make_manifest(vec![
1501 make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), ]);
1504
1505 let options = PlannerOptions {
1506 all_safe: true,
1507 ..Default::default()
1508 };
1509
1510 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1511
1512 assert_eq!(items.len(), 1, "Only safe items should remain");
1513 assert_eq!(items[0].classification, UpdateClassification::Safe);
1514 assert_eq!(items[0].plan_item.component_id, "pytorch");
1515 }
1516
1517 #[test]
1518 fn test_targeted_candidate_is_selected() {
1519 let mut context = make_context();
1520 context
1521 .installed_versions
1522 .insert("pytorch".to_string(), "2.4.0".to_string());
1523 context.installed_components.insert("pytorch".to_string());
1524
1525 let manifest = make_manifest(vec![make_component(
1526 "pytorch",
1527 "3.0.0",
1528 ValidationTier::Validated,
1529 )]);
1530
1531 let options = PlannerOptions {
1532 target_components: vec!["pytorch".to_string()],
1533 ..Default::default()
1534 };
1535
1536 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1537 assert_eq!(items.len(), 1);
1538 assert_eq!(items[0].classification, UpdateClassification::Candidate);
1539 assert!(items[0].selected);
1540 assert!(items[0].plan_item.selected);
1541 }
1542
1543 #[test]
1544 fn test_all_safe_keeps_explicit_targeted_non_safe_component() {
1545 let mut context = make_context();
1546 context
1547 .installed_versions
1548 .insert("pytorch".to_string(), "2.4.0".to_string());
1549 context.installed_components.insert("pytorch".to_string());
1550 context
1551 .installed_versions
1552 .insert("triton".to_string(), "3.0.0".to_string());
1553 context.installed_components.insert("triton".to_string());
1554
1555 let manifest = make_manifest(vec![
1556 make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), ]);
1559
1560 let options = PlannerOptions {
1561 all_safe: true,
1562 target_components: vec!["triton".to_string()],
1563 ..Default::default()
1564 };
1565
1566 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1567
1568 assert_eq!(
1569 items.len(),
1570 2,
1571 "Targeted component and dependencies are in scope"
1572 );
1573
1574 let triton = items
1575 .iter()
1576 .find(|i| i.plan_item.component_id == "triton")
1577 .unwrap();
1578 assert_eq!(triton.classification, UpdateClassification::Guarded);
1579 assert!(triton.selected);
1580 assert!(triton.plan_item.selected);
1581
1582 let pytorch = items
1583 .iter()
1584 .find(|i| i.plan_item.component_id == "pytorch")
1585 .unwrap();
1586 assert_eq!(pytorch.classification, UpdateClassification::Safe);
1587 assert!(pytorch.selected);
1588 }
1589
1590 #[test]
1595 fn test_targeted_blocked_component_fails() {
1596 let context = make_context();
1597 let manifest = make_manifest(vec![
1598 make_component("pytorch", "2.5.0", ValidationTier::Validated),
1599 make_component("bad-pkg", "1.0.0", ValidationTier::Blocked),
1600 ]);
1601
1602 let options = PlannerOptions {
1603 target_components: vec!["bad-pkg".to_string()],
1604 ..Default::default()
1605 };
1606
1607 let result = planner().build_plan(&manifest, &context, &options);
1608 assert!(result.is_err(), "Targeting a blocked component should fail");
1609 }
1610
1611 #[test]
1612 fn test_targeted_rocm_component_allows_planned_rocm_prereq() {
1613 let context = make_context();
1614 let manifest = make_manifest(vec![
1615 make_component("rocm", "7.2.4", ValidationTier::Validated),
1616 make_component("pytorch", "2.5.0", ValidationTier::Validated),
1617 ]);
1618
1619 let options = PlannerOptions {
1620 target_components: vec!["pytorch".to_string()],
1621 ..Default::default()
1622 };
1623
1624 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1625 let ids: HashSet<_> = items
1626 .iter()
1627 .map(|i| i.plan_item.component_id.as_str())
1628 .collect();
1629 assert!(ids.contains("rocm"));
1630 assert!(ids.contains("pytorch"));
1631 }
1632
1633 #[test]
1634 fn test_targeted_component_uses_manifest_declared_dependencies() {
1635 let context = make_context();
1636 let mut custom = make_component("custom-runtime", "1.2.0", ValidationTier::Validated);
1637 custom.dependencies = vec!["rocm".to_string(), "mpi4py".to_string()];
1638
1639 let manifest = make_manifest(vec![
1640 make_component("rocm", "7.2.4", ValidationTier::Validated),
1641 make_component("mpi4py", "4.0.0", ValidationTier::Validated),
1642 custom,
1643 ]);
1644
1645 let options = PlannerOptions {
1646 target_components: vec!["custom-runtime".to_string()],
1647 ..Default::default()
1648 };
1649
1650 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1651 let ids: HashSet<_> = items
1652 .iter()
1653 .map(|i| i.plan_item.component_id.as_str())
1654 .collect();
1655 assert!(ids.contains("custom-runtime"));
1656 assert!(ids.contains("rocm"));
1657 assert!(ids.contains("mpi4py"));
1658 }
1659
1660 #[test]
1661 fn test_targeted_component_with_planned_rocm_not_blocked_by_min_version_on_fresh_host() {
1662 let context = make_context();
1663 let manifest = make_manifest(vec![
1664 make_component("rocm", "7.2.4", ValidationTier::Validated),
1665 ManifestComponent {
1666 min_rocm_version: "7.2.4".to_string(),
1667 ..make_component("llama-cpp", "0.2.0", ValidationTier::Validated)
1668 },
1669 ]);
1670
1671 let options = PlannerOptions {
1672 target_components: vec!["llama-cpp".to_string()],
1673 ..Default::default()
1674 };
1675
1676 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1677 let ids: HashSet<_> = items
1678 .iter()
1679 .map(|i| i.plan_item.component_id.as_str())
1680 .collect();
1681 assert!(ids.contains("rocm"));
1682 assert!(ids.contains("llama-cpp"));
1683 }
1684
1685 #[test]
1686 fn test_targeted_unknown_component_fails() {
1687 let context = make_context();
1688 let manifest = make_manifest(vec![make_component(
1689 "pytorch",
1690 "2.5.0",
1691 ValidationTier::Validated,
1692 )]);
1693
1694 let options = PlannerOptions {
1695 target_components: vec!["nonexistent".to_string()],
1696 ..Default::default()
1697 };
1698
1699 let result = planner().build_plan(&manifest, &context, &options);
1700 assert!(
1701 result.is_err(),
1702 "Targeting a nonexistent component should fail"
1703 );
1704 }
1705
1706 #[test]
1712 fn test_planner_works_with_baseline_manifest() {
1713 let context = make_context();
1714 let manifest = make_manifest(vec![make_component(
1715 "pytorch",
1716 "2.4.0",
1717 ValidationTier::Validated,
1718 )]);
1719
1720 let options = PlannerOptions::default();
1721 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1722 assert!(!items.is_empty());
1723 }
1724
1725 #[test]
1730 fn test_runtime_too_old_error() {
1731 let error = PlannerError::RuntimeTooOld {
1732 current: "0.1.0".to_string(),
1733 required: "0.2.0".to_string(),
1734 };
1735 let msg = error.to_string();
1736 assert!(msg.contains("0.1.0"));
1737 assert!(msg.contains("0.2.0"));
1738 assert!(msg.contains("rusty upgrade"));
1739 }
1740
1741 #[test]
1746 fn test_rocm_channel_in_context() {
1747 let mut context = make_context();
1748 context.rocm_channel = "legacy".to_string();
1749
1750 let manifest = make_manifest(vec![make_component(
1751 "pytorch",
1752 "2.4.0",
1753 ValidationTier::Validated,
1754 )]);
1755
1756 let options = PlannerOptions::default();
1757 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1758 assert!(!items.is_empty());
1759 }
1760
1761 #[test]
1771 fn test_classification_serde_roundtrip() {
1772 let classifications = [
1773 UpdateClassification::Safe,
1774 UpdateClassification::Guarded,
1775 UpdateClassification::Blocked,
1776 UpdateClassification::Candidate,
1777 UpdateClassification::Experimental,
1778 ];
1779 for c in &classifications {
1780 let json = serde_json::to_string(c).unwrap();
1781 let back: UpdateClassification = serde_json::from_str(&json).unwrap();
1782 assert_eq!(*c, back);
1783 }
1784 }
1785
1786 #[test]
1791 fn test_plan_summary_from_items() {
1792 let mut context = make_context();
1793 context
1794 .installed_versions
1795 .insert("pytorch".to_string(), "2.4.0".to_string());
1796 context.installed_components.insert("pytorch".to_string());
1797 context
1798 .installed_versions
1799 .insert("triton".to_string(), "3.0.0".to_string());
1800 context.installed_components.insert("triton".to_string());
1801
1802 let manifest = make_manifest(vec![
1803 make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), make_component("new-pkg", "1.0.0", ValidationTier::Validated), make_component("bad-pkg", "1.0.0", ValidationTier::Blocked), ]);
1808
1809 let options = PlannerOptions::default();
1810 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1811
1812 let summary = PlanSummary::from_items(&items);
1813 assert_eq!(summary.total, 4);
1814 assert_eq!(summary.safe, 1);
1815 assert_eq!(summary.guarded, 1);
1816 assert_eq!(summary.candidate, 1);
1817 assert_eq!(summary.blocked, 1);
1818 assert_eq!(summary.selected, 1); }
1820
1821 #[test]
1826 fn test_planner_item_output_conversion() {
1827 let mut context = make_context();
1828 context
1829 .installed_versions
1830 .insert("pytorch".to_string(), "2.4.0".to_string());
1831 context.installed_components.insert("pytorch".to_string());
1832
1833 let manifest = make_manifest(vec![make_component(
1834 "pytorch",
1835 "2.4.1",
1836 ValidationTier::Validated,
1837 )]);
1838
1839 let options = PlannerOptions::default();
1840 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1841
1842 let output: Vec<PlannerItemOutput> = items.iter().map(PlannerItemOutput::from).collect();
1843 assert_eq!(output.len(), 1);
1844 assert_eq!(output[0].component_id, "pytorch");
1845 assert_eq!(output[0].classification, "safe");
1846 assert!(output[0].selected);
1847 }
1848
1849 #[test]
1854 fn test_full_plan_build_mixed_classifications() {
1855 let mut context = make_context();
1856 context
1857 .installed_versions
1858 .insert("rocm".to_string(), "7.2.0".to_string());
1859 context.installed_components.insert("rocm".to_string());
1860 context
1861 .installed_versions
1862 .insert("pytorch".to_string(), "2.4.0".to_string());
1863 context.installed_components.insert("pytorch".to_string());
1864 context
1865 .installed_versions
1866 .insert("triton".to_string(), "3.0.0".to_string());
1867 context.installed_components.insert("triton".to_string());
1868
1869 let manifest = make_manifest(vec![
1870 make_component("rocm", "7.2.1", ValidationTier::Validated), make_component("pytorch", "2.5.0", ValidationTier::Validated), make_component("triton", "4.0.0", ValidationTier::Validated), make_component("wandb", "0.17.0", ValidationTier::Validated), make_component("deepspeed", "0.15.0", ValidationTier::Experimental), ]);
1876
1877 let options = PlannerOptions {
1878 include_experimental: true,
1879 ..Default::default()
1880 };
1881
1882 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1883
1884 assert_eq!(items.len(), 5);
1885
1886 let rocm = items
1887 .iter()
1888 .find(|i| i.plan_item.component_id == "rocm")
1889 .unwrap();
1890 assert_eq!(rocm.classification, UpdateClassification::Safe);
1891 assert!(rocm.selected);
1892
1893 let pytorch = items
1894 .iter()
1895 .find(|i| i.plan_item.component_id == "pytorch")
1896 .unwrap();
1897 assert_eq!(pytorch.classification, UpdateClassification::Guarded);
1898 assert!(!pytorch.selected);
1899
1900 let triton = items
1901 .iter()
1902 .find(|i| i.plan_item.component_id == "triton")
1903 .unwrap();
1904 assert_eq!(triton.classification, UpdateClassification::Candidate);
1905 assert!(!triton.selected);
1906
1907 let wandb = items
1908 .iter()
1909 .find(|i| i.plan_item.component_id == "wandb")
1910 .unwrap();
1911 assert_eq!(wandb.classification, UpdateClassification::Candidate);
1912 assert!(!wandb.selected);
1913
1914 let deepspeed = items
1915 .iter()
1916 .find(|i| i.plan_item.component_id == "deepspeed")
1917 .unwrap();
1918 assert_eq!(deepspeed.classification, UpdateClassification::Experimental);
1919 assert!(!deepspeed.selected);
1920 }
1921
1922 #[test]
1927 fn test_compatibility_context_default() {
1928 let ctx = CompatibilityContext::default();
1929 assert!(ctx.rocm_version.is_empty());
1930 assert!(ctx.rocm_channel.is_empty());
1931 assert!(ctx.available_executors.is_empty());
1932 assert!(ctx.installed_components.is_empty());
1933 assert!(ctx.installed_versions.is_empty());
1934 assert!(ctx.gpu_architecture.is_empty());
1935 assert!(ctx.runtime_version.is_empty());
1936 }
1937
1938 #[test]
1939 fn test_compatibility_context_is_installed() {
1940 let mut ctx = CompatibilityContext::new();
1941 assert!(!ctx.is_installed("pytorch"));
1942 ctx.installed_components.insert("pytorch".to_string());
1943 assert!(ctx.is_installed("pytorch"));
1944 }
1945
1946 #[test]
1947 fn test_compatibility_context_installed_version() {
1948 let mut ctx = CompatibilityContext::new();
1949 assert!(ctx.installed_version("pytorch").is_none());
1950 ctx.installed_versions
1951 .insert("pytorch".to_string(), "2.4.0".to_string());
1952 assert_eq!(ctx.installed_version("pytorch"), Some("2.4.0"));
1953 }
1954
1955 #[test]
1960 fn test_planner_error_display() {
1961 let err = PlannerError::ComponentBlocked {
1962 component_id: "bad-pkg".to_string(),
1963 reason: "incompatible hardware".to_string(),
1964 };
1965 assert!(err.to_string().contains("bad-pkg"));
1966 assert!(err.to_string().contains("incompatible hardware"));
1967
1968 let err = PlannerError::ComponentUnknown {
1969 component_id: "nonexistent".to_string(),
1970 };
1971 assert!(err.to_string().contains("nonexistent"));
1972
1973 let err = PlannerError::RuntimeTooOld {
1974 current: "0.1.0".to_string(),
1975 required: "0.2.0".to_string(),
1976 };
1977 assert!(err.to_string().contains("0.1.0"));
1978 assert!(err.to_string().contains("rusty upgrade"));
1979 }
1980
1981 #[test]
1982 fn test_planner_error_is_std_error() {
1983 let err = PlannerError::ComponentUnknown {
1984 component_id: "test".to_string(),
1985 };
1986 let _: &dyn std::error::Error = &err;
1987 }
1988
1989 #[test]
1994 fn test_scan_output_serde_roundtrip() {
1995 let output = ScanOutput {
1996 installed: vec![InstalledComponent {
1997 id: "pytorch".to_string(),
1998 version: "2.4.0".to_string(),
1999 status: "installed".to_string(),
2000 }],
2001 manifest_source: "baseline".to_string(),
2002 rocm_channel: "latest".to_string(),
2003 rocm_version: "7.2.1".to_string(),
2004 gpu_architecture: "gfx1100".to_string(),
2005 };
2006 let json = serde_json::to_string(&output).unwrap();
2007 let back: ScanOutput = serde_json::from_str(&json).unwrap();
2008 assert_eq!(output, back);
2009 }
2010
2011 #[test]
2012 fn test_plan_output_serde_roundtrip() {
2013 let output = PlanOutput {
2014 plan: vec![PlannerItemOutput {
2015 component_id: "pytorch".to_string(),
2016 current_version: "2.4.0".to_string(),
2017 proposed_version: "2.4.1".to_string(),
2018 classification: "safe".to_string(),
2019 risk_tier: "low".to_string(),
2020 selected: true,
2021 visible: true,
2022 rationale: "patch update".to_string(),
2023 dependencies: vec!["rocm".to_string()],
2024 }],
2025 summary: PlanSummary {
2026 total: 1,
2027 safe: 1,
2028 guarded: 0,
2029 candidate: 0,
2030 experimental: 0,
2031 blocked: 0,
2032 selected: 1,
2033 },
2034 };
2035 let json = serde_json::to_string(&output).unwrap();
2036 let back: PlanOutput = serde_json::from_str(&json).unwrap();
2037 assert_eq!(output, back);
2038 }
2039
2040 #[test]
2045 fn test_classification_visibility() {
2046 assert!(UpdateClassification::Safe.is_visible());
2047 assert!(UpdateClassification::Guarded.is_visible());
2048 assert!(!UpdateClassification::Blocked.is_visible());
2049 assert!(UpdateClassification::Candidate.is_visible());
2050 assert!(!UpdateClassification::Experimental.is_visible());
2051 }
2052
2053 #[test]
2054 fn test_classification_preselection() {
2055 assert!(UpdateClassification::Safe.is_preselected());
2056 assert!(!UpdateClassification::Guarded.is_preselected());
2057 assert!(!UpdateClassification::Blocked.is_preselected());
2058 assert!(!UpdateClassification::Candidate.is_preselected());
2059 assert!(!UpdateClassification::Experimental.is_preselected());
2060 }
2061
2062 #[test]
2063 fn test_classification_labels() {
2064 assert_eq!(UpdateClassification::Safe.label(), "safe");
2065 assert_eq!(UpdateClassification::Guarded.label(), "guarded");
2066 assert_eq!(UpdateClassification::Blocked.label(), "blocked");
2067 assert_eq!(UpdateClassification::Candidate.label(), "candidate");
2068 assert_eq!(UpdateClassification::Experimental.label(), "experimental");
2069 }
2070
2071 #[test]
2076 fn test_derive_dependencies_pytorch() {
2077 let deps = planner().derive_dependencies("pytorch");
2078 assert!(deps.contains(&"rocm".to_string()));
2079 }
2080
2081 #[test]
2082 fn test_derive_dependencies_triton() {
2083 let deps = planner().derive_dependencies("triton");
2084 assert!(deps.contains(&"pytorch".to_string()));
2085 }
2086
2087 #[test]
2088 fn test_derive_dependencies_rocm() {
2089 let deps = planner().derive_dependencies("rocm");
2090 assert!(deps.is_empty());
2091 }
2092
2093 #[test]
2094 fn test_derive_dependencies_unknown() {
2095 let deps = planner().derive_dependencies("unknown-component");
2096 assert!(deps.is_empty());
2097 }
2098
2099 #[test]
2104 fn test_version_bump_patch() {
2105 assert_eq!(
2106 planner().version_bump_level("1.0.0", "1.0.1"),
2107 BumpLevel::Patch
2108 );
2109 }
2110
2111 #[test]
2112 fn test_version_bump_minor() {
2113 assert_eq!(
2114 planner().version_bump_level("1.0.0", "1.1.0"),
2115 BumpLevel::Minor
2116 );
2117 }
2118
2119 #[test]
2120 fn test_version_bump_major() {
2121 assert_eq!(
2122 planner().version_bump_level("1.0.0", "2.0.0"),
2123 BumpLevel::Major
2124 );
2125 }
2126
2127 #[test]
2128 fn test_version_bump_unknown() {
2129 assert_eq!(
2130 planner().version_bump_level("abc", "def"),
2131 BumpLevel::Unknown
2132 );
2133 }
2134
2135 #[test]
2136 fn test_version_bump_downgrade_is_unknown() {
2137 assert_eq!(
2138 planner().version_bump_level("7.3.0", "7.2.4"),
2139 BumpLevel::Unknown
2140 );
2141 assert_eq!(
2142 planner().version_bump_level("2.5.1", "2.5.0"),
2143 BumpLevel::Unknown
2144 );
2145 }
2146
2147 #[test]
2152 fn test_version_gte_equal() {
2153 assert!(version_gte("7.2.1", "7.2.1"));
2154 }
2155
2156 #[test]
2157 fn test_version_gte_greater() {
2158 assert!(version_gte("7.3.0", "7.2.1"));
2159 assert!(version_gte("8.0.0", "7.2.1"));
2160 assert!(version_gte("7.2.2", "7.2.1"));
2161 }
2162
2163 #[test]
2164 fn test_version_gte_less() {
2165 assert!(!version_gte("7.2.0", "7.2.1"));
2166 assert!(!version_gte("6.0.0", "7.0.0"));
2167 }
2168
2169 #[test]
2170 fn test_version_gte_different_lengths() {
2171 assert!(version_gte("7.2", "7.2.0"));
2172 assert!(version_gte("7.2.1", "7.2"));
2173 assert!(!version_gte("7.1", "7.2.0"));
2174 }
2175
2176 #[test]
2181 fn test_blocked_by_rocm_version_requirement() {
2182 let context = make_context(); let component = ManifestComponent {
2185 id: "pytorch".to_string(),
2186 version: "3.0.0".to_string(),
2187 script: "install_pytorch.sh".to_string(),
2188 category: crate::core::types::Category::Core,
2189 validation_tier: ValidationTier::Validated,
2190 min_rocm_version: "99.0.0".to_string(), compatible_channels: vec![],
2192 dependencies: vec![],
2193 };
2194
2195 let classification =
2196 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2197 assert_eq!(classification, UpdateClassification::Blocked);
2198 }
2199
2200 #[test]
2201 fn test_not_blocked_by_satisfied_rocm_requirement() {
2202 let context = make_context(); let component = ManifestComponent {
2205 id: "pytorch".to_string(),
2206 version: "3.0.0".to_string(),
2207 script: "install_pytorch.sh".to_string(),
2208 category: crate::core::types::Category::Core,
2209 validation_tier: ValidationTier::Validated,
2210 min_rocm_version: "7.0.0".to_string(),
2211 compatible_channels: vec![],
2212 dependencies: vec![],
2213 };
2214
2215 let classification =
2216 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2217 assert_ne!(classification, UpdateClassification::Blocked);
2218 }
2219
2220 #[test]
2225 fn test_blocked_by_incompatible_channel() {
2226 let mut context = make_context();
2227 context.rocm_channel = "legacy".to_string();
2228
2229 let component = ManifestComponent {
2230 id: "pytorch".to_string(),
2231 version: "3.0.0".to_string(),
2232 script: "install_pytorch.sh".to_string(),
2233 category: crate::core::types::Category::Core,
2234 validation_tier: ValidationTier::Validated,
2235 min_rocm_version: String::new(),
2236 compatible_channels: vec!["latest".to_string(), "stable".to_string()],
2237 dependencies: vec![],
2238 };
2239
2240 let classification =
2241 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2242 assert_eq!(classification, UpdateClassification::Blocked);
2243 }
2244
2245 #[test]
2246 fn test_allowed_by_compatible_channel() {
2247 let mut context = make_context();
2248 context.rocm_channel = "latest".to_string();
2249
2250 let component = ManifestComponent {
2251 id: "pytorch".to_string(),
2252 version: "3.0.0".to_string(),
2253 script: "install_pytorch.sh".to_string(),
2254 category: crate::core::types::Category::Core,
2255 validation_tier: ValidationTier::Validated,
2256 min_rocm_version: String::new(),
2257 compatible_channels: vec!["latest".to_string(), "stable".to_string()],
2258 dependencies: vec![],
2259 };
2260
2261 let classification =
2262 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2263 assert_ne!(classification, UpdateClassification::Blocked);
2264 }
2265
2266 #[test]
2267 fn test_channel_filter_legacy_excludes_latest_only() {
2268 let mut context = make_context();
2269 context.rocm_channel = "legacy".to_string();
2270
2271 let manifest = make_manifest(vec![ManifestComponent {
2272 id: "pytorch".to_string(),
2273 version: "3.0.0".to_string(),
2274 script: "install_pytorch.sh".to_string(),
2275 category: crate::core::types::Category::Core,
2276 validation_tier: ValidationTier::Validated,
2277 min_rocm_version: String::new(),
2278 compatible_channels: vec!["latest".to_string()],
2279 dependencies: vec![],
2280 }]);
2281
2282 let options = PlannerOptions::default();
2283 let items = planner().build_plan(&manifest, &context, &options).unwrap();
2284
2285 assert_eq!(items.len(), 1);
2287 assert_eq!(items[0].classification, UpdateClassification::Blocked);
2288 assert!(!items[0].visible);
2289 }
2290
2291 #[test]
2296 fn test_runtime_too_old_blocks_plan() {
2297 let context = make_context(); let mut manifest = make_manifest(vec![make_component(
2300 "pytorch",
2301 "3.0.0",
2302 ValidationTier::Validated,
2303 )]);
2304 manifest.min_runtime_version = "99.0.0".to_string();
2305
2306 let mut ctx = context;
2307 ctx.runtime_version = "0.1.0".to_string();
2308
2309 let options = PlannerOptions::default();
2310 let result = planner().build_plan(&manifest, &ctx, &options);
2311 assert!(result.is_err());
2312 match result.unwrap_err() {
2313 PlannerError::RuntimeTooOld { current, required } => {
2314 assert_eq!(current, "0.1.0");
2315 assert_eq!(required, "99.0.0");
2316 }
2317 other => panic!("Expected RuntimeTooOld, got: {other:?}"),
2318 }
2319 }
2320
2321 #[test]
2322 fn test_runtime_version_satisfied() {
2323 let mut context = make_context();
2324 context.runtime_version = "1.0.0".to_string();
2325
2326 let mut manifest = make_manifest(vec![make_component(
2327 "pytorch",
2328 "3.0.0",
2329 ValidationTier::Validated,
2330 )]);
2331 manifest.min_runtime_version = "0.5.0".to_string();
2332
2333 let options = PlannerOptions::default();
2334 let result = planner().build_plan(&manifest, &context, &options);
2335 assert!(result.is_ok());
2336 }
2337
2338 #[test]
2343 fn test_blocked_when_rocm_not_installed_but_required() {
2344 let mut context = CompatibilityContext::new();
2345 context.rocm_version = String::new(); context.available_executors =
2347 HashSet::from([ExecutorKind::LegacyScript, ExecutorKind::Rust]);
2348
2349 let component = make_component("pytorch", "3.0.0", ValidationTier::Validated);
2350 let classification =
2351 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2352 assert_eq!(classification, UpdateClassification::Blocked);
2353 }
2354
2355 #[test]
2356 fn test_rocm_component_allowed_when_rocm_not_installed() {
2357 let mut context = CompatibilityContext::new();
2358 context.rocm_version = String::new();
2359 context.available_executors =
2360 HashSet::from([ExecutorKind::LegacyScript, ExecutorKind::Rust]);
2361
2362 let component = make_component("rocm", "7.2.4", ValidationTier::Validated);
2363 let classification =
2364 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2365 assert_ne!(classification, UpdateClassification::Blocked);
2366 }
2367
2368 #[test]
2369 fn test_isolation_safe_tracks_classification() {
2370 let mut context = make_context();
2371 context
2372 .installed_versions
2373 .insert("pytorch".to_string(), "2.4.0".to_string());
2374 context.installed_components.insert("pytorch".to_string());
2375 context
2376 .installed_versions
2377 .insert("triton".to_string(), "3.0.0".to_string());
2378 context.installed_components.insert("triton".to_string());
2379
2380 let manifest = make_manifest(vec![
2381 make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), ]);
2384 let items = planner()
2385 .build_plan(&manifest, &context, &PlannerOptions::default())
2386 .unwrap();
2387
2388 let pytorch = items
2389 .iter()
2390 .find(|i| i.plan_item.component_id == "pytorch")
2391 .unwrap();
2392 let triton = items
2393 .iter()
2394 .find(|i| i.plan_item.component_id == "triton")
2395 .unwrap();
2396
2397 assert!(pytorch.plan_item.isolation_safe);
2398 assert!(!triton.plan_item.isolation_safe);
2399 }
2400
2401 #[test]
2406 fn test_block_reason_includes_rocm_version() {
2407 let context = make_context(); let component = ManifestComponent {
2410 id: "pytorch".to_string(),
2411 version: "3.0.0".to_string(),
2412 script: "install_pytorch.sh".to_string(),
2413 category: crate::core::types::Category::Core,
2414 validation_tier: ValidationTier::Validated,
2415 min_rocm_version: "99.0.0".to_string(),
2416 compatible_channels: vec![],
2417 dependencies: vec![],
2418 };
2419
2420 let reason =
2421 planner().block_reason(&component, &context, !context.rocm_version.is_empty(), None);
2422 assert!(reason.contains("99.0.0"));
2423 assert!(reason.contains("7.2.1"));
2424 }
2425
2426 #[test]
2427 fn test_block_reason_channel_incompatibility() {
2428 let mut context = make_context();
2429 context.rocm_channel = "legacy".to_string();
2430
2431 let component = ManifestComponent {
2432 id: "pytorch".to_string(),
2433 version: "3.0.0".to_string(),
2434 script: "install_pytorch.sh".to_string(),
2435 category: crate::core::types::Category::Core,
2436 validation_tier: ValidationTier::Validated,
2437 min_rocm_version: String::new(),
2438 compatible_channels: vec!["latest".to_string()],
2439 dependencies: vec![],
2440 };
2441
2442 let reason =
2443 planner().block_reason(&component, &context, !context.rocm_version.is_empty(), None);
2444 assert!(reason.contains("legacy"));
2445 }
2446}