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 current_version = context.installed_version(&component.id).unwrap_or("");
311 if is_current_or_newer(current_version, &component.version) {
312 continue;
313 }
314
315 let classification = self.classify_update(
317 component,
318 context,
319 rocm_available_or_planned,
320 planned_rocm_version,
321 );
322
323 if classification == UpdateClassification::Blocked {
325 if target_set.contains(component.id.as_str())
327 || required_deps.contains(component.id.as_str())
328 {
329 let reason = self.block_reason(
330 component,
331 context,
332 rocm_available_or_planned,
333 planned_rocm_version,
334 );
335 return Err(PlannerError::ComponentBlocked {
336 component_id: component.id.clone(),
337 reason,
338 });
339 }
340 items.push(self.build_planner_item(
342 component,
343 classification,
344 context,
345 rocm_available_or_planned,
346 planned_rocm_version,
347 ));
348 continue;
349 }
350
351 if classification == UpdateClassification::Experimental
355 && !options.include_experimental
356 && !target_set.contains(component.id.as_str())
357 {
358 continue;
359 }
360
361 items.push(self.build_planner_item(
362 component,
363 classification,
364 context,
365 rocm_available_or_planned,
366 planned_rocm_version,
367 ));
368 }
369
370 self.enforce_exclusive_groups(&mut items, &target_set);
379
380 if options.all_safe {
383 if options.include_experimental {
384 items.retain(|i| {
385 if target_set.contains(i.plan_item.component_id.as_str()) {
386 return true;
387 }
388 matches!(
389 i.classification,
390 UpdateClassification::Safe | UpdateClassification::Experimental
391 )
392 });
393 } else {
394 items.retain(|i| {
395 target_set.contains(i.plan_item.component_id.as_str())
396 || i.classification == UpdateClassification::Safe
397 });
398 }
399 for item in &mut items {
406 item.selected = true;
407 item.plan_item.selected = true;
408 }
409 }
410
411 if !target_set.is_empty() {
413 for item in &mut items {
414 if target_set.contains(item.plan_item.component_id.as_str()) {
415 item.selected = true;
416 item.plan_item.selected = true;
417 }
418 }
419 }
420
421 self.enforce_dependency_rules(&mut items)?;
423
424 Ok(items)
425 }
426
427 fn classify_update(
429 &self,
430 component: &ManifestComponent,
431 context: &CompatibilityContext,
432 rocm_available_or_planned: bool,
433 planned_rocm_version: Option<&str>,
434 ) -> UpdateClassification {
435 if component.validation_tier == ValidationTier::Experimental {
437 return UpdateClassification::Experimental;
438 }
439
440 if component.validation_tier == ValidationTier::Blocked {
442 return UpdateClassification::Blocked;
443 }
444
445 if !self.is_hardware_compatible(component, context) {
447 return UpdateClassification::Blocked;
448 }
449
450 if !self.is_executor_available(component, context) {
452 return UpdateClassification::Blocked;
453 }
454
455 if !self.is_rocm_compatible(
457 component,
458 context,
459 rocm_available_or_planned,
460 planned_rocm_version,
461 ) {
462 return UpdateClassification::Blocked;
463 }
464
465 let current_version = context.installed_version(&component.id).unwrap_or("");
467
468 if current_version.is_empty() {
470 return UpdateClassification::Candidate;
471 }
472
473 if current_version == component.version {
475 return UpdateClassification::Safe;
476 }
477
478 match self.version_bump_level(current_version, &component.version) {
480 BumpLevel::Patch => {
481 if component.validation_tier == ValidationTier::Validated {
483 UpdateClassification::Safe
484 } else {
485 UpdateClassification::Guarded
486 }
487 }
488 BumpLevel::Minor => UpdateClassification::Guarded,
489 BumpLevel::Major => UpdateClassification::Candidate,
490 BumpLevel::Unknown => {
491 UpdateClassification::Guarded
493 }
494 }
495 }
496
497 fn build_planner_item(
499 &self,
500 component: &ManifestComponent,
501 classification: UpdateClassification,
502 context: &CompatibilityContext,
503 rocm_available_or_planned: bool,
504 planned_rocm_version: Option<&str>,
505 ) -> PlannerItem {
506 let current_version = context
507 .installed_version(&component.id)
508 .unwrap_or("")
509 .to_string();
510
511 let visible = classification.is_visible();
512 let selected = classification.is_preselected() && current_version != component.version;
513 let isolation_safe = matches!(classification, UpdateClassification::Safe);
514
515 let classification_reason = self.classification_reason(
516 component,
517 classification,
518 context,
519 rocm_available_or_planned,
520 planned_rocm_version,
521 );
522
523 let dependencies = self.dependencies_for_component(component);
524
525 let mut item = PlannerItem {
526 plan_item: PlanItem::new(PlanItemInput {
527 component_id: component.id.clone(),
528 current_version: current_version.clone(),
529 proposed_version: component.version.clone(),
530 validation_tier: component.validation_tier,
531 selected,
532 rationale: classification_reason.clone(),
533 dependencies,
534 isolation_safe,
535 }),
536 classification,
537 visible,
538 selected,
539 classification_reason,
540 requires_hardware_check: self.requires_hardware_check(&component.id),
541 min_rocm_version: component.min_rocm_version.clone(),
542 };
543 item.plan_item.exclusive_group = component.exclusive_group.clone();
547 item
548 }
549
550 fn is_hardware_compatible(
552 &self,
553 component: &ManifestComponent,
554 context: &CompatibilityContext,
555 ) -> bool {
556 if !component.compatible_channels.is_empty()
558 && self.requires_rocm(&component.id)
559 && !component
560 .compatible_channels
561 .iter()
562 .any(|ch| ch.eq_ignore_ascii_case(&context.rocm_channel))
563 {
564 return false;
565 }
566 true
567 }
568
569 fn is_executor_available(
571 &self,
572 _component: &ManifestComponent,
573 context: &CompatibilityContext,
574 ) -> bool {
575 context
577 .available_executors
578 .contains(&ExecutorKind::LegacyScript)
579 || context.available_executors.contains(&ExecutorKind::Rust)
580 }
581
582 fn is_rocm_compatible(
584 &self,
585 component: &ManifestComponent,
586 context: &CompatibilityContext,
587 rocm_available_or_planned: bool,
588 planned_rocm_version: Option<&str>,
589 ) -> bool {
590 if !self.requires_rocm(&component.id) {
592 return true;
593 }
594
595 if component.id == "rocm" {
597 return true;
598 }
599
600 if context.rocm_version.is_empty() && !rocm_available_or_planned {
602 return false;
603 }
604
605 let effective_rocm_version = self.effective_rocm_version(context, planned_rocm_version);
607 if !component.min_rocm_version.is_empty()
608 && !effective_rocm_version.is_empty()
609 && !version_gte(&effective_rocm_version, &component.min_rocm_version)
610 {
611 return false;
612 }
613
614 if !component.compatible_channels.is_empty()
616 && !component
617 .compatible_channels
618 .iter()
619 .any(|ch| ch.eq_ignore_ascii_case(&context.rocm_channel))
620 {
621 return false;
622 }
623
624 true
625 }
626
627 fn requires_rocm(&self, component_id: &str) -> bool {
629 matches!(
630 component_id,
631 "rocm"
632 | "rocm-smi"
633 | "pytorch"
634 | "triton"
635 | "onnx"
636 | "migraphx"
637 | "flash-attn"
638 | "flash-attn-triton"
639 | "flash-attn-ck"
640 | "rccl"
641 | "vllm"
642 | "aiter"
643 | "deepspeed"
644 | "bitsandbytes"
645 | "llama-cpp"
646 )
647 }
648
649 fn requires_hardware_check(&self, component_id: &str) -> bool {
651 self.requires_rocm(component_id)
652 }
653
654 fn block_reason(
656 &self,
657 component: &ManifestComponent,
658 context: &CompatibilityContext,
659 rocm_available_or_planned: bool,
660 planned_rocm_version: Option<&str>,
661 ) -> String {
662 if component.validation_tier == ValidationTier::Blocked {
663 return "component is marked as blocked in manifest".to_string();
664 }
665
666 if !self.is_hardware_compatible(component, context) {
667 return format!(
668 "incompatible with ROCm channel '{}' (requires: {})",
669 context.rocm_channel,
670 component.compatible_channels.join(", ")
671 );
672 }
673
674 if !self.is_executor_available(component, context) {
675 return "no available executor for this platform".to_string();
676 }
677
678 if !self.is_rocm_compatible(
679 component,
680 context,
681 rocm_available_or_planned,
682 planned_rocm_version,
683 ) {
684 if context.rocm_version.is_empty() && !rocm_available_or_planned {
685 return "requires ROCm but ROCm is not installed".to_string();
686 }
687 let effective_rocm_version = self.effective_rocm_version(context, planned_rocm_version);
688 if !component.min_rocm_version.is_empty() {
689 if effective_rocm_version.is_empty() {
690 return format!(
691 "requires ROCm >= {} and will rely on the planned ROCm install",
692 component.min_rocm_version
693 );
694 }
695 return format!(
696 "requires ROCm >= {} but effective plan version is {}",
697 component.min_rocm_version, effective_rocm_version
698 );
699 }
700 return format!(
701 "incompatible with effective ROCm version ({})",
702 effective_rocm_version
703 );
704 }
705
706 "unknown reason".to_string()
707 }
708
709 fn classification_reason(
711 &self,
712 component: &ManifestComponent,
713 classification: UpdateClassification,
714 context: &CompatibilityContext,
715 rocm_available_or_planned: bool,
716 planned_rocm_version: Option<&str>,
717 ) -> String {
718 let current = context.installed_version(&component.id).unwrap_or("");
719
720 match classification {
721 UpdateClassification::Safe => {
722 if current == component.version {
723 format!("already up to date (v{})", component.version)
724 } else {
725 format!(
726 "patch update {} → {} (validated, no new deps)",
727 current, component.version
728 )
729 }
730 }
731 UpdateClassification::Guarded => {
732 format!(
733 "minor update {} → {} (new dependencies or config changes)",
734 current, component.version
735 )
736 }
737 UpdateClassification::Blocked => self.block_reason(
738 component,
739 context,
740 rocm_available_or_planned,
741 planned_rocm_version,
742 ),
743 UpdateClassification::Candidate => {
744 if current.is_empty() {
745 format!("new install v{}", component.version)
746 } else {
747 format!(
748 "major update {} → {} (compatible)",
749 current, component.version
750 )
751 }
752 }
753 UpdateClassification::Experimental => {
754 format!("experimental component v{}", component.version)
755 }
756 }
757 }
758
759 fn dependencies_for_component(&self, component: &ManifestComponent) -> Vec<String> {
761 if !component.dependencies.is_empty() {
762 return component.dependencies.clone();
763 }
764 self.derive_dependencies(&component.id)
765 }
766
767 fn derive_dependencies(&self, component_id: &str) -> Vec<String> {
769 match component_id {
770 "pytorch" => vec!["rocm".to_string()],
771 "triton" => vec!["pytorch".to_string()],
772 "flash-attn" | "flash-attn-triton" => {
780 vec![
781 "pytorch".to_string(),
782 "rocm".to_string(),
783 "aiter".to_string(),
784 ]
785 }
786 "flash-attn-ck" => {
787 vec!["pytorch".to_string(), "rocm".to_string()]
788 }
789 "deepspeed" => vec!["pytorch".to_string()],
790 "vllm" => vec!["pytorch".to_string()],
791 "megatron" => vec!["pytorch".to_string(), "mpi4py".to_string()],
792 "onnx" => vec!["rocm".to_string()],
793 "migraphx" => vec!["rocm".to_string()],
794 "bitsandbytes" => vec!["pytorch".to_string()],
795 "aiter" => vec!["pytorch".to_string()],
796 "mpi4py" => vec![],
797 "wandb" => vec![],
798 "comfyui" => vec!["pytorch".to_string()],
799 "vllm-studio" => vec!["vllm".to_string()],
800 "textgen" => vec!["pytorch".to_string()],
801 "rocm" => vec![],
802 "rocm-smi" => vec!["rocm".to_string()],
803 "llama-cpp" => vec!["rocm".to_string()],
804 "permanent-env" => vec![],
805 _ => vec![],
806 }
807 }
808
809 fn collect_required_dependencies(
810 &self,
811 manifest: &Manifest,
812 targets: &HashSet<&str>,
813 ) -> HashSet<String> {
814 let mut required = HashSet::new();
815 let mut stack: Vec<String> = targets.iter().map(|s| (*s).to_string()).collect();
816
817 while let Some(component) = stack.pop() {
818 let deps = manifest
819 .components
820 .iter()
821 .find(|c| c.id == component)
822 .map(|c| self.dependencies_for_component(c))
823 .unwrap_or_else(|| self.derive_dependencies(&component));
824 for dep in deps {
825 if required.insert(dep.clone()) {
826 stack.push(dep);
827 }
828 }
829 }
830
831 for target in targets {
832 required.remove(*target);
833 }
834
835 required
836 }
837
838 fn effective_rocm_version(
839 &self,
840 context: &CompatibilityContext,
841 planned_rocm_version: Option<&str>,
842 ) -> String {
843 match planned_rocm_version {
844 Some(planned) if context.rocm_version.is_empty() => planned.to_string(),
845 Some(planned) if version_gte(planned, &context.rocm_version) => planned.to_string(),
846 _ => context.rocm_version.clone(),
847 }
848 }
849
850 fn version_bump_level(&self, current: &str, proposed: &str) -> BumpLevel {
852 let cur_parts = parse_version_parts(current);
853 let pro_parts = parse_version_parts(proposed);
854
855 match (cur_parts, pro_parts) {
856 (Some(cur), Some(pro)) if !cur.is_empty() && !pro.is_empty() => {
857 let len = cur.len().max(pro.len());
858 let mut cmp = std::cmp::Ordering::Equal;
859 for idx in 0..len {
860 let cur_part = *cur.get(idx).unwrap_or(&0);
861 let pro_part = *pro.get(idx).unwrap_or(&0);
862 cmp = pro_part.cmp(&cur_part);
863 if cmp != std::cmp::Ordering::Equal {
864 break;
865 }
866 }
867
868 match cmp {
869 std::cmp::Ordering::Less => BumpLevel::Unknown,
870 std::cmp::Ordering::Equal => BumpLevel::Patch,
871 std::cmp::Ordering::Greater => {
872 if pro.first().copied().unwrap_or(0) > cur.first().copied().unwrap_or(0) {
873 BumpLevel::Major
874 } else if pro.get(1).copied().unwrap_or(0)
875 > cur.get(1).copied().unwrap_or(0)
876 {
877 BumpLevel::Minor
878 } else {
879 BumpLevel::Patch
880 }
881 }
882 }
883 }
884 _ => BumpLevel::Unknown,
885 }
886 }
887
888 fn check_runtime_version(
890 &self,
891 manifest: &Manifest,
892 context: &CompatibilityContext,
893 ) -> Result<(), PlannerError> {
894 if manifest.min_runtime_version.is_empty() {
895 return Ok(());
896 }
897
898 if context.runtime_version.is_empty() {
899 return Ok(());
900 }
901
902 if !version_gte(&context.runtime_version, &manifest.min_runtime_version) {
903 return Err(PlannerError::RuntimeTooOld {
904 current: context.runtime_version.clone(),
905 required: manifest.min_runtime_version.clone(),
906 });
907 }
908
909 Ok(())
910 }
911
912 fn enforce_exclusive_groups(&self, items: &mut Vec<PlannerItem>, target_set: &HashSet<&str>) {
921 use std::collections::HashMap;
922
923 let mut group_members: HashMap<String, Vec<String>> = HashMap::new();
926 for item in items.iter() {
927 let group = &item.plan_item.exclusive_group;
928 if !group.is_empty() {
929 group_members
930 .entry(group.clone())
931 .or_default()
932 .push(item.plan_item.component_id.clone());
933 }
934 }
935
936 let mut survivors: HashSet<String> = HashSet::new();
938 for members in group_members.values() {
939 let chosen = members
941 .iter()
942 .find(|id| target_set.contains(id.as_str()))
943 .or_else(|| members.first()) .cloned();
945 if let Some(id) = chosen {
946 survivors.insert(id);
947 }
948 }
949
950 if survivors.is_empty() {
951 return;
952 }
953
954 items.retain(|i| {
956 let group = &i.plan_item.exclusive_group;
957 group.is_empty() || survivors.contains(&i.plan_item.component_id)
958 });
959 }
960
961 fn enforce_dependency_rules(&self, items: &mut [PlannerItem]) -> Result<(), PlannerError> {
964 let mut changed = true;
967 while changed {
968 changed = false;
969
970 let deps_to_select: Vec<String> = items
972 .iter()
973 .filter(|i| i.selected)
974 .flat_map(|i| i.plan_item.dependencies.clone())
975 .collect();
976
977 for dep_id in &deps_to_select {
978 if let Some(dep_item) = items
979 .iter_mut()
980 .find(|i| i.plan_item.component_id == *dep_id)
981 {
982 if !dep_item.selected
983 && dep_item.classification != UpdateClassification::Blocked
984 {
985 dep_item.selected = true;
986 changed = true;
987 }
988 }
989 }
990 }
991
992 Ok(())
993 }
994}
995
996impl Default for UpdatePlanner {
997 fn default() -> Self {
998 Self::new()
999 }
1000}
1001
1002#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1008enum BumpLevel {
1009 Patch,
1010 Minor,
1011 Major,
1012 Unknown,
1013}
1014
1015fn parse_version_parts(version: &str) -> Option<Vec<u32>> {
1017 let base = version.split('-').next()?;
1018 let mut parts = Vec::new();
1026 for segment in base.split('.') {
1027 match segment.parse::<u32>() {
1028 Ok(n) => parts.push(n),
1029 Err(_) => break,
1030 }
1031 }
1032 if parts.is_empty() {
1033 None
1034 } else {
1035 Some(parts)
1036 }
1037}
1038
1039fn version_gte(actual: &str, required: &str) -> bool {
1043 let act = parse_version_parts(actual).unwrap_or_default();
1044 let req = parse_version_parts(required).unwrap_or_default();
1045
1046 let max_len = act.len().max(req.len());
1047 for i in 0..max_len {
1048 let a = act.get(i).copied().unwrap_or(0);
1049 let r = req.get(i).copied().unwrap_or(0);
1050 if a > r {
1051 return true;
1052 }
1053 if a < r {
1054 return false;
1055 }
1056 }
1057 true }
1059
1060fn is_current_or_newer(current: &str, proposed: &str) -> bool {
1068 if current.trim().is_empty() || proposed.trim().is_empty() {
1069 return false;
1070 }
1071
1072 let current = current.trim().trim_start_matches('v');
1073 let proposed = proposed.trim().trim_start_matches('v');
1074
1075 if let (Ok(current), Ok(proposed)) = (
1076 semver::Version::parse(current),
1077 semver::Version::parse(proposed),
1078 ) {
1079 return current >= proposed;
1080 }
1081
1082 let Some(current_parts) = parse_version_parts(current) else {
1083 return false;
1084 };
1085 let Some(proposed_parts) = parse_version_parts(proposed) else {
1086 return false;
1087 };
1088
1089 let max_len = current_parts.len().max(proposed_parts.len());
1090 for index in 0..max_len {
1091 let current_part = current_parts.get(index).copied().unwrap_or(0);
1092 let proposed_part = proposed_parts.get(index).copied().unwrap_or(0);
1093 if current_part != proposed_part {
1094 return current_part > proposed_part;
1095 }
1096 }
1097 true
1098}
1099
1100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1106pub struct ScanOutput {
1107 pub installed: Vec<InstalledComponent>,
1109 pub manifest_source: String,
1111 pub rocm_channel: String,
1113 pub rocm_version: String,
1115 pub gpu_architecture: String,
1117}
1118
1119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1121pub struct InstalledComponent {
1122 pub id: String,
1123 pub version: String,
1124 pub status: String,
1125}
1126
1127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1133pub struct PlanOutput {
1134 pub plan: Vec<PlannerItemOutput>,
1136 pub summary: PlanSummary,
1138}
1139
1140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1142pub struct PlannerItemOutput {
1143 pub component_id: String,
1144 pub current_version: String,
1145 pub proposed_version: String,
1146 pub classification: String,
1147 pub risk_tier: String,
1148 pub selected: bool,
1149 pub visible: bool,
1150 pub rationale: String,
1151 pub dependencies: Vec<String>,
1152 #[serde(default)]
1155 pub exclusive_group: String,
1156}
1157
1158impl From<&PlannerItem> for PlannerItemOutput {
1159 fn from(item: &PlannerItem) -> Self {
1160 Self {
1161 component_id: item.plan_item.component_id.clone(),
1162 current_version: item.plan_item.current_version.clone(),
1163 proposed_version: item.plan_item.proposed_version.clone(),
1164 classification: item.classification.label().to_string(),
1165 risk_tier: item.plan_item.risk_tier.label().to_string(),
1166 selected: item.selected,
1167 visible: item.visible,
1168 rationale: item.plan_item.rationale.clone(),
1169 dependencies: item.plan_item.dependencies.clone(),
1170 exclusive_group: item.plan_item.exclusive_group.clone(),
1171 }
1172 }
1173}
1174
1175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1177pub struct PlanSummary {
1178 pub total: usize,
1179 pub safe: usize,
1180 pub guarded: usize,
1181 pub candidate: usize,
1182 pub experimental: usize,
1183 pub blocked: usize,
1184 pub selected: usize,
1185}
1186
1187impl PlanSummary {
1188 pub fn from_items(items: &[PlannerItem]) -> Self {
1190 Self {
1191 total: items.len(),
1192 safe: items
1193 .iter()
1194 .filter(|i| i.classification == UpdateClassification::Safe)
1195 .count(),
1196 guarded: items
1197 .iter()
1198 .filter(|i| i.classification == UpdateClassification::Guarded)
1199 .count(),
1200 candidate: items
1201 .iter()
1202 .filter(|i| i.classification == UpdateClassification::Candidate)
1203 .count(),
1204 experimental: items
1205 .iter()
1206 .filter(|i| i.classification == UpdateClassification::Experimental)
1207 .count(),
1208 blocked: items
1209 .iter()
1210 .filter(|i| i.classification == UpdateClassification::Blocked)
1211 .count(),
1212 selected: items.iter().filter(|i| i.selected).count(),
1213 }
1214 }
1215}
1216
1217#[cfg(test)]
1222mod tests {
1223 use super::*;
1224 use crate::core::manifest::{Manifest, ManifestComponent};
1225 use crate::core::types::{Category, ValidationTier};
1226
1227 fn make_component(id: &str, version: &str, tier: ValidationTier) -> ManifestComponent {
1232 ManifestComponent {
1233 id: id.to_string(),
1234 version: version.to_string(),
1235 script: format!("install_{id}.sh"),
1236 category: Category::Core,
1237 validation_tier: tier,
1238 min_rocm_version: String::new(),
1239 compatible_channels: vec![],
1240 dependencies: vec![],
1241 exclusive_group: String::new(),
1242 }
1243 }
1244
1245 fn make_component_exclusive(
1247 id: &str,
1248 version: &str,
1249 tier: ValidationTier,
1250 group: &str,
1251 ) -> ManifestComponent {
1252 let mut c = make_component(id, version, tier);
1253 c.exclusive_group = group.to_string();
1254 c
1255 }
1256
1257 fn make_manifest(components: Vec<ManifestComponent>) -> Manifest {
1258 Manifest {
1259 schema_version: 2,
1260 sequence: 1,
1261 generated_at: String::new(),
1262 expires_at: None,
1263 min_runtime_version: String::new(),
1264 components,
1265 signature: None,
1266 }
1267 }
1268
1269 fn make_context() -> CompatibilityContext {
1270 let mut ctx = CompatibilityContext::new();
1271 ctx.rocm_version = "7.2.1".to_string();
1272 ctx.rocm_channel = "latest".to_string();
1273 ctx.gpu_architecture = "gfx1100".to_string();
1274 ctx.available_executors = HashSet::from([
1275 ExecutorKind::LegacyScript,
1276 ExecutorKind::Rust,
1277 ExecutorKind::ExternalPackageManager,
1278 ]);
1279 ctx
1280 }
1281
1282 fn planner() -> UpdatePlanner {
1283 UpdatePlanner::new()
1284 }
1285
1286 #[test]
1291 fn test_safe_classification_patch_bump_validated() {
1292 let mut context = make_context();
1293 context
1294 .installed_versions
1295 .insert("pytorch".to_string(), "2.4.0".to_string());
1296 context.installed_components.insert("pytorch".to_string());
1297
1298 let component = make_component("pytorch", "2.4.1", ValidationTier::Validated);
1299 let classification =
1300 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1301 assert_eq!(classification, UpdateClassification::Safe);
1302 }
1303
1304 #[test]
1305 fn test_safe_classification_same_version_reinstall() {
1306 let mut context = make_context();
1307 context
1308 .installed_versions
1309 .insert("pytorch".to_string(), "2.4.0".to_string());
1310 context.installed_components.insert("pytorch".to_string());
1311
1312 let component = make_component("pytorch", "2.4.0", ValidationTier::Validated);
1313 let classification =
1314 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1315 assert_eq!(classification, UpdateClassification::Safe);
1316 }
1317
1318 #[test]
1319 fn test_build_plan_omits_same_version_component() {
1320 let mut context = make_context();
1321 context
1322 .installed_versions
1323 .insert("rocm".to_string(), "7.2.4".to_string());
1324 context.installed_components.insert("rocm".to_string());
1325
1326 let manifest = make_manifest(vec![make_component(
1327 "rocm",
1328 "7.2.4",
1329 ValidationTier::Validated,
1330 )]);
1331
1332 let items = planner()
1333 .build_plan(&manifest, &context, &PlannerOptions::default())
1334 .unwrap();
1335 assert!(
1336 items.is_empty(),
1337 "equal installed and target versions are a no-op"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_build_plan_omits_lower_target_version() {
1343 let mut context = make_context();
1344 context
1345 .installed_versions
1346 .insert("triton".to_string(), "3.7.1".to_string());
1347 context.installed_components.insert("triton".to_string());
1348
1349 let manifest = make_manifest(vec![make_component(
1350 "triton",
1351 "3.7.0",
1352 ValidationTier::Validated,
1353 )]);
1354
1355 let items = planner()
1356 .build_plan(&manifest, &context, &PlannerOptions::default())
1357 .unwrap();
1358 assert!(items.is_empty(), "planner must never propose a downgrade");
1359 }
1360
1361 #[test]
1362 fn test_build_plan_compares_rocm_build_metadata() {
1363 let mut context = make_context();
1364 context
1365 .installed_versions
1366 .insert("pytorch".to_string(), "2.12.1+rocm7.2".to_string());
1367 context.installed_components.insert("pytorch".to_string());
1368
1369 let manifest = make_manifest(vec![make_component(
1370 "pytorch",
1371 "2.12.1",
1372 ValidationTier::Validated,
1373 )]);
1374
1375 let items = planner()
1376 .build_plan(&manifest, &context, &PlannerOptions::default())
1377 .unwrap();
1378 assert!(
1379 items.is_empty(),
1380 "ROCm build metadata must not trigger reinstall"
1381 );
1382 }
1383
1384 #[test]
1389 fn test_guarded_classification_minor_bump() {
1390 let mut context = make_context();
1391 context
1392 .installed_versions
1393 .insert("pytorch".to_string(), "2.4.0".to_string());
1394 context.installed_components.insert("pytorch".to_string());
1395
1396 let component = make_component("pytorch", "2.5.0", ValidationTier::Validated);
1397 let classification =
1398 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1399 assert_eq!(classification, UpdateClassification::Guarded);
1400 }
1401
1402 #[test]
1403 fn test_guarded_classification_patch_bump_candidate_tier() {
1404 let mut context = make_context();
1405 context
1406 .installed_versions
1407 .insert("triton".to_string(), "3.0.0".to_string());
1408 context.installed_components.insert("triton".to_string());
1409
1410 let component = make_component("triton", "3.0.1", ValidationTier::Candidate);
1412 let classification =
1413 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1414 assert_eq!(classification, UpdateClassification::Guarded);
1415 }
1416
1417 #[test]
1422 fn test_blocked_classification_missing_executor() {
1423 let mut context = CompatibilityContext::new();
1424 context.rocm_version = "7.2.1".to_string();
1426
1427 let component = make_component("pytorch", "2.4.0", ValidationTier::Validated);
1428 let classification =
1429 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1430 assert_eq!(classification, UpdateClassification::Blocked);
1431 }
1432
1433 #[test]
1434 fn test_blocked_classification_blocked_tier() {
1435 let context = make_context();
1436 let component = make_component("bad-pkg", "1.0.0", ValidationTier::Blocked);
1437 let classification =
1438 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1439 assert_eq!(classification, UpdateClassification::Blocked);
1440 }
1441
1442 #[test]
1447 fn test_candidate_classification_major_bump() {
1448 let mut context = make_context();
1449 context
1450 .installed_versions
1451 .insert("pytorch".to_string(), "2.4.0".to_string());
1452 context.installed_components.insert("pytorch".to_string());
1453
1454 let component = make_component("pytorch", "3.0.0", ValidationTier::Validated);
1455 let classification =
1456 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1457 assert_eq!(classification, UpdateClassification::Candidate);
1458 }
1459
1460 #[test]
1461 fn test_candidate_classification_new_install() {
1462 let context = make_context();
1463 let component = make_component("triton", "3.1.0", ValidationTier::Validated);
1465 let classification =
1466 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1467 assert_eq!(classification, UpdateClassification::Candidate);
1468 }
1469
1470 #[test]
1475 fn test_experimental_classification() {
1476 let context = make_context();
1477 let component = make_component("deepspeed", "0.15.0", ValidationTier::Experimental);
1478 let classification =
1479 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1480 assert_eq!(classification, UpdateClassification::Experimental);
1481 }
1482
1483 #[test]
1484 fn test_experimental_hidden_without_flag() {
1485 let context = make_context();
1486 let manifest = make_manifest(vec![make_component(
1487 "deepspeed",
1488 "0.15.0",
1489 ValidationTier::Experimental,
1490 )]);
1491
1492 let options = PlannerOptions {
1493 include_experimental: false,
1494 ..Default::default()
1495 };
1496
1497 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1498 assert!(
1499 items.is_empty(),
1500 "Experimental items should be hidden without flag"
1501 );
1502 }
1503
1504 #[test]
1505 fn test_experimental_visible_with_flag() {
1506 let context = make_context();
1507 let manifest = make_manifest(vec![make_component(
1508 "deepspeed",
1509 "0.15.0",
1510 ValidationTier::Experimental,
1511 )]);
1512
1513 let options = PlannerOptions {
1514 include_experimental: true,
1515 ..Default::default()
1516 };
1517
1518 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1519 assert_eq!(items.len(), 1);
1520 assert_eq!(items[0].classification, UpdateClassification::Experimental);
1521 }
1522
1523 #[test]
1528 fn test_safe_updates_preselected() {
1529 let mut context = make_context();
1530 context
1531 .installed_versions
1532 .insert("pytorch".to_string(), "2.4.0".to_string());
1533 context.installed_components.insert("pytorch".to_string());
1534
1535 let manifest = make_manifest(vec![make_component(
1536 "pytorch",
1537 "2.4.1",
1538 ValidationTier::Validated,
1539 )]);
1540
1541 let options = PlannerOptions::default();
1542 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1543
1544 assert_eq!(items.len(), 1);
1545 assert!(items[0].selected, "Safe updates should be preselected");
1546 }
1547
1548 #[test]
1553 fn test_guarded_updates_not_preselected() {
1554 let mut context = make_context();
1555 context
1556 .installed_versions
1557 .insert("pytorch".to_string(), "2.4.0".to_string());
1558 context.installed_components.insert("pytorch".to_string());
1559
1560 let manifest = make_manifest(vec![make_component(
1561 "pytorch",
1562 "2.5.0",
1563 ValidationTier::Validated,
1564 )]);
1565
1566 let options = PlannerOptions::default();
1567 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1568
1569 assert_eq!(items.len(), 1);
1570 assert!(
1571 !items[0].selected,
1572 "Guarded updates should NOT be preselected"
1573 );
1574 }
1575
1576 #[test]
1581 fn test_candidate_updates_visible_not_preselected() {
1582 let mut context = make_context();
1583 context
1584 .installed_versions
1585 .insert("pytorch".to_string(), "2.4.0".to_string());
1586 context.installed_components.insert("pytorch".to_string());
1587
1588 let manifest = make_manifest(vec![make_component(
1589 "pytorch",
1590 "3.0.0",
1591 ValidationTier::Validated,
1592 )]);
1593
1594 let options = PlannerOptions::default();
1595 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1596
1597 assert_eq!(items.len(), 1);
1598 assert!(items[0].visible, "Candidate updates should be visible");
1599 assert!(
1600 !items[0].selected,
1601 "Candidate updates should NOT be preselected"
1602 );
1603 }
1604
1605 #[test]
1615 fn test_blocked_not_in_default_plan() {
1616 let context = make_context();
1617 let manifest = make_manifest(vec![make_component(
1618 "bad-pkg",
1619 "1.0.0",
1620 ValidationTier::Blocked,
1621 )]);
1622
1623 let options = PlannerOptions::default();
1624 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1625
1626 assert_eq!(items.len(), 1);
1628 assert!(!items[0].visible);
1629 assert!(!items[0].selected);
1630 }
1631
1632 #[test]
1633 fn test_blocked_explicit_target_fails() {
1634 let context = make_context();
1635 let manifest = make_manifest(vec![make_component(
1636 "bad-pkg",
1637 "1.0.0",
1638 ValidationTier::Blocked,
1639 )]);
1640
1641 let options = PlannerOptions {
1642 target_components: vec!["bad-pkg".to_string()],
1643 ..Default::default()
1644 };
1645
1646 let result = planner().build_plan(&manifest, &context, &options);
1647 assert!(result.is_err());
1648 match result.unwrap_err() {
1649 PlannerError::ComponentBlocked {
1650 component_id,
1651 reason,
1652 } => {
1653 assert_eq!(component_id, "bad-pkg");
1654 assert!(!reason.is_empty());
1655 }
1656 other => panic!("Expected ComponentBlocked, got: {other:?}"),
1657 }
1658 }
1659
1660 #[test]
1665 fn test_dependency_auto_selected() {
1666 let mut context = make_context();
1667 context
1668 .installed_versions
1669 .insert("rocm".to_string(), "7.2.0".to_string());
1670 context.installed_components.insert("rocm".to_string());
1671 context
1672 .installed_versions
1673 .insert("pytorch".to_string(), "2.4.0".to_string());
1674 context.installed_components.insert("pytorch".to_string());
1675
1676 let manifest = make_manifest(vec![
1677 make_component("rocm", "7.2.1", ValidationTier::Validated), make_component("pytorch", "2.4.1", ValidationTier::Validated), ]);
1680
1681 let options = PlannerOptions::default();
1682 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1683
1684 let rocm_item = items
1686 .iter()
1687 .find(|i| i.plan_item.component_id == "rocm")
1688 .unwrap();
1689 let pytorch_item = items
1690 .iter()
1691 .find(|i| i.plan_item.component_id == "pytorch")
1692 .unwrap();
1693
1694 assert!(rocm_item.selected, "ROCm should be selected (safe)");
1695 assert!(pytorch_item.selected, "PyTorch should be selected (safe)");
1696 }
1697
1698 #[test]
1703 fn test_scan_only_produces_plan() {
1704 let mut context = make_context();
1705 context
1706 .installed_versions
1707 .insert("pytorch".to_string(), "2.4.0".to_string());
1708 context.installed_components.insert("pytorch".to_string());
1709
1710 let manifest = make_manifest(vec![make_component(
1711 "pytorch",
1712 "2.4.1",
1713 ValidationTier::Validated,
1714 )]);
1715
1716 let options = PlannerOptions {
1717 scan_only: true,
1718 ..Default::default()
1719 };
1720
1721 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1722 assert_eq!(items.len(), 1);
1723 }
1725
1726 #[test]
1731 fn test_all_safe_filters_to_safe_only() {
1732 let mut context = make_context();
1733 context
1734 .installed_versions
1735 .insert("pytorch".to_string(), "2.4.0".to_string());
1736 context.installed_components.insert("pytorch".to_string());
1737 context
1738 .installed_versions
1739 .insert("triton".to_string(), "3.0.0".to_string());
1740 context.installed_components.insert("triton".to_string());
1741
1742 let manifest = make_manifest(vec![
1743 make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), ]);
1746
1747 let options = PlannerOptions {
1748 all_safe: true,
1749 ..Default::default()
1750 };
1751
1752 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1753
1754 assert_eq!(items.len(), 1, "Only safe items should remain");
1755 assert_eq!(items[0].classification, UpdateClassification::Safe);
1756 assert_eq!(items[0].plan_item.component_id, "pytorch");
1757 }
1758
1759 #[test]
1760 fn test_default_flash_attn_group_survivor_carries_aiter_dependency() {
1761 let mut context = make_context();
1770 for id in &["pytorch", "rocm", "aiter", "triton"] {
1774 context.installed_components.insert((*id).to_string());
1775 context
1776 .installed_versions
1777 .insert((*id).to_string(), "1.0.0".to_string());
1778 }
1779 for id in &["flash-attn", "flash-attn-triton", "flash-attn-ck"] {
1780 context.installed_components.insert((*id).to_string());
1781 context
1782 .installed_versions
1783 .insert((*id).to_string(), "2.8.3".to_string());
1784 }
1785
1786 let manifest = make_manifest(vec![
1787 make_component_exclusive(
1788 "flash-attn",
1789 "2.8.4",
1790 ValidationTier::Validated,
1791 "flash-attn-backend",
1792 ),
1793 make_component_exclusive(
1794 "flash-attn-triton",
1795 "2.8.4",
1796 ValidationTier::Validated,
1797 "flash-attn-backend",
1798 ),
1799 make_component_exclusive(
1800 "flash-attn-ck",
1801 "2.8.4",
1802 ValidationTier::Validated,
1803 "flash-attn-backend",
1804 ),
1805 ]);
1806
1807 let options = PlannerOptions::default();
1808 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1809
1810 let fa_items: Vec<_> = items
1813 .iter()
1814 .filter(|i| i.plan_item.exclusive_group == "flash-attn-backend")
1815 .collect();
1816 assert_eq!(fa_items.len(), 1, "exclusive group must keep one backend");
1817 assert_eq!(
1818 fa_items[0].plan_item.component_id, "flash-attn",
1819 "default survivor is the first-listed (legacy) id"
1820 );
1821 assert!(
1823 fa_items[0]
1824 .plan_item
1825 .dependencies
1826 .contains(&"aiter".to_string()),
1827 "legacy flash-attn (normalized to triton) must depend on aiter; got {:?}",
1828 fa_items[0].plan_item.dependencies
1829 );
1830 }
1831
1832 #[test]
1833 fn test_explicit_flash_attn_ck_target_drops_triton_and_uses_ck_closure() {
1834 let mut context = make_context();
1837 for id in &["pytorch", "rocm", "aiter", "triton"] {
1838 context.installed_components.insert((*id).to_string());
1839 context
1840 .installed_versions
1841 .insert((*id).to_string(), "1.0.0".to_string());
1842 }
1843 let manifest = make_manifest(vec![
1844 make_component_exclusive(
1845 "flash-attn",
1846 "2.8.4",
1847 ValidationTier::Validated,
1848 "flash-attn-backend",
1849 ),
1850 make_component_exclusive(
1851 "flash-attn-ck",
1852 "2.8.4",
1853 ValidationTier::Validated,
1854 "flash-attn-backend",
1855 ),
1856 ]);
1857 let options = PlannerOptions {
1858 target_components: vec!["flash-attn-ck".to_string()],
1859 ..Default::default()
1860 };
1861 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1862 let ck = items
1863 .iter()
1864 .find(|i| i.plan_item.component_id == "flash-attn-ck")
1865 .expect("explicitly-targeted CK survives");
1866 assert!(
1867 !ck.plan_item.dependencies.contains(&"aiter".to_string()),
1868 "CK backend must NOT depend on aiter; got {:?}",
1869 ck.plan_item.dependencies
1870 );
1871 assert!(items
1872 .iter()
1873 .all(|i| i.plan_item.component_id != "flash-attn"));
1874 }
1875
1876 #[test]
1877 fn test_targeted_candidate_is_selected() {
1878 let mut context = make_context();
1879 context
1880 .installed_versions
1881 .insert("pytorch".to_string(), "2.4.0".to_string());
1882 context.installed_components.insert("pytorch".to_string());
1883
1884 let manifest = make_manifest(vec![make_component(
1885 "pytorch",
1886 "3.0.0",
1887 ValidationTier::Validated,
1888 )]);
1889
1890 let options = PlannerOptions {
1891 target_components: vec!["pytorch".to_string()],
1892 ..Default::default()
1893 };
1894
1895 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1896 assert_eq!(items.len(), 1);
1897 assert_eq!(items[0].classification, UpdateClassification::Candidate);
1898 assert!(items[0].selected);
1899 assert!(items[0].plan_item.selected);
1900 }
1901
1902 #[test]
1903 fn test_all_safe_keeps_explicit_targeted_non_safe_component() {
1904 let mut context = make_context();
1905 context
1906 .installed_versions
1907 .insert("pytorch".to_string(), "2.4.0".to_string());
1908 context.installed_components.insert("pytorch".to_string());
1909 context
1910 .installed_versions
1911 .insert("triton".to_string(), "3.0.0".to_string());
1912 context.installed_components.insert("triton".to_string());
1913
1914 let manifest = make_manifest(vec![
1915 make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), ]);
1918
1919 let options = PlannerOptions {
1920 all_safe: true,
1921 target_components: vec!["triton".to_string()],
1922 ..Default::default()
1923 };
1924
1925 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1926
1927 assert_eq!(
1928 items.len(),
1929 2,
1930 "Targeted component and dependencies are in scope"
1931 );
1932
1933 let triton = items
1934 .iter()
1935 .find(|i| i.plan_item.component_id == "triton")
1936 .unwrap();
1937 assert_eq!(triton.classification, UpdateClassification::Guarded);
1938 assert!(triton.selected);
1939 assert!(triton.plan_item.selected);
1940
1941 let pytorch = items
1942 .iter()
1943 .find(|i| i.plan_item.component_id == "pytorch")
1944 .unwrap();
1945 assert_eq!(pytorch.classification, UpdateClassification::Safe);
1946 assert!(pytorch.selected);
1947 }
1948
1949 #[test]
1954 fn test_targeted_blocked_component_fails() {
1955 let context = make_context();
1956 let manifest = make_manifest(vec![
1957 make_component("pytorch", "2.5.0", ValidationTier::Validated),
1958 make_component("bad-pkg", "1.0.0", ValidationTier::Blocked),
1959 ]);
1960
1961 let options = PlannerOptions {
1962 target_components: vec!["bad-pkg".to_string()],
1963 ..Default::default()
1964 };
1965
1966 let result = planner().build_plan(&manifest, &context, &options);
1967 assert!(result.is_err(), "Targeting a blocked component should fail");
1968 }
1969
1970 #[test]
1971 fn test_targeted_rocm_component_allows_planned_rocm_prereq() {
1972 let context = make_context();
1973 let manifest = make_manifest(vec![
1974 make_component("rocm", "7.2.4", ValidationTier::Validated),
1975 make_component("pytorch", "2.5.0", ValidationTier::Validated),
1976 ]);
1977
1978 let options = PlannerOptions {
1979 target_components: vec!["pytorch".to_string()],
1980 ..Default::default()
1981 };
1982
1983 let items = planner().build_plan(&manifest, &context, &options).unwrap();
1984 let ids: HashSet<_> = items
1985 .iter()
1986 .map(|i| i.plan_item.component_id.as_str())
1987 .collect();
1988 assert!(ids.contains("rocm"));
1989 assert!(ids.contains("pytorch"));
1990 }
1991
1992 #[test]
1993 fn test_targeted_component_uses_manifest_declared_dependencies() {
1994 let context = make_context();
1995 let mut custom = make_component("custom-runtime", "1.2.0", ValidationTier::Validated);
1996 custom.dependencies = vec!["rocm".to_string(), "mpi4py".to_string()];
1997
1998 let manifest = make_manifest(vec![
1999 make_component("rocm", "7.2.4", ValidationTier::Validated),
2000 make_component("mpi4py", "4.0.0", ValidationTier::Validated),
2001 custom,
2002 ]);
2003
2004 let options = PlannerOptions {
2005 target_components: vec!["custom-runtime".to_string()],
2006 ..Default::default()
2007 };
2008
2009 let items = planner().build_plan(&manifest, &context, &options).unwrap();
2010 let ids: HashSet<_> = items
2011 .iter()
2012 .map(|i| i.plan_item.component_id.as_str())
2013 .collect();
2014 assert!(ids.contains("custom-runtime"));
2015 assert!(ids.contains("rocm"));
2016 assert!(ids.contains("mpi4py"));
2017 }
2018
2019 #[test]
2020 fn test_targeted_component_with_planned_rocm_not_blocked_by_min_version_on_fresh_host() {
2021 let context = make_context();
2022 let manifest = make_manifest(vec![
2023 make_component("rocm", "7.2.4", ValidationTier::Validated),
2024 ManifestComponent {
2025 min_rocm_version: "7.2.4".to_string(),
2026 ..make_component("llama-cpp", "0.2.0", ValidationTier::Validated)
2027 },
2028 ]);
2029
2030 let options = PlannerOptions {
2031 target_components: vec!["llama-cpp".to_string()],
2032 ..Default::default()
2033 };
2034
2035 let items = planner().build_plan(&manifest, &context, &options).unwrap();
2036 let ids: HashSet<_> = items
2037 .iter()
2038 .map(|i| i.plan_item.component_id.as_str())
2039 .collect();
2040 assert!(ids.contains("rocm"));
2041 assert!(ids.contains("llama-cpp"));
2042 }
2043
2044 #[test]
2045 fn test_targeted_unknown_component_fails() {
2046 let context = make_context();
2047 let manifest = make_manifest(vec![make_component(
2048 "pytorch",
2049 "2.5.0",
2050 ValidationTier::Validated,
2051 )]);
2052
2053 let options = PlannerOptions {
2054 target_components: vec!["nonexistent".to_string()],
2055 ..Default::default()
2056 };
2057
2058 let result = planner().build_plan(&manifest, &context, &options);
2059 assert!(
2060 result.is_err(),
2061 "Targeting a nonexistent component should fail"
2062 );
2063 }
2064
2065 #[test]
2071 fn test_planner_works_with_baseline_manifest() {
2072 let context = make_context();
2073 let manifest = make_manifest(vec![make_component(
2074 "pytorch",
2075 "2.4.0",
2076 ValidationTier::Validated,
2077 )]);
2078
2079 let options = PlannerOptions::default();
2080 let items = planner().build_plan(&manifest, &context, &options).unwrap();
2081 assert!(!items.is_empty());
2082 }
2083
2084 #[test]
2089 fn test_runtime_too_old_error() {
2090 let error = PlannerError::RuntimeTooOld {
2091 current: "0.1.0".to_string(),
2092 required: "0.2.0".to_string(),
2093 };
2094 let msg = error.to_string();
2095 assert!(msg.contains("0.1.0"));
2096 assert!(msg.contains("0.2.0"));
2097 assert!(msg.contains("rusty upgrade"));
2098 }
2099
2100 #[test]
2105 fn test_rocm_channel_in_context() {
2106 let mut context = make_context();
2107 context.rocm_channel = "legacy".to_string();
2108
2109 let manifest = make_manifest(vec![make_component(
2110 "pytorch",
2111 "2.4.0",
2112 ValidationTier::Validated,
2113 )]);
2114
2115 let options = PlannerOptions::default();
2116 let items = planner().build_plan(&manifest, &context, &options).unwrap();
2117 assert!(!items.is_empty());
2118 }
2119
2120 #[test]
2130 fn test_classification_serde_roundtrip() {
2131 let classifications = [
2132 UpdateClassification::Safe,
2133 UpdateClassification::Guarded,
2134 UpdateClassification::Blocked,
2135 UpdateClassification::Candidate,
2136 UpdateClassification::Experimental,
2137 ];
2138 for c in &classifications {
2139 let json = serde_json::to_string(c).unwrap();
2140 let back: UpdateClassification = serde_json::from_str(&json).unwrap();
2141 assert_eq!(*c, back);
2142 }
2143 }
2144
2145 #[test]
2150 fn test_plan_summary_from_items() {
2151 let mut context = make_context();
2152 context
2153 .installed_versions
2154 .insert("pytorch".to_string(), "2.4.0".to_string());
2155 context.installed_components.insert("pytorch".to_string());
2156 context
2157 .installed_versions
2158 .insert("triton".to_string(), "3.0.0".to_string());
2159 context.installed_components.insert("triton".to_string());
2160
2161 let manifest = make_manifest(vec![
2162 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), ]);
2167
2168 let options = PlannerOptions::default();
2169 let items = planner().build_plan(&manifest, &context, &options).unwrap();
2170
2171 let summary = PlanSummary::from_items(&items);
2172 assert_eq!(summary.total, 4);
2173 assert_eq!(summary.safe, 1);
2174 assert_eq!(summary.guarded, 1);
2175 assert_eq!(summary.candidate, 1);
2176 assert_eq!(summary.blocked, 1);
2177 assert_eq!(summary.selected, 1); }
2179
2180 #[test]
2185 fn test_planner_item_output_conversion() {
2186 let mut context = make_context();
2187 context
2188 .installed_versions
2189 .insert("pytorch".to_string(), "2.4.0".to_string());
2190 context.installed_components.insert("pytorch".to_string());
2191
2192 let manifest = make_manifest(vec![make_component(
2193 "pytorch",
2194 "2.4.1",
2195 ValidationTier::Validated,
2196 )]);
2197
2198 let options = PlannerOptions::default();
2199 let items = planner().build_plan(&manifest, &context, &options).unwrap();
2200
2201 let output: Vec<PlannerItemOutput> = items.iter().map(PlannerItemOutput::from).collect();
2202 assert_eq!(output.len(), 1);
2203 assert_eq!(output[0].component_id, "pytorch");
2204 assert_eq!(output[0].classification, "safe");
2205 assert!(output[0].selected);
2206 }
2207
2208 #[test]
2213 fn test_full_plan_build_mixed_classifications() {
2214 let mut context = make_context();
2215 context
2216 .installed_versions
2217 .insert("rocm".to_string(), "7.2.0".to_string());
2218 context.installed_components.insert("rocm".to_string());
2219 context
2220 .installed_versions
2221 .insert("pytorch".to_string(), "2.4.0".to_string());
2222 context.installed_components.insert("pytorch".to_string());
2223 context
2224 .installed_versions
2225 .insert("triton".to_string(), "3.0.0".to_string());
2226 context.installed_components.insert("triton".to_string());
2227
2228 let manifest = make_manifest(vec![
2229 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), ]);
2235
2236 let options = PlannerOptions {
2237 include_experimental: true,
2238 ..Default::default()
2239 };
2240
2241 let items = planner().build_plan(&manifest, &context, &options).unwrap();
2242
2243 assert_eq!(items.len(), 5);
2244
2245 let rocm = items
2246 .iter()
2247 .find(|i| i.plan_item.component_id == "rocm")
2248 .unwrap();
2249 assert_eq!(rocm.classification, UpdateClassification::Safe);
2250 assert!(rocm.selected);
2251
2252 let pytorch = items
2253 .iter()
2254 .find(|i| i.plan_item.component_id == "pytorch")
2255 .unwrap();
2256 assert_eq!(pytorch.classification, UpdateClassification::Guarded);
2257 assert!(!pytorch.selected);
2258
2259 let triton = items
2260 .iter()
2261 .find(|i| i.plan_item.component_id == "triton")
2262 .unwrap();
2263 assert_eq!(triton.classification, UpdateClassification::Candidate);
2264 assert!(!triton.selected);
2265
2266 let wandb = items
2267 .iter()
2268 .find(|i| i.plan_item.component_id == "wandb")
2269 .unwrap();
2270 assert_eq!(wandb.classification, UpdateClassification::Candidate);
2271 assert!(!wandb.selected);
2272
2273 let deepspeed = items
2274 .iter()
2275 .find(|i| i.plan_item.component_id == "deepspeed")
2276 .unwrap();
2277 assert_eq!(deepspeed.classification, UpdateClassification::Experimental);
2278 assert!(!deepspeed.selected);
2279 }
2280
2281 #[test]
2286 fn test_compatibility_context_default() {
2287 let ctx = CompatibilityContext::default();
2288 assert!(ctx.rocm_version.is_empty());
2289 assert!(ctx.rocm_channel.is_empty());
2290 assert!(ctx.available_executors.is_empty());
2291 assert!(ctx.installed_components.is_empty());
2292 assert!(ctx.installed_versions.is_empty());
2293 assert!(ctx.gpu_architecture.is_empty());
2294 assert!(ctx.runtime_version.is_empty());
2295 }
2296
2297 #[test]
2298 fn test_compatibility_context_is_installed() {
2299 let mut ctx = CompatibilityContext::new();
2300 assert!(!ctx.is_installed("pytorch"));
2301 ctx.installed_components.insert("pytorch".to_string());
2302 assert!(ctx.is_installed("pytorch"));
2303 }
2304
2305 #[test]
2306 fn test_compatibility_context_installed_version() {
2307 let mut ctx = CompatibilityContext::new();
2308 assert!(ctx.installed_version("pytorch").is_none());
2309 ctx.installed_versions
2310 .insert("pytorch".to_string(), "2.4.0".to_string());
2311 assert_eq!(ctx.installed_version("pytorch"), Some("2.4.0"));
2312 }
2313
2314 #[test]
2319 fn test_planner_error_display() {
2320 let err = PlannerError::ComponentBlocked {
2321 component_id: "bad-pkg".to_string(),
2322 reason: "incompatible hardware".to_string(),
2323 };
2324 assert!(err.to_string().contains("bad-pkg"));
2325 assert!(err.to_string().contains("incompatible hardware"));
2326
2327 let err = PlannerError::ComponentUnknown {
2328 component_id: "nonexistent".to_string(),
2329 };
2330 assert!(err.to_string().contains("nonexistent"));
2331
2332 let err = PlannerError::RuntimeTooOld {
2333 current: "0.1.0".to_string(),
2334 required: "0.2.0".to_string(),
2335 };
2336 assert!(err.to_string().contains("0.1.0"));
2337 assert!(err.to_string().contains("rusty upgrade"));
2338 }
2339
2340 #[test]
2341 fn test_planner_error_is_std_error() {
2342 let err = PlannerError::ComponentUnknown {
2343 component_id: "test".to_string(),
2344 };
2345 let _: &dyn std::error::Error = &err;
2346 }
2347
2348 #[test]
2353 fn test_scan_output_serde_roundtrip() {
2354 let output = ScanOutput {
2355 installed: vec![InstalledComponent {
2356 id: "pytorch".to_string(),
2357 version: "2.4.0".to_string(),
2358 status: "installed".to_string(),
2359 }],
2360 manifest_source: "baseline".to_string(),
2361 rocm_channel: "latest".to_string(),
2362 rocm_version: "7.2.1".to_string(),
2363 gpu_architecture: "gfx1100".to_string(),
2364 };
2365 let json = serde_json::to_string(&output).unwrap();
2366 let back: ScanOutput = serde_json::from_str(&json).unwrap();
2367 assert_eq!(output, back);
2368 }
2369
2370 #[test]
2371 fn test_plan_output_serde_roundtrip() {
2372 let output = PlanOutput {
2373 plan: vec![PlannerItemOutput {
2374 component_id: "pytorch".to_string(),
2375 current_version: "2.4.0".to_string(),
2376 proposed_version: "2.4.1".to_string(),
2377 classification: "safe".to_string(),
2378 risk_tier: "low".to_string(),
2379 selected: true,
2380 visible: true,
2381 rationale: "patch update".to_string(),
2382 dependencies: vec!["rocm".to_string()],
2383 exclusive_group: String::new(),
2384 }],
2385 summary: PlanSummary {
2386 total: 1,
2387 safe: 1,
2388 guarded: 0,
2389 candidate: 0,
2390 experimental: 0,
2391 blocked: 0,
2392 selected: 1,
2393 },
2394 };
2395 let json = serde_json::to_string(&output).unwrap();
2396 let back: PlanOutput = serde_json::from_str(&json).unwrap();
2397 assert_eq!(output, back);
2398 }
2399
2400 #[test]
2405 fn test_classification_visibility() {
2406 assert!(UpdateClassification::Safe.is_visible());
2407 assert!(UpdateClassification::Guarded.is_visible());
2408 assert!(!UpdateClassification::Blocked.is_visible());
2409 assert!(UpdateClassification::Candidate.is_visible());
2410 assert!(!UpdateClassification::Experimental.is_visible());
2411 }
2412
2413 #[test]
2414 fn test_classification_preselection() {
2415 assert!(UpdateClassification::Safe.is_preselected());
2416 assert!(!UpdateClassification::Guarded.is_preselected());
2417 assert!(!UpdateClassification::Blocked.is_preselected());
2418 assert!(!UpdateClassification::Candidate.is_preselected());
2419 assert!(!UpdateClassification::Experimental.is_preselected());
2420 }
2421
2422 #[test]
2423 fn test_classification_labels() {
2424 assert_eq!(UpdateClassification::Safe.label(), "safe");
2425 assert_eq!(UpdateClassification::Guarded.label(), "guarded");
2426 assert_eq!(UpdateClassification::Blocked.label(), "blocked");
2427 assert_eq!(UpdateClassification::Candidate.label(), "candidate");
2428 assert_eq!(UpdateClassification::Experimental.label(), "experimental");
2429 }
2430
2431 #[test]
2436 fn test_derive_dependencies_pytorch() {
2437 let deps = planner().derive_dependencies("pytorch");
2438 assert!(deps.contains(&"rocm".to_string()));
2439 }
2440
2441 #[test]
2442 fn test_derive_dependencies_triton() {
2443 let deps = planner().derive_dependencies("triton");
2444 assert!(deps.contains(&"pytorch".to_string()));
2445 }
2446
2447 #[test]
2448 fn test_derive_dependencies_rocm() {
2449 let deps = planner().derive_dependencies("rocm");
2450 assert!(deps.is_empty());
2451 }
2452
2453 #[test]
2454 fn test_derive_dependencies_unknown() {
2455 let deps = planner().derive_dependencies("unknown-component");
2456 assert!(deps.is_empty());
2457 }
2458
2459 #[test]
2464 fn test_version_bump_patch() {
2465 assert_eq!(
2466 planner().version_bump_level("1.0.0", "1.0.1"),
2467 BumpLevel::Patch
2468 );
2469 }
2470
2471 #[test]
2472 fn test_version_bump_minor() {
2473 assert_eq!(
2474 planner().version_bump_level("1.0.0", "1.1.0"),
2475 BumpLevel::Minor
2476 );
2477 }
2478
2479 #[test]
2480 fn test_version_bump_major() {
2481 assert_eq!(
2482 planner().version_bump_level("1.0.0", "2.0.0"),
2483 BumpLevel::Major
2484 );
2485 }
2486
2487 #[test]
2488 fn test_version_bump_unknown() {
2489 assert_eq!(
2490 planner().version_bump_level("abc", "def"),
2491 BumpLevel::Unknown
2492 );
2493 }
2494
2495 #[test]
2496 fn test_version_bump_downgrade_is_unknown() {
2497 assert_eq!(
2498 planner().version_bump_level("7.3.0", "7.2.4"),
2499 BumpLevel::Unknown
2500 );
2501 assert_eq!(
2502 planner().version_bump_level("2.5.1", "2.5.0"),
2503 BumpLevel::Unknown
2504 );
2505 }
2506
2507 #[test]
2512 fn test_version_gte_equal() {
2513 assert!(version_gte("7.2.1", "7.2.1"));
2514 }
2515
2516 #[test]
2517 fn test_version_gte_greater() {
2518 assert!(version_gte("7.3.0", "7.2.1"));
2519 assert!(version_gte("8.0.0", "7.2.1"));
2520 assert!(version_gte("7.2.2", "7.2.1"));
2521 }
2522
2523 #[test]
2524 fn test_version_gte_less() {
2525 assert!(!version_gte("7.2.0", "7.2.1"));
2526 assert!(!version_gte("6.0.0", "7.0.0"));
2527 }
2528
2529 #[test]
2530 fn test_version_gte_different_lengths() {
2531 assert!(version_gte("7.2", "7.2.0"));
2532 assert!(version_gte("7.2.1", "7.2"));
2533 assert!(!version_gte("7.1", "7.2.0"));
2534 }
2535
2536 #[test]
2537 fn test_is_current_or_newer_handles_semver_and_opaque_targets() {
2538 assert!(is_current_or_newer("1.27.1", "1.27.1"));
2539 assert!(is_current_or_newer("1.28.0", "1.27.1"));
2540 assert!(is_current_or_newer("2.12.1+rocm7.2", "2.12.1"));
2541 assert!(!is_current_or_newer("1.23.2", "1.27.1"));
2542 assert!(!is_current_or_newer("v0.20.1", "latest"));
2543 assert!(is_current_or_newer("0.1.19.dev40+g6c48c5fa0", "0.1.0"));
2546 }
2547
2548 #[test]
2549 fn test_parse_version_parts_truncates_dev_suffix() {
2550 assert_eq!(
2552 parse_version_parts("0.1.19.dev40+g6c48c5fa0"),
2553 Some(vec![0, 1, 19])
2554 );
2555 assert_eq!(parse_version_parts("7.2.1"), Some(vec![7, 2, 1]));
2556 assert_eq!(parse_version_parts("7.2"), Some(vec![7, 2]));
2557 assert_eq!(parse_version_parts("latest"), None);
2559 }
2560
2561 #[test]
2566 fn test_blocked_by_rocm_version_requirement() {
2567 let context = make_context(); let component = ManifestComponent {
2570 id: "pytorch".to_string(),
2571 version: "3.0.0".to_string(),
2572 script: "install_pytorch.sh".to_string(),
2573 category: crate::core::types::Category::Core,
2574 validation_tier: ValidationTier::Validated,
2575 min_rocm_version: "99.0.0".to_string(), compatible_channels: vec![],
2577 dependencies: vec![],
2578 exclusive_group: String::new(),
2579 };
2580
2581 let classification =
2582 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2583 assert_eq!(classification, UpdateClassification::Blocked);
2584 }
2585
2586 #[test]
2587 fn test_not_blocked_by_satisfied_rocm_requirement() {
2588 let context = make_context(); let component = ManifestComponent {
2591 id: "pytorch".to_string(),
2592 version: "3.0.0".to_string(),
2593 script: "install_pytorch.sh".to_string(),
2594 category: crate::core::types::Category::Core,
2595 validation_tier: ValidationTier::Validated,
2596 min_rocm_version: "7.0.0".to_string(),
2597 compatible_channels: vec![],
2598 dependencies: vec![],
2599 exclusive_group: String::new(),
2600 };
2601
2602 let classification =
2603 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2604 assert_ne!(classification, UpdateClassification::Blocked);
2605 }
2606
2607 #[test]
2612 fn test_blocked_by_incompatible_channel() {
2613 let mut context = make_context();
2614 context.rocm_channel = "legacy".to_string();
2615
2616 let component = ManifestComponent {
2617 id: "pytorch".to_string(),
2618 version: "3.0.0".to_string(),
2619 script: "install_pytorch.sh".to_string(),
2620 category: crate::core::types::Category::Core,
2621 validation_tier: ValidationTier::Validated,
2622 min_rocm_version: String::new(),
2623 compatible_channels: vec!["latest".to_string(), "stable".to_string()],
2624 dependencies: vec![],
2625 exclusive_group: String::new(),
2626 };
2627
2628 let classification =
2629 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2630 assert_eq!(classification, UpdateClassification::Blocked);
2631 }
2632
2633 #[test]
2634 fn test_allowed_by_compatible_channel() {
2635 let mut context = make_context();
2636 context.rocm_channel = "latest".to_string();
2637
2638 let component = ManifestComponent {
2639 id: "pytorch".to_string(),
2640 version: "3.0.0".to_string(),
2641 script: "install_pytorch.sh".to_string(),
2642 category: crate::core::types::Category::Core,
2643 validation_tier: ValidationTier::Validated,
2644 min_rocm_version: String::new(),
2645 compatible_channels: vec!["latest".to_string(), "stable".to_string()],
2646 dependencies: vec![],
2647 exclusive_group: String::new(),
2648 };
2649
2650 let classification =
2651 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2652 assert_ne!(classification, UpdateClassification::Blocked);
2653 }
2654
2655 #[test]
2656 fn test_channel_filter_legacy_excludes_latest_only() {
2657 let mut context = make_context();
2658 context.rocm_channel = "legacy".to_string();
2659
2660 let manifest = make_manifest(vec![ManifestComponent {
2661 id: "pytorch".to_string(),
2662 version: "3.0.0".to_string(),
2663 script: "install_pytorch.sh".to_string(),
2664 category: crate::core::types::Category::Core,
2665 validation_tier: ValidationTier::Validated,
2666 min_rocm_version: String::new(),
2667 compatible_channels: vec!["latest".to_string()],
2668 dependencies: vec![],
2669 exclusive_group: String::new(),
2670 }]);
2671
2672 let options = PlannerOptions::default();
2673 let items = planner().build_plan(&manifest, &context, &options).unwrap();
2674
2675 assert_eq!(items.len(), 1);
2677 assert_eq!(items[0].classification, UpdateClassification::Blocked);
2678 assert!(!items[0].visible);
2679 }
2680
2681 #[test]
2686 fn test_runtime_too_old_blocks_plan() {
2687 let context = make_context(); let mut manifest = make_manifest(vec![make_component(
2690 "pytorch",
2691 "3.0.0",
2692 ValidationTier::Validated,
2693 )]);
2694 manifest.min_runtime_version = "99.0.0".to_string();
2695
2696 let mut ctx = context;
2697 ctx.runtime_version = "0.1.0".to_string();
2698
2699 let options = PlannerOptions::default();
2700 let result = planner().build_plan(&manifest, &ctx, &options);
2701 assert!(result.is_err());
2702 match result.unwrap_err() {
2703 PlannerError::RuntimeTooOld { current, required } => {
2704 assert_eq!(current, "0.1.0");
2705 assert_eq!(required, "99.0.0");
2706 }
2707 other => panic!("Expected RuntimeTooOld, got: {other:?}"),
2708 }
2709 }
2710
2711 #[test]
2712 fn test_runtime_version_satisfied() {
2713 let mut context = make_context();
2714 context.runtime_version = "1.0.0".to_string();
2715
2716 let mut manifest = make_manifest(vec![make_component(
2717 "pytorch",
2718 "3.0.0",
2719 ValidationTier::Validated,
2720 )]);
2721 manifest.min_runtime_version = "0.5.0".to_string();
2722
2723 let options = PlannerOptions::default();
2724 let result = planner().build_plan(&manifest, &context, &options);
2725 assert!(result.is_ok());
2726 }
2727
2728 #[test]
2733 fn test_blocked_when_rocm_not_installed_but_required() {
2734 let mut context = CompatibilityContext::new();
2735 context.rocm_version = String::new(); context.available_executors =
2737 HashSet::from([ExecutorKind::LegacyScript, ExecutorKind::Rust]);
2738
2739 let component = make_component("pytorch", "3.0.0", ValidationTier::Validated);
2740 let classification =
2741 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2742 assert_eq!(classification, UpdateClassification::Blocked);
2743 }
2744
2745 #[test]
2746 fn test_rocm_component_allowed_when_rocm_not_installed() {
2747 let mut context = CompatibilityContext::new();
2748 context.rocm_version = String::new();
2749 context.available_executors =
2750 HashSet::from([ExecutorKind::LegacyScript, ExecutorKind::Rust]);
2751
2752 let component = make_component("rocm", "7.2.4", ValidationTier::Validated);
2753 let classification =
2754 planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2755 assert_ne!(classification, UpdateClassification::Blocked);
2756 }
2757
2758 #[test]
2759 fn test_isolation_safe_tracks_classification() {
2760 let mut context = make_context();
2761 context
2762 .installed_versions
2763 .insert("pytorch".to_string(), "2.4.0".to_string());
2764 context.installed_components.insert("pytorch".to_string());
2765 context
2766 .installed_versions
2767 .insert("triton".to_string(), "3.0.0".to_string());
2768 context.installed_components.insert("triton".to_string());
2769
2770 let manifest = make_manifest(vec![
2771 make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), ]);
2774 let items = planner()
2775 .build_plan(&manifest, &context, &PlannerOptions::default())
2776 .unwrap();
2777
2778 let pytorch = items
2779 .iter()
2780 .find(|i| i.plan_item.component_id == "pytorch")
2781 .unwrap();
2782 let triton = items
2783 .iter()
2784 .find(|i| i.plan_item.component_id == "triton")
2785 .unwrap();
2786
2787 assert!(pytorch.plan_item.isolation_safe);
2788 assert!(!triton.plan_item.isolation_safe);
2789 }
2790
2791 #[test]
2796 fn test_block_reason_includes_rocm_version() {
2797 let context = make_context(); let component = ManifestComponent {
2800 id: "pytorch".to_string(),
2801 version: "3.0.0".to_string(),
2802 script: "install_pytorch.sh".to_string(),
2803 category: crate::core::types::Category::Core,
2804 validation_tier: ValidationTier::Validated,
2805 min_rocm_version: "99.0.0".to_string(),
2806 compatible_channels: vec![],
2807 dependencies: vec![],
2808 exclusive_group: String::new(),
2809 };
2810
2811 let reason =
2812 planner().block_reason(&component, &context, !context.rocm_version.is_empty(), None);
2813 assert!(reason.contains("99.0.0"));
2814 assert!(reason.contains("7.2.1"));
2815 }
2816
2817 #[test]
2818 fn test_block_reason_channel_incompatibility() {
2819 let mut context = make_context();
2820 context.rocm_channel = "legacy".to_string();
2821
2822 let component = ManifestComponent {
2823 id: "pytorch".to_string(),
2824 version: "3.0.0".to_string(),
2825 script: "install_pytorch.sh".to_string(),
2826 category: crate::core::types::Category::Core,
2827 validation_tier: ValidationTier::Validated,
2828 min_rocm_version: String::new(),
2829 compatible_channels: vec!["latest".to_string()],
2830 dependencies: vec![],
2831 exclusive_group: String::new(),
2832 };
2833
2834 let reason =
2835 planner().block_reason(&component, &context, !context.rocm_version.is_empty(), None);
2836 assert!(reason.contains("legacy"));
2837 }
2838}