1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
/*
* Mitts Build — Rust-first build and package tooling with shared artifact cache
* Copyright (c) 2026 Alexander Panasenko
*
* Contact: alex@prod.codes
* Author: https://prod.codes/about/
* Project: https://github.com/alex09x/mitts
* SPDX-License-Identifier: MIT OR Apache-2.0
*/
use crate::core::hash::Sha256;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// A single entry in an action manifest mapping a set of extra file inputs to a result action key.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionManifestEntry {
/// Extra inputs not present in base inputs: [(normalized_path, sha256)].
/// Workspace-relative paths are prefixed with "ws:".
pub extra_inputs: Vec<(String, String)>,
/// The action key of the compiled result stored in the Action Cache.
pub result_key: String,
}
/// A two-level action manifest for a specific base action key.
/// Maps different variants of external tracked inputs (e.g. `include_str!`, `#[path]`)
/// to their corresponding compiled result keys in the Action Cache.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ActionManifest {
pub base_key: String,
pub entries: Vec<ActionManifestEntry>,
}
impl ActionManifest {
/// Loads an action manifest from a file.
pub fn from_file(path: &Path) -> Result<Option<Self>> {
if !path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read action manifest from {:?}", path))?;
let manifest: Self = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse action manifest from {:?}", path))?;
Ok(Some(manifest))
}
/// Atomically saves the manifest to a destination path using a temporary file.
pub fn save_atomic(&self, dest_path: &Path, tmp_dir: &Path) -> Result<()> {
let json_bytes = serde_json::to_vec_pretty(self)
.context("Failed to serialize ActionManifest to JSON")?;
let pid = std::process::id();
let counter = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp_path = tmp_dir.join(format!(
"{}.{}.{}.manifest.tmp",
self.base_key, pid, counter
));
fs::write(&tmp_path, json_bytes)
.with_context(|| format!("Failed to write tmp manifest to {:?}", tmp_path))?;
if let Err(err) = fs::rename(&tmp_path, dest_path) {
let _ = fs::remove_file(&tmp_path);
anyhow::bail!(
"Failed to atomically rename manifest to {:?}: {}",
dest_path,
err
);
}
Ok(())
}
/// Finds a matching result key for the current worktree state.
/// Returns Some(result_key) if an entry matches, or None.
pub fn find_matching_entry(
&self,
ws_root: Option<&Path>,
effective_env: Option<&HashMap<String, String>>,
out_dir: Option<&Path>,
) -> Option<String> {
for entry in &self.entries {
let mut matches = true;
for (path_str, expected_hash) in &entry.extra_inputs {
if let Some(var_name) = path_str.strip_prefix("env:") {
let actual_hash = if let Some(env) = effective_env {
if let Some(val) = env.get(var_name) {
Sha256::digest(val.as_bytes())
} else {
match std::env::var(var_name) {
Ok(v) => Sha256::digest(v.as_bytes()),
Err(_) => Sha256::digest(b"__UNSET__"),
}
}
} else {
match std::env::var(var_name) {
Ok(v) => Sha256::digest(v.as_bytes()),
Err(_) => Sha256::digest(b"__UNSET__"),
}
};
if actual_hash != *expected_hash {
matches = false;
break;
}
continue;
}
let file_path = if let Some(rel) = path_str.strip_prefix("ws:") {
if let Some(root) = ws_root {
root.join(rel)
} else {
PathBuf::from(rel)
}
} else if let Some(rel) = path_str.strip_prefix("out:") {
if let Some(out) = out_dir {
out.join(rel)
} else {
PathBuf::from(rel)
}
} else {
PathBuf::from(path_str)
};
if !file_path.is_file() {
matches = false;
break;
}
match Sha256::digest_file(&file_path) {
Ok(actual_hash) if actual_hash == *expected_hash => {}
_ => {
matches = false;
break;
}
}
}
if matches {
return Some(entry.result_key.clone());
}
}
None
}
/// Records or updates an entry in this manifest.
pub fn record_entry(&mut self, mut extra_inputs: Vec<(String, String)>, result_key: String) {
extra_inputs.sort_by(|a, b| a.0.cmp(&b.0));
extra_inputs.dedup_by(|a, b| a.0 == b.0);
for entry in &mut self.entries {
if entry.extra_inputs == extra_inputs {
entry.result_key = result_key;
return;
}
}
self.entries.push(ActionManifestEntry {
extra_inputs,
result_key,
});
if self.entries.len() > 50 {
self.entries.drain(0..self.entries.len() - 50);
}
}
}