1use super::crates_io::{CratesIoClient, DependencyData};
13use super::{is_paiml_crate, PAIML_CRATES};
14use anyhow::Result;
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum DriftSeverity {
21 Major,
23 Minor,
25 Patch,
27}
28
29impl DriftSeverity {
30 pub fn as_str(&self) -> &'static str {
32 match self {
33 DriftSeverity::Major => "MAJOR",
34 DriftSeverity::Minor => "MINOR",
35 DriftSeverity::Patch => "PATCH",
36 }
37 }
38}
39
40impl std::fmt::Display for DriftSeverity {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 write!(f, "{}", self.as_str())
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DriftReport {
49 pub crate_name: String,
51 pub crate_version: String,
53 pub dependency: String,
55 pub uses_version: String,
57 pub latest_version: String,
59 pub severity: DriftSeverity,
61}
62
63impl DriftReport {
64 pub fn display(&self) -> String {
66 format!(
67 "{} {}: {} {} → {} ({})",
68 self.crate_name,
69 self.crate_version,
70 self.dependency,
71 self.uses_version,
72 self.latest_version,
73 self.severity
74 )
75 }
76}
77
78pub struct DriftChecker {
80 latest_versions: HashMap<String, semver::Version>,
82}
83
84impl DriftChecker {
85 pub fn new() -> Self {
87 Self { latest_versions: HashMap::new() }
88 }
89
90 #[cfg(feature = "native")]
92 pub async fn fetch_latest_versions(&mut self, client: &mut CratesIoClient) -> Result<()> {
93 for crate_name in PAIML_CRATES {
94 match client.get_latest_version(crate_name).await {
95 Ok(version) => {
96 self.latest_versions.insert((*crate_name).to_string(), version);
97 }
98 Err(_) => {
99 continue;
101 }
102 }
103 }
104 Ok(())
105 }
106
107 #[cfg(feature = "native")]
113 pub async fn detect_self_drift(
114 &mut self,
115 client: &mut CratesIoClient,
116 ) -> Result<Vec<DriftReport>> {
117 if self.latest_versions.is_empty() {
118 self.fetch_latest_versions(client).await?;
119 }
120
121 let mut drifts = Vec::new();
122
123 let crate_version = match self.latest_versions.get("batuta") {
124 Some(v) => v.to_string(),
125 None => return Ok(drifts),
126 };
127
128 let deps = match client.get_dependencies("batuta", &crate_version).await {
129 Ok(d) => d,
130 Err(_) => return Ok(drifts),
131 };
132
133 self.check_deps_for_drift("batuta", &crate_version, &deps, &mut drifts);
134 Self::sort_drifts(&mut drifts);
135 Ok(drifts)
136 }
137
138 #[cfg(feature = "native")]
143 pub async fn detect_drift(&mut self, client: &mut CratesIoClient) -> Result<Vec<DriftReport>> {
144 if self.latest_versions.is_empty() {
146 self.fetch_latest_versions(client).await?;
147 }
148
149 let mut drifts = Vec::new();
150
151 for crate_name in PAIML_CRATES {
153 let crate_version = match self.latest_versions.get(*crate_name) {
154 Some(v) => v.to_string(),
155 None => continue, };
157
158 let deps = match client.get_dependencies(crate_name, &crate_version).await {
160 Ok(d) => d,
161 Err(_) => continue, };
163
164 self.check_deps_for_drift(crate_name, &crate_version, &deps, &mut drifts);
165 }
166
167 Self::sort_drifts(&mut drifts);
168 Ok(drifts)
169 }
170
171 fn check_deps_for_drift(
173 &self,
174 crate_name: &str,
175 crate_version: &str,
176 deps: &[DependencyData],
177 drifts: &mut Vec<DriftReport>,
178 ) {
179 for dep in deps {
180 if !is_paiml_crate(&dep.crate_id) {
182 continue;
183 }
184
185 if dep.kind == "dev" {
187 continue;
188 }
189
190 let latest = match self.latest_versions.get(&dep.crate_id) {
192 Some(v) => v,
193 None => continue, };
195
196 if let Some(drift) = self.check_drift(crate_name, crate_version, dep, latest) {
198 drifts.push(drift);
199 }
200 }
201 }
202
203 fn sort_drifts(drifts: &mut [DriftReport]) {
205 drifts.sort_by(|a, b| match (&a.severity, &b.severity) {
206 (DriftSeverity::Major, DriftSeverity::Major) => a.crate_name.cmp(&b.crate_name),
207 (DriftSeverity::Major, _) => std::cmp::Ordering::Less,
208 (_, DriftSeverity::Major) => std::cmp::Ordering::Greater,
209 (DriftSeverity::Minor, DriftSeverity::Minor) => a.crate_name.cmp(&b.crate_name),
210 (DriftSeverity::Minor, _) => std::cmp::Ordering::Less,
211 (_, DriftSeverity::Minor) => std::cmp::Ordering::Greater,
212 _ => a.crate_name.cmp(&b.crate_name),
213 });
214 }
215
216 fn check_drift(
218 &self,
219 crate_name: &str,
220 crate_version: &str,
221 dep: &DependencyData,
222 latest: &semver::Version,
223 ) -> Option<DriftReport> {
224 let uses_version = &dep.version_req;
226
227 let version_str = uses_version
230 .trim_start_matches('^')
231 .trim_start_matches('~')
232 .trim_start_matches('=')
233 .trim_start_matches('>')
234 .trim_start_matches('<')
235 .trim();
236
237 let (uses_major, uses_minor) = Self::parse_version_parts(version_str);
239
240 let severity = if uses_major < latest.major as u32 {
242 Some(DriftSeverity::Major)
244 } else if uses_major == latest.major as u32 && uses_minor < latest.minor as u32 {
245 Some(DriftSeverity::Major) } else {
248 None
250 };
251
252 severity.map(|sev| DriftReport {
253 crate_name: crate_name.to_string(),
254 crate_version: crate_version.to_string(),
255 dependency: dep.crate_id.clone(),
256 uses_version: uses_version.clone(),
257 latest_version: latest.to_string(),
258 severity: sev,
259 })
260 }
261
262 fn parse_version_parts(version_str: &str) -> (u32, u32) {
264 let parts: Vec<&str> = version_str.split('.').collect();
265 let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
266 let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
267 (major, minor)
268 }
269
270 pub fn latest_versions(&self) -> &HashMap<String, semver::Version> {
272 &self.latest_versions
273 }
274}
275
276impl Default for DriftChecker {
277 fn default() -> Self {
278 Self::new()
279 }
280}
281
282pub fn format_drift_errors(drifts: &[DriftReport]) -> String {
284 if drifts.is_empty() {
285 return String::new();
286 }
287
288 let mut output = String::new();
289 output.push_str("🔴 Stack Drift Detected - Cannot Proceed\n\n");
290
291 for drift in drifts {
292 output.push_str(&format!(" {}\n", drift.display()));
293 }
294
295 output.push_str("\nStack drift detected. Fix dependencies before proceeding.\n");
296 output.push_str("Run: batuta stack drift --fix\n");
297 output.push_str("Or use --allow-drift to bypass (for local development).\n");
298
299 output
300}
301
302pub fn format_drift_json(drifts: &[DriftReport]) -> Result<String> {
304 Ok(serde_json::to_string_pretty(drifts)?)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn make_dep(crate_id: &str, version_req: &str, kind: &str) -> DependencyData {
312 DependencyData {
313 crate_id: crate_id.to_string(),
314 version_req: version_req.to_string(),
315 kind: kind.to_string(),
316 optional: false,
317 }
318 }
319
320 fn make_report(severity: DriftSeverity) -> DriftReport {
321 DriftReport {
322 crate_name: "trueno-rag".to_string(),
323 crate_version: "0.1.5".to_string(),
324 dependency: "trueno".to_string(),
325 uses_version: "0.10.1".to_string(),
326 latest_version: "0.11.0".to_string(),
327 severity,
328 }
329 }
330
331 #[test]
334 fn test_drift_severity_as_str() {
335 assert_eq!(DriftSeverity::Major.as_str(), "MAJOR");
336 assert_eq!(DriftSeverity::Minor.as_str(), "MINOR");
337 assert_eq!(DriftSeverity::Patch.as_str(), "PATCH");
338 }
339
340 #[test]
341 fn test_drift_severity_display_trait() {
342 assert_eq!(format!("{}", DriftSeverity::Major), "MAJOR");
343 assert_eq!(format!("{}", DriftSeverity::Minor), "MINOR");
344 assert_eq!(format!("{}", DriftSeverity::Patch), "PATCH");
345 }
346
347 #[test]
348 fn test_drift_severity_clone_eq() {
349 let a = DriftSeverity::Major;
350 let b = a;
351 assert_eq!(a, b);
352 }
353
354 #[test]
355 fn test_drift_severity_debug() {
356 let dbg = format!("{:?}", DriftSeverity::Patch);
357 assert_eq!(dbg, "Patch");
358 }
359
360 #[test]
361 fn test_drift_severity_serde_roundtrip() {
362 let json = serde_json::to_string(&DriftSeverity::Minor).expect("json serialize failed");
363 let back: DriftSeverity = serde_json::from_str(&json).expect("json deserialize failed");
364 assert_eq!(back, DriftSeverity::Minor);
365 }
366
367 #[test]
370 fn test_drift_report_display() {
371 let report = make_report(DriftSeverity::Major);
372 let display = report.display();
373 assert!(display.contains("trueno-rag"));
374 assert!(display.contains("0.1.5"));
375 assert!(display.contains("trueno"));
376 assert!(display.contains("0.10.1"));
377 assert!(display.contains("0.11.0"));
378 assert!(display.contains("MAJOR"));
379 }
380
381 #[test]
382 fn test_drift_report_serde_roundtrip() {
383 let report = make_report(DriftSeverity::Major);
384 let json = serde_json::to_string(&report).expect("json serialize failed");
385 let back: DriftReport = serde_json::from_str(&json).expect("json deserialize failed");
386 assert_eq!(back.crate_name, "trueno-rag");
387 assert_eq!(back.severity, DriftSeverity::Major);
388 }
389
390 #[test]
391 fn test_drift_report_clone() {
392 let report = make_report(DriftSeverity::Minor);
393 let cloned = report.clone();
394 assert_eq!(cloned.crate_name, report.crate_name);
395 assert_eq!(cloned.severity, report.severity);
396 }
397
398 #[test]
401 fn test_drift_checker_new() {
402 let checker = DriftChecker::new();
403 assert!(checker.latest_versions.is_empty());
404 }
405
406 #[test]
407 fn test_drift_checker_default() {
408 let checker = DriftChecker::default();
409 assert!(checker.latest_versions().is_empty());
410 }
411
412 #[test]
413 fn test_drift_checker_latest_versions_accessor() {
414 let mut checker = DriftChecker::new();
415 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
416 assert_eq!(checker.latest_versions().len(), 1);
417 assert_eq!(checker.latest_versions()["trueno"], semver::Version::new(0, 14, 0));
418 }
419
420 #[test]
423 fn test_parse_version_parts_full() {
424 assert_eq!(DriftChecker::parse_version_parts("0.11.0"), (0, 11));
425 }
426
427 #[test]
428 fn test_parse_version_parts_two() {
429 assert_eq!(DriftChecker::parse_version_parts("0.11"), (0, 11));
430 }
431
432 #[test]
433 fn test_parse_version_parts_three() {
434 assert_eq!(DriftChecker::parse_version_parts("1.2.3"), (1, 2));
435 }
436
437 #[test]
438 fn test_parse_version_parts_single() {
439 assert_eq!(DriftChecker::parse_version_parts("2"), (2, 0));
440 }
441
442 #[test]
443 fn test_parse_version_parts_empty() {
444 assert_eq!(DriftChecker::parse_version_parts(""), (0, 0));
445 }
446
447 #[test]
448 fn test_parse_version_parts_garbage() {
449 assert_eq!(DriftChecker::parse_version_parts("abc.def"), (0, 0));
450 }
451
452 #[test]
455 fn test_check_drift_behind_minor() {
456 let mut checker = DriftChecker::new();
457 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
458
459 let dep = make_dep("trueno", "^0.11", "normal");
460 let latest = &semver::Version::new(0, 14, 0);
461 let result = checker.check_drift("aprender", "0.24.0", &dep, latest);
462
463 assert!(result.is_some());
464 let report = result.expect("operation failed");
465 assert_eq!(report.dependency, "trueno");
466 assert_eq!(report.severity, DriftSeverity::Major);
467 }
468
469 #[test]
470 fn test_check_drift_up_to_date() {
471 let checker = DriftChecker::new();
472 let dep = make_dep("trueno", "^0.14", "normal");
473 let latest = &semver::Version::new(0, 14, 0);
474 let result = checker.check_drift("aprender", "0.24.0", &dep, latest);
475 assert!(result.is_none());
476 }
477
478 #[test]
479 fn test_check_drift_ahead() {
480 let checker = DriftChecker::new();
481 let dep = make_dep("trueno", "0.15", "normal");
482 let latest = &semver::Version::new(0, 14, 0);
483 let result = checker.check_drift("aprender", "0.24.0", &dep, latest);
484 assert!(result.is_none());
485 }
486
487 #[test]
488 fn test_check_drift_major_behind() {
489 let checker = DriftChecker::new();
490 let dep = make_dep("repartir", "1.0", "normal");
491 let latest = &semver::Version::new(2, 0, 0);
492 let result = checker.check_drift("batuta", "0.6.0", &dep, latest);
493
494 assert!(result.is_some());
495 let report = result.expect("operation failed");
496 assert_eq!(report.severity, DriftSeverity::Major);
497 }
498
499 #[test]
500 fn test_check_drift_strips_caret() {
501 let checker = DriftChecker::new();
502 let dep = make_dep("trueno", "^0.11.0", "normal");
503 let latest = &semver::Version::new(0, 14, 0);
504 let result = checker.check_drift("test", "1.0.0", &dep, latest);
505 assert!(result.is_some());
506 }
507
508 #[test]
509 fn test_check_drift_strips_tilde() {
510 let checker = DriftChecker::new();
511 let dep = make_dep("trueno", "~0.11", "normal");
512 let latest = &semver::Version::new(0, 14, 0);
513 let result = checker.check_drift("test", "1.0.0", &dep, latest);
514 assert!(result.is_some());
515 }
516
517 #[test]
518 fn test_check_drift_strips_eq() {
519 let checker = DriftChecker::new();
520 let dep = make_dep("trueno", "=0.11.0", "normal");
521 let latest = &semver::Version::new(0, 14, 0);
522 let result = checker.check_drift("test", "1.0.0", &dep, latest);
523 assert!(result.is_some());
524 }
525
526 #[test]
527 fn test_check_drift_strips_gt() {
528 let checker = DriftChecker::new();
529 let dep = make_dep("trueno", ">0.11", "normal");
530 let latest = &semver::Version::new(0, 14, 0);
531 let result = checker.check_drift("test", "1.0.0", &dep, latest);
532 assert!(result.is_some());
533 }
534
535 #[test]
538 fn test_format_drift_errors_empty() {
539 let output = format_drift_errors(&[]);
540 assert!(output.is_empty());
541 }
542
543 #[test]
544 fn test_format_drift_errors_with_drifts() {
545 let drifts = vec![make_report(DriftSeverity::Major)];
546 let output = format_drift_errors(&drifts);
547 assert!(output.contains("Stack Drift Detected"));
548 assert!(output.contains("trueno-rag"));
549 assert!(output.contains("batuta stack drift --fix"));
550 assert!(output.contains("--allow-drift"));
551 }
552
553 #[test]
554 fn test_format_drift_errors_multiple() {
555 let drifts = vec![
556 make_report(DriftSeverity::Major),
557 DriftReport {
558 crate_name: "aprender".to_string(),
559 crate_version: "0.24.0".to_string(),
560 dependency: "trueno".to_string(),
561 uses_version: "0.12".to_string(),
562 latest_version: "0.14.0".to_string(),
563 severity: DriftSeverity::Major,
564 },
565 ];
566 let output = format_drift_errors(&drifts);
567 assert!(output.contains("trueno-rag"));
568 assert!(output.contains("aprender"));
569 }
570
571 #[test]
574 fn test_format_drift_json_empty() {
575 let json = format_drift_json(&[]).expect("unexpected failure");
576 assert_eq!(json, "[]");
577 }
578
579 #[test]
580 fn test_format_drift_json_single() {
581 let drifts = vec![make_report(DriftSeverity::Major)];
582 let json = format_drift_json(&drifts).expect("unexpected failure");
583 assert!(json.contains("trueno-rag"));
584 assert!(json.contains("\"Major\""));
585 }
586
587 #[test]
588 fn test_format_drift_json_roundtrip() {
589 let drifts = vec![
590 make_report(DriftSeverity::Major),
591 DriftReport {
592 crate_name: "aprender".to_string(),
593 crate_version: "0.24.0".to_string(),
594 dependency: "trueno".to_string(),
595 uses_version: "0.12".to_string(),
596 latest_version: "0.14.0".to_string(),
597 severity: DriftSeverity::Minor,
598 },
599 ];
600 let json = format_drift_json(&drifts).expect("unexpected failure");
601 let back: Vec<DriftReport> = serde_json::from_str(&json).expect("json deserialize failed");
602 assert_eq!(back.len(), 2);
603 assert_eq!(back[0].crate_name, "trueno-rag");
604 assert_eq!(back[1].severity, DriftSeverity::Minor);
605 }
606
607 fn make_drift(name: &str, severity: DriftSeverity) -> DriftReport {
610 DriftReport {
611 crate_name: name.to_string(),
612 crate_version: "0.1.0".to_string(),
613 dependency: "trueno".to_string(),
614 uses_version: "0.10".to_string(),
615 latest_version: "0.14.0".to_string(),
616 severity,
617 }
618 }
619
620 #[test]
621 fn test_drift_sort_major_first() {
622 let mut drifts = vec![
623 make_drift("zeta", DriftSeverity::Minor),
624 make_drift("alpha", DriftSeverity::Major),
625 ];
626 DriftChecker::sort_drifts(&mut drifts);
627 assert_eq!(drifts[0].crate_name, "alpha");
628 assert_eq!(drifts[0].severity, DriftSeverity::Major);
629 }
630
631 #[test]
632 fn test_drift_sort_major_alpha() {
633 let mut drifts = vec![
634 make_drift("beta", DriftSeverity::Major),
635 make_drift("alpha", DriftSeverity::Major),
636 ];
637 DriftChecker::sort_drifts(&mut drifts);
638 assert_eq!(drifts[0].crate_name, "alpha");
639 assert_eq!(drifts[1].crate_name, "beta");
640 }
641
642 #[test]
643 fn test_drift_sort_minor_alpha() {
644 let mut drifts = vec![
645 make_drift("beta", DriftSeverity::Minor),
646 make_drift("alpha", DriftSeverity::Minor),
647 ];
648 DriftChecker::sort_drifts(&mut drifts);
649 assert_eq!(drifts[0].crate_name, "alpha");
650 }
651
652 #[test]
653 fn test_drift_sort_patch_alpha() {
654 let mut drifts = vec![
655 make_drift("beta", DriftSeverity::Patch),
656 make_drift("alpha", DriftSeverity::Patch),
657 ];
658 DriftChecker::sort_drifts(&mut drifts);
659 assert_eq!(drifts[0].crate_name, "alpha");
660 }
661
662 #[test]
663 fn test_drift_sort_minor_before_patch() {
664 let mut drifts = vec![
665 make_drift("alpha", DriftSeverity::Patch),
666 make_drift("beta", DriftSeverity::Minor),
667 ];
668 DriftChecker::sort_drifts(&mut drifts);
669 assert_eq!(drifts[0].severity, DriftSeverity::Minor);
670 }
671
672 #[test]
673 fn test_drift_sort_all_severities() {
674 let mut drifts = vec![
675 make_drift("alpha", DriftSeverity::Patch),
676 make_drift("beta", DriftSeverity::Minor),
677 make_drift("gamma", DriftSeverity::Major),
678 ];
679 DriftChecker::sort_drifts(&mut drifts);
680 assert_eq!(drifts[0].severity, DriftSeverity::Major);
681 assert_eq!(drifts[1].severity, DriftSeverity::Minor);
682 assert_eq!(drifts[2].severity, DriftSeverity::Patch);
683 }
684
685 #[test]
688 fn test_check_deps_for_drift_with_paiml_dep() {
689 let mut checker = DriftChecker::new();
690 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
691
692 let deps = vec![make_dep("trueno", "^0.11", "normal")];
693 let mut drifts = Vec::new();
694 checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
695 assert_eq!(drifts.len(), 1);
696 assert_eq!(drifts[0].dependency, "trueno");
697 }
698
699 #[test]
700 fn test_check_deps_for_drift_skips_non_paiml() {
701 let mut checker = DriftChecker::new();
702 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
703
704 let deps = vec![make_dep("serde", "1.0", "normal")];
705 let mut drifts = Vec::new();
706 checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
707 assert!(drifts.is_empty());
708 }
709
710 #[test]
711 fn test_check_deps_for_drift_skips_dev_deps() {
712 let mut checker = DriftChecker::new();
713 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
714
715 let deps = vec![make_dep("trueno", "^0.11", "dev")];
716 let mut drifts = Vec::new();
717 checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
718 assert!(drifts.is_empty());
719 }
720
721 #[test]
722 fn test_check_deps_for_drift_skips_unpublished() {
723 let checker = DriftChecker::new(); let deps = vec![make_dep("trueno", "^0.11", "normal")];
725 let mut drifts = Vec::new();
726 checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
727 assert!(drifts.is_empty());
728 }
729
730 #[test]
731 fn test_check_deps_for_drift_up_to_date() {
732 let mut checker = DriftChecker::new();
733 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
734
735 let deps = vec![make_dep("trueno", "^0.14", "normal")];
736 let mut drifts = Vec::new();
737 checker.check_deps_for_drift("aprender", "0.24.0", &deps, &mut drifts);
738 assert!(drifts.is_empty());
739 }
740
741 #[test]
742 fn test_check_deps_for_drift_mixed() {
743 let mut checker = DriftChecker::new();
744 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
745 checker.latest_versions.insert("aprender".to_string(), semver::Version::new(0, 25, 0));
746
747 let deps = vec![
748 make_dep("trueno", "^0.11", "normal"), make_dep("serde", "1.0", "normal"), make_dep("aprender", "^0.25", "normal"), make_dep("trueno", "^0.12", "dev"), ];
753 let mut drifts = Vec::new();
754 checker.check_deps_for_drift("realizar", "0.6.0", &deps, &mut drifts);
755 assert_eq!(drifts.len(), 1);
756 assert_eq!(drifts[0].dependency, "trueno");
757 }
758
759 #[test]
762 fn test_check_drift_equal_major_different_minor() {
763 let checker = DriftChecker::new();
764 let dep = make_dep("trueno", "0.14.0", "normal");
766 let latest = &semver::Version::new(0, 14, 5);
767 assert!(checker.check_drift("test", "1.0.0", &dep, latest).is_none());
768 }
769
770 #[test]
771 fn test_check_drift_report_fields() {
772 let checker = DriftChecker::new();
773 let dep = make_dep("trueno", "^0.11", "normal");
774 let latest = &semver::Version::new(0, 14, 0);
775 let report =
776 checker.check_drift("aprender", "0.24.0", &dep, latest).expect("unexpected failure");
777 assert_eq!(report.crate_name, "aprender");
778 assert_eq!(report.crate_version, "0.24.0");
779 assert_eq!(report.dependency, "trueno");
780 assert_eq!(report.uses_version, "^0.11");
781 assert_eq!(report.latest_version, "0.14.0");
782 }
783
784 #[test]
785 fn test_check_drift_strips_lt() {
786 let checker = DriftChecker::new();
787 let dep = make_dep("trueno", "<0.11", "normal");
788 let latest = &semver::Version::new(0, 14, 0);
789 assert!(checker.check_drift("test", "1.0.0", &dep, latest).is_some());
790 }
791
792 #[cfg(feature = "native")]
795 #[tokio::test]
796 async fn test_detect_self_drift_offline_client() {
797 let mut client = CratesIoClient::new();
798 client.set_offline(true);
799
800 let mut checker = DriftChecker::new();
801 checker.latest_versions.insert("batuta".to_string(), semver::Version::new(0, 7, 2));
803 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 16, 0));
804
805 let drifts = checker.detect_self_drift(&mut client).await.expect("async operation failed");
807 assert!(drifts.is_empty());
808 }
809
810 #[cfg(feature = "native")]
811 #[tokio::test]
812 async fn test_detect_self_drift_no_batuta_published() {
813 let mut client = CratesIoClient::new();
814 client.set_offline(true);
815
816 let mut checker = DriftChecker::new();
817 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 16, 0));
819
820 let drifts = checker.detect_self_drift(&mut client).await.expect("async operation failed");
821 assert!(drifts.is_empty());
822 }
823
824 #[test]
826 fn test_detect_self_drift_all_current() {
827 let mut checker = DriftChecker::new();
828 checker.latest_versions.insert("batuta".to_string(), semver::Version::new(0, 7, 2));
829 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 16, 0));
830 checker.latest_versions.insert("aprender".to_string(), semver::Version::new(0, 27, 0));
831
832 let deps = vec![
834 make_dep("trueno", "^0.16", "normal"),
835 make_dep("aprender", "^0.27", "normal"),
836 make_dep("serde", "1.0", "normal"), ];
838 let mut drifts = Vec::new();
839 checker.check_deps_for_drift("batuta", "0.7.2", &deps, &mut drifts);
840 assert!(drifts.is_empty(), "No drift expected when deps are current");
841 }
842
843 #[test]
845 fn test_detect_self_drift_never_reports_other_crates() {
846 let mut checker = DriftChecker::new();
847 checker.latest_versions.insert("batuta".to_string(), semver::Version::new(0, 7, 2));
848 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 16, 0));
849
850 let deps = vec![make_dep("trueno", "^0.11", "normal")]; let mut drifts = Vec::new();
853 checker.check_deps_for_drift("batuta", "0.7.2", &deps, &mut drifts);
854
855 for d in &drifts {
856 assert_eq!(
857 d.crate_name, "batuta",
858 "Self-drift must only report batuta, got: {}",
859 d.crate_name
860 );
861 }
862 assert_eq!(drifts.len(), 1);
863 }
864
865 #[cfg(feature = "native")]
866 #[tokio::test]
867 async fn test_detect_drift_offline_client() {
868 let mut client = CratesIoClient::new();
869 client.set_offline(true);
870
871 let mut checker = DriftChecker::new();
872 checker.latest_versions.insert("trueno".to_string(), semver::Version::new(0, 14, 0));
874 checker.latest_versions.insert("aprender".to_string(), semver::Version::new(0, 25, 0));
875
876 let drifts = checker.detect_drift(&mut client).await.expect("async operation failed");
878 assert!(drifts.is_empty());
879 }
880
881 #[cfg(feature = "native")]
882 #[tokio::test]
883 async fn test_detect_drift_no_versions_cached() {
884 let mut client = CratesIoClient::new();
885 client.set_offline(true);
886
887 let mut checker = DriftChecker::new();
888 let drifts = checker.detect_drift(&mut client).await.expect("async operation failed");
891 assert!(drifts.is_empty());
892 }
893
894 #[cfg(feature = "native")]
895 #[tokio::test]
896 async fn test_fetch_latest_versions_offline() {
897 let mut client = CratesIoClient::new();
898 client.set_offline(true);
899
900 let mut checker = DriftChecker::new();
901 checker.fetch_latest_versions(&mut client).await.expect("async operation failed");
903 assert!(checker.latest_versions.is_empty());
905 }
906}