agpm_cli/resolver/resource_service.rs
1//! Resource fetching service for dependency resolution.
2//!
3//! This service handles fetching resource content from local files or Git worktrees
4//! and resolving canonical paths for dependencies.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9
10use crate::core::file_error::{FileOperation, FileResultExt};
11use crate::manifest::ResourceDependency;
12
13use super::types::ResolutionCore;
14use super::version_resolver::VersionResolutionService;
15
16/// Service for fetching resource content and resolving paths.
17pub struct ResourceFetchingService;
18
19impl ResourceFetchingService {
20 /// Create a new resource fetching service.
21 pub fn new() -> Self {
22 Self
23 }
24
25 /// Fetch the content of a resource for metadata extraction.
26 ///
27 /// This method retrieves the file content from either:
28 /// - Local filesystem (for path-only dependencies)
29 /// - Git worktree (for Git-backed dependencies with version)
30 ///
31 /// This method can prepare versions on-demand if they haven't been prepared yet,
32 /// which is necessary for transitive dependencies discovered during resolution.
33 ///
34 /// # Arguments
35 ///
36 /// * `core` - The resolution core with manifest and cache
37 /// * `dep` - The resource dependency to fetch
38 /// * `version_service` - Version service to get/prepare worktree paths
39 ///
40 /// # Returns
41 ///
42 /// The file content as a string
43 pub async fn fetch_content(
44 core: &ResolutionCore,
45 dep: &ResourceDependency,
46 version_service: &mut VersionResolutionService,
47 ) -> Result<String> {
48 match dep {
49 ResourceDependency::Simple(path) => {
50 // Local file - resolve relative to manifest directory
51 let manifest_dir = core
52 .manifest
53 .manifest_dir
54 .as_ref()
55 .context("Manifest directory not available for local dependency")?;
56
57 let full_path = manifest_dir.join(path);
58 let canonical_path = full_path.canonicalize().with_file_context(
59 FileOperation::Canonicalize,
60 &full_path,
61 format!("resolving local dependency path: {}", path),
62 "resource_service",
63 )?;
64
65 Self::read_with_cache_retry(&canonical_path).await
66 }
67 ResourceDependency::Detailed(detailed) => {
68 if let Some(source) = &detailed.source {
69 // Git-backed dependency
70 // Use dep.get_version() to handle branch/rev/version precedence
71 let version_key = dep.get_version().unwrap_or("HEAD");
72 let group_key = format!("{}::{}", source, version_key);
73
74 // Check if version is already prepared, if not prepare it on-demand
75 if version_service.get_prepared_version(&group_key).is_none() {
76 // Prepare this version on-demand (common with transitive dependencies)
77 // Use dep.get_version() to properly handle branch/rev/version precedence
78 version_service
79 .prepare_additional_version(core, source, dep.get_version())
80 .await
81 .with_context(|| {
82 format!(
83 "Failed to prepare version on-demand for source '{}' @ '{}'",
84 source, version_key
85 )
86 })?;
87 }
88
89 let prepared = version_service.get_prepared_version(&group_key).unwrap();
90 let worktree_path = &prepared.worktree_path;
91 let file_path = worktree_path.join(&detailed.path);
92
93 // Don't canonicalize Git-backed files - worktrees may have coherency delays
94 Self::read_with_cache_retry(&file_path).await
95 } else {
96 // Local path-only dependency
97 let manifest_dir = core
98 .manifest
99 .manifest_dir
100 .as_ref()
101 .context("Manifest directory not available")?;
102
103 let full_path = manifest_dir.join(&detailed.path);
104 let canonical_path = full_path.canonicalize().map_err(|e| {
105 // Create a FileOperationError for canonicalization failures
106 let file_error = crate::core::file_error::FileOperationError::new(
107 crate::core::file_error::FileOperationContext::new(
108 crate::core::file_error::FileOperation::Canonicalize,
109 &full_path,
110 format!("resolving local dependency path: {}", detailed.path),
111 "resource_service::fetch_content",
112 ),
113 e,
114 );
115 anyhow::Error::from(file_error)
116 })?;
117
118 Self::read_with_cache_retry(&canonical_path).await
119 }
120 }
121 }
122 }
123
124 /// Get the canonical path for a dependency.
125 ///
126 /// Resolves dependency path to its canonical form on the filesystem.
127 /// Can prepare versions on-demand if needed.
128 ///
129 /// # Arguments
130 ///
131 /// * `core` - The resolution core with manifest and cache
132 /// * `dep` - The resource dependency
133 /// * `version_service` - Version service to get/prepare worktree paths
134 ///
135 /// # Returns
136 ///
137 /// The canonical absolute path to the resource
138 pub async fn get_canonical_path(
139 core: &ResolutionCore,
140 dep: &ResourceDependency,
141 version_service: &mut VersionResolutionService,
142 ) -> Result<PathBuf> {
143 match dep {
144 ResourceDependency::Simple(path) => {
145 let manifest_dir = core
146 .manifest
147 .manifest_dir
148 .as_ref()
149 .context("Manifest directory not available")?;
150
151 let full_path = manifest_dir.join(path);
152 full_path.canonicalize().map_err(|e| {
153 // Create a FileOperationError for canonicalization failures
154 let file_error = crate::core::file_error::FileOperationError::new(
155 crate::core::file_error::FileOperationContext::new(
156 crate::core::file_error::FileOperation::Canonicalize,
157 &full_path,
158 format!("canonicalizing local dependency path: {}", path),
159 "resource_service::get_canonical_path",
160 ),
161 e,
162 );
163 anyhow::Error::from(file_error)
164 })
165 }
166 ResourceDependency::Detailed(detailed) => {
167 if let Some(source) = &detailed.source {
168 // Git-backed dependency
169 // Use dep.get_version() to handle branch/rev/version precedence
170 let version_key = dep.get_version().unwrap_or("HEAD");
171 let group_key = format!("{}::{}", source, version_key);
172
173 // Check if version is already prepared, if not prepare it on-demand
174 if version_service.get_prepared_version(&group_key).is_none() {
175 version_service
176 .prepare_additional_version(core, source, detailed.version.as_deref())
177 .await
178 .with_context(|| {
179 format!(
180 "Failed to prepare version on-demand for source '{}' @ '{}'",
181 source, version_key
182 )
183 })?;
184 }
185
186 let prepared = version_service.get_prepared_version(&group_key).unwrap();
187
188 let worktree_path = &prepared.worktree_path;
189 let file_path = worktree_path.join(&detailed.path);
190
191 // Return the path without canonicalizing - Git worktrees may have coherency delays
192 Ok(file_path)
193 } else {
194 // Local path-only dependency
195 let manifest_dir = core
196 .manifest
197 .manifest_dir
198 .as_ref()
199 .context("Manifest directory not available")?;
200
201 let full_path = manifest_dir.join(&detailed.path);
202 full_path.canonicalize().map_err(|e| {
203 // Create a FileOperationError for canonicalization failures
204 let file_error = crate::core::file_error::FileOperationError::new(
205 crate::core::file_error::FileOperationContext::new(
206 crate::core::file_error::FileOperation::Canonicalize,
207 &full_path,
208 format!("canonicalizing dependency path: {}", detailed.path),
209 "resource_service::get_canonical_path",
210 ),
211 e,
212 );
213 anyhow::Error::from(file_error)
214 })
215 }
216 }
217 }
218 }
219
220 /// Read file with retry logic for cache coherency issues.
221 ///
222 /// Git worktrees can have filesystem coherency delays after creation.
223 /// This method retries up to 10 times with 100ms delays between attempts.
224 async fn read_with_cache_retry(path: &Path) -> Result<String> {
225 use tokio::time::{Duration, sleep};
226
227 const MAX_ATTEMPTS: u32 = 10;
228 const RETRY_DELAY_MS: u64 = 100;
229
230 for attempt in 0..MAX_ATTEMPTS {
231 match tokio::fs::read_to_string(path).await {
232 Ok(content) => return Ok(content),
233 Err(e)
234 if e.kind() == std::io::ErrorKind::NotFound && attempt < MAX_ATTEMPTS - 1 =>
235 {
236 // File not found, but we have retries left
237 tracing::debug!(
238 "File not found at {}, retrying ({}/{})",
239 path.display(),
240 attempt + 1,
241 MAX_ATTEMPTS
242 );
243 sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
244 continue;
245 }
246 Err(e) => {
247 // Other error or final attempt
248 return Err(e).with_file_context(
249 FileOperation::Read,
250 path,
251 "reading dependency content in resource service",
252 "resource_service",
253 )?;
254 }
255 }
256 }
257
258 // This should never be reached, but provide a fallback with proper error context
259 let file_error = crate::core::file_error::FileOperationError::new(
260 crate::core::file_error::FileOperationContext::new(
261 crate::core::file_error::FileOperation::Read,
262 path,
263 format!("reading file after {} attempts", MAX_ATTEMPTS),
264 "resource_service::read_with_cache_retry",
265 ),
266 std::io::Error::new(std::io::ErrorKind::NotFound, "file not found after retries"),
267 );
268 Err(anyhow::Error::from(file_error))
269 }
270}
271
272impl Default for ResourceFetchingService {
273 fn default() -> Self {
274 Self::new()
275 }
276}