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}