agpm_cli/installer/
mod.rs

1//! Shared installation utilities for AGPM resources.
2//!
3//! This module provides common functionality for installing resources from
4//! lockfile entries to the project directory. It's shared between the install
5//! and update commands to avoid code duplication. The module includes both
6//! installation logic and automatic cleanup of removed or relocated artifacts.
7//!
8//! # SHA-Based Parallel Installation Architecture
9//!
10//! The installer uses SHA-based worktrees for optimal parallel resource installation:
11//! - **SHA-based worktrees**: Each unique commit gets one worktree for maximum deduplication
12//! - **Pre-resolved SHAs**: All versions resolved to SHAs before installation begins
13//! - **Concurrency control**: Direct parallelism control via --max-parallel flag
14//! - **Context-aware logging**: Each operation includes dependency name for debugging
15//! - **Efficient cleanup**: Worktrees are managed by the cache layer for reuse
16//! - **Pre-warming**: Worktrees created upfront to minimize installation latency
17//! - **Automatic artifact cleanup**: Removes old files when paths change or dependencies are removed
18//!
19//! # Installation Process
20//!
21//! 1. **SHA validation**: Ensures all resources have valid 40-character commit SHAs
22//! 2. **Worktree pre-warming**: Creates SHA-based worktrees for all unique commits
23//! 3. **Parallel processing**: Installs multiple resources concurrently using dedicated worktrees
24//! 4. **Content validation**: Validates markdown format and structure
25//! 5. **Atomic installation**: Files are written atomically to prevent corruption
26//! 6. **Progress tracking**: Real-time progress updates during parallel operations
27//! 7. **Artifact cleanup**: Automatically removes old files from previous installations when paths change
28//!
29//! # Artifact Cleanup (v0.3.18+)
30//!
31//! The module provides automatic cleanup of obsolete artifacts when:
32//! - **Dependencies are removed**: Files from removed dependencies are deleted
33//! - **Paths are relocated**: Old files are removed when `installed_at` paths change
34//! - **Structure changes**: Empty parent directories are cleaned up recursively
35//!
36//! The cleanup process:
37//! 1. Compares old and new lockfiles to identify removed artifacts
38//! 2. Removes files that exist in the old lockfile but not in the new one
39//! 3. Recursively removes empty parent directories up to `.claude/`
40//! 4. Reports the number of cleaned artifacts to the user
41//!
42//! See [`cleanup_removed_artifacts()`] for implementation details.
43//!
44//! # Performance Characteristics
45//!
46//! - **SHA-based deduplication**: Multiple refs to same commit share one worktree
47//! - **Parallel processing**: Multiple dependencies installed simultaneously
48//! - **Pre-warming optimization**: Worktrees created upfront to minimize latency
49//! - **Parallelism-controlled**: User controls concurrency via --max-parallel flag
50//! - **Atomic operations**: Fast, safe file installation with proper error handling
51//! - **Reduced disk usage**: No duplicate worktrees for identical commits
52//! - **Efficient cleanup**: Minimal overhead for artifact cleanup operations
53
54use crate::utils::progress::{InstallationPhase, MultiPhaseProgress};
55use anyhow::{Context, Result};
56use std::path::PathBuf;
57
58mod cleanup;
59mod context;
60mod gitignore;
61mod selective;
62
63#[cfg(test)]
64mod tests;
65
66pub use cleanup::cleanup_removed_artifacts;
67pub use context::InstallContext;
68pub use gitignore::{add_path_to_gitignore, update_gitignore};
69pub use selective::*;
70
71use context::read_with_cache_retry;
72
73/// Type alias for complex installation result tuples to improve code readability.
74///
75/// This type alias simplifies the return type of parallel installation functions
76/// that need to return either success information or error details with context.
77/// It was introduced in AGPM v0.3.0 to resolve `clippy::type_complexity` warnings
78/// while maintaining clear semantics for installation results.
79///
80/// # Success Variant: `Ok((String, bool, String, Option<String>))`
81///
82/// When installation succeeds, the tuple contains:
83/// - `String`: Resource name that was processed
84/// - `bool`: Whether the resource was actually installed (`true`) or already up-to-date (`false`)
85/// - `String`: SHA-256 checksum of the installed file content
86/// - `Option<String>`: SHA-256 checksum of the template rendering inputs, or None for non-templated resources
87///
88/// # Error Variant: `Err((String, anyhow::Error))`
89///
90/// When installation fails, the tuple contains:
91/// - `String`: Resource name that failed to install
92/// - `anyhow::Error`: Detailed error information for debugging
93///
94/// # Usage
95///
96/// This type is primarily used in parallel installation operations where
97/// individual resource results need to be collected and processed:
98///
99/// ```rust,ignore
100/// use agpm_cli::installer::InstallResult;
101/// use futures::stream::{self, StreamExt};
102///
103/// # async fn example() -> anyhow::Result<()> {
104/// let results: Vec<InstallResult> = stream::iter(vec!["resource1", "resource2"])
105///     .map(|resource_name| async move {
106///         // Installation logic here
107///         Ok((resource_name.to_string(), true, "sha256:abc123".to_string()))
108///     })
109///     .buffer_unordered(10)
110///     .collect()
111///     .await;
112///
113/// // Process results
114/// for result in results {
115///     match result {
116///         Ok((name, installed, checksum)) => {
117///             println!("✓ {}: installed={}, checksum={}", name, installed, checksum);
118///         }
119///         Err((name, error)) => {
120///             eprintln!("✗ {}: {}", name, error);
121///         }
122///     }
123/// }
124/// # Ok(())
125/// # }
126/// ```
127///
128/// # Design Rationale
129///
130/// The type alias serves several purposes:
131/// - **Clippy compliance**: Resolves `type_complexity` warnings for complex generic types
132/// - **Code clarity**: Makes function signatures more readable and self-documenting
133/// - **Error context**: Preserves resource name context when installation fails
134/// - **Batch processing**: Enables efficient collection and processing of parallel results
135type InstallResult = Result<
136    (
137        crate::lockfile::ResourceId,
138        bool,
139        String,
140        Option<String>,
141        crate::manifest::patches::AppliedPatches,
142    ),
143    (crate::lockfile::ResourceId, anyhow::Error),
144>;
145
146use futures::{
147    future,
148    stream::{self, StreamExt},
149};
150use std::path::Path;
151use std::sync::Arc;
152use tokio::sync::Mutex;
153
154use crate::cache::Cache;
155use crate::core::ResourceIterator;
156use crate::lockfile::{LockFile, LockedResource};
157use crate::manifest::Manifest;
158use crate::markdown::MarkdownFile;
159use crate::utils::fs::{atomic_write, ensure_dir};
160use crate::utils::progress::ProgressBar;
161use hex;
162use std::collections::HashSet;
163
164/// Install a single resource from a lock entry using worktrees for parallel safety.
165///
166/// This function installs a resource specified by a lockfile entry to the project
167/// directory. It uses Git worktrees through the cache layer to enable safe parallel
168/// operations without conflicts between concurrent installations.
169///
170/// # Arguments
171///
172/// * `entry` - The locked resource to install containing source and version info
173/// * `resource_dir` - The subdirectory name for this resource type (e.g., "agents")
174/// * `context` - Installation context containing project configuration and cache instance
175///
176/// # Returns
177///
178/// Returns `Ok((installed, file_checksum, context_checksum, applied_patches))` where:
179/// - `installed` is `true` if the resource was actually installed (new or updated),
180///   `false` if the resource already existed and was unchanged
181/// - `file_checksum` is the SHA-256 hash of the installed file content (after rendering)
182/// - `context_checksum` is the SHA-256 hash of the template rendering inputs, or None for non-templated resources
183/// - `applied_patches` contains information about any patches that were applied during installation
184///
185/// # Worktree Usage
186///
187/// For remote resources, this function:
188/// 1. Uses `cache.get_or_clone_source_worktree_with_context()` to get a worktree
189/// 2. Each dependency gets its own isolated worktree for parallel safety
190/// 3. Worktrees are automatically managed and reused by the cache layer
191/// 4. Context (dependency name) is provided for debugging parallel operations
192///
193/// # Installation Process
194///
195/// 1. **Path resolution**: Determines destination based on `installed_at` or defaults
196/// 2. **Repository access**: Gets worktree from cache (for remote) or validates local path
197/// 3. **Content validation**: Verifies markdown format and structure
198/// 4. **Atomic write**: Installs file atomically to prevent corruption
199///
200/// # Examples
201///
202/// ```rust,no_run
203/// use agpm_cli::installer::{install_resource, InstallContext};
204/// use agpm_cli::lockfile::LockedResourceBuilder;
205/// use agpm_cli::cache::Cache;
206/// use agpm_cli::core::ResourceType;
207/// use std::path::Path;
208///
209/// # async fn example() -> anyhow::Result<()> {
210/// let cache = Cache::new()?;
211/// let entry = LockedResourceBuilder::new(
212///     "example-agent".to_string(),
213///     "agents/example.md".to_string(),
214///     "sha256:...".to_string(),
215///     ".claude/agents/example.md".to_string(),
216///     ResourceType::Agent,
217/// )
218/// .source(Some("community".to_string()))
219/// .url(Some("https://github.com/example/repo.git".to_string()))
220/// .version(Some("v1.0.0".to_string()))
221/// .resolved_commit(Some("abc123".to_string()))
222/// .tool(Some("claude-code".to_string()))
223/// .build();
224///
225/// let context = InstallContext::new(Path::new("."), &cache, false, false, None, None, None, None, None, None, None);
226/// let (installed, checksum, _old_checksum, _patches) = install_resource(&entry, "agents", &context).await?;
227/// if installed {
228///     println!("Resource was installed with checksum: {}", checksum);
229/// } else {
230///     println!("Resource already existed and was unchanged");
231/// }
232/// # Ok(())
233/// # }
234/// ```
235///
236/// # Error Handling
237///
238/// Returns an error if:
239/// - The source repository cannot be accessed or cloned
240/// - The specified file path doesn't exist in the repository
241/// - The file is not valid markdown format
242/// - File system operations fail (permissions, disk space)
243/// - Worktree creation fails due to Git issues
244pub async fn install_resource(
245    entry: &LockedResource,
246    resource_dir: &str,
247    context: &InstallContext<'_>,
248) -> Result<(bool, String, Option<String>, crate::manifest::patches::AppliedPatches)> {
249    // Determine destination path
250    let dest_path = if entry.installed_at.is_empty() {
251        context.project_dir.join(resource_dir).join(format!("{}.md", entry.name))
252    } else {
253        context.project_dir.join(&entry.installed_at)
254    };
255
256    // Check if file already exists and compare checksums
257    let existing_checksum = if dest_path.exists() {
258        // Use blocking task for checksum calculation to avoid blocking the async runtime
259        let path = dest_path.clone();
260        tokio::task::spawn_blocking(move || LockFile::compute_checksum(&path)).await??.into()
261    } else {
262        None
263    };
264
265    // Early-exit optimization: Skip processing if nothing changed
266    // This dramatically speeds up subsequent installations when resources are unchanged
267    //
268    // Note: This optimization is ONLY applied to Git-based dependencies where we can reliably
269    // detect changes via the resolved_commit SHA. For local dependencies (where resolved_commit
270    // is None/empty), we skip this optimization and always process the files, because:
271    // 1. Local source files can change without any manifest metadata changing
272    // 2. Transitive dependencies (e.g., embedded snippets) can change
273    // 3. Reading local files is fast (no Git operations needed)
274    // 4. The final checksum comparison (later in this function) will still prevent
275    //    unnecessary disk writes if the content hasn't actually changed
276    let is_local_dependency = entry.resolved_commit.as_deref().is_none_or(str::is_empty);
277
278    if !context.force_refresh && !is_local_dependency {
279        if let Some(old_lockfile) = context.old_lockfile {
280            if let Some(old_entry) = old_lockfile.find_resource(&entry.name, &entry.resource_type) {
281                // Check if all inputs that affect the final content are unchanged
282                let resolved_commit_unchanged = old_entry.resolved_commit == entry.resolved_commit;
283                let variant_inputs_unchanged = old_entry.variant_inputs == entry.variant_inputs;
284                let patches_unchanged = old_entry.applied_patches == entry.applied_patches;
285
286                let all_inputs_unchanged =
287                    resolved_commit_unchanged && variant_inputs_unchanged && patches_unchanged;
288
289                if all_inputs_unchanged && dest_path.exists() {
290                    // File exists and all inputs match - verify checksum matches
291                    if existing_checksum.as_ref() == Some(&old_entry.checksum) {
292                        tracing::debug!(
293                            "⏭️  Skipping unchanged Git resource: {} (checksum matches)",
294                            entry.name
295                        );
296                        return Ok((
297                            false, // not installed (already up to date)
298                            old_entry.checksum.clone(),
299                            old_entry.context_checksum.clone(),
300                            crate::manifest::patches::AppliedPatches::from_lockfile_patches(
301                                &old_entry.applied_patches,
302                            ),
303                        ));
304                    } else {
305                        tracing::debug!(
306                            "Checksum mismatch for {}: existing={:?}, expected={}",
307                            entry.name,
308                            existing_checksum,
309                            old_entry.checksum
310                        );
311                    }
312                }
313            }
314        }
315    }
316
317    if is_local_dependency {
318        tracing::debug!(
319            "Processing local dependency: {} (early-exit optimization skipped)",
320            entry.name
321        );
322    }
323
324    let new_content = if let Some(source_name) = &entry.source {
325        let url = entry
326            .url
327            .as_ref()
328            .ok_or_else(|| anyhow::anyhow!("Resource {} has no URL", entry.name))?;
329
330        // Check if this is a local directory source (no SHA or empty SHA)
331        let is_local_source = entry.resolved_commit.as_deref().is_none_or(str::is_empty);
332
333        let cache_dir = if is_local_source {
334            // Local directory source - use the URL as the path directly
335            PathBuf::from(url)
336        } else {
337            // Git-based resource - use SHA-based worktree creation
338            let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
339                anyhow::anyhow!("Resource {} missing resolved commit SHA. Run 'agpm update' to regenerate lockfile.", entry.name)
340            })?;
341
342            // Validate SHA format
343            if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
344                return Err(anyhow::anyhow!(
345                    "Invalid SHA '{}' for resource {}. Expected 40 hex characters.",
346                    sha,
347                    entry.name
348                ));
349            }
350
351            let mut cache_dir = context
352                .cache
353                .get_or_create_worktree_for_sha(source_name, url, sha, Some(&entry.name))
354                .await?;
355
356            if context.force_refresh {
357                let _ = context.cache.cleanup_worktree(&cache_dir).await;
358                cache_dir = context
359                    .cache
360                    .get_or_create_worktree_for_sha(source_name, url, sha, Some(&entry.name))
361                    .await?;
362            }
363
364            cache_dir
365        };
366
367        // Read the content from the source (with cache coherency retry)
368        let source_path = cache_dir.join(&entry.path);
369        let file_content = read_with_cache_retry(&source_path).await?;
370
371        // Validate markdown - silently accepts invalid frontmatter (warnings handled by MetadataExtractor)
372        MarkdownFile::parse(&file_content)?;
373
374        file_content
375    } else {
376        // Local resource - copy directly from project directory or absolute path
377        let source_path = {
378            let candidate = Path::new(&entry.path);
379            if candidate.is_absolute() {
380                candidate.to_path_buf()
381            } else {
382                context.project_dir.join(candidate)
383            }
384        };
385
386        if !source_path.exists() {
387            return Err(anyhow::anyhow!(
388                "Local file '{}' not found. Expected at: {}",
389                entry.path,
390                source_path.display()
391            ));
392        }
393
394        let local_content = tokio::fs::read_to_string(&source_path)
395            .await
396            .with_context(|| format!("Failed to read resource file: {}", source_path.display()))?;
397
398        // Validate markdown - silently accepts invalid frontmatter (warnings handled by MetadataExtractor)
399        MarkdownFile::parse(&local_content)?;
400
401        local_content
402    };
403
404    // Apply patches if provided (before templating)
405    let empty_patches = std::collections::BTreeMap::new();
406    let (patched_content, applied_patches) = if context.project_patches.is_some()
407        || context.private_patches.is_some()
408    {
409        use crate::manifest::patches::apply_patches_to_content_with_origin;
410
411        // Look up patches for this specific resource
412        let resource_type = entry.resource_type.to_plural();
413        let lookup_name = entry.lookup_name();
414
415        tracing::debug!(
416            "Installer patch lookup: resource_type={}, lookup_name={}, name={}, manifest_alias={:?}",
417            resource_type,
418            lookup_name,
419            entry.name,
420            entry.manifest_alias
421        );
422
423        let project_patch_data = context
424            .project_patches
425            .and_then(|patches| patches.get(resource_type, lookup_name))
426            .unwrap_or(&empty_patches);
427
428        tracing::debug!("Found {} project patches for {}", project_patch_data.len(), lookup_name);
429
430        let private_patch_data = context
431            .private_patches
432            .and_then(|patches| patches.get(resource_type, lookup_name))
433            .unwrap_or(&empty_patches);
434
435        let file_path = entry.installed_at.as_str();
436        apply_patches_to_content_with_origin(
437            &new_content,
438            file_path,
439            project_patch_data,
440            private_patch_data,
441        )
442        .with_context(|| format!("Failed to apply patches to resource {}", entry.name))?
443    } else {
444        (new_content.clone(), crate::manifest::patches::AppliedPatches::default())
445    };
446
447    // Apply templating to markdown files (after patching)
448    // Strategy: Always render frontmatter (for template variables in frontmatter fields),
449    //           but only render body if agpm.templating: true
450    // Track whether templating was applied and capture context checksum from rendering
451    let (final_content, templating_was_applied, captured_checksum) = if entry.path.ends_with(".md")
452    {
453        // Strategy: Always render frontmatter first (it may contain template vars)
454        // Then check agpm.templating flag in the RENDERED frontmatter
455        // Then conditionally render the body based on that flag
456        let template_context_builder = &context.template_context_builder;
457        use crate::templating::TemplateRenderer;
458
459        // Step 1: Extract raw frontmatter text WITHOUT parsing YAML
460        // (since frontmatter may contain template syntax that makes it unparseable YAML)
461        use crate::markdown::frontmatter::FrontmatterParser;
462        let parser = FrontmatterParser::new();
463
464        let (raw_frontmatter_text, body_content) =
465            if let Some(raw_fm) = parser.extract_raw_frontmatter(&patched_content) {
466                let body = parser.strip_frontmatter(&patched_content);
467                (raw_fm, body)
468            } else {
469                // No frontmatter
470                (String::new(), patched_content.clone())
471            };
472
473        if raw_frontmatter_text.is_empty() {
474            // No frontmatter - return content as-is (no templating to do)
475            (patched_content, false, None)
476        } else {
477            // Determine resource type from entry
478            let resource_type = entry.resource_type;
479
480            // Compute context digest for cache invalidation
481            // This ensures that changes to dependency versions invalidate the cache
482            // Wrap templating logic in a block that can be skipped on errors
483            let templating_result: Option<(String, bool, Option<String>)> = 'templating: {
484                let context_digest = match template_context_builder.compute_context_digest() {
485                    Ok(digest) => digest,
486                    Err(e) => {
487                        // Digest computation failed - fall back to using original content without templating
488                        tracing::debug!(
489                            "Failed to compute context digest for {}: {}. Using original content.",
490                            entry.name,
491                            e
492                        );
493                        break 'templating None;
494                    }
495                };
496
497                let resource_id = crate::lockfile::ResourceId::new(
498                    entry.name.clone(),
499                    entry.source.clone(),
500                    entry.tool.clone(),
501                    resource_type,
502                    entry.variant_inputs.hash().to_string(),
503                );
504
505                // Try to build template context - if it fails, fall back to using original content
506                let (template_context, captured_context_checksum) = match template_context_builder
507                    .build_context(&resource_id, entry.variant_inputs.json())
508                    .await
509                {
510                    Ok(ctx) => ctx,
511                    Err(e) => {
512                        // Context building failed - likely resource not in lockfile or other issue
513                        // Fall back to using original content without templating
514                        tracing::debug!(
515                            "Failed to build template context for {}: {}. Using original content.",
516                            entry.name,
517                            e
518                        );
519                        break 'templating None;
520                    }
521                };
522
523                // Show verbose output before rendering
524                if context.verbose {
525                    let num_resources = template_context
526                        .get("resources")
527                        .and_then(|v| v.as_object())
528                        .map(|o| o.len())
529                        .unwrap_or(0);
530                    let num_dependencies = template_context
531                        .get("dependencies")
532                        .and_then(|v| v.as_object())
533                        .map(|o| o.len())
534                        .unwrap_or(0);
535
536                    tracing::info!("📝 Rendering template: {}", entry.path);
537                    tracing::info!(
538                        "   Context: {} resources, {} dependencies",
539                        num_resources,
540                        num_dependencies
541                    );
542                    tracing::debug!("   Context digest: {}", context_digest);
543                }
544
545                // Step 2: Render the raw frontmatter text (which may contain template syntax)
546                let frontmatter_template = format!("---\n{}\n---\n", raw_frontmatter_text);
547
548                let mut renderer = TemplateRenderer::new(
549                    true,
550                    context.project_dir.to_path_buf(),
551                    context.max_content_file_size,
552                )
553                .with_context(|| "Failed to create template renderer")?;
554
555                let rendered_frontmatter = renderer
556                    .render_template(&frontmatter_template, &template_context)
557                    .map_err(|e| {
558                        tracing::error!(
559                            "Frontmatter rendering failed for resource '{}': {}",
560                            entry.name,
561                            e
562                        );
563                        e
564                    })
565                    .with_context(|| {
566                        let manifest_alias_str = entry
567                            .manifest_alias
568                            .as_ref()
569                            .map(|a| format!(", manifest_alias=\"{}\"", a))
570                            .unwrap_or_default();
571                        let source_str = entry
572                            .source
573                            .as_ref()
574                            .map(|s| format!(", source=\"{}\"", s))
575                            .unwrap_or_default();
576                        let tool_str = entry
577                            .tool
578                            .as_ref()
579                            .map(|t| format!(", tool=\"{}\"", t))
580                            .unwrap_or_default();
581                        let commit_str = entry
582                            .resolved_commit
583                            .as_ref()
584                            .map(|c| format!(", resolved_commit=\"{}\"", &c[..8.min(c.len())]))
585                            .unwrap_or_default();
586
587                        // Try to find parent resources if lockfile is available
588                        let parent_str = if let Some(lf) = context.lockfile {
589                            let parents = find_parent_resources(lf, &entry.name);
590                            if !parents.is_empty() {
591                                format!(", required_by=\"{}\"", parents.join(", "))
592                            } else {
593                                String::new()
594                            }
595                        } else {
596                            String::new()
597                        };
598
599                        format!(
600                            "Failed to render frontmatter for canonical_name=\"{}\"{}{}{}{}{}",
601                            entry.name,
602                            manifest_alias_str,
603                            source_str,
604                            tool_str,
605                            commit_str,
606                            parent_str
607                        )
608                    })?;
609
610                // Step 3: Parse the rendered frontmatter to check agpm.templating flag
611                // If parsing fails, use original content entirely (no templating)
612                let (templating_enabled, yaml_parse_failed) = match MarkdownFile::parse(
613                    &rendered_frontmatter,
614                ) {
615                    Ok(parsed_rendered) => (
616                        parsed_rendered
617                            .metadata
618                            .as_ref()
619                            .and_then(|m| m.extra.get("agpm"))
620                            .and_then(|agpm| agpm.get("templating"))
621                            .and_then(|v| v.as_bool())
622                            .unwrap_or(false),
623                        false,
624                    ),
625                    Err(e) => {
626                        // Parsing failed - frontmatter is invalid even after rendering
627                        // Emit warning and fall back to using original content as-is
628                        eprintln!(
629                            "Warning: Unable to parse YAML frontmatter in '{}' after template rendering.\n\
630                        The file will be installed as-is without processing.\n\
631                        Parse error: {}\n",
632                            entry.name, e
633                        );
634                        tracing::debug!(
635                            "Failed to parse rendered frontmatter for {}, using original content",
636                            entry.name
637                        );
638                        (false, true)
639                    }
640                };
641
642                tracing::debug!(
643                    "Resource '{}': templating_enabled={}",
644                    entry.name,
645                    templating_enabled
646                );
647
648                // If YAML parsing failed, use original content entirely
649                if yaml_parse_failed {
650                    break 'templating Some((patched_content.clone(), false, None));
651                }
652                // Step 4: Conditionally render the body based on agpm.templating flag
653                let final_body = if templating_enabled {
654                    // Render the body through Tera
655                    let mut renderer = TemplateRenderer::new(
656                        true,
657                        context.project_dir.to_path_buf(),
658                        context.max_content_file_size,
659                    )
660                    .with_context(|| "Failed to create template renderer")?;
661
662                    renderer
663                        .render_template(&body_content, &template_context)
664                        .map_err(|e| {
665                            tracing::error!(
666                                "Body rendering failed for resource '{}': {}",
667                                entry.name,
668                                e
669                            );
670                            for (i, cause) in e.chain().skip(1).enumerate() {
671                                tracing::error!("  Caused by [{}]: {}", i + 1, cause);
672                            }
673                            e
674                        })
675                        .with_context(|| {
676                            let manifest_alias_str = entry
677                                .manifest_alias
678                                .as_ref()
679                                .map(|a| format!(", manifest_alias=\"{}\"", a))
680                                .unwrap_or_default();
681                            let source_str = entry
682                                .source
683                                .as_ref()
684                                .map(|s| format!(", source=\"{}\"", s))
685                                .unwrap_or_default();
686                            let tool_str = entry
687                                .tool
688                                .as_ref()
689                                .map(|t| format!(", tool=\"{}\"", t))
690                                .unwrap_or_default();
691                            let commit_str = entry
692                                .resolved_commit
693                                .as_ref()
694                                .map(|c| format!(", resolved_commit=\"{}\"", &c[..8.min(c.len())]))
695                                .unwrap_or_default();
696
697                            // Try to find parent resources if lockfile is available
698                            let parent_str = if let Some(lf) = context.lockfile {
699                                let parents = find_parent_resources(lf, &entry.name);
700                                if !parents.is_empty() {
701                                    format!(", required_by=\"{}\"", parents.join(", "))
702                                } else {
703                                    String::new()
704                                }
705                            } else {
706                                String::new()
707                            };
708
709                            format!(
710                                "Failed to render body for canonical_name=\"{}\"{}{}{}{}{}",
711                                entry.name,
712                                manifest_alias_str,
713                                source_str,
714                                tool_str,
715                                commit_str,
716                                parent_str
717                            )
718                        })?
719                } else {
720                    // Use original body content without rendering
721                    tracing::debug!(
722                        "agpm.templating not enabled for {}, using original body content",
723                        entry.name
724                    );
725                    body_content.clone()
726                };
727
728                // Step 5: Combine rendered frontmatter with body
729                // The rendered frontmatter ends with "---\n", body starts after
730                let mut final_content = rendered_frontmatter;
731                final_content.push_str(&final_body);
732
733                // Preserve trailing newline from original if present
734                if patched_content.ends_with('\n') && !final_content.ends_with('\n') {
735                    final_content.push('\n');
736                }
737
738                if templating_enabled && context.verbose {
739                    // Show verbose output after rendering
740                    let size_bytes = final_content.len();
741                    let size_str = if size_bytes < 1024 {
742                        format!("{} B", size_bytes)
743                    } else if size_bytes < 1024 * 1024 {
744                        format!("{:.1} KB", size_bytes as f64 / 1024.0)
745                    } else {
746                        format!("{:.1} MB", size_bytes as f64 / (1024.0 * 1024.0))
747                    };
748                    tracing::info!("   Output: {} ({})", dest_path.display(), size_str);
749                    tracing::info!("✅ Template rendered successfully");
750                }
751
752                Some((
753                    final_content,
754                    templating_enabled,
755                    if templating_enabled {
756                        captured_context_checksum
757                    } else {
758                        None
759                    },
760                ))
761            };
762
763            // Unwrap templating result or fall back to patched content
764            templating_result.unwrap_or((patched_content, false, None))
765        }
766    } else {
767        tracing::debug!("Not a markdown file: {}", entry.path);
768        (patched_content, false, None)
769    };
770
771    // Calculate file checksum of final content (after patching and templating)
772    let file_checksum = {
773        use sha2::{Digest, Sha256};
774        let mut hasher = Sha256::new();
775        hasher.update(final_content.as_bytes());
776        let hash = hasher.finalize();
777        format!("sha256:{}", hex::encode(hash))
778    };
779
780    // Use captured context checksum from rendering (avoid rebuilding context)
781    let context_checksum = if templating_was_applied {
782        captured_checksum
783    } else {
784        None
785    };
786
787    // Reinstall decision: trust file checksum, ignore context checksum
788    let content_changed = existing_checksum.as_ref() != Some(&file_checksum);
789
790    // Check if we should actually write the file to disk
791    let should_install = entry.install.unwrap_or(true);
792
793    let actually_installed = if should_install && content_changed {
794        // Only write if install=true and content is different or file doesn't exist
795        if let Some(parent) = dest_path.parent() {
796            ensure_dir(parent)?;
797        }
798
799        // Add to .gitignore BEFORE writing file to prevent accidental commits
800        if let Some(lock) = context.gitignore_lock {
801            // Calculate relative path for gitignore
802            let relative_path = dest_path
803                .strip_prefix(context.project_dir)
804                .unwrap_or(&dest_path)
805                .to_string_lossy()
806                .to_string();
807
808            add_path_to_gitignore(context.project_dir, &relative_path, lock)
809                .await
810                .with_context(|| format!("Failed to add {} to .gitignore", relative_path))?;
811        }
812
813        atomic_write(&dest_path, final_content.as_bytes())
814            .with_context(|| format!("Failed to install resource to {}", dest_path.display()))?;
815
816        true
817    } else if !should_install {
818        // install=false: content-only dependency, don't write file
819        tracing::debug!(
820            "Skipping file write for content-only dependency: {} (install=false)",
821            entry.name
822        );
823        false
824    } else {
825        // install=true but content unchanged
826        false
827    };
828
829    Ok((actually_installed, file_checksum, context_checksum, applied_patches))
830}
831
832/// Install a single resource with progress bar updates for user feedback.
833///
834/// This function wraps [`install_resource`] with progress bar integration to provide
835/// real-time feedback during resource installation. It updates the progress bar
836/// message before delegating to the core installation logic.
837///
838/// # Arguments
839///
840/// * `entry` - The locked resource containing installation metadata
841/// * `project_dir` - Root project directory for installation target
842/// * `resource_dir` - Subdirectory name for this resource type (e.g., "agents")
843/// * `cache` - Cache instance for Git repository and worktree management
844/// * `force_refresh` - Whether to force refresh of cached repositories
845/// * `pb` - Progress bar to update with installation status
846///
847/// # Returns
848///
849/// Returns a tuple of:
850/// - `bool`: Whether the resource was actually installed (`true` for new/updated, `false` for unchanged)
851/// - `String`: SHA-256 checksum of the installed file content
852/// - `Option<String>`: SHA-256 checksum of the template rendering inputs, or None for non-templated resources
853/// - `AppliedPatches`: Information about any patches that were applied during installation
854///
855/// # Progress Integration
856///
857/// The function automatically sets the progress bar message to indicate which
858/// resource is currently being installed. This provides users with real-time
859/// feedback about installation progress.
860///
861/// # Examples
862///
863/// ```rust,no_run
864/// use agpm_cli::installer::{install_resource_with_progress, InstallContext};
865/// use agpm_cli::lockfile::{LockedResource, LockedResourceBuilder};
866/// use agpm_cli::cache::Cache;
867/// use agpm_cli::core::ResourceType;
868/// use agpm_cli::utils::progress::ProgressBar;
869/// use std::path::Path;
870///
871/// # async fn example() -> anyhow::Result<()> {
872/// let cache = Cache::new()?;
873/// let pb = ProgressBar::new(1);
874/// let entry = LockedResourceBuilder::new(
875///     "example-agent".to_string(),
876///     "agents/example.md".to_string(),
877///     "sha256:...".to_string(),
878///     ".claude/agents/example.md".to_string(),
879///     ResourceType::Agent,
880/// )
881/// .source(Some("community".to_string()))
882/// .url(Some("https://github.com/example/repo.git".to_string()))
883/// .version(Some("v1.0.0".to_string()))
884/// .resolved_commit(Some("abc123".to_string()))
885/// .tool(Some("claude-code".to_string()))
886/// .build();
887///
888/// let context = InstallContext::new(Path::new("."), &cache, false, false, None, None, None, None, None, None, None);
889/// let (installed, checksum, _old_checksum, _patches) = install_resource_with_progress(
890///     &entry,
891///     "agents",
892///     &context,
893///     &pb
894/// ).await?;
895///
896/// pb.inc(1);
897/// # Ok(())
898/// # }
899/// ```
900///
901/// # Errors
902///
903/// Returns the same errors as [`install_resource`], including:
904/// - Repository access failures
905/// - File system operation errors
906/// - Invalid markdown content
907/// - Git worktree creation failures
908pub async fn install_resource_with_progress(
909    entry: &LockedResource,
910    resource_dir: &str,
911    context: &InstallContext<'_>,
912    pb: &ProgressBar,
913) -> Result<(bool, String, Option<String>, crate::manifest::patches::AppliedPatches)> {
914    pb.set_message(format!("Installing {}", entry.name));
915    install_resource(entry, resource_dir, context).await
916}
917
918/// Install a single resource in a thread-safe manner for parallel execution.
919///
920/// This is a private helper function used by parallel installation operations.
921/// It's a thin wrapper around [`install_resource`] designed for use in parallel
922/// installation streams.
923pub(crate) async fn install_resource_for_parallel(
924    entry: &LockedResource,
925    resource_dir: &str,
926    context: &InstallContext<'_>,
927) -> Result<(bool, String, Option<String>, crate::manifest::patches::AppliedPatches)> {
928    install_resource(entry, resource_dir, context).await
929}
930
931/// Filtering options for resource installation operations.
932///
933/// This enum controls which resources are processed during installation,
934/// enabling both full installations and selective updates. The filter
935/// determines which entries from the lockfile are actually installed.
936///
937/// # Use Cases
938///
939/// - **Full installations**: Install all resources defined in lockfile
940/// - **Selective updates**: Install only resources that have been updated
941/// - **Performance optimization**: Avoid reinstalling unchanged resources
942/// - **Incremental deployments**: Update only what has changed
943///
944/// # Variants
945///
946/// ## All Resources
947/// [`ResourceFilter::All`] processes every resource entry in the lockfile,
948/// regardless of whether it has changed. This is used by the install command
949/// for complete environment setup.
950///
951/// ## Updated Resources Only
952/// [`ResourceFilter::Updated`] processes only resources that have version
953/// changes, as tracked by the update command. This enables efficient
954/// incremental updates without full reinstallation.
955///
956/// # Examples
957///
958/// Install all resources:
959/// ```rust,no_run
960/// use agpm_cli::installer::ResourceFilter;
961///
962/// let filter = ResourceFilter::All;
963/// // This will install every resource in the lockfile
964/// ```
965///
966/// Install only updated resources:
967/// ```rust,no_run
968/// use agpm_cli::installer::ResourceFilter;
969///
970/// let updates = vec![
971///     ("agent1".to_string(), None, "v1.0.0".to_string(), "v1.1.0".to_string()),
972///     ("tool2".to_string(), Some("community".to_string()), "v2.1.0".to_string(), "v2.2.0".to_string()),
973/// ];
974/// let filter = ResourceFilter::Updated(updates);
975/// // This will install only agent1 and tool2
976/// ```
977///
978/// # Update Tuple Format
979///
980/// For [`ResourceFilter::Updated`], each tuple contains:
981/// - `name`: Resource name as defined in the manifest
982/// - `old_version`: Previous version (for logging and tracking)
983/// - `new_version`: New version to install
984///
985/// The old version is primarily used for user feedback and logging,
986/// while the new version determines what gets installed.
987pub enum ResourceFilter {
988    /// Install all resources from the lockfile.
989    ///
990    /// This option processes every resource entry in the lockfile,
991    /// installing or updating each one regardless of whether it has
992    /// changed since the last installation.
993    All,
994
995    /// Install only specific updated resources.
996    ///
997    /// This option processes only the resources specified in the update list,
998    /// allowing for efficient incremental updates. Each tuple contains:
999    /// - Resource name
1000    /// - Source name (None for local resources)
1001    /// - Old version (for tracking)
1002    /// - New version (to install)
1003    Updated(Vec<(String, Option<String>, String, String)>),
1004}
1005
1006/// Resource installation function supporting multiple progress configurations.
1007///
1008/// This function consolidates all resource installation patterns into a single, flexible
1009/// interface that can handle both full installations and selective updates with different
1010/// progress reporting mechanisms. It represents the modernized installation architecture
1011/// introduced in AGPM v0.3.0.
1012///
1013/// # Architecture Benefits
1014///
1015/// - **Single API**: Single function handles install and update commands
1016/// - **Flexible progress**: Supports dynamic, simple, and quiet progress modes
1017/// - **Selective installation**: Can install all resources or just updated ones
1018/// - **Optimal concurrency**: Leverages worktree-based parallel operations
1019/// - **Cache efficiency**: Integrates with instance-level caching systems
1020///
1021/// # Parameters
1022///
1023/// * `filter` - Determines which resources to install ([`ResourceFilter::All`] or [`ResourceFilter::Updated`])
1024/// * `lockfile` - The lockfile containing all resource definitions to install
1025/// * `manifest` - The project manifest providing configuration and target directories
1026/// * `project_dir` - Root directory where resources should be installed
1027/// * `cache` - Cache instance for Git repository and worktree management
1028/// * `force_refresh` - Whether to force refresh of cached repositories
1029/// * `max_concurrency` - Optional limit on concurrent operations (None = unlimited)
1030/// * `progress` - Optional multi-phase progress manager ([`MultiPhaseProgress`])
1031///
1032/// # Progress Reporting
1033///
1034/// Progress is reported through the optional [`MultiPhaseProgress`] parameter:
1035/// - **Enabled**: Pass `Some(progress)` for multi-phase progress with live updates
1036/// - **Disabled**: Pass `None` for quiet operation (scripts and automation)
1037///
1038/// # Installation Process
1039///
1040/// 1. **Resource filtering**: Collects entries based on filter criteria
1041/// 2. **Cache warming**: Pre-creates worktrees for all unique repositories
1042/// 3. **Parallel installation**: Processes resources with configured concurrency
1043/// 4. **Progress coordination**: Updates progress based on configuration
1044/// 5. **Error aggregation**: Collects and reports any installation failures
1045///
1046/// # Concurrency Behavior
1047///
1048/// The function implements advanced parallel processing:
1049/// - **Pre-warming phase**: Creates all needed worktrees upfront for maximum parallelism
1050/// - **Parallel execution**: Each resource installed in its own async task
1051/// - **Concurrency control**: `max_concurrency` limits simultaneous operations
1052/// - **Thread safety**: Progress updates are atomic and thread-safe
1053///
1054/// # Returns
1055///
1056/// Returns a tuple of:
1057/// - The number of resources that were actually installed (new or updated content).
1058///   Resources that already exist with identical content are not counted.
1059/// - A vector of (`resource_name`, checksum) pairs for all processed resources
1060///
1061/// # Errors
1062///
1063/// Returns an error if any resource installation fails. The error includes details
1064/// about all failed installations with specific error messages for debugging.
1065///
1066/// # Examples
1067///
1068/// Install all resources with progress tracking:
1069/// ```rust,no_run
1070/// use agpm_cli::installer::{install_resources, ResourceFilter};
1071/// use agpm_cli::utils::progress::MultiPhaseProgress;
1072/// use agpm_cli::lockfile::LockFile;
1073/// use agpm_cli::manifest::Manifest;
1074/// use agpm_cli::cache::Cache;
1075/// use std::sync::Arc;
1076/// use std::path::Path;
1077///
1078/// # async fn example() -> anyhow::Result<()> {
1079/// # let lockfile = Arc::new(LockFile::default());
1080/// # let manifest = Manifest::default();
1081/// # let project_dir = Path::new(".");
1082/// # let cache = Cache::new()?;
1083/// let progress = Arc::new(MultiPhaseProgress::new(true));
1084///
1085/// let (count, _checksums, _old_checksums, _patches) = install_resources(
1086///     ResourceFilter::All,
1087///     &lockfile,
1088///     &manifest,
1089///     &project_dir,
1090///     cache,
1091///     false,
1092///     Some(8), // Limit to 8 concurrent operations
1093///     Some(progress),
1094///     false, // verbose
1095///     None, // old_lockfile
1096/// ).await?;
1097///
1098/// println!("Installed {} resources", count);
1099/// # Ok(())
1100/// # }
1101/// ```
1102///
1103/// Install resources quietly (for automation):
1104/// ```rust,no_run
1105/// use agpm_cli::installer::{install_resources, ResourceFilter};
1106/// use agpm_cli::lockfile::LockFile;
1107/// use agpm_cli::manifest::Manifest;
1108/// use agpm_cli::cache::Cache;
1109/// use std::path::Path;
1110/// use std::sync::Arc;
1111///
1112/// # async fn example() -> anyhow::Result<()> {
1113/// # let lockfile = Arc::new(LockFile::default());
1114/// # let manifest = Manifest::default();
1115/// # let project_dir = Path::new(".");
1116/// # let cache = Cache::new()?;
1117/// let updates = vec![("agent1".to_string(), None, "v1.0".to_string(), "v1.1".to_string())];
1118///
1119/// let (count, _checksums, _old_checksums, _patches) = install_resources(
1120///     ResourceFilter::Updated(updates),
1121///     &lockfile,
1122///     &manifest,
1123///     &project_dir,
1124///     cache,
1125///     false,
1126///     None, // Unlimited concurrency
1127///     None, // No progress output
1128///     false, // verbose
1129///     None, // old_lockfile
1130/// ).await?;
1131///
1132/// println!("Updated {} resources", count);
1133/// # Ok(())
1134/// # }
1135/// ```
1136#[allow(clippy::too_many_arguments)]
1137pub async fn install_resources(
1138    filter: ResourceFilter,
1139    lockfile: &Arc<LockFile>,
1140    manifest: &Manifest,
1141    project_dir: &Path,
1142    cache: Cache,
1143    force_refresh: bool,
1144    max_concurrency: Option<usize>,
1145    progress: Option<Arc<MultiPhaseProgress>>,
1146    verbose: bool,
1147    old_lockfile: Option<&LockFile>,
1148) -> Result<(
1149    usize,
1150    Vec<(crate::lockfile::ResourceId, String)>,
1151    Vec<(crate::lockfile::ResourceId, Option<String>)>,
1152    Vec<(crate::lockfile::ResourceId, crate::manifest::patches::AppliedPatches)>,
1153)> {
1154    // Collect entries to install based on filter
1155    let all_entries: Vec<(LockedResource, String)> = match filter {
1156        ResourceFilter::All => {
1157            // Use existing ResourceIterator logic for all entries
1158            ResourceIterator::collect_all_entries(lockfile, manifest)
1159                .into_iter()
1160                .map(|(entry, dir)| (entry.clone(), dir.into_owned()))
1161                .collect()
1162        }
1163        ResourceFilter::Updated(ref updates) => {
1164            // Collect only the updated entries
1165            let mut entries = Vec::new();
1166            for (name, source, _, _) in updates {
1167                if let Some((resource_type, entry)) =
1168                    ResourceIterator::find_resource_by_name_and_source(
1169                        lockfile,
1170                        name,
1171                        source.as_deref(),
1172                    )
1173                {
1174                    // Get artifact configuration path
1175                    let tool = entry.tool.as_deref().unwrap_or("claude-code");
1176                    let artifact_path = manifest
1177                        .get_artifact_resource_path(tool, resource_type)
1178                        .expect("Resource type should be supported by configured tools");
1179                    let target_dir = artifact_path.display().to_string();
1180                    entries.push((entry.clone(), target_dir));
1181                }
1182            }
1183            entries
1184        }
1185    };
1186
1187    if all_entries.is_empty() {
1188        return Ok((0, Vec::new(), Vec::new(), Vec::new()));
1189    }
1190
1191    // Sort entries for deterministic processing order
1192    // This ensures context checksums are deterministic even when lockfile isn't normalized yet
1193    let mut all_entries = all_entries;
1194    all_entries.sort_by(|(a, _), (b, _)| {
1195        a.resource_type.cmp(&b.resource_type).then_with(|| a.name.cmp(&b.name))
1196    });
1197
1198    let total = all_entries.len();
1199
1200    // Start installation phase with progress if provided
1201    if let Some(ref pm) = progress {
1202        pm.start_phase_with_progress(InstallationPhase::InstallingResources, total);
1203    }
1204
1205    // Pre-warm the cache by creating all needed worktrees upfront
1206    let mut unique_worktrees = HashSet::new();
1207    for (entry, _) in &all_entries {
1208        if let Some(source_name) = &entry.source
1209            && let Some(url) = &entry.url
1210        {
1211            // Only pre-warm if we have a valid SHA
1212            if let Some(sha) = entry.resolved_commit.as_ref().filter(|commit| {
1213                commit.len() == 40 && commit.chars().all(|c| c.is_ascii_hexdigit())
1214            }) {
1215                unique_worktrees.insert((source_name.clone(), url.clone(), sha.clone()));
1216            }
1217        }
1218    }
1219
1220    if !unique_worktrees.is_empty() {
1221        let context = match filter {
1222            ResourceFilter::All => "pre-warm",
1223            ResourceFilter::Updated(_) => "update-pre-warm",
1224        };
1225
1226        let worktree_futures: Vec<_> = unique_worktrees
1227            .into_iter()
1228            .map(|(source, url, sha)| {
1229                let cache = cache.clone();
1230                async move {
1231                    cache
1232                        .get_or_create_worktree_for_sha(&source, &url, &sha, Some(context))
1233                        .await
1234                        .ok(); // Ignore errors during pre-warming
1235                }
1236            })
1237            .collect();
1238
1239        // Execute all worktree creations in parallel
1240        future::join_all(worktree_futures).await;
1241    }
1242
1243    // Create thread-safe progress tracking
1244    let installed_count = Arc::new(Mutex::new(0));
1245    let concurrency = max_concurrency.unwrap_or(usize::MAX).max(1);
1246
1247    // Create gitignore lock for thread-safe gitignore updates
1248    let gitignore_lock = Arc::new(Mutex::new(()));
1249
1250    // Update initial progress message
1251    if let Some(ref pm) = progress {
1252        pm.update_current_message(&format!("Installing 0/{total} resources"));
1253    }
1254
1255    // Process installations in parallel
1256    let results: Vec<InstallResult> = stream::iter(all_entries)
1257        .map(|(entry, resource_dir)| {
1258            let project_dir = project_dir.to_path_buf();
1259            let installed_count = Arc::clone(&installed_count);
1260            let cache = cache.clone();
1261            let progress = progress.clone();
1262            let gitignore_lock = Arc::clone(&gitignore_lock);
1263
1264            async move {
1265                // Update progress message for current resource
1266                if let Some(ref pm) = progress {
1267                    pm.update_current_message(&format!("Installing {}", entry.name));
1268                }
1269
1270                let install_context = InstallContext::new(
1271                    &project_dir,
1272                    &cache,
1273                    force_refresh,
1274                    verbose,
1275                    Some(manifest),
1276                    Some(lockfile),
1277                    old_lockfile, // Pass old_lockfile for early-exit optimization
1278                    Some(&manifest.project_patches),
1279                    Some(&manifest.private_patches),
1280                    Some(&gitignore_lock),
1281                    None, // max_content_file_size - not available in install_resources context
1282                );
1283
1284                let res =
1285                    install_resource_for_parallel(&entry, &resource_dir, &install_context).await;
1286
1287                // Update progress on success - but only count if actually installed
1288                if let Ok((
1289                    actually_installed,
1290                    _file_checksum,
1291                    _context_checksum,
1292                    _applied_patches,
1293                )) = &res
1294                {
1295                    if *actually_installed {
1296                        let mut count = installed_count.lock().await;
1297                        *count += 1;
1298                    }
1299
1300                    if let Some(ref pm) = progress {
1301                        let count = *installed_count.lock().await;
1302                        pm.update_current_message(&format!("Installing {count}/{total} resources"));
1303                        pm.increment_progress(1);
1304                    }
1305                }
1306
1307                match res {
1308                    Ok((installed, file_checksum, context_checksum, applied_patches)) => Ok((
1309                        entry.id(),
1310                        installed,
1311                        file_checksum,
1312                        context_checksum,
1313                        applied_patches,
1314                    )),
1315                    Err(err) => Err((entry.id(), err)),
1316                }
1317            }
1318        })
1319        .buffered(concurrency) // Use buffered instead of buffer_unordered to preserve input order for deterministic checksums
1320        .collect()
1321        .await;
1322
1323    // Handle errors and collect checksums, context checksums, and applied patches
1324    let mut errors = Vec::new();
1325    let mut checksums = Vec::new();
1326    let mut context_checksums = Vec::new();
1327    let mut applied_patches_list = Vec::new();
1328    for result in results {
1329        match result {
1330            Ok((id, _installed, file_checksum, context_checksum, applied_patches)) => {
1331                checksums.push((id.clone(), file_checksum));
1332                context_checksums.push((id.clone(), context_checksum));
1333                applied_patches_list.push((id, applied_patches));
1334            }
1335            Err((id, error)) => {
1336                errors.push((id, error));
1337            }
1338        }
1339    }
1340
1341    if !errors.is_empty() {
1342        // Complete phase with error message
1343        if let Some(ref pm) = progress {
1344            pm.complete_phase(Some(&format!("Failed to install {} resources", errors.len())));
1345        }
1346
1347        // Format each error with full context using user_friendly_error
1348        use crate::core::error::user_friendly_error;
1349        let error_msgs: Vec<String> = errors
1350            .into_iter()
1351            .map(|(id, error)| {
1352                // Convert error to user-friendly format to get enhanced context
1353                let error_ctx = user_friendly_error(error);
1354                // Format with resource name and the full error message
1355                format!("  {}:\n    {}", id.name(), error_ctx.to_string().replace('\n', "\n    "))
1356            })
1357            .collect();
1358
1359        return Err(anyhow::anyhow!(
1360            "Failed to install {} resources:\n{}",
1361            error_msgs.len(),
1362            error_msgs.join("\n\n")
1363        ));
1364    }
1365
1366    let final_count = *installed_count.lock().await;
1367
1368    // Complete installation phase successfully
1369    if let Some(ref pm) = progress
1370        && final_count > 0
1371    {
1372        pm.complete_phase(Some(&format!("Installed {final_count} resources")));
1373    }
1374
1375    Ok((final_count, checksums, context_checksums, applied_patches_list))
1376}
1377
1378/// Find parent resources that depend on the given resource.
1379///
1380/// This function searches through the lockfile to find resources that list
1381/// the given resource name in their `dependencies` field. This is useful for
1382/// error reporting to show which resources depend on a failing resource.
1383///
1384/// # Arguments
1385///
1386/// * `lockfile` - The lockfile to search
1387/// * `resource_name` - The canonical name of the resource to find parents for
1388///
1389/// # Returns
1390///
1391/// A vector of parent resource names (manifest aliases if available, otherwise
1392/// canonical names) that directly depend on the given resource.
1393///
1394/// # Examples
1395///
1396/// ```rust,no_run
1397/// use agpm_cli::lockfile::LockFile;
1398/// use agpm_cli::installer::find_parent_resources;
1399///
1400/// let lockfile = LockFile::default();
1401/// let parents = find_parent_resources(&lockfile, "agents/helper");
1402/// if !parents.is_empty() {
1403///     println!("Resource is required by: {}", parents.join(", "));
1404/// }
1405/// ```
1406pub fn find_parent_resources(lockfile: &LockFile, resource_name: &str) -> Vec<String> {
1407    use crate::core::ResourceIterator;
1408
1409    let mut parents = Vec::new();
1410
1411    // Iterate through all resources in the lockfile
1412    for (entry, _dir) in
1413        ResourceIterator::collect_all_entries(lockfile, &crate::manifest::Manifest::default())
1414    {
1415        // Check if this resource depends on the target resource
1416        if entry.dependencies.iter().any(|dep| dep == resource_name) {
1417            // Use manifest_alias if available (user-facing name), otherwise canonical name
1418            let parent_name = entry.manifest_alias.as_ref().unwrap_or(&entry.name).clone();
1419            parents.push(parent_name);
1420        }
1421    }
1422
1423    parents
1424}