Skip to main content

batuta/stack/
drift.rs

1//! Stack Drift Detection
2//!
3//! Detects when PAIML stack crates are using outdated versions of other
4//! stack crates. This ensures stack coherence and prevents version drift.
5//!
6//! ## Toyota Way Principles
7//!
8//! - **Jidoka**: Blocks operations when drift detected
9//! - **Genchi Genbutsu**: Real-time crates.io dependency verification
10//! - **Kaizen**: Continuous improvement through enforced updates
11
12use 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/// Severity of version drift
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum DriftSeverity {
21    /// Major version difference (e.g., 0.10 vs 0.11) - likely breaking
22    Major,
23    /// Minor version difference within same major (e.g., 0.10.1 vs 0.10.5)
24    Minor,
25    /// Patch version difference - negligible
26    Patch,
27}
28
29impl DriftSeverity {
30    /// Get display string for severity
31    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/// A single drift issue detected in the stack
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DriftReport {
49    /// The crate that has the outdated dependency
50    pub crate_name: String,
51    /// Version of the crate with the drift
52    pub crate_version: String,
53    /// The dependency that is behind
54    pub dependency: String,
55    /// Version requirement the crate uses
56    pub uses_version: String,
57    /// Latest version available on crates.io
58    pub latest_version: String,
59    /// Severity of the drift
60    pub severity: DriftSeverity,
61}
62
63impl DriftReport {
64    /// Format for display
65    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
78/// Drift checker for the PAIML stack
79pub struct DriftChecker {
80    /// Latest versions of PAIML crates (cached)
81    latest_versions: HashMap<String, semver::Version>,
82}
83
84impl DriftChecker {
85    /// Create a new drift checker
86    pub fn new() -> Self {
87        Self { latest_versions: HashMap::new() }
88    }
89
90    /// Fetch latest versions of all PAIML crates
91    #[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                    // Crate not published yet, skip
100                    continue;
101                }
102            }
103        }
104        Ok(())
105    }
106
107    /// Detect drift for batuta's own dependencies only.
108    ///
109    /// Checks whether the published batuta crate uses the latest versions
110    /// of other PAIML stack crates. This is the startup check — it only
111    /// reports on batuta itself, not the entire ecosystem.
112    #[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    /// Detect drift across the entire PAIML stack (maintainer tool).
139    ///
140    /// For each published PAIML crate, checks if its dependencies on other
141    /// PAIML crates are using the latest versions.
142    #[cfg(feature = "native")]
143    pub async fn detect_drift(&mut self, client: &mut CratesIoClient) -> Result<Vec<DriftReport>> {
144        // First, ensure we have latest versions
145        if self.latest_versions.is_empty() {
146            self.fetch_latest_versions(client).await?;
147        }
148
149        let mut drifts = Vec::new();
150
151        // Check each published PAIML crate
152        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, // Not published
156            };
157
158            // Get dependencies for this crate version
159            let deps = match client.get_dependencies(crate_name, &crate_version).await {
160                Ok(d) => d,
161                Err(_) => continue, // Skip if can't fetch deps
162            };
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    /// Check dependencies of a single crate for drift against latest versions
172    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            // Only check PAIML dependencies
181            if !is_paiml_crate(&dep.crate_id) {
182                continue;
183            }
184
185            // Skip dev dependencies
186            if dep.kind == "dev" {
187                continue;
188            }
189
190            // Get latest version of this dependency
191            let latest = match self.latest_versions.get(&dep.crate_id) {
192                Some(v) => v,
193                None => continue, // Dependency not published
194            };
195
196            // Check if behind
197            if let Some(drift) = self.check_drift(crate_name, crate_version, dep, latest) {
198                drifts.push(drift);
199            }
200        }
201    }
202
203    /// Sort drift reports by severity (major first) then alphabetically
204    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    /// Check if a dependency version is behind the latest
217    fn check_drift(
218        &self,
219        crate_name: &str,
220        crate_version: &str,
221        dep: &DependencyData,
222        latest: &semver::Version,
223    ) -> Option<DriftReport> {
224        // Parse the version requirement to extract the base version
225        let uses_version = &dep.version_req;
226
227        // Try to parse a semver from the requirement
228        // Handle common patterns: "0.11", "0.11.0", "^0.11", "~0.11"
229        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        // Parse as version or partial version
238        let (uses_major, uses_minor) = Self::parse_version_parts(version_str);
239
240        // Compare with latest
241        let severity = if uses_major < latest.major as u32 {
242            // Major version behind (rare for 0.x crates)
243            Some(DriftSeverity::Major)
244        } else if uses_major == latest.major as u32 && uses_minor < latest.minor as u32 {
245            // Minor version behind within same major
246            Some(DriftSeverity::Major) // For 0.x, minor is effectively major
247        } else {
248            // Up to date or ahead
249            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    /// Parse version string into (major, minor) parts
263    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    /// Get cached latest versions
271    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
282/// Format drift reports for display (blocking error style)
283pub 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
302/// Format drift reports as JSON
303pub 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    // ===== DriftSeverity =====
332
333    #[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    // ===== DriftReport =====
368
369    #[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    // ===== DriftChecker =====
399
400    #[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    // ===== parse_version_parts =====
421
422    #[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    // ===== check_drift =====
453
454    #[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    // ===== format_drift_errors =====
536
537    #[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    // ===== format_drift_json =====
572
573    #[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    // ===== Drift sorting (exercises the comparator from detect_drift) =====
608
609    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    // ===== check_deps_for_drift =====
686
687    #[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(); // no versions cached
724        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"),   // behind
749            make_dep("serde", "1.0", "normal"),      // non-paiml, skip
750            make_dep("aprender", "^0.25", "normal"), // up to date
751            make_dep("trueno", "^0.12", "dev"),      // dev dep, skip
752        ];
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    // ===== check_drift edge cases =====
760
761    #[test]
762    fn test_check_drift_equal_major_different_minor() {
763        let checker = DriftChecker::new();
764        // same major (0), uses minor 14, latest minor 14 → up to date
765        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    // ===== async detect_drift with offline client =====
793
794    #[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        // Pre-populate so fetch_latest_versions is skipped
802        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        // Offline client can't get_dependencies → empty result
806        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        // Only trueno published, not batuta → should return empty
818        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    /// Falsification: if batuta's deps are current, self-drift must return empty.
825    #[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        // Simulate batuta depending on current versions
833        let deps = vec![
834            make_dep("trueno", "^0.16", "normal"),
835            make_dep("aprender", "^0.27", "normal"),
836            make_dep("serde", "1.0", "normal"), // non-PAIML, ignored
837        ];
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    /// Falsification: self-drift must never produce reports for non-batuta crates.
844    #[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        // Even if we fabricate deps for batuta, all reports must have crate_name == "batuta"
851        let deps = vec![make_dep("trueno", "^0.11", "normal")]; // stale
852        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        // Pre-populate so fetch_latest_versions is skipped
873        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        // Offline client can't get_dependencies → all crates skip → empty result
877        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        // latest_versions is empty → fetch_latest_versions called → offline errors → remains empty
889        // Then detect_drift loops but no crate has a version → all skip
890        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        // Should not error, just skip crates that can't be fetched
902        checker.fetch_latest_versions(&mut client).await.expect("async operation failed");
903        // All fetches fail in offline mode → no versions cached
904        assert!(checker.latest_versions.is_empty());
905    }
906}