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}