agpm_cli/resolver/backtracking/
mod.rs

1//! Automatic version backtracking for SHA conflict resolution.
2//!
3//! This module implements automatic resolution of version conflicts by finding
4//! alternative versions that satisfy all constraints and resolve to the same commit SHA.
5//!
6//! # Algorithm
7//!
8//! When SHA conflicts are detected (multiple requirements for the same resource resolving
9//! to different commits), the backtracking resolver attempts to find compatible versions:
10//!
11//! 1. **Query available versions**: Fetch all tags from the Git repository
12//! 2. **Filter by constraints**: Find versions satisfying all requirements
13//! 3. **Try alternatives**: Test versions in preference order (latest first)
14//! 4. **Verify SHA match**: Check if alternative version resolves to same SHA as other requirements
15//! 5. **Handle transitive deps**: Re-resolve transitive dependencies after version changes
16//! 6. **Iterate if needed**: Continue until all conflicts resolved or limits reached
17//!
18//! # Performance Limits
19//!
20//! To prevent excessive computation:
21//! - Maximum 100 version resolution attempts per conflict
22//! - 10-second timeout for entire backtracking process
23//! - Early termination if no progress made
24
25mod algorithm;
26mod registry;
27mod transitive;
28mod types;
29
30use anyhow::Result;
31use std::collections::HashMap;
32use std::time::{Duration, Instant};
33
34use crate::resolver::ResolutionCore;
35use crate::resolver::version_resolver::VersionResolutionService;
36use crate::version::conflict::{ConflictDetector, VersionConflict};
37
38pub use registry::{ResourceParams, parse_resource_id_string, resource_id_to_string};
39pub use types::{BacktrackingIteration, BacktrackingResult, TerminationReason, VersionUpdate};
40
41use registry::{ResourceRegistry, TransitiveChangeTracker};
42
43/// Maximum number of version resolution attempts before giving up
44const MAX_ATTEMPTS: usize = 100;
45
46/// Maximum duration for backtracking before timeout (increased for transitive resolution)
47const MAX_DURATION: Duration = Duration::from_secs(10);
48
49/// Maximum number of backtracking iterations before giving up
50const MAX_ITERATIONS: usize = 10;
51
52/// Automatic version backtracking resolver.
53///
54/// Attempts to resolve SHA conflicts by finding alternative versions
55/// that satisfy all constraints and resolve to the same commit.
56pub struct BacktrackingResolver<'a> {
57    /// Core resolution context with manifest, cache, and source manager
58    core: &'a ResolutionCore,
59
60    /// Version resolution service for Git operations
61    version_service: &'a mut VersionResolutionService,
62
63    /// Maximum version resolution attempts
64    max_attempts: usize,
65
66    /// Maximum duration before timeout
67    timeout: Duration,
68
69    /// Start time for timeout tracking
70    start_time: Instant,
71
72    /// Number of attempts made so far
73    attempts: usize,
74
75    /// Tracks resources whose versions changed (need transitive re-resolution)
76    change_tracker: TransitiveChangeTracker,
77
78    /// Iteration history for debugging and oscillation detection
79    iteration_history: Vec<BacktrackingIteration>,
80
81    /// Maximum iterations before giving up
82    max_iterations: usize,
83
84    /// Registry of all resources for conflict detection after version changes
85    resource_registry: ResourceRegistry,
86}
87
88impl<'a> BacktrackingResolver<'a> {
89    /// Create a new backtracking resolver with default limits.
90    pub fn new(
91        core: &'a ResolutionCore,
92        version_service: &'a mut VersionResolutionService,
93    ) -> Self {
94        Self {
95            core,
96            version_service,
97            max_attempts: MAX_ATTEMPTS,
98            timeout: MAX_DURATION,
99            start_time: Instant::now(),
100            attempts: 0,
101            change_tracker: TransitiveChangeTracker::new(),
102            iteration_history: Vec::new(),
103            max_iterations: MAX_ITERATIONS,
104            resource_registry: ResourceRegistry::new(),
105        }
106    }
107
108    /// Populate the resource registry from a ConflictDetector.
109    ///
110    /// This extracts all requirements from the conflict detector and builds
111    /// a complete resource registry for conflict detection during backtracking.
112    pub fn populate_from_conflict_detector(&mut self, conflict_detector: &ConflictDetector) {
113        let requirements = conflict_detector.requirements();
114
115        let mut skipped_count = 0;
116        let mut processed_count = 0;
117
118        for (resource_id, reqs) in requirements {
119            if resource_id_to_string(resource_id).is_err() {
120                let tool_info =
121                    resource_id.tool().map(|t| format!("tool: {}", t)).unwrap_or_default();
122                let type_info = format!("type: {}", resource_id.resource_type());
123
124                tracing::warn!(
125                    "Skipping resource without source: {} (name: {}, {}, {} requirements: {})",
126                    resource_id,
127                    resource_id.name(),
128                    type_info,
129                    tool_info,
130                    reqs.len()
131                );
132
133                skipped_count += 1;
134                continue;
135            }
136
137            processed_count += 1;
138
139            for req in reqs {
140                self.resource_registry.add_or_update_resource(ResourceParams {
141                    resource_id: resource_id.clone(),
142                    version: req.requirement.clone(),
143                    sha: req.resolved_sha.clone(),
144                    version_constraint: req.requirement.clone(),
145                    required_by: req.required_by.clone(),
146                });
147            }
148        }
149
150        if skipped_count > 0 {
151            tracing::info!(
152                "Population complete: processed {} resources, skipped {} without source",
153                processed_count,
154                skipped_count
155            );
156        } else {
157            tracing::debug!(
158                "Population complete: processed {} resources, no local resources skipped",
159                processed_count
160            );
161        }
162    }
163
164    /// Create a backtracking resolver with custom limits (for testing).
165    #[allow(dead_code)]
166    pub fn with_limits(
167        core: &'a ResolutionCore,
168        version_service: &'a mut VersionResolutionService,
169        max_attempts: usize,
170        timeout: Duration,
171    ) -> Self {
172        Self {
173            core,
174            version_service,
175            max_attempts,
176            timeout,
177            start_time: Instant::now(),
178            attempts: 0,
179            change_tracker: TransitiveChangeTracker::new(),
180            iteration_history: Vec::new(),
181            max_iterations: MAX_ITERATIONS,
182            resource_registry: ResourceRegistry::new(),
183        }
184    }
185
186    /// Attempt to resolve conflicts by finding compatible versions.
187    pub async fn resolve_conflicts(
188        &mut self,
189        initial_conflicts: &[VersionConflict],
190    ) -> Result<BacktrackingResult> {
191        tracing::debug!(
192            "Starting iterative backtracking for {} conflict(s), limits: {} iterations, {} attempts, {}s timeout",
193            initial_conflicts.len(),
194            self.max_iterations,
195            self.max_attempts,
196            self.timeout.as_secs()
197        );
198
199        let mut current_conflicts = initial_conflicts.to_vec();
200        let mut all_updates = Vec::new();
201        let mut total_transitive = 0;
202
203        for iteration_num in 1..=self.max_iterations {
204            tracing::debug!("=== Backtracking iteration {} ===", iteration_num);
205            tracing::debug!("Processing {} conflict(s)", current_conflicts.len());
206
207            if self.start_time.elapsed() > self.timeout {
208                tracing::warn!("Backtracking timeout after {:?}", self.start_time.elapsed());
209                return Ok(self.build_result(
210                    false,
211                    all_updates,
212                    total_transitive,
213                    TerminationReason::Timeout,
214                ));
215            }
216
217            let mut iteration_updates = Vec::new();
218            for conflict in &current_conflicts {
219                match self.resolve_single_conflict(conflict).await? {
220                    Some(update) => {
221                        tracing::debug!(
222                            "Resolved conflict for {}: {} → {}",
223                            conflict.resource,
224                            update.old_version,
225                            update.new_version
226                        );
227                        iteration_updates.push(update);
228                    }
229                    None => {
230                        tracing::debug!("Could not resolve conflict for {}", conflict.resource);
231                        return Ok(self.build_result(
232                            false,
233                            all_updates,
234                            total_transitive,
235                            TerminationReason::NoCompatibleVersion,
236                        ));
237                    }
238                }
239            }
240
241            if iteration_updates.is_empty() {
242                tracing::debug!("No updates found in iteration {}", iteration_num);
243                return Ok(self.build_result(
244                    false,
245                    all_updates,
246                    total_transitive,
247                    TerminationReason::NoCompatibleVersion,
248                ));
249            }
250
251            for update in &iteration_updates {
252                self.change_tracker.record_change(
253                    &update.resource_id,
254                    &update.old_version,
255                    &update.new_version,
256                    &update.new_sha,
257                    update.variant_inputs.clone(),
258                );
259
260                self.resource_registry.update_version_and_sha(
261                    &update.resource_id,
262                    update.new_version.clone(),
263                    update.new_sha.clone(),
264                );
265            }
266
267            all_updates.extend(iteration_updates.clone());
268
269            tracing::debug!(
270                "Re-extracting transitive deps for {} changed resource(s)",
271                self.change_tracker.get_changed_resources().len()
272            );
273            let transitive_count = transitive::reextract_transitive_deps(
274                self.core,
275                self.version_service,
276                &mut self.change_tracker,
277            )
278            .await?;
279            total_transitive += transitive_count;
280
281            if transitive_count > 0 {
282                tracing::debug!("Re-resolved {} transitive dependency(ies)", transitive_count);
283            }
284
285            let new_conflicts = transitive::detect_conflicts_after_changes(&self.resource_registry);
286            tracing::debug!(
287                "After iteration {}: {} conflict(s) remaining",
288                iteration_num,
289                new_conflicts.len()
290            );
291
292            self.iteration_history.push(BacktrackingIteration {
293                iteration: iteration_num,
294                conflicts: current_conflicts.clone(),
295                updates: iteration_updates,
296                transitive_reresolutions: transitive_count,
297                made_progress: !new_conflicts.is_empty() || transitive_count > 0,
298            });
299
300            if new_conflicts.is_empty() {
301                tracing::info!(
302                    "✓ Resolved all conflicts after {} iteration(s), {} version update(s), {} transitive re-resolution(s)",
303                    iteration_num,
304                    all_updates.len(),
305                    total_transitive
306                );
307                return Ok(self.build_result(
308                    true,
309                    all_updates,
310                    total_transitive,
311                    TerminationReason::Success,
312                ));
313            }
314
315            if algorithm::conflicts_equal(&current_conflicts, &new_conflicts) {
316                tracing::warn!(
317                    "No progress made in iteration {}: same conflicts remain",
318                    iteration_num
319                );
320                return Ok(self.build_result(
321                    false,
322                    all_updates,
323                    total_transitive,
324                    TerminationReason::NoProgress,
325                ));
326            }
327
328            if self.detect_oscillation(&new_conflicts) {
329                tracing::warn!("Oscillation detected in iteration {}", iteration_num);
330                return Ok(self.build_result(
331                    false,
332                    all_updates,
333                    total_transitive,
334                    TerminationReason::Oscillation,
335                ));
336            }
337
338            current_conflicts = new_conflicts;
339        }
340
341        tracing::warn!(
342            "Reached max iterations ({}) without resolving all conflicts. {} conflict(s) remaining",
343            self.max_iterations,
344            current_conflicts.len()
345        );
346        Ok(self.build_result(
347            false,
348            all_updates,
349            total_transitive,
350            TerminationReason::MaxIterations,
351        ))
352    }
353
354    /// Resolve a single conflict by finding an alternative version.
355    async fn resolve_single_conflict(
356        &mut self,
357        conflict: &VersionConflict,
358    ) -> Result<Option<VersionUpdate>> {
359        let source_name = conflict
360            .resource
361            .source()
362            .ok_or_else(|| anyhow::anyhow!("Resource {} has no source", conflict.resource))?;
363
364        let mut sha_groups: HashMap<&str, Vec<&crate::version::conflict::ConflictingRequirement>> =
365            HashMap::new();
366        for req in &conflict.conflicting_requirements {
367            sha_groups.entry(req.resolved_sha.as_str()).or_default().push(req);
368        }
369
370        let target_sha = algorithm::select_target_sha(&sha_groups)?;
371
372        tracing::debug!(
373            "Target SHA for {}: {} ({} requirements)",
374            conflict.resource,
375            &target_sha[..8.min(target_sha.len())],
376            sha_groups.get(target_sha).map_or(0, |v| v.len())
377        );
378
379        let requirements_to_update: Vec<&crate::version::conflict::ConflictingRequirement> =
380            conflict
381                .conflicting_requirements
382                .iter()
383                .filter(|req| req.resolved_sha != target_sha)
384                .collect();
385
386        if requirements_to_update.is_empty() {
387            return Ok(None);
388        }
389
390        let req_to_update = requirements_to_update[0];
391
392        if req_to_update.required_by == "manifest" {
393            algorithm::find_alternative_for_direct_dependency(
394                self.core,
395                self.version_service,
396                source_name,
397                req_to_update,
398                target_sha,
399                &mut self.attempts,
400                self.max_attempts,
401                self.start_time,
402                self.timeout,
403            )
404            .await
405        } else {
406            algorithm::find_alternative_for_transitive(
407                self.core,
408                self.version_service,
409                source_name,
410                req_to_update,
411                target_sha,
412                &mut self.attempts,
413                self.max_attempts,
414                self.start_time,
415                self.timeout,
416            )
417            .await
418        }
419    }
420
421    /// Detect if we're oscillating between two conflict states.
422    fn detect_oscillation(&self, current_conflicts: &[VersionConflict]) -> bool {
423        for iteration in &self.iteration_history {
424            if algorithm::conflicts_equal(&iteration.conflicts, current_conflicts) {
425                tracing::warn!(
426                    "Oscillation detected: conflicts match iteration {}",
427                    iteration.iteration
428                );
429                return true;
430            }
431        }
432        false
433    }
434
435    fn build_result(
436        &self,
437        resolved: bool,
438        updates: Vec<VersionUpdate>,
439        total_transitive: usize,
440        termination_reason: TerminationReason,
441    ) -> BacktrackingResult {
442        BacktrackingResult {
443            resolved,
444            updates,
445            iterations: self.iteration_history.len(),
446            attempted_versions: self.attempts,
447            iteration_history: self.iteration_history.clone(),
448            total_transitive_reresolutions: total_transitive,
449            termination_reason,
450        }
451    }
452}