1pub mod deliver;
7pub mod events;
8pub mod gate;
9pub mod report;
10pub mod trust;
11
12use std::path::{Path, PathBuf};
13
14use serde::Serialize;
15use sha2::{Digest as _, Sha256};
16pub use speccoder_core::hooks::{HookDef, HookFile, HookRun, Point, Role, Runner, TrustState};
17
18pub fn parse(text: &str) -> Result<Vec<HookDef>, String> {
24 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
25 let Some(hooks) = table.get("hooks") else {
26 return Ok(Vec::new());
27 };
28 let Some(hooks) = hooks.as_table() else {
29 return Err("[hooks] holds one table per hook: [hooks.<name>]".to_owned());
30 };
31 let mut out = hooks
32 .iter()
33 .map(|(name, v)| {
34 let v = serde_json::to_value(v).map_err(|e| e.to_string())?;
35 speccoder_core::hooks::parse(name, &v)
36 })
37 .collect::<Result<Vec<_>, _>>()?;
38 out.sort_by(|a, b| a.name.cmp(&b.name));
39 Ok(out)
40}
41
42pub fn load(root: &Path) -> Result<Vec<HookDef>, String> {
48 let Some(dir) = crate::verify::find_config(root) else {
49 return Ok(Vec::new());
50 };
51 let file = dir.join(crate::verify::config::FILE);
52 let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
53 parse(&text)
54}
55
56#[must_use]
59pub fn gate_tables(table: &toml::Table) -> serde_json::Value {
60 let mut out = serde_json::Map::new();
61 if let Some(hooks) = table.get("hooks").and_then(toml::Value::as_table) {
62 for (name, t) in hooks {
63 if t.get("at").is_some()
64 && let Ok(v) = serde_json::to_value(t)
65 {
66 out.insert(name.clone(), v);
67 }
68 }
69 }
70 if out.is_empty() {
71 serde_json::Value::Null
72 } else {
73 speccoder_core::hooks::canonical(&serde_json::Value::Object(out))
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct Digested {
80 pub def: HookDef,
82 pub digest: String,
84 pub files: Vec<HookFile>,
86 pub outside: Vec<String>,
89}
90
91fn sha256_hex(bytes: &[u8]) -> String {
92 Sha256::digest(bytes)
93 .iter()
94 .map(|b| format!("{b:02x}"))
95 .collect()
96}
97
98#[must_use]
100pub fn digest(def: &HookDef, root: &Path) -> Digested {
101 let canonical_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
102 let mut files = Vec::new();
103 let mut outside = Vec::new();
104 for f in &def.files {
107 let content = std::fs::read(canonical_root.join(f)).ok();
108 files.push(HookFile {
109 path: f.clone(),
110 digest: content.map_or_else(
111 || "missing".to_owned(),
112 |c| format!("sha256:{}", sha256_hex(&c)),
113 ),
114 });
115 }
116 for arg in def.argv().unwrap_or_default() {
117 let candidate = Path::new(arg);
118 let full = if candidate.is_absolute() {
119 candidate.to_path_buf()
120 } else {
121 canonical_root.join(candidate)
122 };
123 let Ok(resolved) = std::fs::canonicalize(&full) else {
124 continue;
125 };
126 if !resolved.is_file() {
127 continue;
128 }
129 match resolved.strip_prefix(&canonical_root) {
130 Ok(rel) => {
131 let content = std::fs::read(&resolved).unwrap_or_default();
132 let path = rel.to_string_lossy().replace('\\', "/");
133 if !files.iter().any(|f: &HookFile| f.path == path) {
134 files.push(HookFile {
135 path,
136 digest: format!("sha256:{}", sha256_hex(&content)),
137 });
138 }
139 }
140 Err(_) => outside.push(arg.clone()),
142 }
143 }
144 let mut bytes = def.definition.to_string().into_bytes();
145 for f in &files {
146 bytes.push(0);
147 bytes.extend_from_slice(f.path.as_bytes());
148 bytes.push(0);
149 bytes.extend_from_slice(f.digest.as_bytes());
150 }
151 Digested {
152 def: def.clone(),
153 digest: format!("sha256:{}", sha256_hex(&bytes)),
154 files,
155 outside,
156 }
157}
158
159#[must_use]
162pub fn judge_files(project: &Path, wt_root: &Path) -> Vec<String> {
163 let prefix = std::fs::canonicalize(project)
164 .ok()
165 .zip(std::fs::canonicalize(wt_root).ok())
166 .and_then(|(p, w)| p.strip_prefix(&w).ok().map(Path::to_path_buf))
167 .unwrap_or_default();
168 load(project)
169 .unwrap_or_default()
170 .iter()
171 .flat_map(|d| digest(d, project).files)
172 .map(|f| prefix.join(&f.path).to_string_lossy().replace('\\', "/"))
173 .collect()
174}
175
176#[must_use]
179pub fn dir(state: &Path, root: &Path) -> PathBuf {
180 crate::worktree::project_state_dir(state, root).join("hooks")
181}
182
183#[must_use]
185pub fn project_id(state: &Path, root: &Path) -> String {
186 crate::worktree::project_state_dir(state, root)
187 .file_name()
188 .map(|n| n.to_string_lossy().into_owned())
189 .unwrap_or_default()
190}
191
192pub const RECENT: usize = 20;
194
195const RUNS: &str = "runs.json";
196
197#[must_use]
199pub fn runs(dir: &Path) -> std::collections::BTreeMap<String, Vec<HookRun>> {
200 std::fs::read_to_string(dir.join(RUNS))
201 .ok()
202 .and_then(|t| serde_json::from_str(&t).ok())
203 .unwrap_or_default()
204}
205
206pub fn record_run(dir: &Path, hook: &str, run: HookRun) {
208 let mut all = runs(dir);
209 let list = all.entry(hook.to_owned()).or_default();
210 list.push(run);
211 let excess = list.len().saturating_sub(RECENT);
212 list.drain(..excess);
213 write_json(dir, RUNS, &all);
214}
215
216pub(crate) fn write_json(dir: &Path, name: &str, value: &impl Serialize) {
217 let _ = std::fs::create_dir_all(dir);
218 if let Ok(text) = serde_json::to_string_pretty(value) {
219 let tmp = dir.join(format!(".{name}.tmp"));
220 if std::fs::write(&tmp, text).is_ok() {
221 let _ = std::fs::rename(&tmp, dir.join(name));
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 const TOML: &str = r#"
231[hooks.licence]
232at = "tick"
233argv = ["sh", "scripts/licence.sh"]
234
235[hooks.chat]
236on = ["*"]
237url = "https://hooks.example.com/x"
238"#;
239
240 #[test]
241 fn hooks_are_read_by_name_and_a_bad_one_is_an_error() {
242 let hooks = parse(TOML).unwrap();
243 assert_eq!(
244 hooks.iter().map(|h| h.name.as_str()).collect::<Vec<_>>(),
245 ["chat", "licence"]
246 );
247 assert!(parse("[hooks]\nx = 1\n").is_err());
248 assert!(parse("").unwrap().is_empty());
249 }
250
251 #[test]
252 fn a_byte_of_the_definition_or_its_script_changes_the_digest() {
253 let dir = tempfile::tempdir().unwrap();
254 std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
255 std::fs::write(dir.path().join("scripts/licence.sh"), "exit 0\n").unwrap();
256 let hooks = parse(TOML).unwrap();
257 let gate = &hooks[1];
258 let first = digest(gate, dir.path());
259 assert_eq!(first.files.len(), 1);
260 assert_eq!(first.files[0].path, "scripts/licence.sh");
261 assert_eq!(digest(gate, dir.path()).digest, first.digest);
262 std::fs::write(dir.path().join("scripts/licence.sh"), "exit 1\n").unwrap();
263 assert_ne!(digest(gate, dir.path()).digest, first.digest);
264 let reordered = parse(
265 "[hooks.licence]\nargv = [\"sh\", \"scripts/licence.sh\"]\n# a comment\nat = \"tick\"\n",
266 )
267 .unwrap();
268 assert_eq!(
269 digest(&reordered[0], dir.path()).digest,
270 digest(gate, dir.path()).digest
271 );
272 let longer =
273 parse("[hooks.licence]\nat = \"tick\"\nargv = [\"sh\", \"scripts/licence.sh\"]\ntimeout = 61\n")
274 .unwrap();
275 assert_ne!(
276 digest(&longer[0], dir.path()).digest,
277 digest(gate, dir.path()).digest
278 );
279 }
280
281 #[test]
282 fn only_gates_join_the_judges_configuration() {
283 let table: toml::Table = toml::from_str(TOML).unwrap();
284 let v = gate_tables(&table);
285 assert!(v.get("licence").is_some() && v.get("chat").is_none());
286 let moved: toml::Table =
287 toml::from_str(&TOML.replace("hooks.example.com", "other.example.com")).unwrap();
288 assert_eq!(gate_tables(&moved), v);
289 }
290
291 #[test]
292 fn a_hook_keeps_its_last_twenty_runs() {
293 let dir = tempfile::tempdir().unwrap();
294 for i in 0..25 {
295 record_run(
296 dir.path(),
297 "licence",
298 HookRun {
299 at: format!("{i}"),
300 what: "test".into(),
301 outcome: "passed".into(),
302 duration_ms: 1,
303 error: None,
304 },
305 );
306 }
307 let r = &runs(dir.path())["licence"];
308 assert_eq!(r.len(), RECENT);
309 assert_eq!(r[0].at, "5");
310 }
311}