pg_embedded_setup_unpriv/cache/operations/lookup.rs
1//! Cache lookup and hit/miss detection.
2//!
3//! Provides functions for checking cache status and finding matching versions.
4
5use camino::{Utf8Path, Utf8PathBuf};
6use postgresql_embedded::{Version, VersionReq};
7use std::fs;
8use tracing::{debug, warn};
9
10use super::copy::copy_from_cache;
11
12/// Marker file name indicating a complete cache entry.
13pub(crate) const COMPLETION_MARKER: &str = ".complete";
14
15/// Observability target for cache operations.
16const LOG_TARGET: &str = "pg_embed::cache";
17
18/// Result of a cache lookup operation.
19#[derive(Debug)]
20#[must_use]
21pub enum CacheLookupResult {
22 /// Cache hit: binaries exist and are valid.
23 Hit {
24 /// Path to the cached version directory.
25 source_dir: Utf8PathBuf,
26 },
27 /// Cache miss: binaries need to be downloaded.
28 Miss,
29}
30
31/// Returns true if the cache entry at the given path is complete.
32///
33/// A cache entry is complete if both the `.complete` marker and `bin/`
34/// directory exist.
35fn is_cache_entry_complete(version_dir: &Utf8Path) -> bool {
36 let marker = version_dir.join(COMPLETION_MARKER);
37 let bin_dir = version_dir.join("bin");
38 marker.exists() && bin_dir.is_dir()
39}
40
41/// Checks if the cache contains valid binaries for the given version.
42///
43/// A cache entry is considered valid if:
44/// 1. The version directory exists
45/// 2. The `.complete` marker file is present
46/// 3. The `bin` subdirectory exists (indicating extracted binaries)
47///
48/// # Arguments
49///
50/// * `cache_dir` - Root directory of the binary cache
51/// * `version` - Exact version string to look up (e.g., "17.4.0")
52///
53/// # Examples
54///
55/// ```no_run
56/// use camino::Utf8Path;
57/// use pg_embedded_setup_unpriv::cache::{check_cache, CacheLookupResult};
58///
59/// let cache_dir = Utf8Path::new("/home/user/.cache/pg-embedded/binaries");
60/// match check_cache(cache_dir, "17.4.0") {
61/// CacheLookupResult::Hit { source_dir } => {
62/// println!("Found cached binaries at {source_dir}");
63/// }
64/// CacheLookupResult::Miss => {
65/// println!("Cache miss, need to download");
66/// }
67/// }
68/// ```
69pub fn check_cache(cache_dir: &Utf8Path, version: &str) -> CacheLookupResult {
70 let version_dir = cache_dir.join(version);
71
72 if is_cache_entry_complete(&version_dir) {
73 debug!(
74 target: LOG_TARGET,
75 version = %version,
76 path = %version_dir,
77 "cache hit"
78 );
79 CacheLookupResult::Hit {
80 source_dir: version_dir,
81 }
82 } else {
83 log_cache_miss(&version_dir, version);
84 CacheLookupResult::Miss
85 }
86}
87
88/// Logs details about a cache miss for debugging.
89fn log_cache_miss(version_dir: &Utf8Path, version: &str) {
90 let marker = version_dir.join(COMPLETION_MARKER);
91 let bin_dir = version_dir.join("bin");
92 debug!(
93 target: LOG_TARGET,
94 version = %version,
95 marker_exists = marker.exists(),
96 bin_exists = bin_dir.is_dir(),
97 "cache miss"
98 );
99}
100
101/// Finds a cached version that satisfies the given version requirement.
102///
103/// Scans the cache directory for version subdirectories and returns the highest
104/// version that matches the requirement. This allows a requirement like `^17` to
105/// use a cached `17.4.0` entry.
106///
107/// # Arguments
108///
109/// * `cache_dir` - Root directory of the binary cache
110/// * `version_req` - Version requirement to match against (e.g., `^17`, `=17.4.0`)
111///
112/// # Returns
113///
114/// Returns `Some((version_string, source_dir))` if a matching cache entry is found,
115/// `None` otherwise.
116///
117/// # Examples
118///
119/// ```no_run
120/// use camino::Utf8Path;
121/// use postgresql_embedded::VersionReq;
122/// use pg_embedded_setup_unpriv::cache::find_matching_cached_version;
123///
124/// let cache_dir = Utf8Path::new("/home/user/.cache/pg-embedded/binaries");
125/// let version_req = VersionReq::parse("^17").expect("valid version req");
126/// if let Some((version, source_dir)) = find_matching_cached_version(cache_dir, &version_req) {
127/// println!("Found cached {version} at {source_dir}");
128/// }
129/// ```
130#[must_use]
131pub fn find_matching_cached_version(
132 cache_dir: &Utf8Path,
133 version_req: &VersionReq,
134) -> Option<(String, Utf8PathBuf)> {
135 let dir_entries = read_cache_directory(cache_dir)?;
136
137 // Use max_by for O(n) instead of collect + sort for O(n log n)
138 let (version, path) = dir_entries
139 .filter_map(Result::ok)
140 .filter_map(|entry| try_parse_cache_entry(&entry, version_req))
141 .max_by(|a, b| a.0.cmp(&b.0))?;
142
143 let version_str = version.to_string();
144 debug!(
145 target: LOG_TARGET,
146 version_req = %version_req,
147 matched_version = %version_str,
148 path = %path,
149 "found matching cached version"
150 );
151 Some((version_str, path))
152}
153
154/// Reads the cache directory, logging errors as debug messages.
155fn read_cache_directory(cache_dir: &Utf8Path) -> Option<fs::ReadDir> {
156 match fs::read_dir(cache_dir) {
157 Ok(entries) => Some(entries),
158 Err(err) => {
159 debug!(
160 target: LOG_TARGET,
161 cache_dir = %cache_dir,
162 error = %err,
163 "failed to read cache directory"
164 );
165 None
166 }
167 }
168}
169
170/// Attempts to parse a directory entry as a valid cache entry matching the version requirement.
171fn try_parse_cache_entry(
172 entry: &fs::DirEntry,
173 version_req: &VersionReq,
174) -> Option<(Version, Utf8PathBuf)> {
175 let path = entry.path();
176 let dir_name = path.file_name()?.to_str()?;
177
178 // Skip hidden directories
179 if dir_name.starts_with('.') {
180 return None;
181 }
182
183 let version = Version::parse(dir_name).ok()?;
184 if !version_req.matches(&version) {
185 return None;
186 }
187
188 let utf8_path = Utf8PathBuf::from_path_buf(path).ok()?;
189 is_cache_entry_complete(&utf8_path).then_some((version, utf8_path))
190}
191
192/// Attempts to use the cache, falling back gracefully on errors.
193///
194/// This is a convenience wrapper that logs warnings instead of failing when
195/// cache operations encounter errors.
196///
197/// # Arguments
198///
199/// * `cache_dir` - Root directory of the binary cache
200/// * `version` - Version string to look up
201/// * `target` - Target installation directory for copy
202///
203/// # Returns
204///
205/// Returns `true` if binaries were successfully copied from cache, `false` if
206/// the cache was missed or an error occurred.
207#[must_use]
208pub fn try_use_cache(cache_dir: &Utf8Path, version: &str, target: &Utf8Path) -> bool {
209 let CacheLookupResult::Hit { source_dir } = check_cache(cache_dir, version) else {
210 return false;
211 };
212
213 match copy_from_cache(&source_dir, target) {
214 Ok(()) => true,
215 Err(err) => {
216 warn!(
217 target: LOG_TARGET,
218 error = %err,
219 version = %version,
220 "cache copy failed, falling back to download"
221 );
222 false
223 }
224 }
225}