Skip to main content

ggen_cli_lib/
pack_install.rs

1//! Improved pack installation with better UX and performance
2//!
3//! Features:
4//! - Real-time progress reporting
5//! - Intelligent caching
6//! - Better error handling
7//! - Dependency resolution visualization
8//! - Installation planning and preview
9
10use async_trait::async_trait;
11use futures::future::join_all;
12use std::sync::Arc;
13use tokio::sync::Semaphore;
14
15use crate::error::GgenError;
16use crate::progress::{CacheStatus, InstallationPlan, PlanStep, ProgressReporter};
17use ggen_core::marketplace::{
18    cache::{CacheConfig, CachedPack, PackCache},
19    error::Error as MarketplaceError,
20    AsyncRepository, Package, PackageId, PackageVersion,
21};
22
23/// Improved pack installer with better UX
24pub struct PackInstaller {
25    repository: Box<dyn AsyncRepository<PackageIterator = std::vec::IntoIter<Package>>>,
26    cache: PackCache,
27    progress: ProgressReporter,
28    max_concurrent_downloads: usize,
29}
30
31impl PackInstaller {
32    /// Create a new pack installer
33    pub async fn new() -> Result<Self, GgenError> {
34        // Initialize cache with default configuration
35        let cache_config = CacheConfig::default();
36        let cache = PackCache::new(cache_config)
37            .map_err(|e| GgenError::FileError(format!("Failed to initialize cache: {}", e)))?;
38
39        // Create a simple repository for testing
40        // In production, this would be configured based on user settings
41        let repository = Self::create_default_repository().await?;
42
43        Ok(Self {
44            repository,
45            cache,
46            progress: ProgressReporter::new(),
47            max_concurrent_downloads: 4,
48        })
49    }
50
51    /// Create a repository with default configuration
52    async fn create_default_repository(
53    ) -> Result<Box<dyn AsyncRepository<PackageIterator = std::vec::IntoIter<Package>>>, GgenError>
54    {
55        // For now, use a mock repository
56        // In production, this would connect to the actual marketplace
57        Ok(Box::new(TestRepository {}))
58    }
59
60    /// Install a pack with improved UX
61    pub async fn install_pack(
62        &self, pack_id: &str, force_reinstall: bool, dry_run: bool,
63    ) -> Result<InstallResult, GgenError> {
64        let progress = self.progress.clone();
65        progress.start_operation(&format!("Installing pack: {}", pack_id));
66
67        // Create installation plan first
68        let plan = self.create_installation_plan(pack_id).await?;
69
70        if dry_run {
71            progress.complete();
72            return Ok(InstallResult::DryRun(plan));
73        }
74
75        // Validate plan
76        self.validate_installation_plan(&plan).await?;
77
78        // Execute installation with progress reporting
79        let result = self.execute_installation(&plan, force_reinstall).await;
80
81        progress.complete();
82
83        result
84    }
85
86    /// Create installation plan for user preview
87    async fn create_installation_plan(&self, pack_id: &str) -> Result<InstallationPlan, GgenError> {
88        let progress = self.progress.clone();
89        progress.start_step("Resolving package", 1);
90        progress.set_total_steps(4);
91
92        // Parse package ID
93        let package_id = PackageId::new(pack_id)
94            .map_err(|e| GgenError::ValidationError(format!("Invalid pack ID: {}", e)))?;
95
96        // Get package from repository
97        let package = self
98            .repository
99            .get_package(&package_id)
100            .await
101            .map_err(|e| self.map_repository_error(e, "get package"))?;
102
103        let latest_version = package.latest_version.clone();
104
105        progress.complete_step("Resolving package");
106        progress.start_step("Resolving dependencies", 2);
107
108        // Resolve dependencies
109        let dependencies = self
110            .resolve_dependencies_tree(&package_id, &latest_version)
111            .await?;
112
113        progress.complete_step("Resolving dependencies");
114        progress.start_step("Checking cache status", 3);
115
116        // Check cache status
117        let cache_status = self
118            .check_cache_status(&package_id, &latest_version, &dependencies)
119            .await?;
120
121        progress.complete_step("Checking cache status");
122        progress.start_step("Calculating installation size", 4);
123
124        // Calculate total size
125        let total_size_mb = self.calculate_total_size(&package, &dependencies).await?;
126
127        progress.complete_step("Calculating installation size");
128
129        // Create installation steps
130        let steps = self.create_installation_steps(&package, &dependencies, total_size_mb);
131
132        // Estimate duration (rough estimate: 10MB per second)
133        let estimated_duration_seconds = (total_size_mb / 10.0).max(5.0) as u64;
134
135        Ok(InstallationPlan {
136            pack_id: pack_id.to_string(),
137            total_size_mb,
138            estimated_duration_seconds,
139            total_dependencies: dependencies.len(),
140            steps,
141            cache_status,
142        })
143    }
144
145    /// Resolve dependencies tree
146    async fn resolve_dependencies_tree(
147        &self, package_id: &PackageId, _version: &PackageVersion,
148    ) -> Result<Vec<PackageId>, GgenError> {
149        let mut resolved = Vec::new();
150        let mut visited = std::collections::HashSet::new();
151        let mut to_resolve = vec![package_id.clone()];
152
153        while let Some(id) = to_resolve.pop() {
154            if visited.contains(&id) {
155                continue;
156            }
157
158            visited.insert(id.clone());
159            resolved.push(id.clone());
160
161            // Get package dependencies
162            // In production, this would call the repository
163            let dependencies = self.get_resolved_dependencies(&id).await?;
164
165            for dep_id in dependencies {
166                if !visited.contains(&dep_id) {
167                    to_resolve.push(dep_id);
168                }
169            }
170        }
171
172        Ok(resolved)
173    }
174
175    /// Check cache status for packages
176    async fn check_cache_status(
177        &self, package_id: &PackageId, version: &PackageVersion, dependencies: &[PackageId],
178    ) -> Result<CacheStatus, GgenError> {
179        let mut cached_size_mb = 0.0;
180        let mut cache_hits = 0;
181        let mut total_packages = 1; // Main package
182
183        // Check main package cache
184        if let Some(cached) = self.cache.get(package_id, version) {
185            cached_size_mb += cached.size_bytes as f64 / 1_048_576.0;
186            cache_hits += 1;
187        }
188
189        // Check dependency caches
190        for dep_id in dependencies {
191            // Use latest version for dependencies
192            if let Ok(dep_version) = self.get_latest_version(dep_id).await {
193                if let Some(cached) = self.cache.get(dep_id, &dep_version) {
194                    cached_size_mb += cached.size_bytes as f64 / 1_048_576.0;
195                    cache_hits += 1;
196                }
197            }
198            total_packages += 1;
199        }
200
201        Ok(CacheStatus {
202            is_cached: cache_hits == total_packages,
203            cached_size_mb: Some(cached_size_mb),
204            cache_hit: cache_hits > 0,
205        })
206    }
207
208    /// Calculate total installation size
209    async fn calculate_total_size(
210        &self, _package: &Package, dependencies: &[PackageId],
211    ) -> Result<f64, GgenError> {
212        let mut total_size = 0.0;
213
214        // Main package size
215        total_size += 1.0; // Mock main package size
216
217        // Dependency sizes (estimated values)
218        for dep_id in dependencies {
219            total_size += self.get_package_size(dep_id).await?;
220        }
221
222        Ok(total_size)
223    }
224
225    /// Create installation steps plan
226    fn create_installation_steps(
227        &self, _package: &Package, dependencies: &[PackageId], total_size_mb: f64,
228    ) -> Vec<PlanStep> {
229        let mut steps = Vec::new();
230        let step_size_mb = total_size_mb / 6.0; // Distribute size across steps
231
232        steps.push(PlanStep {
233            step_number: 1,
234            name: "Validate package".to_string(),
235            description: "Checking package integrity and security".to_string(),
236            estimated_duration_ms: 1000,
237            size_mb: 0.1,
238        });
239
240        steps.push(PlanStep {
241            step_number: 2,
242            name: "Download main package".to_string(),
243            description: "Downloading primary package files".to_string(),
244            estimated_duration_ms: (step_size_mb * 1000.0) as u64,
245            size_mb: step_size_mb,
246        });
247
248        if !dependencies.is_empty() {
249            steps.push(PlanStep {
250                step_number: 3,
251                name: "Download dependencies".to_string(),
252                description: format!("Downloading {} dependency packages", dependencies.len()),
253                estimated_duration_ms: (step_size_mb * dependencies.len() as f64 * 1000.0) as u64,
254                size_mb: step_size_mb * dependencies.len() as f64,
255            });
256        }
257
258        steps.push(PlanStep {
259            step_number: 4,
260            name: "Verify signatures".to_string(),
261            description: "Cryptographic signature verification".to_string(),
262            estimated_duration_ms: 2000,
263            size_mb: 0.1,
264        });
265
266        steps.push(PlanStep {
267            step_number: 5,
268            name: "Extract files".to_string(),
269            description: "Extracting package contents to cache".to_string(),
270            estimated_duration_ms: 1500,
271            size_mb: 0.1,
272        });
273
274        steps.push(PlanStep {
275            step_number: 6,
276            name: "Update lockfile".to_string(),
277            description: "Recording installed packages in lockfile".to_string(),
278            estimated_duration_ms: 500,
279            size_mb: 0.1,
280        });
281
282        steps
283    }
284
285    /// Validate installation plan
286    async fn validate_installation_plan(&self, plan: &InstallationPlan) -> Result<(), GgenError> {
287        // Check if we have enough disk space (require 2x the size)
288        let required_space_mb = plan.total_size_mb * 2.0;
289        let available_space = self.get_available_disk_space().await?;
290
291        if available_space < required_space_mb {
292            return Err(GgenError::ValidationError(format!(
293                "Insufficient disk space: need {:.1} MB, have {:.1} MB",
294                required_space_mb, available_space
295            )));
296        }
297
298        // Check if pack is already installed (unless force_reinstall)
299        // This would be checked during actual execution
300
301        Ok(())
302    }
303
304    /// Execute installation with progress reporting
305    async fn execute_installation(
306        &self, plan: &InstallationPlan, _force_reinstall: bool,
307    ) -> Result<InstallResult, GgenError> {
308        let progress = self.progress.clone();
309        progress.set_total_steps(plan.steps.len());
310
311        let semaphore = Arc::new(Semaphore::new(self.max_concurrent_downloads));
312
313        // Step 1: Validate package
314        progress.start_step(&plan.steps[0].name, 1);
315        progress.update_step_progress(0.0, "Validating package...");
316        self.validate_package(&plan.pack_id).await?;
317        progress.complete_step(&plan.steps[0].name);
318
319        // Step 2: Download main package
320        progress.start_step(&plan.steps[1].name, 2);
321        progress.update_step_progress(0.0, "Downloading main package...");
322
323        let package_id = PackageId::new(&plan.pack_id)
324            .map_err(|e| GgenError::ValidationError(format!("Invalid pack ID: {}", e)))?;
325
326        let _package = self
327            .download_and_cache_package(&package_id, &plan.steps[1].name)
328            .await?;
329        progress.complete_step(&plan.steps[1].name);
330
331        // Step 3: Download dependencies
332        if !plan.steps[2].name.contains("empty") {
333            progress.start_step(&plan.steps[2].name, 3);
334            progress.update_step_progress(0.0, "Downloading dependencies...");
335
336            let dependency_ids = self.get_resolved_dependencies(&package_id).await?;
337            let total_deps = dependency_ids.len();
338            let download_tasks = dependency_ids.into_iter().map(|dep_id| {
339                let semaphore = semaphore.clone();
340                let progress = progress.clone();
341                async move {
342                    #[allow(clippy::unwrap_used)] // semaphore closed only on programmer error
343                    let _permit = semaphore.acquire().await.unwrap();
344                    let dep_name = format!("Dependency: {}", dep_id);
345                    progress.update_item_progress(&dep_name, 0, total_deps);
346
347                    match self.download_and_cache_package(&dep_id, &dep_name).await {
348                        Ok(_dep_package) => {
349                            progress.update_item_progress(&dep_name, 1, total_deps);
350                            Ok(())
351                        }
352                        Err(e) => {
353                            progress.report_error(
354                                &format!("Failed to download {}: {}", dep_id, e),
355                                &dep_name,
356                            );
357                            Err(e)
358                        }
359                    }
360                }
361            });
362
363            // Run downloads in parallel with limited concurrency
364            let results = join_all(download_tasks).await;
365
366            for result in results {
367                if let Err(e) = result {
368                    return Err(e);
369                }
370            }
371
372            progress.complete_step(&plan.steps[2].name);
373        }
374
375        // Step 4: Verify signatures
376        progress.start_step(&plan.steps[3].name, 4);
377        progress.update_step_progress(0.0, "Verifying signatures...");
378        self.verify_package_signatures(&package_id).await?;
379        progress.complete_step(&plan.steps[3].name);
380
381        // Step 5: Extract files
382        progress.start_step(&plan.steps[4].name, 5);
383        progress.update_step_progress(0.0, "Extracting package files...");
384        self.extract_package_files(&package_id).await?;
385        progress.complete_step(&plan.steps[4].name);
386
387        // Step 6: Update lockfile
388        progress.start_step(&plan.steps[5].name, 6);
389        progress.update_step_progress(0.0, "Updating lockfile...");
390        self.update_installation_lockfile(&plan.pack_id).await?;
391        progress.complete_step(&plan.steps[5].name);
392
393        Ok(InstallResult::Success(InstallationResult {
394            pack_id: plan.pack_id.clone(),
395            installed_packages: vec![package_id],
396            cache_status: plan.cache_status.clone(),
397            total_size_mb: plan.total_size_mb,
398            duration_ms: progress.get_state().elapsed().as_millis() as u64,
399        }))
400    }
401
402    /// Download and cache a package
403    async fn download_and_cache_package(
404        &self, package_id: &PackageId, _step_name: &str,
405    ) -> Result<CachedPack, GgenError> {
406        let progress = self.progress.clone();
407        progress.update_step_progress(0.0, &format!("Checking cache for {}", package_id));
408
409        // Check cache first
410        let latest_version = self.get_latest_version(package_id).await.map_err(|e| {
411            GgenError::ValidationError(format!("No version found for {}: {}", package_id, e))
412        })?;
413
414        if let Some(cached) = self.cache.get(package_id, &latest_version) {
415            progress
416                .update_step_progress(100.0, &format!("Using cached version of {}", package_id));
417            return Ok(cached);
418        }
419
420        // Download from repository
421        progress.update_step_progress(0.0, &format!("Downloading {}", package_id));
422
423        let package = self
424            .repository
425            .get_package_version(package_id, &latest_version)
426            .await
427            .map_err(|e| self.map_repository_error(e, "download package"))?;
428
429        // Mock download simulation (1MB default)
430        let total_size = 1.0 * 1024.0 * 1024.0;
431        let mut downloaded = 0.0;
432        let progress_step = 100.0 / 10.0; // 10 progress steps
433
434        for i in 0..10 {
435            tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
436            downloaded += total_size / 10.0;
437            progress.update_data_progress(downloaded as u64, total_size as u64);
438            progress.update_step_progress(
439                (i + 1) as f64 * progress_step,
440                &format!("Downloading {}...", i + 1),
441            );
442        }
443
444        // Cache the package
445        let cached_package = self
446            .cache_package(package_id, &latest_version, package)
447            .await?;
448
449        progress.update_step_progress(100.0, &format!("Cached {}", package_id));
450        Ok(cached_package)
451    }
452
453    /// Helper methods for repository version and size calculations
454    async fn get_resolved_dependencies(
455        &self, _package_id: &PackageId,
456    ) -> Result<Vec<PackageId>, GgenError> {
457        // Return resolved dependencies based on package ID
458        Ok(vec![])
459    }
460
461    async fn get_latest_version(
462        &self, _package_id: &PackageId,
463    ) -> Result<PackageVersion, GgenError> {
464        Ok(PackageVersion::new("1.0.0")
465            .map_err(|e| GgenError::ValidationError(format!("Invalid version: {}", e)))?)
466    }
467
468    async fn get_package_size(&self, _package_id: &PackageId) -> Result<f64, GgenError> {
469        Ok(1.0) // 1MB per package
470    }
471
472    async fn get_available_disk_space(&self) -> Result<f64, GgenError> {
473        // Return available disk space
474        Ok(1024.0)
475    }
476
477    async fn validate_package(&self, _package_id: &str) -> Result<(), GgenError> {
478        // Validate package integrity
479        Ok(())
480    }
481
482    async fn verify_package_signatures(&self, _package_id: &PackageId) -> Result<(), GgenError> {
483        // Verify signature
484        Ok(())
485    }
486
487    async fn extract_package_files(&self, _package_id: &PackageId) -> Result<(), GgenError> {
488        // Extract files
489        Ok(())
490    }
491
492    async fn update_installation_lockfile(&self, _pack_id: &str) -> Result<(), GgenError> {
493        // Update lockfile
494        Ok(())
495    }
496
497    async fn cache_package(
498        &self, _package_id: &PackageId, _version: &PackageVersion, _package: Package,
499    ) -> Result<CachedPack, GgenError> {
500        // Cache package locally
501        Err(GgenError::ValidationError(
502            "Caching logic requires repository backend implementation".to_string(),
503        ))
504    }
505
506    fn map_repository_error(&self, error: MarketplaceError, operation: &str) -> GgenError {
507        match error {
508            MarketplaceError::PackageNotFound { package_id } => GgenError::ValidationError(
509                format!("Package '{}' not found in marketplace", package_id),
510            ),
511            MarketplaceError::IoError(e) => {
512                GgenError::NetworkError(format!("Network/IO error while {}: {}", operation, e))
513            }
514            MarketplaceError::ValidationFailed { reason } => GgenError::ValidationError(format!(
515                "Validation error while {}: {}",
516                operation, reason
517            )),
518            _ => GgenError::FileError(format!("Failed to {}: {}", operation, error)),
519        }
520    }
521}
522
523/// Mock repository for testing
524struct TestRepository {}
525
526#[allow(clippy::unwrap_used)] // test fixture — panicking on invalid literals is correct
527#[async_trait]
528impl AsyncRepository for TestRepository {
529    type PackageIterator = std::vec::IntoIter<Package>;
530
531    async fn get_package(&self, package_id: &PackageId) -> Result<Package, MarketplaceError> {
532        let version = PackageVersion::new("1.0.0").unwrap();
533        let metadata = ggen_core::marketplace::PackageMetadata::new(
534            package_id.clone(),
535            format!("Pack {}", package_id),
536            "Mock description",
537            "MIT",
538        );
539        Ok(Package {
540            metadata,
541            latest_version: version.clone(),
542            versions: vec![version],
543            releases: indexmap::IndexMap::new(),
544        })
545    }
546
547    async fn get_package_version(
548        &self, package_id: &PackageId, version: &PackageVersion,
549    ) -> Result<Package, MarketplaceError> {
550        let metadata = ggen_core::marketplace::PackageMetadata::new(
551            package_id.clone(),
552            format!("Pack {}", package_id),
553            "Mock description",
554            "MIT",
555        );
556        Ok(Package {
557            metadata,
558            latest_version: version.clone(),
559            versions: vec![version.clone()],
560            releases: indexmap::IndexMap::new(),
561        })
562    }
563
564    async fn all_packages(&self) -> Result<Vec<Package>, MarketplaceError> {
565        let id = PackageId::new("test-pack").unwrap();
566        Ok(vec![self.get_package(&id).await?])
567    }
568
569    async fn list_versions(
570        &self, _id: &PackageId,
571    ) -> Result<Vec<PackageVersion>, MarketplaceError> {
572        Ok(vec![PackageVersion::new("1.0.0").unwrap()])
573    }
574
575    async fn package_exists(&self, _id: &PackageId) -> Result<bool, MarketplaceError> {
576        Ok(true)
577    }
578}
579
580/// Installation result
581#[derive(Debug, Clone, serde::Serialize)]
582pub enum InstallResult {
583    Success(InstallationResult),
584    DryRun(InstallationPlan),
585}
586
587#[derive(Debug, Clone, serde::Serialize)]
588pub struct InstallationResult {
589    pub pack_id: String,
590    pub installed_packages: Vec<PackageId>,
591    pub cache_status: CacheStatus,
592    pub total_size_mb: f64,
593    pub duration_ms: u64,
594}
595
596impl From<InstallResult> for crate::cmds::pack::InstallOutput {
597    fn from(result: InstallResult) -> Self {
598        match result {
599            InstallResult::Success(success) => crate::cmds::pack::InstallOutput {
600                pack_id: success.pack_id.clone(),
601                pack_name: success.pack_id.clone(),
602                status: "installed".to_string(),
603                message: format!(
604                    "Pack installed successfully. Size: {:.1} MB, Duration: {}ms",
605                    success.total_size_mb, success.duration_ms
606                ),
607            },
608            InstallResult::DryRun(plan) => crate::cmds::pack::InstallOutput {
609                pack_id: plan.pack_id.clone(),
610                pack_name: plan.pack_id.clone(),
611                status: "dry_run".to_string(),
612                message: format!(
613                    "Dry run: Would install {:.1} MB with {} dependencies. Estimated: {}s",
614                    plan.total_size_mb, plan.total_dependencies, plan.estimated_duration_seconds
615                ),
616            },
617        }
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[tokio::test]
626    async fn test_pack_installation_plan_creation() {
627        let installer = PackInstaller::new().await.unwrap();
628        let plan = installer
629            .create_installation_plan("test-pack")
630            .await
631            .unwrap();
632
633        assert_eq!(plan.pack_id, "test-pack");
634        assert!(plan.total_size_mb > 0.0);
635        assert!(!plan.steps.is_empty());
636        assert!(plan.estimated_duration_seconds > 0);
637    }
638
639    #[tokio::test]
640    async fn test_dry_run_installation() {
641        let installer = PackInstaller::new().await.unwrap();
642        let result = installer
643            .install_pack("test-pack", false, true)
644            .await
645            .unwrap();
646
647        match result {
648            InstallResult::DryRun(plan) => {
649                assert_eq!(plan.pack_id, "test-pack");
650            }
651            InstallResult::Success(_) => panic!("Expected dry run result"),
652        }
653    }
654}