use crate::core::plan::PlanItem;
use crate::orchestrator::planner::PlannerItem;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt;
fn is_opaque_target(proposed: &str) -> bool {
let p = proposed.trim();
p.is_empty() || p == "latest" || p == "installed"
}
fn is_real_version(s: &str) -> bool {
let s = s.trim();
!s.is_empty() && s != "installed" && s != "unknown" && s != "not installed"
}
fn version_reached(installed: &str, target: &str) -> bool {
let inst = installed.trim();
let tgt = target.trim();
if inst == tgt {
return true;
}
inst.starts_with(tgt)
&& inst
.get(tgt.len()..)
.map(|s| s.starts_with('+'))
.unwrap_or(false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApplyStatus {
Success,
Failed,
BlockedByFailure,
HeldBack,
Skipped,
}
impl ApplyStatus {
pub fn label(self) -> &'static str {
match self {
ApplyStatus::Success => "success",
ApplyStatus::Failed => "failed",
ApplyStatus::BlockedByFailure => "blocked-by-failure",
ApplyStatus::HeldBack => "held-back",
ApplyStatus::Skipped => "skipped",
}
}
}
impl fmt::Display for ApplyStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApplyItem {
pub component_id: String,
pub current_version: String,
pub proposed_version: String,
pub status: ApplyStatus,
pub error_message: String,
pub dependencies: Vec<String>,
pub was_selected: bool,
}
impl ApplyItem {
pub fn new(
component_id: impl Into<String>,
current_version: impl Into<String>,
proposed_version: impl Into<String>,
status: ApplyStatus,
) -> Self {
Self {
component_id: component_id.into(),
current_version: current_version.into(),
proposed_version: proposed_version.into(),
status,
error_message: String::new(),
dependencies: Vec::new(),
was_selected: true,
}
}
pub fn held_back(item: &PlannerItem) -> Self {
Self {
component_id: item.plan_item.component_id.clone(),
current_version: item.plan_item.current_version.clone(),
proposed_version: item.plan_item.proposed_version.clone(),
status: ApplyStatus::HeldBack,
error_message: String::new(),
dependencies: item.plan_item.dependencies.clone(),
was_selected: false,
}
}
pub fn blocked_by_failure(item: &PlannerItem, blocker_id: &str, reason: &str) -> Self {
Self {
component_id: item.plan_item.component_id.clone(),
current_version: item.plan_item.current_version.clone(),
proposed_version: item.plan_item.proposed_version.clone(),
status: ApplyStatus::BlockedByFailure,
error_message: format!("blocked by failed dependency '{blocker_id}': {reason}"),
dependencies: item.plan_item.dependencies.clone(),
was_selected: true,
}
}
pub fn skipped(item: &PlannerItem) -> Self {
Self {
component_id: item.plan_item.component_id.clone(),
current_version: item.plan_item.current_version.clone(),
proposed_version: item.plan_item.proposed_version.clone(),
status: ApplyStatus::Skipped,
error_message: String::new(),
dependencies: item.plan_item.dependencies.clone(),
was_selected: false,
}
}
}
pub trait ApplyExecutor {
fn apply_component(&self, component_id: &str, proposed_version: &str) -> Result<(), String>;
}
pub struct NoOpExecutor;
impl ApplyExecutor for NoOpExecutor {
fn apply_component(&self, _component_id: &str, _proposed_version: &str) -> Result<(), String> {
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct ApplyOptions {
pub force_fail: HashSet<String>,
pub verify_post_install_version: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApplySummary {
pub success: Vec<ApplyItem>,
pub failed: Vec<ApplyItem>,
pub held_back: Vec<ApplyItem>,
pub blocked: Vec<ApplyItem>,
pub skipped: Vec<ApplyItem>,
}
impl ApplySummary {
pub fn new() -> Self {
Self {
success: Vec::new(),
failed: Vec::new(),
held_back: Vec::new(),
blocked: Vec::new(),
skipped: Vec::new(),
}
}
pub fn total(&self) -> usize {
self.success.len()
+ self.failed.len()
+ self.held_back.len()
+ self.blocked.len()
+ self.skipped.len()
}
pub fn has_failures(&self) -> bool {
!self.failed.is_empty() || !self.blocked.is_empty()
}
pub fn is_complete_partition(&self) -> bool {
let all_ids: HashSet<&str> = self
.success
.iter()
.chain(self.failed.iter())
.chain(self.held_back.iter())
.chain(self.blocked.iter())
.chain(self.skipped.iter())
.map(|i| i.component_id.as_str())
.collect();
let total_count = self.success.len()
+ self.failed.len()
+ self.held_back.len()
+ self.blocked.len()
+ self.skipped.len();
all_ids.len() == total_count
}
}
impl Default for ApplySummary {
fn default() -> Self {
Self::new()
}
}
pub struct ApplyEngine {
executor: Box<dyn ApplyExecutor>,
}
impl ApplyEngine {
pub fn new(executor: impl ApplyExecutor + 'static) -> Self {
Self {
executor: Box::new(executor),
}
}
pub fn new_noop() -> Self {
Self::new(NoOpExecutor)
}
pub fn apply(&self, items: &[PlannerItem], options: &ApplyOptions) -> ApplySummary {
let mut summary = ApplySummary::new();
let (selected, deselected): (Vec<&PlannerItem>, Vec<&PlannerItem>) =
items.iter().partition(|i| i.selected);
for item in &deselected {
summary.held_back.push(ApplyItem::held_back(item));
}
let sorted = match dependency_sort(selected.clone()) {
Ok(sorted) => sorted,
Err(_) => {
for item in &selected {
let mut apply_item = ApplyItem::new(
&item.plan_item.component_id,
&item.plan_item.current_version,
&item.plan_item.proposed_version,
ApplyStatus::Failed,
);
apply_item.error_message = "dependency cycle detected".to_string();
apply_item.dependencies = item.plan_item.dependencies.clone();
summary.failed.push(apply_item);
}
return summary;
}
};
let mut failed_ids: HashSet<String> = HashSet::new();
let mut failure_reasons: HashMap<String, String> = HashMap::new();
for item in &sorted {
let blocked_by = self.find_failed_dependency(&item.plan_item, &failed_ids);
if let Some(blocker_id) = blocked_by {
let reason = failure_reasons
.get(&blocker_id)
.cloned()
.unwrap_or_default();
summary
.blocked
.push(ApplyItem::blocked_by_failure(item, &blocker_id, &reason));
failed_ids.insert(item.plan_item.component_id.clone());
failure_reasons.insert(
item.plan_item.component_id.clone(),
format!("dependency '{blocker_id}' failed"),
);
continue;
}
let status = if options.force_fail.contains(&item.plan_item.component_id) {
Err(format!(
"forced failure for component '{}'",
item.plan_item.component_id
))
} else {
self.executor.apply_component(
&item.plan_item.component_id,
&item.plan_item.proposed_version,
)
};
match status {
Ok(()) => {
let proposed = item.plan_item.proposed_version.as_str();
let verify = options.verify_post_install_version;
let detected = if !verify || is_opaque_target(proposed) {
String::new()
} else {
crate::platform::registry::get_version(&item.plan_item.component_id)
};
let advanced = !verify
|| is_opaque_target(proposed)
|| !is_real_version(&detected)
|| version_reached(&detected, proposed);
if advanced {
let mut apply_item = ApplyItem::new(
&item.plan_item.component_id,
&item.plan_item.current_version,
&item.plan_item.proposed_version,
ApplyStatus::Success,
);
apply_item.dependencies = item.plan_item.dependencies.clone();
summary.success.push(apply_item);
} else {
let mut apply_item = ApplyItem::new(
&item.plan_item.component_id,
&item.plan_item.current_version,
&item.plan_item.proposed_version,
ApplyStatus::Failed,
);
apply_item.error_message = format!(
"install reported success but the version did not advance to \
'{proposed}' (still '{detected}'). The component may be sealed \
(set MLSTACK_UNSEAL_CORE=1 to override) or the installer did not \
pin to the target version."
);
apply_item.dependencies = item.plan_item.dependencies.clone();
summary.failed.push(apply_item);
failed_ids.insert(item.plan_item.component_id.clone());
failure_reasons.insert(
item.plan_item.component_id.clone(),
"install did not advance the version".to_string(),
);
}
}
Err(err) => {
let mut apply_item = ApplyItem::new(
&item.plan_item.component_id,
&item.plan_item.current_version,
&item.plan_item.proposed_version,
ApplyStatus::Failed,
);
apply_item.error_message = err.clone();
apply_item.dependencies = item.plan_item.dependencies.clone();
summary.failed.push(apply_item);
failed_ids.insert(item.plan_item.component_id.clone());
failure_reasons.insert(item.plan_item.component_id.clone(), err);
}
}
}
summary
}
fn find_failed_dependency(
&self,
item: &PlanItem,
failed_ids: &HashSet<String>,
) -> Option<String> {
item.dependencies
.iter()
.find(|dep| failed_ids.contains(*dep))
.cloned()
}
}
fn dependency_sort(items: Vec<&PlannerItem>) -> Result<Vec<&PlannerItem>, String> {
let n = items.len();
if n == 0 {
return Ok(items);
}
let id_to_idx: HashMap<&str, usize> = items
.iter()
.enumerate()
.map(|(i, item)| (item.plan_item.component_id.as_str(), i))
.collect();
let mut in_degree: Vec<usize> = vec![0; n];
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
for (idx, item) in items.iter().enumerate() {
for dep_id in &item.plan_item.dependencies {
if let Some(&dep_idx) = id_to_idx.get(dep_id.as_str()) {
adj[dep_idx].push(idx);
in_degree[idx] += 1;
}
}
}
let mut queue = std::collections::VecDeque::new();
for (i, °) in in_degree.iter().enumerate() {
if deg == 0 {
queue.push_back(i);
}
}
let mut sorted_indices = Vec::with_capacity(n);
while let Some(idx) = queue.pop_front() {
sorted_indices.push(idx);
for &neighbor in &adj[idx] {
in_degree[neighbor] -= 1;
if in_degree[neighbor] == 0 {
queue.push_back(neighbor);
}
}
}
if sorted_indices.len() != n {
return Err("circular dependency detected".to_string());
}
let mut sorted: Vec<&PlannerItem> = Vec::with_capacity(n);
for idx in sorted_indices {
sorted.push(items[idx]);
}
Ok(sorted)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::plan::{PlanItem, PlanItemInput};
use crate::core::types::ValidationTier;
use crate::orchestrator::planner::{PlannerItem, UpdateClassification};
#[test]
fn honesty_guard_helpers_classify_versions() {
assert!(is_opaque_target(""));
assert!(is_opaque_target("latest"));
assert!(is_opaque_target("installed"));
assert!(!is_opaque_target("2.13.0"));
assert!(!is_opaque_target("25.1"));
assert!(is_real_version("2.13.0+rocm7.2"));
assert!(is_real_version("core_v0.15.0rc7"));
assert!(!is_real_version(""));
assert!(!is_real_version("installed"));
assert!(!is_real_version("unknown"));
assert!(!is_real_version("not installed"));
assert!(version_reached("2.13.0", "2.13.0"));
assert!(version_reached("2.13.0+rocm7.2", "2.13.0"));
assert!(!version_reached("2.12.1+rocm7.2", "2.13.0"));
assert!(!version_reached("core_v0.15.0rc7", "25.1"));
}
fn make_plan_item(id: &str, current: &str, proposed: &str, deps: Vec<&str>) -> PlanItem {
PlanItem::new(PlanItemInput {
component_id: id.to_string(),
current_version: current.to_string(),
proposed_version: proposed.to_string(),
validation_tier: ValidationTier::Validated,
selected: true,
rationale: "test".to_string(),
dependencies: deps.into_iter().map(|s| s.to_string()).collect(),
isolation_safe: true,
})
}
fn make_planner_item(
id: &str,
current: &str,
proposed: &str,
deps: Vec<&str>,
selected: bool,
) -> PlannerItem {
PlannerItem {
plan_item: make_plan_item(id, current, proposed, deps),
classification: UpdateClassification::Safe,
visible: true,
selected,
classification_reason: "test".to_string(),
requires_hardware_check: false,
min_rocm_version: String::new(),
}
}
fn make_planner_item_with_classification(
id: &str,
current: &str,
proposed: &str,
deps: Vec<&str>,
selected: bool,
classification: UpdateClassification,
) -> PlannerItem {
PlannerItem {
plan_item: make_plan_item(id, current, proposed, deps),
classification,
visible: true,
selected,
classification_reason: "test".to_string(),
requires_hardware_check: false,
min_rocm_version: String::new(),
}
}
#[test]
fn test_apply_dependency_safe_order_b_before_a() {
let items = vec![
make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
];
let engine = ApplyEngine::new_noop();
let options = ApplyOptions::default();
let summary = engine.apply(&items, &options);
assert_eq!(summary.success.len(), 2);
assert_eq!(summary.failed.len(), 0);
let order: Vec<&str> = summary
.success
.iter()
.map(|i| i.component_id.as_str())
.collect();
let b_pos = order.iter().position(|&id| id == "b").unwrap();
let a_pos = order.iter().position(|&id| id == "a").unwrap();
assert!(
b_pos < a_pos,
"B should be applied before A (B at {b_pos}, A at {a_pos})"
);
}
#[test]
fn test_apply_dependency_chain_c_before_b_before_a() {
let items = vec![
make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
make_planner_item("b", "1.0.0", "1.1.0", vec!["c"], true),
make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
];
let engine = ApplyEngine::new_noop();
let summary = engine.apply(&items, &ApplyOptions::default());
assert_eq!(summary.success.len(), 3);
let order: Vec<&str> = summary
.success
.iter()
.map(|i| i.component_id.as_str())
.collect();
let c_pos = order.iter().position(|&id| id == "c").unwrap();
let b_pos = order.iter().position(|&id| id == "b").unwrap();
let a_pos = order.iter().position(|&id| id == "a").unwrap();
assert!(c_pos < b_pos, "C before B");
assert!(b_pos < a_pos, "B before A");
}
#[test]
fn test_apply_failed_guarded_stops_dependents() {
let items = vec![
make_planner_item_with_classification(
"a",
"1.0.0",
"1.1.0",
vec!["b"],
true,
UpdateClassification::Guarded,
),
make_planner_item_with_classification(
"b",
"1.0.0",
"1.1.0",
vec![],
true,
UpdateClassification::Guarded,
),
];
let engine = ApplyEngine::new_noop();
let options = ApplyOptions {
force_fail: HashSet::from(["b".to_string()]),
..Default::default()
};
let summary = engine.apply(&items, &options);
assert_eq!(summary.failed.len(), 1);
assert_eq!(summary.failed[0].component_id, "b");
assert_eq!(summary.blocked.len(), 1);
assert_eq!(summary.blocked[0].component_id, "a");
assert_eq!(summary.blocked[0].status, ApplyStatus::BlockedByFailure);
assert!(summary.blocked[0].error_message.contains("b"));
}
#[test]
fn test_apply_unrelated_continues_after_failure() {
let items = vec![
make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
make_planner_item("d", "1.0.0", "1.1.0", vec![], true),
];
let engine = ApplyEngine::new_noop();
let options = ApplyOptions {
force_fail: HashSet::from(["b".to_string()]),
..Default::default()
};
let summary = engine.apply(&items, &options);
assert_eq!(summary.failed.len(), 1);
assert_eq!(summary.failed[0].component_id, "b");
assert_eq!(summary.blocked.len(), 1);
assert_eq!(summary.blocked[0].component_id, "a");
assert_eq!(summary.success.len(), 2);
let success_ids: HashSet<&str> = summary
.success
.iter()
.map(|i| i.component_id.as_str())
.collect();
assert!(success_ids.contains("c"));
assert!(success_ids.contains("d"));
}
#[test]
fn test_apply_user_can_untick_preselected_component() {
let items = vec![
make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
make_planner_item("b", "1.0.0", "1.1.0", vec![], false),
];
let engine = ApplyEngine::new_noop();
let summary = engine.apply(&items, &ApplyOptions::default());
assert_eq!(summary.success.len(), 1);
assert_eq!(summary.success[0].component_id, "a");
assert_eq!(summary.held_back.len(), 1);
assert_eq!(summary.held_back[0].component_id, "b");
assert_eq!(summary.held_back[0].status, ApplyStatus::HeldBack);
assert!(!summary.held_back[0].was_selected);
}
#[test]
fn test_apply_deselect_all_results_in_noop() {
let items = vec![
make_planner_item("a", "1.0.0", "1.1.0", vec![], false),
make_planner_item("b", "1.0.0", "1.1.0", vec![], false),
];
let engine = ApplyEngine::new_noop();
let summary = engine.apply(&items, &ApplyOptions::default());
assert_eq!(summary.success.len(), 0);
assert_eq!(summary.failed.len(), 0);
assert_eq!(summary.held_back.len(), 2);
}
#[test]
fn test_apply_status_serde_roundtrip() {
let statuses = [
ApplyStatus::Success,
ApplyStatus::Failed,
ApplyStatus::BlockedByFailure,
ApplyStatus::HeldBack,
ApplyStatus::Skipped,
];
for status in &statuses {
let json = serde_json::to_string(status).unwrap();
let back: ApplyStatus = serde_json::from_str(&json).unwrap();
assert_eq!(*status, back);
}
}
#[test]
fn test_apply_status_serializes_to_kebab_case() {
assert_eq!(
serde_json::to_string(&ApplyStatus::Success).unwrap(),
"\"success\""
);
assert_eq!(
serde_json::to_string(&ApplyStatus::BlockedByFailure).unwrap(),
"\"blocked-by-failure\""
);
assert_eq!(
serde_json::to_string(&ApplyStatus::HeldBack).unwrap(),
"\"held-back\""
);
}
#[test]
fn test_apply_summary_complete_partitioning() {
let items = vec![
make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
make_planner_item("c", "1.0.0", "1.1.0", vec![], true),
make_planner_item("d", "1.0.0", "1.1.0", vec![], false),
];
let engine = ApplyEngine::new_noop();
let options = ApplyOptions {
force_fail: HashSet::from(["c".to_string()]),
..Default::default()
};
let summary = engine.apply(&items, &options);
assert!(summary.is_complete_partition());
assert_eq!(summary.total(), 4);
assert_eq!(summary.success.len(), 2);
assert_eq!(summary.failed.len(), 1);
assert_eq!(summary.held_back.len(), 1);
}
#[test]
fn test_apply_summary_has_failures() {
let mut summary = ApplySummary::new();
assert!(!summary.has_failures());
summary
.success
.push(ApplyItem::new("a", "1.0.0", "1.1.0", ApplyStatus::Success));
assert!(!summary.has_failures());
summary
.failed
.push(ApplyItem::new("b", "1.0.0", "1.1.0", ApplyStatus::Failed));
assert!(summary.has_failures());
}
#[test]
fn test_dependency_sort_empty() {
let items: Vec<&PlannerItem> = vec![];
let result = dependency_sort(items);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_dependency_sort_no_deps() {
let items = [
make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
make_planner_item("b", "1.0.0", "1.1.0", vec![], true),
];
let result = dependency_sort(items.iter().collect());
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 2);
}
#[test]
fn test_dependency_sort_cycle_detected() {
let items = [
make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true),
make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
];
let result = dependency_sort(items.iter().collect());
assert!(result.is_err());
}
#[test]
fn test_apply_item_held_back() {
let item = make_planner_item("test", "1.0.0", "1.1.0", vec![], false);
let apply_item = ApplyItem::held_back(&item);
assert_eq!(apply_item.component_id, "test");
assert_eq!(apply_item.status, ApplyStatus::HeldBack);
assert!(!apply_item.was_selected);
}
#[test]
fn test_apply_item_blocked_by_failure() {
let item = make_planner_item("a", "1.0.0", "1.1.0", vec!["b"], true);
let apply_item = ApplyItem::blocked_by_failure(&item, "b", "install failed");
assert_eq!(apply_item.component_id, "a");
assert_eq!(apply_item.status, ApplyStatus::BlockedByFailure);
assert!(apply_item.error_message.contains("b"));
assert!(apply_item.error_message.contains("install failed"));
assert!(apply_item.was_selected);
}
#[test]
fn test_apply_transitive_dependency_block() {
let items = vec![
make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
make_planner_item("c", "1.0.0", "1.1.0", vec!["b"], true),
];
let engine = ApplyEngine::new_noop();
let options = ApplyOptions {
force_fail: HashSet::from(["a".to_string()]),
..Default::default()
};
let summary = engine.apply(&items, &options);
assert_eq!(summary.failed.len(), 1);
assert_eq!(summary.failed[0].component_id, "a");
assert_eq!(summary.blocked.len(), 2);
let blocked_ids: HashSet<&str> = summary
.blocked
.iter()
.map(|i| i.component_id.as_str())
.collect();
assert!(blocked_ids.contains("b"));
assert!(blocked_ids.contains("c"));
}
#[test]
fn test_apply_diamond_dependency() {
let items = vec![
make_planner_item("a", "1.0.0", "1.1.0", vec![], true),
make_planner_item("b", "1.0.0", "1.1.0", vec!["a"], true),
make_planner_item("c", "1.0.0", "1.1.0", vec!["a"], true),
make_planner_item("d", "1.0.0", "1.1.0", vec!["b", "c"], true),
];
let engine = ApplyEngine::new_noop();
let options = ApplyOptions {
force_fail: HashSet::from(["a".to_string()]),
..Default::default()
};
let summary = engine.apply(&items, &options);
assert_eq!(summary.failed.len(), 1);
assert_eq!(summary.blocked.len(), 3);
assert_eq!(summary.success.len(), 0);
}
#[test]
fn test_apply_all_succeed() {
let items = vec![
make_planner_item("comp-a", "7.2.0", "7.2.1", vec![], true),
make_planner_item("comp-b", "2.4.0", "2.4.1", vec!["comp-a"], true),
make_planner_item("comp-c", "3.0.0", "3.0.1", vec!["comp-b"], true),
];
let engine = ApplyEngine::new_noop();
let summary = engine.apply(&items, &ApplyOptions::default());
assert_eq!(summary.success.len(), 3);
assert_eq!(summary.failed.len(), 0);
assert_eq!(summary.blocked.len(), 0);
assert_eq!(summary.held_back.len(), 0);
}
#[test]
fn test_apply_summary_serde_roundtrip() {
let mut summary = ApplySummary::new();
summary
.success
.push(ApplyItem::new("a", "1.0.0", "1.1.0", ApplyStatus::Success));
summary
.failed
.push(ApplyItem::new("b", "1.0.0", "1.1.0", ApplyStatus::Failed));
summary
.held_back
.push(ApplyItem::new("c", "1.0.0", "1.1.0", ApplyStatus::HeldBack));
let json = serde_json::to_string(&summary).unwrap();
let back: ApplySummary = serde_json::from_str(&json).unwrap();
assert_eq!(summary, back);
}
#[test]
fn test_apply_item_serde_roundtrip() {
let item = ApplyItem {
component_id: "pytorch".to_string(),
current_version: "2.4.0".to_string(),
proposed_version: "2.5.0".to_string(),
status: ApplyStatus::Failed,
error_message: "install script returned non-zero exit code".to_string(),
dependencies: vec!["rocm".to_string()],
was_selected: true,
};
let json = serde_json::to_string(&item).unwrap();
let back: ApplyItem = serde_json::from_str(&json).unwrap();
assert_eq!(item, back);
}
}