pub mod deliver;
pub mod events;
pub mod gate;
pub mod report;
pub mod trust;
use std::path::{Path, PathBuf};
use serde::Serialize;
use sha2::{Digest as _, Sha256};
pub use speccoder_core::hooks::{HookDef, HookFile, HookRun, Point, Role, Runner, TrustState};
pub fn parse(text: &str) -> Result<Vec<HookDef>, String> {
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let Some(hooks) = table.get("hooks") else {
return Ok(Vec::new());
};
let Some(hooks) = hooks.as_table() else {
return Err("[hooks] holds one table per hook: [hooks.<name>]".to_owned());
};
let mut out = hooks
.iter()
.map(|(name, v)| {
let v = serde_json::to_value(v).map_err(|e| e.to_string())?;
speccoder_core::hooks::parse(name, &v)
})
.collect::<Result<Vec<_>, _>>()?;
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn load(root: &Path) -> Result<Vec<HookDef>, String> {
let Some(dir) = crate::verify::find_config(root) else {
return Ok(Vec::new());
};
let file = dir.join(crate::verify::config::FILE);
let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
parse(&text)
}
#[must_use]
pub fn gate_tables(table: &toml::Table) -> serde_json::Value {
let mut out = serde_json::Map::new();
if let Some(hooks) = table.get("hooks").and_then(toml::Value::as_table) {
for (name, t) in hooks {
if t.get("at").is_some()
&& let Ok(v) = serde_json::to_value(t)
{
out.insert(name.clone(), v);
}
}
}
if out.is_empty() {
serde_json::Value::Null
} else {
speccoder_core::hooks::canonical(&serde_json::Value::Object(out))
}
}
#[derive(Debug, Clone)]
pub struct Digested {
pub def: HookDef,
pub digest: String,
pub files: Vec<HookFile>,
pub outside: Vec<String>,
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[must_use]
pub fn digest(def: &HookDef, root: &Path) -> Digested {
let canonical_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut files = Vec::new();
let mut outside = Vec::new();
for f in &def.files {
let content = std::fs::read(canonical_root.join(f)).ok();
files.push(HookFile {
path: f.clone(),
digest: content.map_or_else(
|| "missing".to_owned(),
|c| format!("sha256:{}", sha256_hex(&c)),
),
});
}
for arg in def.argv().unwrap_or_default() {
let candidate = Path::new(arg);
let full = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
canonical_root.join(candidate)
};
let Ok(resolved) = std::fs::canonicalize(&full) else {
continue;
};
if !resolved.is_file() {
continue;
}
match resolved.strip_prefix(&canonical_root) {
Ok(rel) => {
let content = std::fs::read(&resolved).unwrap_or_default();
let path = rel.to_string_lossy().replace('\\', "/");
if !files.iter().any(|f: &HookFile| f.path == path) {
files.push(HookFile {
path,
digest: format!("sha256:{}", sha256_hex(&content)),
});
}
}
Err(_) => outside.push(arg.clone()),
}
}
let mut bytes = def.definition.to_string().into_bytes();
for f in &files {
bytes.push(0);
bytes.extend_from_slice(f.path.as_bytes());
bytes.push(0);
bytes.extend_from_slice(f.digest.as_bytes());
}
Digested {
def: def.clone(),
digest: format!("sha256:{}", sha256_hex(&bytes)),
files,
outside,
}
}
#[must_use]
pub fn judge_files(project: &Path, wt_root: &Path) -> Vec<String> {
let prefix = std::fs::canonicalize(project)
.ok()
.zip(std::fs::canonicalize(wt_root).ok())
.and_then(|(p, w)| p.strip_prefix(&w).ok().map(Path::to_path_buf))
.unwrap_or_default();
load(project)
.unwrap_or_default()
.iter()
.flat_map(|d| digest(d, project).files)
.map(|f| prefix.join(&f.path).to_string_lossy().replace('\\', "/"))
.collect()
}
#[must_use]
pub fn dir(state: &Path, root: &Path) -> PathBuf {
crate::worktree::project_state_dir(state, root).join("hooks")
}
#[must_use]
pub fn project_id(state: &Path, root: &Path) -> String {
crate::worktree::project_state_dir(state, root)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub const RECENT: usize = 20;
const RUNS: &str = "runs.json";
#[must_use]
pub fn runs(dir: &Path) -> std::collections::BTreeMap<String, Vec<HookRun>> {
std::fs::read_to_string(dir.join(RUNS))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn record_run(dir: &Path, hook: &str, run: HookRun) {
let mut all = runs(dir);
let list = all.entry(hook.to_owned()).or_default();
list.push(run);
let excess = list.len().saturating_sub(RECENT);
list.drain(..excess);
write_json(dir, RUNS, &all);
}
pub(crate) fn write_json(dir: &Path, name: &str, value: &impl Serialize) {
let _ = std::fs::create_dir_all(dir);
if let Ok(text) = serde_json::to_string_pretty(value) {
let tmp = dir.join(format!(".{name}.tmp"));
if std::fs::write(&tmp, text).is_ok() {
let _ = std::fs::rename(&tmp, dir.join(name));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOML: &str = r#"
[hooks.licence]
at = "tick"
argv = ["sh", "scripts/licence.sh"]
[hooks.chat]
on = ["*"]
url = "https://hooks.example.com/x"
"#;
#[test]
fn hooks_are_read_by_name_and_a_bad_one_is_an_error() {
let hooks = parse(TOML).unwrap();
assert_eq!(
hooks.iter().map(|h| h.name.as_str()).collect::<Vec<_>>(),
["chat", "licence"]
);
assert!(parse("[hooks]\nx = 1\n").is_err());
assert!(parse("").unwrap().is_empty());
}
#[test]
fn a_byte_of_the_definition_or_its_script_changes_the_digest() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
std::fs::write(dir.path().join("scripts/licence.sh"), "exit 0\n").unwrap();
let hooks = parse(TOML).unwrap();
let gate = &hooks[1];
let first = digest(gate, dir.path());
assert_eq!(first.files.len(), 1);
assert_eq!(first.files[0].path, "scripts/licence.sh");
assert_eq!(digest(gate, dir.path()).digest, first.digest);
std::fs::write(dir.path().join("scripts/licence.sh"), "exit 1\n").unwrap();
assert_ne!(digest(gate, dir.path()).digest, first.digest);
let reordered = parse(
"[hooks.licence]\nargv = [\"sh\", \"scripts/licence.sh\"]\n# a comment\nat = \"tick\"\n",
)
.unwrap();
assert_eq!(
digest(&reordered[0], dir.path()).digest,
digest(gate, dir.path()).digest
);
let longer =
parse("[hooks.licence]\nat = \"tick\"\nargv = [\"sh\", \"scripts/licence.sh\"]\ntimeout = 61\n")
.unwrap();
assert_ne!(
digest(&longer[0], dir.path()).digest,
digest(gate, dir.path()).digest
);
}
#[test]
fn only_gates_join_the_judges_configuration() {
let table: toml::Table = toml::from_str(TOML).unwrap();
let v = gate_tables(&table);
assert!(v.get("licence").is_some() && v.get("chat").is_none());
let moved: toml::Table =
toml::from_str(&TOML.replace("hooks.example.com", "other.example.com")).unwrap();
assert_eq!(gate_tables(&moved), v);
}
#[test]
fn a_hook_keeps_its_last_twenty_runs() {
let dir = tempfile::tempdir().unwrap();
for i in 0..25 {
record_run(
dir.path(),
"licence",
HookRun {
at: format!("{i}"),
what: "test".into(),
outcome: "passed".into(),
duration_ms: 1,
error: None,
},
);
}
let r = &runs(dir.path())["licence"];
assert_eq!(r.len(), RECENT);
assert_eq!(r[0].at, "5");
}
}