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}