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