speccoder_runtime/format/speckit/
hooks.rs1use std::path::Path;
13
14use serde::Deserialize;
15
16pub const FILE: &str = ".specify/extensions.yml";
18
19#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
21pub struct Hook {
22 #[serde(default)]
24 pub extension: String,
25 #[serde(default)]
27 pub command: String,
28 #[serde(default = "yes")]
30 pub enabled: bool,
31 #[serde(default = "yes")]
33 pub optional: bool,
34 #[serde(default)]
36 pub priority: Option<i64>,
37 #[serde(default)]
39 pub condition: Option<serde_json::Value>,
40 #[serde(default)]
42 pub prompt: Option<String>,
43 #[serde(default)]
45 pub description: Option<String>,
46}
47
48fn yes() -> bool {
49 true
50}
51
52impl Hook {
53 #[must_use]
55 pub fn skill(&self) -> String {
56 self.command.replace('.', "-")
57 }
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct Plan {
63 pub run: Vec<Hook>,
65 pub offer: Vec<Hook>,
67 pub skipped: Vec<(Hook, String)>,
69}
70
71#[derive(Debug, Deserialize)]
72struct RawFile {
73 #[serde(default)]
74 hooks: std::collections::BTreeMap<String, serde_json::Value>,
75}
76
77pub fn plan(root: &Path, event: &str) -> Result<Plan, String> {
81 let path = root.join(FILE);
82 let text = match std::fs::read_to_string(&path) {
83 Ok(t) => t,
84 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Plan::default()),
85 Err(e) => return Err(format!("{}: {e}", path.display())),
86 };
87 plan_from(&text, event).map_err(|e| format!("{FILE}: {e} — no hooks were checked"))
88}
89
90pub fn plan_from(text: &str, event: &str) -> Result<Plan, String> {
92 let file: RawFile = if text.trim().is_empty() {
93 RawFile {
94 hooks: Default::default(),
95 }
96 } else {
97 serde_saphyr::from_str(text).map_err(|e| e.to_string())?
98 };
99 let entries = match file.hooks.get(event) {
100 None | Some(serde_json::Value::Null) => Vec::new(),
101 Some(serde_json::Value::Array(items)) => items.clone(),
102 Some(single @ serde_json::Value::Object(_)) => vec![single.clone()],
103 Some(_) => return Err(format!("hooks.{event} must be a list")),
104 };
105 let mut hooks: Vec<Hook> = entries
106 .into_iter()
107 .filter_map(|v| serde_json::from_value::<Hook>(v).ok())
108 .filter(|h| h.enabled && !h.command.is_empty())
109 .collect();
110 hooks.sort_by_key(|h| h.priority.unwrap_or(10));
111 let mut out = Plan::default();
112 for h in hooks {
113 let conditional = h
114 .condition
115 .as_ref()
116 .is_some_and(|c| !matches!(c, serde_json::Value::Null) && c.as_str() != Some(""));
117 if conditional {
118 out.skipped.push((
119 h,
120 "has a condition (evaluated by Spec Kit's hook executor)".to_owned(),
121 ));
122 } else if h.optional {
123 out.offer.push(h);
124 } else {
125 out.run.push(h);
126 }
127 }
128 Ok(out)
129}
130
131#[must_use]
133pub fn offer_line(event: &str, h: &Hook) -> String {
134 format!(
135 "optional {event} hook ({}): {} — run it with `speccoder spec {}`",
136 h.extension,
137 h.prompt
138 .clone()
139 .or_else(|| h.description.clone())
140 .unwrap_or_default(),
141 h.skill()
142 )
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 const FILE_TEXT: &str = r#"
150installed: [git]
151settings:
152 auto_execute_hooks: true
153hooks:
154 before_implement:
155 - extension: git
156 command: speckit.git.commit
157 enabled: true
158 optional: true
159 priority: 10
160 prompt: Commit outstanding changes before implementation?
161 - extension: audit
162 command: speckit.audit.check
163 optional: false
164 priority: 5
165 - extension: x
166 command: speckit.x.conditional
167 optional: false
168 condition: "config.x.enabled"
169 - extension: off
170 command: speckit.off.never
171 enabled: false
172 optional: false
173 after_implement: []
174"#;
175
176 #[test]
177 fn mandatory_hooks_run_optional_ones_are_offered_conditional_ones_skipped() {
178 let p = plan_from(FILE_TEXT, "before_implement").unwrap_or_else(|e| panic!("{e}"));
179 assert_eq!(
180 p.run.iter().map(Hook::skill).collect::<Vec<_>>(),
181 ["speckit-audit-check"]
182 );
183 assert_eq!(
184 p.offer.iter().map(Hook::skill).collect::<Vec<_>>(),
185 ["speckit-git-commit"]
186 );
187 assert_eq!(p.skipped.len(), 1);
188 assert!(offer_line("before_implement", &p.offer[0]).contains("Commit outstanding changes"));
189 assert_eq!(
190 plan_from(FILE_TEXT, "after_implement").unwrap_or_default(),
191 Plan::default()
192 );
193 assert_eq!(
194 plan_from("", "before_implement").unwrap_or_default(),
195 Plan::default()
196 );
197 assert!(plan_from("hooks: [", "x").is_err());
198 }
199}