use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::platform::environment::mlstack_root;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledComponent {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_index: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(default)]
pub sealed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_at: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pip_packages: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstalledComponentRegistry {
#[serde(default)]
components: Vec<InstalledComponent>,
}
impl InstalledComponentRegistry {
pub fn path() -> PathBuf {
mlstack_root().join("installed.json")
}
pub fn load() -> Self {
let path = Self::path();
match fs::read_to_string(&path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self) -> anyhow::Result<()> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
let temp_path = path.with_extension("json.tmp");
fs::write(&temp_path, json)?;
fs::rename(&temp_path, &path)?;
Ok(())
}
pub fn reload_and_save() -> anyhow::Result<()> {
Self::load().save()
}
pub fn mark_installed(&mut self, component: InstalledComponent) {
if let Some(existing) = self.components.iter_mut().find(|c| c.id == component.id) {
let preserve_seal = existing.sealed && !component.sealed;
*existing = component;
if preserve_seal {
existing.sealed = true;
}
} else {
self.components.push(component);
}
}
pub fn seal_core(
&mut self,
id: &str,
version: Option<String>,
source_index: Option<String>,
location: Option<String>,
pip_packages: Vec<String>,
installed_at: Option<String>,
) {
self.mark_installed(InstalledComponent {
id: id.to_string(),
version,
source_index,
location,
sealed: true,
installed_at,
pip_packages,
});
}
pub fn get(&self, id: &str) -> Option<&InstalledComponent> {
self.components.iter().find(|c| c.id == id)
}
pub fn is_installed(&self, id: &str) -> bool {
self.components.iter().any(|c| c.id == id)
}
pub fn is_sealed(&self, id: &str) -> bool {
self.get(id).is_some_and(|c| c.sealed)
}
pub fn installed_ids(&self) -> HashSet<String> {
self.components.iter().map(|c| c.id.clone()).collect()
}
pub fn all_pip_packages(&self) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for c in &self.components {
for p in &c.pip_packages {
if seen.insert(p.clone()) {
out.push(p.clone());
}
}
}
out
}
pub fn unseal(&mut self, id: &str) -> bool {
if let Some(c) = self.components.iter_mut().find(|c| c.id == id) {
let was = c.sealed;
c.sealed = false;
was
} else {
false
}
}
pub fn remove(&mut self, id: &str) -> bool {
let before = self.components.len();
self.components.retain(|c| c.id != id);
self.components.len() != before
}
pub fn clear(&mut self) {
self.components.clear();
}
pub fn len(&self) -> usize {
self.components.len()
}
pub fn is_empty(&self) -> bool {
self.components.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &InstalledComponent> {
self.components.iter()
}
}
pub const CORE_COMPONENT_IDS: &[&str] = &["rocm", "pytorch", "triton"];
pub const SEALED_INSTALL_ONCE_IDS: &[&str] = &[
"aiter",
"flash-attn",
"flash_attention",
"flash_attention_amd",
"rccl",
"migraphx",
"bitsandbytes",
];
pub fn is_core_component(id: &str) -> bool {
CORE_COMPONENT_IDS.contains(&id)
}
pub fn should_seal_component(id: &str) -> bool {
is_core_component(id) || SEALED_INSTALL_ONCE_IDS.contains(&id)
}
#[cfg(test)]
mod tests {
use super::*;
fn reg(id: &str, sealed: bool) -> InstalledComponent {
InstalledComponent {
id: id.to_string(),
version: Some("1.0".into()),
source_index: None,
location: None,
sealed,
installed_at: None,
pip_packages: vec![format!("{id}-pkg")],
}
}
#[test]
fn mark_and_lookup() {
let mut r = InstalledComponentRegistry::default();
r.mark_installed(reg("pytorch", true));
assert!(r.is_installed("pytorch"));
assert!(r.is_sealed("pytorch"));
assert!(!r.is_installed("rocm"));
assert_eq!(r.len(), 1);
}
#[test]
fn seal_is_preserved_on_remark() {
let mut r = InstalledComponentRegistry::default();
r.mark_installed(reg("pytorch", true));
let updated = InstalledComponent {
id: "pytorch".to_string(),
version: Some("2.9.0".into()),
source_index: Some("https://download.pytorch.org/whl/rocm7.6".into()),
location: None,
sealed: false,
installed_at: None,
pip_packages: vec!["torch".into(), "torchvision".into()],
};
r.mark_installed(updated);
assert!(
r.is_sealed("pytorch"),
"seal must survive a non-sealed remark"
);
let got = r.get("pytorch").expect("component present");
assert_eq!(got.version.as_deref(), Some("2.9.0"), "version must update");
assert_eq!(
got.source_index.as_deref(),
Some("https://download.pytorch.org/whl/rocm7.6")
);
assert_eq!(got.pip_packages, vec!["torch", "torchvision"]);
}
#[test]
fn unseal_is_explicit() {
let mut r = InstalledComponentRegistry::default();
r.mark_installed(reg("pytorch", true));
assert!(r.unseal("pytorch"));
assert!(!r.is_sealed("pytorch"));
}
#[test]
fn all_pip_packages_dedups() {
let mut r = InstalledComponentRegistry::default();
let mut a = reg("pytorch", true);
a.pip_packages = vec!["torch".into(), "torchvision".into()];
let mut b = reg("vllm", false);
b.pip_packages = vec!["vllm".into(), "torch".into()]; r.mark_installed(a);
r.mark_installed(b);
let pkgs = r.all_pip_packages();
let torchs = pkgs.iter().filter(|p| *p == "torch").count();
assert_eq!(torchs, 1, "torch must appear once: {pkgs:?}");
assert!(pkgs.contains(&"vllm".to_string()));
}
#[test]
fn remove_and_clear() {
let mut r = InstalledComponentRegistry::default();
r.mark_installed(reg("rocm", true));
r.mark_installed(reg("pytorch", true));
assert!(r.remove("rocm"));
assert!(!r.is_installed("rocm"));
r.clear();
assert!(r.is_empty());
}
#[test]
fn load_missing_is_empty() {
let _guard = crate::test_support::lock_env();
let dir = tempfile::tempdir().unwrap();
let saved_home = std::env::var("MLSTACK_USER_HOME").ok();
std::env::set_var(
"MLSTACK_USER_HOME",
dir.path().to_string_lossy().to_string(),
);
let r = InstalledComponentRegistry::load();
assert!(r.is_empty());
match saved_home {
Some(v) => std::env::set_var("MLSTACK_USER_HOME", v),
None => std::env::remove_var("MLSTACK_USER_HOME"),
}
}
#[test]
fn save_then_load_roundtrip() {
let _guard = crate::test_support::lock_env();
let dir = tempfile::tempdir().unwrap();
let saved_home = std::env::var("MLSTACK_USER_HOME").ok();
std::env::set_var(
"MLSTACK_USER_HOME",
dir.path().to_string_lossy().to_string(),
);
let mut r = InstalledComponentRegistry::default();
r.seal_core(
"pytorch",
Some("2.4.0".into()),
Some("https://download.pytorch.org/whl/rocm6.2".into()),
Some("/home/u/.mlstack/global".into()),
vec!["torch".into()],
Some("2026-06-24T00:00:00Z".into()),
);
r.save().unwrap();
assert!(InstalledComponentRegistry::path().exists());
let loaded = InstalledComponentRegistry::load();
assert!(loaded.is_sealed("pytorch"));
assert_eq!(
loaded.get("pytorch").unwrap().version.as_deref(),
Some("2.4.0")
);
match saved_home {
Some(v) => std::env::set_var("MLSTACK_USER_HOME", v),
None => std::env::remove_var("MLSTACK_USER_HOME"),
}
}
#[test]
fn core_ids_classify_correctly() {
assert!(is_core_component("rocm"));
assert!(is_core_component("pytorch"));
assert!(is_core_component("triton"));
assert!(!is_core_component("vllm"));
assert!(!is_core_component("deepspeed"));
}
}