monarch_conda/
diff.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9use std::path::Path;
10use std::time::Duration;
11use std::time::SystemTime;
12use std::time::UNIX_EPOCH;
13
14use anyhow::Result;
15use anyhow::ensure;
16use digest::Digest;
17use digest::Output;
18use serde::Deserialize;
19use serde::Serialize;
20use sha2::Sha256;
21use tokio::fs;
22
23use crate::hash_utils;
24use crate::pack_meta_history::History;
25use crate::pack_meta_history::Offsets;
26
27/// Fingerprint of the conda-meta directory, used by `CondaFingerprint` below.
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub struct CondaMetaFingerprint {
30    // TODO(agallagher): It might be worth storing more information of installed
31    // packages, so that we could print better error messages when we detect two
32    // envs are not equivalent.
33    hash: Output<Sha256>,
34}
35
36impl CondaMetaFingerprint {
37    async fn from_env(path: &Path) -> Result<Self> {
38        let mut hasher = Sha256::new();
39        hash_utils::hash_directory_tree(&path.join("conda-meta"), &mut hasher).await?;
40        Ok(Self {
41            hash: hasher.finalize(),
42        })
43    }
44}
45
46/// Fingerprint of the pack-meta directory, used by `CondaFingerprint` below.
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub struct PackMetaFingerprint {
49    offsets: Output<Sha256>,
50    pub history: History,
51}
52
53impl PackMetaFingerprint {
54    async fn from_env(path: &Path) -> Result<Self> {
55        let pack_meta = path.join("pack-meta");
56
57        // Read the fulle history.jsonl file.
58        let contents = fs::read_to_string(pack_meta.join("history.jsonl")).await?;
59        let history = History::from_contents(&contents)?;
60
61        // Read entire offsets.jsonl file, but avoid hashing the offsets, which can change.
62        let mut hasher = Sha256::new();
63        let contents = fs::read_to_string(pack_meta.join("offsets.jsonl")).await?;
64        let offsets = Offsets::from_contents(&contents)?;
65        for ent in offsets.entries {
66            let contents = bincode::serialize(&(ent.path, ent.mode, ent.offsets.len()))?;
67            hasher.update(contents.len().to_le_bytes());
68            hasher.update(&contents);
69        }
70        let offsets = hasher.finalize();
71
72        Ok(Self { history, offsets })
73    }
74}
75
76/// A fingerprint of a conda environment, used to detect if two envs are similar enough to
77/// facilitate mtime-based conda syncing.
78#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
79pub struct CondaFingerprint {
80    pub conda_meta: CondaMetaFingerprint,
81    pub pack_meta: PackMetaFingerprint,
82}
83
84impl CondaFingerprint {
85    pub async fn from_env(path: &Path) -> Result<Self> {
86        Ok(Self {
87            conda_meta: CondaMetaFingerprint::from_env(path).await?,
88            pack_meta: PackMetaFingerprint::from_env(path).await?,
89        })
90    }
91
92    /// Create a comparator to compare the mtimes of files from two "equivalent" conda envs.
93    /// In particular, thie comparator will be aware of spuriuos mtime changes that occurs from
94    /// prefix replacement (via `meta-pack`), and will filter them out.
95    pub fn mtime_comparator(
96        a: &Self,
97        b: &Self,
98    ) -> Result<Box<dyn Fn(&SystemTime, &SystemTime) -> std::cmp::Ordering + Send + Sync>> {
99        let (a_prefix, a_base) = a.pack_meta.history.first()?;
100        let (b_prefix, b_base) = b.pack_meta.history.first()?;
101        ensure!(a_prefix == b_prefix);
102
103        // NOTE(agallagher): There appears to be some mtime drift on some files after fbpkg creation,
104        // so acccount for that here.
105        let slop = Duration::from_mins(5);
106
107        // We load the timestamp from the first history entry, and use this to see if any
108        // files have been updated since the env was created.
109        let a_base = UNIX_EPOCH + Duration::from_secs(a_base) + slop;
110        let b_base = UNIX_EPOCH + Duration::from_secs(b_base) + slop;
111
112        // We also load the last prefix update window for each, as any mtimes from this window
113        // should be ignored.
114        let a_window = a
115            .pack_meta
116            .history
117            .prefix_and_last_update_window()?
118            .1
119            .map(|(s, e)| {
120                (
121                    UNIX_EPOCH + Duration::from_secs(s),
122                    UNIX_EPOCH + Duration::from_secs(e + 1),
123                )
124            });
125        let b_window = b
126            .pack_meta
127            .history
128            .prefix_and_last_update_window()?
129            .1
130            .map(|(s, e)| {
131                (
132                    UNIX_EPOCH + Duration::from_secs(s),
133                    UNIX_EPOCH + Duration::from_secs(e + 1),
134                )
135            });
136
137        Ok(Box::new(move |a: &SystemTime, b: &SystemTime| {
138            match (
139                *a > a_base && a_window.is_none_or(|(s, e)| *a < s || *a > e),
140                *b > b_base && b_window.is_none_or(|(s, e)| *b < s || *b > e),
141            ) {
142                (true, false) => std::cmp::Ordering::Greater,
143                (false, true) => std::cmp::Ordering::Less,
144                (false, false) => std::cmp::Ordering::Equal,
145                (true, true) => a.cmp(b),
146            }
147        }))
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use std::path::PathBuf;
154    use std::time::SystemTime;
155
156    use anyhow::Result;
157    use rattler_conda_types::package::FileMode;
158    use tempfile::TempDir;
159    use tokio::fs;
160
161    use super::*;
162    use crate::pack_meta_history::HistoryRecord;
163    use crate::pack_meta_history::Offset;
164    use crate::pack_meta_history::OffsetRecord;
165
166    /// Helper function to create a conda environment with configurable packages and files
167    async fn setup_conda_env_with_config(
168        temp_dir: &TempDir,
169        env_name: &str,
170        base_time: SystemTime,
171        prefix: &str,
172        packages: &[(&str, &str, &str)], // (name, version, build)
173        include_update_window: bool,
174    ) -> Result<PathBuf> {
175        let env_path = temp_dir.path().join(env_name);
176        fs::create_dir_all(&env_path).await?;
177
178        // Create conda-meta directory with package files
179        let conda_meta_path = env_path.join("conda-meta");
180        fs::create_dir_all(&conda_meta_path).await?;
181
182        // Create conda package metadata files for each package
183        for (name, version, build) in packages {
184            let package_json = format!(
185                r#"{{
186                    "name": "{}",
187                    "version": "{}",
188                    "build": "{}",
189                    "build_number": 0,
190                    "paths_data": {{
191                        "paths": [
192                            {{
193                                "path": "lib/{}.so",
194                                "path_type": "hardlink",
195                                "size_in_bytes": 1024,
196                                "mode": "binary"
197                            }}
198                        ]
199                    }},
200                    "repodata_record": {{
201                        "package_record": {{
202                            "timestamp": {}
203                        }}
204                    }}
205                }}"#,
206                name,
207                version,
208                build,
209                name,
210                base_time.duration_since(UNIX_EPOCH)?.as_secs()
211            );
212
213            fs::write(
214                conda_meta_path.join(format!("{}-{}-{}.json", name, version, build)),
215                package_json,
216            )
217            .await?;
218        }
219
220        // Create pack-meta directory and files
221        let pack_meta_path = env_path.join("pack-meta");
222        fs::create_dir_all(&pack_meta_path).await?;
223
224        // Create history.jsonl file
225        create_history_file(&pack_meta_path, prefix, base_time, include_update_window).await?;
226
227        // Create offsets.jsonl file
228        create_offsets_file(&pack_meta_path, packages).await?;
229
230        Ok(env_path)
231    }
232
233    /// Helper function to create a simple conda environment
234    async fn create_dummy_conda_env(temp_dir: &TempDir, env_name: &str) -> Result<PathBuf> {
235        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
236        let default_packages = &[
237            ("python", "3.9.0", "h12debd9_1"),
238            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
239        ];
240        setup_conda_env_with_config(
241            temp_dir,
242            env_name,
243            base_time,
244            "/opt/conda",
245            default_packages,
246            false,
247        )
248        .await
249    }
250
251    /// Helper function to create a history.jsonl file
252    async fn create_history_file(
253        pack_meta_path: &Path,
254        prefix: &str,
255        base_time: SystemTime,
256        include_update_window: bool,
257    ) -> Result<()> {
258        let base_timestamp = base_time.duration_since(UNIX_EPOCH)?.as_secs();
259        let mut history = History { entries: vec![] };
260
261        // Add initial history entry
262        history.entries.push(HistoryRecord {
263            timestamp: base_timestamp,
264            prefix: PathBuf::from(prefix),
265            finished: true,
266        });
267
268        // Optionally add an update window (start and end entries)
269        if include_update_window {
270            history.entries.push(HistoryRecord {
271                timestamp: base_timestamp + 3600, // 1 hour later
272                prefix: PathBuf::from(prefix),
273                finished: false,
274            });
275            history.entries.push(HistoryRecord {
276                timestamp: base_timestamp + 3660, // 1 minute after start
277                prefix: PathBuf::from(prefix),
278                finished: true,
279            });
280        }
281
282        fs::write(pack_meta_path.join("history.jsonl"), history.to_str()?).await?;
283        Ok(())
284    }
285
286    /// Helper function to create an offsets.jsonl file
287    async fn create_offsets_file(
288        pack_meta_path: &Path,
289        packages: &[(&str, &str, &str)],
290    ) -> Result<()> {
291        let mut offset_entries = Vec::new();
292
293        // Add default entries for common files
294        offset_entries.push(OffsetRecord {
295            path: PathBuf::from("bin/python"),
296            mode: FileMode::Binary,
297            offsets: vec![Offset {
298                start: 0,
299                len: 1024,
300                contents: None,
301            }],
302        });
303
304        // Add entries for each package
305        for (name, _, _) in packages {
306            offset_entries.push(OffsetRecord {
307                path: PathBuf::from(format!("lib/{}.so", name)),
308                mode: FileMode::Binary,
309                offsets: vec![Offset {
310                    start: 0,
311                    len: 1024,
312                    contents: None,
313                }],
314            });
315        }
316
317        let offsets = Offsets {
318            entries: offset_entries,
319        };
320        fs::write(pack_meta_path.join("offsets.jsonl"), offsets.to_str()?).await?;
321        Ok(())
322    }
323
324    #[tokio::test]
325    async fn test_conda_fingerprint_creation() -> Result<()> {
326        let temp_dir = TempDir::new()?;
327        let env_path = create_dummy_conda_env(&temp_dir, "test_env").await?;
328
329        // Create fingerprint
330        let fingerprint = CondaFingerprint::from_env(&env_path).await?;
331
332        // Verify that the fingerprint was created successfully
333        assert_eq!(fingerprint.pack_meta.history.entries.len(), 1);
334        assert_eq!(
335            fingerprint.pack_meta.history.entries[0].timestamp,
336            1640995200
337        );
338        assert_eq!(
339            fingerprint.pack_meta.history.entries[0].prefix,
340            PathBuf::from("/opt/conda")
341        );
342        assert!(fingerprint.pack_meta.history.entries[0].finished);
343
344        Ok(())
345    }
346
347    #[tokio::test]
348    async fn test_conda_fingerprint_equality() -> Result<()> {
349        let temp_dir = TempDir::new()?;
350
351        // Create two identical environments
352        let env1_path = create_dummy_conda_env(&temp_dir, "env1").await?;
353        let env2_path = create_dummy_conda_env(&temp_dir, "env2").await?;
354
355        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
356        let default_packages = &[
357            ("python", "3.9.0", "h12debd9_1"),
358            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
359        ];
360        for env_path in [&env1_path, &env2_path] {
361            let pack_meta_path = env_path.join("pack-meta");
362            create_history_file(&pack_meta_path, "/opt/conda", base_time, false).await?;
363            create_offsets_file(&pack_meta_path, default_packages).await?;
364        }
365
366        // Create fingerprints
367        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
368        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
369
370        // They should be equal since they have identical content
371        assert_eq!(fingerprint1, fingerprint2);
372
373        Ok(())
374    }
375
376    #[tokio::test]
377    async fn test_conda_fingerprint_inequality_different_packages() -> Result<()> {
378        let temp_dir = TempDir::new()?;
379
380        // Create two environments with different packages
381        let env1_path = create_dummy_conda_env(&temp_dir, "env1").await?;
382        let env2_path = create_dummy_conda_env(&temp_dir, "env2").await?;
383
384        // Add an extra package to env2
385        let conda_meta2_path = env2_path.join("conda-meta");
386        fs::write(
387            conda_meta2_path.join("scipy-1.7.0-py39h7a9d4c0_0.json"),
388            r#"{"name": "scipy", "version": "1.7.0", "build": "py39h7a9d4c0_0"}"#,
389        )
390        .await?;
391
392        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
393        let default_packages = &[
394            ("python", "3.9.0", "h12debd9_1"),
395            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
396        ];
397        for env_path in [&env1_path, &env2_path] {
398            let pack_meta_path = env_path.join("pack-meta");
399            create_history_file(&pack_meta_path, "/opt/conda", base_time, false).await?;
400            create_offsets_file(&pack_meta_path, default_packages).await?;
401        }
402
403        // Create fingerprints
404        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
405        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
406
407        // They should be different due to different packages
408        assert_ne!(fingerprint1, fingerprint2);
409
410        Ok(())
411    }
412
413    #[tokio::test]
414    async fn test_mtime_comparator_basic() -> Result<()> {
415        let temp_dir = TempDir::new()?;
416
417        // Create two identical environments
418        let env1_path = create_dummy_conda_env(&temp_dir, "env1").await?;
419        let env2_path = create_dummy_conda_env(&temp_dir, "env2").await?;
420
421        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
422        let default_packages = &[
423            ("python", "3.9.0", "h12debd9_1"),
424            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
425        ];
426        for env_path in [&env1_path, &env2_path] {
427            let pack_meta_path = env_path.join("pack-meta");
428            create_history_file(&pack_meta_path, "/opt/conda", base_time, false).await?;
429            create_offsets_file(&pack_meta_path, default_packages).await?;
430        }
431
432        // Create fingerprints
433        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
434        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
435
436        // Create mtime comparator
437        let comparator = CondaFingerprint::mtime_comparator(&fingerprint1, &fingerprint2)?;
438
439        // Test various mtime scenarios
440        let base_timestamp = 1640995200;
441        let test_base_time = UNIX_EPOCH + Duration::from_secs(base_timestamp);
442        let old_time = test_base_time - Duration::from_hours(1); // 1 hour before base
443        let new_time = test_base_time + Duration::from_hours(2); // 2 hours after base (beyond slop)
444
445        // Files older than base should be considered equal
446        assert_eq!(comparator(&old_time, &old_time), std::cmp::Ordering::Equal);
447
448        // File newer than base vs old file
449        assert_eq!(
450            comparator(&new_time, &old_time),
451            std::cmp::Ordering::Greater
452        );
453        assert_eq!(comparator(&old_time, &new_time), std::cmp::Ordering::Less);
454
455        // Both files newer than base should compare normally
456        let newer_time = new_time + Duration::from_mins(30);
457        assert_eq!(comparator(&new_time, &newer_time), std::cmp::Ordering::Less);
458        assert_eq!(
459            comparator(&newer_time, &new_time),
460            std::cmp::Ordering::Greater
461        );
462
463        Ok(())
464    }
465
466    #[tokio::test]
467    async fn test_mtime_comparator_with_update_window() -> Result<()> {
468        let temp_dir = TempDir::new()?;
469
470        // Create two identical environments
471        let env1_path = create_dummy_conda_env(&temp_dir, "env1").await?;
472        let env2_path = create_dummy_conda_env(&temp_dir, "env2").await?;
473
474        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
475        let default_packages = &[
476            ("python", "3.9.0", "h12debd9_1"),
477            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
478        ];
479        for env_path in [&env1_path, &env2_path] {
480            let pack_meta_path = env_path.join("pack-meta");
481            // Include update window this time
482            create_history_file(&pack_meta_path, "/opt/conda", base_time, true).await?;
483            create_offsets_file(&pack_meta_path, default_packages).await?;
484        }
485
486        // Create fingerprints
487        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
488        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
489
490        // Create mtime comparator
491        let comparator = CondaFingerprint::mtime_comparator(&fingerprint1, &fingerprint2)?;
492
493        let base_timestamp = 1640995200;
494        let test_base_time = UNIX_EPOCH + Duration::from_secs(base_timestamp);
495        let update_start = test_base_time + Duration::from_hours(1); // Update window start
496        let update_end = test_base_time + Duration::from_mins(61); // Update window end
497        let in_window_time = update_start + Duration::from_secs(30); // Inside update window
498        let after_window_time = update_end + Duration::from_hours(1); // After update window
499
500        // Files with mtimes in the update window should be ignored (treated as equal to old files)
501        let old_time = base_time - Duration::from_hours(1);
502        assert_eq!(
503            comparator(&in_window_time, &old_time),
504            std::cmp::Ordering::Equal
505        );
506        assert_eq!(
507            comparator(&old_time, &in_window_time),
508            std::cmp::Ordering::Equal
509        );
510
511        // Files after the update window should be considered newer
512        assert_eq!(
513            comparator(&after_window_time, &old_time),
514            std::cmp::Ordering::Greater
515        );
516        assert_eq!(
517            comparator(&old_time, &after_window_time),
518            std::cmp::Ordering::Less
519        );
520
521        Ok(())
522    }
523
524    #[tokio::test]
525    async fn test_mtime_comparator_different_prefixes_fails() -> Result<()> {
526        let temp_dir = TempDir::new()?;
527
528        // Create two environments with different prefixes
529        let env1_path = create_dummy_conda_env(&temp_dir, "env1").await?;
530        let env2_path = create_dummy_conda_env(&temp_dir, "env2").await?;
531
532        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
533        let default_packages = &[
534            ("python", "3.9.0", "h12debd9_1"),
535            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
536        ];
537
538        // Different prefixes
539        create_history_file(
540            &env1_path.join("pack-meta"),
541            "/opt/conda1",
542            base_time,
543            false,
544        )
545        .await?;
546        create_history_file(
547            &env2_path.join("pack-meta"),
548            "/opt/conda2",
549            base_time,
550            false,
551        )
552        .await?;
553
554        for env_path in [&env1_path, &env2_path] {
555            create_offsets_file(&env_path.join("pack-meta"), default_packages).await?;
556        }
557
558        // Create fingerprints
559        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
560        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
561
562        // mtime_comparator should fail due to different prefixes
563        let result = CondaFingerprint::mtime_comparator(&fingerprint1, &fingerprint2);
564        assert!(result.is_err());
565
566        Ok(())
567    }
568
569    #[tokio::test]
570    async fn test_pack_meta_fingerprint_offsets_hashing() -> Result<()> {
571        let temp_dir = TempDir::new()?;
572
573        // Create two environments with identical structure but different offset values
574        let env1_path = create_dummy_conda_env(&temp_dir, "env1").await?;
575        let env2_path = create_dummy_conda_env(&temp_dir, "env2").await?;
576
577        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
578        for env_path in [&env1_path, &env2_path] {
579            let pack_meta_path = env_path.join("pack-meta");
580            create_history_file(&pack_meta_path, "/opt/conda", base_time, false).await?;
581        }
582
583        // Create identical offset files
584        let default_packages = &[
585            ("python", "3.9.0", "h12debd9_1"),
586            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
587        ];
588        for env_path in [&env1_path, &env2_path] {
589            create_offsets_file(&env_path.join("pack-meta"), default_packages).await?;
590        }
591
592        // Create fingerprints
593        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
594        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
595
596        // Pack meta fingerprints should be identical for identical offset structures
597        assert_eq!(fingerprint1.pack_meta, fingerprint2.pack_meta);
598
599        // Now create env3 with different offset structure (different number of offsets)
600        let env3_path = create_dummy_conda_env(&temp_dir, "env3").await?;
601        let pack_meta3_path = env3_path.join("pack-meta");
602        create_history_file(&pack_meta3_path, "/opt/conda", base_time, false).await?;
603
604        // Create offsets with different structure
605        let different_offsets = Offsets {
606            entries: vec![OffsetRecord {
607                path: PathBuf::from("bin/python"),
608                mode: FileMode::Binary,
609                offsets: vec![
610                    Offset {
611                        start: 0,
612                        len: 1024,
613                        contents: None,
614                    },
615                    Offset {
616                        start: 2048,
617                        len: 512,
618                        contents: None,
619                    }, // Extra offset
620                ],
621            }],
622        };
623        fs::write(
624            pack_meta3_path.join("offsets.jsonl"),
625            different_offsets.to_str()?,
626        )
627        .await?;
628
629        let fingerprint3 = CondaFingerprint::from_env(&env3_path).await?;
630
631        // Pack meta fingerprints should be different due to different offset structure
632        assert_ne!(fingerprint1.pack_meta, fingerprint3.pack_meta);
633
634        Ok(())
635    }
636
637    #[tokio::test]
638    async fn test_mtime_comparator_complex_scenarios() -> Result<()> {
639        let temp_dir = TempDir::new()?;
640        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
641
642        // Create environments with multiple update windows
643        let packages = &[
644            ("python", "3.9.0", "h12debd9_1"),
645            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
646            ("scipy", "1.7.0", "py39h0123456_0"),
647        ];
648
649        let env1_path = setup_conda_env_with_config(
650            &temp_dir,
651            "env1",
652            base_time,
653            "/opt/conda",
654            packages,
655            true, // Include update window
656        )
657        .await?;
658
659        let env2_path = setup_conda_env_with_config(
660            &temp_dir,
661            "env2",
662            base_time,
663            "/opt/conda",
664            packages,
665            true, // Include update window
666        )
667        .await?;
668
669        // Create fingerprints
670        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
671        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
672
673        // Create mtime comparator
674        let comparator = CondaFingerprint::mtime_comparator(&fingerprint1, &fingerprint2)?;
675
676        // Test edge cases around the slop period (5 minutes)
677        let base_timestamp = base_time.duration_since(UNIX_EPOCH)?.as_secs();
678        let slop = Duration::from_mins(5);
679        let base_plus_slop = UNIX_EPOCH + Duration::from_secs(base_timestamp) + slop;
680
681        // Times just before and after the slop period
682        let just_before_slop = base_plus_slop - Duration::from_secs(30);
683        let just_after_slop = base_plus_slop + Duration::from_secs(30);
684
685        // Files just before slop should be equal
686        assert_eq!(
687            comparator(&just_before_slop, &just_before_slop),
688            std::cmp::Ordering::Equal
689        );
690
691        // Files after slop should be considered newer
692        assert_eq!(
693            comparator(&just_after_slop, &just_before_slop),
694            std::cmp::Ordering::Greater
695        );
696        assert_eq!(
697            comparator(&just_before_slop, &just_after_slop),
698            std::cmp::Ordering::Less
699        );
700
701        Ok(())
702    }
703
704    #[tokio::test]
705    async fn test_mtime_comparator_mixed_update_windows() -> Result<()> {
706        let temp_dir = TempDir::new()?;
707        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
708
709        // Create one environment with update window and one without
710        let packages = &[("python", "3.9.0", "h12debd9_1")];
711
712        let env1_path = setup_conda_env_with_config(
713            &temp_dir,
714            "env1",
715            base_time,
716            "/opt/conda",
717            packages,
718            true, // Has update window
719        )
720        .await?;
721
722        let env2_path = setup_conda_env_with_config(
723            &temp_dir,
724            "env2",
725            base_time,
726            "/opt/conda",
727            packages,
728            false, // No update window
729        )
730        .await?;
731
732        // Create fingerprints
733        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
734        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
735
736        // Create mtime comparator
737        let comparator = CondaFingerprint::mtime_comparator(&fingerprint1, &fingerprint2)?;
738
739        let base_timestamp = base_time.duration_since(UNIX_EPOCH)?.as_secs();
740        let update_window_time = UNIX_EPOCH + Duration::from_secs(base_timestamp + 3630); // In the middle of update window
741        let old_time = base_time - Duration::from_hours(1);
742        let new_time = base_time + Duration::from_hours(2);
743
744        // When update_window_time is the first arg (env1 context with update window),
745        // it should be treated as equal to old files
746        assert_eq!(
747            comparator(&update_window_time, &old_time),
748            std::cmp::Ordering::Equal
749        );
750
751        // When update_window_time is the second arg (env2 context with NO update window),
752        // it should be considered newer than old files
753        assert_eq!(
754            comparator(&old_time, &update_window_time),
755            std::cmp::Ordering::Less
756        );
757
758        // But new files should still be greater than both
759        assert_eq!(
760            comparator(&new_time, &old_time),
761            std::cmp::Ordering::Greater
762        );
763        assert_eq!(
764            comparator(&new_time, &update_window_time),
765            std::cmp::Ordering::Greater
766        );
767
768        Ok(())
769    }
770
771    #[tokio::test]
772    async fn test_mtime_comparator_version_differences() -> Result<()> {
773        let temp_dir = TempDir::new()?;
774        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
775
776        // Create environments with different package versions
777        let env1_packages = &[
778            ("python", "3.9.0", "h12debd9_1"),
779            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
780        ];
781
782        let env2_packages = &[
783            ("python", "3.9.0", "h12debd9_1"),
784            ("numpy", "1.22.0", "py39h7a9d4c0_0"), // Different version
785        ];
786
787        let env1_path = setup_conda_env_with_config(
788            &temp_dir,
789            "env1",
790            base_time,
791            "/opt/conda",
792            env1_packages,
793            false,
794        )
795        .await?;
796
797        let env2_path = setup_conda_env_with_config(
798            &temp_dir,
799            "env2",
800            base_time,
801            "/opt/conda",
802            env2_packages,
803            false,
804        )
805        .await?;
806
807        // Create fingerprints
808        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
809        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
810
811        // Environments should have different fingerprints due to package differences
812        assert_ne!(fingerprint1, fingerprint2);
813
814        // But the mtime comparator should still work since the core history is the same
815        let comparator = CondaFingerprint::mtime_comparator(&fingerprint1, &fingerprint2)?;
816
817        let old_time = base_time - Duration::from_hours(1);
818        let new_time = base_time + Duration::from_hours(2);
819
820        // Basic mtime comparison should still work
821        assert_eq!(comparator(&old_time, &old_time), std::cmp::Ordering::Equal);
822        assert_eq!(
823            comparator(&new_time, &old_time),
824            std::cmp::Ordering::Greater
825        );
826
827        Ok(())
828    }
829
830    #[tokio::test]
831    async fn test_mtime_comparator_empty_environments() -> Result<()> {
832        let temp_dir = TempDir::new()?;
833        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
834
835        // Create environments with minimal packages
836        let minimal_packages = &[("python", "3.9.0", "h12debd9_1")];
837
838        let env1_path = setup_conda_env_with_config(
839            &temp_dir,
840            "env1",
841            base_time,
842            "/opt/conda",
843            minimal_packages,
844            false,
845        )
846        .await?;
847
848        let env2_path = setup_conda_env_with_config(
849            &temp_dir,
850            "env2",
851            base_time,
852            "/opt/conda",
853            minimal_packages,
854            false,
855        )
856        .await?;
857
858        // Create fingerprints
859        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
860        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
861
862        // Create mtime comparator
863        let comparator = CondaFingerprint::mtime_comparator(&fingerprint1, &fingerprint2)?;
864
865        // Test with identical times
866        let test_time = base_time + Duration::from_mins(30);
867        assert_eq!(
868            comparator(&test_time, &test_time),
869            std::cmp::Ordering::Equal
870        );
871
872        // Test with very old times (should be equal)
873        let very_old_time = UNIX_EPOCH + Duration::from_secs(1000);
874        assert_eq!(
875            comparator(&very_old_time, &very_old_time),
876            std::cmp::Ordering::Equal
877        );
878
879        Ok(())
880    }
881
882    #[tokio::test]
883    async fn test_conda_fingerprint_with_large_environments() -> Result<()> {
884        let temp_dir = TempDir::new()?;
885        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
886
887        // Create environments with many packages
888        let large_package_set = &[
889            ("python", "3.9.0", "h12debd9_1"),
890            ("numpy", "1.21.0", "py39h7a9d4c0_0"),
891            ("scipy", "1.7.0", "py39h0123456_0"),
892            ("pandas", "1.3.0", "py39h0abcdef_0"),
893            ("matplotlib", "3.4.0", "py39h0fedcba_0"),
894            ("scikit-learn", "0.24.0", "py39h0987654_0"),
895            ("tensorflow", "2.6.0", "py39h0321654_0"),
896            ("pytorch", "1.9.0", "py39h0456789_0"),
897            ("jupyter", "1.0.0", "py39h0111111_0"),
898            ("ipython", "7.25.0", "py39h0222222_0"),
899        ];
900
901        let env1_path = setup_conda_env_with_config(
902            &temp_dir,
903            "env1",
904            base_time,
905            "/opt/conda",
906            large_package_set,
907            true,
908        )
909        .await?;
910
911        let env2_path = setup_conda_env_with_config(
912            &temp_dir,
913            "env2",
914            base_time,
915            "/opt/conda",
916            large_package_set,
917            true,
918        )
919        .await?;
920
921        // Create fingerprints
922        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
923        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
924
925        // Fingerprints should be identical for identical large environments
926        assert_eq!(fingerprint1, fingerprint2);
927
928        // Create mtime comparator and verify it works with large environments
929        let comparator = CondaFingerprint::mtime_comparator(&fingerprint1, &fingerprint2)?;
930
931        let old_time = base_time - Duration::from_hours(1);
932        let new_time = base_time + Duration::from_hours(2);
933
934        assert_eq!(comparator(&old_time, &old_time), std::cmp::Ordering::Equal);
935        assert_eq!(
936            comparator(&new_time, &old_time),
937            std::cmp::Ordering::Greater
938        );
939
940        Ok(())
941    }
942
943    #[tokio::test]
944    async fn test_mtime_comparator_boundary_conditions() -> Result<()> {
945        let temp_dir = TempDir::new()?;
946        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1640995200);
947        let packages = &[("python", "3.9.0", "h12debd9_1")];
948
949        let env1_path = setup_conda_env_with_config(
950            &temp_dir,
951            "env1",
952            base_time,
953            "/opt/conda",
954            packages,
955            true, // Include update window
956        )
957        .await?;
958
959        let env2_path = setup_conda_env_with_config(
960            &temp_dir,
961            "env2",
962            base_time,
963            "/opt/conda",
964            packages,
965            true, // Include update window
966        )
967        .await?;
968
969        // Create fingerprints
970        let fingerprint1 = CondaFingerprint::from_env(&env1_path).await?;
971        let fingerprint2 = CondaFingerprint::from_env(&env2_path).await?;
972
973        // Create mtime comparator
974        let comparator = CondaFingerprint::mtime_comparator(&fingerprint1, &fingerprint2)?;
975
976        let base_timestamp = base_time.duration_since(UNIX_EPOCH)?.as_secs();
977        let slop = Duration::from_mins(5);
978
979        // Test exact boundary conditions
980        let _exact_base_plus_slop = UNIX_EPOCH + Duration::from_secs(base_timestamp) + slop;
981        let update_window_start = UNIX_EPOCH + Duration::from_secs(base_timestamp + 3600);
982        let update_window_end = UNIX_EPOCH + Duration::from_secs(base_timestamp + 3661); // +1 sec for window end
983
984        // Test exactly at the boundary points
985        let one_sec_before_window = update_window_start - Duration::from_secs(1);
986        let one_sec_after_window = update_window_end + Duration::from_secs(1);
987        let old_time = base_time - Duration::from_hours(1);
988
989        // Just before update window should be newer than old files
990        assert_eq!(
991            comparator(&one_sec_before_window, &old_time),
992            std::cmp::Ordering::Greater
993        );
994
995        // Just after update window should be newer than old files
996        assert_eq!(
997            comparator(&one_sec_after_window, &old_time),
998            std::cmp::Ordering::Greater
999        );
1000
1001        // Exactly at window start should be equal to old files (in window)
1002        assert_eq!(
1003            comparator(&update_window_start, &old_time),
1004            std::cmp::Ordering::Equal
1005        );
1006
1007        // Test extreme time values
1008        let very_far_future = UNIX_EPOCH + Duration::from_secs(u32::MAX as u64);
1009        assert_eq!(
1010            comparator(&very_far_future, &old_time),
1011            std::cmp::Ordering::Greater
1012        );
1013
1014        Ok(())
1015    }
1016}