1use anyhow::Result;
2use camino::Utf8PathBuf;
3use chrono::Utc;
4use serde_json::{Map, Value};
5
6use crate::model::{
7 CacheOperationKind, CacheOperationReceipt, CacheProofReceipt, Check, Compression,
8 ReceiptSummary, RunnerOs, SCHEMA_VERSION, ToolInfo,
9};
10use crate::store::{
11 CacheRequest, CacheStore, accessible_scopes, cache_version, normalize_ref, scope_from_ref,
12};
13use crate::{TOOL_NAME, TOOL_VERSION};
14
15#[derive(Debug, Clone)]
16pub struct CommonOptions {
17 pub store: Utf8PathBuf,
18 pub workspace: Utf8PathBuf,
19 pub ref_name: String,
20 pub default_branch: String,
21 pub base_ref: Option<String>,
22 pub runner_os: RunnerOs,
23 pub compression: Option<Compression>,
24 pub enable_cross_os_archive: bool,
25}
26
27#[derive(Debug, Clone)]
28pub struct RestoreOptions {
29 pub common: CommonOptions,
30 pub key: String,
31 pub restore_keys: Vec<String>,
32 pub paths: Vec<String>,
33 pub lookup_only: bool,
34 pub fail_on_cache_miss: bool,
35}
36
37#[derive(Debug, Clone)]
38pub struct SaveOptions {
39 pub common: CommonOptions,
40 pub key: String,
41 pub paths: Vec<String>,
42}
43
44#[derive(Debug, Clone)]
45pub struct CheckWorkflowOptions {
46 pub common: CommonOptions,
47 pub repo_root: Utf8PathBuf,
48 pub workflows: Vec<Utf8PathBuf>,
49 pub context: Value,
50}
51
52pub fn restore_cache(options: &RestoreOptions) -> Result<CacheProofReceipt> {
53 let mut store = CacheStore::open(options.common.store.clone())?;
54 let request = request_from_restore(options);
55 let operation = restore_operation(
56 &mut store,
57 &request,
58 CacheOperationKind::Restore,
59 options.lookup_only,
60 options.fail_on_cache_miss,
61 true,
62 )?;
63 Ok(receipt(
64 "restore",
65 options.common.store.clone(),
66 options.common.workspace.clone(),
67 vec![operation],
68 Vec::new(),
69 ))
70}
71
72pub fn save_cache(options: &SaveOptions) -> Result<CacheProofReceipt> {
73 let mut store = CacheStore::open(options.common.store.clone())?;
74 let request = request_from_save(options);
75 let operation = save_operation(&mut store, &request)?;
76 Ok(receipt(
77 "save",
78 options.common.store.clone(),
79 options.common.workspace.clone(),
80 vec![operation],
81 Vec::new(),
82 ))
83}
84
85pub fn check_workflows(options: &CheckWorkflowOptions) -> Result<CacheProofReceipt> {
86 let mut store = CacheStore::open(options.common.store.clone())?;
87 let reports = crate::workflow::check_workflows(options, &mut store)?;
88 let operations = reports
89 .iter()
90 .flat_map(|report| report.cache_steps.iter())
91 .map(|step| step.operation_receipt.clone())
92 .collect::<Vec<_>>();
93 Ok(receipt(
94 "check-workflow",
95 options.common.store.clone(),
96 options.common.workspace.clone(),
97 operations,
98 reports,
99 ))
100}
101
102pub(crate) fn request_from_parts(
103 common: &CommonOptions,
104 key: String,
105 restore_keys: Vec<String>,
106 paths: Vec<String>,
107) -> CacheRequest {
108 let scope = scope_from_ref(&common.ref_name);
109 let compression = common.compression.unwrap_or_else(|| {
110 Compression::default_for(common.runner_os, common.enable_cross_os_archive)
111 });
112 CacheRequest {
113 workspace: common.workspace.clone(),
114 key,
115 restore_keys,
116 paths,
117 scope: scope.clone(),
118 accessible_scopes: accessible_scopes(
119 &scope,
120 &common.default_branch,
121 common.base_ref.as_deref(),
122 ),
123 runner_os: common.runner_os,
124 compression,
125 enable_cross_os_archive: common.enable_cross_os_archive,
126 }
127}
128
129pub(crate) fn restore_operation(
130 store: &mut CacheStore,
131 request: &CacheRequest,
132 operation: CacheOperationKind,
133 lookup_only: bool,
134 fail_on_cache_miss: bool,
135 copy_files: bool,
136) -> Result<CacheOperationReceipt> {
137 let mut checks = validate_request(request);
138 let version = cache_version(
139 &request.paths,
140 request.compression,
141 request.enable_cross_os_archive,
142 );
143
144 let restore = store.restore(request, copy_files && !lookup_only)?;
145 let cache_hit = match restore.matched.as_ref().map(|matched| matched.match_kind) {
146 Some(crate::model::CacheMatchKind::ExactKey) => "true".to_owned(),
147 Some(_) => "false".to_owned(),
148 None => String::new(),
149 };
150
151 if let Some(matched) = &restore.matched {
152 checks.push(Check::pass(
153 "cache.restore.match",
154 format!("matched cache key {} in {}", matched.key, matched.scope),
155 ));
156 if lookup_only {
157 checks.push(Check::skip(
158 "cache.restore.copy",
159 "lookup-only requested; cache contents were not restored",
160 ));
161 } else {
162 checks.push(Check::pass(
163 "cache.restore.copy",
164 format!(
165 "restored {} files and {} bytes",
166 restore.restored_files, restore.restored_bytes
167 ),
168 ));
169 }
170 } else if fail_on_cache_miss {
171 checks.push(Check::fail(
172 "cache.restore.miss",
173 "cache miss and fail-on-cache-miss is enabled",
174 ));
175 } else {
176 checks.push(Check::warn(
177 "cache.restore.miss",
178 "no matching cache entry found",
179 ));
180 }
181
182 let path_records = store.inspect_paths(&request.workspace, &request.paths)?;
183
184 Ok(CacheOperationReceipt {
185 operation,
186 key: request.key.clone(),
187 restore_keys: request.restore_keys.clone(),
188 paths: request.paths.clone(),
189 version,
190 scope: request.scope.clone(),
191 accessible_scopes: request.accessible_scopes.clone(),
192 runner_os: request.runner_os,
193 compression: request.compression,
194 enable_cross_os_archive: request.enable_cross_os_archive,
195 lookup_only,
196 fail_on_cache_miss,
197 matched: restore.matched,
198 cache_hit,
199 restored_files: restore.restored_files,
200 restored_bytes: restore.restored_bytes,
201 saved_files: 0,
202 saved_bytes: 0,
203 path_records,
204 checks,
205 })
206}
207
208pub(crate) fn save_operation(
209 store: &mut CacheStore,
210 request: &CacheRequest,
211) -> Result<CacheOperationReceipt> {
212 let mut checks = validate_request(request);
213 let version = cache_version(
214 &request.paths,
215 request.compression,
216 request.enable_cross_os_archive,
217 );
218 let saved = store.save(request)?;
219
220 let (saved_files, saved_bytes) = if let Some(entry) = &saved.entry {
221 checks.push(Check::pass(
222 "cache.save.created",
223 format!("saved cache {} with {} files", entry.key, entry.files),
224 ));
225 (entry.files, entry.bytes)
226 } else if saved.skipped_existing {
227 checks.push(Check::skip(
228 "cache.save.exists",
229 "cache key, version, and scope already exist; save skipped",
230 ));
231 (0, 0)
232 } else {
233 checks.push(Check::fail(
234 "cache.save.empty",
235 "no files matched cache paths; empty cache was not saved",
236 ));
237 (0, 0)
238 };
239
240 Ok(CacheOperationReceipt {
241 operation: CacheOperationKind::Save,
242 key: request.key.clone(),
243 restore_keys: Vec::new(),
244 paths: request.paths.clone(),
245 version,
246 scope: request.scope.clone(),
247 accessible_scopes: request.accessible_scopes.clone(),
248 runner_os: request.runner_os,
249 compression: request.compression,
250 enable_cross_os_archive: request.enable_cross_os_archive,
251 lookup_only: false,
252 fail_on_cache_miss: false,
253 matched: None,
254 cache_hit: String::new(),
255 restored_files: 0,
256 restored_bytes: 0,
257 saved_files,
258 saved_bytes,
259 path_records: saved.path_records,
260 checks,
261 })
262}
263
264pub(crate) fn context_with_defaults(options: &CheckWorkflowOptions) -> Value {
265 let mut root = match options.context.clone() {
266 Value::Object(object) => object,
267 _ => Map::new(),
268 };
269
270 root.entry("runner".to_owned()).or_insert_with(|| {
271 let mut runner = Map::new();
272 runner.insert(
273 "os".to_owned(),
274 Value::String(options.common.runner_os.gha_name().to_owned()),
275 );
276 Value::Object(runner)
277 });
278
279 root.entry("github".to_owned()).or_insert_with(|| {
280 let mut github = Map::new();
281 github.insert(
282 "ref".to_owned(),
283 Value::String(normalize_ref(&options.common.ref_name)),
284 );
285 github.insert(
286 "ref_name".to_owned(),
287 Value::String(ref_name_only(&options.common.ref_name)),
288 );
289 github.insert("event_name".to_owned(), Value::String("push".to_owned()));
290 Value::Object(github)
291 });
292
293 Value::Object(root)
294}
295
296fn request_from_restore(options: &RestoreOptions) -> CacheRequest {
297 request_from_parts(
298 &options.common,
299 options.key.clone(),
300 options.restore_keys.clone(),
301 options.paths.clone(),
302 )
303}
304
305fn request_from_save(options: &SaveOptions) -> CacheRequest {
306 request_from_parts(
307 &options.common,
308 options.key.clone(),
309 Vec::new(),
310 options.paths.clone(),
311 )
312}
313
314fn validate_request(request: &CacheRequest) -> Vec<Check> {
315 let mut checks = Vec::new();
316 if request.key.is_empty() {
317 checks.push(Check::fail("cache.key", "cache key cannot be empty"));
318 } else if request.key.len() > 512 {
319 checks.push(Check::fail(
320 "cache.key.length",
321 "cache key exceeds GitHub's 512 character limit",
322 ));
323 } else {
324 checks.push(Check::pass(
325 "cache.key",
326 "cache key is present and within the 512 character limit",
327 ));
328 }
329
330 if request.paths.is_empty() {
331 checks.push(Check::fail(
332 "cache.path",
333 "at least one cache path is required",
334 ));
335 } else {
336 checks.push(Check::pass(
337 "cache.path",
338 "at least one cache path is configured",
339 ));
340 }
341
342 if request.accessible_scopes.is_empty() {
343 checks.push(Check::fail(
344 "cache.scope",
345 "no accessible cache scopes were computed",
346 ));
347 } else {
348 checks.push(Check::pass(
349 "cache.scope",
350 format!(
351 "computed {} accessible cache scopes",
352 request.accessible_scopes.len()
353 ),
354 ));
355 }
356
357 checks
358}
359
360fn receipt(
361 mode: &str,
362 store: Utf8PathBuf,
363 workspace: Utf8PathBuf,
364 operations: Vec<CacheOperationReceipt>,
365 workflows: Vec<crate::model::WorkflowCacheReport>,
366) -> CacheProofReceipt {
367 let mut checks = Vec::new();
368 let mut summary = ReceiptSummary::default();
369 for operation in &operations {
370 let op_summary = operation.summary();
371 summary.add(&op_summary);
372 checks.extend(operation.checks.clone());
373 }
374 for workflow in &workflows {
375 summary.add(&workflow.summary);
376 checks.extend(workflow.checks.clone());
377 }
378
379 CacheProofReceipt {
380 schema_version: SCHEMA_VERSION,
381 tool: ToolInfo {
382 name: TOOL_NAME.to_owned(),
383 version: TOOL_VERSION.to_owned(),
384 },
385 checked_at: Utc::now(),
386 mode: mode.to_owned(),
387 store,
388 workspace,
389 summary,
390 operations,
391 workflows,
392 checks,
393 }
394}
395
396fn ref_name_only(value: &str) -> String {
397 value
398 .trim()
399 .trim_start_matches("refs/heads/")
400 .trim_start_matches("refs/tags/")
401 .to_owned()
402}