agpm_cli/installer/
mod.rs

1//! Shared installation utilities for AGPM resources.
2//!
3//! This module provides common functionality for installing resources from
4//! lockfile entries to the project directory. It's shared between the install
5//! and update commands to avoid code duplication. The module includes both
6//! installation logic and automatic cleanup of removed or relocated artifacts.
7//!
8//! # SHA-Based Parallel Installation Architecture
9//!
10//! The installer uses SHA-based worktrees for optimal parallel resource installation:
11//! - **SHA-based worktrees**: Each unique commit gets one worktree for maximum deduplication
12//! - **Pre-resolved SHAs**: All versions resolved to SHAs before installation begins
13//! - **Concurrency control**: Direct parallelism control via --max-parallel flag
14//! - **Context-aware logging**: Each operation includes dependency name for debugging
15//! - **Efficient cleanup**: Worktrees are managed by the cache layer for reuse
16//! - **Pre-warming**: Worktrees created upfront to minimize installation latency
17//! - **Automatic artifact cleanup**: Removes old files when paths change or dependencies are removed
18//!
19//! # Installation Process
20//!
21//! 1. **SHA validation**: Ensures all resources have valid 40-character commit SHAs
22//! 2. **Worktree pre-warming**: Creates SHA-based worktrees for all unique commits
23//! 3. **Parallel processing**: Installs multiple resources concurrently using dedicated worktrees
24//! 4. **Content validation**: Validates markdown format and structure
25//! 5. **Atomic installation**: Files are written atomically to prevent corruption
26//! 6. **Progress tracking**: Real-time progress updates during parallel operations
27//! 7. **Artifact cleanup**: Automatically removes old files from previous installations when paths change
28//!
29//! # Artifact Cleanup (v0.3.18+)
30//!
31//! The module provides automatic cleanup of obsolete artifacts when:
32//! - **Dependencies are removed**: Files from removed dependencies are deleted
33//! - **Paths are relocated**: Old files are removed when `installed_at` paths change
34//! - **Structure changes**: Empty parent directories are cleaned up recursively
35//!
36//! The cleanup process:
37//! 1. Compares old and new lockfiles to identify removed artifacts
38//! 2. Removes files that exist in the old lockfile but not in the new one
39//! 3. Recursively removes empty parent directories up to `.claude/`
40//! 4. Reports the number of cleaned artifacts to the user
41//!
42//! See [`cleanup_removed_artifacts()`] for implementation details.
43//!
44//! # Performance Characteristics
45//!
46//! - **SHA-based deduplication**: Multiple refs to same commit share one worktree
47//! - **Parallel processing**: Multiple dependencies installed simultaneously
48//! - **Pre-warming optimization**: Worktrees created upfront to minimize latency
49//! - **Parallelism-controlled**: User controls concurrency via --max-parallel flag
50//! - **Atomic operations**: Fast, safe file installation with proper error handling
51//! - **Reduced disk usage**: No duplicate worktrees for identical commits
52//! - **Efficient cleanup**: Minimal overhead for artifact cleanup operations
53
54use crate::utils::progress::{InstallationPhase, MultiPhaseProgress};
55use anyhow::Result;
56
57mod cleanup;
58mod context;
59mod gitignore;
60mod resource;
61mod selective;
62
63#[cfg(test)]
64mod tests;
65
66pub use cleanup::cleanup_removed_artifacts;
67pub use context::InstallContext;
68pub use gitignore::{add_path_to_gitignore, update_gitignore};
69pub use selective::*;
70
71use resource::{
72    apply_resource_patches, compute_file_checksum, read_source_content, render_resource_content,
73    should_skip_installation, validate_markdown_content, write_resource_to_disk,
74};
75
76/// Type alias for complex installation result tuples to improve code readability.
77///
78/// This type alias simplifies the return type of parallel installation functions
79/// that need to return either success information or error details with context.
80/// It was introduced in AGPM v0.3.0 to resolve `clippy::type_complexity` warnings
81/// while maintaining clear semantics for installation results.
82///
83/// # Success Variant: `Ok((String, bool, String, Option<String>))`
84///
85/// When installation succeeds, the tuple contains:
86/// - `String`: Resource name that was processed
87/// - `bool`: Whether the resource was actually installed (`true`) or already up-to-date (`false`)
88/// - `String`: SHA-256 checksum of the installed file content
89/// - `Option<String>`: SHA-256 checksum of the template rendering inputs, or None for non-templated resources
90///
91/// # Error Variant: `Err((String, anyhow::Error))`
92///
93/// When installation fails, the tuple contains:
94/// - `String`: Resource name that failed to install
95/// - `anyhow::Error`: Detailed error information for debugging
96///
97/// # Usage
98///
99/// This type is primarily used in parallel installation operations where
100/// individual resource results need to be collected and processed:
101///
102/// ```rust,ignore
103/// use agpm_cli::installer::InstallResult;
104/// use futures::stream::{self, StreamExt};
105///
106/// # async fn example() -> anyhow::Result<()> {
107/// let results: Vec<InstallResult> = stream::iter(vec!["resource1", "resource2"])
108///     .map(|resource_name| async move {
109///         // Installation logic here
110///         Ok((resource_name.to_string(), true, "sha256:abc123".to_string()))
111///     })
112///     .buffer_unordered(10)
113///     .collect()
114///     .await;
115///
116/// // Process results
117/// for result in results {
118///     match result {
119///         Ok((name, installed, checksum)) => {
120///             println!("✓ {}: installed={}, checksum={}", name, installed, checksum);
121///         }
122///         Err((name, error)) => {
123///             eprintln!("✗ {}: {}", name, error);
124///         }
125///     }
126/// }
127/// # Ok(())
128/// # }
129/// ```
130///
131/// # Design Rationale
132///
133/// The type alias serves several purposes:
134/// - **Clippy compliance**: Resolves `type_complexity` warnings for complex generic types
135/// - **Code clarity**: Makes function signatures more readable and self-documenting
136/// - **Error context**: Preserves resource name context when installation fails
137/// - **Batch processing**: Enables efficient collection and processing of parallel results
138type InstallResult = Result<
139    (
140        crate::lockfile::ResourceId,
141        bool,
142        String,
143        Option<String>,
144        crate::manifest::patches::AppliedPatches,
145    ),
146    (crate::lockfile::ResourceId, anyhow::Error),
147>;
148
149/// Results from a successful installation operation.
150///
151/// This struct encapsulates all the data returned from installing resources,
152/// providing a more readable and maintainable alternative to the complex 4-tuple
153/// that previously triggered clippy::type_complexity warnings.
154///
155/// # Fields
156///
157/// - **installed_count**: Number of resources that were successfully installed
158/// - **checksums**: File checksums for each installed resource (ResourceId -> SHA256)
159/// - **context_checksums**: Template context checksums for each resource (ResourceId -> SHA256 or None)
160/// - **applied_patches**: List of applied patches for each resource (ResourceId -> AppliedPatches)
161#[derive(Debug, Clone)]
162pub struct InstallationResults {
163    /// Number of resources that were successfully installed
164    pub installed_count: usize,
165    /// File checksums for each installed resource
166    pub checksums: Vec<(crate::lockfile::ResourceId, String)>,
167    /// Template context checksums for each resource (None if no templating used)
168    pub context_checksums: Vec<(crate::lockfile::ResourceId, Option<String>)>,
169    /// Applied patch information for each resource
170    pub applied_patches:
171        Vec<(crate::lockfile::ResourceId, crate::manifest::patches::AppliedPatches)>,
172}
173
174impl InstallationResults {
175    /// Creates a new InstallationResults instance.
176    ///
177    /// # Arguments
178    ///
179    /// * `installed_count` - Number of successfully installed resources
180    /// * `checksums` - File checksums for each installed resource
181    /// * `context_checksums` - Template context checksums for each resource
182    /// * `applied_patches` - Applied patch information for each resource
183    pub fn new(
184        installed_count: usize,
185        checksums: Vec<(crate::lockfile::ResourceId, String)>,
186        context_checksums: Vec<(crate::lockfile::ResourceId, Option<String>)>,
187        applied_patches: Vec<(
188            crate::lockfile::ResourceId,
189            crate::manifest::patches::AppliedPatches,
190        )>,
191    ) -> Self {
192        Self {
193            installed_count,
194            checksums,
195            context_checksums,
196            applied_patches,
197        }
198    }
199
200    /// Returns true if no resources were installed.
201    pub fn is_empty(&self) -> bool {
202        self.installed_count == 0
203    }
204
205    /// Returns the number of installed resources.
206    pub fn len(&self) -> usize {
207        self.installed_count
208    }
209}
210
211use futures::{
212    future,
213    stream::{self, StreamExt},
214};
215use std::path::Path;
216use std::sync::Arc;
217use tokio::sync::Mutex;
218
219use crate::cache::Cache;
220use crate::core::ResourceIterator;
221use crate::lockfile::{LockFile, LockedResource};
222use crate::manifest::Manifest;
223use crate::utils::progress::ProgressBar;
224use std::collections::HashSet;
225
226/// Install a single resource from a lock entry using worktrees for parallel safety.
227///
228/// This function installs a resource specified by a lockfile entry to the project
229/// directory. It uses Git worktrees through the cache layer to enable safe parallel
230/// operations without conflicts between concurrent installations.
231///
232/// # Arguments
233///
234/// * `entry` - The locked resource to install containing source and version info
235/// * `resource_dir` - The subdirectory name for this resource type (e.g., "agents")
236/// * `context` - Installation context containing project configuration and cache instance
237///
238/// # Returns
239///
240/// Returns `Ok((installed, file_checksum, context_checksum, applied_patches))` where:
241/// - `installed` is `true` if the resource was actually installed (new or updated),
242///   `false` if the resource already existed and was unchanged
243/// - `file_checksum` is the SHA-256 hash of the installed file content (after rendering)
244/// - `context_checksum` is the SHA-256 hash of the template rendering inputs, or None for non-templated resources
245/// - `applied_patches` contains information about any patches that were applied during installation
246///
247/// # Worktree Usage
248///
249/// For remote resources, this function:
250/// 1. Uses `cache.get_or_clone_source_worktree_with_context()` to get a worktree
251/// 2. Each dependency gets its own isolated worktree for parallel safety
252/// 3. Worktrees are automatically managed and reused by the cache layer
253/// 4. Context (dependency name) is provided for debugging parallel operations
254///
255/// # Installation Process
256///
257/// 1. **Path resolution**: Determines destination based on `installed_at` or defaults
258/// 2. **Repository access**: Gets worktree from cache (for remote) or validates local path
259/// 3. **Content validation**: Verifies markdown format and structure
260/// 4. **Atomic write**: Installs file atomically to prevent corruption
261///
262/// # Examples
263///
264/// ```rust,no_run
265/// use agpm_cli::installer::{install_resource, InstallContext};
266/// use agpm_cli::lockfile::LockedResourceBuilder;
267/// use agpm_cli::cache::Cache;
268/// use agpm_cli::core::ResourceType;
269/// use std::path::Path;
270///
271/// # async fn example() -> anyhow::Result<()> {
272/// let cache = Cache::new()?;
273/// let entry = LockedResourceBuilder::new(
274///     "example-agent".to_string(),
275///     "agents/example.md".to_string(),
276///     "sha256:...".to_string(),
277///     ".claude/agents/example.md".to_string(),
278///     ResourceType::Agent,
279/// )
280/// .source(Some("community".to_string()))
281/// .url(Some("https://github.com/example/repo.git".to_string()))
282/// .version(Some("v1.0.0".to_string()))
283/// .resolved_commit(Some("abc123".to_string()))
284/// .tool(Some("claude-code".to_string()))
285/// .build();
286///
287/// let context = InstallContext::new(Path::new("."), &cache, false, false, None, None, None, None, None, None, None);
288/// let (installed, checksum, _old_checksum, _patches) = install_resource(&entry, "agents", &context).await?;
289/// if installed {
290///     println!("Resource was installed with checksum: {}", checksum);
291/// } else {
292///     println!("Resource already existed and was unchanged");
293/// }
294/// # Ok(())
295/// # }
296/// ```
297///
298/// # Error Handling
299///
300/// Returns an error if:
301/// - The source repository cannot be accessed or cloned
302/// - The specified file path doesn't exist in the repository
303/// - The file is not valid markdown format
304/// - File system operations fail (permissions, disk space)
305/// - Worktree creation fails due to Git issues
306pub async fn install_resource(
307    entry: &LockedResource,
308    resource_dir: &str,
309    context: &InstallContext<'_>,
310) -> Result<(bool, String, Option<String>, crate::manifest::patches::AppliedPatches)> {
311    // Determine destination path
312    let dest_path = if entry.installed_at.is_empty() {
313        context.project_dir.join(resource_dir).join(format!("{}.md", entry.name))
314    } else {
315        context.project_dir.join(&entry.installed_at)
316    };
317
318    // Check if file already exists and compute checksum
319    let existing_checksum = if dest_path.exists() {
320        let path = dest_path.clone();
321        tokio::task::spawn_blocking(move || LockFile::compute_checksum(&path)).await??.into()
322    } else {
323        None
324    };
325
326    // Early-exit optimization: Skip if nothing changed (Git dependencies only)
327    if let Some((checksum, context_checksum, patches)) =
328        should_skip_installation(entry, &dest_path, existing_checksum.as_ref(), context)
329    {
330        return Ok((false, checksum, context_checksum, patches));
331    }
332
333    // Log local dependency processing
334    if entry.resolved_commit.as_deref().is_none_or(str::is_empty) {
335        tracing::debug!(
336            "Processing local dependency: {} (early-exit optimization skipped)",
337            entry.name
338        );
339    }
340
341    // Read source content from Git or local file
342    let content = read_source_content(entry, context).await?;
343
344    // Validate markdown format
345    validate_markdown_content(&content)?;
346
347    // Apply patches (before templating)
348    let (patched_content, applied_patches) = apply_resource_patches(&content, entry, context)?;
349
350    // Apply templating to markdown files
351    let (final_content, _templating_was_applied, context_checksum) =
352        render_resource_content(&patched_content, entry, context).await?;
353
354    // Calculate file checksum of final content
355    let file_checksum = compute_file_checksum(&final_content);
356
357    // Determine if content has changed
358    let content_changed = existing_checksum.as_ref() != Some(&file_checksum);
359
360    // Write to disk if needed
361    let should_install = entry.install.unwrap_or(true);
362    let actually_installed = write_resource_to_disk(
363        &dest_path,
364        &final_content,
365        should_install,
366        content_changed,
367        context,
368    )
369    .await?;
370
371    Ok((actually_installed, file_checksum, context_checksum, applied_patches))
372}
373
374/// Install a single resource with progress bar updates for user feedback.
375///
376/// This function wraps [`install_resource`] with progress bar integration to provide
377/// real-time feedback during resource installation. It updates the progress bar
378/// message before delegating to the core installation logic.
379///
380/// # Arguments
381///
382/// * `entry` - The locked resource containing installation metadata
383/// * `project_dir` - Root project directory for installation target
384/// * `resource_dir` - Subdirectory name for this resource type (e.g., "agents")
385/// * `cache` - Cache instance for Git repository and worktree management
386/// * `force_refresh` - Whether to force refresh of cached repositories
387/// * `pb` - Progress bar to update with installation status
388///
389/// # Returns
390///
391/// Returns a tuple of:
392/// - `bool`: Whether the resource was actually installed (`true` for new/updated, `false` for unchanged)
393/// - `String`: SHA-256 checksum of the installed file content
394/// - `Option<String>`: SHA-256 checksum of the template rendering inputs, or None for non-templated resources
395/// - `AppliedPatches`: Information about any patches that were applied during installation
396///
397/// # Progress Integration
398///
399/// The function automatically sets the progress bar message to indicate which
400/// resource is currently being installed. This provides users with real-time
401/// feedback about installation progress.
402///
403/// # Examples
404///
405/// ```rust,no_run
406/// use agpm_cli::installer::{install_resource_with_progress, InstallContext};
407/// use agpm_cli::lockfile::{LockedResource, LockedResourceBuilder};
408/// use agpm_cli::cache::Cache;
409/// use agpm_cli::core::ResourceType;
410/// use agpm_cli::utils::progress::ProgressBar;
411/// use std::path::Path;
412///
413/// # async fn example() -> anyhow::Result<()> {
414/// let cache = Cache::new()?;
415/// let pb = ProgressBar::new(1);
416/// let entry = LockedResourceBuilder::new(
417///     "example-agent".to_string(),
418///     "agents/example.md".to_string(),
419///     "sha256:...".to_string(),
420///     ".claude/agents/example.md".to_string(),
421///     ResourceType::Agent,
422/// )
423/// .source(Some("community".to_string()))
424/// .url(Some("https://github.com/example/repo.git".to_string()))
425/// .version(Some("v1.0.0".to_string()))
426/// .resolved_commit(Some("abc123".to_string()))
427/// .tool(Some("claude-code".to_string()))
428/// .build();
429///
430/// let context = InstallContext::new(Path::new("."), &cache, false, false, None, None, None, None, None, None, None);
431/// let (installed, checksum, _old_checksum, _patches) = install_resource_with_progress(
432///     &entry,
433///     "agents",
434///     &context,
435///     &pb
436/// ).await?;
437///
438/// pb.inc(1);
439/// # Ok(())
440/// # }
441/// ```
442///
443/// # Errors
444///
445/// Returns the same errors as [`install_resource`], including:
446/// - Repository access failures
447/// - File system operation errors
448/// - Invalid markdown content
449/// - Git worktree creation failures
450pub async fn install_resource_with_progress(
451    entry: &LockedResource,
452    resource_dir: &str,
453    context: &InstallContext<'_>,
454    pb: &ProgressBar,
455) -> Result<(bool, String, Option<String>, crate::manifest::patches::AppliedPatches)> {
456    pb.set_message(format!("Installing {}", entry.name));
457    install_resource(entry, resource_dir, context).await
458}
459
460/// Install a single resource in a thread-safe manner for parallel execution.
461///
462/// This is a private helper function used by parallel installation operations.
463/// It's a thin wrapper around [`install_resource`] designed for use in parallel
464/// installation streams.
465pub(crate) async fn install_resource_for_parallel(
466    entry: &LockedResource,
467    resource_dir: &str,
468    context: &InstallContext<'_>,
469) -> Result<(bool, String, Option<String>, crate::manifest::patches::AppliedPatches)> {
470    install_resource(entry, resource_dir, context).await
471}
472
473/// Filtering options for resource installation operations.
474///
475/// This enum controls which resources are processed during installation,
476/// enabling both full installations and selective updates. The filter
477/// determines which entries from the lockfile are actually installed.
478///
479/// # Use Cases
480///
481/// - **Full installations**: Install all resources defined in lockfile
482/// - **Selective updates**: Install only resources that have been updated
483/// - **Performance optimization**: Avoid reinstalling unchanged resources
484/// - **Incremental deployments**: Update only what has changed
485///
486/// # Variants
487///
488/// ## All Resources
489/// [`ResourceFilter::All`] processes every resource entry in the lockfile,
490/// regardless of whether it has changed. This is used by the install command
491/// for complete environment setup.
492///
493/// ## Updated Resources Only
494/// [`ResourceFilter::Updated`] processes only resources that have version
495/// changes, as tracked by the update command. This enables efficient
496/// incremental updates without full reinstallation.
497///
498/// # Examples
499///
500/// Install all resources:
501/// ```rust,no_run
502/// use agpm_cli::installer::ResourceFilter;
503///
504/// let filter = ResourceFilter::All;
505/// // This will install every resource in the lockfile
506/// ```
507///
508/// Install only updated resources:
509/// ```rust,no_run
510/// use agpm_cli::installer::ResourceFilter;
511///
512/// let updates = vec![
513///     ("agent1".to_string(), None, "v1.0.0".to_string(), "v1.1.0".to_string()),
514///     ("tool2".to_string(), Some("community".to_string()), "v2.1.0".to_string(), "v2.2.0".to_string()),
515/// ];
516/// let filter = ResourceFilter::Updated(updates);
517/// // This will install only agent1 and tool2
518/// ```
519///
520/// # Update Tuple Format
521///
522/// For [`ResourceFilter::Updated`], each tuple contains:
523/// - `name`: Resource name as defined in the manifest
524/// - `old_version`: Previous version (for logging and tracking)
525/// - `new_version`: New version to install
526///
527/// The old version is primarily used for user feedback and logging,
528/// while the new version determines what gets installed.
529pub enum ResourceFilter {
530    /// Install all resources from the lockfile.
531    ///
532    /// This option processes every resource entry in the lockfile,
533    /// installing or updating each one regardless of whether it has
534    /// changed since the last installation.
535    All,
536
537    /// Install only specific updated resources.
538    ///
539    /// This option processes only the resources specified in the update list,
540    /// allowing for efficient incremental updates. Each tuple contains:
541    /// - Resource name
542    /// - Source name (None for local resources)
543    /// - Old version (for tracking)
544    /// - New version (to install)
545    Updated(Vec<(String, Option<String>, String, String)>),
546}
547
548/// Resource installation function supporting multiple progress configurations.
549///
550/// This function consolidates all resource installation patterns into a single, flexible
551/// interface that can handle both full installations and selective updates with different
552/// progress reporting mechanisms. It represents the modernized installation architecture
553/// introduced in AGPM v0.3.0.
554///
555/// # Architecture Benefits
556///
557/// - **Single API**: Single function handles install and update commands
558/// - **Flexible progress**: Supports dynamic, simple, and quiet progress modes
559/// - **Selective installation**: Can install all resources or just updated ones
560/// - **Optimal concurrency**: Leverages worktree-based parallel operations
561/// - **Cache efficiency**: Integrates with instance-level caching systems
562///
563/// # Parameters
564///
565/// * `filter` - Determines which resources to install ([`ResourceFilter::All`] or [`ResourceFilter::Updated`])
566/// * `lockfile` - The lockfile containing all resource definitions to install
567/// * `manifest` - The project manifest providing configuration and target directories
568/// * `project_dir` - Root directory where resources should be installed
569/// * `cache` - Cache instance for Git repository and worktree management
570/// * `force_refresh` - Whether to force refresh of cached repositories
571/// * `max_concurrency` - Optional limit on concurrent operations (None = unlimited)
572/// * `progress` - Optional multi-phase progress manager ([`MultiPhaseProgress`])
573///
574/// # Progress Reporting
575///
576/// Progress is reported through the optional [`MultiPhaseProgress`] parameter:
577/// - **Enabled**: Pass `Some(progress)` for multi-phase progress with live updates
578/// - **Disabled**: Pass `None` for quiet operation (scripts and automation)
579///
580/// # Installation Process
581///
582/// 1. **Resource filtering**: Collects entries based on filter criteria
583/// 2. **Cache warming**: Pre-creates worktrees for all unique repositories
584/// 3. **Parallel installation**: Processes resources with configured concurrency
585/// 4. **Progress coordination**: Updates progress based on configuration
586/// 5. **Error aggregation**: Collects and reports any installation failures
587///
588/// # Concurrency Behavior
589///
590/// The function implements advanced parallel processing:
591/// - **Pre-warming phase**: Creates all needed worktrees upfront for maximum parallelism
592/// - **Parallel execution**: Each resource installed in its own async task
593/// - **Concurrency control**: `max_concurrency` limits simultaneous operations
594/// - **Thread safety**: Progress updates are atomic and thread-safe
595///
596/// # Returns
597///
598/// Returns a tuple of:
599/// - The number of resources that were actually installed (new or updated content).
600///   Resources that already exist with identical content are not counted.
601/// - A vector of (`resource_name`, checksum) pairs for all processed resources
602///
603/// # Errors
604///
605/// Returns an error if any resource installation fails. The error includes details
606/// about all failed installations with specific error messages for debugging.
607///
608/// # Examples
609///
610/// Install all resources with progress tracking:
611/// ```rust,no_run
612/// use agpm_cli::installer::{install_resources, ResourceFilter};
613/// use agpm_cli::utils::progress::MultiPhaseProgress;
614/// use agpm_cli::lockfile::LockFile;
615/// use agpm_cli::manifest::Manifest;
616/// use agpm_cli::cache::Cache;
617/// use std::sync::Arc;
618/// use std::path::Path;
619///
620/// # async fn example() -> anyhow::Result<()> {
621/// # let lockfile = Arc::new(LockFile::default());
622/// # let manifest = Manifest::default();
623/// # let project_dir = Path::new(".");
624/// # let cache = Cache::new()?;
625/// let progress = Arc::new(MultiPhaseProgress::new(true));
626///
627/// let results = install_resources(
628///     ResourceFilter::All,
629///     &lockfile,
630///     &manifest,
631///     &project_dir,
632///     cache,
633///     false,
634///     Some(8), // Limit to 8 concurrent operations
635///     Some(progress),
636///     false, // verbose
637///     None, // old_lockfile
638/// ).await?;
639///
640/// println!("Installed {} resources", results.installed_count);
641/// # Ok(())
642/// # }
643/// ```
644///
645/// Install resources quietly (for automation):
646/// ```rust,no_run
647/// use agpm_cli::installer::{install_resources, ResourceFilter};
648/// use agpm_cli::lockfile::LockFile;
649/// use agpm_cli::manifest::Manifest;
650/// use agpm_cli::cache::Cache;
651/// use std::path::Path;
652/// use std::sync::Arc;
653///
654/// # async fn example() -> anyhow::Result<()> {
655/// # let lockfile = Arc::new(LockFile::default());
656/// # let manifest = Manifest::default();
657/// # let project_dir = Path::new(".");
658/// # let cache = Cache::new()?;
659/// let updates = vec![("agent1".to_string(), None, "v1.0".to_string(), "v1.1".to_string())];
660///
661/// let results = install_resources(
662///     ResourceFilter::Updated(updates),
663///     &lockfile,
664///     &manifest,
665///     &project_dir,
666///     cache,
667///     false,
668///     None, // Unlimited concurrency
669///     None, // No progress output
670///     false, // verbose
671///     None, // old_lockfile
672/// ).await?;
673///
674/// println!("Updated {} resources", results.installed_count);
675/// # Ok(())
676/// # }
677/// ```
678/// Collect entries to install based on filter criteria.
679///
680/// Returns a sorted vector of (LockedResource, target_directory) tuples.
681/// Sorting ensures deterministic processing order for consistent context checksums.
682fn collect_install_entries(
683    filter: &ResourceFilter,
684    lockfile: &LockFile,
685    manifest: &Manifest,
686) -> Vec<(LockedResource, String)> {
687    let all_entries: Vec<(LockedResource, String)> = match filter {
688        ResourceFilter::All => {
689            // Use existing ResourceIterator logic for all entries
690            ResourceIterator::collect_all_entries(lockfile, manifest)
691                .into_iter()
692                .map(|(entry, dir)| (entry.clone(), dir.into_owned()))
693                .collect()
694        }
695        ResourceFilter::Updated(updates) => {
696            // Collect only the updated entries
697            let mut entries = Vec::new();
698            for (name, source, _, _) in updates {
699                if let Some((resource_type, entry)) =
700                    ResourceIterator::find_resource_by_name_and_source(
701                        lockfile,
702                        name,
703                        source.as_deref(),
704                    )
705                {
706                    // Get artifact configuration path
707                    let tool = entry.tool.as_deref().unwrap_or("claude-code");
708                    let artifact_path = manifest
709                        .get_artifact_resource_path(tool, resource_type)
710                        .expect("Resource type should be supported by configured tools");
711                    let target_dir = artifact_path.display().to_string();
712                    entries.push((entry.clone(), target_dir));
713                }
714            }
715            entries
716        }
717    };
718
719    if all_entries.is_empty() {
720        return Vec::new();
721    }
722
723    // Sort entries for deterministic processing order
724    let mut sorted_entries = all_entries;
725    sorted_entries.sort_by(|(a, _), (b, _)| {
726        a.resource_type.cmp(&b.resource_type).then_with(|| a.name.cmp(&b.name))
727    });
728
729    sorted_entries
730}
731
732/// Pre-warm cache by creating all needed worktrees upfront.
733///
734/// Creates worktrees for all unique (source, url, sha) combinations to enable
735/// parallel installation without worktree creation bottlenecks.
736async fn pre_warm_worktrees(
737    entries: &[(LockedResource, String)],
738    cache: &Cache,
739    filter: &ResourceFilter,
740) {
741    let mut unique_worktrees = HashSet::new();
742
743    // Collect unique worktrees
744    for (entry, _) in entries {
745        if let Some(source_name) = &entry.source
746            && let Some(url) = &entry.url
747        {
748            // Only pre-warm if we have a valid SHA
749            if let Some(sha) = entry.resolved_commit.as_ref().filter(|commit| {
750                commit.len() == 40 && commit.chars().all(|c| c.is_ascii_hexdigit())
751            }) {
752                unique_worktrees.insert((source_name.clone(), url.clone(), sha.clone()));
753            }
754        }
755    }
756
757    if unique_worktrees.is_empty() {
758        return;
759    }
760
761    let context = match filter {
762        ResourceFilter::All => "pre-warm",
763        ResourceFilter::Updated(_) => "update-pre-warm",
764    };
765
766    let worktree_futures: Vec<_> = unique_worktrees
767        .into_iter()
768        .map(|(source, url, sha)| {
769            let cache = cache.clone();
770            async move {
771                cache.get_or_create_worktree_for_sha(&source, &url, &sha, Some(context)).await.ok(); // Ignore errors during pre-warming
772            }
773        })
774        .collect();
775
776    // Execute all worktree creations in parallel
777    future::join_all(worktree_futures).await;
778}
779
780/// Execute parallel installation with progress tracking.
781///
782/// Processes all entries concurrently with active progress tracking and gitignore updates.
783/// Returns vector of installation results for each resource.
784#[allow(clippy::too_many_arguments)]
785async fn execute_parallel_installation(
786    entries: Vec<(LockedResource, String)>,
787    project_dir: &Path,
788    cache: &Cache,
789    manifest: &Manifest,
790    lockfile: &Arc<LockFile>,
791    force_refresh: bool,
792    verbose: bool,
793    max_concurrency: Option<usize>,
794    progress: Option<Arc<MultiPhaseProgress>>,
795    old_lockfile: Option<&LockFile>,
796) -> Vec<InstallResult> {
797    // Create thread-safe progress tracking
798    let installed_count = Arc::new(Mutex::new(0));
799    let type_counts =
800        Arc::new(Mutex::new(std::collections::HashMap::<crate::core::ResourceType, usize>::new()));
801    let concurrency = max_concurrency.unwrap_or(usize::MAX).max(1);
802
803    // Create gitignore lock for thread-safe gitignore updates
804    let gitignore_lock = Arc::new(Mutex::new(()));
805
806    let total = entries.len();
807
808    // Process installations in parallel with active tracking
809    stream::iter(entries)
810        .map(|(entry, resource_dir)| {
811            let project_dir = project_dir.to_path_buf();
812            let installed_count = Arc::clone(&installed_count);
813            let type_counts = Arc::clone(&type_counts);
814            let cache = cache.clone();
815            let progress = progress.clone();
816            let gitignore_lock = Arc::clone(&gitignore_lock);
817            let entry_type = entry.resource_type;
818            async move {
819                // Signal that this resource is starting
820                if let Some(ref pm) = progress {
821                    pm.mark_resource_active(&entry);
822                }
823
824                let install_context = InstallContext::new(
825                    &project_dir,
826                    &cache,
827                    force_refresh,
828                    verbose,
829                    Some(manifest),
830                    Some(lockfile),
831                    old_lockfile,
832                    Some(&manifest.project_patches),
833                    Some(&manifest.private_patches),
834                    Some(&gitignore_lock),
835                    None,
836                );
837
838                let res =
839                    install_resource_for_parallel(&entry, &resource_dir, &install_context).await;
840
841                // Handle result and track completion
842                match res {
843                    Ok((actually_installed, file_checksum, context_checksum, applied_patches)) => {
844                        // Always increment the counter (regardless of whether file was written)
845                        let mut count = installed_count.lock().await;
846                        *count += 1;
847
848                        // Track by type for summary (only count those actually written to disk)
849                        if actually_installed {
850                            *type_counts.lock().await.entry(entry_type).or_insert(0) += 1;
851                        }
852
853                        // Signal completion and update counter
854                        if let Some(ref pm) = progress {
855                            pm.mark_resource_complete(&entry, *count, total);
856                        }
857
858                        Ok((
859                            entry.id(),
860                            actually_installed,
861                            file_checksum,
862                            context_checksum,
863                            applied_patches,
864                        ))
865                    }
866                    Err(err) => {
867                        // On error, still increment counter but skip slot clearing to avoid deadlocks
868                        let mut count = installed_count.lock().await;
869                        *count += 1;
870                        Err((entry.id(), err))
871                    }
872                }
873            }
874        })
875        .buffered(concurrency)
876        .collect()
877        .await
878}
879
880/// Process installation results and aggregate checksums.
881///
882/// Aggregates installation results, handles errors with detailed context,
883/// and returns structured results for lockfile updates.
884fn process_install_results(
885    results: Vec<InstallResult>,
886    progress: Option<Arc<MultiPhaseProgress>>,
887) -> Result<InstallationResults> {
888    // Handle errors and collect checksums, context checksums, and applied patches
889    let mut errors = Vec::new();
890    let mut checksums = Vec::new();
891    let mut context_checksums = Vec::new();
892    let mut applied_patches_list = Vec::new();
893
894    for result in results {
895        match result {
896            Ok((id, _installed, file_checksum, context_checksum, applied_patches)) => {
897                checksums.push((id.clone(), file_checksum));
898                context_checksums.push((id.clone(), context_checksum));
899                applied_patches_list.push((id, applied_patches));
900            }
901            Err((id, error)) => {
902                errors.push((id, error));
903            }
904        }
905    }
906
907    // Complete installation phase
908    if let Some(ref pm) = progress {
909        if !errors.is_empty() {
910            pm.complete_phase_with_window(Some(&format!(
911                "Failed to install {} resources",
912                errors.len()
913            )));
914        } else {
915            let installed_count = checksums.len();
916            if installed_count > 0 {
917                pm.complete_phase_with_window(Some(&format!(
918                    "Installed {installed_count} resources"
919                )));
920            }
921        }
922    }
923
924    // Handle errors with detailed context
925    if !errors.is_empty() {
926        // Format each error - use enhanced formatting for template errors
927        let error_msgs: Vec<String> = errors
928            .into_iter()
929            .map(|(id, error)| {
930                // Check if this is a TemplateError by walking the error chain
931                let mut current_error: &dyn std::error::Error = error.as_ref();
932                loop {
933                    if let Some(template_error) =
934                        current_error.downcast_ref::<crate::templating::TemplateError>()
935                    {
936                        // Found a TemplateError - use its detailed formatting
937                        return format!(
938                            "  {}:\n{}",
939                            id.name(),
940                            template_error.format_with_context()
941                        );
942                    }
943
944                    // Move to the next error in the chain
945                    match current_error.source() {
946                        Some(source) => current_error = source,
947                        None => break,
948                    }
949                }
950
951                // Not a template error - use default formatting
952                format!("  {}: {}", id.name(), error)
953            })
954            .collect();
955
956        // Return the formatted errors without wrapping context
957        return Err(anyhow::anyhow!(
958            "Installation incomplete: {} resource(s) could not be set up\n{}",
959            error_msgs.len(),
960            error_msgs.join("\n\n")
961        ));
962    }
963
964    let installed_count = checksums.len();
965    Ok(InstallationResults::new(
966        installed_count,
967        checksums,
968        context_checksums,
969        applied_patches_list,
970    ))
971}
972
973#[allow(clippy::too_many_arguments)]
974pub async fn install_resources(
975    filter: ResourceFilter,
976    lockfile: &Arc<LockFile>,
977    manifest: &Manifest,
978    project_dir: &Path,
979    cache: Cache,
980    force_refresh: bool,
981    max_concurrency: Option<usize>,
982    progress: Option<Arc<MultiPhaseProgress>>,
983    verbose: bool,
984    old_lockfile: Option<&LockFile>,
985) -> Result<InstallationResults> {
986    // 1. Collect entries to install
987    let all_entries = collect_install_entries(&filter, lockfile, manifest);
988    if all_entries.is_empty() {
989        return Ok(InstallationResults::new(0, Vec::new(), Vec::new(), Vec::new()));
990    }
991
992    let total = all_entries.len();
993
994    // Calculate optimal window size
995    let concurrency = max_concurrency.unwrap_or_else(|| {
996        let cores = std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(4);
997        std::cmp::max(10, cores * 2)
998    });
999    let window_size =
1000        crate::utils::progress::MultiPhaseProgress::calculate_window_size(concurrency);
1001
1002    // Start installation phase with active window tracking
1003    if let Some(ref pm) = progress {
1004        pm.start_phase_with_active_tracking(
1005            InstallationPhase::InstallingResources,
1006            total,
1007            window_size,
1008        );
1009    }
1010
1011    // 2. Pre-warm worktrees
1012    pre_warm_worktrees(&all_entries, &cache, &filter).await;
1013
1014    // 3. Execute parallel installation
1015    let results = execute_parallel_installation(
1016        all_entries,
1017        project_dir,
1018        &cache,
1019        manifest,
1020        lockfile,
1021        force_refresh,
1022        verbose,
1023        max_concurrency,
1024        progress.clone(),
1025        old_lockfile,
1026    )
1027    .await;
1028
1029    // 4. Process results and aggregate checksums
1030    process_install_results(results, progress)
1031}
1032
1033/// Finalize installation by configuring hooks, MCP servers, and updating lockfiles.
1034///
1035/// This function performs the final steps shared by install and update commands after
1036/// resources are installed. It executes multiple operations in sequence, with each
1037/// step building on the previous.
1038///
1039/// # Process Steps
1040///
1041/// 1. **Hook Configuration** - Configures Claude Code hooks from source files
1042/// 2. **MCP Server Setup** - Groups and configures MCP servers by tool type
1043/// 3. **Patch Application** - Applies and tracks project/private patches
1044/// 4. **Artifact Cleanup** - Removes old files from previous installations
1045/// 5. **Lockfile Saving** - Writes main lockfile with checksums (unless --no-lock)
1046/// 6. **Private Lockfile** - Saves private patches to separate file
1047/// 7. **Gitignore Update** - Adds installed paths to .gitignore
1048///
1049/// # Arguments
1050///
1051/// * `lockfile` - Mutable lockfile to update with applied patches
1052/// * `manifest` - Project manifest for configuration
1053/// * `project_dir` - Project root directory
1054/// * `cache` - Cache instance for Git operations
1055/// * `old_lockfile` - Optional previous lockfile for artifact cleanup
1056/// * `quiet` - Whether to suppress output messages
1057/// * `no_lock` - Whether to skip lockfile saving (development mode)
1058///
1059/// # Returns
1060///
1061/// Returns `(hook_count, server_count)` tuple:
1062/// - `hook_count`: Number of hooks configured (regardless of changed status)
1063/// - `server_count`: Number of MCP servers configured (regardless of changed status)
1064///
1065/// # Errors
1066///
1067/// Returns an error if:
1068/// - **Hook configuration fails**: Invalid hook source files or permission errors
1069/// - **MCP handler not found**: Tool type has no registered MCP handler
1070/// - **Tool not configured**: Tool missing from manifest `[default-tools]` section
1071/// - **Lockfile save fails**: Permission denied or disk full
1072/// - **Gitignore update fails**: Rare I/O errors
1073///
1074/// # Examples
1075///
1076/// ```rust,no_run
1077/// # use agpm_cli::installer::finalize_installation;
1078/// # use agpm_cli::lockfile::LockFile;
1079/// # use agpm_cli::manifest::Manifest;
1080/// # use agpm_cli::cache::Cache;
1081/// # use std::path::Path;
1082/// # async fn example() -> anyhow::Result<()> {
1083/// let mut lockfile = LockFile::default();
1084/// let manifest = Manifest::default();
1085/// let project_dir = Path::new(".");
1086/// let cache = Cache::new()?;
1087///
1088/// let (hooks, servers) = finalize_installation(
1089///     &mut lockfile,
1090///     &manifest,
1091///     project_dir,
1092///     &cache,
1093///     None,    // no old lockfile (fresh install)
1094///     false,   // not quiet
1095///     false,   // create lockfile
1096/// ).await?;
1097///
1098/// println!("Configured {} hooks and {} servers", hooks, servers);
1099/// # Ok(())
1100/// # }
1101/// ```
1102///
1103/// # Implementation Notes
1104///
1105/// - Hooks are configured by reading directly from source files (no copying)
1106/// - MCP servers are grouped by tool type for batch configuration
1107/// - Patch tracking: project patches stored in lockfile, private in separate file
1108/// - Artifact cleanup only runs if old lockfile exists (update scenario)
1109/// - Private lockfile automatically deleted if empty
1110pub async fn finalize_installation(
1111    lockfile: &mut LockFile,
1112    manifest: &Manifest,
1113    project_dir: &Path,
1114    cache: &Cache,
1115    old_lockfile: Option<&LockFile>,
1116    quiet: bool,
1117    no_lock: bool,
1118) -> Result<(usize, usize)> {
1119    use anyhow::Context;
1120
1121    let mut hook_count = 0;
1122    let mut server_count = 0;
1123
1124    // Handle hooks if present
1125    if !lockfile.hooks.is_empty() {
1126        // Configure hooks directly from source files (no copying)
1127        let hooks_changed = crate::hooks::install_hooks(lockfile, project_dir, cache).await?;
1128        hook_count = lockfile.hooks.len();
1129
1130        // Always show hooks configuration feedback with changed count
1131        if !quiet {
1132            if hook_count == 1 {
1133                if hooks_changed == 1 {
1134                    println!("✓ Configured 1 hook (1 changed)");
1135                } else {
1136                    println!("✓ Configured 1 hook ({hooks_changed} changed)");
1137                }
1138            } else {
1139                println!("✓ Configured {hook_count} hooks ({hooks_changed} changed)");
1140            }
1141        }
1142    }
1143
1144    // Handle MCP servers if present - group by artifact type
1145    if !lockfile.mcp_servers.is_empty() {
1146        use crate::mcp::handlers::McpHandler;
1147        use std::collections::HashMap;
1148
1149        // Group MCP servers by artifact type
1150        let mut servers_by_type: HashMap<String, Vec<crate::lockfile::LockedResource>> =
1151            HashMap::new();
1152        {
1153            // Scope to limit the immutable borrow of lockfile
1154            for server in &lockfile.mcp_servers {
1155                let tool = server.tool.clone().unwrap_or_else(|| "claude-code".to_string());
1156                servers_by_type.entry(tool).or_default().push(server.clone());
1157            }
1158        }
1159
1160        // Collect all applied patches to update lockfile after iteration
1161        let mut all_mcp_patches: Vec<(String, crate::manifest::patches::AppliedPatches)> =
1162            Vec::new();
1163        // Track total changed MCP servers
1164        let mut total_mcp_changed = 0;
1165
1166        // Configure MCP servers for each artifact type using appropriate handler
1167        for (artifact_type, servers) in servers_by_type {
1168            if let Some(handler) = crate::mcp::handlers::get_mcp_handler(&artifact_type) {
1169                // Get artifact base directory - must be properly configured
1170                let artifact_base = manifest
1171                    .get_tool_config(&artifact_type)
1172                    .map(|c| &c.path)
1173                    .ok_or_else(|| {
1174                        anyhow::anyhow!(
1175                            "Tool '{}' is not configured. Please define it in [default-tools] section.",
1176                            artifact_type
1177                        )
1178                    })?;
1179                let artifact_base = project_dir.join(artifact_base);
1180
1181                // Configure MCP servers by reading directly from source (no file copying)
1182                let server_entries = servers.clone();
1183
1184                // Collect applied patches and changed count
1185                let (applied_patches_list, changed_count) = handler
1186                    .configure_mcp_servers(
1187                        project_dir,
1188                        &artifact_base,
1189                        &server_entries,
1190                        cache,
1191                        manifest,
1192                    )
1193                    .await
1194                    .with_context(|| {
1195                        format!(
1196                            "Failed to configure MCP servers for artifact type '{}'",
1197                            artifact_type
1198                        )
1199                    })?;
1200
1201                // Collect patches for later application
1202                all_mcp_patches.extend(applied_patches_list);
1203                total_mcp_changed += changed_count;
1204
1205                server_count += servers.len();
1206            }
1207        }
1208
1209        // Update lockfile with all collected applied patches
1210        for (name, applied_patches) in all_mcp_patches {
1211            lockfile.update_resource_applied_patches(&name, &applied_patches);
1212        }
1213
1214        // Use the actual changed count from MCP handlers
1215        let mcp_servers_changed = total_mcp_changed;
1216
1217        if server_count > 0 && !quiet {
1218            if server_count == 1 {
1219                if mcp_servers_changed == 1 {
1220                    println!("✓ Configured 1 MCP server (1 changed)");
1221                } else {
1222                    println!("✓ Configured 1 MCP server ({mcp_servers_changed} changed)");
1223                }
1224            } else {
1225                println!("✓ Configured {server_count} MCP servers ({mcp_servers_changed} changed)");
1226            }
1227        }
1228    }
1229
1230    // Clean up removed or moved artifacts if old lockfile provided
1231    if let Some(old) = old_lockfile {
1232        if let Ok(removed) = cleanup_removed_artifacts(old, lockfile, project_dir).await {
1233            if !removed.is_empty() && !quiet {
1234                println!("✓ Cleaned up {} moved or removed artifact(s)", removed.len());
1235            }
1236        }
1237    }
1238
1239    if !no_lock {
1240        // Save lockfile with checksums
1241        lockfile.save(&project_dir.join("agpm.lock")).with_context(|| {
1242            format!("Failed to save lockfile to {}", project_dir.join("agpm.lock").display())
1243        })?;
1244
1245        // Build and save private lockfile if there are private patches
1246        use crate::lockfile::PrivateLockFile;
1247        let mut private_lock = PrivateLockFile::new();
1248
1249        // Collect private patches for all installed resources
1250        for (entry, _) in ResourceIterator::collect_all_entries(lockfile, manifest) {
1251            let resource_type = entry.resource_type.to_plural();
1252            // Use the lookup_name helper to get the correct name for patch lookups
1253            let lookup_name = entry.lookup_name();
1254            if let Some(private_patches) = manifest.private_patches.get(resource_type, lookup_name)
1255            {
1256                private_lock.add_private_patches(
1257                    resource_type,
1258                    &entry.name,
1259                    private_patches.clone(),
1260                );
1261            }
1262        }
1263
1264        // Save private lockfile (automatically deletes if empty)
1265        private_lock
1266            .save(project_dir)
1267            .with_context(|| "Failed to save private lockfile".to_string())?;
1268    }
1269
1270    // Update .gitignore
1271    update_gitignore(lockfile, project_dir, true)?;
1272
1273    Ok((hook_count, server_count))
1274}
1275
1276/// Find parent resources that depend on the given resource.
1277///
1278/// This function searches through the lockfile to find resources that list
1279/// the given resource name in their `dependencies` field. This is useful for
1280/// error reporting to show which resources depend on a failing resource.
1281///
1282/// # Arguments
1283///
1284/// * `lockfile` - The lockfile to search
1285/// * `resource_name` - The canonical name of the resource to find parents for
1286///
1287/// # Returns
1288///
1289/// A vector of parent resource names (manifest aliases if available, otherwise
1290/// canonical names) that directly depend on the given resource.
1291///
1292/// # Examples
1293///
1294/// ```rust,no_run
1295/// use agpm_cli::lockfile::LockFile;
1296/// use agpm_cli::installer::find_parent_resources;
1297///
1298/// let lockfile = LockFile::default();
1299/// let parents = find_parent_resources(&lockfile, "agents/helper");
1300/// if !parents.is_empty() {
1301///     println!("Resource is required by: {}", parents.join(", "));
1302/// }
1303/// ```
1304pub fn find_parent_resources(lockfile: &LockFile, resource_name: &str) -> Vec<String> {
1305    use crate::core::ResourceIterator;
1306
1307    let mut parents = Vec::new();
1308
1309    // Iterate through all resources in the lockfile
1310    for (entry, _dir) in
1311        ResourceIterator::collect_all_entries(lockfile, &crate::manifest::Manifest::default())
1312    {
1313        // Check if this resource depends on the target resource
1314        if entry.dependencies.iter().any(|dep| dep == resource_name) {
1315            // Use manifest_alias if available (user-facing name), otherwise canonical name
1316            let parent_name = entry.manifest_alias.as_ref().unwrap_or(&entry.name).clone();
1317            parents.push(parent_name);
1318        }
1319    }
1320
1321    parents
1322}