Skip to main content

hypha/cache/
mod.rs

1//! Cache management for downloaded spores and domain metadata.
2//!
3//! Cache location: `$CMN_HOME/hypha/cache/`
4//!
5//! Cache structure:
6//! ```text
7//! $CMN_HOME/hypha/cache/
8//! └── {domain}/
9//!     ├── mycelium/
10//!     │   ├── cmn.json             # cached cmn.json entry
11//!     │   ├── mycelium.json       # full mycelium manifest
12//!     │   └── status.json         # cache status for all items
13//!     │
14//!     ├── repos/                  # Bare git repositories for spawn/pull
15//!     │   └── {root_commit}/      # Repository identified by first commit SHA
16//!     │
17//!     └── spore/
18//!         └── {hash}/
19//!             ├── spore.json
20//!             └── content/
21//! ```
22
23use 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/// Status of a single cached item
32#[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    /// Check if this cache entry is still fresh within the given TTL (milliseconds)
64    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
72/// Cached taste verdict for a spore — alias for the substrate standard type.
73pub type TasteVerdictCache = substrate::TasteVerdictRecord;
74
75/// A cached key trust entry — records that a domain confirmed ownership of a key
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct KeyTrustEntry {
78    pub key: String,
79    pub confirmed_at_epoch_ms: u64,
80}
81
82/// Cache status for domain metadata (cmn, mycelium)
83#[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
91/// Cache directory structure
92pub 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    /// Create a new CacheDir under $CMN_HOME/hypha/cache/ (or [cache] path from config.toml)
103    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        // Ensure cache root exists with restricted permissions
112        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    /// Get the domain cache helper
132    pub fn domain(&self, domain: &str) -> DomainCache {
133        DomainCache {
134            root: self.root.join(domain),
135            domain: domain.to_string(),
136        }
137    }
138
139    /// Get the cache path for a specific spore (legacy compatibility)
140    /// Structure: ~/.cmn/cache/{domain}/spore/{hash}/
141    pub fn spore_path(&self, domain: &str, hash: &str) -> PathBuf {
142        self.domain(domain).spore_path(hash)
143    }
144
145    /// List all cached spores
146    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        // Iterate through domain directories
154        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                // Iterate through spore directories
165                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                        // Try to read spore.json for metadata
177                        let manifest_path = hash_path.join("spore.json");
178                        let (name, synopsis) = read_spore_metadata(&manifest_path);
179
180                        // Read taste verdict if present
181                        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                        // Get directory size
196                        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    /// Remove all cached items
216    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    /// Create a CacheDir with explicit TTL values (for testing)
244    #[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
257/// Write content atomically with an exclusive file lock to prevent concurrent corruption.
258/// Acquires a per-directory `.lock` file before performing the atomic write.
259fn 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
299/// Write content to a file atomically: write to a temp file in the same directory,
300/// then rename to the final path. This prevents partial/corrupt reads on crash.
301fn 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
348/// Domain-specific cache operations
349pub struct DomainCache {
350    pub root: PathBuf,
351    pub domain: String,
352}
353
354impl DomainCache {
355    /// Get the mycelium metadata directory
356    pub fn mycelium_dir(&self) -> PathBuf {
357        self.root.join("mycelium")
358    }
359
360    /// Get the spore cache directory
361    pub fn spore_dir(&self) -> PathBuf {
362        self.root.join("spore")
363    }
364
365    /// Get the cache path for a specific spore
366    pub fn spore_path(&self, hash: &str) -> PathBuf {
367        self.spore_dir().join(hash)
368    }
369
370    // --- Repos (bare git repositories for spawn/pull) ---
371
372    /// Get the repos cache directory for this domain
373    pub fn repos_dir(&self) -> PathBuf {
374        self.root.join("repos")
375    }
376
377    /// Get the cache path for a specific repository by root_commit
378    ///
379    /// The root_commit is the SHA of the first commit in the repository,
380    /// which serves as a stable identifier for the repository.
381    pub fn repo_path(&self, root_commit: &str) -> PathBuf {
382        self.repos_dir().join(root_commit)
383    }
384
385    // --- CMN Entry (cmn.json) ---
386
387    /// Get path to cached cmn.json
388    pub fn cmn_path(&self) -> PathBuf {
389        self.mycelium_dir().join("cmn.json")
390    }
391
392    /// Load cached cmn.json entry
393    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    /// Save cmn.json entry to cache
405    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    // --- Full Mycelium manifest ---
426    // cmn.json is the lightweight entry point (at /.well-known/cmn.json)
427    // mycelium.json is the complete manifest with spores list (fetched via the type:"mycelium" endpoint)
428
429    /// Get path to mycelium.json (complete manifest with spores list)
430    pub fn mycelium_path(&self) -> PathBuf {
431        self.mycelium_dir().join("mycelium.json")
432    }
433
434    /// Load cached full mycelium manifest
435    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    /// Save full mycelium manifest to cache
447    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    // --- Status ---
471
472    /// Get path to status.json
473    pub fn status_path(&self) -> PathBuf {
474        self.mycelium_dir().join("status.json")
475    }
476
477    /// Load cache status
478    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    /// Save cache status
491    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    // --- Domain-level taste verdict ---
512
513    /// Get path to domain-level taste.json
514    pub fn domain_taste_path(&self) -> PathBuf {
515        self.mycelium_dir().join("taste.json")
516    }
517
518    /// Load cached taste verdict for the domain itself
519    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    /// Save taste verdict for the domain itself
531    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    // --- Taste verdict ---
555
556    /// Get path to taste.json for a spore
557    pub fn taste_path(&self, hash: &str) -> PathBuf {
558        self.spore_path(hash).join("taste.json")
559    }
560
561    /// Load cached taste verdict for a spore
562    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    /// Save taste verdict for a spore
574    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    // --- Key trust ---
599
600    /// Get path to key_trust.json for this domain
601    pub fn key_trust_path(&self) -> PathBuf {
602        self.mycelium_dir().join("key_trust.json")
603    }
604
605    /// Load cached key trust entries
606    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    /// Save a key trust entry (domain confirmed this key)
619    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        // Update existing or add new
631        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    /// Check if a key is trusted within the given TTL (milliseconds).
651    /// Applies clock skew tolerance to prevent false negatives from clock drift.
652    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    /// Update cmn.json fetch status
662    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
673/// Read spore metadata from manifest file
674fn 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
685/// Information about a cached spore
686pub 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
696/// Calculate directory size iteratively
697fn 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
715/// Handle the `cache list` command
716pub 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
756/// Handle the `cache clean` command
757pub 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        // For now, just clean all. Later we can add age-based cleanup.
772        out.error(
773            "invalid_args",
774            "Use --all to remove all cached items. Age-based cleanup not yet implemented.",
775        )
776    }
777}
778
779/// Handle the `cache path` command
780pub 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        // Write a config file with custom TTLs under $CMN_HOME/hypha/config.toml,
890        // and verify CacheDir::new() picks them up.
891        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        // Just created — should be fresh for any positive TTL
928        assert!(status.is_fresh(1000));
929        assert!(status.is_fresh(3_600_000));
930        // Zero TTL means never fresh
931        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}