Skip to main content

rusty_stack/orchestrator/
migration.rs

1//! Migration Wave 1 — Rust equivalents of shell logic.
2//!
3//! This module provides consolidated Rust implementations of the read-heavy
4//! shell logic from `update_helper.sh` and `update_stack.sh`:
5//!
6//! - **Version lookup** — `up_get_version()` equivalent (VAL-MIGR-001, VAL-MIGR-002)
7//! - **Selection plan** — `update_stack.sh` menu logic (VAL-MIGR-003, VAL-MIGR-004)
8//! - **Manifest freshness** — TTL and offline tolerance (VAL-MIGR-005, VAL-MIGR-006)
9//! - **Consolidated detection** — `up_detect_installed()` equivalent (VAL-MIGR-007)
10//! - **Partial installation** — recognition of partially installed state (VAL-MIGR-008)
11//!
12//! # Shell Parity
13//!
14//! Each function in this module is designed to produce identical output to its
15//! shell counterpart. The tests in `tests/migration_wave1.rs` verify this parity.
16
17use crate::core::manifest::{self, Manifest, ManifestFetcher, ManifestSource, ResolvedManifest};
18use crate::orchestrator::planner::{CompatibilityContext, PlannerOptions, UpdatePlanner};
19use crate::platform::registry;
20
21use std::collections::{HashMap, HashSet};
22use std::path::Path;
23
24// ===========================================================================
25// Version Lookup (VAL-MIGR-001, VAL-MIGR-002)
26// ===========================================================================
27
28/// Rust equivalent of `up_get_version()` from `update_helper.sh`.
29///
30/// Returns the version string for a component, matching the shell behavior:
31/// - ROCm: version from `/opt/rocm/.info/version` or `rocminfo --version`
32/// - Python modules: `__version__` attribute or "not installed"
33/// - Git-based: `git log -1 --format="%h %s"` or "installed" or "not installed"
34/// - Command-based: first line of `--version` output or "not installed"
35/// - permanent-env: "installed" or "not installed"
36/// - Unknown: "unknown"
37///
38/// # Errors
39///
40/// Unknown component IDs return "unknown" (structured, not panic).
41/// This matches the shell's `*) echo "unknown" ;;` default case.
42pub fn migrated_get_version(component_id: &str) -> String {
43    registry::get_version(component_id)
44}
45
46/// Rust equivalent of `up_get_version()` with explicit home directory.
47///
48/// Same as `migrated_get_version` but uses the provided home directory
49/// for git-based and path-based components.
50pub fn migrated_get_version_with_home(component_id: &str, home: &Path) -> String {
51    registry::get_version_with_home(component_id, home)
52}
53
54/// Rust equivalent of `up_get_versions_batch()` from `update_helper.sh`.
55///
56/// Queries versions for multiple Python ML modules in a single subprocess.
57/// Returns `component_id=version` pairs.
58///
59/// # Differences from Shell
60///
61/// The shell function takes the python interpreter as the first argument.
62/// This Rust version auto-discovers interpreters via `python_interpreters()`.
63pub fn migrated_get_versions_batch(component_ids: &[&str]) -> Vec<registry::VersionInfo> {
64    registry::get_versions_batch(component_ids)
65}
66
67// ===========================================================================
68// Selection Plan (VAL-MIGR-003, VAL-MIGR-004)
69// ===========================================================================
70
71/// Rust equivalent of the `update_stack.sh` menu construction logic.
72///
73/// Builds a selection plan from the manifest and current system state.
74/// The plan includes:
75/// - Ordered list of components (matching shell menu order)
76/// - Preselected defaults (safe updates preselected, others not)
77/// - Classification (safe/guarded/blocked/candidate/experimental)
78///
79/// # Channel Support
80///
81/// When the ROCm channel changes, calling this again produces a fresh plan
82/// with no stale entries from the previous channel.
83pub fn migrated_build_selection_plan(
84    manifest: &Manifest,
85    context: &CompatibilityContext,
86    options: &PlannerOptions,
87) -> Result<Vec<PlannerItem>, crate::orchestrator::planner::PlannerError> {
88    let planner = UpdatePlanner::new();
89    planner.build_plan(manifest, context, options)
90}
91
92// Re-export PlannerItem for convenience
93pub use crate::orchestrator::planner::PlannerItem;
94
95/// Build a selection plan with automatic system detection.
96///
97/// This is the highest-level entry point that combines:
98/// 1. Hardware detection
99/// 2. Component detection
100/// 3. Version querying
101/// 4. Plan construction
102///
103/// Returns the plan items ready for display to the user.
104pub fn build_plan_from_system(
105    manifest: &Manifest,
106    rocm_channel: &str,
107    include_experimental: bool,
108) -> Result<Vec<PlannerItem>, crate::orchestrator::planner::PlannerError> {
109    // Detect installed components
110    let installed_ids = registry::detect_all_installed();
111    let mut installed_versions = HashMap::new();
112    let mut installed_set = HashSet::new();
113
114    for id in &installed_ids {
115        let v = registry::get_version(id);
116        installed_versions.insert(id.clone(), v);
117        installed_set.insert(id.clone());
118    }
119
120    // Detect ROCm version
121    let rocm_version = installed_versions.get("rocm").cloned().unwrap_or_default();
122
123    // Build context
124    let context = CompatibilityContext {
125        rocm_version,
126        rocm_channel: rocm_channel.to_string(),
127        available_executors: HashSet::from([
128            crate::core::types::ExecutorKind::LegacyScript,
129            crate::core::types::ExecutorKind::Rust,
130        ]),
131        installed_components: installed_set,
132        installed_versions,
133        gpu_architecture: String::new(),
134        runtime_version: env!("CARGO_PKG_VERSION").to_string(),
135    };
136
137    let options = PlannerOptions {
138        include_experimental,
139        ..PlannerOptions::default()
140    };
141
142    migrated_build_selection_plan(manifest, &context, &options)
143}
144
145// ===========================================================================
146// Manifest Freshness (VAL-MIGR-005, VAL-MIGR-006)
147// ===========================================================================
148
149/// Result of a manifest freshness check.
150#[derive(Debug, Clone)]
151pub struct FreshnessResult {
152    /// Whether the manifest is fresh (not stale).
153    pub is_fresh: bool,
154    /// Source of the manifest.
155    pub source: ManifestSource,
156    /// Warning message if the manifest is stale but usable.
157    pub warning: Option<String>,
158    /// Whether a re-fetch was triggered.
159    pub refetch_triggered: bool,
160}
161
162/// Check manifest freshness and resolve using the fallback chain.
163///
164/// This is the Rust equivalent of the shell manifest freshness logic:
165/// 1. Try to fetch fresh remote manifest
166/// 2. If TTL expired, trigger re-fetch
167/// 3. If offline, use cached manifest with warning
168/// 4. If all else fails, use bundled baseline
169///
170/// # VAL-MIGR-005: Cache Expiry
171///
172/// If the cached manifest's TTL has expired, a re-fetch is triggered.
173/// The `refetch_triggered` field will be `true`.
174///
175/// # VAL-MIGR-006: Offline Tolerance
176///
177/// If the network is unavailable but a stale manifest exists, the operation
178/// proceeds with a warning. The `warning` field will contain the message.
179pub fn check_manifest_freshness(fetcher: &dyn ManifestFetcher) -> FreshnessResult {
180    // Try to fetch fresh remote
181    if let Some(remote) = fetcher.fetch_remote() {
182        if remote.verify_trust().is_ok() {
183            return FreshnessResult {
184                is_fresh: true,
185                source: ManifestSource::FreshRemote,
186                warning: None,
187                refetch_triggered: false,
188            };
189        }
190        // Fresh fetch succeeded but trust check failed
191        // This triggers a re-fetch attempt (which would also fail)
192    }
193
194    // Try cached manifest
195    if let Some(_cached) = fetcher.load_cached() {
196        return FreshnessResult {
197            is_fresh: false,
198            source: ManifestSource::CachedRemote,
199            warning: Some(
200                "Using cached manifest (remote unavailable or trust check failed)".to_string(),
201            ),
202            refetch_triggered: true,
203        };
204    }
205
206    // Fall back to baseline
207    FreshnessResult {
208        is_fresh: false,
209        source: ManifestSource::Baseline,
210        warning: Some("Using bundled baseline manifest (no remote, no cache)".to_string()),
211        refetch_triggered: true,
212    }
213}
214
215/// Resolve manifest with freshness checking.
216///
217/// Combines freshness check with actual manifest resolution.
218/// Returns the resolved manifest and freshness information.
219pub fn resolve_with_freshness(
220    fetcher: &dyn ManifestFetcher,
221) -> (ResolvedManifest, FreshnessResult) {
222    let freshness = check_manifest_freshness(fetcher);
223    let resolved = manifest::resolve_manifest(fetcher);
224    (resolved, freshness)
225}
226
227// ===========================================================================
228// Consolidated Detection (VAL-MIGR-007, VAL-MIGR-008)
229// ===========================================================================
230
231/// Installation status of a component, with partial installation recognition.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum ComponentInstallStatus {
234    /// Fully installed and functional.
235    Installed,
236    /// Not installed at all.
237    NotInstalled,
238    /// Partially installed — needs update to complete installation.
239    NeedsUpdate,
240}
241
242/// Detection result for a single component.
243#[derive(Debug, Clone)]
244pub struct DetectionResult {
245    /// Component ID.
246    pub component_id: String,
247    /// Installation status.
248    pub status: ComponentInstallStatus,
249    /// Version string (if installed).
250    pub version: String,
251    /// Detection method used.
252    pub detection_method: String,
253}
254
255/// Rust equivalent of `up_detect_installed()` from `update_helper.sh`.
256///
257/// Detects all installed components using the consolidated detection pipeline.
258/// This matches the shell's `up_detect_installed` function which:
259/// 1. Checks ROCm via version file / rocminfo
260/// 2. Detects Python modules in a single subprocess
261/// 3. Checks git-based components
262/// 4. Checks system tools (rocm-smi)
263/// 5. Checks permanent-env
264///
265/// # VAL-MIGR-007: Consolidated Detector
266///
267/// The detection results must match the shell for all components.
268pub fn migrated_detect_all() -> Vec<DetectionResult> {
269    let home = crate::platform::environment::resolve_user_home();
270    migrated_detect_all_with_home(&home)
271}
272
273/// Detect all installed components with explicit home directory (testable).
274pub fn migrated_detect_all_with_home(home: &Path) -> Vec<DetectionResult> {
275    let installed_ids = registry::detect_all_installed_with_home(home);
276    let mut results = Vec::new();
277
278    for info in registry::known_components() {
279        let version = registry::get_version_with_home(&info.id, home);
280        let is_installed = installed_ids.contains(&info.id);
281
282        let status = if is_installed {
283            ComponentInstallStatus::Installed
284        } else {
285            check_partial_installation(&info.id, home)
286        };
287
288        results.push(DetectionResult {
289            component_id: info.id.clone(),
290            status,
291            version,
292            detection_method: format!("{:?}", info.detection_method),
293        });
294    }
295
296    results
297}
298
299/// Detect a single component's installation status.
300///
301/// # VAL-MIGR-008: Partial Installation Recognition
302///
303/// Partially installed components are reported as `NeedsUpdate`,
304/// not `Installed` or `NotInstalled`.
305pub fn detect_component_status(component_id: &str) -> DetectionResult {
306    let home = crate::platform::environment::resolve_user_home();
307    detect_component_status_with_home(component_id, &home)
308}
309
310/// Detect a single component's status with explicit home directory.
311pub fn detect_component_status_with_home(component_id: &str, home: &Path) -> DetectionResult {
312    let info = registry::get_component(component_id);
313    let is_installed = registry::is_component_installed_with_home(component_id, home);
314    let version = registry::get_version_with_home(component_id, home);
315
316    let status = if is_installed {
317        ComponentInstallStatus::Installed
318    } else {
319        check_partial_installation(component_id, home)
320    };
321
322    DetectionResult {
323        component_id: component_id.to_string(),
324        status,
325        version,
326        detection_method: info
327            .map(|i| format!("{:?}", i.detection_method))
328            .unwrap_or_else(|| "Unknown".to_string()),
329    }
330}
331
332/// Check if a component is partially installed.
333///
334/// A component is "partially installed" when some indicators of installation
335/// are present but the component is not fully functional. Examples:
336/// - ROCm: version file exists but rocminfo doesn't work
337/// - Git-based: directory exists but no `.git` directory
338/// - permanent-env: file exists but missing required markers
339///
340/// Returns `NeedsUpdate` if partially installed, `NotInstalled` otherwise.
341fn check_partial_installation(component_id: &str, home: &Path) -> ComponentInstallStatus {
342    match component_id {
343        "rocm" => {
344            // Partial: version file exists but rocminfo doesn't work
345            let version_file = Path::new("/opt/rocm/.info/version").exists();
346            let rocminfo_works = std::process::Command::new("rocminfo")
347                .arg("--version")
348                .output()
349                .map(|o| o.status.success())
350                .unwrap_or(false);
351
352            if version_file && !rocminfo_works {
353                ComponentInstallStatus::NeedsUpdate
354            } else {
355                ComponentInstallStatus::NotInstalled
356            }
357        }
358        "comfyui" | "vllm-studio" | "textgen" => {
359            // Partial: directory exists but no .git
360            let clone_dir = match component_id {
361                "comfyui" => "ComfyUI",
362                "vllm-studio" => "vllm-studio",
363                "textgen" => "text-generation-webui",
364                _ => return ComponentInstallStatus::NotInstalled,
365            };
366            let dir = home.join(clone_dir);
367            if dir.exists() && !dir.join(".git").exists() {
368                ComponentInstallStatus::NeedsUpdate
369            } else {
370                ComponentInstallStatus::NotInstalled
371            }
372        }
373        "permanent-env" => {
374            // Partial: file exists but missing marker
375            let env_file = home.join(".mlstack_env");
376            if env_file.exists() {
377                let content = std::fs::read_to_string(&env_file).unwrap_or_default();
378                if !content.contains("MLSTACK_PYTHON_BIN") {
379                    ComponentInstallStatus::NeedsUpdate
380                } else {
381                    ComponentInstallStatus::NotInstalled
382                }
383            } else {
384                ComponentInstallStatus::NotInstalled
385            }
386        }
387        _ => ComponentInstallStatus::NotInstalled,
388    }
389}
390
391// ===========================================================================
392// Tests
393// ===========================================================================
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    // -----------------------------------------------------------------------
400    // VAL-MIGR-001: Version Lookup — Shell Parity
401    // -----------------------------------------------------------------------
402
403    #[test]
404    fn test_migrated_get_version_all_components_no_panic() {
405        let ids = [
406            "rocm",
407            "pytorch",
408            "triton",
409            "deepspeed",
410            "vllm",
411            "aiter",
412            "onnx",
413            "bitsandbytes",
414            "migraphx",
415            "flash-attn",
416            "mpi4py",
417            "wandb",
418            "comfyui",
419            "vllm-studio",
420            "textgen",
421            "rocm-smi",
422            "permanent-env",
423        ];
424
425        for id in &ids {
426            let v = migrated_get_version(id);
427            assert!(!v.is_empty(), "version for '{id}' must not be empty");
428        }
429    }
430
431    // -----------------------------------------------------------------------
432    // VAL-MIGR-002: Unknown Component Graceful Handling
433    // -----------------------------------------------------------------------
434
435    #[test]
436    fn test_migrated_get_version_unknown_returns_unknown() {
437        assert_eq!(migrated_get_version("nonexistent"), "unknown");
438        assert_eq!(migrated_get_version("fake-component"), "unknown");
439        assert_eq!(migrated_get_version(""), "unknown");
440    }
441
442    // -----------------------------------------------------------------------
443    // VAL-MIGR-007: Consolidated Detection
444    // -----------------------------------------------------------------------
445
446    #[test]
447    fn test_migrated_detect_all_returns_valid_results() {
448        let results = migrated_detect_all();
449
450        // All results must have valid component IDs
451        for result in &results {
452            assert!(
453                registry::is_known_component(&result.component_id),
454                "result component must be known: {}",
455                result.component_id
456            );
457        }
458    }
459
460    // -----------------------------------------------------------------------
461    // VAL-MIGR-008: Partial Installation Recognition
462    // -----------------------------------------------------------------------
463
464    #[test]
465    fn test_partial_installation_detection_with_temp_dir() {
466        let temp_dir = tempfile::tempdir().unwrap();
467        let home = temp_dir.path();
468
469        // Test git-based partial: directory exists, no .git
470        let comfyui_dir = home.join("ComfyUI");
471        std::fs::create_dir_all(&comfyui_dir).unwrap();
472
473        let result = detect_component_status_with_home("comfyui", home);
474        assert_eq!(
475            result.status,
476            ComponentInstallStatus::NeedsUpdate,
477            "ComfyUI dir without .git must be NeedsUpdate"
478        );
479
480        // Complete the installation
481        std::fs::create_dir_all(comfyui_dir.join(".git")).unwrap();
482        let result = detect_component_status_with_home("comfyui", home);
483        assert_eq!(
484            result.status,
485            ComponentInstallStatus::Installed,
486            "ComfyUI with .git must be Installed"
487        );
488    }
489
490    #[test]
491    fn test_partial_installation_permanent_env() {
492        let temp_dir = tempfile::tempdir().unwrap();
493        let home = temp_dir.path();
494
495        // No file at all
496        let result = detect_component_status_with_home("permanent-env", home);
497        assert_eq!(result.status, ComponentInstallStatus::NotInstalled);
498
499        // File exists but no marker
500        let env_file = home.join(".mlstack_env");
501        std::fs::write(&env_file, "# empty").unwrap();
502        let result = detect_component_status_with_home("permanent-env", home);
503        assert_eq!(
504            result.status,
505            ComponentInstallStatus::NeedsUpdate,
506            "env file without marker must be NeedsUpdate"
507        );
508
509        // File with marker
510        std::fs::write(&env_file, "export MLSTACK_PYTHON_BIN=/usr/bin/python3").unwrap();
511        let result = detect_component_status_with_home("permanent-env", home);
512        assert_eq!(
513            result.status,
514            ComponentInstallStatus::Installed,
515            "env file with marker must be Installed"
516        );
517    }
518
519    // -----------------------------------------------------------------------
520    // VAL-MIGR-005/006: Manifest Freshness
521    // -----------------------------------------------------------------------
522
523    struct MockFetcher {
524        remote: Option<Manifest>,
525        cached: Option<Manifest>,
526    }
527
528    impl ManifestFetcher for MockFetcher {
529        fn fetch_remote(&self) -> Option<Manifest> {
530            self.remote.clone()
531        }
532        fn load_cached(&self) -> Option<Manifest> {
533            self.cached.clone()
534        }
535    }
536
537    fn make_signed_manifest() -> Manifest {
538        let mut m = Manifest {
539            schema_version: manifest::CURRENT_SCHEMA_VERSION,
540            sequence: 1,
541            generated_at: chrono::Utc::now().to_rfc3339(),
542            expires_at: None,
543            min_runtime_version: String::new(),
544            components: vec![],
545            signature: None,
546        };
547        m.signature = Some(m.compute_signature());
548        m
549    }
550
551    #[test]
552    fn test_freshness_check_fresh_remote() {
553        let fetcher = MockFetcher {
554            remote: Some(make_signed_manifest()),
555            cached: None,
556        };
557        let result = check_manifest_freshness(&fetcher);
558        assert!(result.is_fresh);
559        assert_eq!(result.source, ManifestSource::FreshRemote);
560        assert!(result.warning.is_none());
561    }
562
563    #[test]
564    fn test_freshness_check_offline_with_cache() {
565        let fetcher = MockFetcher {
566            remote: None,
567            cached: Some(make_signed_manifest()),
568        };
569        let result = check_manifest_freshness(&fetcher);
570        assert!(!result.is_fresh);
571        assert_eq!(result.source, ManifestSource::CachedRemote);
572        assert!(result.warning.is_some());
573        assert!(result.refetch_triggered);
574    }
575
576    #[test]
577    fn test_freshness_check_offline_no_cache() {
578        let fetcher = MockFetcher {
579            remote: None,
580            cached: None,
581        };
582        let result = check_manifest_freshness(&fetcher);
583        assert!(!result.is_fresh);
584        assert_eq!(result.source, ManifestSource::Baseline);
585        assert!(result.warning.is_some());
586    }
587}