agpm_cli/installer/selective.rs
1//! Selective installation support for updated resources.
2//!
3//! This module provides targeted installation of only resources that have been
4//! updated, rather than reinstalling all resources. It's designed for efficient
5//! update operations where only a subset of dependencies have changed.
6
7use anyhow::Result;
8use futures::stream::{self, StreamExt};
9use std::collections::HashSet;
10use std::sync::Arc;
11use tokio::sync::Mutex;
12
13use crate::core::ResourceIterator;
14use crate::lockfile::LockFile;
15use crate::manifest::Manifest;
16use crate::utils::progress::ProgressBar;
17
18use super::{InstallContext, install_resource_for_parallel};
19
20/// Install only specific updated resources in parallel (selective installation).
21///
22/// This function provides targeted installation of only the resources that have
23/// been updated, rather than reinstalling all resources. It's designed for
24/// efficient update operations where only a subset of dependencies have changed.
25/// The function uses the same parallel processing architecture as full installations
26/// but operates on a filtered set of resources.
27///
28/// # Arguments
29///
30/// * `updates` - Vector of tuples containing (name, `old_version`, `new_version`) for each updated resource
31/// * `lockfile` - Lockfile containing all available resources (updated resources must exist here)
32/// * `manifest` - Project manifest providing configuration and target directories
33/// * `project_dir` - Root directory where resources will be installed
34/// * `cache` - Cache instance for Git repository and worktree management
35/// * `pb` - Optional progress bar for user feedback during installation
36/// * `_quiet` - Quiet mode flag (currently unused, maintained for API compatibility)
37///
38/// # Update Tuple Format
39///
40/// Each update tuple contains:
41/// - `name`: Resource name as defined in the lockfile
42/// - `old_version`: Previous version (used for logging and user feedback)
43/// - `new_version`: New version that will be installed
44///
45/// # Selective Processing
46///
47/// The function implements selective resource processing:
48/// 1. **Filtering**: Only processes resources listed in the `updates` vector
49/// 2. **Lookup**: Finds corresponding entries in the lockfile for each update
50/// 3. **Validation**: Ensures all specified resources exist before processing
51/// 4. **Installation**: Uses the same parallel architecture as full installations
52///
53/// # Examples
54///
55/// ```rust,no_run
56/// use agpm_cli::installer::{install_updated_resources, InstallContext};
57/// use agpm_cli::lockfile::LockFile;
58/// use agpm_cli::manifest::Manifest;
59/// use agpm_cli::cache::Cache;
60/// use agpm_cli::utils::progress::ProgressBar;
61/// use std::path::Path;
62/// use std::sync::Arc;
63///
64/// # async fn example() -> anyhow::Result<()> {
65/// let lockfile = Arc::new(LockFile::load(Path::new("agpm.lock"))?);
66/// let manifest = Manifest::load(Path::new("agpm.toml"))?;
67/// let cache = Cache::new()?;
68/// let pb = ProgressBar::new(3);
69///
70/// // Define which resources to update
71/// let updates = vec![
72/// ("ai-agent".to_string(), None, "v1.0.0".to_string(), "v1.1.0".to_string()),
73/// ("helper-tool".to_string(), Some("community".to_string()), "v2.0.0".to_string(), "v2.1.0".to_string()),
74/// ("data-processor".to_string(), None, "v1.5.0".to_string(), "v1.6.0".to_string()),
75/// ];
76///
77/// let context = InstallContext::new(Path::new("."), &cache, false, false, Some(&manifest), Some(&lockfile), None, None, None, None, None);
78/// let count = install_updated_resources(
79/// &updates,
80/// &lockfile,
81/// &manifest,
82/// &context,
83/// Some(&pb),
84/// false
85/// ).await?;
86///
87/// println!("Updated {} resources", count);
88/// # Ok(())
89/// # }
90/// ```
91///
92/// # Performance Benefits
93///
94/// Selective installation provides significant performance benefits:
95/// - **Reduced processing**: Only installs resources that have actually changed
96/// - **Faster execution**: Avoids redundant operations on unchanged resources
97/// - **Network efficiency**: Only fetches Git data for repositories with updates
98/// - **Disk efficiency**: Minimizes file system operations and cache usage
99///
100/// # Integration with Update Command
101///
102/// This function is typically used by the `agpm update` command after dependency
103/// resolution determines which resources have new versions available:
104///
105/// ```text
106/// Update Flow:
107/// 1. Resolve dependencies → identify version changes
108/// 2. Update lockfile → record new versions and checksums
109/// 3. Selective installation → install only changed resources
110/// ```
111///
112/// # Returns
113///
114/// Returns the total number of resources that were successfully installed.
115/// This represents the actual number of files that were updated on disk.
116///
117/// # Errors
118///
119/// Returns an error if:
120/// - Any specified resource name is not found in the lockfile
121/// - Git repository access fails for resources being updated
122/// - File system operations fail during installation
123/// - Any individual resource installation encounters an error
124///
125/// The function uses atomic error handling - if any resource fails, the entire
126/// operation fails and detailed error information is provided.
127pub async fn install_updated_resources(
128 updates: &[(String, Option<String>, String, String)], // (name, source, old_version, new_version)
129 lockfile: &Arc<LockFile>,
130 manifest: &Manifest,
131 install_ctx: &InstallContext<'_>,
132 pb: Option<&ProgressBar>,
133 _quiet: bool,
134) -> Result<usize> {
135 let project_dir = install_ctx.project_dir;
136 let cache = install_ctx.cache;
137 if updates.is_empty() {
138 return Ok(0);
139 }
140
141 let total = updates.len();
142
143 // Collect all entries to install
144 let mut entries_to_install = Vec::new();
145 for (name, source, _, _) in updates {
146 if let Some((resource_type, entry)) =
147 ResourceIterator::find_resource_by_name_and_source(lockfile, name, source.as_deref())
148 {
149 // Get artifact configuration path
150 let tool = entry.tool.as_deref().unwrap_or("claude-code");
151 let artifact_path = manifest
152 .get_artifact_resource_path(tool, resource_type)
153 .expect("Resource type should be supported by configured tools");
154 let target_dir = artifact_path.display().to_string();
155 entries_to_install.push((entry.clone(), target_dir));
156 }
157 }
158
159 if entries_to_install.is_empty() {
160 return Ok(0);
161 }
162
163 // Pre-warm the cache by creating all needed worktrees upfront
164 if let Some(pb) = pb {
165 pb.set_message("Preparing resources...");
166 }
167
168 // Collect unique (source, url, sha) triples to pre-create worktrees
169 let mut unique_worktrees = HashSet::new();
170 for (entry, _) in &entries_to_install {
171 if let Some(source_name) = &entry.source
172 && let Some(url) = &entry.url
173 {
174 // Only pre-warm if we have a valid SHA
175 if let Some(sha) = entry.resolved_commit.as_ref().filter(|commit| {
176 commit.len() == 40 && commit.chars().all(|c| c.is_ascii_hexdigit())
177 }) {
178 unique_worktrees.insert((source_name.clone(), url.clone(), sha.clone()));
179 }
180 }
181 }
182
183 // Pre-create all worktrees in parallel
184 if !unique_worktrees.is_empty() {
185 use futures::future;
186 let worktree_futures: Vec<_> = unique_worktrees
187 .into_iter()
188 .map(|(source, url, sha)| {
189 async move {
190 cache
191 .get_or_create_worktree_for_sha(
192 &source,
193 &url,
194 &sha,
195 Some("update-pre-warm"),
196 )
197 .await
198 .ok(); // Ignore errors during pre-warming
199 }
200 })
201 .collect();
202
203 // Execute all worktree creations in parallel
204 future::join_all(worktree_futures).await;
205 }
206
207 // Create thread-safe progress tracking
208 let installed_count = Arc::new(Mutex::new(0));
209 let pb = pb.map(Arc::new);
210 let cache = Arc::new(cache);
211
212 // Set initial progress
213 if let Some(ref pb) = pb {
214 pb.set_message(format!("Installing 0/{total} resources"));
215 }
216
217 // Use concurrent stream processing for parallel installation
218 let results: Vec<Result<(), anyhow::Error>> = stream::iter(entries_to_install)
219 .map(|(entry, resource_dir)| {
220 let project_dir = project_dir.to_path_buf();
221 let installed_count = Arc::clone(&installed_count);
222 let pb = pb.clone();
223 let cache = Arc::clone(&cache);
224 let lockfile = Arc::clone(lockfile);
225
226 async move {
227 // Install the resource
228 let context = InstallContext::new(
229 &project_dir,
230 cache.as_ref(),
231 false,
232 false, // verbose - will be threaded through from CLI
233 Some(manifest),
234 Some(&lockfile),
235 None, // old_lockfile - not available in parallel context
236 install_ctx.project_patches,
237 install_ctx.private_patches,
238 install_ctx.gitignore_lock,
239 install_ctx.max_content_file_size,
240 );
241 install_resource_for_parallel(&entry, &resource_dir, &context).await?;
242
243 // Update progress
244 let mut count = installed_count.lock().await;
245 *count += 1;
246
247 if let Some(pb) = pb {
248 pb.set_message(format!("Installing {}/{} resources", *count, total));
249 pb.inc(1);
250 }
251
252 Ok::<(), anyhow::Error>(())
253 }
254 })
255 .buffered(usize::MAX) // Allow unlimited task concurrency while preserving input order for deterministic checksums
256 .collect()
257 .await;
258
259 // Check all results for errors
260 for result in results {
261 result?;
262 }
263
264 let final_count = *installed_count.lock().await;
265 Ok(final_count)
266}