#![deny(warnings)]
use std::collections::HashMap;
use std::path::PathBuf;
use crate::federation::scanner::InstalledAsset;
use crate::persistence::writer::{DbCommand, WriterHandle};
pub fn drift_kind_label(kind: &DriftKind) -> &'static str {
match kind {
DriftKind::None => "none",
DriftKind::ContentDrift => "content_drift",
DriftKind::VersionDrift => "version_drift",
}
}
pub async fn persist_drift(tx: &WriterHandle, records: &[DriftRecord], first_detected: &str) {
for r in records {
if r.kind == DriftKind::None {
continue;
}
let _ = tx
.send(DbCommand::RecordDrift {
workspace_path: r.workspace_path.to_string_lossy().into_owned(),
asset_id: r.asset_id.clone(),
drift_type: drift_kind_label(&r.kind).to_string(),
first_detected: first_detected.to_string(),
expected_hash: r.expected_hash.clone(),
actual_hash: r.actual_hash.clone(),
})
.await;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriftKind {
None,
ContentDrift,
VersionDrift,
}
pub fn classify_drift(
installed: &InstalledAsset,
canonical_hash: Option<&str>,
canonical_version: Option<&str>,
) -> DriftKind {
let canon_hash = match canonical_hash {
Some(h) => h,
None => return DriftKind::None,
};
if installed.content_hash == canon_hash {
return DriftKind::None;
}
let installed_ver = installed.installed_version.as_deref();
let versions_same = match (installed_ver, canonical_version) {
(None, None) => true,
(Some(iv), Some(cv)) => iv == cv,
_ => false,
};
if versions_same {
DriftKind::ContentDrift
} else {
DriftKind::VersionDrift
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DriftRecord {
pub workspace_path: PathBuf,
pub asset_id: String,
pub expected_hash: String,
pub actual_hash: String,
pub kind: DriftKind,
}
pub fn detect_drift(
installed: &[InstalledAsset],
canonical_hashes: &HashMap<String, String>,
canonical_versions: &HashMap<String, String>,
) -> Vec<DriftRecord> {
let mut records: Vec<DriftRecord> = installed
.iter()
.filter(|asset| asset.confirmed)
.filter_map(|asset| {
let canon_hash = canonical_hashes.get(&asset.asset_id).map(|s| s.as_str());
let canon_version = canonical_versions.get(&asset.asset_id).map(|s| s.as_str());
let kind = classify_drift(asset, canon_hash, canon_version);
if kind == DriftKind::None {
return Option::None;
}
let expected_hash = canon_hash.unwrap_or("").to_string();
Some(DriftRecord {
workspace_path: asset.workspace_path.clone(),
asset_id: asset.asset_id.clone(),
expected_hash,
actual_hash: asset.content_hash.clone(),
kind,
})
})
.collect();
records.sort_by(|a, b| {
a.asset_id
.cmp(&b.asset_id)
.then_with(|| a.workspace_path.cmp(&b.workspace_path))
});
records
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn make_asset(
asset_id: &str,
content_hash: &str,
installed_version: Option<&str>,
) -> InstalledAsset {
use crate::federation::scanner::DetectionMethod;
InstalledAsset {
workspace_path: PathBuf::from(format!("/ws/{asset_id}")),
asset_id: asset_id.to_string(),
installed_version: installed_version.map(|s| s.to_string()),
content_hash: content_hash.to_string(),
detection_methods: vec![DetectionMethod::Filename, DetectionMethod::MetadataComment],
confirmed: true,
harness: ".claude".to_string(),
}
}
#[test]
fn classify_no_canonical_hash_returns_none() {
let asset = make_asset("agents/aws/cdk", "abc123", Some("1.0.0"));
assert_eq!(classify_drift(&asset, None, Some("1.0.0")), DriftKind::None);
}
#[test]
fn classify_hash_match_returns_none() {
let asset = make_asset("agents/aws/cdk", "abc123", Some("1.0.0"));
assert_eq!(
classify_drift(&asset, Some("abc123"), Some("1.0.0")),
DriftKind::None
);
}
#[test]
fn classify_hash_mismatch_same_version_is_content_drift() {
let asset = make_asset("agents/aws/cdk", "aaa", Some("1.0.0"));
assert_eq!(
classify_drift(&asset, Some("bbb"), Some("1.0.0")),
DriftKind::ContentDrift
);
}
#[test]
fn classify_hash_mismatch_both_version_none_is_content_drift() {
let asset = make_asset("agents/aws/cdk", "aaa", None);
assert_eq!(
classify_drift(&asset, Some("bbb"), None),
DriftKind::ContentDrift
);
}
#[test]
fn classify_hash_mismatch_different_versions_is_version_drift() {
let asset = make_asset("agents/aws/cdk", "aaa", Some("1.0.0"));
assert_eq!(
classify_drift(&asset, Some("bbb"), Some("2.0.0")),
DriftKind::VersionDrift
);
}
#[test]
fn classify_hash_mismatch_installed_version_none_canonical_some_is_version_drift() {
let asset = make_asset("agents/aws/cdk", "aaa", None);
assert_eq!(
classify_drift(&asset, Some("bbb"), Some("1.0.0")),
DriftKind::VersionDrift
);
}
#[test]
fn classify_hash_mismatch_installed_some_canonical_none_is_version_drift() {
let asset = make_asset("agents/aws/cdk", "aaa", Some("1.0.0"));
assert_eq!(
classify_drift(&asset, Some("bbb"), None),
DriftKind::VersionDrift
);
}
#[test]
fn detect_drift_empty_returns_empty() {
let records = detect_drift(&[], &HashMap::new(), &HashMap::new());
assert!(records.is_empty());
}
#[test]
fn detect_drift_no_mismatch_returns_empty() {
let asset = make_asset("agents/aws/cdk", "abc123", Some("1.0.0"));
let mut hashes = HashMap::new();
hashes.insert("agents/aws/cdk".to_string(), "abc123".to_string());
let mut versions = HashMap::new();
versions.insert("agents/aws/cdk".to_string(), "1.0.0".to_string());
let records = detect_drift(&[asset], &hashes, &versions);
assert!(records.is_empty());
}
#[test]
fn detect_drift_content_drift_detected() {
let asset = make_asset("agents/aws/cdk", "actual_hash", Some("1.0.0"));
let mut hashes = HashMap::new();
hashes.insert("agents/aws/cdk".to_string(), "expected_hash".to_string());
let mut versions = HashMap::new();
versions.insert("agents/aws/cdk".to_string(), "1.0.0".to_string());
let records = detect_drift(&[asset], &hashes, &versions);
assert_eq!(records.len(), 1);
assert_eq!(records[0].kind, DriftKind::ContentDrift);
assert_eq!(records[0].expected_hash, "expected_hash");
assert_eq!(records[0].actual_hash, "actual_hash");
}
#[test]
fn detect_drift_version_drift_detected() {
let asset = make_asset("agents/aws/cdk", "new_hash", Some("2.0.0"));
let mut hashes = HashMap::new();
hashes.insert("agents/aws/cdk".to_string(), "old_hash".to_string());
let mut versions = HashMap::new();
versions.insert("agents/aws/cdk".to_string(), "1.0.0".to_string());
let records = detect_drift(&[asset], &hashes, &versions);
assert_eq!(records.len(), 1);
assert_eq!(records[0].kind, DriftKind::VersionDrift);
}
#[test]
fn detect_drift_sorted_by_asset_id_then_path() {
use crate::federation::scanner::DetectionMethod;
let mut asset_b = make_asset("agents/b", "hash_b", Some("1.0.0"));
let mut asset_a = make_asset("agents/a", "hash_a", Some("1.0.0"));
asset_b.content_hash = "b_actual".to_string();
asset_a.content_hash = "a_actual".to_string();
let mut hashes = HashMap::new();
hashes.insert("agents/b".to_string(), "b_expected".to_string());
hashes.insert("agents/a".to_string(), "a_expected".to_string());
let mut versions = HashMap::new();
versions.insert("agents/b".to_string(), "1.0.0".to_string());
versions.insert("agents/a".to_string(), "1.0.0".to_string());
let records = detect_drift(&[asset_b, asset_a], &hashes, &versions);
assert_eq!(records.len(), 2);
assert_eq!(records[0].asset_id, "agents/a");
assert_eq!(records[1].asset_id, "agents/b");
let _ = DetectionMethod::Filename;
}
#[test]
fn detect_drift_unconfirmed_assets_excluded() {
use crate::federation::scanner::DetectionMethod;
let mut asset = make_asset("agents/aws/cdk", "actual", Some("1.0.0"));
asset.confirmed = false;
asset.detection_methods = vec![DetectionMethod::Filename];
let mut hashes = HashMap::new();
hashes.insert("agents/aws/cdk".to_string(), "expected".to_string());
let mut versions = HashMap::new();
versions.insert("agents/aws/cdk".to_string(), "1.0.0".to_string());
let records = detect_drift(&[asset], &hashes, &versions);
assert!(
records.is_empty(),
"unconfirmed assets should not generate drift records"
);
}
prop_compose! {
fn arb_hash()(s in "[0-9a-f]{10}") -> String { s }
}
prop_compose! {
fn arb_version_str()(
major in 0u8..5,
minor in 0u8..10,
patch in 0u8..10,
) -> String {
format!("{major}.{minor}.{patch}")
}
}
proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 256,
..Default::default()
})]
#[test]
fn prop21a_content_drift_iff_hash_differs_version_same(
hash_a in arb_hash(),
hash_b in arb_hash(),
version in arb_version_str(),
) {
let asset = make_asset("agents/test", &hash_a, Some(&version));
let canonical_hash = Some(hash_b.as_str());
let canonical_version = Some(version.as_str());
let result = classify_drift(&asset, canonical_hash, canonical_version);
if hash_a == hash_b {
prop_assert_eq!(result, DriftKind::None, "hash match → None");
} else {
prop_assert_eq!(result, DriftKind::ContentDrift,
"hash differs, version same → ContentDrift");
}
}
#[test]
fn prop21b_version_drift_iff_hash_differs_version_different(
hash_a in arb_hash(),
hash_b in arb_hash(),
ver_a in arb_version_str(),
ver_b in arb_version_str(),
) {
prop_assume!(ver_a != ver_b);
let asset = make_asset("agents/test", &hash_a, Some(&ver_a));
let canonical_hash = Some(hash_b.as_str());
let canonical_version = Some(ver_b.as_str());
let result = classify_drift(&asset, canonical_hash, canonical_version);
if hash_a == hash_b {
prop_assert_eq!(result, DriftKind::None, "hash match → None");
} else {
prop_assert_eq!(result, DriftKind::VersionDrift,
"hash differs, versions different → VersionDrift");
}
}
#[test]
fn prop21c_none_iff_hash_matches(
hash in arb_hash(),
version_a in arb_version_str(),
version_b in arb_version_str(),
) {
let asset = make_asset("agents/test", &hash, Some(&version_a));
let result = classify_drift(&asset, Some(hash.as_str()), Some(&version_b));
prop_assert_eq!(result, DriftKind::None,
"hash matches → None regardless of versions");
}
#[test]
fn prop21d_no_canonical_hash_always_none(
hash in arb_hash(),
version in arb_version_str(),
) {
let asset = make_asset("agents/test", &hash, Some(&version));
let result = classify_drift(&asset, None, Some(&version));
prop_assert_eq!(result, DriftKind::None,
"no canonical hash → cannot detect drift → None");
}
#[test]
fn prop21e_both_version_none_hash_mismatch_is_content_drift(
hash_a in arb_hash(),
hash_b in arb_hash(),
) {
prop_assume!(hash_a != hash_b);
let asset = make_asset("agents/test", &hash_a, None);
let result = classify_drift(&asset, Some(&hash_b), None);
prop_assert_eq!(result, DriftKind::ContentDrift,
"hash differs, both versions None → ContentDrift");
}
}
}