Skip to main content

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