Skip to main content

rusty_stack/orchestrator/
planner.rs

1//! Update planner — computes eligible updates and classifies by risk.
2//!
3//! This module implements:
4//! - [`UpdateClassification`] — safe/guarded/blocked/candidate/experimental
5//! - [`PlannerItem`] — an update candidate with classification, selection state, rationale
6//! - [`UpdatePlanner`] — builds an update plan from scan results and manifest data
7//!
8//! # Classification Rules
9//!
10//! | Classification | Criteria |
11//! |----------------|----------|
12//! | `Safe`         | Patch-only bump, Validated tier, no new dependencies, compatible hardware |
13//! | `Guarded`      | Minor bump or new dependencies or config changes |
14//! | `Blocked`      | Incompatible hardware, missing executor, manifest-declared incompatibility |
15//! | `Candidate`    | Major bump that passes all compatibility checks |
16//! | `Experimental` | Component marked `Experimental` validation tier |
17//!
18//! # Selection Defaults
19//!
20//! | Classification | Visible | Preselected |
21//! |----------------|---------|-------------|
22//! | Safe           | Yes     | Yes         |
23//! | Guarded        | Yes     | No          |
24//! | Candidate      | Yes     | No          |
25//! | Experimental   | No*     | No          |
26//! | Blocked        | No      | No          |
27//!
28//! \* Visible only with `--include-experimental` flag.
29
30use 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// ---------------------------------------------------------------------------
38// Update Classification
39// ---------------------------------------------------------------------------
40
41/// Classification of an update's risk and suitability.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum UpdateClassification {
45    /// Patch-only bump, validated, no new deps, compatible hardware.
46    Safe,
47    /// Minor bump, new dependencies, or config changes.
48    Guarded,
49    /// Incompatible hardware, missing executor, or manifest-declared incompatibility.
50    Blocked,
51    /// Major version bump that passes all compatibility checks.
52    Candidate,
53    /// Experimental validation tier component.
54    Experimental,
55}
56
57impl UpdateClassification {
58    /// Human-readable label for this classification.
59    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    /// Whether updates with this classification are visible by default.
70    pub fn is_visible(self) -> bool {
71        !matches!(
72            self,
73            UpdateClassification::Blocked | UpdateClassification::Experimental
74        )
75    }
76
77    /// Whether updates with this classification are preselected by default.
78    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// ---------------------------------------------------------------------------
90// Planner Item (extends PlanItem with classification)
91// ---------------------------------------------------------------------------
92
93/// An update candidate produced by the planner.
94///
95/// Extends the base [`PlanItem`] with classification, visibility, and
96/// compatibility information.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98pub struct PlannerItem {
99    /// Base plan item with risk tier, dependencies, etc.
100    #[serde(flatten)]
101    pub plan_item: PlanItem,
102    /// Update classification (safe/guarded/blocked/candidate/experimental).
103    pub classification: UpdateClassification,
104    /// Whether this item is visible in the default plan view.
105    pub visible: bool,
106    /// Whether this item is preselected for apply.
107    pub selected: bool,
108    /// Reason for the classification (human-readable).
109    pub classification_reason: String,
110    /// Whether this component requires hardware compatibility check.
111    pub requires_hardware_check: bool,
112    /// Minimum ROCm version required for this update (empty = any).
113    pub min_rocm_version: String,
114}
115
116// ---------------------------------------------------------------------------
117// Compatibility Context
118// ---------------------------------------------------------------------------
119
120/// Context about the system's capabilities used for compatibility checks.
121#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
122pub struct CompatibilityContext {
123    /// Current ROCm version (empty if not installed).
124    pub rocm_version: String,
125    /// Active ROCm channel (Legacy/Stable/Latest).
126    pub rocm_channel: String,
127    /// Available executor kinds on this platform.
128    pub available_executors: HashSet<ExecutorKind>,
129    /// Component IDs that are currently installed.
130    pub installed_components: HashSet<String>,
131    /// Version map: component_id → installed version string.
132    pub installed_versions: HashMap<String, String>,
133    /// GPU architecture (e.g., "gfx1100").
134    pub gpu_architecture: String,
135    /// Current runtime version of the binary.
136    pub runtime_version: String,
137}
138
139impl CompatibilityContext {
140    /// Create a new empty context.
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    /// Check if a component is installed.
146    pub fn is_installed(&self, component_id: &str) -> bool {
147        self.installed_components.contains(component_id)
148    }
149
150    /// Get the installed version of a component.
151    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// ---------------------------------------------------------------------------
159// Planner Options
160// ---------------------------------------------------------------------------
161
162/// Options controlling planner behavior.
163#[derive(Debug, Clone, Default)]
164pub struct PlannerOptions {
165    /// Only scan and produce a plan, don't apply.
166    pub scan_only: bool,
167    /// Apply all safe-classified updates without prompting.
168    pub all_safe: bool,
169    /// Include experimental components in the plan.
170    pub include_experimental: bool,
171    /// Specific component IDs to update (empty = all eligible).
172    pub target_components: Vec<String>,
173    /// Force JSON output mode.
174    pub json_output: bool,
175}
176
177// ---------------------------------------------------------------------------
178// Planner Error
179// ---------------------------------------------------------------------------
180
181/// Errors that can occur during planning.
182#[derive(Debug, Clone, PartialEq)]
183pub enum PlannerError {
184    /// A targeted component is blocked.
185    ComponentBlocked {
186        component_id: String,
187        reason: String,
188    },
189    /// A targeted component is unknown.
190    ComponentUnknown { component_id: String },
191    /// The runtime version is too old for the manifest.
192    RuntimeTooOld { current: String, required: String },
193    /// Dependency conflict detected.
194    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
235// ---------------------------------------------------------------------------
236// Update Planner
237// ---------------------------------------------------------------------------
238
239/// Builds an update plan from manifest data and system context.
240///
241/// The planner:
242/// 1. Resolves the manifest (baseline + overlay)
243/// 2. For each component, runs compatibility checks
244/// 3. Classifies each update (safe/guarded/blocked/candidate/experimental)
245/// 4. Applies selection defaults
246/// 5. Enforces dependency rules
247/// 6. Returns a filtered, ordered plan
248pub struct UpdatePlanner;
249
250impl UpdatePlanner {
251    /// Create a new planner instance.
252    pub fn new() -> Self {
253        Self
254    }
255
256    /// Build an update plan from the given manifest and compatibility context.
257    ///
258    /// Applies classification rules, selection defaults, and dependency ordering.
259    /// Returns a plan with [`PlannerItem`] entries.
260    pub fn build_plan(
261        &self,
262        manifest: &Manifest,
263        context: &CompatibilityContext,
264        options: &PlannerOptions,
265    ) -> Result<Vec<PlannerItem>, PlannerError> {
266        // Check runtime version compatibility
267        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 specific targets were given, include targets and their dependency closure.
300            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            // An update plan must never contain a reinstall or a downgrade.
308            // Keep opaque targets such as `latest` eligible, because their
309            // concrete version is resolved by the component installer.
310            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            // Classify the update
316            let classification = self.classify_update(
317                component,
318                context,
319                rocm_available_or_planned,
320                planned_rocm_version,
321            );
322
323            // If blocked, handle differently
324            if classification == UpdateClassification::Blocked {
325                // If explicitly targeted (or required by a target), return error
326                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                // Otherwise, include in plan but mark as blocked
341                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            // Filter experimental unless flag is set — BUT never drop a component
352            // the user explicitly targeted by name (e.g. `update migraphx-python`),
353            // regardless of tier/visibility. Targeting is an explicit opt-in.
354            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        // Enforce exclusive groups: components sharing an `exclusive_group`
371        // install conflicting/overlapping artifacts (e.g. Flash Attention's
372        // Triton vs CK backends both install the identical `flash_attn` package,
373        // so only the last-installed remains active). Keep AT MOST ONE per group:
374        // if the user explicitly targeted a member, keep that one; otherwise keep
375        // the first-listed (the manifest's recommended default). This prevents a
376        // blanket `--all-safe` (or `update flash-attn-triton flash-attn-ck`) from
377        // scheduling redundant, order-dependent builds.
378        self.enforce_exclusive_groups(&mut items, &target_set);
379
380        // Apply --all-safe: select safe items (and experimental if explicitly included),
381        // but never discard explicitly targeted components.
382        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            // Auto-select retained items. Set BOTH the outer `item.selected`
400            // (read by PlannerItemOutput::from and PlanSummary::from_items, and
401            // used by the apply path) AND `item.plan_item.selected` (the
402            // serialized plan field). Setting only plan_item.selected left
403            // experimental items retained+displayed but silently unselected for
404            // apply, contradicting --include-experimental.
405            for item in &mut items {
406                item.selected = true;
407                item.plan_item.selected = true;
408            }
409        }
410
411        // Explicit targets should be selected even when classification defaults are non-safe.
412        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        // Enforce dependency rules
422        self.enforce_dependency_rules(&mut items)?;
423
424        Ok(items)
425    }
426
427    /// Classify a single component update.
428    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        // Experimental tier always classifies as experimental
436        if component.validation_tier == ValidationTier::Experimental {
437            return UpdateClassification::Experimental;
438        }
439
440        // Blocked tier always classifies as blocked
441        if component.validation_tier == ValidationTier::Blocked {
442            return UpdateClassification::Blocked;
443        }
444
445        // Check hardware compatibility
446        if !self.is_hardware_compatible(component, context) {
447            return UpdateClassification::Blocked;
448        }
449
450        // Check executor availability
451        if !self.is_executor_available(component, context) {
452            return UpdateClassification::Blocked;
453        }
454
455        // Check ROCm version compatibility
456        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        // Classify based on version delta
466        let current_version = context.installed_version(&component.id).unwrap_or("");
467
468        // Not installed → Candidate (not safe to auto-install)
469        if current_version.is_empty() {
470            return UpdateClassification::Candidate;
471        }
472
473        // Same version remains safe classification, but is treated as already current.
474        if current_version == component.version {
475            return UpdateClassification::Safe;
476        }
477
478        // Parse version delta
479        match self.version_bump_level(current_version, &component.version) {
480            BumpLevel::Patch => {
481                // Patch bump: safe if validated tier, guarded otherwise
482                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                // Can't determine version delta → guarded
492                UpdateClassification::Guarded
493            }
494        }
495    }
496
497    /// Build a planner item from a manifest component and its classification.
498    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        // Copy the manifest's exclusive_group onto the plan item so
544        // enforce_exclusive_groups can enforce at-most-one-per-group without
545        // re-reading the manifest.
546        item.plan_item.exclusive_group = component.exclusive_group.clone();
547        item
548    }
549
550    /// Check hardware compatibility for a component.
551    fn is_hardware_compatible(
552        &self,
553        component: &ManifestComponent,
554        context: &CompatibilityContext,
555    ) -> bool {
556        // Check ROCm channel compatibility at the hardware level
557        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    /// Check if an executor is available for a component.
570    fn is_executor_available(
571        &self,
572        _component: &ManifestComponent,
573        context: &CompatibilityContext,
574    ) -> bool {
575        // At minimum, we need LegacyScript or Rust executor
576        context
577            .available_executors
578            .contains(&ExecutorKind::LegacyScript)
579            || context.available_executors.contains(&ExecutorKind::Rust)
580    }
581
582    /// Check ROCm version compatibility.
583    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        // Components that don't need ROCm are always compatible
591        if !self.requires_rocm(&component.id) {
592            return true;
593        }
594
595        // ROCm itself must remain installable on fresh systems.
596        if component.id == "rocm" {
597            return true;
598        }
599
600        // If ROCm is not installed, ROCm-requiring components are blocked
601        if context.rocm_version.is_empty() && !rocm_available_or_planned {
602            return false;
603        }
604
605        // Check min ROCm version if specified in manifest
606        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        // Check ROCm channel compatibility
615        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    /// Check if a component requires ROCm.
628    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    /// Check if a component requires hardware compatibility check.
650    fn requires_hardware_check(&self, component_id: &str) -> bool {
651        self.requires_rocm(component_id)
652    }
653
654    /// Get the reason a component is blocked.
655    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    /// Get the classification reason string.
710    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    /// Derive known dependencies for a component.
760    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    /// Derive known dependencies for a component.
768    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            // FA-Triton's Triton backend imports aiter.ops.triton at runtime —
773            // aiter must be installed first or flash_attn import fails. CK uses
774            // composable_kernel, not aiter. The legacy `flash-attn` id is
775            // normalized to `flash-attn-triton` by the executor (and is the
776            // default survivor of the `flash-attn-backend` exclusive group), so
777            // it MUST share Triton's closure — otherwise the default --all-safe
778            // route installs the Triton backend without its `aiter` prerequisite.
779            "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    /// Derive known dependencies for a component.
851    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    /// Check runtime version against manifest requirements.
889    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    /// Enforce dependency rules on the plan items.
913    ///
914    /// Enforce mutual-exclusion groups: within each non-empty `exclusive_group`,
915    /// keep at most one component. If the user explicitly targeted a member, keep
916    /// that one (and if multiple were targeted, keep the first-listed and drop the
917    /// rest — targeting two mutually exclusive backends is contradictory). When no
918    /// member was explicitly targeted, keep the first-listed in the manifest (the
919    /// recommended default). Mutates `items` in place, preserving manifest order.
920    fn enforce_exclusive_groups(&self, items: &mut Vec<PlannerItem>, target_set: &HashSet<&str>) {
921        use std::collections::HashMap;
922
923        // Map each exclusive_group -> the component ids in that group, in manifest
924        // (i.e. current items) order. Only non-empty groups participate.
925        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        // For each group, decide which single member survives.
937        let mut survivors: HashSet<String> = HashSet::new();
938        for members in group_members.values() {
939            // Prefer an explicitly-targeted member (first-listed if multiple targeted).
940            let chosen = members
941                .iter()
942                .find(|id| target_set.contains(id.as_str()))
943                .or_else(|| members.first()) // else the manifest's recommended default
944                .cloned();
945            if let Some(id) = chosen {
946                survivors.insert(id);
947            }
948        }
949
950        if survivors.is_empty() {
951            return;
952        }
953
954        // Retain: keep an item iff it has no group OR it is its group's survivor.
955        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    /// If component A depends on B and both are selected:
962    /// - If B is deselected, A must also be deselected (or B re-selected)
963    fn enforce_dependency_rules(&self, items: &mut [PlannerItem]) -> Result<(), PlannerError> {
964        // Auto-select dependencies of selected items
965        // We need to collect the deps that need auto-selection first, then apply
966        let mut changed = true;
967        while changed {
968            changed = false;
969
970            // Collect (index, needs_select) pairs for deps
971            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// ---------------------------------------------------------------------------
1003// Helpers
1004// ---------------------------------------------------------------------------
1005
1006/// Version bump level.
1007#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1008enum BumpLevel {
1009    Patch,
1010    Minor,
1011    Major,
1012    Unknown,
1013}
1014
1015/// Parse a version string into numeric parts.
1016fn parse_version_parts(version: &str) -> Option<Vec<u32>> {
1017    let base = version.split('-').next()?;
1018    // Truncate at the first non-numeric segment instead of failing the whole
1019    // parse. PEP 440 dev/local versions like "0.1.19.dev40+g6c48c5fa0" must still
1020    // yield [0,1,19] so the caller sees they're newer than "0.1.0". Failing
1021    // entirely (the old `collect::<Option>` behaviour) made `is_current_or_newer`
1022    // give up and flag a perfectly-current dev build as needing a *downgrade*.
1023    // Full `+local` build metadata (e.g. "2.12.1+rocm7.2") is handled by the
1024    // semver path in `is_current_or_newer`; this fallback only needs the prefix.
1025    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
1039/// Compare two version strings: returns true if `actual >= required`.
1040///
1041/// Handles versions like "7.2.1", "7.2", "7". Missing parts are treated as 0.
1042fn 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 // equal
1058}
1059
1060/// Return whether the installed version already satisfies the manifest target.
1061///
1062/// Semver is preferred so build metadata such as `+rocm7.2` does not make an
1063/// otherwise equal version look like an update. Numeric fallback keeps support
1064/// for existing two-part component versions. Opaque values (`latest`, git
1065/// tags, `installed`) remain eligible because they cannot prove that the target
1066/// is already installed.
1067fn 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// ---------------------------------------------------------------------------
1101// Scan Result JSON output
1102// ---------------------------------------------------------------------------
1103
1104/// Machine-readable scan output for non-TTY / JSON mode.
1105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1106pub struct ScanOutput {
1107    /// Components detected as installed.
1108    pub installed: Vec<InstalledComponent>,
1109    /// Manifest source used for planning.
1110    pub manifest_source: String,
1111    /// ROCm channel in use.
1112    pub rocm_channel: String,
1113    /// ROCm version detected.
1114    pub rocm_version: String,
1115    /// GPU architecture detected.
1116    pub gpu_architecture: String,
1117}
1118
1119/// An installed component in scan output.
1120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1121pub struct InstalledComponent {
1122    pub id: String,
1123    pub version: String,
1124    pub status: String,
1125}
1126
1127// ---------------------------------------------------------------------------
1128// Plan Output JSON
1129// ---------------------------------------------------------------------------
1130
1131/// Machine-readable plan output for non-TTY / JSON mode.
1132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1133pub struct PlanOutput {
1134    /// The plan items.
1135    pub plan: Vec<PlannerItemOutput>,
1136    /// Summary statistics.
1137    pub summary: PlanSummary,
1138}
1139
1140/// A single item in the plan output.
1141#[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    /// Mutual-exclusion group (empty = none). Surfaced so JSON consumers can see
1153    /// which items were deduped as mutually exclusive backends.
1154    #[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/// Summary statistics for the plan.
1176#[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    /// Compute summary from a list of planner items.
1189    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// ===========================================================================
1218// Tests
1219// ===========================================================================
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224    use crate::core::manifest::{Manifest, ManifestComponent};
1225    use crate::core::types::{Category, ValidationTier};
1226
1227    // -----------------------------------------------------------------------
1228    // Helpers
1229    // -----------------------------------------------------------------------
1230
1231    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    /// Variant of [`make_component`] that sets the `exclusive_group`.
1246    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    // -----------------------------------------------------------------------
1287    // VAL-UPD-002: Safe updates (patch-only, validated) classified correctly
1288    // -----------------------------------------------------------------------
1289
1290    #[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    // -----------------------------------------------------------------------
1385    // VAL-UPD-003: Guarded updates (minor bump, new deps) classified correctly
1386    // -----------------------------------------------------------------------
1387
1388    #[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        // Patch bump but Candidate tier → guarded
1411        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    // -----------------------------------------------------------------------
1418    // VAL-UPD-004: Blocked updates (incompatible hardware, missing executor)
1419    // -----------------------------------------------------------------------
1420
1421    #[test]
1422    fn test_blocked_classification_missing_executor() {
1423        let mut context = CompatibilityContext::new();
1424        // No executors available
1425        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    // -----------------------------------------------------------------------
1443    // VAL-UPD-005: Candidate updates (major bump, compatible)
1444    // -----------------------------------------------------------------------
1445
1446    #[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        // Not installed → Candidate
1464        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    // -----------------------------------------------------------------------
1471    // VAL-UPD-006: Experimental updates hidden without --include-experimental
1472    // -----------------------------------------------------------------------
1473
1474    #[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    // -----------------------------------------------------------------------
1524    // VAL-UPD-007: Safe updates preselected by default
1525    // -----------------------------------------------------------------------
1526
1527    #[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    // -----------------------------------------------------------------------
1549    // VAL-UPD-008: Guarded updates suggested but not auto-applied
1550    // -----------------------------------------------------------------------
1551
1552    #[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    // -----------------------------------------------------------------------
1577    // VAL-UPD-009: Candidate updates visible but never preselected
1578    // -----------------------------------------------------------------------
1579
1580    #[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    // -----------------------------------------------------------------------
1606    // VAL-UPD-010: Experimental updates hidden unless --include-experimental
1607    // (covered above in test_experimental_hidden_without_flag)
1608    // -----------------------------------------------------------------------
1609
1610    // -----------------------------------------------------------------------
1611    // VAL-UPD-011: Blocked updates never offered, explicit targeting fails
1612    // -----------------------------------------------------------------------
1613
1614    #[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        // Blocked items should be in the plan but not visible or selected
1627        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    // -----------------------------------------------------------------------
1661    // VAL-UPD-013: Dependency rules enforced in plan
1662    // -----------------------------------------------------------------------
1663
1664    #[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), // safe (patch)
1678            make_component("pytorch", "2.4.1", ValidationTier::Validated), // safe (patch)
1679        ]);
1680
1681        let options = PlannerOptions::default();
1682        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1683
1684        // Both should be selected (pytorch depends on rocm, both safe)
1685        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    // -----------------------------------------------------------------------
1699    // VAL-UPD-019: --scan-only produces plan without applying
1700    // -----------------------------------------------------------------------
1701
1702    #[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        // scan_only doesn't change the plan content, just prevents apply
1724    }
1725
1726    // -----------------------------------------------------------------------
1727    // VAL-UPD-020: --all-safe applies only validated safe updates
1728    // -----------------------------------------------------------------------
1729
1730    #[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), // safe (patch)
1744            make_component("triton", "3.1.0", ValidationTier::Validated),  // guarded (minor)
1745        ]);
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        // Regression (PR #27 fourth-wave review): enforce_exclusive_groups retains
1762        // the first-listed flash-attn-backend entry (legacy `flash-attn`) when no
1763        // backend is explicitly targeted. The executor normalizes that id to
1764        // `flash-attn-triton`, whose runtime import requires `aiter`. The planner
1765        // must therefore give the legacy id the SAME closure as Triton — otherwise
1766        // the default --all-safe route installs the Triton backend without `aiter`
1767        // and flash_attn is non-functional. Asserts both the survivor selection
1768        // AND the dependency closure.
1769        let mut context = make_context();
1770        // Mark FA backends as installed at an older version so they classify as
1771        // plan candidates (not filtered). aiter/pytorch/rocm/triton present so
1772        // nothing is blocked on missing prerequisites.
1773        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        // Exactly one backend survives the exclusive group (the first-listed =
1811        // legacy flash-attn, the manifest's recommended default).
1812        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        // The survivor MUST carry aiter (it installs the Triton backend).
1822        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        // When CK is explicitly targeted, it becomes the group survivor and must
1835        // NOT carry aiter (CK uses composable_kernel, not aiter).
1836        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), // safe
1916            make_component("triton", "3.1.0", ValidationTier::Validated),  // guarded
1917        ]);
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    // -----------------------------------------------------------------------
1950    // VAL-UPD-021: Targeted component still runs full compatibility check
1951    // -----------------------------------------------------------------------
1952
1953    #[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    // -----------------------------------------------------------------------
2066    // VAL-UPD-023: Manifest fetch failure falls back to cached/baseline
2067    // (This is tested in core::manifest tests; planner accepts any Manifest)
2068    // -----------------------------------------------------------------------
2069
2070    #[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    // -----------------------------------------------------------------------
2085    // VAL-UPD-024: Runtime too old produces upgrade instruction
2086    // -----------------------------------------------------------------------
2087
2088    #[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    // -----------------------------------------------------------------------
2101    // VAL-UPD-026: Hardware scan respects ROCm channel selection
2102    // -----------------------------------------------------------------------
2103
2104    #[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    // -----------------------------------------------------------------------
2121    // VAL-UPD-027: Signed remote manifest validated
2122    // (Manifest trust is tested in core::manifest; planner accepts any Manifest)
2123    // -----------------------------------------------------------------------
2124
2125    // -----------------------------------------------------------------------
2126    // Classification serde roundtrip
2127    // -----------------------------------------------------------------------
2128
2129    #[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    // -----------------------------------------------------------------------
2146    // PlanSummary tests
2147    // -----------------------------------------------------------------------
2148
2149    #[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), // safe
2163            make_component("triton", "3.1.0", ValidationTier::Validated),  // guarded
2164            make_component("new-pkg", "1.0.0", ValidationTier::Validated), // candidate (new)
2165            make_component("bad-pkg", "1.0.0", ValidationTier::Blocked),   // blocked
2166        ]);
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); // only safe is selected
2178    }
2179
2180    // -----------------------------------------------------------------------
2181    // PlannerItemOutput conversion
2182    // -----------------------------------------------------------------------
2183
2184    #[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    // -----------------------------------------------------------------------
2209    // Full plan build with multiple classifications
2210    // -----------------------------------------------------------------------
2211
2212    #[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), // safe (patch)
2230            make_component("pytorch", "2.5.0", ValidationTier::Validated), // guarded (minor)
2231            make_component("triton", "4.0.0", ValidationTier::Validated), // candidate (major)
2232            make_component("wandb", "0.17.0", ValidationTier::Validated), // candidate (new install)
2233            make_component("deepspeed", "0.15.0", ValidationTier::Experimental), // experimental
2234        ]);
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    // -----------------------------------------------------------------------
2282    // CompatibilityContext tests
2283    // -----------------------------------------------------------------------
2284
2285    #[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    // -----------------------------------------------------------------------
2315    // PlannerError tests
2316    // -----------------------------------------------------------------------
2317
2318    #[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    // -----------------------------------------------------------------------
2349    // ScanOutput / PlanOutput serde
2350    // -----------------------------------------------------------------------
2351
2352    #[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    // -----------------------------------------------------------------------
2401    // UpdateClassification properties
2402    // -----------------------------------------------------------------------
2403
2404    #[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    // -----------------------------------------------------------------------
2432    // Dependency derivation
2433    // -----------------------------------------------------------------------
2434
2435    #[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    // -----------------------------------------------------------------------
2460    // Version bump level detection
2461    // -----------------------------------------------------------------------
2462
2463    #[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    // -----------------------------------------------------------------------
2508    // version_gte helper
2509    // -----------------------------------------------------------------------
2510
2511    #[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        // PEP 440 dev/local build (e.g. aiter 0.1.19.dev40+g6c48c5fa0) is NEWER
2544        // than the manifest's pinned 0.1.0 — must NOT be proposed as a downgrade.
2545        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        // Non-numeric segments truncate the prefix instead of nuking the parse.
2551        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        // Fully opaque values still yield None (remain eligible-for-update).
2558        assert_eq!(parse_version_parts("latest"), None);
2559    }
2560
2561    // -----------------------------------------------------------------------
2562    // VAL-UPD-004: Blocked by ROCm version requirement
2563    // -----------------------------------------------------------------------
2564
2565    #[test]
2566    fn test_blocked_by_rocm_version_requirement() {
2567        let context = make_context(); // rocm_version = "7.2.1"
2568
2569        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(), // impossibly high
2576            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(); // rocm_version = "7.2.1"
2589
2590        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    // -----------------------------------------------------------------------
2608    // VAL-UPD-026: Hardware scan respects ROCm channel selection
2609    // -----------------------------------------------------------------------
2610
2611    #[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        // The item should be blocked (not visible)
2676        assert_eq!(items.len(), 1);
2677        assert_eq!(items[0].classification, UpdateClassification::Blocked);
2678        assert!(!items[0].visible);
2679    }
2680
2681    // -----------------------------------------------------------------------
2682    // VAL-UPD-024: Runtime too old produces upgrade instruction
2683    // -----------------------------------------------------------------------
2684
2685    #[test]
2686    fn test_runtime_too_old_blocks_plan() {
2687        let context = make_context(); // runtime_version is empty by default
2688
2689        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    // -----------------------------------------------------------------------
2729    // VAL-UPD-004: Blocked when ROCm not installed but required
2730    // -----------------------------------------------------------------------
2731
2732    #[test]
2733    fn test_blocked_when_rocm_not_installed_but_required() {
2734        let mut context = CompatibilityContext::new();
2735        context.rocm_version = String::new(); // no ROCm
2736        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), // safe
2772            make_component("triton", "3.1.0", ValidationTier::Validated),  // guarded
2773        ]);
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    // -----------------------------------------------------------------------
2792    // VAL-UPD-027: Block reason includes ROCm version info
2793    // -----------------------------------------------------------------------
2794
2795    #[test]
2796    fn test_block_reason_includes_rocm_version() {
2797        let context = make_context(); // rocm_version = "7.2.1"
2798
2799        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}