Skip to main content

kermit_bench/
cache.rs

1//! On-disk cache for benchmark relation files.
2//!
3//! Relation files referenced by a [`BenchmarkDefinition`] are downloaded lazily
4//! into the platform cache directory under
5//! `<cache_dir>/kermit/benchmarks/<benchmark>/<relation>.parquet`. On Linux
6//! this resolves to `~/.cache/kermit/benchmarks/…`.
7//!
8//! [`ensure_cached`] is the entry point; [`clean_benchmark`] and [`clean_all`]
9//! remove cached files.
10
11use {
12    crate::{definition::BenchmarkDefinition, error::BenchError},
13    std::{
14        fs,
15        path::{Path, PathBuf},
16    },
17};
18
19/// Returns the base cache directory for kermit benchmarks
20/// (`$XDG_CACHE_HOME/kermit/benchmarks` on Linux).
21///
22/// # Errors
23///
24/// Returns [`BenchError::NoCacheDir`] if the platform cache directory cannot
25/// be determined.
26pub fn base_cache_dir() -> Result<PathBuf, BenchError> {
27    let cache = dirs::cache_dir().ok_or(BenchError::NoCacheDir)?;
28    Ok(cache.join("kermit").join("benchmarks"))
29}
30
31/// Returns the cache directory for a specific benchmark.
32///
33/// # Errors
34///
35/// Returns [`BenchError::NoCacheDir`] if the platform cache directory cannot
36/// be determined.
37pub fn cache_dir(benchmark_name: &str) -> Result<PathBuf, BenchError> {
38    Ok(base_cache_dir()?.join(benchmark_name))
39}
40
41/// Returns the expected path for a cached relation file.
42///
43/// # Errors
44///
45/// Returns [`BenchError::NoCacheDir`] if the platform cache directory cannot
46/// be determined.
47pub fn relation_cache_path(
48    benchmark_name: &str, relation_name: &str,
49) -> Result<PathBuf, BenchError> {
50    Ok(cache_dir(benchmark_name)?.join(format!("{relation_name}.parquet")))
51}
52
53/// Returns true if all relation files for the benchmark are cached.
54///
55/// # Errors
56///
57/// Returns [`BenchError::NoCacheDir`] if the platform cache directory cannot
58/// be determined.
59pub fn is_cached(benchmark: &BenchmarkDefinition) -> Result<bool, BenchError> {
60    for rel in &benchmark.relations {
61        let path = relation_cache_path(&benchmark.name, &rel.name)?;
62        if !path.exists() {
63            return Ok(false);
64        }
65    }
66    Ok(true)
67}
68
69/// Ensures all relations for a benchmark are downloaded and cached.
70///
71/// Returns paths to the cached files in the same order as the benchmark's
72/// relations list.
73///
74/// # Errors
75///
76/// Returns a [`BenchError`] if any of the following occur:
77/// - [`BenchError::NoCacheDir`] — the platform cache directory is not
78///   available.
79/// - [`BenchError::Io`] — the cache directory cannot be created, or a
80///   downloaded file cannot be written.
81/// - [`BenchError::Download`] — an HTTP error occurred while fetching a
82///   relation file.
83pub fn ensure_cached(benchmark: &BenchmarkDefinition) -> Result<Vec<PathBuf>, BenchError> {
84    let mut paths = Vec::with_capacity(benchmark.relations.len());
85
86    for rel in &benchmark.relations {
87        let path = relation_cache_path(&benchmark.name, &rel.name)?;
88        if !path.exists() {
89            eprintln!("  downloading {} from {}...", rel.name, rel.url);
90            download_file(&rel.url, &path)?;
91        }
92        paths.push(path);
93    }
94
95    Ok(paths)
96}
97
98/// Downloads a file from a URL to the given destination path.
99fn download_file(url: &str, dest: &Path) -> Result<(), BenchError> {
100    if let Some(parent) = dest.parent() {
101        fs::create_dir_all(parent)?;
102    }
103
104    let part_path = dest.with_extension("parquet.part");
105
106    let response = reqwest::blocking::get(url)
107        .and_then(|r| r.error_for_status())
108        .map_err(|source| BenchError::Download {
109            url: url.to_string(),
110            source,
111        })?;
112
113    let bytes = response.bytes().map_err(|source| BenchError::Download {
114        url: url.to_string(),
115        source,
116    })?;
117
118    fs::write(&part_path, &bytes)?;
119    fs::rename(&part_path, dest)?;
120
121    Ok(())
122}
123
124/// Removes the cache directory for a specific benchmark.
125///
126/// A non-existent cache directory is treated as success (idempotent).
127///
128/// # Errors
129///
130/// Returns a [`BenchError`] if:
131/// - [`BenchError::NoCacheDir`] — the platform cache directory is not
132///   available.
133/// - [`BenchError::Io`] — the directory exists but cannot be removed.
134pub fn clean_benchmark(name: &str) -> Result<(), BenchError> {
135    let dir = cache_dir(name)?;
136    if dir.exists() {
137        fs::remove_dir_all(&dir)?;
138    }
139    Ok(())
140}
141
142/// Removes the entire kermit benchmark cache.
143///
144/// A non-existent cache directory is treated as success (idempotent).
145///
146/// # Errors
147///
148/// Returns a [`BenchError`] if:
149/// - [`BenchError::NoCacheDir`] — the platform cache directory is not
150///   available.
151/// - [`BenchError::Io`] — the directory exists but cannot be removed.
152pub fn clean_all() -> Result<(), BenchError> {
153    let dir = base_cache_dir()?;
154    if dir.exists() {
155        fs::remove_dir_all(&dir)?;
156    }
157    Ok(())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn cache_dir_structure() {
166        let dir = cache_dir("triangle").unwrap();
167        assert!(dir.ends_with("kermit/benchmarks/triangle"));
168    }
169
170    #[test]
171    fn relation_cache_path_structure() {
172        let path = relation_cache_path("triangle", "edge").unwrap();
173        assert!(path.ends_with("kermit/benchmarks/triangle/edge.parquet"));
174    }
175
176    #[test]
177    fn is_cached_false_when_missing() {
178        let def = BenchmarkDefinition {
179            name: "nonexistent_test_benchmark".to_string(),
180            description: String::new(),
181            relations: vec![crate::definition::RelationSource {
182                name: "r".to_string(),
183                url: "http://x".to_string(),
184            }],
185            queries: vec![crate::definition::QueryDefinition {
186                name: "q".to_string(),
187                description: "test".to_string(),
188                query: "Q(X) :- r(X).".to_string(),
189            }],
190            generator: None,
191        };
192        assert!(!is_cached(&def).unwrap());
193    }
194
195    #[test]
196    fn clean_nonexistent_is_noop() {
197        assert!(clean_benchmark("this_benchmark_does_not_exist_12345").is_ok());
198    }
199}