use crate::build_spec::{BuildSpec, CrateSource, SCHEMA_VERSION};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[gen_macros::fsm(label = "gen.cargo.lock-lifecycle-state")]
pub enum LockLifecycleState {
Unlocked {
current_lock_hash: String,
},
Locked {
spec_hash: String,
lock_hash: String,
},
Drifted {
committed_lock_hash: String,
current_lock_hash: String,
},
MissingLock,
}
impl LockLifecycleState {
#[must_use]
pub fn summary(&self) -> &'static str {
match self {
Self::Unlocked { .. } => "unlocked",
Self::Locked { .. } => "locked",
Self::Drifted { .. } => "drifted",
Self::MissingLock => "missing-lock",
}
}
#[must_use]
pub fn can_build_with_committed_spec(&self) -> bool {
matches!(self, Self::Locked { .. })
}
#[must_use]
pub fn requires_operator_action(&self) -> bool {
matches!(self, Self::Drifted { .. } | Self::MissingLock)
}
}
#[must_use]
pub fn current_state(root: &Path) -> LockLifecycleState {
use crate::build_spec::{check_freshness, Freshness};
match check_freshness(root) {
Freshness::MissingLock => LockLifecycleState::MissingLock,
Freshness::MissingSpec { lock_hash } | Freshness::UnhashedSpec { lock_hash } => {
LockLifecycleState::Unlocked {
current_lock_hash: lock_hash,
}
}
Freshness::Fresh { spec_hash, lock_hash } => LockLifecycleState::Locked {
spec_hash,
lock_hash,
},
Freshness::Drifted { spec_hash, lock_hash } => LockLifecycleState::Drifted {
committed_lock_hash: spec_hash,
current_lock_hash: lock_hash,
},
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockDiff {
pub added: Vec<LockEntry>,
pub removed: Vec<LockEntry>,
pub upgraded: Vec<LockUpgrade>,
pub source_changed: Vec<LockSourceChange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_version: Option<SchemaVersionDelta>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockEntry {
pub name: String,
pub version: String,
pub source_kind: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockUpgrade {
pub name: String,
pub from_version: String,
pub to_version: String,
pub source_kind: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockSourceChange {
pub name: String,
pub version: String,
pub from_kind: String,
pub to_kind: String,
pub from_locator: String,
pub to_locator: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaVersionDelta {
pub from: u32,
pub to: u32,
}
impl LockDiff {
#[must_use]
pub fn is_empty(&self) -> bool {
self.added.is_empty()
&& self.removed.is_empty()
&& self.upgraded.is_empty()
&& self.source_changed.is_empty()
&& self.schema_version.is_none()
}
#[must_use]
pub fn total_moves(&self) -> usize {
self.added.len() + self.removed.len() + self.upgraded.len() + self.source_changed.len()
}
}
#[must_use]
pub fn diff_specs(old: &BuildSpec, new: &BuildSpec) -> LockDiff {
use std::collections::BTreeMap;
let mut old_by_id: BTreeMap<(String, String), &crate::build_spec::CrateSpec> =
BTreeMap::new();
for c in old.crates.values() {
old_by_id.insert((c.name.clone(), c.version.clone()), c);
}
let mut new_by_id: BTreeMap<(String, String), &crate::build_spec::CrateSpec> =
BTreeMap::new();
for c in new.crates.values() {
new_by_id.insert((c.name.clone(), c.version.clone()), c);
}
let mut added = Vec::new();
let mut removed = Vec::new();
let mut upgraded: Vec<LockUpgrade> = Vec::new();
let mut source_changed = Vec::new();
let mut pending_unmatched_new: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
for ((name, version), new_crate) in &new_by_id {
if let Some(old_crate) = old_by_id.get(&(name.clone(), version.clone())) {
let (old_kind, old_loc) = source_kind_and_locator(&old_crate.source);
let (new_kind, new_loc) = source_kind_and_locator(&new_crate.source);
if old_kind != new_kind || old_loc != new_loc {
source_changed.push(LockSourceChange {
name: name.clone(),
version: version.clone(),
from_kind: old_kind,
to_kind: new_kind,
from_locator: old_loc,
to_locator: new_loc,
});
}
} else {
pending_unmatched_new
.entry(name.clone())
.or_default()
.push((version.clone(), source_kind(&new_crate.source)));
}
}
for (name, new_versions) in pending_unmatched_new {
let old_versions: Vec<String> = old_by_id
.iter()
.filter(|((n, _), _)| n == &name)
.map(|((_, v), _)| v.clone())
.collect();
if old_versions.is_empty() {
for (v, kind) in new_versions {
added.push(LockEntry {
name: name.clone(),
version: v,
source_kind: kind,
});
}
} else {
for (new_v, kind) in &new_versions {
for old_v in &old_versions {
upgraded.push(LockUpgrade {
name: name.clone(),
from_version: old_v.clone(),
to_version: new_v.clone(),
source_kind: kind.clone(),
});
}
}
}
}
for ((name, version), c) in &old_by_id {
if !new_by_id.contains_key(&(name.clone(), version.clone())) {
let same_name_in_new = new_by_id.iter().any(|((n, _), _)| n == name);
if !same_name_in_new {
removed.push(LockEntry {
name: name.clone(),
version: version.clone(),
source_kind: source_kind(&c.source),
});
}
}
}
let mut diff = LockDiff {
added,
removed,
upgraded,
source_changed,
schema_version: if old.version != new.version {
Some(SchemaVersionDelta {
from: old.version,
to: new.version,
})
} else {
None
},
};
diff.added.sort_by(|a, b| (a.name.clone(), a.version.clone()).cmp(&(b.name.clone(), b.version.clone())));
diff.removed.sort_by(|a, b| (a.name.clone(), a.version.clone()).cmp(&(b.name.clone(), b.version.clone())));
diff.upgraded.sort_by(|a, b| a.name.cmp(&b.name));
diff.source_changed.sort_by(|a, b| a.name.cmp(&b.name));
diff
}
fn source_kind(s: &CrateSource) -> String {
match s {
CrateSource::Registry { .. } => "registry".to_string(),
CrateSource::Git { .. } => "git".to_string(),
CrateSource::Path { .. } => "path".to_string(),
}
}
fn source_kind_and_locator(s: &CrateSource) -> (String, String) {
match s {
CrateSource::Registry { url, .. } => ("registry".to_string(), url.clone()),
CrateSource::Git { url, rev, .. } => ("git".to_string(), format!("{url}#{rev}")),
CrateSource::Path { relative_path } => ("path".to_string(), relative_path.clone()),
}
}
#[allow(dead_code)]
const _: u32 = SCHEMA_VERSION;
#[derive(Debug, Default, Clone, Copy)]
pub struct CargoLifecycle;
impl CargoLifecycle {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl gen_types::LockLifecyclePrimitive for CargoLifecycle {
type State = LockLifecycleState;
type Diff = LockDiff;
fn ecosystem(&self) -> &'static str {
"cargo"
}
fn current_state(&self, root: &Path) -> Self::State {
current_state(root)
}
fn requires_operator_action(&self, state: &Self::State) -> bool {
state.requires_operator_action()
}
fn is_locked(&self, state: &Self::State) -> bool {
matches!(state, LockLifecycleState::Locked { .. })
}
fn is_missing_lock(&self, state: &Self::State) -> bool {
matches!(state, LockLifecycleState::MissingLock)
}
fn snapshot(&self, root: &Path) -> Result<(), gen_types::LockError> {
use gen_types::LockError;
match self.current_state(root) {
LockLifecycleState::MissingLock => Err(LockError::MissingLockfile {
path: root.join("Cargo.lock"),
}),
_ => crate::build_spec::generate_multi_target_and_write(root)
.map(|_| ())
.map_err(|e| LockError::SpecGeneration {
action: "snapshot",
source: Box::new(e),
}),
}
}
fn update(&self, root: &Path) -> Result<Self::Diff, gen_types::LockError> {
use gen_types::LockError;
let spec_path = root.join("Cargo.build-spec.json");
let old_spec = if spec_path.exists() {
match std::fs::read(&spec_path) {
Ok(b) => serde_json::from_slice::<BuildSpec>(&b).ok(),
Err(_) => None,
}
} else {
None
};
crate::build_spec::generate_multi_target_and_write(root).map_err(|e| {
LockError::SpecGeneration {
action: "update",
source: Box::new(e),
}
})?;
let new_bytes =
std::fs::read(&spec_path).map_err(|source| LockError::Io {
action: "update",
path: spec_path.clone(),
source,
})?;
let new_spec: BuildSpec =
serde_json::from_slice(&new_bytes).map_err(|e| LockError::SpecGeneration {
action: "update",
source: Box::new(e),
})?;
Ok(old_spec
.as_ref()
.map_or_else(LockDiff::default, |o| diff_specs(o, &new_spec)))
}
fn reset(&self, root: &Path) -> Result<(), gen_types::LockError> {
use gen_types::LockError;
let spec_path = root.join("Cargo.build-spec.json");
if spec_path.exists() {
std::fs::remove_file(&spec_path).map_err(|source| LockError::Io {
action: "reset",
path: spec_path,
source,
})?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::build_spec::{
BuildSpec, CrateSource, CrateSpec, BuildRustCrateArgs, WorkspaceSpec,
};
use indexmap::IndexMap;
fn empty_spec(version: u32) -> BuildSpec {
BuildSpec {
version,
workspace: WorkspaceSpec {
root: "/x".into(),
members: vec![],
},
crates: IndexMap::new(),
root_crate: String::new(),
workspace_members: vec![],
flake_metadata: IndexMap::new(),
target_resolves: None,
cargo_lock_sha256: None,
}
}
fn make_crate(name: &str, version: &str, source: CrateSource) -> CrateSpec {
CrateSpec {
name: name.to_string(),
version: version.to_string(),
edition: "2024".to_string(),
source,
features: vec![],
proc_macro: false,
build_script: None,
links: None,
quirks: vec![],
binaries: vec![],
lib_target: None,
dependencies: vec![],
runtime_dependencies: vec![],
build_dependencies: vec![],
crate_renames: IndexMap::new(),
build_rust_crate_args: BuildRustCrateArgs::default(),
}
}
fn registry(name: &str) -> CrateSource {
CrateSource::Registry {
url: format!("https://static.crates.io/crates/{name}/{name}-1.0.0.crate"),
sha256: "deadbeef".to_string(),
name_with_ext: format!("{name}-1.0.0.tar.gz"),
}
}
fn git(url: &str, rev: &str) -> CrateSource {
CrateSource::Git {
url: url.to_string(),
rev: rev.to_string(),
sha256: Some("abc".to_string()),
}
}
#[test]
fn empty_diff_for_identical_specs() {
let a = empty_spec(8);
let b = empty_spec(8);
let d = diff_specs(&a, &b);
assert!(d.is_empty());
assert_eq!(d.total_moves(), 0);
}
#[test]
fn add_detection() {
let mut a = empty_spec(8);
let mut b = empty_spec(8);
b.crates.insert(
"serde-1.0.0".into(),
make_crate("serde", "1.0.0", registry("serde")),
);
let _ = a;
let d = diff_specs(&empty_spec(8), &b);
assert_eq!(d.added.len(), 1);
assert_eq!(d.added[0].name, "serde");
assert_eq!(d.added[0].version, "1.0.0");
assert_eq!(d.added[0].source_kind, "registry");
}
#[test]
fn remove_detection() {
let mut a = empty_spec(8);
a.crates.insert(
"serde-1.0.0".into(),
make_crate("serde", "1.0.0", registry("serde")),
);
let b = empty_spec(8);
let d = diff_specs(&a, &b);
assert_eq!(d.removed.len(), 1);
assert_eq!(d.removed[0].name, "serde");
}
#[test]
fn upgrade_detection() {
let mut a = empty_spec(8);
a.crates.insert(
"serde-1.0.0".into(),
make_crate("serde", "1.0.0", registry("serde")),
);
let mut b = empty_spec(8);
b.crates.insert(
"serde-1.1.0".into(),
make_crate("serde", "1.1.0", registry("serde")),
);
let d = diff_specs(&a, &b);
assert_eq!(d.upgraded.len(), 1);
assert_eq!(d.upgraded[0].name, "serde");
assert_eq!(d.upgraded[0].from_version, "1.0.0");
assert_eq!(d.upgraded[0].to_version, "1.1.0");
assert!(d.added.is_empty());
assert!(d.removed.is_empty());
}
#[test]
fn source_change_detection_registry_to_git() {
let mut a = empty_spec(8);
a.crates.insert(
"serde-1.0.0".into(),
make_crate("serde", "1.0.0", registry("serde")),
);
let mut b = empty_spec(8);
b.crates.insert(
"serde-1.0.0".into(),
make_crate(
"serde",
"1.0.0",
git("https://github.com/x/y", "deadbeef"),
),
);
let d = diff_specs(&a, &b);
assert_eq!(d.source_changed.len(), 1);
assert_eq!(d.source_changed[0].from_kind, "registry");
assert_eq!(d.source_changed[0].to_kind, "git");
assert!(d.upgraded.is_empty());
}
#[test]
fn schema_version_delta() {
let a = empty_spec(7);
let b = empty_spec(8);
let d = diff_specs(&a, &b);
assert_eq!(d.schema_version, Some(SchemaVersionDelta { from: 7, to: 8 }));
}
#[test]
fn diff_is_deterministic_via_sorted_outputs() {
let mut a = empty_spec(8);
let mut b = empty_spec(8);
for name in ["zebra", "apple", "mango"] {
b.crates.insert(
format!("{name}-1.0.0"),
make_crate(name, "1.0.0", registry(name)),
);
}
let _ = a.crates.insert(
"zebra-1.0.0".into(),
make_crate("zebra", "1.0.0", registry("zebra")),
);
let d = diff_specs(&a, &b);
assert_eq!(d.added.len(), 2);
assert_eq!(d.added[0].name, "apple");
assert_eq!(d.added[1].name, "mango");
}
#[test]
fn lifecycle_state_has_four_variants() {
use gen_platform::TypedDispatcherTrait;
assert_eq!(<LockLifecycleState as TypedDispatcherTrait>::variant_count(), 4);
let kinds = <LockLifecycleState as TypedDispatcherTrait>::variant_kinds();
assert!(kinds.contains(&"unlocked"));
assert!(kinds.contains(&"locked"));
assert!(kinds.contains(&"drifted"));
assert!(kinds.contains(&"missing-lock"));
}
#[test]
fn lifecycle_state_summary_matches_discriminant() {
let cases = [
LockLifecycleState::Unlocked { current_lock_hash: "x".into() },
LockLifecycleState::Locked { spec_hash: "x".into(), lock_hash: "x".into() },
LockLifecycleState::Drifted { committed_lock_hash: "x".into(), current_lock_hash: "y".into() },
LockLifecycleState::MissingLock,
];
for s in &cases {
assert_eq!(s.summary(), s.discriminant());
}
}
#[test]
fn requires_operator_action_only_for_drift_and_missing() {
assert!(LockLifecycleState::Drifted { committed_lock_hash: "a".into(), current_lock_hash: "b".into() }.requires_operator_action());
assert!(LockLifecycleState::MissingLock.requires_operator_action());
assert!(!LockLifecycleState::Unlocked { current_lock_hash: "x".into() }.requires_operator_action());
assert!(!LockLifecycleState::Locked { spec_hash: "x".into(), lock_hash: "x".into() }.requires_operator_action());
}
}