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