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            // Classify the update
308            let classification = self.classify_update(
309                component,
310                context,
311                rocm_available_or_planned,
312                planned_rocm_version,
313            );
314
315            // If blocked, handle differently
316            if classification == UpdateClassification::Blocked {
317                // If explicitly targeted (or required by a target), return error
318                if target_set.contains(component.id.as_str())
319                    || required_deps.contains(component.id.as_str())
320                {
321                    let reason = self.block_reason(
322                        component,
323                        context,
324                        rocm_available_or_planned,
325                        planned_rocm_version,
326                    );
327                    return Err(PlannerError::ComponentBlocked {
328                        component_id: component.id.clone(),
329                        reason,
330                    });
331                }
332                // Otherwise, include in plan but mark as blocked
333                items.push(self.build_planner_item(
334                    component,
335                    classification,
336                    context,
337                    rocm_available_or_planned,
338                    planned_rocm_version,
339                ));
340                continue;
341            }
342
343            // Filter experimental unless flag is set
344            if classification == UpdateClassification::Experimental && !options.include_experimental
345            {
346                continue;
347            }
348
349            items.push(self.build_planner_item(
350                component,
351                classification,
352                context,
353                rocm_available_or_planned,
354                planned_rocm_version,
355            ));
356        }
357
358        // Apply --all-safe: keep safe items (and experimental if explicitly included),
359        // but never discard explicitly targeted components.
360        if options.all_safe {
361            if options.include_experimental {
362                items.retain(|i| {
363                    if target_set.contains(i.plan_item.component_id.as_str()) {
364                        return true;
365                    }
366                    matches!(
367                        i.classification,
368                        UpdateClassification::Safe | UpdateClassification::Experimental
369                    )
370                });
371            } else {
372                items.retain(|i| {
373                    target_set.contains(i.plan_item.component_id.as_str())
374                        || i.classification == UpdateClassification::Safe
375                });
376            }
377        }
378
379        // Explicit targets should be selected even when classification defaults are non-safe.
380        if !target_set.is_empty() {
381            for item in &mut items {
382                if target_set.contains(item.plan_item.component_id.as_str()) {
383                    item.selected = true;
384                    item.plan_item.selected = true;
385                }
386            }
387        }
388
389        // Enforce dependency rules
390        self.enforce_dependency_rules(&mut items)?;
391
392        Ok(items)
393    }
394
395    /// Classify a single component update.
396    fn classify_update(
397        &self,
398        component: &ManifestComponent,
399        context: &CompatibilityContext,
400        rocm_available_or_planned: bool,
401        planned_rocm_version: Option<&str>,
402    ) -> UpdateClassification {
403        // Experimental tier always classifies as experimental
404        if component.validation_tier == ValidationTier::Experimental {
405            return UpdateClassification::Experimental;
406        }
407
408        // Blocked tier always classifies as blocked
409        if component.validation_tier == ValidationTier::Blocked {
410            return UpdateClassification::Blocked;
411        }
412
413        // Check hardware compatibility
414        if !self.is_hardware_compatible(component, context) {
415            return UpdateClassification::Blocked;
416        }
417
418        // Check executor availability
419        if !self.is_executor_available(component, context) {
420            return UpdateClassification::Blocked;
421        }
422
423        // Check ROCm version compatibility
424        if !self.is_rocm_compatible(
425            component,
426            context,
427            rocm_available_or_planned,
428            planned_rocm_version,
429        ) {
430            return UpdateClassification::Blocked;
431        }
432
433        // Classify based on version delta
434        let current_version = context.installed_version(&component.id).unwrap_or("");
435
436        // Not installed → Candidate (not safe to auto-install)
437        if current_version.is_empty() {
438            return UpdateClassification::Candidate;
439        }
440
441        // Same version remains safe classification, but is treated as already current.
442        if current_version == component.version {
443            return UpdateClassification::Safe;
444        }
445
446        // Parse version delta
447        match self.version_bump_level(current_version, &component.version) {
448            BumpLevel::Patch => {
449                // Patch bump: safe if validated tier, guarded otherwise
450                if component.validation_tier == ValidationTier::Validated {
451                    UpdateClassification::Safe
452                } else {
453                    UpdateClassification::Guarded
454                }
455            }
456            BumpLevel::Minor => UpdateClassification::Guarded,
457            BumpLevel::Major => UpdateClassification::Candidate,
458            BumpLevel::Unknown => {
459                // Can't determine version delta → guarded
460                UpdateClassification::Guarded
461            }
462        }
463    }
464
465    /// Build a planner item from a manifest component and its classification.
466    fn build_planner_item(
467        &self,
468        component: &ManifestComponent,
469        classification: UpdateClassification,
470        context: &CompatibilityContext,
471        rocm_available_or_planned: bool,
472        planned_rocm_version: Option<&str>,
473    ) -> PlannerItem {
474        let current_version = context
475            .installed_version(&component.id)
476            .unwrap_or("")
477            .to_string();
478
479        let visible = classification.is_visible();
480        let selected = classification.is_preselected() && current_version != component.version;
481        let isolation_safe = matches!(classification, UpdateClassification::Safe);
482
483        let classification_reason = self.classification_reason(
484            component,
485            classification,
486            context,
487            rocm_available_or_planned,
488            planned_rocm_version,
489        );
490
491        let dependencies = self.dependencies_for_component(component);
492
493        PlannerItem {
494            plan_item: PlanItem::new(PlanItemInput {
495                component_id: component.id.clone(),
496                current_version: current_version.clone(),
497                proposed_version: component.version.clone(),
498                validation_tier: component.validation_tier,
499                selected,
500                rationale: classification_reason.clone(),
501                dependencies,
502                isolation_safe,
503            }),
504            classification,
505            visible,
506            selected,
507            classification_reason,
508            requires_hardware_check: self.requires_hardware_check(&component.id),
509            min_rocm_version: component.min_rocm_version.clone(),
510        }
511    }
512
513    /// Check hardware compatibility for a component.
514    fn is_hardware_compatible(
515        &self,
516        component: &ManifestComponent,
517        context: &CompatibilityContext,
518    ) -> bool {
519        // Check ROCm channel compatibility at the hardware level
520        if !component.compatible_channels.is_empty()
521            && self.requires_rocm(&component.id)
522            && !component
523                .compatible_channels
524                .iter()
525                .any(|ch| ch.eq_ignore_ascii_case(&context.rocm_channel))
526        {
527            return false;
528        }
529        true
530    }
531
532    /// Check if an executor is available for a component.
533    fn is_executor_available(
534        &self,
535        _component: &ManifestComponent,
536        context: &CompatibilityContext,
537    ) -> bool {
538        // At minimum, we need LegacyScript or Rust executor
539        context
540            .available_executors
541            .contains(&ExecutorKind::LegacyScript)
542            || context.available_executors.contains(&ExecutorKind::Rust)
543    }
544
545    /// Check ROCm version compatibility.
546    fn is_rocm_compatible(
547        &self,
548        component: &ManifestComponent,
549        context: &CompatibilityContext,
550        rocm_available_or_planned: bool,
551        planned_rocm_version: Option<&str>,
552    ) -> bool {
553        // Components that don't need ROCm are always compatible
554        if !self.requires_rocm(&component.id) {
555            return true;
556        }
557
558        // ROCm itself must remain installable on fresh systems.
559        if component.id == "rocm" {
560            return true;
561        }
562
563        // If ROCm is not installed, ROCm-requiring components are blocked
564        if context.rocm_version.is_empty() && !rocm_available_or_planned {
565            return false;
566        }
567
568        // Check min ROCm version if specified in manifest
569        let effective_rocm_version = self.effective_rocm_version(context, planned_rocm_version);
570        if !component.min_rocm_version.is_empty()
571            && !effective_rocm_version.is_empty()
572            && !version_gte(&effective_rocm_version, &component.min_rocm_version)
573        {
574            return false;
575        }
576
577        // Check ROCm channel compatibility
578        if !component.compatible_channels.is_empty()
579            && !component
580                .compatible_channels
581                .iter()
582                .any(|ch| ch.eq_ignore_ascii_case(&context.rocm_channel))
583        {
584            return false;
585        }
586
587        true
588    }
589
590    /// Check if a component requires ROCm.
591    fn requires_rocm(&self, component_id: &str) -> bool {
592        matches!(
593            component_id,
594            "rocm"
595                | "rocm-smi"
596                | "pytorch"
597                | "triton"
598                | "onnx"
599                | "migraphx"
600                | "flash-attn"
601                | "rccl"
602                | "vllm"
603                | "aiter"
604                | "deepspeed"
605                | "bitsandbytes"
606                | "llama-cpp"
607        )
608    }
609
610    /// Check if a component requires hardware compatibility check.
611    fn requires_hardware_check(&self, component_id: &str) -> bool {
612        self.requires_rocm(component_id)
613    }
614
615    /// Get the reason a component is blocked.
616    fn block_reason(
617        &self,
618        component: &ManifestComponent,
619        context: &CompatibilityContext,
620        rocm_available_or_planned: bool,
621        planned_rocm_version: Option<&str>,
622    ) -> String {
623        if component.validation_tier == ValidationTier::Blocked {
624            return "component is marked as blocked in manifest".to_string();
625        }
626
627        if !self.is_hardware_compatible(component, context) {
628            return format!(
629                "incompatible with ROCm channel '{}' (requires: {})",
630                context.rocm_channel,
631                component.compatible_channels.join(", ")
632            );
633        }
634
635        if !self.is_executor_available(component, context) {
636            return "no available executor for this platform".to_string();
637        }
638
639        if !self.is_rocm_compatible(
640            component,
641            context,
642            rocm_available_or_planned,
643            planned_rocm_version,
644        ) {
645            if context.rocm_version.is_empty() && !rocm_available_or_planned {
646                return "requires ROCm but ROCm is not installed".to_string();
647            }
648            let effective_rocm_version = self.effective_rocm_version(context, planned_rocm_version);
649            if !component.min_rocm_version.is_empty() {
650                if effective_rocm_version.is_empty() {
651                    return format!(
652                        "requires ROCm >= {} and will rely on the planned ROCm install",
653                        component.min_rocm_version
654                    );
655                }
656                return format!(
657                    "requires ROCm >= {} but effective plan version is {}",
658                    component.min_rocm_version, effective_rocm_version
659                );
660            }
661            return format!(
662                "incompatible with effective ROCm version ({})",
663                effective_rocm_version
664            );
665        }
666
667        "unknown reason".to_string()
668    }
669
670    /// Get the classification reason string.
671    fn classification_reason(
672        &self,
673        component: &ManifestComponent,
674        classification: UpdateClassification,
675        context: &CompatibilityContext,
676        rocm_available_or_planned: bool,
677        planned_rocm_version: Option<&str>,
678    ) -> String {
679        let current = context.installed_version(&component.id).unwrap_or("");
680
681        match classification {
682            UpdateClassification::Safe => {
683                if current == component.version {
684                    format!("already up to date (v{})", component.version)
685                } else {
686                    format!(
687                        "patch update {} → {} (validated, no new deps)",
688                        current, component.version
689                    )
690                }
691            }
692            UpdateClassification::Guarded => {
693                format!(
694                    "minor update {} → {} (new dependencies or config changes)",
695                    current, component.version
696                )
697            }
698            UpdateClassification::Blocked => self.block_reason(
699                component,
700                context,
701                rocm_available_or_planned,
702                planned_rocm_version,
703            ),
704            UpdateClassification::Candidate => {
705                if current.is_empty() {
706                    format!("new install v{}", component.version)
707                } else {
708                    format!(
709                        "major update {} → {} (compatible)",
710                        current, component.version
711                    )
712                }
713            }
714            UpdateClassification::Experimental => {
715                format!("experimental component v{}", component.version)
716            }
717        }
718    }
719
720    /// Derive known dependencies for a component.
721    fn dependencies_for_component(&self, component: &ManifestComponent) -> Vec<String> {
722        if !component.dependencies.is_empty() {
723            return component.dependencies.clone();
724        }
725        self.derive_dependencies(&component.id)
726    }
727
728    /// Derive known dependencies for a component.
729    fn derive_dependencies(&self, component_id: &str) -> Vec<String> {
730        match component_id {
731            "pytorch" => vec!["rocm".to_string()],
732            "triton" => vec!["pytorch".to_string()],
733            "flash-attn" => vec!["pytorch".to_string()],
734            "deepspeed" => vec!["pytorch".to_string()],
735            "vllm" => vec!["pytorch".to_string()],
736            "megatron" => vec!["pytorch".to_string(), "mpi4py".to_string()],
737            "onnx" => vec!["rocm".to_string()],
738            "migraphx" => vec!["rocm".to_string()],
739            "bitsandbytes" => vec!["pytorch".to_string()],
740            "aiter" => vec!["pytorch".to_string()],
741            "mpi4py" => vec![],
742            "wandb" => vec![],
743            "comfyui" => vec!["pytorch".to_string()],
744            "vllm-studio" => vec!["vllm".to_string()],
745            "textgen" => vec!["pytorch".to_string()],
746            "rocm" => vec![],
747            "rocm-smi" => vec!["rocm".to_string()],
748            "llama-cpp" => vec!["rocm".to_string()],
749            "permanent-env" => vec![],
750            _ => vec![],
751        }
752    }
753
754    fn collect_required_dependencies(
755        &self,
756        manifest: &Manifest,
757        targets: &HashSet<&str>,
758    ) -> HashSet<String> {
759        let mut required = HashSet::new();
760        let mut stack: Vec<String> = targets.iter().map(|s| (*s).to_string()).collect();
761
762        while let Some(component) = stack.pop() {
763            let deps = manifest
764                .components
765                .iter()
766                .find(|c| c.id == component)
767                .map(|c| self.dependencies_for_component(c))
768                .unwrap_or_else(|| self.derive_dependencies(&component));
769            for dep in deps {
770                if required.insert(dep.clone()) {
771                    stack.push(dep);
772                }
773            }
774        }
775
776        for target in targets {
777            required.remove(*target);
778        }
779
780        required
781    }
782
783    fn effective_rocm_version(
784        &self,
785        context: &CompatibilityContext,
786        planned_rocm_version: Option<&str>,
787    ) -> String {
788        match planned_rocm_version {
789            Some(planned) if context.rocm_version.is_empty() => planned.to_string(),
790            Some(planned) if version_gte(planned, &context.rocm_version) => planned.to_string(),
791            _ => context.rocm_version.clone(),
792        }
793    }
794
795    /// Derive known dependencies for a component.
796    fn version_bump_level(&self, current: &str, proposed: &str) -> BumpLevel {
797        let cur_parts = parse_version_parts(current);
798        let pro_parts = parse_version_parts(proposed);
799
800        match (cur_parts, pro_parts) {
801            (Some(cur), Some(pro)) if !cur.is_empty() && !pro.is_empty() => {
802                let len = cur.len().max(pro.len());
803                let mut cmp = std::cmp::Ordering::Equal;
804                for idx in 0..len {
805                    let cur_part = *cur.get(idx).unwrap_or(&0);
806                    let pro_part = *pro.get(idx).unwrap_or(&0);
807                    cmp = pro_part.cmp(&cur_part);
808                    if cmp != std::cmp::Ordering::Equal {
809                        break;
810                    }
811                }
812
813                match cmp {
814                    std::cmp::Ordering::Less => BumpLevel::Unknown,
815                    std::cmp::Ordering::Equal => BumpLevel::Patch,
816                    std::cmp::Ordering::Greater => {
817                        if pro.first().copied().unwrap_or(0) > cur.first().copied().unwrap_or(0) {
818                            BumpLevel::Major
819                        } else if pro.get(1).copied().unwrap_or(0)
820                            > cur.get(1).copied().unwrap_or(0)
821                        {
822                            BumpLevel::Minor
823                        } else {
824                            BumpLevel::Patch
825                        }
826                    }
827                }
828            }
829            _ => BumpLevel::Unknown,
830        }
831    }
832
833    /// Check runtime version against manifest requirements.
834    fn check_runtime_version(
835        &self,
836        manifest: &Manifest,
837        context: &CompatibilityContext,
838    ) -> Result<(), PlannerError> {
839        if manifest.min_runtime_version.is_empty() {
840            return Ok(());
841        }
842
843        if context.runtime_version.is_empty() {
844            return Ok(());
845        }
846
847        if !version_gte(&context.runtime_version, &manifest.min_runtime_version) {
848            return Err(PlannerError::RuntimeTooOld {
849                current: context.runtime_version.clone(),
850                required: manifest.min_runtime_version.clone(),
851            });
852        }
853
854        Ok(())
855    }
856
857    /// Enforce dependency rules on the plan items.
858    ///
859    /// If component A depends on B and both are selected:
860    /// - If B is deselected, A must also be deselected (or B re-selected)
861    fn enforce_dependency_rules(&self, items: &mut [PlannerItem]) -> Result<(), PlannerError> {
862        // Auto-select dependencies of selected items
863        // We need to collect the deps that need auto-selection first, then apply
864        let mut changed = true;
865        while changed {
866            changed = false;
867
868            // Collect (index, needs_select) pairs for deps
869            let deps_to_select: Vec<String> = items
870                .iter()
871                .filter(|i| i.selected)
872                .flat_map(|i| i.plan_item.dependencies.clone())
873                .collect();
874
875            for dep_id in &deps_to_select {
876                if let Some(dep_item) = items
877                    .iter_mut()
878                    .find(|i| i.plan_item.component_id == *dep_id)
879                {
880                    if !dep_item.selected
881                        && dep_item.classification != UpdateClassification::Blocked
882                    {
883                        dep_item.selected = true;
884                        changed = true;
885                    }
886                }
887            }
888        }
889
890        Ok(())
891    }
892}
893
894impl Default for UpdatePlanner {
895    fn default() -> Self {
896        Self::new()
897    }
898}
899
900// ---------------------------------------------------------------------------
901// Helpers
902// ---------------------------------------------------------------------------
903
904/// Version bump level.
905#[derive(Debug, Clone, Copy, PartialEq, Eq)]
906enum BumpLevel {
907    Patch,
908    Minor,
909    Major,
910    Unknown,
911}
912
913/// Parse a version string into numeric parts.
914fn parse_version_parts(version: &str) -> Option<Vec<u32>> {
915    let base = version.split('-').next()?;
916    base.split('.')
917        .map(|s| s.parse::<u32>().ok())
918        .collect::<Option<Vec<_>>>()
919}
920
921/// Compare two version strings: returns true if `actual >= required`.
922///
923/// Handles versions like "7.2.1", "7.2", "7". Missing parts are treated as 0.
924fn version_gte(actual: &str, required: &str) -> bool {
925    let act = parse_version_parts(actual).unwrap_or_default();
926    let req = parse_version_parts(required).unwrap_or_default();
927
928    let max_len = act.len().max(req.len());
929    for i in 0..max_len {
930        let a = act.get(i).copied().unwrap_or(0);
931        let r = req.get(i).copied().unwrap_or(0);
932        if a > r {
933            return true;
934        }
935        if a < r {
936            return false;
937        }
938    }
939    true // equal
940}
941
942// ---------------------------------------------------------------------------
943// Scan Result JSON output
944// ---------------------------------------------------------------------------
945
946/// Machine-readable scan output for non-TTY / JSON mode.
947#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
948pub struct ScanOutput {
949    /// Components detected as installed.
950    pub installed: Vec<InstalledComponent>,
951    /// Manifest source used for planning.
952    pub manifest_source: String,
953    /// ROCm channel in use.
954    pub rocm_channel: String,
955    /// ROCm version detected.
956    pub rocm_version: String,
957    /// GPU architecture detected.
958    pub gpu_architecture: String,
959}
960
961/// An installed component in scan output.
962#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
963pub struct InstalledComponent {
964    pub id: String,
965    pub version: String,
966    pub status: String,
967}
968
969// ---------------------------------------------------------------------------
970// Plan Output JSON
971// ---------------------------------------------------------------------------
972
973/// Machine-readable plan output for non-TTY / JSON mode.
974#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
975pub struct PlanOutput {
976    /// The plan items.
977    pub plan: Vec<PlannerItemOutput>,
978    /// Summary statistics.
979    pub summary: PlanSummary,
980}
981
982/// A single item in the plan output.
983#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
984pub struct PlannerItemOutput {
985    pub component_id: String,
986    pub current_version: String,
987    pub proposed_version: String,
988    pub classification: String,
989    pub risk_tier: String,
990    pub selected: bool,
991    pub visible: bool,
992    pub rationale: String,
993    pub dependencies: Vec<String>,
994}
995
996impl From<&PlannerItem> for PlannerItemOutput {
997    fn from(item: &PlannerItem) -> Self {
998        Self {
999            component_id: item.plan_item.component_id.clone(),
1000            current_version: item.plan_item.current_version.clone(),
1001            proposed_version: item.plan_item.proposed_version.clone(),
1002            classification: item.classification.label().to_string(),
1003            risk_tier: item.plan_item.risk_tier.label().to_string(),
1004            selected: item.selected,
1005            visible: item.visible,
1006            rationale: item.plan_item.rationale.clone(),
1007            dependencies: item.plan_item.dependencies.clone(),
1008        }
1009    }
1010}
1011
1012/// Summary statistics for the plan.
1013#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1014pub struct PlanSummary {
1015    pub total: usize,
1016    pub safe: usize,
1017    pub guarded: usize,
1018    pub candidate: usize,
1019    pub experimental: usize,
1020    pub blocked: usize,
1021    pub selected: usize,
1022}
1023
1024impl PlanSummary {
1025    /// Compute summary from a list of planner items.
1026    pub fn from_items(items: &[PlannerItem]) -> Self {
1027        Self {
1028            total: items.len(),
1029            safe: items
1030                .iter()
1031                .filter(|i| i.classification == UpdateClassification::Safe)
1032                .count(),
1033            guarded: items
1034                .iter()
1035                .filter(|i| i.classification == UpdateClassification::Guarded)
1036                .count(),
1037            candidate: items
1038                .iter()
1039                .filter(|i| i.classification == UpdateClassification::Candidate)
1040                .count(),
1041            experimental: items
1042                .iter()
1043                .filter(|i| i.classification == UpdateClassification::Experimental)
1044                .count(),
1045            blocked: items
1046                .iter()
1047                .filter(|i| i.classification == UpdateClassification::Blocked)
1048                .count(),
1049            selected: items.iter().filter(|i| i.selected).count(),
1050        }
1051    }
1052}
1053
1054// ===========================================================================
1055// Tests
1056// ===========================================================================
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::*;
1061    use crate::core::manifest::{Manifest, ManifestComponent};
1062    use crate::core::types::{Category, ValidationTier};
1063
1064    // -----------------------------------------------------------------------
1065    // Helpers
1066    // -----------------------------------------------------------------------
1067
1068    fn make_component(id: &str, version: &str, tier: ValidationTier) -> ManifestComponent {
1069        ManifestComponent {
1070            id: id.to_string(),
1071            version: version.to_string(),
1072            script: format!("install_{id}.sh"),
1073            category: Category::Core,
1074            validation_tier: tier,
1075            min_rocm_version: String::new(),
1076            compatible_channels: vec![],
1077            dependencies: vec![],
1078        }
1079    }
1080
1081    fn make_manifest(components: Vec<ManifestComponent>) -> Manifest {
1082        Manifest {
1083            schema_version: 2,
1084            sequence: 1,
1085            generated_at: String::new(),
1086            expires_at: None,
1087            min_runtime_version: String::new(),
1088            components,
1089            signature: None,
1090        }
1091    }
1092
1093    fn make_context() -> CompatibilityContext {
1094        let mut ctx = CompatibilityContext::new();
1095        ctx.rocm_version = "7.2.1".to_string();
1096        ctx.rocm_channel = "latest".to_string();
1097        ctx.gpu_architecture = "gfx1100".to_string();
1098        ctx.available_executors = HashSet::from([
1099            ExecutorKind::LegacyScript,
1100            ExecutorKind::Rust,
1101            ExecutorKind::ExternalPackageManager,
1102        ]);
1103        ctx
1104    }
1105
1106    fn planner() -> UpdatePlanner {
1107        UpdatePlanner::new()
1108    }
1109
1110    // -----------------------------------------------------------------------
1111    // VAL-UPD-002: Safe updates (patch-only, validated) classified correctly
1112    // -----------------------------------------------------------------------
1113
1114    #[test]
1115    fn test_safe_classification_patch_bump_validated() {
1116        let mut context = make_context();
1117        context
1118            .installed_versions
1119            .insert("pytorch".to_string(), "2.4.0".to_string());
1120        context.installed_components.insert("pytorch".to_string());
1121
1122        let component = make_component("pytorch", "2.4.1", ValidationTier::Validated);
1123        let classification =
1124            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1125        assert_eq!(classification, UpdateClassification::Safe);
1126    }
1127
1128    #[test]
1129    fn test_safe_classification_same_version_reinstall() {
1130        let mut context = make_context();
1131        context
1132            .installed_versions
1133            .insert("pytorch".to_string(), "2.4.0".to_string());
1134        context.installed_components.insert("pytorch".to_string());
1135
1136        let component = make_component("pytorch", "2.4.0", ValidationTier::Validated);
1137        let classification =
1138            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1139        assert_eq!(classification, UpdateClassification::Safe);
1140    }
1141
1142    // -----------------------------------------------------------------------
1143    // VAL-UPD-003: Guarded updates (minor bump, new deps) classified correctly
1144    // -----------------------------------------------------------------------
1145
1146    #[test]
1147    fn test_guarded_classification_minor_bump() {
1148        let mut context = make_context();
1149        context
1150            .installed_versions
1151            .insert("pytorch".to_string(), "2.4.0".to_string());
1152        context.installed_components.insert("pytorch".to_string());
1153
1154        let component = make_component("pytorch", "2.5.0", ValidationTier::Validated);
1155        let classification =
1156            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1157        assert_eq!(classification, UpdateClassification::Guarded);
1158    }
1159
1160    #[test]
1161    fn test_guarded_classification_patch_bump_candidate_tier() {
1162        let mut context = make_context();
1163        context
1164            .installed_versions
1165            .insert("triton".to_string(), "3.0.0".to_string());
1166        context.installed_components.insert("triton".to_string());
1167
1168        // Patch bump but Candidate tier → guarded
1169        let component = make_component("triton", "3.0.1", ValidationTier::Candidate);
1170        let classification =
1171            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1172        assert_eq!(classification, UpdateClassification::Guarded);
1173    }
1174
1175    // -----------------------------------------------------------------------
1176    // VAL-UPD-004: Blocked updates (incompatible hardware, missing executor)
1177    // -----------------------------------------------------------------------
1178
1179    #[test]
1180    fn test_blocked_classification_missing_executor() {
1181        let mut context = CompatibilityContext::new();
1182        // No executors available
1183        context.rocm_version = "7.2.1".to_string();
1184
1185        let component = make_component("pytorch", "2.4.0", ValidationTier::Validated);
1186        let classification =
1187            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1188        assert_eq!(classification, UpdateClassification::Blocked);
1189    }
1190
1191    #[test]
1192    fn test_blocked_classification_blocked_tier() {
1193        let context = make_context();
1194        let component = make_component("bad-pkg", "1.0.0", ValidationTier::Blocked);
1195        let classification =
1196            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1197        assert_eq!(classification, UpdateClassification::Blocked);
1198    }
1199
1200    // -----------------------------------------------------------------------
1201    // VAL-UPD-005: Candidate updates (major bump, compatible)
1202    // -----------------------------------------------------------------------
1203
1204    #[test]
1205    fn test_candidate_classification_major_bump() {
1206        let mut context = make_context();
1207        context
1208            .installed_versions
1209            .insert("pytorch".to_string(), "2.4.0".to_string());
1210        context.installed_components.insert("pytorch".to_string());
1211
1212        let component = make_component("pytorch", "3.0.0", ValidationTier::Validated);
1213        let classification =
1214            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1215        assert_eq!(classification, UpdateClassification::Candidate);
1216    }
1217
1218    #[test]
1219    fn test_candidate_classification_new_install() {
1220        let context = make_context();
1221        // Not installed → Candidate
1222        let component = make_component("triton", "3.1.0", ValidationTier::Validated);
1223        let classification =
1224            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1225        assert_eq!(classification, UpdateClassification::Candidate);
1226    }
1227
1228    // -----------------------------------------------------------------------
1229    // VAL-UPD-006: Experimental updates hidden without --include-experimental
1230    // -----------------------------------------------------------------------
1231
1232    #[test]
1233    fn test_experimental_classification() {
1234        let context = make_context();
1235        let component = make_component("deepspeed", "0.15.0", ValidationTier::Experimental);
1236        let classification =
1237            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
1238        assert_eq!(classification, UpdateClassification::Experimental);
1239    }
1240
1241    #[test]
1242    fn test_experimental_hidden_without_flag() {
1243        let context = make_context();
1244        let manifest = make_manifest(vec![make_component(
1245            "deepspeed",
1246            "0.15.0",
1247            ValidationTier::Experimental,
1248        )]);
1249
1250        let options = PlannerOptions {
1251            include_experimental: false,
1252            ..Default::default()
1253        };
1254
1255        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1256        assert!(
1257            items.is_empty(),
1258            "Experimental items should be hidden without flag"
1259        );
1260    }
1261
1262    #[test]
1263    fn test_experimental_visible_with_flag() {
1264        let context = make_context();
1265        let manifest = make_manifest(vec![make_component(
1266            "deepspeed",
1267            "0.15.0",
1268            ValidationTier::Experimental,
1269        )]);
1270
1271        let options = PlannerOptions {
1272            include_experimental: true,
1273            ..Default::default()
1274        };
1275
1276        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1277        assert_eq!(items.len(), 1);
1278        assert_eq!(items[0].classification, UpdateClassification::Experimental);
1279    }
1280
1281    // -----------------------------------------------------------------------
1282    // VAL-UPD-007: Safe updates preselected by default
1283    // -----------------------------------------------------------------------
1284
1285    #[test]
1286    fn test_safe_updates_preselected() {
1287        let mut context = make_context();
1288        context
1289            .installed_versions
1290            .insert("pytorch".to_string(), "2.4.0".to_string());
1291        context.installed_components.insert("pytorch".to_string());
1292
1293        let manifest = make_manifest(vec![make_component(
1294            "pytorch",
1295            "2.4.1",
1296            ValidationTier::Validated,
1297        )]);
1298
1299        let options = PlannerOptions::default();
1300        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1301
1302        assert_eq!(items.len(), 1);
1303        assert!(items[0].selected, "Safe updates should be preselected");
1304    }
1305
1306    // -----------------------------------------------------------------------
1307    // VAL-UPD-008: Guarded updates suggested but not auto-applied
1308    // -----------------------------------------------------------------------
1309
1310    #[test]
1311    fn test_guarded_updates_not_preselected() {
1312        let mut context = make_context();
1313        context
1314            .installed_versions
1315            .insert("pytorch".to_string(), "2.4.0".to_string());
1316        context.installed_components.insert("pytorch".to_string());
1317
1318        let manifest = make_manifest(vec![make_component(
1319            "pytorch",
1320            "2.5.0",
1321            ValidationTier::Validated,
1322        )]);
1323
1324        let options = PlannerOptions::default();
1325        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1326
1327        assert_eq!(items.len(), 1);
1328        assert!(
1329            !items[0].selected,
1330            "Guarded updates should NOT be preselected"
1331        );
1332    }
1333
1334    // -----------------------------------------------------------------------
1335    // VAL-UPD-009: Candidate updates visible but never preselected
1336    // -----------------------------------------------------------------------
1337
1338    #[test]
1339    fn test_candidate_updates_visible_not_preselected() {
1340        let mut context = make_context();
1341        context
1342            .installed_versions
1343            .insert("pytorch".to_string(), "2.4.0".to_string());
1344        context.installed_components.insert("pytorch".to_string());
1345
1346        let manifest = make_manifest(vec![make_component(
1347            "pytorch",
1348            "3.0.0",
1349            ValidationTier::Validated,
1350        )]);
1351
1352        let options = PlannerOptions::default();
1353        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1354
1355        assert_eq!(items.len(), 1);
1356        assert!(items[0].visible, "Candidate updates should be visible");
1357        assert!(
1358            !items[0].selected,
1359            "Candidate updates should NOT be preselected"
1360        );
1361    }
1362
1363    // -----------------------------------------------------------------------
1364    // VAL-UPD-010: Experimental updates hidden unless --include-experimental
1365    // (covered above in test_experimental_hidden_without_flag)
1366    // -----------------------------------------------------------------------
1367
1368    // -----------------------------------------------------------------------
1369    // VAL-UPD-011: Blocked updates never offered, explicit targeting fails
1370    // -----------------------------------------------------------------------
1371
1372    #[test]
1373    fn test_blocked_not_in_default_plan() {
1374        let context = make_context();
1375        let manifest = make_manifest(vec![make_component(
1376            "bad-pkg",
1377            "1.0.0",
1378            ValidationTier::Blocked,
1379        )]);
1380
1381        let options = PlannerOptions::default();
1382        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1383
1384        // Blocked items should be in the plan but not visible or selected
1385        assert_eq!(items.len(), 1);
1386        assert!(!items[0].visible);
1387        assert!(!items[0].selected);
1388    }
1389
1390    #[test]
1391    fn test_blocked_explicit_target_fails() {
1392        let context = make_context();
1393        let manifest = make_manifest(vec![make_component(
1394            "bad-pkg",
1395            "1.0.0",
1396            ValidationTier::Blocked,
1397        )]);
1398
1399        let options = PlannerOptions {
1400            target_components: vec!["bad-pkg".to_string()],
1401            ..Default::default()
1402        };
1403
1404        let result = planner().build_plan(&manifest, &context, &options);
1405        assert!(result.is_err());
1406        match result.unwrap_err() {
1407            PlannerError::ComponentBlocked {
1408                component_id,
1409                reason,
1410            } => {
1411                assert_eq!(component_id, "bad-pkg");
1412                assert!(!reason.is_empty());
1413            }
1414            other => panic!("Expected ComponentBlocked, got: {other:?}"),
1415        }
1416    }
1417
1418    // -----------------------------------------------------------------------
1419    // VAL-UPD-013: Dependency rules enforced in plan
1420    // -----------------------------------------------------------------------
1421
1422    #[test]
1423    fn test_dependency_auto_selected() {
1424        let mut context = make_context();
1425        context
1426            .installed_versions
1427            .insert("rocm".to_string(), "7.2.0".to_string());
1428        context.installed_components.insert("rocm".to_string());
1429        context
1430            .installed_versions
1431            .insert("pytorch".to_string(), "2.4.0".to_string());
1432        context.installed_components.insert("pytorch".to_string());
1433
1434        let manifest = make_manifest(vec![
1435            make_component("rocm", "7.2.1", ValidationTier::Validated), // safe (patch)
1436            make_component("pytorch", "2.4.1", ValidationTier::Validated), // safe (patch)
1437        ]);
1438
1439        let options = PlannerOptions::default();
1440        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1441
1442        // Both should be selected (pytorch depends on rocm, both safe)
1443        let rocm_item = items
1444            .iter()
1445            .find(|i| i.plan_item.component_id == "rocm")
1446            .unwrap();
1447        let pytorch_item = items
1448            .iter()
1449            .find(|i| i.plan_item.component_id == "pytorch")
1450            .unwrap();
1451
1452        assert!(rocm_item.selected, "ROCm should be selected (safe)");
1453        assert!(pytorch_item.selected, "PyTorch should be selected (safe)");
1454    }
1455
1456    // -----------------------------------------------------------------------
1457    // VAL-UPD-019: --scan-only produces plan without applying
1458    // -----------------------------------------------------------------------
1459
1460    #[test]
1461    fn test_scan_only_produces_plan() {
1462        let mut context = make_context();
1463        context
1464            .installed_versions
1465            .insert("pytorch".to_string(), "2.4.0".to_string());
1466        context.installed_components.insert("pytorch".to_string());
1467
1468        let manifest = make_manifest(vec![make_component(
1469            "pytorch",
1470            "2.4.1",
1471            ValidationTier::Validated,
1472        )]);
1473
1474        let options = PlannerOptions {
1475            scan_only: true,
1476            ..Default::default()
1477        };
1478
1479        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1480        assert_eq!(items.len(), 1);
1481        // scan_only doesn't change the plan content, just prevents apply
1482    }
1483
1484    // -----------------------------------------------------------------------
1485    // VAL-UPD-020: --all-safe applies only validated safe updates
1486    // -----------------------------------------------------------------------
1487
1488    #[test]
1489    fn test_all_safe_filters_to_safe_only() {
1490        let mut context = make_context();
1491        context
1492            .installed_versions
1493            .insert("pytorch".to_string(), "2.4.0".to_string());
1494        context.installed_components.insert("pytorch".to_string());
1495        context
1496            .installed_versions
1497            .insert("triton".to_string(), "3.0.0".to_string());
1498        context.installed_components.insert("triton".to_string());
1499
1500        let manifest = make_manifest(vec![
1501            make_component("pytorch", "2.4.1", ValidationTier::Validated), // safe (patch)
1502            make_component("triton", "3.1.0", ValidationTier::Validated),  // guarded (minor)
1503        ]);
1504
1505        let options = PlannerOptions {
1506            all_safe: true,
1507            ..Default::default()
1508        };
1509
1510        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1511
1512        assert_eq!(items.len(), 1, "Only safe items should remain");
1513        assert_eq!(items[0].classification, UpdateClassification::Safe);
1514        assert_eq!(items[0].plan_item.component_id, "pytorch");
1515    }
1516
1517    #[test]
1518    fn test_targeted_candidate_is_selected() {
1519        let mut context = make_context();
1520        context
1521            .installed_versions
1522            .insert("pytorch".to_string(), "2.4.0".to_string());
1523        context.installed_components.insert("pytorch".to_string());
1524
1525        let manifest = make_manifest(vec![make_component(
1526            "pytorch",
1527            "3.0.0",
1528            ValidationTier::Validated,
1529        )]);
1530
1531        let options = PlannerOptions {
1532            target_components: vec!["pytorch".to_string()],
1533            ..Default::default()
1534        };
1535
1536        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1537        assert_eq!(items.len(), 1);
1538        assert_eq!(items[0].classification, UpdateClassification::Candidate);
1539        assert!(items[0].selected);
1540        assert!(items[0].plan_item.selected);
1541    }
1542
1543    #[test]
1544    fn test_all_safe_keeps_explicit_targeted_non_safe_component() {
1545        let mut context = make_context();
1546        context
1547            .installed_versions
1548            .insert("pytorch".to_string(), "2.4.0".to_string());
1549        context.installed_components.insert("pytorch".to_string());
1550        context
1551            .installed_versions
1552            .insert("triton".to_string(), "3.0.0".to_string());
1553        context.installed_components.insert("triton".to_string());
1554
1555        let manifest = make_manifest(vec![
1556            make_component("pytorch", "2.4.1", ValidationTier::Validated), // safe
1557            make_component("triton", "3.1.0", ValidationTier::Validated),  // guarded
1558        ]);
1559
1560        let options = PlannerOptions {
1561            all_safe: true,
1562            target_components: vec!["triton".to_string()],
1563            ..Default::default()
1564        };
1565
1566        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1567
1568        assert_eq!(
1569            items.len(),
1570            2,
1571            "Targeted component and dependencies are in scope"
1572        );
1573
1574        let triton = items
1575            .iter()
1576            .find(|i| i.plan_item.component_id == "triton")
1577            .unwrap();
1578        assert_eq!(triton.classification, UpdateClassification::Guarded);
1579        assert!(triton.selected);
1580        assert!(triton.plan_item.selected);
1581
1582        let pytorch = items
1583            .iter()
1584            .find(|i| i.plan_item.component_id == "pytorch")
1585            .unwrap();
1586        assert_eq!(pytorch.classification, UpdateClassification::Safe);
1587        assert!(pytorch.selected);
1588    }
1589
1590    // -----------------------------------------------------------------------
1591    // VAL-UPD-021: Targeted component still runs full compatibility check
1592    // -----------------------------------------------------------------------
1593
1594    #[test]
1595    fn test_targeted_blocked_component_fails() {
1596        let context = make_context();
1597        let manifest = make_manifest(vec![
1598            make_component("pytorch", "2.5.0", ValidationTier::Validated),
1599            make_component("bad-pkg", "1.0.0", ValidationTier::Blocked),
1600        ]);
1601
1602        let options = PlannerOptions {
1603            target_components: vec!["bad-pkg".to_string()],
1604            ..Default::default()
1605        };
1606
1607        let result = planner().build_plan(&manifest, &context, &options);
1608        assert!(result.is_err(), "Targeting a blocked component should fail");
1609    }
1610
1611    #[test]
1612    fn test_targeted_rocm_component_allows_planned_rocm_prereq() {
1613        let context = make_context();
1614        let manifest = make_manifest(vec![
1615            make_component("rocm", "7.2.4", ValidationTier::Validated),
1616            make_component("pytorch", "2.5.0", ValidationTier::Validated),
1617        ]);
1618
1619        let options = PlannerOptions {
1620            target_components: vec!["pytorch".to_string()],
1621            ..Default::default()
1622        };
1623
1624        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1625        let ids: HashSet<_> = items
1626            .iter()
1627            .map(|i| i.plan_item.component_id.as_str())
1628            .collect();
1629        assert!(ids.contains("rocm"));
1630        assert!(ids.contains("pytorch"));
1631    }
1632
1633    #[test]
1634    fn test_targeted_component_uses_manifest_declared_dependencies() {
1635        let context = make_context();
1636        let mut custom = make_component("custom-runtime", "1.2.0", ValidationTier::Validated);
1637        custom.dependencies = vec!["rocm".to_string(), "mpi4py".to_string()];
1638
1639        let manifest = make_manifest(vec![
1640            make_component("rocm", "7.2.4", ValidationTier::Validated),
1641            make_component("mpi4py", "4.0.0", ValidationTier::Validated),
1642            custom,
1643        ]);
1644
1645        let options = PlannerOptions {
1646            target_components: vec!["custom-runtime".to_string()],
1647            ..Default::default()
1648        };
1649
1650        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1651        let ids: HashSet<_> = items
1652            .iter()
1653            .map(|i| i.plan_item.component_id.as_str())
1654            .collect();
1655        assert!(ids.contains("custom-runtime"));
1656        assert!(ids.contains("rocm"));
1657        assert!(ids.contains("mpi4py"));
1658    }
1659
1660    #[test]
1661    fn test_targeted_component_with_planned_rocm_not_blocked_by_min_version_on_fresh_host() {
1662        let context = make_context();
1663        let manifest = make_manifest(vec![
1664            make_component("rocm", "7.2.4", ValidationTier::Validated),
1665            ManifestComponent {
1666                min_rocm_version: "7.2.4".to_string(),
1667                ..make_component("llama-cpp", "0.2.0", ValidationTier::Validated)
1668            },
1669        ]);
1670
1671        let options = PlannerOptions {
1672            target_components: vec!["llama-cpp".to_string()],
1673            ..Default::default()
1674        };
1675
1676        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1677        let ids: HashSet<_> = items
1678            .iter()
1679            .map(|i| i.plan_item.component_id.as_str())
1680            .collect();
1681        assert!(ids.contains("rocm"));
1682        assert!(ids.contains("llama-cpp"));
1683    }
1684
1685    #[test]
1686    fn test_targeted_unknown_component_fails() {
1687        let context = make_context();
1688        let manifest = make_manifest(vec![make_component(
1689            "pytorch",
1690            "2.5.0",
1691            ValidationTier::Validated,
1692        )]);
1693
1694        let options = PlannerOptions {
1695            target_components: vec!["nonexistent".to_string()],
1696            ..Default::default()
1697        };
1698
1699        let result = planner().build_plan(&manifest, &context, &options);
1700        assert!(
1701            result.is_err(),
1702            "Targeting a nonexistent component should fail"
1703        );
1704    }
1705
1706    // -----------------------------------------------------------------------
1707    // VAL-UPD-023: Manifest fetch failure falls back to cached/baseline
1708    // (This is tested in core::manifest tests; planner accepts any Manifest)
1709    // -----------------------------------------------------------------------
1710
1711    #[test]
1712    fn test_planner_works_with_baseline_manifest() {
1713        let context = make_context();
1714        let manifest = make_manifest(vec![make_component(
1715            "pytorch",
1716            "2.4.0",
1717            ValidationTier::Validated,
1718        )]);
1719
1720        let options = PlannerOptions::default();
1721        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1722        assert!(!items.is_empty());
1723    }
1724
1725    // -----------------------------------------------------------------------
1726    // VAL-UPD-024: Runtime too old produces upgrade instruction
1727    // -----------------------------------------------------------------------
1728
1729    #[test]
1730    fn test_runtime_too_old_error() {
1731        let error = PlannerError::RuntimeTooOld {
1732            current: "0.1.0".to_string(),
1733            required: "0.2.0".to_string(),
1734        };
1735        let msg = error.to_string();
1736        assert!(msg.contains("0.1.0"));
1737        assert!(msg.contains("0.2.0"));
1738        assert!(msg.contains("rusty upgrade"));
1739    }
1740
1741    // -----------------------------------------------------------------------
1742    // VAL-UPD-026: Hardware scan respects ROCm channel selection
1743    // -----------------------------------------------------------------------
1744
1745    #[test]
1746    fn test_rocm_channel_in_context() {
1747        let mut context = make_context();
1748        context.rocm_channel = "legacy".to_string();
1749
1750        let manifest = make_manifest(vec![make_component(
1751            "pytorch",
1752            "2.4.0",
1753            ValidationTier::Validated,
1754        )]);
1755
1756        let options = PlannerOptions::default();
1757        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1758        assert!(!items.is_empty());
1759    }
1760
1761    // -----------------------------------------------------------------------
1762    // VAL-UPD-027: Signed remote manifest validated
1763    // (Manifest trust is tested in core::manifest; planner accepts any Manifest)
1764    // -----------------------------------------------------------------------
1765
1766    // -----------------------------------------------------------------------
1767    // Classification serde roundtrip
1768    // -----------------------------------------------------------------------
1769
1770    #[test]
1771    fn test_classification_serde_roundtrip() {
1772        let classifications = [
1773            UpdateClassification::Safe,
1774            UpdateClassification::Guarded,
1775            UpdateClassification::Blocked,
1776            UpdateClassification::Candidate,
1777            UpdateClassification::Experimental,
1778        ];
1779        for c in &classifications {
1780            let json = serde_json::to_string(c).unwrap();
1781            let back: UpdateClassification = serde_json::from_str(&json).unwrap();
1782            assert_eq!(*c, back);
1783        }
1784    }
1785
1786    // -----------------------------------------------------------------------
1787    // PlanSummary tests
1788    // -----------------------------------------------------------------------
1789
1790    #[test]
1791    fn test_plan_summary_from_items() {
1792        let mut context = make_context();
1793        context
1794            .installed_versions
1795            .insert("pytorch".to_string(), "2.4.0".to_string());
1796        context.installed_components.insert("pytorch".to_string());
1797        context
1798            .installed_versions
1799            .insert("triton".to_string(), "3.0.0".to_string());
1800        context.installed_components.insert("triton".to_string());
1801
1802        let manifest = make_manifest(vec![
1803            make_component("pytorch", "2.4.1", ValidationTier::Validated), // safe
1804            make_component("triton", "3.1.0", ValidationTier::Validated),  // guarded
1805            make_component("new-pkg", "1.0.0", ValidationTier::Validated), // candidate (new)
1806            make_component("bad-pkg", "1.0.0", ValidationTier::Blocked),   // blocked
1807        ]);
1808
1809        let options = PlannerOptions::default();
1810        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1811
1812        let summary = PlanSummary::from_items(&items);
1813        assert_eq!(summary.total, 4);
1814        assert_eq!(summary.safe, 1);
1815        assert_eq!(summary.guarded, 1);
1816        assert_eq!(summary.candidate, 1);
1817        assert_eq!(summary.blocked, 1);
1818        assert_eq!(summary.selected, 1); // only safe is selected
1819    }
1820
1821    // -----------------------------------------------------------------------
1822    // PlannerItemOutput conversion
1823    // -----------------------------------------------------------------------
1824
1825    #[test]
1826    fn test_planner_item_output_conversion() {
1827        let mut context = make_context();
1828        context
1829            .installed_versions
1830            .insert("pytorch".to_string(), "2.4.0".to_string());
1831        context.installed_components.insert("pytorch".to_string());
1832
1833        let manifest = make_manifest(vec![make_component(
1834            "pytorch",
1835            "2.4.1",
1836            ValidationTier::Validated,
1837        )]);
1838
1839        let options = PlannerOptions::default();
1840        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1841
1842        let output: Vec<PlannerItemOutput> = items.iter().map(PlannerItemOutput::from).collect();
1843        assert_eq!(output.len(), 1);
1844        assert_eq!(output[0].component_id, "pytorch");
1845        assert_eq!(output[0].classification, "safe");
1846        assert!(output[0].selected);
1847    }
1848
1849    // -----------------------------------------------------------------------
1850    // Full plan build with multiple classifications
1851    // -----------------------------------------------------------------------
1852
1853    #[test]
1854    fn test_full_plan_build_mixed_classifications() {
1855        let mut context = make_context();
1856        context
1857            .installed_versions
1858            .insert("rocm".to_string(), "7.2.0".to_string());
1859        context.installed_components.insert("rocm".to_string());
1860        context
1861            .installed_versions
1862            .insert("pytorch".to_string(), "2.4.0".to_string());
1863        context.installed_components.insert("pytorch".to_string());
1864        context
1865            .installed_versions
1866            .insert("triton".to_string(), "3.0.0".to_string());
1867        context.installed_components.insert("triton".to_string());
1868
1869        let manifest = make_manifest(vec![
1870            make_component("rocm", "7.2.1", ValidationTier::Validated), // safe (patch)
1871            make_component("pytorch", "2.5.0", ValidationTier::Validated), // guarded (minor)
1872            make_component("triton", "4.0.0", ValidationTier::Validated), // candidate (major)
1873            make_component("wandb", "0.17.0", ValidationTier::Validated), // candidate (new install)
1874            make_component("deepspeed", "0.15.0", ValidationTier::Experimental), // experimental
1875        ]);
1876
1877        let options = PlannerOptions {
1878            include_experimental: true,
1879            ..Default::default()
1880        };
1881
1882        let items = planner().build_plan(&manifest, &context, &options).unwrap();
1883
1884        assert_eq!(items.len(), 5);
1885
1886        let rocm = items
1887            .iter()
1888            .find(|i| i.plan_item.component_id == "rocm")
1889            .unwrap();
1890        assert_eq!(rocm.classification, UpdateClassification::Safe);
1891        assert!(rocm.selected);
1892
1893        let pytorch = items
1894            .iter()
1895            .find(|i| i.plan_item.component_id == "pytorch")
1896            .unwrap();
1897        assert_eq!(pytorch.classification, UpdateClassification::Guarded);
1898        assert!(!pytorch.selected);
1899
1900        let triton = items
1901            .iter()
1902            .find(|i| i.plan_item.component_id == "triton")
1903            .unwrap();
1904        assert_eq!(triton.classification, UpdateClassification::Candidate);
1905        assert!(!triton.selected);
1906
1907        let wandb = items
1908            .iter()
1909            .find(|i| i.plan_item.component_id == "wandb")
1910            .unwrap();
1911        assert_eq!(wandb.classification, UpdateClassification::Candidate);
1912        assert!(!wandb.selected);
1913
1914        let deepspeed = items
1915            .iter()
1916            .find(|i| i.plan_item.component_id == "deepspeed")
1917            .unwrap();
1918        assert_eq!(deepspeed.classification, UpdateClassification::Experimental);
1919        assert!(!deepspeed.selected);
1920    }
1921
1922    // -----------------------------------------------------------------------
1923    // CompatibilityContext tests
1924    // -----------------------------------------------------------------------
1925
1926    #[test]
1927    fn test_compatibility_context_default() {
1928        let ctx = CompatibilityContext::default();
1929        assert!(ctx.rocm_version.is_empty());
1930        assert!(ctx.rocm_channel.is_empty());
1931        assert!(ctx.available_executors.is_empty());
1932        assert!(ctx.installed_components.is_empty());
1933        assert!(ctx.installed_versions.is_empty());
1934        assert!(ctx.gpu_architecture.is_empty());
1935        assert!(ctx.runtime_version.is_empty());
1936    }
1937
1938    #[test]
1939    fn test_compatibility_context_is_installed() {
1940        let mut ctx = CompatibilityContext::new();
1941        assert!(!ctx.is_installed("pytorch"));
1942        ctx.installed_components.insert("pytorch".to_string());
1943        assert!(ctx.is_installed("pytorch"));
1944    }
1945
1946    #[test]
1947    fn test_compatibility_context_installed_version() {
1948        let mut ctx = CompatibilityContext::new();
1949        assert!(ctx.installed_version("pytorch").is_none());
1950        ctx.installed_versions
1951            .insert("pytorch".to_string(), "2.4.0".to_string());
1952        assert_eq!(ctx.installed_version("pytorch"), Some("2.4.0"));
1953    }
1954
1955    // -----------------------------------------------------------------------
1956    // PlannerError tests
1957    // -----------------------------------------------------------------------
1958
1959    #[test]
1960    fn test_planner_error_display() {
1961        let err = PlannerError::ComponentBlocked {
1962            component_id: "bad-pkg".to_string(),
1963            reason: "incompatible hardware".to_string(),
1964        };
1965        assert!(err.to_string().contains("bad-pkg"));
1966        assert!(err.to_string().contains("incompatible hardware"));
1967
1968        let err = PlannerError::ComponentUnknown {
1969            component_id: "nonexistent".to_string(),
1970        };
1971        assert!(err.to_string().contains("nonexistent"));
1972
1973        let err = PlannerError::RuntimeTooOld {
1974            current: "0.1.0".to_string(),
1975            required: "0.2.0".to_string(),
1976        };
1977        assert!(err.to_string().contains("0.1.0"));
1978        assert!(err.to_string().contains("rusty upgrade"));
1979    }
1980
1981    #[test]
1982    fn test_planner_error_is_std_error() {
1983        let err = PlannerError::ComponentUnknown {
1984            component_id: "test".to_string(),
1985        };
1986        let _: &dyn std::error::Error = &err;
1987    }
1988
1989    // -----------------------------------------------------------------------
1990    // ScanOutput / PlanOutput serde
1991    // -----------------------------------------------------------------------
1992
1993    #[test]
1994    fn test_scan_output_serde_roundtrip() {
1995        let output = ScanOutput {
1996            installed: vec![InstalledComponent {
1997                id: "pytorch".to_string(),
1998                version: "2.4.0".to_string(),
1999                status: "installed".to_string(),
2000            }],
2001            manifest_source: "baseline".to_string(),
2002            rocm_channel: "latest".to_string(),
2003            rocm_version: "7.2.1".to_string(),
2004            gpu_architecture: "gfx1100".to_string(),
2005        };
2006        let json = serde_json::to_string(&output).unwrap();
2007        let back: ScanOutput = serde_json::from_str(&json).unwrap();
2008        assert_eq!(output, back);
2009    }
2010
2011    #[test]
2012    fn test_plan_output_serde_roundtrip() {
2013        let output = PlanOutput {
2014            plan: vec![PlannerItemOutput {
2015                component_id: "pytorch".to_string(),
2016                current_version: "2.4.0".to_string(),
2017                proposed_version: "2.4.1".to_string(),
2018                classification: "safe".to_string(),
2019                risk_tier: "low".to_string(),
2020                selected: true,
2021                visible: true,
2022                rationale: "patch update".to_string(),
2023                dependencies: vec!["rocm".to_string()],
2024            }],
2025            summary: PlanSummary {
2026                total: 1,
2027                safe: 1,
2028                guarded: 0,
2029                candidate: 0,
2030                experimental: 0,
2031                blocked: 0,
2032                selected: 1,
2033            },
2034        };
2035        let json = serde_json::to_string(&output).unwrap();
2036        let back: PlanOutput = serde_json::from_str(&json).unwrap();
2037        assert_eq!(output, back);
2038    }
2039
2040    // -----------------------------------------------------------------------
2041    // UpdateClassification properties
2042    // -----------------------------------------------------------------------
2043
2044    #[test]
2045    fn test_classification_visibility() {
2046        assert!(UpdateClassification::Safe.is_visible());
2047        assert!(UpdateClassification::Guarded.is_visible());
2048        assert!(!UpdateClassification::Blocked.is_visible());
2049        assert!(UpdateClassification::Candidate.is_visible());
2050        assert!(!UpdateClassification::Experimental.is_visible());
2051    }
2052
2053    #[test]
2054    fn test_classification_preselection() {
2055        assert!(UpdateClassification::Safe.is_preselected());
2056        assert!(!UpdateClassification::Guarded.is_preselected());
2057        assert!(!UpdateClassification::Blocked.is_preselected());
2058        assert!(!UpdateClassification::Candidate.is_preselected());
2059        assert!(!UpdateClassification::Experimental.is_preselected());
2060    }
2061
2062    #[test]
2063    fn test_classification_labels() {
2064        assert_eq!(UpdateClassification::Safe.label(), "safe");
2065        assert_eq!(UpdateClassification::Guarded.label(), "guarded");
2066        assert_eq!(UpdateClassification::Blocked.label(), "blocked");
2067        assert_eq!(UpdateClassification::Candidate.label(), "candidate");
2068        assert_eq!(UpdateClassification::Experimental.label(), "experimental");
2069    }
2070
2071    // -----------------------------------------------------------------------
2072    // Dependency derivation
2073    // -----------------------------------------------------------------------
2074
2075    #[test]
2076    fn test_derive_dependencies_pytorch() {
2077        let deps = planner().derive_dependencies("pytorch");
2078        assert!(deps.contains(&"rocm".to_string()));
2079    }
2080
2081    #[test]
2082    fn test_derive_dependencies_triton() {
2083        let deps = planner().derive_dependencies("triton");
2084        assert!(deps.contains(&"pytorch".to_string()));
2085    }
2086
2087    #[test]
2088    fn test_derive_dependencies_rocm() {
2089        let deps = planner().derive_dependencies("rocm");
2090        assert!(deps.is_empty());
2091    }
2092
2093    #[test]
2094    fn test_derive_dependencies_unknown() {
2095        let deps = planner().derive_dependencies("unknown-component");
2096        assert!(deps.is_empty());
2097    }
2098
2099    // -----------------------------------------------------------------------
2100    // Version bump level detection
2101    // -----------------------------------------------------------------------
2102
2103    #[test]
2104    fn test_version_bump_patch() {
2105        assert_eq!(
2106            planner().version_bump_level("1.0.0", "1.0.1"),
2107            BumpLevel::Patch
2108        );
2109    }
2110
2111    #[test]
2112    fn test_version_bump_minor() {
2113        assert_eq!(
2114            planner().version_bump_level("1.0.0", "1.1.0"),
2115            BumpLevel::Minor
2116        );
2117    }
2118
2119    #[test]
2120    fn test_version_bump_major() {
2121        assert_eq!(
2122            planner().version_bump_level("1.0.0", "2.0.0"),
2123            BumpLevel::Major
2124        );
2125    }
2126
2127    #[test]
2128    fn test_version_bump_unknown() {
2129        assert_eq!(
2130            planner().version_bump_level("abc", "def"),
2131            BumpLevel::Unknown
2132        );
2133    }
2134
2135    #[test]
2136    fn test_version_bump_downgrade_is_unknown() {
2137        assert_eq!(
2138            planner().version_bump_level("7.3.0", "7.2.4"),
2139            BumpLevel::Unknown
2140        );
2141        assert_eq!(
2142            planner().version_bump_level("2.5.1", "2.5.0"),
2143            BumpLevel::Unknown
2144        );
2145    }
2146
2147    // -----------------------------------------------------------------------
2148    // version_gte helper
2149    // -----------------------------------------------------------------------
2150
2151    #[test]
2152    fn test_version_gte_equal() {
2153        assert!(version_gte("7.2.1", "7.2.1"));
2154    }
2155
2156    #[test]
2157    fn test_version_gte_greater() {
2158        assert!(version_gte("7.3.0", "7.2.1"));
2159        assert!(version_gte("8.0.0", "7.2.1"));
2160        assert!(version_gte("7.2.2", "7.2.1"));
2161    }
2162
2163    #[test]
2164    fn test_version_gte_less() {
2165        assert!(!version_gte("7.2.0", "7.2.1"));
2166        assert!(!version_gte("6.0.0", "7.0.0"));
2167    }
2168
2169    #[test]
2170    fn test_version_gte_different_lengths() {
2171        assert!(version_gte("7.2", "7.2.0"));
2172        assert!(version_gte("7.2.1", "7.2"));
2173        assert!(!version_gte("7.1", "7.2.0"));
2174    }
2175
2176    // -----------------------------------------------------------------------
2177    // VAL-UPD-004: Blocked by ROCm version requirement
2178    // -----------------------------------------------------------------------
2179
2180    #[test]
2181    fn test_blocked_by_rocm_version_requirement() {
2182        let context = make_context(); // rocm_version = "7.2.1"
2183
2184        let component = ManifestComponent {
2185            id: "pytorch".to_string(),
2186            version: "3.0.0".to_string(),
2187            script: "install_pytorch.sh".to_string(),
2188            category: crate::core::types::Category::Core,
2189            validation_tier: ValidationTier::Validated,
2190            min_rocm_version: "99.0.0".to_string(), // impossibly high
2191            compatible_channels: vec![],
2192            dependencies: vec![],
2193        };
2194
2195        let classification =
2196            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2197        assert_eq!(classification, UpdateClassification::Blocked);
2198    }
2199
2200    #[test]
2201    fn test_not_blocked_by_satisfied_rocm_requirement() {
2202        let context = make_context(); // rocm_version = "7.2.1"
2203
2204        let component = ManifestComponent {
2205            id: "pytorch".to_string(),
2206            version: "3.0.0".to_string(),
2207            script: "install_pytorch.sh".to_string(),
2208            category: crate::core::types::Category::Core,
2209            validation_tier: ValidationTier::Validated,
2210            min_rocm_version: "7.0.0".to_string(),
2211            compatible_channels: vec![],
2212            dependencies: vec![],
2213        };
2214
2215        let classification =
2216            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2217        assert_ne!(classification, UpdateClassification::Blocked);
2218    }
2219
2220    // -----------------------------------------------------------------------
2221    // VAL-UPD-026: Hardware scan respects ROCm channel selection
2222    // -----------------------------------------------------------------------
2223
2224    #[test]
2225    fn test_blocked_by_incompatible_channel() {
2226        let mut context = make_context();
2227        context.rocm_channel = "legacy".to_string();
2228
2229        let component = ManifestComponent {
2230            id: "pytorch".to_string(),
2231            version: "3.0.0".to_string(),
2232            script: "install_pytorch.sh".to_string(),
2233            category: crate::core::types::Category::Core,
2234            validation_tier: ValidationTier::Validated,
2235            min_rocm_version: String::new(),
2236            compatible_channels: vec!["latest".to_string(), "stable".to_string()],
2237            dependencies: vec![],
2238        };
2239
2240        let classification =
2241            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2242        assert_eq!(classification, UpdateClassification::Blocked);
2243    }
2244
2245    #[test]
2246    fn test_allowed_by_compatible_channel() {
2247        let mut context = make_context();
2248        context.rocm_channel = "latest".to_string();
2249
2250        let component = ManifestComponent {
2251            id: "pytorch".to_string(),
2252            version: "3.0.0".to_string(),
2253            script: "install_pytorch.sh".to_string(),
2254            category: crate::core::types::Category::Core,
2255            validation_tier: ValidationTier::Validated,
2256            min_rocm_version: String::new(),
2257            compatible_channels: vec!["latest".to_string(), "stable".to_string()],
2258            dependencies: vec![],
2259        };
2260
2261        let classification =
2262            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2263        assert_ne!(classification, UpdateClassification::Blocked);
2264    }
2265
2266    #[test]
2267    fn test_channel_filter_legacy_excludes_latest_only() {
2268        let mut context = make_context();
2269        context.rocm_channel = "legacy".to_string();
2270
2271        let manifest = make_manifest(vec![ManifestComponent {
2272            id: "pytorch".to_string(),
2273            version: "3.0.0".to_string(),
2274            script: "install_pytorch.sh".to_string(),
2275            category: crate::core::types::Category::Core,
2276            validation_tier: ValidationTier::Validated,
2277            min_rocm_version: String::new(),
2278            compatible_channels: vec!["latest".to_string()],
2279            dependencies: vec![],
2280        }]);
2281
2282        let options = PlannerOptions::default();
2283        let items = planner().build_plan(&manifest, &context, &options).unwrap();
2284
2285        // The item should be blocked (not visible)
2286        assert_eq!(items.len(), 1);
2287        assert_eq!(items[0].classification, UpdateClassification::Blocked);
2288        assert!(!items[0].visible);
2289    }
2290
2291    // -----------------------------------------------------------------------
2292    // VAL-UPD-024: Runtime too old produces upgrade instruction
2293    // -----------------------------------------------------------------------
2294
2295    #[test]
2296    fn test_runtime_too_old_blocks_plan() {
2297        let context = make_context(); // runtime_version is empty by default
2298
2299        let mut manifest = make_manifest(vec![make_component(
2300            "pytorch",
2301            "3.0.0",
2302            ValidationTier::Validated,
2303        )]);
2304        manifest.min_runtime_version = "99.0.0".to_string();
2305
2306        let mut ctx = context;
2307        ctx.runtime_version = "0.1.0".to_string();
2308
2309        let options = PlannerOptions::default();
2310        let result = planner().build_plan(&manifest, &ctx, &options);
2311        assert!(result.is_err());
2312        match result.unwrap_err() {
2313            PlannerError::RuntimeTooOld { current, required } => {
2314                assert_eq!(current, "0.1.0");
2315                assert_eq!(required, "99.0.0");
2316            }
2317            other => panic!("Expected RuntimeTooOld, got: {other:?}"),
2318        }
2319    }
2320
2321    #[test]
2322    fn test_runtime_version_satisfied() {
2323        let mut context = make_context();
2324        context.runtime_version = "1.0.0".to_string();
2325
2326        let mut manifest = make_manifest(vec![make_component(
2327            "pytorch",
2328            "3.0.0",
2329            ValidationTier::Validated,
2330        )]);
2331        manifest.min_runtime_version = "0.5.0".to_string();
2332
2333        let options = PlannerOptions::default();
2334        let result = planner().build_plan(&manifest, &context, &options);
2335        assert!(result.is_ok());
2336    }
2337
2338    // -----------------------------------------------------------------------
2339    // VAL-UPD-004: Blocked when ROCm not installed but required
2340    // -----------------------------------------------------------------------
2341
2342    #[test]
2343    fn test_blocked_when_rocm_not_installed_but_required() {
2344        let mut context = CompatibilityContext::new();
2345        context.rocm_version = String::new(); // no ROCm
2346        context.available_executors =
2347            HashSet::from([ExecutorKind::LegacyScript, ExecutorKind::Rust]);
2348
2349        let component = make_component("pytorch", "3.0.0", ValidationTier::Validated);
2350        let classification =
2351            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2352        assert_eq!(classification, UpdateClassification::Blocked);
2353    }
2354
2355    #[test]
2356    fn test_rocm_component_allowed_when_rocm_not_installed() {
2357        let mut context = CompatibilityContext::new();
2358        context.rocm_version = String::new();
2359        context.available_executors =
2360            HashSet::from([ExecutorKind::LegacyScript, ExecutorKind::Rust]);
2361
2362        let component = make_component("rocm", "7.2.4", ValidationTier::Validated);
2363        let classification =
2364            planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
2365        assert_ne!(classification, UpdateClassification::Blocked);
2366    }
2367
2368    #[test]
2369    fn test_isolation_safe_tracks_classification() {
2370        let mut context = make_context();
2371        context
2372            .installed_versions
2373            .insert("pytorch".to_string(), "2.4.0".to_string());
2374        context.installed_components.insert("pytorch".to_string());
2375        context
2376            .installed_versions
2377            .insert("triton".to_string(), "3.0.0".to_string());
2378        context.installed_components.insert("triton".to_string());
2379
2380        let manifest = make_manifest(vec![
2381            make_component("pytorch", "2.4.1", ValidationTier::Validated), // safe
2382            make_component("triton", "3.1.0", ValidationTier::Validated),  // guarded
2383        ]);
2384        let items = planner()
2385            .build_plan(&manifest, &context, &PlannerOptions::default())
2386            .unwrap();
2387
2388        let pytorch = items
2389            .iter()
2390            .find(|i| i.plan_item.component_id == "pytorch")
2391            .unwrap();
2392        let triton = items
2393            .iter()
2394            .find(|i| i.plan_item.component_id == "triton")
2395            .unwrap();
2396
2397        assert!(pytorch.plan_item.isolation_safe);
2398        assert!(!triton.plan_item.isolation_safe);
2399    }
2400
2401    // -----------------------------------------------------------------------
2402    // VAL-UPD-027: Block reason includes ROCm version info
2403    // -----------------------------------------------------------------------
2404
2405    #[test]
2406    fn test_block_reason_includes_rocm_version() {
2407        let context = make_context(); // rocm_version = "7.2.1"
2408
2409        let component = ManifestComponent {
2410            id: "pytorch".to_string(),
2411            version: "3.0.0".to_string(),
2412            script: "install_pytorch.sh".to_string(),
2413            category: crate::core::types::Category::Core,
2414            validation_tier: ValidationTier::Validated,
2415            min_rocm_version: "99.0.0".to_string(),
2416            compatible_channels: vec![],
2417            dependencies: vec![],
2418        };
2419
2420        let reason =
2421            planner().block_reason(&component, &context, !context.rocm_version.is_empty(), None);
2422        assert!(reason.contains("99.0.0"));
2423        assert!(reason.contains("7.2.1"));
2424    }
2425
2426    #[test]
2427    fn test_block_reason_channel_incompatibility() {
2428        let mut context = make_context();
2429        context.rocm_channel = "legacy".to_string();
2430
2431        let component = ManifestComponent {
2432            id: "pytorch".to_string(),
2433            version: "3.0.0".to_string(),
2434            script: "install_pytorch.sh".to_string(),
2435            category: crate::core::types::Category::Core,
2436            validation_tier: ValidationTier::Validated,
2437            min_rocm_version: String::new(),
2438            compatible_channels: vec!["latest".to_string()],
2439            dependencies: vec![],
2440        };
2441
2442        let reason =
2443            planner().block_reason(&component, &context, !context.rocm_version.is_empty(), None);
2444        assert!(reason.contains("legacy"));
2445    }
2446}