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}