1use std::collections::BTreeSet;
61use std::fmt::Write as _;
62use std::path::{Path, PathBuf};
63
64use serde::{Deserialize, Serialize};
65use sha2::{Digest, Sha256};
66
67use crate::error::{HostError, ProcessOutput};
68
69pub const DEFAULT_PIP_TIMEOUT_SECS: u64 = 600;
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct CacheMetadata {
88 pub venv_path: String,
90
91 #[serde(default)]
93 pub requirements_file: Option<String>,
94
95 pub requirements_hash: String,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub manifest_version: Option<String>,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub manifest_hash: Option<String>,
107
108 #[serde(default)]
110 pub python_version: Option<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum CacheVerdict {
118 Valid,
120 VenvMissing,
122 MetadataMissing,
124 MetadataUnreadable,
126 RequirementsChanged,
128 ManifestVersionChanged,
130 ManifestHashChanged,
132}
133
134impl CacheVerdict {
135 pub fn is_valid(&self) -> bool {
137 matches!(self, Self::Valid)
138 }
139}
140
141pub fn hash_file_or_empty(path: Option<&Path>) -> String {
148 let mut hasher = Sha256::new();
149 match path {
150 Some(p) => match std::fs::read(p) {
151 Ok(bytes) => hasher.update(&bytes),
152 Err(_) => hasher.update(b""),
153 },
154 None => hasher.update(b""),
155 }
156 let digest = hasher.finalize();
157 let mut hex = String::with_capacity(digest.len() * 2);
158 for byte in digest {
159 let _ = write!(hex, "{byte:02x}");
160 }
161 hex
162}
163
164fn manifest_stem_for_class(class_name: &str) -> String {
171 let safe: String = class_name
172 .trim()
173 .chars()
174 .map(|c| {
175 if c.is_alphanumeric() || c == '-' || c == '_' {
176 c
177 } else {
178 '-'
179 }
180 })
181 .collect();
182 let safe = safe.trim_matches('-');
183 if safe.is_empty() {
184 "plugin".to_string()
185 } else {
186 safe.to_string()
187 }
188}
189
190pub fn manifest_filename_for_class(class_name: &str) -> String {
201 format!(
202 "{}.plugin-manifest.yaml",
203 manifest_stem_for_class(class_name)
204 )
205}
206
207pub fn evaluate_cache(
214 venv_path: &Path,
215 metadata_path: &Path,
216 expected_requirements_hash: &str,
217 expected_manifest_version: Option<&str>,
218 expected_manifest_hash: Option<&str>,
219) -> CacheVerdict {
220 if !venv_path.exists() {
221 return CacheVerdict::VenvMissing;
222 }
223 if !metadata_path.exists() {
224 return CacheVerdict::MetadataMissing;
225 }
226
227 let Ok(raw) = std::fs::read_to_string(metadata_path) else {
228 return CacheVerdict::MetadataUnreadable;
229 };
230 let Ok(metadata) = serde_json::from_str::<CacheMetadata>(&raw) else {
231 return CacheVerdict::MetadataUnreadable;
232 };
233
234 if metadata.requirements_hash != expected_requirements_hash {
235 return CacheVerdict::RequirementsChanged;
236 }
237
238 if let Some(cached) = metadata.manifest_version.as_deref()
240 && Some(cached) != expected_manifest_version
241 {
242 return CacheVerdict::ManifestVersionChanged;
243 }
244 if let Some(cached) = metadata.manifest_hash.as_deref()
245 && Some(cached) != expected_manifest_hash
246 {
247 return CacheVerdict::ManifestHashChanged;
248 }
249
250 CacheVerdict::Valid
251}
252
253const OWNERS_FILENAME: &str = "owners.json";
259
260#[derive(Debug, Clone, Default, Serialize, Deserialize)]
268pub struct VenvOwners {
269 #[serde(default)]
271 pub owners: BTreeSet<String>,
272}
273
274impl VenvOwners {
275 pub fn load(path: &Path) -> Self {
283 std::fs::read_to_string(path)
284 .ok()
285 .and_then(|raw| serde_json::from_str(&raw).ok())
286 .unwrap_or_default()
287 }
288
289 pub fn has_other_owner(&self, class_name: &str) -> bool {
291 self.owners.iter().any(|o| o != class_name)
292 }
293}
294
295#[derive(Debug, Clone)]
300pub struct VenvLayout {
301 pub plugin_path: PathBuf,
303 pub venv_path: PathBuf,
305 pub cache_dir: PathBuf,
307 pub metadata_path: PathBuf,
309 pub manifest_path: PathBuf,
311 pub owners_path: PathBuf,
314}
315
316impl VenvLayout {
317 pub fn resolve(plugin_dirs: &[String], class_name: &str) -> Result<Self, HostError> {
320 let first = plugin_dirs.first().ok_or_else(|| HostError::Config {
324 message: "isolated_venv requires at least one plugin directory — \
325 the venv is built under the first one"
326 .into(),
327 })?;
328
329 let class_root = class_name.split('.').next().unwrap_or(class_name);
330 if class_root.is_empty() {
331 return Err(HostError::Config {
332 message: format!("`class_name` '{class_name}' has no leading package segment"),
333 });
334 }
335
336 let plugin_path = Path::new(first).join(class_root);
337 let venv_path = plugin_path.join(".venv");
338 let cache_dir = plugin_path.join(".cpex").join("venv_cache");
339
340 let metadata_path = cache_dir.join(format!(
358 "{}_metadata.json",
359 manifest_stem_for_class(class_name)
360 ));
361 let manifest_path = plugin_path.join(manifest_filename_for_class(class_name));
362 let owners_path = cache_dir.join(OWNERS_FILENAME);
365
366 Ok(Self {
367 plugin_path,
368 venv_path,
369 cache_dir,
370 metadata_path,
371 manifest_path,
372 owners_path,
373 })
374 }
375
376 pub fn python_executable(&self) -> PathBuf {
378 if cfg!(windows) {
379 self.venv_path.join("Scripts").join("python.exe")
380 } else {
381 self.venv_path.join("bin").join("python")
382 }
383 }
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
388pub enum EnsureOutcome {
389 Reused,
391 Built {
393 installed_requirements: bool,
396 },
397 SharedUpdate {
405 installed_requirements: bool,
407 },
408}
409
410#[derive(Debug, Clone)]
415pub struct VenvManager {
416 layout: VenvLayout,
417 requirements_file: Option<PathBuf>,
420 manifest_version: Option<String>,
422 class_name: String,
426 pip_timeout_secs: u64,
428}
429
430impl VenvManager {
431 pub fn new(
437 plugin_dirs: &[String],
438 class_name: &str,
439 requirements_file: Option<&str>,
440 manifest_version: Option<&str>,
441 ) -> Result<Self, HostError> {
442 let layout = VenvLayout::resolve(plugin_dirs, class_name)?;
443
444 let requirements_file = requirements_file.map(|rel| {
445 let path = Path::new(rel);
446 if path.is_absolute() {
447 path.to_path_buf()
448 } else {
449 layout.plugin_path.join(path)
450 }
451 });
452
453 Ok(Self {
454 layout,
455 requirements_file,
456 manifest_version: manifest_version.map(str::to_string),
457 class_name: class_name.to_string(),
458 pip_timeout_secs: DEFAULT_PIP_TIMEOUT_SECS,
459 })
460 }
461
462 pub fn with_pip_timeout_secs(mut self, secs: u64) -> Self {
469 self.pip_timeout_secs = secs;
470 self
471 }
472
473 pub fn pip_timeout_secs(&self) -> u64 {
475 self.pip_timeout_secs
476 }
477
478 pub fn layout(&self) -> &VenvLayout {
480 &self.layout
481 }
482
483 pub fn owners(&self) -> VenvOwners {
485 VenvOwners::load(&self.layout.owners_path)
486 }
487
488 pub fn python_executable(&self) -> PathBuf {
490 self.layout.python_executable()
491 }
492
493 pub fn cache_verdict(&self) -> CacheVerdict {
495 evaluate_cache(
496 &self.layout.venv_path,
497 &self.layout.metadata_path,
498 &hash_file_or_empty(self.requirements_file.as_deref()),
499 self.manifest_version.as_deref(),
500 Some(&hash_file_or_empty(Some(&self.layout.manifest_path))),
501 )
502 }
503
504 pub async fn ensure(&self) -> Result<EnsureOutcome, HostError> {
511 let verdict = self.cache_verdict();
512 if verdict.is_valid() {
513 tracing::info!(venv = %self.layout.venv_path.display(), "reusing cached venv");
514 self.register_owner().await?;
519 return Ok(EnsureOutcome::Reused);
520 }
521
522 let shared = self.venv_has_other_owner();
527 let venv_exists = self.layout.venv_path.exists();
528 let update_in_place = shared && venv_exists;
529
530 tracing::info!(
531 venv = %self.layout.venv_path.display(),
532 reason = ?verdict,
533 class_name = %self.class_name,
534 update_in_place,
535 "{}",
536 if update_in_place {
537 "updating shared venv in place — another plugin owns it"
538 } else {
539 "building venv"
540 }
541 );
542
543 self.prepare_directories(update_in_place).await?;
544 if !update_in_place {
545 self.create_venv().await?;
546 }
547
548 let installed_requirements = self.install_requirements().await?;
549 self.register_owner().await?;
552 self.save_metadata().await?;
553
554 if update_in_place {
555 return Ok(EnsureOutcome::SharedUpdate {
556 installed_requirements,
557 });
558 }
559 Ok(EnsureOutcome::Built {
560 installed_requirements,
561 })
562 }
563
564 fn venv_has_other_owner(&self) -> bool {
566 VenvOwners::load(&self.layout.owners_path).has_other_owner(&self.class_name)
567 }
568
569 async fn register_owner(&self) -> Result<(), HostError> {
578 let path = self.layout.owners_path.clone();
579 let cache_dir = self.layout.cache_dir.clone();
580 let class_name = self.class_name.clone();
581
582 tokio::task::spawn_blocking(move || {
583 let mut owners = VenvOwners::load(&path);
584 if !owners.owners.insert(class_name) {
585 return Ok(());
587 }
588
589 std::fs::create_dir_all(&cache_dir).map_err(|e| HostError::VenvBuild {
590 message: format!("could not create cache dir {}: {e}", cache_dir.display()),
591 })?;
592 let json = serde_json::to_string_pretty(&owners).map_err(|e| HostError::VenvBuild {
593 message: format!("could not serialize the venv owner registry: {e}"),
594 })?;
595 std::fs::write(&path, json).map_err(|e| HostError::VenvBuild {
596 message: format!("could not write venv owners {}: {e}", path.display()),
597 })
598 })
599 .await
600 .map_err(|e| HostError::VenvBuild {
601 message: format!("venv owner registration panicked: {e}"),
602 })?
603 }
604
605 pub async fn release_owner(&self) -> Result<(), HostError> {
612 let path = self.layout.owners_path.clone();
613 let class_name = self.class_name.clone();
614
615 tokio::task::spawn_blocking(move || {
616 let mut owners = VenvOwners::load(&path);
617 if !owners.owners.remove(&class_name) {
618 return Ok(());
619 }
620
621 if owners.owners.is_empty() {
625 if path.exists() {
626 std::fs::remove_file(&path).map_err(|e| HostError::VenvBuild {
627 message: format!("could not remove venv owners {}: {e}", path.display()),
628 })?;
629 }
630 return Ok(());
631 }
632
633 let json = serde_json::to_string_pretty(&owners).map_err(|e| HostError::VenvBuild {
634 message: format!("could not serialize the venv owner registry: {e}"),
635 })?;
636 std::fs::write(&path, json).map_err(|e| HostError::VenvBuild {
637 message: format!("could not write venv owners {}: {e}", path.display()),
638 })
639 })
640 .await
641 .map_err(|e| HostError::VenvBuild {
642 message: format!("venv owner release panicked: {e}"),
643 })?
644 }
645
646 async fn prepare_directories(&self, update_in_place: bool) -> Result<(), HostError> {
653 let venv_path = self.layout.venv_path.clone();
654 let cache_dir = self.layout.cache_dir.clone();
655 let plugin_path = self.layout.plugin_path.clone();
656
657 tokio::task::spawn_blocking(move || {
658 std::fs::create_dir_all(&plugin_path).map_err(|e| HostError::VenvBuild {
659 message: format!("could not create plugin dir {}: {e}", plugin_path.display()),
660 })?;
661 std::fs::create_dir_all(&cache_dir).map_err(|e| HostError::VenvBuild {
662 message: format!("could not create cache dir {}: {e}", cache_dir.display()),
663 })?;
664
665 if !update_in_place && venv_path.exists() {
671 std::fs::remove_dir_all(&venv_path).map_err(|e| HostError::VenvBuild {
672 message: format!("could not remove stale venv {}: {e}", venv_path.display()),
673 })?;
674 }
675 Ok(())
676 })
677 .await
678 .map_err(|e| HostError::VenvBuild {
679 message: format!("venv directory preparation panicked: {e}"),
680 })?
681 }
682
683 async fn create_venv(&self) -> Result<(), HostError> {
688 let output = tokio::process::Command::new("python3")
689 .arg("-m")
690 .arg("venv")
691 .arg(&self.layout.venv_path)
692 .output()
693 .await
694 .map_err(|e| HostError::VenvBuild {
695 message: format!("could not run `python3 -m venv`: {e} (is python3 on PATH?)"),
696 })?;
697
698 if !output.status.success() {
699 return Err(HostError::VenvCommand {
700 step: "python3 -m venv",
701 context: self.layout.venv_path.display().to_string(),
702 output: ProcessOutput::from_output(&output),
703 });
704 }
705 Ok(())
706 }
707
708 async fn install_requirements(&self) -> Result<bool, HostError> {
730 let Some(requirements) = self.requirements_file.as_ref().filter(|p| p.exists()) else {
731 tracing::info!("no requirements file; skipping install");
732 return Ok(false);
733 };
734
735 let python = self.python_executable();
736 let mut child = tokio::process::Command::new(&python)
737 .args(["-m", "pip", "install", "-r"])
738 .arg(requirements)
739 .stdin(std::process::Stdio::null())
740 .stdout(std::process::Stdio::null())
743 .stderr(std::process::Stdio::piped())
744 .kill_on_drop(true)
747 .spawn()
748 .map_err(|e| HostError::VenvBuild {
749 message: format!("could not run pip in {}: {e}", python.display()),
750 })?;
751
752 let stderr = child.stderr.take();
757 let stderr_task = tokio::spawn(async move {
758 let mut buf = Vec::new();
759 if let Some(mut stderr) = stderr {
760 use tokio::io::AsyncReadExt;
761 let _ = stderr.read_to_end(&mut buf).await;
762 }
763 buf
764 });
765
766 let timeout = std::time::Duration::from_secs(self.pip_timeout_secs);
767 let status = match tokio::time::timeout(timeout, child.wait()).await {
770 Ok(result) => result.map_err(|e| HostError::VenvBuild {
771 message: format!("could not wait on pip in {}: {e}", python.display()),
772 })?,
773
774 Err(_) => {
775 tracing::warn!(
776 timeout_secs = self.pip_timeout_secs,
777 requirements = %requirements.display(),
778 "pip install exceeded its timeout; killing it"
779 );
780
781 if let Err(e) = child.kill().await {
786 tracing::warn!("could not kill the timed-out pip process: {e}");
787 }
788 stderr_task.abort();
789
790 return Err(HostError::VenvBuild {
791 message: format!(
792 "pip install -r {} exceeded the {}s timeout and was killed — check \
793 network reachability to the package index, or raise the pip timeout",
794 requirements.display(),
795 self.pip_timeout_secs,
796 ),
797 });
798 },
799 };
800
801 if !status.success() {
802 let stderr = stderr_task.await.unwrap_or_default();
803 return Err(HostError::VenvCommand {
809 step: "pip install",
810 context: requirements.display().to_string(),
811 output: ProcessOutput {
812 exit_code: status.code(),
813 stderr: String::from_utf8_lossy(&stderr).trim().to_string(),
814 },
815 });
816 }
817 Ok(true)
818 }
819
820 async fn save_metadata(&self) -> Result<(), HostError> {
822 let metadata = CacheMetadata {
823 venv_path: self.layout.venv_path.display().to_string(),
824 requirements_file: self
825 .requirements_file
826 .as_ref()
827 .filter(|p| p.exists())
828 .map(|p| p.display().to_string()),
829 requirements_hash: hash_file_or_empty(self.requirements_file.as_deref()),
830 manifest_version: self.manifest_version.clone(),
831 manifest_hash: Some(hash_file_or_empty(Some(&self.layout.manifest_path))),
832 python_version: None,
833 };
834
835 let path = self.layout.metadata_path.clone();
836 let json = serde_json::to_string_pretty(&metadata).map_err(|e| HostError::VenvBuild {
837 message: format!("could not serialize cache metadata: {e}"),
838 })?;
839
840 tokio::task::spawn_blocking(move || {
841 std::fs::write(&path, json).map_err(|e| HostError::VenvBuild {
842 message: format!("could not write cache metadata {}: {e}", path.display()),
843 })
844 })
845 .await
846 .map_err(|e| HostError::VenvBuild {
847 message: format!("cache metadata write panicked: {e}"),
848 })?
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use crate::testing::TempDir;
856
857 const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
860
861 fn scaffold(dir: &TempDir, metadata: &str) -> (PathBuf, PathBuf) {
863 let venv = dir.path().join(".venv");
864 std::fs::create_dir_all(&venv).unwrap();
865 let metadata_path = dir.path().join("metadata.json");
866 std::fs::write(&metadata_path, metadata).unwrap();
867 (venv, metadata_path)
868 }
869
870 fn metadata_json(
871 requirements_hash: &str,
872 version: Option<&str>,
873 manifest_hash: Option<&str>,
874 ) -> String {
875 let mut obj = serde_json::json!({
876 "venv_path": "/tmp/.venv",
877 "requirements_file": null,
878 "requirements_hash": requirements_hash,
879 "python_version": "3.12.1",
880 });
881 if let Some(v) = version {
882 obj["manifest_version"] = serde_json::json!(v);
883 }
884 if let Some(h) = manifest_hash {
885 obj["manifest_hash"] = serde_json::json!(h);
886 }
887 serde_json::to_string(&obj).unwrap()
888 }
889
890 #[test]
893 fn absent_requirements_file_hashes_to_the_empty_digest() {
894 assert_eq!(hash_file_or_empty(None), EMPTY_SHA256);
897 assert_eq!(
898 hash_file_or_empty(Some(Path::new("/nonexistent/requirements.txt"))),
899 EMPTY_SHA256
900 );
901 }
902
903 #[test]
904 fn requirements_hash_tracks_content_not_path() {
905 let dir = TempDir::new();
906 let a = dir.path().join("a.txt");
907 let b = dir.path().join("b.txt");
908 std::fs::write(&a, "requests==2.31.0\n").unwrap();
909 std::fs::write(&b, "requests==2.31.0\n").unwrap();
910 assert_eq!(hash_file_or_empty(Some(&a)), hash_file_or_empty(Some(&b)));
911
912 std::fs::write(&b, "requests==2.32.0\n").unwrap();
913 assert_ne!(hash_file_or_empty(Some(&a)), hash_file_or_empty(Some(&b)));
914 }
915
916 #[test]
919 fn manifest_filename_matches_the_python_sanitization_rule() {
920 assert_eq!(
923 manifest_filename_for_class("pkg.module.ClassName"),
924 "pkg-module-ClassName.plugin-manifest.yaml"
925 );
926 assert_eq!(
927 manifest_filename_for_class("my_pkg.filters.PiiFilter"),
928 "my_pkg-filters-PiiFilter.plugin-manifest.yaml"
929 );
930 assert_eq!(
933 manifest_filename_for_class(" .A. "),
934 "A.plugin-manifest.yaml"
935 );
936 assert_eq!(
937 manifest_filename_for_class("..."),
938 "plugin.plugin-manifest.yaml"
939 );
940 }
941
942 #[test]
943 fn plugins_sharing_a_package_get_distinct_manifest_filenames() {
944 assert_ne!(
946 manifest_filename_for_class("pkg.a.PluginA"),
947 manifest_filename_for_class("pkg.b.PluginB")
948 );
949 }
950
951 #[test]
954 fn valid_when_every_present_signal_agrees() {
955 let dir = TempDir::new();
956 let (venv, meta) = scaffold(
957 &dir,
958 &metadata_json(EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
959 );
960
961 assert_eq!(
962 evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
963 CacheVerdict::Valid
964 );
965 }
966
967 #[test]
968 fn invalid_when_the_venv_directory_is_gone() {
969 let dir = TempDir::new();
970 let meta = dir.path().join("metadata.json");
971 std::fs::write(&meta, metadata_json(EMPTY_SHA256, None, None)).unwrap();
972
973 assert_eq!(
974 evaluate_cache(&dir.path().join(".venv"), &meta, EMPTY_SHA256, None, None),
975 CacheVerdict::VenvMissing
976 );
977 }
978
979 #[test]
980 fn invalid_when_metadata_is_absent() {
981 let dir = TempDir::new();
982 let venv = dir.path().join(".venv");
983 std::fs::create_dir_all(&venv).unwrap();
984
985 assert_eq!(
986 evaluate_cache(
987 &venv,
988 &dir.path().join("missing.json"),
989 EMPTY_SHA256,
990 None,
991 None
992 ),
993 CacheVerdict::MetadataMissing
994 );
995 }
996
997 #[test]
998 fn invalid_when_metadata_will_not_parse() {
999 let dir = TempDir::new();
1002 let (venv, meta) = scaffold(&dir, "{ this is not json");
1003
1004 assert_eq!(
1005 evaluate_cache(&venv, &meta, EMPTY_SHA256, None, None),
1006 CacheVerdict::MetadataUnreadable
1007 );
1008 }
1009
1010 #[test]
1011 fn changed_requirements_hash_invalidates() {
1012 let dir = TempDir::new();
1013 let (venv, meta) = scaffold(
1014 &dir,
1015 &metadata_json("old-hash", Some("1.0.0"), Some("abc123")),
1016 );
1017
1018 assert_eq!(
1019 evaluate_cache(&venv, &meta, "new-hash", Some("1.0.0"), Some("abc123")),
1020 CacheVerdict::RequirementsChanged
1021 );
1022 }
1023
1024 #[test]
1025 fn changed_manifest_version_invalidates_when_the_signal_is_present() {
1026 let dir = TempDir::new();
1027 let (venv, meta) = scaffold(
1028 &dir,
1029 &metadata_json(EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
1030 );
1031
1032 assert_eq!(
1033 evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("2.0.0"), Some("abc123")),
1034 CacheVerdict::ManifestVersionChanged
1035 );
1036 }
1037
1038 #[test]
1039 fn changed_manifest_hash_invalidates_when_the_signal_is_present() {
1040 let dir = TempDir::new();
1041 let (venv, meta) = scaffold(
1042 &dir,
1043 &metadata_json(EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
1044 );
1045
1046 assert_eq!(
1047 evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("def456")),
1048 CacheVerdict::ManifestHashChanged
1049 );
1050 }
1051
1052 #[test]
1053 fn absent_manifest_signals_do_not_invalidate() {
1054 let dir = TempDir::new();
1059 let (venv, meta) = scaffold(&dir, &metadata_json(EMPTY_SHA256, None, None));
1060
1061 assert_eq!(
1062 evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
1063 CacheVerdict::Valid,
1064 "absent manifest signals mean 'no signal', not 'mismatch'"
1065 );
1066 }
1067
1068 #[test]
1069 fn each_manifest_signal_is_evaluated_independently() {
1070 let dir = TempDir::new();
1074 let (venv, meta) = scaffold(&dir, &metadata_json(EMPTY_SHA256, Some("1.0.0"), None));
1075
1076 assert_eq!(
1077 evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("any-hash")),
1078 CacheVerdict::Valid,
1079 "an absent manifest_hash is skipped even when manifest_version is present"
1080 );
1081 assert_eq!(
1082 evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("9.9.9"), Some("any-hash")),
1083 CacheVerdict::ManifestVersionChanged,
1084 "the present signal is still enforced"
1085 );
1086 }
1087
1088 #[test]
1089 fn a_present_signal_against_an_unknown_expected_value_invalidates() {
1090 let dir = TempDir::new();
1093 let (venv, meta) = scaffold(&dir, &metadata_json(EMPTY_SHA256, Some("1.0.0"), None));
1094
1095 assert_eq!(
1096 evaluate_cache(&venv, &meta, EMPTY_SHA256, None, None),
1097 CacheVerdict::ManifestVersionChanged
1098 );
1099 }
1100
1101 #[test]
1102 fn requirements_are_checked_before_manifest_signals() {
1103 let dir = TempDir::new();
1107 let (venv, meta) = scaffold(&dir, &metadata_json("old", Some("1.0.0"), Some("abc")));
1108
1109 assert_eq!(
1110 evaluate_cache(&venv, &meta, "new", Some("2.0.0"), Some("def")),
1111 CacheVerdict::RequirementsChanged
1112 );
1113 }
1114
1115 #[test]
1116 fn metadata_round_trips_without_inventing_manifest_keys() {
1117 let metadata = CacheMetadata {
1121 venv_path: "/tmp/x/.venv".into(),
1122 requirements_file: None,
1123 requirements_hash: EMPTY_SHA256.into(),
1124 manifest_version: None,
1125 manifest_hash: None,
1126 python_version: Some("3.12.1".into()),
1127 };
1128 let json = serde_json::to_string(&metadata).unwrap();
1129 assert!(
1130 !json.contains("manifest_version"),
1131 "absent signals must stay absent: {json}"
1132 );
1133 assert!(!json.contains("manifest_hash"));
1134
1135 let parsed: CacheMetadata = serde_json::from_str(&json).unwrap();
1136 assert!(parsed.manifest_version.is_none());
1137 }
1138
1139 #[test]
1142 fn layout_is_keyed_on_the_class_root_so_a_package_shares_one_venv() {
1143 let dirs = vec!["/plugins".to_string()];
1144 let a = VenvLayout::resolve(&dirs, "pkg.a.PluginA").unwrap();
1145 let b = VenvLayout::resolve(&dirs, "pkg.b.PluginB").unwrap();
1146
1147 assert_eq!(a.venv_path, PathBuf::from("/plugins/pkg/.venv"));
1148 assert_eq!(a.venv_path, b.venv_path, "one venv per package root");
1149 assert_eq!(a.cache_dir, PathBuf::from("/plugins/pkg/.cpex/venv_cache"));
1150
1151 assert_ne!(a.manifest_path, b.manifest_path);
1153 }
1154
1155 #[test]
1156 fn layout_uses_the_first_plugin_dir() {
1157 let dirs = vec!["/first".to_string(), "/second".to_string()];
1158 let layout = VenvLayout::resolve(&dirs, "pkg.Plugin").unwrap();
1159 assert_eq!(layout.plugin_path, PathBuf::from("/first/pkg"));
1160 }
1161
1162 #[test]
1163 fn layout_requires_at_least_one_plugin_dir() {
1164 let err = VenvLayout::resolve(&[], "pkg.Plugin").unwrap_err();
1165 assert!(matches!(err, HostError::Config { .. }));
1166 assert!(
1167 err.to_string().contains("plugin directory"),
1168 "the message should explain what is missing: {err}"
1169 );
1170 }
1171
1172 #[test]
1173 fn metadata_filename_is_keyed_on_the_full_class_name() {
1174 let layout = VenvLayout::resolve(&["/plugins".to_string()], "pkg.Plugin").unwrap();
1178 assert_eq!(
1179 layout.metadata_path,
1180 PathBuf::from("/plugins/pkg/.cpex/venv_cache/pkg-Plugin_metadata.json")
1181 );
1182 }
1183
1184 #[test]
1185 fn plugins_sharing_a_package_get_distinct_metadata_files() {
1186 let dirs = vec!["/plugins".to_string()];
1190 let a = VenvLayout::resolve(&dirs, "pkg.a.PluginA").unwrap();
1191 let b = VenvLayout::resolve(&dirs, "pkg.b.PluginB").unwrap();
1192 assert_eq!(a.venv_path, b.venv_path, "still one shared venv");
1193 assert_ne!(a.metadata_path, b.metadata_path);
1194 }
1195
1196 #[test]
1197 fn python_executable_sits_inside_the_venv() {
1198 let layout = VenvLayout::resolve(&["/plugins".to_string()], "pkg.Plugin").unwrap();
1199 let exe = layout.python_executable();
1200 assert!(exe.starts_with(&layout.venv_path));
1201 assert!(exe.ends_with(if cfg!(windows) {
1202 "python.exe"
1203 } else {
1204 "python"
1205 }));
1206 }
1207
1208 fn manager_in(dir: &TempDir, class_name: &str, requirements: Option<&str>) -> VenvManager {
1211 VenvManager::new(
1212 &[dir.path().display().to_string()],
1213 class_name,
1214 requirements,
1215 Some("1.0.0"),
1216 )
1217 .expect("layout resolves")
1218 }
1219
1220 #[test]
1221 fn relative_requirements_paths_resolve_under_the_plugin_path() {
1222 let dir = TempDir::new();
1223 let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt"));
1224 assert_eq!(
1225 mgr.requirements_file.as_deref(),
1226 Some(dir.path().join("pkg").join("requirements.txt").as_path())
1227 );
1228 }
1229
1230 #[test]
1231 fn absolute_requirements_paths_are_left_alone() {
1232 let dir = TempDir::new();
1233 let mgr = manager_in(&dir, "pkg.Plugin", Some("/etc/shared/requirements.txt"));
1234 assert_eq!(
1235 mgr.requirements_file.as_deref(),
1236 Some(Path::new("/etc/shared/requirements.txt"))
1237 );
1238 }
1239
1240 #[test]
1241 fn a_missing_venv_reports_an_invalid_cache() {
1242 let dir = TempDir::new();
1243 let mgr = manager_in(&dir, "pkg.Plugin", None);
1244 assert_eq!(mgr.cache_verdict(), CacheVerdict::VenvMissing);
1245 }
1246
1247 #[tokio::test]
1248 async fn ensure_builds_then_reuses_the_venv() {
1249 if crate::testing::skip_without_python3("ensure_builds_then_reuses_the_venv") {
1252 return;
1253 }
1254 let dir = TempDir::new();
1255 let mgr = manager_in(&dir, "pkg.Plugin", None);
1256
1257 let first = mgr.ensure().await.expect("venv builds");
1258 assert_eq!(
1259 first,
1260 EnsureOutcome::Built {
1261 installed_requirements: false
1262 },
1263 "no requirements file means a fresh venv with no install"
1264 );
1265 assert!(
1266 mgr.python_executable().exists(),
1267 "the interpreter must exist after a build"
1268 );
1269
1270 let second = mgr.ensure().await.expect("cached venv is reusable");
1271 assert_eq!(
1272 second,
1273 EnsureOutcome::Reused,
1274 "unchanged inputs must not rebuild"
1275 );
1276 }
1277
1278 #[tokio::test]
1279 async fn a_changed_requirements_hash_forces_a_rebuild() {
1280 if crate::testing::skip_without_python3("a_changed_requirements_hash_forces_a_rebuild") {
1281 return;
1282 }
1283 let dir = TempDir::new();
1284 std::fs::create_dir_all(dir.path().join("pkg")).unwrap();
1285 let requirements = dir.path().join("pkg").join("requirements.txt");
1286 std::fs::write(&requirements, "").unwrap();
1289
1290 let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt"));
1291 assert!(matches!(
1292 mgr.ensure().await.unwrap(),
1293 EnsureOutcome::Built { .. }
1294 ));
1295 assert_eq!(mgr.ensure().await.unwrap(), EnsureOutcome::Reused);
1296
1297 std::fs::write(&requirements, "# a comment changes the hash\n").unwrap();
1299 assert_eq!(mgr.cache_verdict(), CacheVerdict::RequirementsChanged);
1300 assert!(matches!(
1301 mgr.ensure().await.unwrap(),
1302 EnsureOutcome::Built { .. }
1303 ));
1304 }
1305
1306 #[tokio::test]
1307 async fn an_absent_requirements_file_builds_and_skips_install() {
1308 if crate::testing::skip_without_python3(
1309 "an_absent_requirements_file_builds_and_skips_install",
1310 ) {
1311 return;
1312 }
1313 let dir = TempDir::new();
1314 let mgr = manager_in(&dir, "pkg.Plugin", Some("does-not-exist.txt"));
1316
1317 assert_eq!(
1318 mgr.ensure()
1319 .await
1320 .expect("a missing requirements file is not fatal"),
1321 EnsureOutcome::Built {
1322 installed_requirements: false
1323 }
1324 );
1325 }
1326
1327 #[tokio::test]
1328 async fn two_plugins_sharing_a_package_do_not_thrash_each_others_cache() {
1329 if crate::testing::skip_without_python3(
1332 "two_plugins_sharing_a_package_do_not_thrash_each_others_cache",
1333 ) {
1334 return;
1335 }
1336 let dir = TempDir::new();
1337 let a = manager_in(&dir, "pkg.a.PluginA", None);
1338 let b = manager_in(&dir, "pkg.b.PluginB", None);
1339
1340 assert_eq!(
1341 a.layout().venv_path,
1342 b.layout().venv_path,
1343 "one shared venv"
1344 );
1345 assert_ne!(a.layout().metadata_path, b.layout().metadata_path);
1346
1347 assert!(matches!(
1348 a.ensure().await.unwrap(),
1349 EnsureOutcome::Built { .. }
1350 ));
1351 assert_eq!(a.ensure().await.unwrap(), EnsureOutcome::Reused);
1352
1353 assert!(matches!(
1357 b.ensure().await.unwrap(),
1358 EnsureOutcome::SharedUpdate { .. }
1359 ));
1360 assert_eq!(
1363 a.ensure().await.unwrap(),
1364 EnsureOutcome::Reused,
1365 "B's build must not invalidate A"
1366 );
1367 assert_eq!(b.ensure().await.unwrap(), EnsureOutcome::Reused);
1368 }
1369
1370 #[test]
1373 fn the_owners_file_is_shared_by_every_plugin_in_the_package() {
1374 let dirs = vec!["/plugins".to_string()];
1379 let a = VenvLayout::resolve(&dirs, "pkg.a.PluginA").unwrap();
1380 let b = VenvLayout::resolve(&dirs, "pkg.b.PluginB").unwrap();
1381
1382 assert_eq!(a.venv_path, b.venv_path, "one shared venv");
1383 assert_eq!(
1384 a.owners_path, b.owners_path,
1385 "co-owners of one venv must read the same registry"
1386 );
1387 assert_eq!(
1388 a.owners_path,
1389 PathBuf::from("/plugins/pkg/.cpex/venv_cache/owners.json")
1390 );
1391 }
1392
1393 #[test]
1394 fn a_missing_or_corrupt_owner_registry_reads_as_empty() {
1395 let dir = TempDir::new();
1399 assert!(
1400 VenvOwners::load(&dir.path().join("nope.json"))
1401 .owners
1402 .is_empty()
1403 );
1404
1405 let corrupt = dir.path().join("owners.json");
1406 std::fs::write(&corrupt, "{ not json").unwrap();
1407 assert!(VenvOwners::load(&corrupt).owners.is_empty());
1408 }
1409
1410 #[test]
1411 fn has_other_owner_ignores_the_caller_itself() {
1412 let mut owners = VenvOwners::default();
1413 owners.owners.insert("pkg.a.PluginA".to_string());
1414
1415 assert!(
1416 !owners.has_other_owner("pkg.a.PluginA"),
1417 "a plugin re-initializing is still the sole owner and may rebuild"
1418 );
1419 assert!(
1420 owners.has_other_owner("pkg.b.PluginB"),
1421 "a different plugin in the same package is a co-owner"
1422 );
1423 }
1424
1425 #[tokio::test]
1426 async fn a_cache_hit_still_claims_ownership_of_the_venv() {
1427 if crate::testing::skip_without_python3("a_cache_hit_still_claims_ownership_of_the_venv") {
1431 return;
1432 }
1433 let dir = TempDir::new();
1434 let mgr = manager_in(&dir, "pkg.Plugin", None);
1435
1436 mgr.ensure().await.unwrap();
1437 assert!(mgr.owners().owners.contains("pkg.Plugin"));
1438
1439 mgr.release_owner().await.unwrap();
1441 assert!(mgr.owners().owners.is_empty());
1442
1443 assert_eq!(mgr.ensure().await.unwrap(), EnsureOutcome::Reused);
1444 assert!(
1445 mgr.owners().owners.contains("pkg.Plugin"),
1446 "the reuse path must register ownership too"
1447 );
1448 }
1449
1450 #[tokio::test]
1451 async fn a_second_plugin_rebuilding_does_not_delete_the_shared_venv() {
1452 if crate::testing::skip_without_python3(
1457 "a_second_plugin_rebuilding_does_not_delete_the_shared_venv",
1458 ) {
1459 return;
1460 }
1461 let dir = TempDir::new();
1462 let a = manager_in(&dir, "pkg.a.PluginA", None);
1463 let b = manager_in(&dir, "pkg.b.PluginB", None);
1464 assert_eq!(a.layout().venv_path, b.layout().venv_path, "one venv");
1465
1466 assert!(matches!(
1468 a.ensure().await.unwrap(),
1469 EnsureOutcome::Built { .. }
1470 ));
1471 assert!(a.python_executable().exists());
1472
1473 let marker = a.layout().venv_path.join("a-was-here.marker");
1477 std::fs::write(&marker, "A's interpreter tree").unwrap();
1478
1479 assert!(!b.cache_verdict().is_valid());
1482 assert_eq!(
1483 b.ensure().await.unwrap(),
1484 EnsureOutcome::SharedUpdate {
1485 installed_requirements: false
1486 },
1487 "a co-owned venv must be updated in place, not recreated"
1488 );
1489
1490 assert!(
1491 marker.exists(),
1492 "B's build deleted the shared venv out from under A"
1493 );
1494 assert!(
1495 a.python_executable().exists(),
1496 "A's interpreter must survive B's build"
1497 );
1498
1499 let owners = a.owners().owners;
1501 assert!(owners.contains("pkg.a.PluginA"), "owners: {owners:?}");
1502 assert!(owners.contains("pkg.b.PluginB"), "owners: {owners:?}");
1503
1504 assert_eq!(a.ensure().await.unwrap(), EnsureOutcome::Reused);
1506 assert_eq!(b.ensure().await.unwrap(), EnsureOutcome::Reused);
1507 }
1508
1509 #[tokio::test]
1510 async fn the_sole_owner_still_gets_a_destructive_rebuild() {
1511 if crate::testing::skip_without_python3("the_sole_owner_still_gets_a_destructive_rebuild") {
1515 return;
1516 }
1517 let dir = TempDir::new();
1518 let mgr = manager_in(&dir, "pkg.Plugin", None);
1519 mgr.ensure().await.unwrap();
1520
1521 let stale = mgr.layout().venv_path.join("stale-dependency.marker");
1522 std::fs::write(&stale, "removed from requirements").unwrap();
1523
1524 std::fs::write(&mgr.layout().manifest_path, "version: 1.0.1\n").unwrap();
1526 assert_eq!(mgr.cache_verdict(), CacheVerdict::ManifestHashChanged);
1527
1528 assert!(
1529 matches!(mgr.ensure().await.unwrap(), EnsureOutcome::Built { .. }),
1530 "the sole owner rebuilds rather than updating in place"
1531 );
1532 assert!(
1533 !stale.exists(),
1534 "a sole-owner rebuild must clear the old venv contents"
1535 );
1536 }
1537
1538 #[tokio::test]
1539 async fn releasing_the_last_owner_restores_destructive_rebuilds() {
1540 if crate::testing::skip_without_python3(
1543 "releasing_the_last_owner_restores_destructive_rebuilds",
1544 ) {
1545 return;
1546 }
1547 let dir = TempDir::new();
1548 let a = manager_in(&dir, "pkg.a.PluginA", None);
1549 let b = manager_in(&dir, "pkg.b.PluginB", None);
1550
1551 a.ensure().await.unwrap();
1552 b.ensure().await.unwrap();
1553 assert_eq!(a.owners().owners.len(), 2);
1554
1555 a.release_owner().await.unwrap();
1557 assert!(!a.owners().has_other_owner("pkg.b.PluginB"));
1558
1559 let stale = b.layout().venv_path.join("stale.marker");
1561 std::fs::write(&stale, "x").unwrap();
1562 std::fs::write(&b.layout().manifest_path, "version: 2.0.0\n").unwrap();
1563
1564 assert!(matches!(
1565 b.ensure().await.unwrap(),
1566 EnsureOutcome::Built { .. }
1567 ));
1568 assert!(!stale.exists(), "the sole remaining owner rebuilds fully");
1569
1570 b.release_owner().await.unwrap();
1572 assert!(!b.layout().owners_path.exists());
1573 }
1574
1575 #[test]
1578 fn the_default_pip_timeout_is_applied_and_overridable() {
1579 let dir = TempDir::new();
1580 let mgr = manager_in(&dir, "pkg.Plugin", None);
1581 assert_eq!(mgr.pip_timeout_secs(), DEFAULT_PIP_TIMEOUT_SECS);
1582 assert_eq!(mgr.with_pip_timeout_secs(3).pip_timeout_secs(), 3);
1583 }
1584
1585 #[tokio::test]
1586 async fn a_hanging_pip_install_times_out_and_kills_the_child() {
1587 if crate::testing::skip_without_python3(
1595 "a_hanging_pip_install_times_out_and_kills_the_child",
1596 ) {
1597 return;
1598 }
1599 let dir = TempDir::new();
1600 std::fs::create_dir_all(dir.path().join("pkg")).unwrap();
1601 std::fs::write(dir.path().join("pkg").join("requirements.txt"), "").unwrap();
1602
1603 let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt")).with_pip_timeout_secs(1);
1604
1605 mgr.prepare_directories(false).await.expect("dirs prepared");
1608 mgr.create_venv().await.expect("the venv builds");
1609
1610 let site = {
1614 let lib = mgr.layout().venv_path.join("lib");
1615 std::fs::read_dir(&lib)
1616 .expect("venv lib dir")
1617 .flatten()
1618 .map(|e| e.path().join("site-packages"))
1619 .find(|p| p.exists())
1620 .expect("site-packages exists in a built venv")
1621 };
1622 let pip_pkg = site.join("pip");
1623 std::fs::create_dir_all(&pip_pkg).unwrap();
1624 std::fs::write(pip_pkg.join("__init__.py"), "").unwrap();
1625 let pidfile = dir.path().join("pip.pid");
1629 std::fs::write(
1630 pip_pkg.join("__main__.py"),
1631 format!(
1632 "import os, time\nopen({:?}, 'w').write(str(os.getpid()))\ntime.sleep(300)\n",
1633 pidfile.display().to_string()
1634 ),
1635 )
1636 .unwrap();
1637
1638 let started = std::time::Instant::now();
1639 let err = mgr
1640 .install_requirements()
1641 .await
1642 .expect_err("a pip that never returns must not stall startup forever");
1643 let elapsed = started.elapsed();
1644
1645 assert!(
1646 matches!(err, HostError::VenvBuild { .. }),
1647 "a pip timeout is a venv build failure: {err:?}"
1648 );
1649 let message = err.to_string();
1650 assert!(
1651 message.contains("timeout"),
1652 "the error must name the timeout so an operator can raise it: {message}"
1653 );
1654
1655 assert!(
1657 elapsed < std::time::Duration::from_secs(30),
1658 "the install must return on the timeout, not on pip's own completion: {elapsed:?}"
1659 );
1660
1661 let pid: i32 = std::fs::read_to_string(&pidfile)
1665 .expect("the fake pip recorded its pid")
1666 .trim()
1667 .parse()
1668 .expect("pid is numeric");
1669 let mut alive = true;
1673 for _ in 0..40 {
1674 let status = std::process::Command::new("kill")
1675 .args(["-0", &pid.to_string()])
1676 .stdout(std::process::Stdio::null())
1677 .stderr(std::process::Stdio::null())
1678 .status()
1679 .expect("kill -0 runs");
1680 if !status.success() {
1681 alive = false;
1682 break;
1683 }
1684 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1685 }
1686 assert!(
1687 !alive,
1688 "the timed-out pip process (pid {pid}) was leaked rather than killed"
1689 );
1690 }
1691
1692 #[tokio::test]
1693 async fn a_failed_pip_install_carries_its_exit_code_and_stderr_as_details() {
1694 if crate::testing::skip_without_python3(
1699 "a_failed_pip_install_carries_its_exit_code_and_stderr_as_details",
1700 ) {
1701 return;
1702 }
1703 let dir = TempDir::new();
1704 std::fs::create_dir_all(dir.path().join("pkg")).unwrap();
1705 std::fs::write(dir.path().join("pkg").join("requirements.txt"), "").unwrap();
1706
1707 let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt"));
1708 mgr.prepare_directories(false).await.expect("dirs prepared");
1709 mgr.create_venv().await.expect("the venv builds");
1710
1711 let site = {
1714 let lib = mgr.layout().venv_path.join("lib");
1715 std::fs::read_dir(&lib)
1716 .expect("venv lib dir")
1717 .flatten()
1718 .map(|e| e.path().join("site-packages"))
1719 .find(|p| p.exists())
1720 .expect("site-packages exists in a built venv")
1721 };
1722 let pip_pkg = site.join("pip");
1723 std::fs::create_dir_all(&pip_pkg).unwrap();
1724 std::fs::write(pip_pkg.join("__init__.py"), "").unwrap();
1725 std::fs::write(
1726 pip_pkg.join("__main__.py"),
1727 "import sys\nsys.stderr.write('ERROR: No matching distribution found for nope\\n')\n\
1728 sys.exit(7)\n",
1729 )
1730 .unwrap();
1731
1732 let err = mgr
1733 .install_requirements()
1734 .await
1735 .expect_err("a non-zero pip exit is a build failure");
1736
1737 let details = err.details();
1738 assert_eq!(
1739 details.get("exit_code").and_then(|v| v.as_i64()),
1740 Some(7),
1741 "pip's exit code must reach details, not just the prose: {details:?}"
1742 );
1743 assert_eq!(
1744 details.get("step").and_then(|v| v.as_str()),
1745 Some("pip install"),
1746 "the failing step must be named so a caller can branch on it: {details:?}"
1747 );
1748 let stderr = details
1749 .get("stderr_excerpt")
1750 .and_then(|v| v.as_str())
1751 .unwrap_or_default();
1752 assert!(
1753 stderr.contains("No matching distribution"),
1754 "pip's own diagnosis must survive into details: {stderr:?}"
1755 );
1756 assert_eq!(
1757 err.code(),
1758 "venv_build_failed",
1759 "the stable code stays the same as the message-only variant's"
1760 );
1761 }
1762
1763 #[tokio::test]
1764 async fn a_manifest_edit_invalidates_the_cache() {
1765 if crate::testing::skip_without_python3("a_manifest_edit_invalidates_the_cache") {
1766 return;
1767 }
1768 let dir = TempDir::new();
1769 let mgr = manager_in(&dir, "pkg.Plugin", None);
1770 assert!(matches!(
1771 mgr.ensure().await.unwrap(),
1772 EnsureOutcome::Built { .. }
1773 ));
1774 assert_eq!(mgr.ensure().await.unwrap(), EnsureOutcome::Reused);
1775
1776 std::fs::write(&mgr.layout().manifest_path, "version: 1.0.1\n").unwrap();
1779 assert_eq!(mgr.cache_verdict(), CacheVerdict::ManifestHashChanged);
1780 }
1781
1782 #[tokio::test]
1783 async fn a_declared_version_bump_invalidates_the_cache() {
1784 if crate::testing::skip_without_python3("a_declared_version_bump_invalidates_the_cache") {
1785 return;
1786 }
1787 let dir = TempDir::new();
1788 let v1 = manager_in(&dir, "pkg.Plugin", None);
1789 assert!(matches!(
1790 v1.ensure().await.unwrap(),
1791 EnsureOutcome::Built { .. }
1792 ));
1793
1794 let v2 = VenvManager::new(
1796 &[dir.path().display().to_string()],
1797 "pkg.Plugin",
1798 None,
1799 Some("2.0.0"),
1800 )
1801 .unwrap();
1802 assert_eq!(v2.cache_verdict(), CacheVerdict::ManifestVersionChanged);
1803 }
1804
1805 #[tokio::test]
1806 async fn metadata_written_by_this_host_is_a_cache_hit_for_it() {
1807 if crate::testing::skip_without_python3(
1811 "metadata_written_by_this_host_is_a_cache_hit_for_it",
1812 ) {
1813 return;
1814 }
1815 let dir = TempDir::new();
1816 let mgr = manager_in(&dir, "pkg.Plugin", None);
1817 mgr.ensure().await.unwrap();
1818
1819 let raw = std::fs::read_to_string(&mgr.layout().metadata_path).unwrap();
1820 let parsed: CacheMetadata =
1821 serde_json::from_str(&raw).expect("our own metadata must parse");
1822 assert_eq!(parsed.manifest_version.as_deref(), Some("1.0.0"));
1823 assert!(parsed.manifest_hash.is_some());
1824 assert_eq!(mgr.cache_verdict(), CacheVerdict::Valid);
1825 }
1826}