use anyhow::Result;
use semver::{Op, Version, VersionReq};
use std::collections::{HashMap, HashSet};
use std::fmt;
use crate::core::AgpmError;
use pubgrub::Ranges;
#[derive(Debug, Clone)]
pub struct VersionConflict {
pub resource: String,
pub conflicting_requirements: Vec<ConflictingRequirement>,
}
#[derive(Debug, Clone)]
pub struct ConflictingRequirement {
pub required_by: String,
pub requirement: String,
pub resolved_version: Option<Version>,
}
impl fmt::Display for VersionConflict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Version conflict for '{}':", self.resource)?;
for req in &self.conflicting_requirements {
writeln!(f, " - {} requires {}", req.required_by, req.requirement)?;
if let Some(v) = &req.resolved_version {
writeln!(f, " (resolved to {v})")?;
}
}
Ok(())
}
}
pub struct ConflictDetector {
requirements: HashMap<String, Vec<(String, String)>>, }
impl Default for ConflictDetector {
fn default() -> Self {
Self::new()
}
}
impl ConflictDetector {
pub fn new() -> Self {
Self {
requirements: HashMap::new(),
}
}
pub fn add_requirement(&mut self, resource: &str, required_by: &str, requirement: &str) {
self.requirements
.entry(resource.to_string())
.or_default()
.push((required_by.to_string(), requirement.to_string()));
}
pub fn detect_conflicts(&self) -> Vec<VersionConflict> {
let mut conflicts = Vec::new();
for (resource, requirements) in &self.requirements {
if requirements.len() <= 1 {
continue; }
let compatible = self.are_requirements_compatible(requirements);
if !compatible {
let conflict = VersionConflict {
resource: resource.clone(),
conflicting_requirements: requirements
.iter()
.map(|(requirer, req)| ConflictingRequirement {
required_by: requirer.clone(),
requirement: req.clone(),
resolved_version: None,
})
.collect(),
};
conflicts.push(conflict);
}
}
conflicts
}
fn are_requirements_compatible(&self, requirements: &[(String, String)]) -> bool {
let has_head = requirements.iter().any(|(_, req)| req == "HEAD");
let has_specific = requirements.iter().any(|(_, req)| req != "HEAD");
if has_head && has_specific {
return false;
}
let parsed_reqs: Vec<_> = requirements
.iter()
.filter_map(|(_, req)| {
if req == "*" {
Some(VersionReq::parse("*").unwrap())
} else if req == "HEAD" {
None
} else {
crate::version::parse_version_req(req).ok()
}
})
.collect();
if parsed_reqs.len() != requirements.len() {
let has_semver = !parsed_reqs.is_empty();
let has_git_refs = parsed_reqs.len() < requirements.len();
if has_semver && has_git_refs {
return false;
}
return self.check_git_ref_compatibility(requirements);
}
self.can_satisfy_all(&parsed_reqs)
}
fn check_git_ref_compatibility(&self, requirements: &[(String, String)]) -> bool {
let refs: HashSet<_> = requirements
.iter()
.filter_map(|(_, req)| {
if !req.starts_with('^')
&& !req.starts_with('~')
&& !req.starts_with('>')
&& !req.starts_with('<')
&& !req.starts_with('=')
&& req != "HEAD"
&& req != "*"
{
Some(req.to_lowercase())
} else {
None
}
})
.collect();
refs.len() <= 1
}
fn can_satisfy_all(&self, requirements: &[VersionReq]) -> bool {
if requirements.is_empty() {
return true;
}
let mut intersection: Option<Ranges<Version>> = None;
for req in requirements {
let range = self.version_req_to_ranges(req);
intersection = match intersection {
None => Some(range),
Some(current) => Some(current.intersection(&range)),
};
if let Some(ref i) = intersection
&& i.is_empty()
{
return false;
}
}
intersection.is_none_or(|i| !i.is_empty())
}
fn version_req_to_ranges(&self, req: &VersionReq) -> Ranges<Version> {
let comparators = &req.comparators;
if comparators.is_empty() {
return Ranges::full();
}
let mut ranges = Ranges::full();
for comp in comparators {
let base_version = if comp.pre.is_empty() {
Version::new(comp.major, comp.minor.unwrap_or(0), comp.patch.unwrap_or(0))
} else {
Version {
major: comp.major,
minor: comp.minor.unwrap_or(0),
patch: comp.patch.unwrap_or(0),
pre: comp.pre.clone(),
build: Default::default(),
}
};
let comp_range = match comp.op {
Op::Exact => {
Ranges::singleton(base_version)
}
Op::Greater => {
Ranges::strictly_higher_than(base_version)
}
Op::GreaterEq => {
Ranges::higher_than(base_version)
}
Op::Less => {
Ranges::strictly_lower_than(base_version)
}
Op::LessEq => {
Ranges::lower_than(base_version)
}
Op::Tilde => {
let upper = if comp.minor.is_none() {
Version::new(comp.major + 1, 0, 0)
} else {
Version::new(comp.major, comp.minor.unwrap() + 1, 0)
};
Ranges::between(base_version, upper)
}
Op::Caret => {
if base_version.major > 0 {
let upper = Version::new(base_version.major + 1, 0, 0);
Ranges::between(base_version, upper)
} else if base_version.minor > 0 {
let upper = Version::new(0, base_version.minor + 1, 0);
Ranges::between(base_version, upper)
} else if comp.patch.is_some() && base_version.patch > 0 {
let upper = Version::new(0, 0, base_version.patch + 1);
Ranges::between(base_version, upper)
} else if comp.patch.is_none() && comp.minor.is_some() {
let upper = Version::new(0, 1, 0);
Ranges::between(base_version, upper)
} else if comp.minor.is_none() {
let upper = Version::new(1, 0, 0);
Ranges::between(base_version, upper)
} else {
let upper = Version::new(0, 0, 1);
Ranges::between(base_version, upper)
}
}
Op::Wildcard => {
if comp.minor.is_none() {
let lower = Version::new(comp.major, 0, 0);
let upper = Version::new(comp.major + 1, 0, 0);
Ranges::between(lower, upper)
} else if comp.patch.is_none() {
let lower = Version::new(comp.major, comp.minor.unwrap(), 0);
let upper = Version::new(comp.major, comp.minor.unwrap() + 1, 0);
Ranges::between(lower, upper)
} else {
Ranges::singleton(base_version)
}
}
_ => {
Ranges::full()
}
};
ranges = ranges.intersection(&comp_range);
}
ranges
}
pub fn resolve_conflicts(
&self,
available_versions: &HashMap<String, Vec<Version>>,
) -> Result<HashMap<String, Version>> {
let mut resolved = HashMap::new();
let conflicts = self.detect_conflicts();
if !conflicts.is_empty() {
let conflict_messages: Vec<String> =
conflicts.iter().map(std::string::ToString::to_string).collect();
return Err(AgpmError::Other {
message: format!(
"Unable to resolve version conflicts:\n{}",
conflict_messages.join("\n")
),
}
.into());
}
for (resource, requirements) in &self.requirements {
let versions = available_versions.get(resource).ok_or_else(|| AgpmError::Other {
message: format!("No versions available for resource: {resource}"),
})?;
let best_version = self.find_best_version(versions, requirements)?;
resolved.insert(resource.clone(), best_version);
}
Ok(resolved)
}
fn find_best_version(
&self,
available: &[Version],
requirements: &[(String, String)],
) -> Result<Version> {
let mut candidates = available.to_vec();
for (_, req_str) in requirements {
if req_str == "latest" || req_str == "*" {
continue; }
if let Ok(req) = crate::version::parse_version_req(req_str) {
candidates.retain(|v| req.matches(v));
}
}
if candidates.is_empty() {
return Err(AgpmError::Other {
message: format!("No version satisfies all requirements: {requirements:?}"),
}
.into());
}
candidates.sort_by(|a, b| b.cmp(a));
Ok(candidates[0].clone())
}
}
pub struct CircularDependencyDetector {
graph: HashMap<String, HashSet<String>>,
}
impl Default for CircularDependencyDetector {
fn default() -> Self {
Self::new()
}
}
impl CircularDependencyDetector {
pub fn new() -> Self {
Self {
graph: HashMap::new(),
}
}
pub fn add_dependency(&mut self, from: &str, to: &str) {
self.graph.entry(from.to_string()).or_default().insert(to.to_string());
}
pub fn detect_cycles(&self) -> Vec<Vec<String>> {
let mut cycles = Vec::new();
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
let mut path = Vec::new();
for node in self.graph.keys() {
if !visited.contains(node) {
self.dfs_detect_cycle(node, &mut visited, &mut rec_stack, &mut path, &mut cycles);
}
}
cycles
}
fn dfs_detect_cycle(
&self,
node: &str,
visited: &mut HashSet<String>,
rec_stack: &mut HashSet<String>,
path: &mut Vec<String>,
cycles: &mut Vec<Vec<String>>,
) {
visited.insert(node.to_string());
rec_stack.insert(node.to_string());
path.push(node.to_string());
if let Some(neighbors) = self.graph.get(node) {
for neighbor in neighbors {
if !visited.contains(neighbor) {
self.dfs_detect_cycle(neighbor, visited, rec_stack, path, cycles);
} else if rec_stack.contains(neighbor) {
let cycle_start = path.iter().position(|n| n == neighbor).unwrap();
let cycle = path[cycle_start..].to_vec();
cycles.push(cycle);
}
}
}
path.pop();
rec_stack.remove(node);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_conflict_detection() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "^1.0.0");
detector.add_requirement("lib1", "app2", "^1.2.0");
let conflicts = detector.detect_conflicts();
assert_eq!(conflicts.len(), 0);
detector.add_requirement("lib2", "app1", "^1.0.0");
detector.add_requirement("lib2", "app2", "^2.0.0");
let conflicts = detector.detect_conflicts();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].resource, "lib2");
}
#[test]
fn test_git_ref_compatibility() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "main");
detector.add_requirement("lib1", "app2", "main");
let conflicts = detector.detect_conflicts();
assert_eq!(conflicts.len(), 0);
detector.add_requirement("lib2", "app1", "main");
detector.add_requirement("lib2", "app2", "develop");
let conflicts = detector.detect_conflicts();
assert_eq!(conflicts.len(), 1);
}
#[test]
fn test_git_ref_case_insensitive() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "main");
detector.add_requirement("lib1", "app2", "Main");
detector.add_requirement("lib1", "app3", "MAIN");
let conflicts = detector.detect_conflicts();
assert_eq!(
conflicts.len(),
0,
"Git refs differing only by case should be compatible (case-insensitive filesystems)"
);
let mut detector2 = ConflictDetector::new();
detector2.add_requirement("lib2", "app1", "Main");
detector2.add_requirement("lib2", "app2", "Develop");
let conflicts2 = detector2.detect_conflicts();
assert_eq!(
conflicts2.len(),
1,
"Different branch names should conflict regardless of case"
);
}
#[test]
fn test_resolve_conflicts() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "^1.0.0");
detector.add_requirement("lib1", "app2", "^1.2.0");
let mut available = HashMap::new();
available.insert(
"lib1".to_string(),
vec![
Version::parse("1.0.0").unwrap(),
Version::parse("1.2.0").unwrap(),
Version::parse("1.5.0").unwrap(),
Version::parse("2.0.0").unwrap(),
],
);
let resolved = detector.resolve_conflicts(&available).unwrap();
assert_eq!(resolved.get("lib1"), Some(&Version::parse("1.5.0").unwrap()));
}
#[test]
fn test_circular_dependency_detection() {
let mut detector = CircularDependencyDetector::new();
detector.add_dependency("A", "B");
detector.add_dependency("B", "C");
detector.add_dependency("C", "A");
let cycles = detector.detect_cycles();
assert_eq!(cycles.len(), 1);
assert!(cycles[0].contains(&"A".to_string()));
assert!(cycles[0].contains(&"B".to_string()));
assert!(cycles[0].contains(&"C".to_string()));
}
#[test]
fn test_no_circular_dependencies() {
let mut detector = CircularDependencyDetector::new();
detector.add_dependency("A", "B");
detector.add_dependency("B", "C");
detector.add_dependency("A", "C");
let cycles = detector.detect_cycles();
assert_eq!(cycles.len(), 0);
}
#[test]
fn test_conflict_display() {
let conflict = VersionConflict {
resource: "test-lib".to_string(),
conflicting_requirements: vec![
ConflictingRequirement {
required_by: "app1".to_string(),
requirement: "^1.0.0".to_string(),
resolved_version: Some(Version::parse("1.5.0").unwrap()),
},
ConflictingRequirement {
required_by: "app2".to_string(),
requirement: "^2.0.0".to_string(),
resolved_version: None,
},
],
};
let display = format!("{}", conflict);
assert!(display.contains("test-lib"));
assert!(display.contains("app1"));
assert!(display.contains("^1.0.0"));
assert!(display.contains("1.5.0"));
}
#[test]
fn test_head_with_specific_version_conflict() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "HEAD");
detector.add_requirement("lib1", "app2", "^1.0.0");
let conflicts = detector.detect_conflicts();
assert_eq!(conflicts.len(), 1, "HEAD mixed with specific version should conflict");
let mut detector2 = ConflictDetector::new();
detector2.add_requirement("lib2", "app1", "*");
detector2.add_requirement("lib2", "app2", "^1.0.0");
let conflicts = detector2.detect_conflicts();
assert_eq!(
conflicts.len(),
0,
"* should be compatible with ^1.0.0 (intersection is [1.0.0, 2.0.0))"
);
let mut detector3 = ConflictDetector::new();
detector3.add_requirement("lib3", "app1", "*");
detector3.add_requirement("lib3", "app2", "~2.1.0");
let conflicts = detector3.detect_conflicts();
assert_eq!(
conflicts.len(),
0,
"* should be compatible with ~2.1.0 (intersection is [2.1.0, 2.2.0))"
);
}
#[test]
fn test_mixed_semver_and_git_refs() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "^1.0.0");
detector.add_requirement("lib1", "app2", "main");
let conflicts = detector.detect_conflicts();
assert_eq!(conflicts.len(), 1, "Mixed semver and git ref should be detected as conflict");
let mut detector2 = ConflictDetector::new();
detector2.add_requirement("lib2", "app1", "v1.0.0");
detector2.add_requirement("lib2", "app2", "develop");
let conflicts2 = detector2.detect_conflicts();
assert_eq!(conflicts2.len(), 1, "Exact version with git branch should conflict");
}
#[test]
fn test_duplicate_requirements_same_version() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "v1.0.0");
detector.add_requirement("lib1", "app2", "v1.0.0");
detector.add_requirement("lib1", "app3", "v1.0.0");
let conflicts = detector.detect_conflicts();
assert_eq!(conflicts.len(), 0, "Same version requirements should not conflict");
}
#[test]
fn test_exact_version_conflicts() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "v1.0.0");
detector.add_requirement("lib1", "app2", "v2.0.0");
let conflicts = detector.detect_conflicts();
assert_eq!(conflicts.len(), 1, "Different exact versions must conflict");
assert_eq!(conflicts[0].conflicting_requirements.len(), 2);
}
#[test]
fn test_resolve_conflicts_missing_resource() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "^1.0.0");
let available = HashMap::new();
let result = detector.resolve_conflicts(&available);
assert!(result.is_err(), "Should error when resource not in available versions");
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("No versions available"), "Error should mention missing versions");
}
#[test]
fn test_resolve_conflicts_with_incompatible_ranges() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "^1.0.0");
detector.add_requirement("lib1", "app2", "^2.0.0");
let mut available = HashMap::new();
available.insert(
"lib1".to_string(),
vec![Version::parse("1.5.0").unwrap(), Version::parse("2.3.0").unwrap()],
);
let result = detector.resolve_conflicts(&available);
assert!(result.is_err(), "Should error when requirements are incompatible");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Unable to resolve version conflicts"),
"Error should mention conflict resolution failure"
);
}
#[test]
fn test_resolve_conflicts_no_matching_version() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "^3.0.0");
let mut available = HashMap::new();
available.insert(
"lib1".to_string(),
vec![Version::parse("1.0.0").unwrap(), Version::parse("2.0.0").unwrap()],
);
let result = detector.resolve_conflicts(&available);
assert!(result.is_err(), "Should error when no version satisfies requirement");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("No version satisfies"),
"Error should mention no matching version: {}",
err_msg
);
}
#[test]
fn test_conflict_aggregated_error_message() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", "^1.0.0");
detector.add_requirement("lib1", "app2", "^2.0.0");
detector.add_requirement("lib2", "app1", "main");
detector.add_requirement("lib2", "app3", "develop");
let conflicts = detector.detect_conflicts();
assert_eq!(conflicts.len(), 2, "Should detect both conflicts");
let lib1_conflict = conflicts.iter().find(|c| c.resource == "lib1");
assert!(lib1_conflict.is_some(), "Should have lib1 conflict");
assert_eq!(
lib1_conflict.unwrap().conflicting_requirements.len(),
2,
"lib1 should have 2 conflicting requirements"
);
let lib2_conflict = conflicts.iter().find(|c| c.resource == "lib2");
assert!(lib2_conflict.is_some(), "Should have lib2 conflict");
assert_eq!(
lib2_conflict.unwrap().conflicting_requirements.len(),
2,
"lib2 should have 2 conflicting requirements"
);
}
#[test]
fn test_multi_comparator_compatible() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", ">=5.0.0, <6.0.0");
detector.add_requirement("lib1", "app2", ">=5.5.0");
let conflicts = detector.detect_conflicts();
assert_eq!(
conflicts.len(),
0,
"Multi-comparator ranges with non-empty intersection should be compatible"
);
}
#[test]
fn test_multi_comparator_incompatible() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", ">=5.0.0, <6.0.0");
detector.add_requirement("lib1", "app2", ">=7.0.0");
let conflicts = detector.detect_conflicts();
assert_eq!(
conflicts.len(),
1,
"Multi-comparator ranges with empty intersection should conflict"
);
}
#[test]
fn test_tilde_operator_variants() {
let mut detector1 = ConflictDetector::new();
detector1.add_requirement("lib1", "app1", "~1");
detector1.add_requirement("lib1", "app2", "^1.5.0");
let conflicts1 = detector1.detect_conflicts();
assert_eq!(
conflicts1.len(),
0,
"~1 should be compatible with ^1.5.0 (intersection is [1.5.0, 2.0.0))"
);
let mut detector2 = ConflictDetector::new();
detector2.add_requirement("lib2", "app1", "~1.2");
detector2.add_requirement("lib2", "app2", "^1.5.0");
let conflicts2 = detector2.detect_conflicts();
assert_eq!(conflicts2.len(), 1, "~1.2 should conflict with ^1.5.0 (disjoint ranges)");
let mut detector3 = ConflictDetector::new();
detector3.add_requirement("lib3", "app1", "~1.2.3");
detector3.add_requirement("lib3", "app2", ">=1.2.0");
let conflicts3 = detector3.detect_conflicts();
assert_eq!(conflicts3.len(), 0, "~1.2.3 should be compatible with >=1.2.0");
}
#[test]
fn test_caret_zero_zero_patch() {
let mut detector1 = ConflictDetector::new();
detector1.add_requirement("lib1", "app1", "^0.0.3");
detector1.add_requirement("lib1", "app2", ">=0.0.3, <0.0.5");
let conflicts1 = detector1.detect_conflicts();
assert_eq!(
conflicts1.len(),
0,
"^0.0.3 should be compatible with >=0.0.3, <0.0.5 (intersection is [0.0.3, 0.0.4))"
);
let mut detector2 = ConflictDetector::new();
detector2.add_requirement("lib2", "app1", "^0.0.3");
detector2.add_requirement("lib2", "app2", "^0.0.5");
let conflicts2 = detector2.detect_conflicts();
assert_eq!(conflicts2.len(), 1, "^0.0.3 should conflict with ^0.0.5 (disjoint ranges)");
}
#[test]
fn test_caret_zero_variants() {
let mut detector1 = ConflictDetector::new();
detector1.add_requirement("lib1", "app1", "^0");
detector1.add_requirement("lib1", "app2", "^0.5.0");
let conflicts1 = detector1.detect_conflicts();
assert_eq!(
conflicts1.len(),
0,
"^0 should be compatible with ^0.5.0 (intersection is [0.5.0, 0.6.0))"
);
let mut detector2 = ConflictDetector::new();
detector2.add_requirement("lib2", "app1", "^0.0");
detector2.add_requirement("lib2", "app2", "^0.5.0");
let conflicts2 = detector2.detect_conflicts();
assert_eq!(conflicts2.len(), 1, "^0.0 should conflict with ^0.5.0 (disjoint ranges)");
}
#[test]
fn test_prerelease_versions() {
let mut detector1 = ConflictDetector::new();
detector1.add_requirement("lib1", "app1", "=1.0.0-beta.1");
detector1.add_requirement("lib1", "app2", "=1.0.0");
let conflicts1 = detector1.detect_conflicts();
assert_eq!(
conflicts1.len(),
1,
"=1.0.0-beta.1 should conflict with =1.0.0 (different prerelease)"
);
let mut detector2 = ConflictDetector::new();
detector2.add_requirement("lib2", "app1", "=1.0.0-beta.1");
detector2.add_requirement("lib2", "app2", "=1.0.0-beta.1");
let conflicts2 = detector2.detect_conflicts();
assert_eq!(conflicts2.len(), 0, "Same prerelease version should be compatible");
let mut detector3 = ConflictDetector::new();
detector3.add_requirement("lib3", "app1", ">=1.0.0-beta");
detector3.add_requirement("lib3", "app2", ">=1.0.0-alpha");
let conflicts3 = detector3.detect_conflicts();
assert_eq!(conflicts3.len(), 0, ">=1.0.0-beta should be compatible with >=1.0.0-alpha");
}
#[test]
fn test_high_version_ranges() {
let mut detector = ConflictDetector::new();
detector.add_requirement("lib1", "app1", ">=5.0.0, <10.0.0");
detector.add_requirement("lib1", "app2", "^7.5.0");
let conflicts = detector.detect_conflicts();
assert_eq!(
conflicts.len(),
0,
"High version ranges should work correctly (intersection is [7.5.0, 8.0.0))"
);
let mut detector2 = ConflictDetector::new();
detector2.add_requirement("lib2", "app1", ">=100.0.0");
detector2.add_requirement("lib2", "app2", "<50.0.0");
let conflicts2 = detector2.detect_conflicts();
assert_eq!(conflicts2.len(), 1, "Disjoint high version ranges should conflict");
}
}