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}