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}