use crate::config::schema::{is_maven_dep, Lockfile, YmConfig};
use crate::workspace::resolver::version_compare;
use std::collections::BTreeMap;
#[derive(Debug, Default, PartialEq, Eq)]
pub struct LockfileDiff {
pub added: Vec<String>,
pub version_changed: Vec<(String, String, String)>,
}
impl LockfileDiff {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.version_changed.is_empty()
}
}
pub fn compute_diff(cfg: &YmConfig, lock: &Lockfile) -> LockfileDiff {
let mut ym_direct: BTreeMap<String, String> = BTreeMap::new();
if let Some(ref deps) = cfg.dependencies {
for (coord, value) in deps {
if !is_maven_dep(coord) {
continue;
}
if value.is_workspace() || value.url().is_some() || value.git().is_some() {
continue;
}
if let Some(version) = value.version() {
ym_direct.insert(coord.clone(), version.to_string());
}
}
}
let mut lock_winners: BTreeMap<String, String> = BTreeMap::new();
for gav in lock.dependencies.keys() {
let parts: Vec<&str> = gav.splitn(3, ':').collect();
if parts.len() != 3 {
continue;
}
let ga = format!("{}:{}", parts[0], parts[1]);
if !ym_direct.contains_key(&ga) {
continue;
}
lock_winners
.entry(ga)
.and_modify(|existing| {
if version_compare(parts[2], existing) > 0 {
*existing = parts[2].to_string();
}
})
.or_insert_with(|| parts[2].to_string());
}
let mut diff = LockfileDiff::default();
for (ga, ym_ver) in &ym_direct {
match lock_winners.get(ga) {
Some(lock_ver) if lock_ver != ym_ver => {
diff.version_changed
.push((ga.clone(), lock_ver.clone(), ym_ver.clone()));
}
None => diff.added.push(ga.clone()),
_ => {}
}
}
diff
}
pub fn format_diff_error(diff: &LockfileDiff) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "Lockfile is out of sync with ym.json:");
if !diff.added.is_empty() {
let _ = writeln!(out, "\n Added in ym.json (missing from ym-lock.json):");
for ga in &diff.added {
let _ = writeln!(out, " + {}", ga);
}
}
if !diff.version_changed.is_empty() {
let _ = writeln!(out, "\n Version changed:");
for (ga, from, to) in &diff.version_changed {
let _ = writeln!(out, " ~ {}: {} → {}", ga, from, to);
}
}
let _ = writeln!(
out,
"\n Run `ym install` (or just `ymc build` without --frozen-lockfile) to update ym-lock.json, then commit it."
);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::{DependencySpec, DependencyValue, ResolvedDependency};
fn make_cfg(deps: &[(&str, &str)]) -> YmConfig {
let mut cfg = YmConfig::default();
let mut map = BTreeMap::new();
for (k, v) in deps {
map.insert(k.to_string(), DependencyValue::Simple(v.to_string()));
}
cfg.dependencies = Some(map);
cfg
}
fn make_lock(deps: &[&str]) -> Lockfile {
let mut lock = Lockfile::default();
for gav in deps {
lock.dependencies
.insert(gav.to_string(), ResolvedDependency::default());
}
lock
}
#[test]
fn test_diff_empty_when_in_sync() {
let cfg = make_cfg(&[("com.google.guava:guava", "33.4.0")]);
let lock = make_lock(&["com.google.guava:guava:33.4.0"]);
let diff = compute_diff(&cfg, &lock);
assert!(diff.is_empty());
}
#[test]
fn test_diff_detects_added_direct_dep() {
let cfg = make_cfg(&[
("com.google.guava:guava", "33.4.0"),
("org.junit.jupiter:junit-jupiter", "5.11.0"),
]);
let lock = make_lock(&["com.google.guava:guava:33.4.0"]);
let diff = compute_diff(&cfg, &lock);
assert_eq!(diff.added, vec!["org.junit.jupiter:junit-jupiter".to_string()]);
assert!(diff.version_changed.is_empty());
}
#[test]
fn test_diff_detects_version_change() {
let cfg = make_cfg(&[("com.google.guava:guava", "33.5.0")]);
let lock = make_lock(&["com.google.guava:guava:33.4.0"]);
let diff = compute_diff(&cfg, &lock);
assert_eq!(
diff.version_changed,
vec![("com.google.guava:guava".to_string(), "33.4.0".to_string(), "33.5.0".to_string())]
);
}
#[test]
fn test_diff_picks_latest_lock_version_for_ga() {
let cfg = make_cfg(&[("com.google.guava:guava", "33.5.0")]);
let mut lock = make_lock(&[
"com.google.guava:guava:33.4.0",
"com.google.guava:guava:33.6.0",
]);
let _ = &mut lock;
let diff = compute_diff(&cfg, &lock);
assert_eq!(diff.version_changed.len(), 1);
assert_eq!(diff.version_changed[0].1, "33.6.0");
assert_eq!(diff.version_changed[0].2, "33.5.0");
}
#[test]
fn test_diff_skips_workspace_url_git() {
let mut cfg = YmConfig::default();
let mut map = BTreeMap::new();
map.insert(
"core".to_string(),
DependencyValue::Detailed(DependencySpec {
workspace: Some(true),
..Default::default()
}),
);
map.insert(
"external-jar".to_string(),
DependencyValue::Detailed(DependencySpec {
url: Some("https://example.com/lib.jar".to_string()),
..Default::default()
}),
);
cfg.dependencies = Some(map);
let lock = Lockfile::default();
let diff = compute_diff(&cfg, &lock);
assert!(diff.is_empty());
}
#[test]
fn test_format_diff_error_lists_changes() {
let diff = LockfileDiff {
added: vec!["org.junit.jupiter:junit-jupiter".to_string()],
version_changed: vec![(
"com.google.guava:guava".to_string(),
"33.4.0".to_string(),
"33.5.0".to_string(),
)],
};
let msg = format_diff_error(&diff);
assert!(msg.contains("Added in ym.json"));
assert!(msg.contains("+ org.junit.jupiter:junit-jupiter"));
assert!(msg.contains("Version changed"));
assert!(msg.contains("~ com.google.guava:guava: 33.4.0 → 33.5.0"));
assert!(msg.contains("Run `ym install`"));
}
}