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 /// Helper function to canonicalize a path with proper error context.
26 ///
27 /// This function provides consistent error handling for path canonicalization
28 /// operations throughout the resource service.
29 ///
30 /// # Arguments
31 ///
32 /// * `path` - The path to canonicalize
33 /// * `operation_desc` - Description of the operation being performed
34 /// * `caller` - The function name calling this helper
35 ///
36 /// # Returns
37 ///
38 /// The canonical path with structured error context on failure
39 pub fn canonicalize_with_context(
40 path: &Path,
41 operation_desc: String,
42 caller: &str,
43 ) -> Result<PathBuf> {
44 path.canonicalize().map_err(|e| {
45 let file_error = crate::core::file_error::FileOperationError::new(
46 crate::core::file_error::FileOperationContext::new(
47 crate::core::file_error::FileOperation::Canonicalize,
48 path,
49 operation_desc,
50 caller,
51 ),
52 e,
53 );
54 anyhow::Error::from(file_error)
55 })
56 }
57
58 /// Fetch the content of a resource for metadata extraction.
59 ///
60 /// This method retrieves the file content from either:
61 /// - Local filesystem (for path-only dependencies)
62 /// - Git worktree (for Git-backed dependencies with version)
63 ///
64 /// This method can prepare versions on-demand if they haven't been prepared yet,
65 /// which is necessary for transitive dependencies discovered during resolution.
66 ///
67 /// # Arguments
68 ///
69 /// * `core` - The resolution core with manifest and cache
70 /// * `dep` - The resource dependency to fetch
71 /// * `version_service` - Version service to get/prepare worktree paths
72 ///
73 /// # Returns
74 ///
75 /// The file content as a string
76 pub async fn fetch_content(
77 core: &ResolutionCore,
78 dep: &ResourceDependency,
79 version_service: &VersionResolutionService,
80 ) -> Result<String> {
81 match dep {
82 ResourceDependency::Simple(path) => {
83 // Local file - resolve relative to manifest directory
84 let manifest_dir = core
85 .manifest
86 .manifest_dir
87 .as_ref()
88 .context("Manifest directory not available for local dependency")?;
89
90 let full_path = manifest_dir.join(path);
91 let canonical_path = Self::canonicalize_with_context(
92 &full_path,
93 format!("resolving local dependency path: {}", path),
94 "resource_service",
95 )?;
96
97 tokio::fs::read_to_string(&canonical_path)
98 .await
99 .with_file_context(
100 FileOperation::Read,
101 &canonical_path,
102 "reading local dependency content",
103 "resource_service",
104 )
105 .map_err(Into::into)
106 }
107 ResourceDependency::Detailed(detailed) => {
108 if let Some(source) = &detailed.source {
109 // Git-backed dependency
110 // Use get_or_prepare_version for coordinated concurrent access
111 // This ensures only one task prepares a version at a time
112 let prepared = version_service
113 .get_or_prepare_version(core, source, dep.get_version())
114 .await
115 .with_context(|| {
116 let version_key = dep.get_version().unwrap_or("HEAD");
117 format!(
118 "Failed to prepare version on-demand for source '{}' @ '{}'",
119 source, version_key
120 )
121 })?;
122
123 let file_path = prepared.worktree_path.join(&detailed.path);
124
125 // Use retry for Git worktree files - they can have brief visibility
126 // delays after creation, especially under high parallel I/O load
127 crate::utils::fs::read_text_file_with_retry(&file_path).await
128 } else {
129 // Local path-only dependency
130 let manifest_dir = core
131 .manifest
132 .manifest_dir
133 .as_ref()
134 .context("Manifest directory not available")?;
135
136 let full_path = manifest_dir.join(&detailed.path);
137 let canonical_path = Self::canonicalize_with_context(
138 &full_path,
139 format!("resolving local dependency path: {}", detailed.path),
140 "resource_service::fetch_content",
141 )?;
142
143 tokio::fs::read_to_string(&canonical_path)
144 .await
145 .with_file_context(
146 FileOperation::Read,
147 &canonical_path,
148 "reading local dependency content",
149 "resource_service",
150 )
151 .map_err(Into::into)
152 }
153 }
154 }
155 }
156
157 /// Get the canonical path for a dependency.
158 ///
159 /// Resolves dependency path to its canonical form on the filesystem.
160 /// Can prepare versions on-demand if needed.
161 ///
162 /// # Arguments
163 ///
164 /// * `core` - The resolution core with manifest and cache
165 /// * `dep` - The resource dependency
166 /// * `version_service` - Version service to get/prepare worktree paths
167 ///
168 /// # Returns
169 ///
170 /// The canonical absolute path to the resource
171 pub async fn get_canonical_path(
172 core: &ResolutionCore,
173 dep: &ResourceDependency,
174 version_service: &VersionResolutionService,
175 ) -> Result<PathBuf> {
176 match dep {
177 ResourceDependency::Simple(path) => {
178 let manifest_dir = core
179 .manifest
180 .manifest_dir
181 .as_ref()
182 .context("Manifest directory not available")?;
183
184 let full_path = manifest_dir.join(path);
185 Self::canonicalize_with_context(
186 &full_path,
187 format!("canonicalizing local dependency path: {}", path),
188 "resource_service::get_canonical_path",
189 )
190 }
191 ResourceDependency::Detailed(detailed) => {
192 if let Some(source) = &detailed.source {
193 // Git-backed dependency
194 // Use dep.get_version() to handle branch/rev/version precedence
195 let version_key = dep.get_version().unwrap_or("HEAD");
196 let group_key = format!("{}::{}", source, version_key);
197
198 // Check if version is already prepared, if not prepare it on-demand
199 if version_service.get_prepared_version(&group_key).is_none() {
200 version_service
201 .prepare_additional_version(core, source, detailed.version.as_deref())
202 .await
203 .with_context(|| {
204 format!(
205 "Failed to prepare version on-demand for source '{}' @ '{}'",
206 source, version_key
207 )
208 })?;
209 }
210
211 // Safe: Same invariant as above - prepare_additional_version ensures the
212 // group_key exists in prepared_versions before this point is reached.
213 let prepared = version_service.get_prepared_version(&group_key).unwrap();
214
215 let worktree_path = &prepared.worktree_path;
216 let file_path = worktree_path.join(&detailed.path);
217
218 // Return the path without canonicalizing - Git worktrees may have coherency delays
219 Ok(file_path)
220 } else {
221 // Local path-only dependency
222 let manifest_dir = core
223 .manifest
224 .manifest_dir
225 .as_ref()
226 .context("Manifest directory not available")?;
227
228 let full_path = manifest_dir.join(&detailed.path);
229 Self::canonicalize_with_context(
230 &full_path,
231 format!("canonicalizing dependency path: {}", detailed.path),
232 "resource_service::get_canonical_path",
233 )
234 }
235 }
236 }
237 }
238}
239
240impl Default for ResourceFetchingService {
241 fn default() -> Self {
242 Self::new()
243 }
244}