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