Skip to main content

aperture_cli/engine/
loader.rs

1use crate::cache::fingerprint::compute_content_hash;
2use crate::cache::metadata::CacheMetadataManager;
3use crate::cache::models::{CachedSpec, CACHE_FORMAT_VERSION};
4use crate::error::Error;
5use crate::fs::OsFileSystem;
6use std::fs;
7use std::path::Path;
8
9/// Loads a cached `OpenAPI` specification from the binary cache with optimized version checking.
10///
11/// This function uses a global cache metadata file for fast version checking before
12/// loading the full specification, significantly improving performance.
13///
14/// After version checks pass, validates the spec file fingerprint (mtime, size, content hash)
15/// against the cached metadata. If the spec file has been modified since caching, returns
16/// `Error::cache_stale` with a suggestion to reinitialize.
17///
18/// # Arguments
19/// * `cache_dir` - The directory containing cached spec files
20/// * `spec_name` - The name of the spec to load (without binary extension)
21///
22/// # Returns
23/// * `Ok(CachedSpec)` - The loaded and deserialized specification
24/// * `Err(Error)` - If the file doesn't exist, deserialization fails, or cache is stale
25///
26/// # Errors
27/// Returns an error if the cache file doesn't exist, deserialization fails, or
28/// the spec file has been modified since the cache was built
29pub fn load_cached_spec<P: AsRef<Path>>(
30    cache_dir: P,
31    spec_name: &str,
32) -> Result<CachedSpec, Error> {
33    // Fast version check using metadata
34    let fs = OsFileSystem;
35    let metadata_manager = CacheMetadataManager::new(&fs);
36
37    // Check if spec exists and version is compatible
38    let spec = match metadata_manager.check_spec_version(&cache_dir, spec_name) {
39        Ok(true) => {
40            // Version is compatible, load spec directly (no version check needed)
41            load_cached_spec_without_version_check(&cache_dir, spec_name)?
42        }
43        Ok(false) => {
44            // Version mismatch or spec not in metadata, fall back to legacy method
45            load_cached_spec_with_version_check(&cache_dir, spec_name)?
46        }
47        Err(_) => {
48            // Metadata loading failed, fall back to legacy method
49            load_cached_spec_with_version_check(&cache_dir, spec_name)?
50        }
51    };
52
53    // Validate spec file fingerprint to detect stale caches
54    check_spec_file_freshness(&cache_dir, spec_name, &metadata_manager)?;
55
56    Ok(spec)
57}
58
59/// Checks whether the spec source file has been modified since the cache was built.
60///
61/// Derives the spec file path from the cache directory (sibling `specs/` directory).
62/// Uses a fast path: checks mtime + file size first and only reads the file to
63/// compute a content hash when those match (avoiding I/O on every load when mtime
64/// already differs).
65/// Silently passes through if fingerprint data is unavailable (legacy metadata) or
66/// if the spec file cannot be read (e.g., deleted after caching).
67fn check_spec_file_freshness<P: AsRef<Path>>(
68    cache_dir: P,
69    spec_name: &str,
70    metadata_manager: &CacheMetadataManager<'_, OsFileSystem>,
71) -> Result<(), Error> {
72    // Bail early if no fingerprint data (legacy metadata) or metadata error
73    let Ok(Some((stored_hash, stored_mtime, stored_size))) =
74        metadata_manager.get_stored_fingerprint(&cache_dir, spec_name)
75    else {
76        return Ok(());
77    };
78
79    // Derive the spec file path from the cache directory
80    // cache_dir is typically ~/.config/aperture/.cache
81    // specs_dir is typically ~/.config/aperture/specs
82    let Some(config_dir) = cache_dir.as_ref().parent() else {
83        return Ok(()); // Can't determine spec path, skip check
84    };
85    let spec_path = config_dir
86        .join(crate::constants::DIR_SPECS)
87        .join(format!("{spec_name}{}", crate::constants::FILE_EXT_YAML));
88
89    // Get current file metadata (single syscall for both mtime and size)
90    let Ok(file_meta) = fs::metadata(&spec_path) else {
91        return Ok(()); // File missing or unreadable, skip check
92    };
93    let current_mtime = file_meta
94        .modified()
95        .ok()
96        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
97        .map(|d| d.as_secs());
98    let Some(current_mtime) = current_mtime else {
99        return Ok(()); // Can't read mtime, skip check
100    };
101    let current_size = file_meta.len();
102
103    // Fast path: if mtime or file size differ, cache is likely stale — no need to
104    // read file content or compute hash
105    if stored_mtime != current_mtime || stored_size != current_size {
106        return Err(Error::cache_stale(spec_name));
107    }
108
109    // Slow path: mtime and size match — read file and verify content hash for certainty
110    let Ok(content) = fs::read(&spec_path) else {
111        return Ok(()); // Can't read file, skip check
112    };
113    let current_hash = compute_content_hash(&content);
114
115    if stored_hash != current_hash {
116        return Err(Error::cache_stale(spec_name));
117    }
118
119    Ok(())
120}
121
122/// Load cached spec without version checking (optimized path)
123fn load_cached_spec_without_version_check<P: AsRef<Path>>(
124    cache_dir: P,
125    spec_name: &str,
126) -> Result<CachedSpec, Error> {
127    let cache_path = cache_dir
128        .as_ref()
129        .join(format!("{spec_name}{}", crate::constants::FILE_EXT_BIN));
130
131    if !cache_path.exists() {
132        return Err(Error::cached_spec_not_found(spec_name));
133    }
134
135    let cache_data = fs::read(&cache_path)
136        .map_err(|e| Error::io_error(format!("Failed to read cache file: {e}")))?;
137    postcard::from_bytes(&cache_data)
138        .map_err(|e| Error::cached_spec_corrupted(spec_name, e.to_string()))
139}
140
141/// Load cached spec with embedded version checking (legacy/fallback path)
142fn load_cached_spec_with_version_check<P: AsRef<Path>>(
143    cache_dir: P,
144    spec_name: &str,
145) -> Result<CachedSpec, Error> {
146    let cache_path = cache_dir
147        .as_ref()
148        .join(format!("{spec_name}{}", crate::constants::FILE_EXT_BIN));
149
150    if !cache_path.exists() {
151        return Err(Error::cached_spec_not_found(spec_name));
152    }
153
154    let cache_data = fs::read(&cache_path)
155        .map_err(|e| Error::io_error(format!("Failed to read cache file: {e}")))?;
156    let cached_spec: CachedSpec = postcard::from_bytes(&cache_data)
157        .map_err(|e| Error::cached_spec_corrupted(spec_name, e.to_string()))?;
158
159    // Check cache format version
160    if cached_spec.cache_format_version != CACHE_FORMAT_VERSION {
161        return Err(Error::cache_version_mismatch(
162            spec_name,
163            cached_spec.cache_format_version,
164            CACHE_FORMAT_VERSION,
165        ));
166    }
167
168    Ok(cached_spec)
169}