1use serde::{Deserialize, Serialize};
24use serde_json::json;
25use std::path::{Path, PathBuf};
26use std::process::ExitCode;
27
28use crate::api::Output;
29use substrate::{CmnEntry, CmnUri};
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct FetchStatus {
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub fetched_at_epoch_ms: Option<u64>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub failed_at_epoch_ms: Option<u64>,
38 #[serde(default)]
39 pub retry_count: u32,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub error: Option<String>,
42}
43
44impl FetchStatus {
45 pub fn success() -> Self {
46 Self {
47 fetched_at_epoch_ms: Some(crate::time::now_epoch_ms()),
48 failed_at_epoch_ms: None,
49 retry_count: 0,
50 error: None,
51 }
52 }
53
54 pub fn failure(error: &str, previous: Option<&FetchStatus>) -> Self {
55 Self {
56 fetched_at_epoch_ms: previous.and_then(|p| p.fetched_at_epoch_ms),
57 failed_at_epoch_ms: Some(crate::time::now_epoch_ms()),
58 retry_count: previous.map(|p| p.retry_count + 1).unwrap_or(1),
59 error: Some(error.to_string()),
60 }
61 }
62
63 pub fn is_fresh(&self, ttl_ms: u64) -> bool {
65 match self.fetched_at_epoch_ms {
66 Some(ts) => crate::time::now_epoch_ms().saturating_sub(ts) < ttl_ms,
67 None => false,
68 }
69 }
70}
71
72pub type TasteVerdictCache = substrate::TasteVerdictRecord;
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct KeyTrustEntry {
78 pub key: String,
79 pub confirmed_at_epoch_ms: u64,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, Default)]
84pub struct CacheStatus {
85 #[serde(default)]
86 pub cmn: FetchStatus,
87 #[serde(default)]
88 pub mycelium: FetchStatus,
89}
90
91pub struct CacheDir {
93 pub root: PathBuf,
94 pub cmn_ttl_ms: u64,
95 pub max_download_bytes: u64,
96 pub max_extract_bytes: u64,
97 pub max_extract_files: u64,
98 pub max_extract_file_bytes: u64,
99}
100
101impl CacheDir {
102 pub fn new() -> Self {
104 let cfg = crate::config::HyphaConfig::load();
105
106 let root = match &cfg.cache.path {
107 Some(p) => PathBuf::from(p),
108 None => crate::config::hypha_dir().join("cache"),
109 };
110
111 if !root.exists() {
113 let _ = std::fs::create_dir_all(&root);
114 #[cfg(unix)]
115 {
116 use std::os::unix::fs::PermissionsExt;
117 let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700));
118 }
119 }
120
121 Self {
122 root,
123 cmn_ttl_ms: cfg.cache.cmn_ttl_s * 1000,
124 max_download_bytes: cfg.cache.max_download_bytes,
125 max_extract_bytes: cfg.cache.max_extract_bytes,
126 max_extract_files: cfg.cache.max_extract_files,
127 max_extract_file_bytes: cfg.cache.max_extract_file_bytes,
128 }
129 }
130
131 pub fn domain(&self, domain: &str) -> DomainCache {
133 DomainCache {
134 root: self.root.join(domain),
135 domain: domain.to_string(),
136 }
137 }
138
139 pub fn spore_path(&self, domain: &str, hash: &str) -> PathBuf {
142 self.domain(domain).spore_path(hash)
143 }
144
145 pub fn list_all(&self) -> Vec<CachedSpore> {
147 let mut spores = Vec::new();
148
149 if !self.root.exists() {
150 return spores;
151 }
152
153 if let Ok(domains) = std::fs::read_dir(&self.root) {
155 for domain_entry in domains.filter_map(|e| e.ok()) {
156 let domain_path = domain_entry.path();
157 if !domain_path.is_dir() {
158 continue;
159 }
160
161 let domain = domain_entry.file_name().to_string_lossy().to_string();
162 let domain_cache = self.domain(&domain);
163
164 let spore_dir = domain_cache.spore_dir();
166 if let Ok(hashes) = std::fs::read_dir(&spore_dir) {
167 for hash_entry in hashes.filter_map(|e| e.ok()) {
168 let hash_path = hash_entry.path();
169 if !hash_path.is_dir() {
170 continue;
171 }
172
173 let hash_dir = hash_entry.file_name().to_string_lossy().to_string();
174 let hash = hash_dir.replace('_', ":");
175
176 let manifest_path = hash_path.join("spore.json");
178 let (name, synopsis) = read_spore_metadata(&manifest_path);
179
180 let verdict = {
182 let taste_path = hash_path.join("taste.json");
183 if taste_path.exists() {
184 std::fs::read_to_string(&taste_path)
185 .ok()
186 .and_then(|s| {
187 serde_json::from_str::<TasteVerdictCache>(&s).ok()
188 })
189 .map(|v| v.verdict)
190 } else {
191 None
192 }
193 };
194
195 let size = dir_size(&hash_path);
197
198 spores.push(CachedSpore {
199 domain: domain.clone(),
200 hash,
201 name,
202 synopsis,
203 path: hash_path,
204 size,
205 verdict,
206 });
207 }
208 }
209 }
210 }
211
212 spores
213 }
214
215 pub fn clean_all(&self) -> Result<usize, crate::sink::HyphaError> {
217 use crate::sink::HyphaError;
218 if !self.root.exists() {
219 return Ok(0);
220 }
221
222 let spores = self.list_all();
223 let count = spores.len();
224
225 std::fs::remove_dir_all(&self.root).map_err(|e| {
226 HyphaError::new(
227 "cache_clean_failed",
228 format!("Failed to remove cache directory: {}", e),
229 )
230 })?;
231
232 Ok(count)
233 }
234}
235
236impl Default for CacheDir {
237 fn default() -> Self {
238 Self::new()
239 }
240}
241
242impl CacheDir {
243 #[cfg(test)]
245 pub fn with_root(root: PathBuf) -> Self {
246 Self {
247 root,
248 cmn_ttl_ms: 300 * 1000,
249 max_download_bytes: 1024 * 1024 * 1024,
250 max_extract_bytes: 512 * 1024 * 1024,
251 max_extract_files: 100_000,
252 max_extract_file_bytes: 256 * 1024 * 1024,
253 }
254 }
255}
256
257fn locked_write_file(path: &std::path::Path, content: &str) -> Result<(), crate::sink::HyphaError> {
260 use crate::sink::HyphaError;
261 use fs2::FileExt;
262
263 let parent = path.parent().ok_or_else(|| {
264 HyphaError::new("cache_write_failed", "Cannot determine parent directory")
265 })?;
266 std::fs::create_dir_all(parent).map_err(|e| {
267 HyphaError::new(
268 "cache_write_failed",
269 format!("Failed to create directory: {}", e),
270 )
271 })?;
272
273 let lock_path = parent.join(".lock");
274 let lock_file = std::fs::OpenOptions::new()
275 .create(true)
276 .write(true)
277 .truncate(true)
278 .open(&lock_path)
279 .map_err(|e| {
280 HyphaError::new(
281 "cache_write_failed",
282 format!("Failed to open lock file: {}", e),
283 )
284 })?;
285
286 lock_file.lock_exclusive().map_err(|e| {
287 HyphaError::new(
288 "cache_write_failed",
289 format!("Failed to acquire lock: {}", e),
290 )
291 })?;
292
293 let result = atomic_write_file(path, content);
294
295 let _ = lock_file.unlock();
296 result
297}
298
299fn atomic_write_file(path: &std::path::Path, content: &str) -> Result<(), crate::sink::HyphaError> {
302 use crate::sink::HyphaError;
303 use std::io::Write;
304
305 let parent = path.parent().ok_or_else(|| {
306 HyphaError::new("cache_write_failed", "Cannot determine parent directory")
307 })?;
308
309 let tmp_path = parent.join(format!(
310 ".tmp.{}",
311 std::time::SystemTime::now()
312 .duration_since(std::time::UNIX_EPOCH)
313 .unwrap_or_default()
314 .as_nanos()
315 ));
316
317 let mut f = std::fs::File::create(&tmp_path).map_err(|e| {
318 HyphaError::new(
319 "cache_write_failed",
320 format!("Failed to create temp file: {}", e),
321 )
322 })?;
323 f.write_all(content.as_bytes()).map_err(|e| {
324 let _ = std::fs::remove_file(&tmp_path);
325 HyphaError::new(
326 "cache_write_failed",
327 format!("Failed to write temp file: {}", e),
328 )
329 })?;
330 f.sync_all().map_err(|e| {
331 let _ = std::fs::remove_file(&tmp_path);
332 HyphaError::new(
333 "cache_write_failed",
334 format!("Failed to sync temp file: {}", e),
335 )
336 })?;
337 drop(f);
338
339 std::fs::rename(&tmp_path, path).map_err(|e| {
340 let _ = std::fs::remove_file(&tmp_path);
341 HyphaError::new(
342 "cache_write_failed",
343 format!("Failed to rename temp file: {}", e),
344 )
345 })
346}
347
348pub struct DomainCache {
350 pub root: PathBuf,
351 pub domain: String,
352}
353
354impl DomainCache {
355 pub fn mycelium_dir(&self) -> PathBuf {
357 self.root.join("mycelium")
358 }
359
360 pub fn spore_dir(&self) -> PathBuf {
362 self.root.join("spore")
363 }
364
365 pub fn spore_path(&self, hash: &str) -> PathBuf {
367 self.spore_dir().join(hash)
368 }
369
370 pub fn repos_dir(&self) -> PathBuf {
374 self.root.join("repos")
375 }
376
377 pub fn repo_path(&self, root_commit: &str) -> PathBuf {
382 self.repos_dir().join(root_commit)
383 }
384
385 pub fn cmn_path(&self) -> PathBuf {
389 self.mycelium_dir().join("cmn.json")
390 }
391
392 pub fn load_cmn(&self) -> Option<CmnEntry> {
394 let path = self.cmn_path();
395 if path.exists() {
396 std::fs::read_to_string(&path)
397 .ok()
398 .and_then(|s| serde_json::from_str(&s).ok())
399 } else {
400 None
401 }
402 }
403
404 pub fn save_cmn(&self, entry: &CmnEntry) -> Result<(), crate::sink::HyphaError> {
406 use crate::sink::HyphaError;
407 let dir = self.mycelium_dir();
408 std::fs::create_dir_all(&dir).map_err(|e| {
409 HyphaError::new(
410 "cache_write_failed",
411 format!("Failed to create mycelium dir: {}", e),
412 )
413 })?;
414
415 let content = serde_json::to_string_pretty(entry).map_err(|e| {
416 HyphaError::new(
417 "cache_write_failed",
418 format!("Failed to serialize cmn entry: {}", e),
419 )
420 })?;
421
422 locked_write_file(&self.cmn_path(), &content)
423 }
424
425 pub fn mycelium_path(&self) -> PathBuf {
431 self.mycelium_dir().join("mycelium.json")
432 }
433
434 pub fn load_mycelium(&self) -> Option<serde_json::Value> {
436 let path = self.mycelium_path();
437 if path.exists() {
438 std::fs::read_to_string(&path)
439 .ok()
440 .and_then(|s| serde_json::from_str(&s).ok())
441 } else {
442 None
443 }
444 }
445
446 pub fn save_mycelium(
448 &self,
449 mycelium: &serde_json::Value,
450 ) -> Result<(), crate::sink::HyphaError> {
451 use crate::sink::HyphaError;
452 let dir = self.mycelium_dir();
453 std::fs::create_dir_all(&dir).map_err(|e| {
454 HyphaError::new(
455 "cache_write_failed",
456 format!("Failed to create mycelium dir: {}", e),
457 )
458 })?;
459
460 let content = crate::mycelium::format_mycelium(mycelium).map_err(|e| {
461 HyphaError::new(
462 "cache_write_failed",
463 format!("Failed to serialize mycelium: {}", e),
464 )
465 })?;
466
467 locked_write_file(&self.mycelium_path(), &content)
468 }
469
470 pub fn status_path(&self) -> PathBuf {
474 self.mycelium_dir().join("status.json")
475 }
476
477 pub fn load_status(&self) -> CacheStatus {
479 let path = self.status_path();
480 if path.exists() {
481 std::fs::read_to_string(&path)
482 .ok()
483 .and_then(|s| serde_json::from_str(&s).ok())
484 .unwrap_or_default()
485 } else {
486 CacheStatus::default()
487 }
488 }
489
490 pub fn save_status(&self, status: &CacheStatus) -> Result<(), crate::sink::HyphaError> {
492 use crate::sink::HyphaError;
493 let dir = self.mycelium_dir();
494 std::fs::create_dir_all(&dir).map_err(|e| {
495 HyphaError::new(
496 "cache_write_failed",
497 format!("Failed to create mycelium dir: {}", e),
498 )
499 })?;
500
501 let content = serde_json::to_string_pretty(status).map_err(|e| {
502 HyphaError::new(
503 "cache_write_failed",
504 format!("Failed to serialize status: {}", e),
505 )
506 })?;
507
508 locked_write_file(&self.status_path(), &content)
509 }
510
511 pub fn domain_taste_path(&self) -> PathBuf {
515 self.mycelium_dir().join("taste.json")
516 }
517
518 pub fn load_domain_taste(&self) -> Option<TasteVerdictCache> {
520 let path = self.domain_taste_path();
521 if path.exists() {
522 std::fs::read_to_string(&path)
523 .ok()
524 .and_then(|s| serde_json::from_str(&s).ok())
525 } else {
526 None
527 }
528 }
529
530 pub fn save_domain_taste(
532 &self,
533 verdict: &TasteVerdictCache,
534 ) -> Result<(), crate::sink::HyphaError> {
535 use crate::sink::HyphaError;
536 let dir = self.mycelium_dir();
537 std::fs::create_dir_all(&dir).map_err(|e| {
538 HyphaError::new(
539 "cache_write_failed",
540 format!("Failed to create mycelium dir: {}", e),
541 )
542 })?;
543
544 let content = serde_json::to_string_pretty(verdict).map_err(|e| {
545 HyphaError::new(
546 "cache_write_failed",
547 format!("Failed to serialize domain taste verdict: {}", e),
548 )
549 })?;
550
551 locked_write_file(&self.domain_taste_path(), &content)
552 }
553
554 pub fn taste_path(&self, hash: &str) -> PathBuf {
558 self.spore_path(hash).join("taste.json")
559 }
560
561 pub fn load_taste(&self, hash: &str) -> Option<TasteVerdictCache> {
563 let path = self.taste_path(hash);
564 if path.exists() {
565 std::fs::read_to_string(&path)
566 .ok()
567 .and_then(|s| serde_json::from_str(&s).ok())
568 } else {
569 None
570 }
571 }
572
573 pub fn save_taste(
575 &self,
576 hash: &str,
577 verdict: &TasteVerdictCache,
578 ) -> Result<(), crate::sink::HyphaError> {
579 use crate::sink::HyphaError;
580 let dir = self.spore_path(hash);
581 std::fs::create_dir_all(&dir).map_err(|e| {
582 HyphaError::new(
583 "cache_write_failed",
584 format!("Failed to create spore dir: {}", e),
585 )
586 })?;
587
588 let content = serde_json::to_string_pretty(verdict).map_err(|e| {
589 HyphaError::new(
590 "cache_write_failed",
591 format!("Failed to serialize taste verdict: {}", e),
592 )
593 })?;
594
595 locked_write_file(&self.taste_path(hash), &content)
596 }
597
598 pub fn key_trust_path(&self) -> PathBuf {
602 self.mycelium_dir().join("key_trust.json")
603 }
604
605 pub fn load_key_trust(&self) -> Vec<KeyTrustEntry> {
607 let path = self.key_trust_path();
608 if path.exists() {
609 std::fs::read_to_string(&path)
610 .ok()
611 .and_then(|s| serde_json::from_str(&s).ok())
612 .unwrap_or_default()
613 } else {
614 Vec::new()
615 }
616 }
617
618 pub fn save_key_trust(&self, key: &str) -> Result<(), crate::sink::HyphaError> {
620 use crate::sink::HyphaError;
621 let dir = self.mycelium_dir();
622 std::fs::create_dir_all(&dir).map_err(|e| {
623 HyphaError::new(
624 "cache_write_failed",
625 format!("Failed to create mycelium dir: {}", e),
626 )
627 })?;
628
629 let mut entries = self.load_key_trust();
630 if let Some(entry) = entries.iter_mut().find(|e| e.key == key) {
632 entry.confirmed_at_epoch_ms = crate::time::now_epoch_ms();
633 } else {
634 entries.push(KeyTrustEntry {
635 key: key.to_string(),
636 confirmed_at_epoch_ms: crate::time::now_epoch_ms(),
637 });
638 }
639
640 let content = serde_json::to_string_pretty(&entries).map_err(|e| {
641 HyphaError::new(
642 "cache_write_failed",
643 format!("Failed to serialize key trust: {}", e),
644 )
645 })?;
646
647 locked_write_file(&self.key_trust_path(), &content)
648 }
649
650 pub fn is_key_trusted(&self, key: &str, ttl_ms: u64, clock_skew_tolerance_ms: u64) -> bool {
653 let entries = self.load_key_trust();
654 let now = crate::time::now_epoch_ms();
655 let effective_ttl = ttl_ms.saturating_add(clock_skew_tolerance_ms);
656 entries
657 .iter()
658 .any(|e| e.key == key && now.saturating_sub(e.confirmed_at_epoch_ms) < effective_ttl)
659 }
660
661 pub fn update_cmn_status(&self, success: bool, error: Option<&str>) {
663 let mut status = self.load_status();
664 if success {
665 status.cmn = FetchStatus::success();
666 } else {
667 status.cmn = FetchStatus::failure(error.unwrap_or("Unknown error"), Some(&status.cmn));
668 }
669 let _ = self.save_status(&status);
670 }
671}
672
673fn read_spore_metadata(manifest_path: &PathBuf) -> (String, String) {
675 if manifest_path.exists() {
676 if let Ok(content) = std::fs::read_to_string(manifest_path) {
677 if let Ok(manifest) = serde_json::from_str::<substrate::Spore>(&content) {
678 return (manifest.capsule.core.name, manifest.capsule.core.synopsis);
679 }
680 }
681 }
682 ("unknown".to_string(), String::new())
683}
684
685pub struct CachedSpore {
687 pub domain: String,
688 pub hash: String,
689 pub name: String,
690 pub synopsis: String,
691 pub path: PathBuf,
692 pub size: u64,
693 pub verdict: Option<substrate::TasteVerdict>,
694}
695
696fn dir_size(path: &Path) -> u64 {
698 let mut size = 0;
699 let mut stack = vec![path.to_path_buf()];
700 while let Some(dir) = stack.pop() {
701 if let Ok(entries) = std::fs::read_dir(&dir) {
702 for entry in entries.filter_map(|e| e.ok()) {
703 let path = entry.path();
704 if path.is_file() {
705 size += entry.metadata().map(|m| m.len()).unwrap_or(0);
706 } else if path.is_dir() {
707 stack.push(path);
708 }
709 }
710 }
711 }
712 size
713}
714
715pub fn handle_list(out: &Output) -> ExitCode {
717 let cache = CacheDir::new();
718 let spores = cache.list_all();
719
720 if spores.is_empty() {
721 let data = json!({
722 "count": 0,
723 "spores": [],
724 "total_size": 0,
725 });
726
727 return out.ok(data);
728 }
729
730 let total_size: u64 = spores.iter().map(|s| s.size).sum();
731
732 let spores_json: Vec<serde_json::Value> = spores
733 .iter()
734 .map(|s| {
735 json!({
736 "domain": s.domain,
737 "hash": s.hash,
738 "name": s.name,
739 "synopsis": s.synopsis,
740 "path": s.path.display().to_string(),
741 "size": s.size,
742 "verdict": s.verdict,
743 })
744 })
745 .collect();
746
747 let data = json!({
748 "count": spores.len(),
749 "spores": spores_json,
750 "total_size": total_size,
751 });
752
753 out.ok(data)
754}
755
756pub fn handle_clean(out: &Output, all: bool) -> ExitCode {
758 let cache = CacheDir::new();
759
760 if all {
761 match cache.clean_all() {
762 Ok(count) => {
763 let data = json!({
764 "removed": count,
765 });
766 out.ok(data)
767 }
768 Err(e) => out.error_hypha(&e),
769 }
770 } else {
771 out.error(
773 "invalid_args",
774 "Use --all to remove all cached items. Age-based cleanup not yet implemented.",
775 )
776 }
777}
778
779pub fn handle_path(out: &Output, uri_str: &str) -> ExitCode {
781 let uri = match CmnUri::parse(uri_str) {
782 Ok(u) => u,
783 Err(e) => return out.error("uri_error", &e),
784 };
785
786 let hash = match &uri.hash {
787 Some(h) => h,
788 None => return out.error("uri_error", "spore URI must include a hash"),
789 };
790
791 let cache = CacheDir::new();
792 let path = cache.spore_path(&uri.domain, hash);
793
794 if !path.exists() {
795 return out.error_hint(
796 "NOT_CACHED",
797 "Spore not cached",
798 Some(&format!("run: hypha taste {}", uri_str)),
799 );
800 }
801
802 let content_path = path.join("content");
803 let display_path = if content_path.exists() {
804 content_path
805 } else {
806 path.clone()
807 };
808
809 let data = json!({
810 "uri": uri_str,
811 "path": display_path.display().to_string(),
812 });
813
814 out.ok(data)
815}
816
817#[cfg(test)]
818#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
819mod tests {
820
821 use super::*;
822 use tempfile::TempDir;
823
824 #[test]
825 fn test_fetch_status_success() {
826 let status = FetchStatus::success();
827 assert!(status.fetched_at_epoch_ms.is_some());
828 assert!(status.failed_at_epoch_ms.is_none());
829 assert_eq!(status.retry_count, 0);
830 }
831
832 #[test]
833 fn test_fetch_status_failure() {
834 let status = FetchStatus::failure("connection timeout", None);
835 assert!(status.failed_at_epoch_ms.is_some());
836 assert_eq!(status.retry_count, 1);
837 assert_eq!(status.error, Some("connection timeout".to_string()));
838 }
839
840 #[test]
841 fn test_fetch_status_retry() {
842 let first = FetchStatus::failure("error 1", None);
843 let second = FetchStatus::failure("error 2", Some(&first));
844 assert_eq!(second.retry_count, 2);
845 }
846
847 #[test]
848 fn test_domain_cache_paths() {
849 let temp = TempDir::new().unwrap();
850 let cache = CacheDir::with_root(temp.path().to_path_buf());
851
852 let domain = cache.domain("example.com");
853 assert!(domain.cmn_path().ends_with("mycelium/cmn.json"));
854 assert!(domain.status_path().ends_with("mycelium/status.json"));
855 }
856
857 #[test]
858 fn test_spore_path_new_structure() {
859 let temp = TempDir::new().unwrap();
860 let cache = CacheDir::with_root(temp.path().to_path_buf());
861
862 let path = cache.spore_path("example.com", "b3.3yMR7vZQ9hL");
863 assert!(path.to_string_lossy().contains("spore/b3.3yMR7vZQ9hL"));
864 }
865
866 #[test]
867 fn test_cache_dir_default_ttl_values() {
868 let temp = TempDir::new().unwrap();
869 let cache = CacheDir::with_root(temp.path().to_path_buf());
870 assert_eq!(cache.cmn_ttl_ms, 300 * 1000);
871 }
872
873 #[test]
874 fn test_cache_dir_custom_ttl() {
875 let temp = TempDir::new().unwrap();
876 let cache = CacheDir {
877 root: temp.path().to_path_buf(),
878 cmn_ttl_ms: 10_000,
879 max_download_bytes: 1024 * 1024 * 1024,
880 max_extract_bytes: 512 * 1024 * 1024,
881 max_extract_files: 100_000,
882 max_extract_file_bytes: 256 * 1024 * 1024,
883 };
884 assert_eq!(cache.cmn_ttl_ms, 10_000);
885 }
886
887 #[test]
888 fn test_cache_dir_from_config_file() {
889 let _lock = crate::config::ENV_LOCK.lock().unwrap();
892 let dir = tempfile::tempdir().unwrap();
893 let hypha_dir = dir.path().join("hypha");
894 std::fs::create_dir_all(&hypha_dir).unwrap();
895 std::fs::write(hypha_dir.join("config.toml"), "[cache]\ncmn_ttl_s = 30\n").unwrap();
896
897 std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
898 let cache = CacheDir::new();
899 std::env::remove_var("CMN_HOME");
900
901 assert_eq!(cache.cmn_ttl_ms, 30 * 1000);
902 }
903
904 #[test]
905 fn test_cache_dir_from_config_custom_path() {
906 let _lock = crate::config::ENV_LOCK.lock().unwrap();
907 let dir = tempfile::tempdir().unwrap();
908 let custom_cache = dir.path().join("my-custom-cache");
909 let hypha_dir = dir.path().join("hypha");
910 std::fs::create_dir_all(&hypha_dir).unwrap();
911 std::fs::write(
912 hypha_dir.join("config.toml"),
913 format!("[cache]\npath = \"{}\"\n", custom_cache.display()),
914 )
915 .unwrap();
916
917 std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
918 let cache = CacheDir::new();
919 std::env::remove_var("CMN_HOME");
920
921 assert_eq!(cache.root, custom_cache);
922 }
923
924 #[test]
925 fn test_fetch_status_is_fresh_respects_ttl() {
926 let status = FetchStatus::success();
927 assert!(status.is_fresh(1000));
929 assert!(status.is_fresh(3_600_000));
930 assert!(!status.is_fresh(0));
932 }
933
934 #[test]
935 fn test_taste_verdict_roundtrip() {
936 let temp = TempDir::new().unwrap();
937 let cache = CacheDir::with_root(temp.path().to_path_buf());
938 let domain = cache.domain("example.com");
939
940 let verdict = TasteVerdictCache {
941 verdict: substrate::TasteVerdict::Safe,
942 notes: Some("Reviewed source code".to_string()),
943 tasted_at_epoch_ms: 1700000000000,
944 };
945
946 domain.save_taste("b3.3yMR7vZQ9hL", &verdict).unwrap();
947 let loaded = domain.load_taste("b3.3yMR7vZQ9hL").unwrap();
948
949 assert_eq!(loaded.verdict, substrate::TasteVerdict::Safe);
950 assert_eq!(loaded.notes, Some("Reviewed source code".to_string()));
951 assert_eq!(loaded.tasted_at_epoch_ms, 1700000000000);
952 }
953
954 #[test]
955 fn test_taste_verdict_not_found() {
956 let temp = TempDir::new().unwrap();
957 let cache = CacheDir::with_root(temp.path().to_path_buf());
958 let domain = cache.domain("example.com");
959
960 assert!(domain.load_taste("b3.nonexistent").is_none());
961 }
962
963 #[test]
964 fn test_status_update() {
965 let temp = TempDir::new().unwrap();
966 let cache = CacheDir::with_root(temp.path().to_path_buf());
967 let domain = cache.domain("example.com");
968
969 domain.update_cmn_status(false, Some("404 not found"));
970 let status = domain.load_status();
971 assert!(status.cmn.failed_at_epoch_ms.is_some());
972 assert_eq!(status.cmn.error, Some("404 not found".to_string()));
973 }
974}