1use std::collections::HashSet;
24use std::fs;
25use std::path::PathBuf;
26
27use serde::{Deserialize, Serialize};
28
29use crate::platform::environment::mlstack_root;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct InstalledComponent {
34 pub id: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub version: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub source_index: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub location: Option<String>,
45 #[serde(default)]
47 pub sealed: bool,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub installed_at: Option<String>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub pip_packages: Vec<String>,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct InstalledComponentRegistry {
59 #[serde(default)]
60 components: Vec<InstalledComponent>,
61}
62
63impl InstalledComponentRegistry {
64 pub fn path() -> PathBuf {
66 mlstack_root().join("installed.json")
67 }
68
69 pub fn load() -> Self {
72 let path = Self::path();
73 match fs::read_to_string(&path) {
74 Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
75 Err(_) => Self::default(),
76 }
77 }
78
79 pub fn save(&self) -> anyhow::Result<()> {
87 let path = Self::path();
88 if let Some(parent) = path.parent() {
89 fs::create_dir_all(parent)?;
90 }
91 let json = serde_json::to_string_pretty(self)?;
92 let temp_path = path.with_extension("json.tmp");
94 fs::write(&temp_path, json)?;
95 fs::rename(&temp_path, &path)?;
96 Ok(())
97 }
98
99 pub fn reload_and_save() -> anyhow::Result<()> {
101 Self::load().save()
102 }
103
104 pub fn mark_installed(&mut self, component: InstalledComponent) {
108 if let Some(existing) = self.components.iter_mut().find(|c| c.id == component.id) {
109 let preserve_seal = existing.sealed && !component.sealed;
110 *existing = component;
111 if preserve_seal {
112 existing.sealed = true;
113 }
114 } else {
115 self.components.push(component);
116 }
117 }
118
119 pub fn seal_core(
121 &mut self,
122 id: &str,
123 version: Option<String>,
124 source_index: Option<String>,
125 location: Option<String>,
126 pip_packages: Vec<String>,
127 installed_at: Option<String>,
128 ) {
129 self.mark_installed(InstalledComponent {
130 id: id.to_string(),
131 version,
132 source_index,
133 location,
134 sealed: true,
135 installed_at,
136 pip_packages,
137 });
138 }
139
140 pub fn get(&self, id: &str) -> Option<&InstalledComponent> {
142 self.components.iter().find(|c| c.id == id)
143 }
144
145 pub fn is_installed(&self, id: &str) -> bool {
147 self.components.iter().any(|c| c.id == id)
148 }
149
150 pub fn is_sealed(&self, id: &str) -> bool {
152 self.get(id).is_some_and(|c| c.sealed)
153 }
154
155 pub fn installed_ids(&self) -> HashSet<String> {
157 self.components.iter().map(|c| c.id.clone()).collect()
158 }
159
160 pub fn all_pip_packages(&self) -> Vec<String> {
162 let mut seen = HashSet::new();
163 let mut out = Vec::new();
164 for c in &self.components {
165 for p in &c.pip_packages {
166 if seen.insert(p.clone()) {
167 out.push(p.clone());
168 }
169 }
170 }
171 out
172 }
173
174 pub fn unseal(&mut self, id: &str) -> bool {
177 if let Some(c) = self.components.iter_mut().find(|c| c.id == id) {
178 let was = c.sealed;
179 c.sealed = false;
180 was
181 } else {
182 false
183 }
184 }
185
186 pub fn remove(&mut self, id: &str) -> bool {
188 let before = self.components.len();
189 self.components.retain(|c| c.id != id);
190 self.components.len() != before
191 }
192
193 pub fn clear(&mut self) {
195 self.components.clear();
196 }
197
198 pub fn len(&self) -> usize {
200 self.components.len()
201 }
202
203 pub fn is_empty(&self) -> bool {
205 self.components.is_empty()
206 }
207
208 pub fn iter(&self) -> impl Iterator<Item = &InstalledComponent> {
210 self.components.iter()
211 }
212}
213
214pub const CORE_COMPONENT_IDS: &[&str] = &["rocm", "pytorch", "triton"];
216
217pub const SEALED_INSTALL_ONCE_IDS: &[&str] = &[
221 "aiter",
222 "flash-attn",
223 "flash_attention",
224 "flash_attention_amd",
225 "rccl",
226 "migraphx",
227 "bitsandbytes",
228];
229
230pub fn is_core_component(id: &str) -> bool {
232 CORE_COMPONENT_IDS.contains(&id)
233}
234
235pub fn should_seal_component(id: &str) -> bool {
241 is_core_component(id) || SEALED_INSTALL_ONCE_IDS.contains(&id)
242}
243
244#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn reg(id: &str, sealed: bool) -> InstalledComponent {
253 InstalledComponent {
254 id: id.to_string(),
255 version: Some("1.0".into()),
256 source_index: None,
257 location: None,
258 sealed,
259 installed_at: None,
260 pip_packages: vec![format!("{id}-pkg")],
261 }
262 }
263
264 #[test]
265 fn mark_and_lookup() {
266 let mut r = InstalledComponentRegistry::default();
267 r.mark_installed(reg("pytorch", true));
268 assert!(r.is_installed("pytorch"));
269 assert!(r.is_sealed("pytorch"));
270 assert!(!r.is_installed("rocm"));
271 assert_eq!(r.len(), 1);
272 }
273
274 #[test]
275 fn seal_is_preserved_on_remark() {
276 let mut r = InstalledComponentRegistry::default();
277 r.mark_installed(reg("pytorch", true));
278 let updated = InstalledComponent {
281 id: "pytorch".to_string(),
282 version: Some("2.9.0".into()),
283 source_index: Some("https://download.pytorch.org/whl/rocm7.6".into()),
284 location: None,
285 sealed: false,
286 installed_at: None,
287 pip_packages: vec!["torch".into(), "torchvision".into()],
288 };
289 r.mark_installed(updated);
290 assert!(
291 r.is_sealed("pytorch"),
292 "seal must survive a non-sealed remark"
293 );
294 let got = r.get("pytorch").expect("component present");
295 assert_eq!(got.version.as_deref(), Some("2.9.0"), "version must update");
296 assert_eq!(
297 got.source_index.as_deref(),
298 Some("https://download.pytorch.org/whl/rocm7.6")
299 );
300 assert_eq!(got.pip_packages, vec!["torch", "torchvision"]);
301 }
302
303 #[test]
304 fn unseal_is_explicit() {
305 let mut r = InstalledComponentRegistry::default();
306 r.mark_installed(reg("pytorch", true));
307 assert!(r.unseal("pytorch"));
308 assert!(!r.is_sealed("pytorch"));
309 }
310
311 #[test]
312 fn all_pip_packages_dedups() {
313 let mut r = InstalledComponentRegistry::default();
314 let mut a = reg("pytorch", true);
315 a.pip_packages = vec!["torch".into(), "torchvision".into()];
316 let mut b = reg("vllm", false);
317 b.pip_packages = vec!["vllm".into(), "torch".into()]; r.mark_installed(a);
319 r.mark_installed(b);
320 let pkgs = r.all_pip_packages();
321 let torchs = pkgs.iter().filter(|p| *p == "torch").count();
322 assert_eq!(torchs, 1, "torch must appear once: {pkgs:?}");
323 assert!(pkgs.contains(&"vllm".to_string()));
324 }
325
326 #[test]
327 fn remove_and_clear() {
328 let mut r = InstalledComponentRegistry::default();
329 r.mark_installed(reg("rocm", true));
330 r.mark_installed(reg("pytorch", true));
331 assert!(r.remove("rocm"));
332 assert!(!r.is_installed("rocm"));
333 r.clear();
334 assert!(r.is_empty());
335 }
336
337 #[test]
338 fn load_missing_is_empty() {
339 let _guard = crate::test_support::lock_env();
341 let dir = tempfile::tempdir().unwrap();
343 let saved_home = std::env::var("MLSTACK_USER_HOME").ok();
344 std::env::set_var(
345 "MLSTACK_USER_HOME",
346 dir.path().to_string_lossy().to_string(),
347 );
348 let r = InstalledComponentRegistry::load();
349 assert!(r.is_empty());
350 match saved_home {
351 Some(v) => std::env::set_var("MLSTACK_USER_HOME", v),
352 None => std::env::remove_var("MLSTACK_USER_HOME"),
353 }
354 }
355
356 #[test]
357 fn save_then_load_roundtrip() {
358 let _guard = crate::test_support::lock_env();
360 let dir = tempfile::tempdir().unwrap();
361 let saved_home = std::env::var("MLSTACK_USER_HOME").ok();
362 std::env::set_var(
363 "MLSTACK_USER_HOME",
364 dir.path().to_string_lossy().to_string(),
365 );
366
367 let mut r = InstalledComponentRegistry::default();
368 r.seal_core(
369 "pytorch",
370 Some("2.4.0".into()),
371 Some("https://download.pytorch.org/whl/rocm6.2".into()),
372 Some("/home/u/.mlstack/global".into()),
373 vec!["torch".into()],
374 Some("2026-06-24T00:00:00Z".into()),
375 );
376 r.save().unwrap();
377 assert!(InstalledComponentRegistry::path().exists());
378
379 let loaded = InstalledComponentRegistry::load();
380 assert!(loaded.is_sealed("pytorch"));
381 assert_eq!(
382 loaded.get("pytorch").unwrap().version.as_deref(),
383 Some("2.4.0")
384 );
385
386 match saved_home {
387 Some(v) => std::env::set_var("MLSTACK_USER_HOME", v),
388 None => std::env::remove_var("MLSTACK_USER_HOME"),
389 }
390 }
391
392 #[test]
393 fn core_ids_classify_correctly() {
394 assert!(is_core_component("rocm"));
395 assert!(is_core_component("pytorch"));
396 assert!(is_core_component("triton"));
397 assert!(!is_core_component("vllm"));
398 assert!(!is_core_component("deepspeed"));
399 }
400}