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 "rccl",
225 "migraphx",
226 "bitsandbytes",
227];
228pub fn is_core_component(id: &str) -> bool {
237 CORE_COMPONENT_IDS.contains(&id)
238}
239
240pub fn should_seal_component(id: &str) -> bool {
246 is_core_component(id) || SEALED_INSTALL_ONCE_IDS.contains(&id)
247}
248
249#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn reg(id: &str, sealed: bool) -> InstalledComponent {
258 InstalledComponent {
259 id: id.to_string(),
260 version: Some("1.0".into()),
261 source_index: None,
262 location: None,
263 sealed,
264 installed_at: None,
265 pip_packages: vec![format!("{id}-pkg")],
266 }
267 }
268
269 #[test]
270 fn mark_and_lookup() {
271 let mut r = InstalledComponentRegistry::default();
272 r.mark_installed(reg("pytorch", true));
273 assert!(r.is_installed("pytorch"));
274 assert!(r.is_sealed("pytorch"));
275 assert!(!r.is_installed("rocm"));
276 assert_eq!(r.len(), 1);
277 }
278
279 #[test]
280 fn seal_is_preserved_on_remark() {
281 let mut r = InstalledComponentRegistry::default();
282 r.mark_installed(reg("pytorch", true));
283 let updated = InstalledComponent {
286 id: "pytorch".to_string(),
287 version: Some("2.9.0".into()),
288 source_index: Some("https://download.pytorch.org/whl/rocm7.6".into()),
289 location: None,
290 sealed: false,
291 installed_at: None,
292 pip_packages: vec!["torch".into(), "torchvision".into()],
293 };
294 r.mark_installed(updated);
295 assert!(
296 r.is_sealed("pytorch"),
297 "seal must survive a non-sealed remark"
298 );
299 let got = r.get("pytorch").expect("component present");
300 assert_eq!(got.version.as_deref(), Some("2.9.0"), "version must update");
301 assert_eq!(
302 got.source_index.as_deref(),
303 Some("https://download.pytorch.org/whl/rocm7.6")
304 );
305 assert_eq!(got.pip_packages, vec!["torch", "torchvision"]);
306 }
307
308 #[test]
309 fn unseal_is_explicit() {
310 let mut r = InstalledComponentRegistry::default();
311 r.mark_installed(reg("pytorch", true));
312 assert!(r.unseal("pytorch"));
313 assert!(!r.is_sealed("pytorch"));
314 }
315
316 #[test]
317 fn all_pip_packages_dedups() {
318 let mut r = InstalledComponentRegistry::default();
319 let mut a = reg("pytorch", true);
320 a.pip_packages = vec!["torch".into(), "torchvision".into()];
321 let mut b = reg("vllm", false);
322 b.pip_packages = vec!["vllm".into(), "torch".into()]; r.mark_installed(a);
324 r.mark_installed(b);
325 let pkgs = r.all_pip_packages();
326 let torchs = pkgs.iter().filter(|p| *p == "torch").count();
327 assert_eq!(torchs, 1, "torch must appear once: {pkgs:?}");
328 assert!(pkgs.contains(&"vllm".to_string()));
329 }
330
331 #[test]
332 fn remove_and_clear() {
333 let mut r = InstalledComponentRegistry::default();
334 r.mark_installed(reg("rocm", true));
335 r.mark_installed(reg("pytorch", true));
336 assert!(r.remove("rocm"));
337 assert!(!r.is_installed("rocm"));
338 r.clear();
339 assert!(r.is_empty());
340 }
341
342 #[test]
343 fn load_missing_is_empty() {
344 let _guard = crate::test_support::lock_env();
346 let dir = tempfile::tempdir().unwrap();
348 let saved_home = std::env::var("MLSTACK_USER_HOME").ok();
349 std::env::set_var(
350 "MLSTACK_USER_HOME",
351 dir.path().to_string_lossy().to_string(),
352 );
353 let r = InstalledComponentRegistry::load();
354 assert!(r.is_empty());
355 match saved_home {
356 Some(v) => std::env::set_var("MLSTACK_USER_HOME", v),
357 None => std::env::remove_var("MLSTACK_USER_HOME"),
358 }
359 }
360
361 #[test]
362 fn save_then_load_roundtrip() {
363 let _guard = crate::test_support::lock_env();
365 let dir = tempfile::tempdir().unwrap();
366 let saved_home = std::env::var("MLSTACK_USER_HOME").ok();
367 std::env::set_var(
368 "MLSTACK_USER_HOME",
369 dir.path().to_string_lossy().to_string(),
370 );
371
372 let mut r = InstalledComponentRegistry::default();
373 r.seal_core(
374 "pytorch",
375 Some("2.4.0".into()),
376 Some("https://download.pytorch.org/whl/rocm6.2".into()),
377 Some("/home/u/.mlstack/global".into()),
378 vec!["torch".into()],
379 Some("2026-06-24T00:00:00Z".into()),
380 );
381 r.save().unwrap();
382 assert!(InstalledComponentRegistry::path().exists());
383
384 let loaded = InstalledComponentRegistry::load();
385 assert!(loaded.is_sealed("pytorch"));
386 assert_eq!(
387 loaded.get("pytorch").unwrap().version.as_deref(),
388 Some("2.4.0")
389 );
390
391 match saved_home {
392 Some(v) => std::env::set_var("MLSTACK_USER_HOME", v),
393 None => std::env::remove_var("MLSTACK_USER_HOME"),
394 }
395 }
396
397 #[test]
398 fn core_ids_classify_correctly() {
399 assert!(is_core_component("rocm"));
400 assert!(is_core_component("pytorch"));
401 assert!(is_core_component("triton"));
402 assert!(!is_core_component("vllm"));
403 assert!(!is_core_component("deepspeed"));
404 }
405}