Skip to main content

rusty_stack/orchestrator/
apply.rs

1//! Apply engine — dependency-safe execution of selected update items.
2//!
3//! This module implements:
4//! - [`ApplyStatus`] — per-item outcome (success, failed, blocked-by-failure, held-back, skipped)
5//! - [`ApplyItem`] — an item in the apply queue with execution status
6//! - [`ApplyEngine`] — executes selected items in dependency order, batches safe items,
7//!   isolates failures so unrelated work continues
8//!
9//! # Execution Model
10//!
11//! 1. Items are sorted in dependency-safe order (topological sort).
12//! 2. Items with `isolation_safe: true` and no failed dependencies may be batched.
13//! 3. When a guarded item fails, all items that depend on it are marked
14//!    `BlockedByFailure` — but unrelated items continue.
15//! 4. Items that were not selected by the user are marked `HeldBack`.
16//!
17//! # Failure Isolation (VAL-UPD-015, VAL-UPD-016)
18//!
19//! If component B fails and A depends on B:
20//! - A is marked `BlockedByFailure` with a reference to B's failure.
21//! - Unrelated component C (no dependency on B) continues and succeeds/fails
22//!   independently.
23
24use crate::core::plan::PlanItem;
25use crate::orchestrator::planner::PlannerItem;
26use serde::{Deserialize, Serialize};
27use std::collections::{HashMap, HashSet};
28use std::fmt;
29
30// Post-install HONESTY GUARD helpers.
31//
32// The apply path used to report success purely from the installer's exit code,
33// so a sealed-core skip, a no-op `pip install` (already-satisfied, no `--upgrade`),
34// or an unchanged `git describe` all reported false "Succeeded" because they
35// exit 0. The Ok(()) arm now re-detects the installed version and verifies it
36// reached the target — but only when both sides are concrete versions.
37
38/// Targets that can't be version-verified (no concrete version). Trust the install.
39fn is_opaque_target(proposed: &str) -> bool {
40    let p = proposed.trim();
41    p.is_empty() || p == "latest" || p == "installed"
42}
43
44/// Detected values that aren't a real version (path/command components report
45/// "installed"/"unknown"). Can't compare against a target — trust the install.
46fn is_real_version(s: &str) -> bool {
47    let s = s.trim();
48    !s.is_empty() && s != "installed" && s != "unknown" && s != "not installed"
49}
50
51/// Does the re-detected `installed` version indicate the `target` was reached?
52/// Exact match, or a build-metadata suffix (detected "2.13.0+rocm7.2" satisfies
53/// target "2.13.0").
54fn version_reached(installed: &str, target: &str) -> bool {
55    let inst = installed.trim();
56    let tgt = target.trim();
57    if inst == tgt {
58        return true;
59    }
60    // build-metadata suffix: "2.13.0+rocm7.2" starts with "2.13.0" then "+"
61    inst.starts_with(tgt)
62        && inst
63            .get(tgt.len()..)
64            .map(|s| s.starts_with('+'))
65            .unwrap_or(false)
66}
67
68// ---------------------------------------------------------------------------
69// ApplyStatus
70// ---------------------------------------------------------------------------
71
72/// Outcome status for a single item after the apply phase.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "kebab-case")]
75pub enum ApplyStatus {
76    /// Item was applied successfully.
77    Success,
78    /// Item failed during apply.
79    Failed,
80    /// Item was blocked because a dependency failed.
81    BlockedByFailure,
82    /// Item was held back (user deselected or not in scope).
83    HeldBack,
84    /// Item was skipped (not selected, not eligible).
85    Skipped,
86}
87
88impl ApplyStatus {
89    /// Human-readable label.
90    pub fn label(self) -> &'static str {
91        match self {
92            ApplyStatus::Success => "success",
93            ApplyStatus::Failed => "failed",
94            ApplyStatus::BlockedByFailure => "blocked-by-failure",
95            ApplyStatus::HeldBack => "held-back",
96            ApplyStatus::Skipped => "skipped",
97        }
98    }
99}
100
101impl fmt::Display for ApplyStatus {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        f.write_str(self.label())
104    }
105}
106
107// ---------------------------------------------------------------------------
108// ApplyItem
109// ---------------------------------------------------------------------------
110
111/// An item that has been processed by the apply engine.
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
113pub struct ApplyItem {
114    /// Component ID.
115    pub component_id: String,
116    /// Current version before apply.
117    pub current_version: String,
118    /// Version that was applied (or attempted).
119    pub proposed_version: String,
120    /// Outcome of the apply operation.
121    pub status: ApplyStatus,
122    /// Error message if failed or blocked.
123    pub error_message: String,
124    /// Component IDs that this item depends on.
125    pub dependencies: Vec<String>,
126    /// Whether this item was selected for apply.
127    pub was_selected: bool,
128}
129
130impl ApplyItem {
131    /// Create a new apply item with the given status.
132    pub fn new(
133        component_id: impl Into<String>,
134        current_version: impl Into<String>,
135        proposed_version: impl Into<String>,
136        status: ApplyStatus,
137    ) -> Self {
138        Self {
139            component_id: component_id.into(),
140            current_version: current_version.into(),
141            proposed_version: proposed_version.into(),
142            status,
143            error_message: String::new(),
144            dependencies: Vec::new(),
145            was_selected: true,
146        }
147    }
148
149    /// Create a held-back item (user deselected).
150    pub fn held_back(item: &PlannerItem) -> Self {
151        Self {
152            component_id: item.plan_item.component_id.clone(),
153            current_version: item.plan_item.current_version.clone(),
154            proposed_version: item.plan_item.proposed_version.clone(),
155            status: ApplyStatus::HeldBack,
156            error_message: String::new(),
157            dependencies: item.plan_item.dependencies.clone(),
158            was_selected: false,
159        }
160    }
161
162    /// Create a blocked-by-failure item.
163    pub fn blocked_by_failure(item: &PlannerItem, blocker_id: &str, reason: &str) -> Self {
164        Self {
165            component_id: item.plan_item.component_id.clone(),
166            current_version: item.plan_item.current_version.clone(),
167            proposed_version: item.plan_item.proposed_version.clone(),
168            status: ApplyStatus::BlockedByFailure,
169            error_message: format!("blocked by failed dependency '{blocker_id}': {reason}"),
170            dependencies: item.plan_item.dependencies.clone(),
171            was_selected: true,
172        }
173    }
174
175    /// Create a skipped item.
176    pub fn skipped(item: &PlannerItem) -> Self {
177        Self {
178            component_id: item.plan_item.component_id.clone(),
179            current_version: item.plan_item.current_version.clone(),
180            proposed_version: item.plan_item.proposed_version.clone(),
181            status: ApplyStatus::Skipped,
182            error_message: String::new(),
183            dependencies: item.plan_item.dependencies.clone(),
184            was_selected: false,
185        }
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Apply Executor Trait
191// ---------------------------------------------------------------------------
192
193/// Trait for executing a single component update.
194///
195/// Implementations can invoke shell scripts, Rust-native installers,
196/// or be mocked for testing.
197///
198/// No `Send + Sync` bound: [`ApplyEngine::apply`] runs components **synchronously
199/// on the calling thread** (a sequential `for` loop, no spawn), and the engine is
200/// never moved across threads. Dropping the auto-trait bound lets an executor
201/// hold non-`Send` state that lives for the whole apply — e.g. the inline
202/// ratatui `Terminal` for the animated apply panel, which must be drawn from the
203/// same thread that drives the event loop.
204pub trait ApplyExecutor {
205    /// Apply a single component update.
206    ///
207    /// Returns `Ok(())` on success, `Err` with a descriptive message on failure.
208    fn apply_component(&self, component_id: &str, proposed_version: &str) -> Result<(), String>;
209}
210
211// ---------------------------------------------------------------------------
212// Default Executor (no-op for testing, real impl would call scripts)
213// ---------------------------------------------------------------------------
214
215/// A no-op executor that always succeeds.
216/// Used as a default when no real executor is configured.
217pub struct NoOpExecutor;
218
219impl ApplyExecutor for NoOpExecutor {
220    fn apply_component(&self, _component_id: &str, _proposed_version: &str) -> Result<(), String> {
221        Ok(())
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Apply Options
227// ---------------------------------------------------------------------------
228
229/// Options controlling apply behavior.
230#[derive(Debug, Clone, Default)]
231pub struct ApplyOptions {
232    /// Component IDs that should be forced to fail (for testing).
233    pub force_fail: HashSet<String>,
234    /// Post-install HONESTY GUARD: re-detect the installed version and fail items
235    /// whose version didn't advance to the target. Default OFF (mock-executor
236    /// tests don't actually install); the REAL apply path (`main.rs apply_plan`)
237    /// explicitly sets this `true`.
238    pub verify_post_install_version: bool,
239}
240
241// ---------------------------------------------------------------------------
242// Apply Summary
243// ---------------------------------------------------------------------------
244
245/// Summary of the apply phase, partitioning all components into
246/// success / failed / held_back buckets.
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248pub struct ApplySummary {
249    /// Items that were applied successfully.
250    pub success: Vec<ApplyItem>,
251    /// Items that failed during apply.
252    pub failed: Vec<ApplyItem>,
253    /// Items held back (not selected by user).
254    pub held_back: Vec<ApplyItem>,
255    /// Items blocked by a failed dependency.
256    pub blocked: Vec<ApplyItem>,
257    /// Items skipped (not eligible).
258    pub skipped: Vec<ApplyItem>,
259}
260
261impl ApplySummary {
262    /// Create an empty summary.
263    pub fn new() -> Self {
264        Self {
265            success: Vec::new(),
266            failed: Vec::new(),
267            held_back: Vec::new(),
268            blocked: Vec::new(),
269            skipped: Vec::new(),
270        }
271    }
272
273    /// Total number of items across all buckets.
274    pub fn total(&self) -> usize {
275        self.success.len()
276            + self.failed.len()
277            + self.held_back.len()
278            + self.blocked.len()
279            + self.skipped.len()
280    }
281
282    /// Whether any item failed.
283    pub fn has_failures(&self) -> bool {
284        !self.failed.is_empty() || !self.blocked.is_empty()
285    }
286
287    /// Check that every component is in exactly one bucket (complete partitioning).
288    pub fn is_complete_partition(&self) -> bool {
289        let all_ids: HashSet<&str> = self
290            .success
291            .iter()
292            .chain(self.failed.iter())
293            .chain(self.held_back.iter())
294            .chain(self.blocked.iter())
295            .chain(self.skipped.iter())
296            .map(|i| i.component_id.as_str())
297            .collect();
298
299        let total_count = self.success.len()
300            + self.failed.len()
301            + self.held_back.len()
302            + self.blocked.len()
303            + self.skipped.len();
304
305        all_ids.len() == total_count
306    }
307}
308
309impl Default for ApplySummary {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Apply Engine
317// ---------------------------------------------------------------------------
318
319/// Executes selected update items in dependency-safe order with failure isolation.
320///
321/// # Algorithm
322///
323/// 1. Separate selected items from deselected (held back).
324/// 2. Sort selected items in dependency order (topological sort).
325/// 3. Execute each item:
326///    a. Check if any dependency has failed → mark `BlockedByFailure`.
327///    b. Execute via the configured [`ApplyExecutor`].
328///    c. Record success or failure.
329/// 4. Unrelated items continue even if some fail.
330pub struct ApplyEngine {
331    /// The executor that applies individual components.
332    executor: Box<dyn ApplyExecutor>,
333}
334
335impl ApplyEngine {
336    /// Create a new apply engine with the given executor.
337    pub fn new(executor: impl ApplyExecutor + 'static) -> Self {
338        Self {
339            executor: Box::new(executor),
340        }
341    }
342
343    /// Create a new apply engine with the default no-op executor.
344    pub fn new_noop() -> Self {
345        Self::new(NoOpExecutor)
346    }
347
348    /// Apply the given planner items.
349    ///
350    /// Items that are not selected are held back. Selected items are executed
351    /// in dependency-safe order with failure isolation.
352    pub fn apply(&self, items: &[PlannerItem], options: &ApplyOptions) -> ApplySummary {
353        let mut summary = ApplySummary::new();
354
355        // Separate selected from deselected
356        let (selected, deselected): (Vec<&PlannerItem>, Vec<&PlannerItem>) =
357            items.iter().partition(|i| i.selected);
358
359        // Mark deselected items as held back
360        for item in &deselected {
361            summary.held_back.push(ApplyItem::held_back(item));
362        }
363
364        // Sort selected items in dependency order
365        let sorted = match dependency_sort(selected.clone()) {
366            Ok(sorted) => sorted,
367            Err(_) => {
368                // If we can't sort due to cycles, fail all selected items
369                for item in &selected {
370                    let mut apply_item = ApplyItem::new(
371                        &item.plan_item.component_id,
372                        &item.plan_item.current_version,
373                        &item.plan_item.proposed_version,
374                        ApplyStatus::Failed,
375                    );
376                    apply_item.error_message = "dependency cycle detected".to_string();
377                    apply_item.dependencies = item.plan_item.dependencies.clone();
378                    summary.failed.push(apply_item);
379                }
380                return summary;
381            }
382        };
383
384        // Track failed component IDs for dependency blocking
385        let mut failed_ids: HashSet<String> = HashSet::new();
386        let mut failure_reasons: HashMap<String, String> = HashMap::new();
387
388        // Execute in order
389        for item in &sorted {
390            // Check if any dependency has failed
391            let blocked_by = self.find_failed_dependency(&item.plan_item, &failed_ids);
392
393            if let Some(blocker_id) = blocked_by {
394                let reason = failure_reasons
395                    .get(&blocker_id)
396                    .cloned()
397                    .unwrap_or_default();
398                summary
399                    .blocked
400                    .push(ApplyItem::blocked_by_failure(item, &blocker_id, &reason));
401                // Also mark as failed for downstream dependents
402                failed_ids.insert(item.plan_item.component_id.clone());
403                failure_reasons.insert(
404                    item.plan_item.component_id.clone(),
405                    format!("dependency '{blocker_id}' failed"),
406                );
407                continue;
408            }
409
410            // Apply the component
411            let status = if options.force_fail.contains(&item.plan_item.component_id) {
412                Err(format!(
413                    "forced failure for component '{}'",
414                    item.plan_item.component_id
415                ))
416            } else {
417                self.executor.apply_component(
418                    &item.plan_item.component_id,
419                    &item.plan_item.proposed_version,
420                )
421            };
422
423            match status {
424                Ok(()) => {
425                    // HONESTY GUARD: re-detect the installed version and verify it
426                    // reached the target. Only when BOTH the target is a concrete
427                    // version AND the detected value is a real version — otherwise
428                    // (opaque target like "latest", or non-version detection like
429                    // "installed") trust the install. This is what stops a sealed-
430                    // core skip / no-op pip / unchanged git-describe from reporting
431                    // false "Succeeded".
432                    let proposed = item.plan_item.proposed_version.as_str();
433                    let verify = options.verify_post_install_version;
434                    let detected = if !verify || is_opaque_target(proposed) {
435                        String::new()
436                    } else {
437                        crate::platform::registry::get_version(&item.plan_item.component_id)
438                    };
439                    let advanced = !verify
440                        || is_opaque_target(proposed)
441                        || !is_real_version(&detected)
442                        || version_reached(&detected, proposed);
443                    if advanced {
444                        let mut apply_item = ApplyItem::new(
445                            &item.plan_item.component_id,
446                            &item.plan_item.current_version,
447                            &item.plan_item.proposed_version,
448                            ApplyStatus::Success,
449                        );
450                        apply_item.dependencies = item.plan_item.dependencies.clone();
451                        summary.success.push(apply_item);
452                    } else {
453                        let mut apply_item = ApplyItem::new(
454                            &item.plan_item.component_id,
455                            &item.plan_item.current_version,
456                            &item.plan_item.proposed_version,
457                            ApplyStatus::Failed,
458                        );
459                        apply_item.error_message = format!(
460                            "install reported success but the version did not advance to \
461                             '{proposed}' (still '{detected}'). The component may be sealed \
462                             (set MLSTACK_UNSEAL_CORE=1 to override) or the installer did not \
463                             pin to the target version."
464                        );
465                        apply_item.dependencies = item.plan_item.dependencies.clone();
466                        summary.failed.push(apply_item);
467                        failed_ids.insert(item.plan_item.component_id.clone());
468                        failure_reasons.insert(
469                            item.plan_item.component_id.clone(),
470                            "install did not advance the version".to_string(),
471                        );
472                    }
473                }
474                Err(err) => {
475                    let mut apply_item = ApplyItem::new(
476                        &item.plan_item.component_id,
477                        &item.plan_item.current_version,
478                        &item.plan_item.proposed_version,
479                        ApplyStatus::Failed,
480                    );
481                    apply_item.error_message = err.clone();
482                    apply_item.dependencies = item.plan_item.dependencies.clone();
483                    summary.failed.push(apply_item);
484
485                    failed_ids.insert(item.plan_item.component_id.clone());
486                    failure_reasons.insert(item.plan_item.component_id.clone(), err);
487                }
488            }
489        }
490
491        summary
492    }
493
494    /// Find a failed dependency for the given item.
495    ///
496    /// Returns the first failed dependency ID, if any.
497    fn find_failed_dependency(
498        &self,
499        item: &PlanItem,
500        failed_ids: &HashSet<String>,
501    ) -> Option<String> {
502        item.dependencies
503            .iter()
504            .find(|dep| failed_ids.contains(*dep))
505            .cloned()
506    }
507}
508
509// ---------------------------------------------------------------------------
510// Dependency Sort
511// ---------------------------------------------------------------------------
512
513/// Sort planner items in dependency-safe execution order.
514///
515/// Uses topological sort (Kahn's algorithm). Returns an error if a cycle
516/// is detected.
517fn dependency_sort(items: Vec<&PlannerItem>) -> Result<Vec<&PlannerItem>, String> {
518    let n = items.len();
519    if n == 0 {
520        return Ok(items);
521    }
522
523    let id_to_idx: HashMap<&str, usize> = items
524        .iter()
525        .enumerate()
526        .map(|(i, item)| (item.plan_item.component_id.as_str(), i))
527        .collect();
528
529    // Build adjacency list and in-degree counts
530    let mut in_degree: Vec<usize> = vec![0; n];
531    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
532
533    for (idx, item) in items.iter().enumerate() {
534        for dep_id in &item.plan_item.dependencies {
535            if let Some(&dep_idx) = id_to_idx.get(dep_id.as_str()) {
536                adj[dep_idx].push(idx);
537                in_degree[idx] += 1;
538            }
539        }
540    }
541
542    // BFS
543    let mut queue = std::collections::VecDeque::new();
544    for (i, &deg) in in_degree.iter().enumerate() {
545        if deg == 0 {
546            queue.push_back(i);
547        }
548    }
549
550    let mut sorted_indices = Vec::with_capacity(n);
551    while let Some(idx) = queue.pop_front() {
552        sorted_indices.push(idx);
553        for &neighbor in &adj[idx] {
554            in_degree[neighbor] -= 1;
555            if in_degree[neighbor] == 0 {
556                queue.push_back(neighbor);
557            }
558        }
559    }
560
561    if sorted_indices.len() != n {
562        return Err("circular dependency detected".to_string());
563    }
564
565    // Reorder
566    let mut sorted: Vec<&PlannerItem> = Vec::with_capacity(n);
567    for idx in sorted_indices {
568        sorted.push(items[idx]);
569    }
570
571    Ok(sorted)
572}
573
574// ===========================================================================
575// Tests
576// ===========================================================================
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use crate::core::plan::{PlanItem, PlanItemInput};
582    use crate::core::types::ValidationTier;
583    use crate::orchestrator::planner::{PlannerItem, UpdateClassification};
584
585    // -----------------------------------------------------------------------
586    // Helpers
587    // -----------------------------------------------------------------------
588
589    #[test]
590    fn honesty_guard_helpers_classify_versions() {
591        // Opaque targets can't be verified — trust the install.
592        assert!(is_opaque_target(""));
593        assert!(is_opaque_target("latest"));
594        assert!(is_opaque_target("installed"));
595        assert!(!is_opaque_target("2.13.0"));
596        assert!(!is_opaque_target("25.1"));
597
598        // Real versions vs non-version detection strings.
599        assert!(is_real_version("2.13.0+rocm7.2"));
600        assert!(is_real_version("core_v0.15.0rc7"));
601        assert!(!is_real_version(""));
602        assert!(!is_real_version("installed"));
603        assert!(!is_real_version("unknown"));
604        assert!(!is_real_version("not installed"));
605
606        // version_reached: exact + build-metadata suffix.
607        assert!(version_reached("2.13.0", "2.13.0"));
608        assert!(version_reached("2.13.0+rocm7.2", "2.13.0"));
609        // The false-success case: old version didn't advance.
610        assert!(!version_reached("2.12.1+rocm7.2", "2.13.0"));
611        assert!(!version_reached("core_v0.15.0rc7", "25.1"));
612    }
613
614    fn make_plan_item(id: &str, current: &str, proposed: &str, deps: Vec<&str>) -> PlanItem {
615        PlanItem::new(PlanItemInput {
616            component_id: id.to_string(),
617            current_version: current.to_string(),
618            proposed_version: proposed.to_string(),
619            validation_tier: ValidationTier::Validated,
620            selected: true,
621            rationale: "test".to_string(),
622            dependencies: deps.into_iter().map(|s| s.to_string()).collect(),
623            isolation_safe: true,
624        })
625    }
626
627    fn make_planner_item(
628        id: &str,
629        current: &str,
630        proposed: &str,
631        deps: Vec<&str>,
632        selected: bool,
633    ) -> PlannerItem {
634        PlannerItem {
635            plan_item: make_plan_item(id, current, proposed, deps),
636            classification: UpdateClassification::Safe,
637            visible: true,
638            selected,
639            classification_reason: "test".to_string(),
640            requires_hardware_check: false,
641            min_rocm_version: String::new(),
642        }
643    }
644
645    fn make_planner_item_with_classification(
646        id: &str,
647        current: &str,
648        proposed: &str,
649        deps: Vec<&str>,
650        selected: bool,
651        classification: UpdateClassification,
652    ) -> PlannerItem {
653        PlannerItem {
654            plan_item: make_plan_item(id, current, proposed, deps),
655            classification,
656            visible: true,
657            selected,
658            classification_reason: "test".to_string(),
659            requires_hardware_check: false,
660            min_rocm_version: String::new(),
661        }
662    }
663
664    // -----------------------------------------------------------------------
665    // VAL-UPD-014: Apply executes in dependency-safe order
666    // -----------------------------------------------------------------------
667
668    #[test]
669    fn test_apply_dependency_safe_order_b_before_a() {
670        // A depends on B → B must complete before A starts
671        let items = vec![
672            make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
673            make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
674        ];
675
676        let engine = ApplyEngine::new_noop();
677        let options = ApplyOptions::default();
678        let summary = engine.apply(&items, &options);
679
680        // Both should succeed
681        assert_eq!(summary.success.len(), 2);
682        assert_eq!(summary.failed.len(), 0);
683
684        // Verify B appears before A in the success list
685        let order: Vec<&str> = summary
686            .success
687            .iter()
688            .map(|i| i.component_id.as_str())
689            .collect();
690        let b_pos = order.iter().position(|&id| id == "b").unwrap();
691        let a_pos = order.iter().position(|&id| id == "a").unwrap();
692        assert!(
693            b_pos < a_pos,
694            "B should be applied before A (B at {b_pos}, A at {a_pos})"
695        );
696    }
697
698    #[test]
699    fn test_apply_dependency_chain_c_before_b_before_a() {
700        // A depends on B, B depends on C → C, B, A order
701        let items = vec![
702            make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
703            make_planner_item("b", "1.0.0", "1.1.0", vec!["c"], true),
704            make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
705        ];
706
707        let engine = ApplyEngine::new_noop();
708        let summary = engine.apply(&items, &ApplyOptions::default());
709
710        assert_eq!(summary.success.len(), 3);
711        let order: Vec<&str> = summary
712            .success
713            .iter()
714            .map(|i| i.component_id.as_str())
715            .collect();
716        let c_pos = order.iter().position(|&id| id == "c").unwrap();
717        let b_pos = order.iter().position(|&id| id == "b").unwrap();
718        let a_pos = order.iter().position(|&id| id == "a").unwrap();
719        assert!(c_pos < b_pos, "C before B");
720        assert!(b_pos < a_pos, "B before A");
721    }
722
723    // -----------------------------------------------------------------------
724    // VAL-UPD-015: Failed guarded update stops dependent work
725    // -----------------------------------------------------------------------
726
727    #[test]
728    fn test_apply_failed_guarded_stops_dependents() {
729        // B fails → A (depends on B) should be blocked-by-failure
730        let items = vec![
731            make_planner_item_with_classification(
732                "a",
733                "1.0.0",
734                "1.1.0",
735                vec!["b"],
736                true,
737                UpdateClassification::Guarded,
738            ),
739            make_planner_item_with_classification(
740                "b",
741                "1.0.0",
742                "1.1.0",
743                vec![],
744                true,
745                UpdateClassification::Guarded,
746            ),
747        ];
748
749        let engine = ApplyEngine::new_noop();
750        let options = ApplyOptions {
751            force_fail: HashSet::from(["b".to_string()]),
752            ..Default::default()
753        };
754        let summary = engine.apply(&items, &options);
755
756        // B should be failed
757        assert_eq!(summary.failed.len(), 1);
758        assert_eq!(summary.failed[0].component_id, "b");
759
760        // A should be blocked by failure
761        assert_eq!(summary.blocked.len(), 1);
762        assert_eq!(summary.blocked[0].component_id, "a");
763        assert_eq!(summary.blocked[0].status, ApplyStatus::BlockedByFailure);
764        assert!(summary.blocked[0].error_message.contains("b"));
765    }
766
767    // -----------------------------------------------------------------------
768    // VAL-UPD-016: Isolation-safe — unrelated work continues after failure
769    // -----------------------------------------------------------------------
770
771    #[test]
772    fn test_apply_unrelated_continues_after_failure() {
773        // B fails. A depends on B (blocked). C and D are unrelated (should succeed).
774        let items = vec![
775            make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
776            make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
777            make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
778            make_planner_item("d", "1.0.0", "1.1.0", vec![], true),
779        ];
780
781        let engine = ApplyEngine::new_noop();
782        let options = ApplyOptions {
783            force_fail: HashSet::from(["b".to_string()]),
784            ..Default::default()
785        };
786        let summary = engine.apply(&items, &options);
787
788        // B failed
789        assert_eq!(summary.failed.len(), 1);
790        assert_eq!(summary.failed[0].component_id, "b");
791
792        // A blocked by B's failure
793        assert_eq!(summary.blocked.len(), 1);
794        assert_eq!(summary.blocked[0].component_id, "a");
795
796        // C and D succeed (unrelated)
797        assert_eq!(summary.success.len(), 2);
798        let success_ids: HashSet<&str> = summary
799            .success
800            .iter()
801            .map(|i| i.component_id.as_str())
802            .collect();
803        assert!(success_ids.contains("c"));
804        assert!(success_ids.contains("d"));
805    }
806
807    // -----------------------------------------------------------------------
808    // VAL-UPD-012: User can untick any preselected component
809    // -----------------------------------------------------------------------
810
811    #[test]
812    fn test_apply_user_can_untick_preselected_component() {
813        // A is preselected (selected=true), B is deselected (selected=false)
814        let items = vec![
815            make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
816            make_planner_item("b", "1.0.0", "1.1.0", vec![], false),
817        ];
818
819        let engine = ApplyEngine::new_noop();
820        let summary = engine.apply(&items, &ApplyOptions::default());
821
822        // A succeeds
823        assert_eq!(summary.success.len(), 1);
824        assert_eq!(summary.success[0].component_id, "a");
825
826        // B is held back
827        assert_eq!(summary.held_back.len(), 1);
828        assert_eq!(summary.held_back[0].component_id, "b");
829        assert_eq!(summary.held_back[0].status, ApplyStatus::HeldBack);
830        assert!(!summary.held_back[0].was_selected);
831    }
832
833    #[test]
834    fn test_apply_deselect_all_results_in_noop() {
835        let items = vec![
836            make_planner_item("a", "1.0.0", "1.1.0", vec![], false),
837            make_planner_item("b", "1.0.0", "1.1.0", vec![], false),
838        ];
839
840        let engine = ApplyEngine::new_noop();
841        let summary = engine.apply(&items, &ApplyOptions::default());
842
843        assert_eq!(summary.success.len(), 0);
844        assert_eq!(summary.failed.len(), 0);
845        assert_eq!(summary.held_back.len(), 2);
846    }
847
848    // -----------------------------------------------------------------------
849    // ApplyStatus serde roundtrip
850    // -----------------------------------------------------------------------
851
852    #[test]
853    fn test_apply_status_serde_roundtrip() {
854        let statuses = [
855            ApplyStatus::Success,
856            ApplyStatus::Failed,
857            ApplyStatus::BlockedByFailure,
858            ApplyStatus::HeldBack,
859            ApplyStatus::Skipped,
860        ];
861        for status in &statuses {
862            let json = serde_json::to_string(status).unwrap();
863            let back: ApplyStatus = serde_json::from_str(&json).unwrap();
864            assert_eq!(*status, back);
865        }
866    }
867
868    #[test]
869    fn test_apply_status_serializes_to_kebab_case() {
870        assert_eq!(
871            serde_json::to_string(&ApplyStatus::Success).unwrap(),
872            "\"success\""
873        );
874        assert_eq!(
875            serde_json::to_string(&ApplyStatus::BlockedByFailure).unwrap(),
876            "\"blocked-by-failure\""
877        );
878        assert_eq!(
879            serde_json::to_string(&ApplyStatus::HeldBack).unwrap(),
880            "\"held-back\""
881        );
882    }
883
884    // -----------------------------------------------------------------------
885    // ApplySummary complete partitioning (VAL-UPD-018)
886    // -----------------------------------------------------------------------
887
888    #[test]
889    fn test_apply_summary_complete_partitioning() {
890        // 4 items: 2 succeed, 1 fails, 1 held back
891        let items = vec![
892            make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
893            make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
894            make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
895            make_planner_item("d", "1.0.0", "1.1.0", vec![], false),
896        ];
897
898        let engine = ApplyEngine::new_noop();
899        let options = ApplyOptions {
900            force_fail: HashSet::from(["c".to_string()]),
901            ..Default::default()
902        };
903        let summary = engine.apply(&items, &options);
904
905        // Verify complete partitioning
906        assert!(summary.is_complete_partition());
907        assert_eq!(summary.total(), 4);
908        assert_eq!(summary.success.len(), 2);
909        assert_eq!(summary.failed.len(), 1);
910        assert_eq!(summary.held_back.len(), 1);
911    }
912
913    // -----------------------------------------------------------------------
914    // ApplySummary has_failures
915    // -----------------------------------------------------------------------
916
917    #[test]
918    fn test_apply_summary_has_failures() {
919        let mut summary = ApplySummary::new();
920        assert!(!summary.has_failures());
921
922        summary
923            .success
924            .push(ApplyItem::new("a", "1.0.0", "1.1.0", ApplyStatus::Success));
925        assert!(!summary.has_failures());
926
927        summary
928            .failed
929            .push(ApplyItem::new("b", "1.0.0", "1.1.0", ApplyStatus::Failed));
930        assert!(summary.has_failures());
931    }
932
933    // -----------------------------------------------------------------------
934    // Dependency sort
935    // -----------------------------------------------------------------------
936
937    #[test]
938    fn test_dependency_sort_empty() {
939        let items: Vec<&PlannerItem> = vec![];
940        let result = dependency_sort(items);
941        assert!(result.is_ok());
942        assert!(result.unwrap().is_empty());
943    }
944
945    #[test]
946    fn test_dependency_sort_no_deps() {
947        let items = [
948            make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
949            make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
950        ];
951        let result = dependency_sort(items.iter().collect());
952        assert!(result.is_ok());
953        assert_eq!(result.unwrap().len(), 2);
954    }
955
956    #[test]
957    fn test_dependency_sort_cycle_detected() {
958        let items = [
959            make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
960            make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
961        ];
962        let result = dependency_sort(items.iter().collect());
963        assert!(result.is_err());
964    }
965
966    // -----------------------------------------------------------------------
967    // ApplyItem constructors
968    // -----------------------------------------------------------------------
969
970    #[test]
971    fn test_apply_item_held_back() {
972        let item = make_planner_item("test", "1.0.0", "1.1.0", vec![], false);
973        let apply_item = ApplyItem::held_back(&item);
974        assert_eq!(apply_item.component_id, "test");
975        assert_eq!(apply_item.status, ApplyStatus::HeldBack);
976        assert!(!apply_item.was_selected);
977    }
978
979    #[test]
980    fn test_apply_item_blocked_by_failure() {
981        let item = make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true);
982        let apply_item = ApplyItem::blocked_by_failure(&item, "b", "install failed");
983        assert_eq!(apply_item.component_id, "a");
984        assert_eq!(apply_item.status, ApplyStatus::BlockedByFailure);
985        assert!(apply_item.error_message.contains("b"));
986        assert!(apply_item.error_message.contains("install failed"));
987        assert!(apply_item.was_selected);
988    }
989
990    // -----------------------------------------------------------------------
991    // Multi-level dependency failure propagation
992    // -----------------------------------------------------------------------
993
994    #[test]
995    fn test_apply_transitive_dependency_block() {
996        // C depends on B, B depends on A. A fails → B blocked → C blocked.
997        let items = vec![
998            make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
999            make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
1000            make_planner_item("c", "1.0.0", "1.1.0", vec!["b"], true),
1001        ];
1002
1003        let engine = ApplyEngine::new_noop();
1004        let options = ApplyOptions {
1005            force_fail: HashSet::from(["a".to_string()]),
1006            ..Default::default()
1007        };
1008        let summary = engine.apply(&items, &options);
1009
1010        // A failed
1011        assert_eq!(summary.failed.len(), 1);
1012        assert_eq!(summary.failed[0].component_id, "a");
1013
1014        // B and C blocked
1015        assert_eq!(summary.blocked.len(), 2);
1016        let blocked_ids: HashSet<&str> = summary
1017            .blocked
1018            .iter()
1019            .map(|i| i.component_id.as_str())
1020            .collect();
1021        assert!(blocked_ids.contains("b"));
1022        assert!(blocked_ids.contains("c"));
1023    }
1024
1025    // -----------------------------------------------------------------------
1026    // Diamond dependency
1027    // -----------------------------------------------------------------------
1028
1029    #[test]
1030    fn test_apply_diamond_dependency() {
1031        // D depends on B and C, B depends on A, C depends on A
1032        // If A fails, B and C are blocked (depend on A), D is blocked (depends on B/C)
1033        let items = vec![
1034            make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
1035            make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
1036            make_planner_item("c", "1.0.0", "1.1.0", vec!["a"], true),
1037            make_planner_item("d", "1.0.0", "1.1.0", vec!["b", "c"], true),
1038        ];
1039
1040        let engine = ApplyEngine::new_noop();
1041        let options = ApplyOptions {
1042            force_fail: HashSet::from(["a".to_string()]),
1043            ..Default::default()
1044        };
1045        let summary = engine.apply(&items, &options);
1046
1047        assert_eq!(summary.failed.len(), 1);
1048        assert_eq!(summary.blocked.len(), 3);
1049        assert_eq!(summary.success.len(), 0);
1050    }
1051
1052    // -----------------------------------------------------------------------
1053    // All items succeed
1054    // -----------------------------------------------------------------------
1055
1056    #[test]
1057    fn test_apply_all_succeed() {
1058        // Fake component ids: detection returns "unknown" (not real components),
1059        // so the post-install honesty guard trusts the NoOp install's success.
1060        // Real ids (rocm/pytorch) would be re-detected at their ACTUAL installed
1061        // version and correctly fail the version-advance check.
1062        let items = vec![
1063            make_planner_item("comp-a", "7.2.0", "7.2.1", vec![], true),
1064            make_planner_item("comp-b", "2.4.0", "2.4.1", vec!["comp-a"], true),
1065            make_planner_item("comp-c", "3.0.0", "3.0.1", vec!["comp-b"], true),
1066        ];
1067
1068        let engine = ApplyEngine::new_noop();
1069        let summary = engine.apply(&items, &ApplyOptions::default());
1070
1071        assert_eq!(summary.success.len(), 3);
1072        assert_eq!(summary.failed.len(), 0);
1073        assert_eq!(summary.blocked.len(), 0);
1074        assert_eq!(summary.held_back.len(), 0);
1075    }
1076
1077    // -----------------------------------------------------------------------
1078    // ApplySummary serde
1079    // -----------------------------------------------------------------------
1080
1081    #[test]
1082    fn test_apply_summary_serde_roundtrip() {
1083        let mut summary = ApplySummary::new();
1084        summary
1085            .success
1086            .push(ApplyItem::new("a", "1.0.0", "1.1.0", ApplyStatus::Success));
1087        summary
1088            .failed
1089            .push(ApplyItem::new("b", "1.0.0", "1.1.0", ApplyStatus::Failed));
1090        summary
1091            .held_back
1092            .push(ApplyItem::new("c", "1.0.0", "1.1.0", ApplyStatus::HeldBack));
1093
1094        let json = serde_json::to_string(&summary).unwrap();
1095        let back: ApplySummary = serde_json::from_str(&json).unwrap();
1096        assert_eq!(summary, back);
1097    }
1098
1099    // -----------------------------------------------------------------------
1100    // ApplyItem serde
1101    // -----------------------------------------------------------------------
1102
1103    #[test]
1104    fn test_apply_item_serde_roundtrip() {
1105        let item = ApplyItem {
1106            component_id: "pytorch".to_string(),
1107            current_version: "2.4.0".to_string(),
1108            proposed_version: "2.5.0".to_string(),
1109            status: ApplyStatus::Failed,
1110            error_message: "install script returned non-zero exit code".to_string(),
1111            dependencies: vec!["rocm".to_string()],
1112            was_selected: true,
1113        };
1114        let json = serde_json::to_string(&item).unwrap();
1115        let back: ApplyItem = serde_json::from_str(&json).unwrap();
1116        assert_eq!(item, back);
1117    }
1118}