use super::crates_io::{CratesIoClient, DependencyData};
use super::{is_paiml_crate, PAIML_CRATES};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DriftSeverity {
Major,
Minor,
Patch,
}
impl DriftSeverity {
pub fn as_str(&self) -> &'static str {
match self {
DriftSeverity::Major => "MAJOR",
DriftSeverity::Minor => "MINOR",
DriftSeverity::Patch => "PATCH",
}
}
}
impl std::fmt::Display for DriftSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftReport {
pub crate_name: String,
pub crate_version: String,
pub dependency: String,
pub uses_version: String,
pub latest_version: String,
pub severity: DriftSeverity,
}
impl DriftReport {
pub fn display(&self) -> String {
format!(
"{} {}: {} {} → {} ({})",
self.crate_name,
self.crate_version,
self.dependency,
self.uses_version,
self.latest_version,
self.severity
)
}
}
pub struct DriftChecker {
latest_versions: HashMap<String, semver::Version>,
}
impl DriftChecker {
pub fn new() -> Self {
Self { latest_versions: HashMap::new() }
}
#[cfg(feature = "native")]
pub async fn fetch_latest_versions(&mut self, client: &mut CratesIoClient) -> Result<()> {
for crate_name in PAIML_CRATES {
match client.get_latest_version(crate_name).await {
Ok(version) => {
self.latest_versions.insert((*crate_name).to_string(), version);
}
Err(_) => {
continue;
}
}
}
Ok(())
}
#[cfg(feature = "native")]
pub async fn detect_self_drift(
&mut self,
client: &mut CratesIoClient,
) -> Result<Vec<DriftReport>> {
if self.latest_versions.is_empty() {
self.fetch_latest_versions(client).await?;
}
let mut drifts = Vec::new();
let crate_version = match self.latest_versions.get("batuta") {
Some(v) => v.to_string(),
None => return Ok(drifts),
};
let deps = match client.get_dependencies("batuta", &crate_version).await {
Ok(d) => d,
Err(_) => return Ok(drifts),
};
self.check_deps_for_drift("batuta", &crate_version, &deps, &mut drifts);
Self::sort_drifts(&mut drifts);
Ok(drifts)
}
#[cfg(feature = "native")]
pub async fn detect_drift(&mut self, client: &mut CratesIoClient) -> Result<Vec<DriftReport>> {
if self.latest_versions.is_empty() {
self.fetch_latest_versions(client).await?;
}
let mut drifts = Vec::new();
for crate_name in PAIML_CRATES {
let crate_version = match self.latest_versions.get(*crate_name) {
Some(v) => v.to_string(),
None => continue, };
let deps = match client.get_dependencies(crate_name, &crate_version).await {
Ok(d) => d,
Err(_) => continue, };
self.check_deps_for_drift(crate_name, &crate_version, &deps, &mut drifts);
}
Self::sort_drifts(&mut drifts);
Ok(drifts)
}
fn check_deps_for_drift(
&self,
crate_name: &str,
crate_version: &str,
deps: &[DependencyData],
drifts: &mut Vec<DriftReport>,
) {
for dep in deps {
if !is_paiml_crate(&dep.crate_id) {
continue;
}
if dep.kind == "dev" {
continue;
}
let latest = match self.latest_versions.get(&dep.crate_id) {
Some(v) => v,
None => continue, };
if let Some(drift) = self.check_drift(crate_name, crate_version, dep, latest) {
drifts.push(drift);
}
}
}
fn sort_drifts(drifts: &mut [DriftReport]) {
drifts.sort_by(|a, b| match (&a.severity, &b.severity) {
(DriftSeverity::Major, DriftSeverity::Major) => a.crate_name.cmp(&b.crate_name),
(DriftSeverity::Major, _) => std::cmp::Ordering::Less,
(_, DriftSeverity::Major) => std::cmp::Ordering::Greater,
(DriftSeverity::Minor, DriftSeverity::Minor) => a.crate_name.cmp(&b.crate_name),
(DriftSeverity::Minor, _) => std::cmp::Ordering::Less,
(_, DriftSeverity::Minor) => std::cmp::Ordering::Greater,
_ => a.crate_name.cmp(&b.crate_name),
});
}
fn check_drift(
&self,
crate_name: &str,
crate_version: &str,
dep: &DependencyData,
latest: &semver::Version,
) -> Option<DriftReport> {
let uses_version = &dep.version_req;
let version_str = uses_version
.trim_start_matches('^')
.trim_start_matches('~')
.trim_start_matches('=')
.trim_start_matches('>')
.trim_start_matches('<')
.trim();
let (uses_major, uses_minor) = Self::parse_version_parts(version_str);
let severity = if uses_major < latest.major as u32 {
Some(DriftSeverity::Major)
} else if uses_major == latest.major as u32 && uses_minor < latest.minor as u32 {
Some(DriftSeverity::Major) } else {
None
};
severity.map(|sev| DriftReport {
crate_name: crate_name.to_string(),
crate_version: crate_version.to_string(),
dependency: dep.crate_id.clone(),
uses_version: uses_version.clone(),
latest_version: latest.to_string(),
severity: sev,
})
}
fn parse_version_parts(version_str: &str) -> (u32, u32) {
let parts: Vec<&str> = version_str.split('.').collect();
let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
(major, minor)
}
pub fn latest_versions(&self) -> &HashMap<String, semver::Version> {
&self.latest_versions
}
}
impl Default for DriftChecker {
fn default() -> Self {
Self::new()
}
}
pub fn format_drift_errors(drifts: &[DriftReport]) -> String {
if drifts.is_empty() {
return String::new();
}
let mut output = String::new();
output.push_str("🔴 Stack Drift Detected - Cannot Proceed\n\n");
for drift in drifts {
output.push_str(&format!(" {}\n", drift.display()));
}
output.push_str("\nStack drift detected. Fix dependencies before proceeding.\n");
output.push_str("Run: batuta stack drift --fix\n");
output.push_str("Or use --allow-drift to bypass (for local development).\n");
output
}
pub fn format_drift_json(drifts: &[DriftReport]) -> Result<String> {
Ok(serde_json::to_string_pretty(drifts)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_dep(crate_id: &str, version_req: &str, kind: &str) -> DependencyData {
DependencyData {
crate_id: crate_id.to_string(),
version_req: version_req.to_string(),
kind: kind.to_string(),
optional: false,
}
}
fn make_report(severity: DriftSeverity) -> DriftReport {
DriftReport {
crate_name: "trueno-rag".to_string(),
crate_version: "0.1.5".to_string(),
dependency: "trueno".to_string(),
uses_version: "0.10.1".to_string(),
latest_version: "0.11.0".to_string(),
severity,
}
}
#[test]
fn test_drift_severity_as_str() {
assert_eq!(DriftSeverity::Major.as_str(), "MAJOR");
assert_eq!(DriftSeverity::Minor.as_str(), "MINOR");
assert_eq!(DriftSeverity::Patch.as_str(), "PATCH");
}
#[test]
fn test_drift_severity_display_trait() {
assert_eq!(format!("{}", DriftSeverity::Major), "MAJOR");
assert_eq!(format!("{}", DriftSeverity::Minor), "MINOR");
assert_eq!(format!("{}", DriftSeverity::Patch), "PATCH");
}
#[test]
fn test_drift_severity_clone_eq() {
let a = DriftSeverity::Major;
let b = a;
assert_eq!(a, b);
}
#[test]
fn test_drift_severity_debug() {
let dbg = format!("{:?}", DriftSeverity::Patch);
assert_eq!(dbg, "Patch");
}
#[test]
fn test_drift_severity_serde_roundtrip() {
let json = serde_json::to_string(&DriftSeverity::Minor).expect("json serialize failed");
let back: DriftSeverity = serde_json::from_str(&json).expect("json deserialize failed");
assert_eq!(back, DriftSeverity::Minor);
}
#[test]
fn test_drift_report_display() {
let report = make_report(DriftSeverity::Major);
let display = report.display();
assert!(display.contains("trueno-rag"));
assert!(display.contains("0.1.5"));
assert!(display.contains("trueno"));
assert!(display.contains("0.10.1"));
assert!(display.contains("0.11.0"));
assert!(display.contains("MAJOR"));
}
#[test]
fn test_drift_report_serde_roundtrip() {
let report = make_report(DriftSeverity::Major);
let json = serde_json::to_string(&report).expect("json serialize failed");
let back: DriftReport = serde_json::from_str(&json).expect("json deserialize failed");
assert_eq!(back.crate_name, "trueno-rag");
assert_eq!(back.severity, DriftSeverity::Major);
}
#[test]
fn test_drift_report_clone() {
let report = make_report(DriftSeverity::Minor);
let cloned = report.clone();
assert_eq!(cloned.crate_name, report.crate_name);
assert_eq!(cloned.severity, report.severity);
}
#[test]
fn test_drift_checker_new() {
let checker = DriftChecker::new();
assert!(checker.latest_versions.is_empty());
}
#[test]
fn test_drift_checker_default() {
let checker = DriftChecker::default();
assert!(checker.latest_versions().is_empty());
}
#[test]
fn test_drift_checker_latest_versions_accessor() {
let mut checker = DriftChecker::new();
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
assert_eq!(checker.latest_versions().len(), 1);
assert_eq!(checker.latest_versions()["trueno"], semver::Version::new(0, 14, 0));
}
#[test]
fn test_parse_version_parts_full() {
assert_eq!(DriftChecker::parse_version_parts("0.11.0"), (0, 11));
}
#[test]
fn test_parse_version_parts_two() {
assert_eq!(DriftChecker::parse_version_parts("0.11"), (0, 11));
}
#[test]
fn test_parse_version_parts_three() {
assert_eq!(DriftChecker::parse_version_parts("1.2.3"), (1, 2));
}
#[test]
fn test_parse_version_parts_single() {
assert_eq!(DriftChecker::parse_version_parts("2"), (2, 0));
}
#[test]
fn test_parse_version_parts_empty() {
assert_eq!(DriftChecker::parse_version_parts(""), (0, 0));
}
#[test]
fn test_parse_version_parts_garbage() {
assert_eq!(DriftChecker::parse_version_parts("abc.def"), (0, 0));
}
#[test]
fn test_check_drift_behind_minor() {
let mut checker = DriftChecker::new();
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
let dep = make_dep("trueno", "^0.11", "normal");
let latest = &semver::Version::new(0, 14, 0);
let result = checker.check_drift("aprender", "0.24.0", &dep, latest);
assert!(result.is_some());
let report = result.expect("operation failed");
assert_eq!(report.dependency, "trueno");
assert_eq!(report.severity, DriftSeverity::Major);
}
#[test]
fn test_check_drift_up_to_date() {
let checker = DriftChecker::new();
let dep = make_dep("trueno", "^0.14", "normal");
let latest = &semver::Version::new(0, 14, 0);
let result = checker.check_drift("aprender", "0.24.0", &dep, latest);
assert!(result.is_none());
}
#[test]
fn test_check_drift_ahead() {
let checker = DriftChecker::new();
let dep = make_dep("trueno", "0.15", "normal");
let latest = &semver::Version::new(0, 14, 0);
let result = checker.check_drift("aprender", "0.24.0", &dep, latest);
assert!(result.is_none());
}
#[test]
fn test_check_drift_major_behind() {
let checker = DriftChecker::new();
let dep = make_dep("repartir", "1.0", "normal");
let latest = &semver::Version::new(2, 0, 0);
let result = checker.check_drift("batuta", "0.6.0", &dep, latest);
assert!(result.is_some());
let report = result.expect("operation failed");
assert_eq!(report.severity, DriftSeverity::Major);
}
#[test]
fn test_check_drift_strips_caret() {
let checker = DriftChecker::new();
let dep = make_dep("trueno", "^0.11.0", "normal");
let latest = &semver::Version::new(0, 14, 0);
let result = checker.check_drift("test", "1.0.0", &dep, latest);
assert!(result.is_some());
}
#[test]
fn test_check_drift_strips_tilde() {
let checker = DriftChecker::new();
let dep = make_dep("trueno", "~0.11", "normal");
let latest = &semver::Version::new(0, 14, 0);
let result = checker.check_drift("test", "1.0.0", &dep, latest);
assert!(result.is_some());
}
#[test]
fn test_check_drift_strips_eq() {
let checker = DriftChecker::new();
let dep = make_dep("trueno", "=0.11.0", "normal");
let latest = &semver::Version::new(0, 14, 0);
let result = checker.check_drift("test", "1.0.0", &dep, latest);
assert!(result.is_some());
}
#[test]
fn test_check_drift_strips_gt() {
let checker = DriftChecker::new();
let dep = make_dep("trueno", ">0.11", "normal");
let latest = &semver::Version::new(0, 14, 0);
let result = checker.check_drift("test", "1.0.0", &dep, latest);
assert!(result.is_some());
}
#[test]
fn test_format_drift_errors_empty() {
let output = format_drift_errors(&[]);
assert!(output.is_empty());
}
#[test]
fn test_format_drift_errors_with_drifts() {
let drifts = vec![make_report(DriftSeverity::Major)];
let output = format_drift_errors(&drifts);
assert!(output.contains("Stack Drift Detected"));
assert!(output.contains("trueno-rag"));
assert!(output.contains("batuta stack drift --fix"));
assert!(output.contains("--allow-drift"));
}
#[test]
fn test_format_drift_errors_multiple() {
let drifts = vec![
make_report(DriftSeverity::Major),
DriftReport {
crate_name: "aprender".to_string(),
crate_version: "0.24.0".to_string(),
dependency: "trueno".to_string(),
uses_version: "0.12".to_string(),
latest_version: "0.14.0".to_string(),
severity: DriftSeverity::Major,
},
];
let output = format_drift_errors(&drifts);
assert!(output.contains("trueno-rag"));
assert!(output.contains("aprender"));
}
#[test]
fn test_format_drift_json_empty() {
let json = format_drift_json(&[]).expect("unexpected failure");
assert_eq!(json, "[]");
}
#[test]
fn test_format_drift_json_single() {
let drifts = vec![make_report(DriftSeverity::Major)];
let json = format_drift_json(&drifts).expect("unexpected failure");
assert!(json.contains("trueno-rag"));
assert!(json.contains("\"Major\""));
}
#[test]
fn test_format_drift_json_roundtrip() {
let drifts = vec![
make_report(DriftSeverity::Major),
DriftReport {
crate_name: "aprender".to_string(),
crate_version: "0.24.0".to_string(),
dependency: "trueno".to_string(),
uses_version: "0.12".to_string(),
latest_version: "0.14.0".to_string(),
severity: DriftSeverity::Minor,
},
];
let json = format_drift_json(&drifts).expect("unexpected failure");
let back: Vec<DriftReport> = serde_json::from_str(&json).expect("json deserialize failed");
assert_eq!(back.len(), 2);
assert_eq!(back[0].crate_name, "trueno-rag");
assert_eq!(back[1].severity, DriftSeverity::Minor);
}
fn make_drift(name: &str, severity: DriftSeverity) -> DriftReport {
DriftReport {
crate_name: name.to_string(),
crate_version: "0.1.0".to_string(),
dependency: "trueno".to_string(),
uses_version: "0.10".to_string(),
latest_version: "0.14.0".to_string(),
severity,
}
}
#[test]
fn test_drift_sort_major_first() {
let mut drifts = vec![
make_drift("zeta", DriftSeverity::Minor),
make_drift("alpha", DriftSeverity::Major),
];
DriftChecker::sort_drifts(&mut drifts);
assert_eq!(drifts[0].crate_name, "alpha");
assert_eq!(drifts[0].severity, DriftSeverity::Major);
}
#[test]
fn test_drift_sort_major_alpha() {
let mut drifts = vec![
make_drift("beta", DriftSeverity::Major),
make_drift("alpha", DriftSeverity::Major),
];
DriftChecker::sort_drifts(&mut drifts);
assert_eq!(drifts[0].crate_name, "alpha");
assert_eq!(drifts[1].crate_name, "beta");
}
#[test]
fn test_drift_sort_minor_alpha() {
let mut drifts = vec![
make_drift("beta", DriftSeverity::Minor),
make_drift("alpha", DriftSeverity::Minor),
];
DriftChecker::sort_drifts(&mut drifts);
assert_eq!(drifts[0].crate_name, "alpha");
}
#[test]
fn test_drift_sort_patch_alpha() {
let mut drifts = vec![
make_drift("beta", DriftSeverity::Patch),
make_drift("alpha", DriftSeverity::Patch),
];
DriftChecker::sort_drifts(&mut drifts);
assert_eq!(drifts[0].crate_name, "alpha");
}
#[test]
fn test_drift_sort_minor_before_patch() {
let mut drifts = vec![
make_drift("alpha", DriftSeverity::Patch),
make_drift("beta", DriftSeverity::Minor),
];
DriftChecker::sort_drifts(&mut drifts);
assert_eq!(drifts[0].severity, DriftSeverity::Minor);
}
#[test]
fn test_drift_sort_all_severities() {
let mut drifts = vec![
make_drift("alpha", DriftSeverity::Patch),
make_drift("beta", DriftSeverity::Minor),
make_drift("gamma", DriftSeverity::Major),
];
DriftChecker::sort_drifts(&mut drifts);
assert_eq!(drifts[0].severity, DriftSeverity::Major);
assert_eq!(drifts[1].severity, DriftSeverity::Minor);
assert_eq!(drifts[2].severity, DriftSeverity::Patch);
}
#[test]
fn test_check_deps_for_drift_with_paiml_dep() {
let mut checker = DriftChecker::new();
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
let deps = vec![make_dep("trueno", "^0.11", "normal")];
let mut drifts = Vec::new();
checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
assert_eq!(drifts.len(), 1);
assert_eq!(drifts[0].dependency, "trueno");
}
#[test]
fn test_check_deps_for_drift_skips_non_paiml() {
let mut checker = DriftChecker::new();
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
let deps = vec![make_dep("serde", "1.0", "normal")];
let mut drifts = Vec::new();
checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
assert!(drifts.is_empty());
}
#[test]
fn test_check_deps_for_drift_skips_dev_deps() {
let mut checker = DriftChecker::new();
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
let deps = vec![make_dep("trueno", "^0.11", "dev")];
let mut drifts = Vec::new();
checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
assert!(drifts.is_empty());
}
#[test]
fn test_check_deps_for_drift_skips_unpublished() {
let checker = DriftChecker::new(); let deps = vec![make_dep("trueno", "^0.11", "normal")];
let mut drifts = Vec::new();
checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
assert!(drifts.is_empty());
}
#[test]
fn test_check_deps_for_drift_up_to_date() {
let mut checker = DriftChecker::new();
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
let deps = vec![make_dep("trueno", "^0.14", "normal")];
let mut drifts = Vec::new();
checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
assert!(drifts.is_empty());
}
#[test]
fn test_check_deps_for_drift_mixed() {
let mut checker = DriftChecker::new();
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
checker.latest_versions.insert("aprender".to_string(), semver::Version::new(0, 25, 0));
let deps = vec![
make_dep("trueno", "^0.11", "normal"), make_dep("serde", "1.0", "normal"), make_dep("aprender", "^0.25", "normal"), make_dep("trueno", "^0.12", "dev"), ];
let mut drifts = Vec::new();
checker.check_deps_for_drift("realizar", "0.6.0", &deps, &mut drifts);
assert_eq!(drifts.len(), 1);
assert_eq!(drifts[0].dependency, "trueno");
}
#[test]
fn test_check_drift_equal_major_different_minor() {
let checker = DriftChecker::new();
let dep = make_dep("trueno", "0.14.0", "normal");
let latest = &semver::Version::new(0, 14, 5);
assert!(checker.check_drift("test", "1.0.0", &dep, latest).is_none());
}
#[test]
fn test_check_drift_report_fields() {
let checker = DriftChecker::new();
let dep = make_dep("trueno", "^0.11", "normal");
let latest = &semver::Version::new(0, 14, 0);
let report =
checker.check_drift("aprender", "0.24.0", &dep, latest).expect("unexpected failure");
assert_eq!(report.crate_name, "aprender");
assert_eq!(report.crate_version, "0.24.0");
assert_eq!(report.dependency, "trueno");
assert_eq!(report.uses_version, "^0.11");
assert_eq!(report.latest_version, "0.14.0");
}
#[test]
fn test_check_drift_strips_lt() {
let checker = DriftChecker::new();
let dep = make_dep("trueno", "<0.11", "normal");
let latest = &semver::Version::new(0, 14, 0);
assert!(checker.check_drift("test", "1.0.0", &dep, latest).is_some());
}
#[cfg(feature = "native")]
#[tokio::test]
async fn test_detect_self_drift_offline_client() {
let mut client = CratesIoClient::new();
client.set_offline(true);
let mut checker = DriftChecker::new();
checker.latest_versions.insert("batuta".to_string(), semver::Version::new(0, 7, 2));
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 16, 0));
let drifts = checker.detect_self_drift(&mut client).await.expect("async operation failed");
assert!(drifts.is_empty());
}
#[cfg(feature = "native")]
#[tokio::test]
async fn test_detect_self_drift_no_batuta_published() {
let mut client = CratesIoClient::new();
client.set_offline(true);
let mut checker = DriftChecker::new();
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 16, 0));
let drifts = checker.detect_self_drift(&mut client).await.expect("async operation failed");
assert!(drifts.is_empty());
}
#[test]
fn test_detect_self_drift_all_current() {
let mut checker = DriftChecker::new();
checker.latest_versions.insert("batuta".to_string(), semver::Version::new(0, 7, 2));
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 16, 0));
checker.latest_versions.insert("aprender".to_string(), semver::Version::new(0, 27, 0));
let deps = vec![
make_dep("trueno", "^0.16", "normal"),
make_dep("aprender", "^0.27", "normal"),
make_dep("serde", "1.0", "normal"), ];
let mut drifts = Vec::new();
checker.check_deps_for_drift("batuta", "0.7.2", &deps, &mut drifts);
assert!(drifts.is_empty(), "No drift expected when deps are current");
}
#[test]
fn test_detect_self_drift_never_reports_other_crates() {
let mut checker = DriftChecker::new();
checker.latest_versions.insert("batuta".to_string(), semver::Version::new(0, 7, 2));
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 16, 0));
let deps = vec![make_dep("trueno", "^0.11", "normal")]; let mut drifts = Vec::new();
checker.check_deps_for_drift("batuta", "0.7.2", &deps, &mut drifts);
for d in &drifts {
assert_eq!(
d.crate_name, "batuta",
"Self-drift must only report batuta, got: {}",
d.crate_name
);
}
assert_eq!(drifts.len(), 1);
}
#[cfg(feature = "native")]
#[tokio::test]
async fn test_detect_drift_offline_client() {
let mut client = CratesIoClient::new();
client.set_offline(true);
let mut checker = DriftChecker::new();
checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
checker.latest_versions.insert("aprender".to_string(), semver::Version::new(0, 25, 0));
let drifts = checker.detect_drift(&mut client).await.expect("async operation failed");
assert!(drifts.is_empty());
}
#[cfg(feature = "native")]
#[tokio::test]
async fn test_detect_drift_no_versions_cached() {
let mut client = CratesIoClient::new();
client.set_offline(true);
let mut checker = DriftChecker::new();
let drifts = checker.detect_drift(&mut client).await.expect("async operation failed");
assert!(drifts.is_empty());
}
#[cfg(feature = "native")]
#[tokio::test]
async fn test_fetch_latest_versions_offline() {
let mut client = CratesIoClient::new();
client.set_offline(true);
let mut checker = DriftChecker::new();
checker.fetch_latest_versions(&mut client).await.expect("async operation failed");
assert!(checker.latest_versions.is_empty());
}
}