1use std::path::Path;
6
7use speccoder_core::hooks::{HookEntry, HookTested, HooksReport, Role, Runner, TrustState};
8use speccoder_core::{CommandData, CommandResult, CommandStatus};
9
10use super::{Digested, HookDef};
11
12fn toml_of(name: &str, definition: &serde_json::Value) -> String {
13 let body = toml::to_string(definition).unwrap_or_default();
14 format!("[hooks.{name}]\n{body}")
15}
16
17fn problem(def: &HookDef, root: &Path) -> Option<String> {
19 match &def.runner {
20 Runner::Url {
21 secret_env: Some(var),
22 ..
23 } if std::env::var_os(var).is_none() => Some(format!(
24 "${var} is not set: export the signing key, or drop `secret_env`"
25 )),
26 Runner::Argv(argv) => {
27 let program = argv.first()?;
28 let found = if program.contains('/') {
29 root.join(program).is_file() || Path::new(program).is_file()
30 } else {
31 std::env::var_os("PATH")
32 .is_some_and(|p| std::env::split_paths(&p).any(|d| d.join(program).is_file()))
33 };
34 (!found).then(|| format!("`{program}` is not found: install it, or fix `argv`"))
35 }
36 Runner::Url { .. } => None,
37 }
38}
39
40#[must_use]
42pub fn entry(d: &Digested, root: &Path, dir: &Path) -> HookEntry {
43 let def = &d.def;
44 let state = d.trust(dir);
45 let (role, on) = match &def.role {
46 Role::Gate { .. } => ("gate", Vec::new()),
47 Role::Observer { on } => ("observer", on.clone()),
48 };
49 HookEntry {
50 name: def.name.clone(),
51 role: role.to_owned(),
52 listens: def.listens(),
53 at: def.at(),
54 on,
55 runs: match &def.runner {
56 Runner::Argv(a) => a.join(" "),
57 Runner::Url { url, .. } => url.clone(),
58 },
59 state,
60 digest: d.digest.clone(),
61 files: d.files.clone(),
62 outside: d.outside.clone(),
63 definition: toml_of(&def.name, &def.definition),
64 trusted_definition: (state == TrustState::Changed)
65 .then(|| super::trust::last_trusted(dir, &def.name))
66 .flatten()
67 .map(|t| toml_of(&def.name, &t.definition)),
68 recent: super::runs(dir).remove(&def.name).unwrap_or_default(),
69 trust_flag: format!("--trust-hook {}={}", def.name, d.digest),
70 problem: problem(def, root),
71 }
72}
73
74fn result(message: &str, report: HooksReport, status: CommandStatus) -> CommandResult {
75 CommandResult::new("hooks", status, message).with_data(CommandData::Hooks(Box::new(report)))
76}
77
78fn read(
79 root: &Path,
80 state: &Path,
81) -> Result<(Vec<Digested>, std::path::PathBuf), Box<CommandResult>> {
82 match super::load(root) {
83 Ok(defs) => Ok((
84 defs.iter().map(|d| super::digest(d, root)).collect(),
85 super::dir(state, root),
86 )),
87 Err(e) => Err(Box::new(
88 result(
89 &e,
90 HooksReport {
91 hooks: Vec::new(),
92 problem: Some(e.clone()),
93 trusted: Vec::new(),
94 tested: None,
95 delivered: None,
96 pending: None,
97 },
98 CommandStatus::ConfigError,
99 )
100 .with_code("HOOK-MISCONFIGURED"),
101 )),
102 }
103}
104
105fn report_of(hooks: &[Digested], root: &Path, dir: &Path) -> HooksReport {
106 HooksReport {
107 hooks: hooks.iter().map(|d| entry(d, root, dir)).collect(),
108 problem: None,
109 trusted: Vec::new(),
110 tested: None,
111 delivered: None,
112 pending: None,
113 }
114}
115
116fn summary(r: &HooksReport) -> String {
117 let waiting = r
118 .hooks
119 .iter()
120 .filter(|h| h.state != TrustState::Trusted)
121 .count();
122 match (r.hooks.len(), waiting) {
123 (0, _) => "no hooks: add a [hooks.<name>] to speccoder.toml (docs: hooks)".to_owned(),
124 (1, 0) => "1 hook, trusted".to_owned(),
125 (n, 0) => format!("{n} hooks, all trusted"),
126 (n, w) => format!(
127 "{n} hook{} — {w} wait{} for your trust",
128 if n == 1 { "" } else { "s" },
129 if w == 1 { "s" } else { "" }
130 ),
131 }
132}
133
134#[must_use]
136pub fn list(root: &Path, state: &Path) -> CommandResult {
137 let (hooks, dir) = match read(root, state) {
138 Ok(h) => h,
139 Err(r) => return *r,
140 };
141 let report = report_of(&hooks, root, &dir);
142 let message = summary(&report);
143 result(&message, report, CommandStatus::Ok)
144}
145
146pub fn trust(
150 root: &Path,
151 state: &Path,
152 name: Option<&str>,
153 via: &str,
154 confirm: &mut dyn FnMut(&HookEntry) -> bool,
155) -> CommandResult {
156 let (hooks, dir) = match read(root, state) {
157 Ok(h) => h,
158 Err(r) => return *r,
159 };
160 if let Some(n) = name
161 && let Some((ext, _)) = hooks
162 .iter()
163 .find(|h| h.def.name == n)
164 .and_then(|h| h.def.extension.clone())
165 {
166 return result(
167 &format!(
168 "{n} comes from the extension {ext}, trusted as a whole: speccoder extensions trust {ext}"
169 ),
170 report_of(&hooks, root, &dir),
171 CommandStatus::UsageError,
172 )
173 .with_fix(vec![format!("speccoder extensions trust {ext}")]);
174 }
175 if let Some(n) = name
176 && !hooks.iter().any(|h| h.def.name == n)
177 {
178 return result(
179 &format!("there is no hook `{n}`"),
180 report_of(&hooks, root, &dir),
181 CommandStatus::NotFound,
182 );
183 }
184 let by = crate::approvals::who_in(root);
185 let mut trusted = Vec::new();
186 for d in hooks
187 .iter()
188 .filter(|h| name.is_none_or(|n| h.def.name == n))
189 {
190 let e = entry(d, root, &dir);
191 if d.def.extension.is_some() || e.state == TrustState::Trusted || !confirm(&e) {
193 continue;
194 }
195 super::trust::record(&dir, d, &by, via);
196 if d.def.at().is_none() && !super::deliver::has_cursor(&dir, &d.def.name) {
199 super::deliver::start_here(&dir, root, &d.def.name);
200 }
201 trusted.push(d.def.name.clone());
202 }
203 let mut report = report_of(&hooks, root, &dir);
204 let message = match trusted.len() {
205 0 => summary(&report),
206 _ => format!("trusted {}: it runs from now on", trusted.join(", ")),
207 };
208 report.trusted = trusted;
209 result(&message, report, CommandStatus::Ok)
210}
211
212#[must_use]
215pub fn trust_shown(
216 root: &Path,
217 state: &Path,
218 name: &str,
219 digest: &str,
220 via: &str,
221) -> CommandResult {
222 let current = super::load(root)
223 .ok()
224 .and_then(|h| h.into_iter().find(|d| d.name == name))
225 .map(|d| super::digest(&d, root).digest);
226 if current.as_deref() != Some(digest) {
227 return CommandResult::new(
228 "hooks",
229 CommandStatus::UsageError,
230 &format!("{name} changed since it was shown: look at it again before trusting"),
231 )
232 .with_code("CALL-CHANGED");
233 }
234 trust(root, state, Some(name), via, &mut |e| e.digest == digest)
236}
237
238#[must_use]
241pub fn test(root: &Path, state: &Path, name: &str) -> CommandResult {
242 let (hooks, dir) = match read(root, state) {
243 Ok(h) => h,
244 Err(r) => return *r,
245 };
246 let Some(d) = hooks.iter().find(|h| h.def.name == name) else {
247 return result(
248 &format!("there is no hook `{name}`"),
249 report_of(&hooks, root, &dir),
250 CommandStatus::NotFound,
251 );
252 };
253 if d.trust(&dir) != TrustState::Trusted {
254 let fix = match &d.def.extension {
255 Some((ext, _)) => format!("speccoder extensions trust {ext}"),
256 None => format!("speccoder hooks trust {name}"),
257 };
258 return result(
259 &format!("{name} waits for your trust: it runs only after you trusted it"),
260 report_of(&hooks, root, &dir),
261 CommandStatus::NeedsHuman,
262 )
263 .with_code("HOOK-UNTRUSTED")
264 .with_fix(vec![fix]);
265 }
266 let began = std::time::Instant::now();
267 let tested = if d.def.at().is_some() {
268 let g = super::gate::test(&d.def, root, state);
269 HookTested {
270 name: name.to_owned(),
271 outcome: g.outcome.as_str().to_owned(),
272 detail: g.reason,
273 duration_ms: g.duration_ms,
274 }
275 } else {
276 let sample = super::events::sample(&super::project_id(state, root));
277 let sent = super::deliver::send(&d.def, &sample, root, state);
278 let duration_ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
279 super::record_run(
280 &dir,
281 name,
282 super::HookRun {
283 at: crate::attest::now_rfc3339(),
284 what: "test".to_owned(),
285 outcome: if sent.is_ok() { "delivered" } else { "failed" }.to_owned(),
286 duration_ms,
287 error: sent.as_ref().err().cloned(),
288 findings: Vec::new(),
289 findings_error: None,
290 },
291 );
292 HookTested {
293 name: name.to_owned(),
294 outcome: if sent.is_ok() { "delivered" } else { "failed" }.to_owned(),
295 detail: Some(sent.unwrap_or_else(|e| e)),
296 duration_ms,
297 }
298 };
299 let words = match (tested.outcome.as_str(), tested.detail.as_deref()) {
300 ("passed", _) => format!("{name} would pass"),
301 ("blocked", Some(why)) => format!("{name} would block — {why}"),
302 ("delivered", Some(answer)) => format!("{name} took the sample event ({answer})"),
303 ("failed", Some(why)) => format!("{name} did not take the sample event — {why}"),
304 (o, _) => format!("{name}: {o}"),
305 };
306 let mut report = report_of(&hooks, root, &dir);
307 report.tested = Some(tested);
308 result(&words, report, CommandStatus::Ok)
309}
310
311#[must_use]
313pub fn deliver(root: &Path, state: &Path) -> CommandResult {
314 let (hooks, dir) = match read(root, state) {
315 Ok(h) => h,
316 Err(r) => return *r,
317 };
318 let done = super::deliver::pass(root, state, None);
319 let mut report = report_of(&hooks, root, &dir);
320 report.delivered = Some(done.delivered);
321 report.pending = Some(done.pending);
322 let mut message = if done.busy {
323 "another speccoder process is delivering; it takes what waits".to_owned()
324 } else {
325 format!(
326 "delivered {} event{}; {} pending",
327 done.delivered,
328 if done.delivered == 1 { "" } else { "s" },
329 done.pending
330 )
331 };
332 for (name, why) in &done.failed {
333 message.push_str(&format!("\n{name} failed: {why}"));
334 }
335 if !done.untrusted.is_empty() {
336 message.push_str(&format!(
337 "\nskipped (not trusted): {}",
338 done.untrusted.join(", ")
339 ));
340 }
341 let status = if done.failed.is_empty() {
342 CommandStatus::Ok
343 } else {
344 CommandStatus::Error
345 };
346 result(&message, report, status)
347}
348
349pub fn apply_flags(root: &Path, state: &Path, flags: &[String]) -> Result<(), Box<CommandResult>> {
356 if flags.is_empty() {
357 return Ok(());
358 }
359 let (hooks, dir) = read(root, state)?;
360 let parsed = flags
361 .iter()
362 .map(|f| super::trust::parse_flag(f))
363 .collect::<Result<Vec<_>, _>>()
364 .map_err(|e| {
365 CommandResult::new("hooks", CommandStatus::UsageError, &e).with_code("USAGE")
366 })?;
367 super::trust::apply_flags(&dir, &hooks, &parsed, &crate::approvals::who_in(root)).map_err(
368 |e| CommandResult::new("hooks", CommandStatus::NeedsHuman, &e).with_code("HOOK-UNTRUSTED"),
369 )?;
370 for (name, _) in &parsed {
371 if hooks
372 .iter()
373 .any(|h| &h.def.name == name && h.def.at().is_none())
374 && !super::deliver::has_cursor(&dir, name)
375 {
376 super::deliver::start_here(&dir, root, name);
377 }
378 }
379 Ok(())
380}
381
382#[must_use]
385pub fn untrusted_notice(root: &Path, state: &Path) -> Option<String> {
386 let (hooks, dir) = read(root, state).ok()?;
387 let waiting: Vec<&str> = hooks
388 .iter()
389 .filter(|h| h.def.at().is_none() && h.def.extension.is_none())
390 .filter(|h| h.trust(&dir) != TrustState::Trusted)
391 .map(|h| h.def.name.as_str())
392 .collect();
393 (!waiting.is_empty()).then(|| {
394 format!(
395 "{} not trusted: {} nothing until you trust {} (speccoder hooks trust)",
396 waiting.join(", "),
397 if waiting.len() == 1 {
398 "it hears"
399 } else {
400 "they hear"
401 },
402 if waiting.len() == 1 { "it" } else { "them" }
403 )
404 })
405}