1use crate::config::GitFlowConfig;
9use crate::git::GitFlow;
10use crate::stage::Stage;
11use crate::version;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use tracing::{info, warn};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Hook {
19 BranchCreate,
21 BranchCleanup,
23 DocsUpdate,
25 ChangelogAppend,
27 VersionBump,
29}
30
31#[derive(Debug, Clone)]
33pub struct HookContext {
34 pub phase: u32,
36 pub project_root: PathBuf,
38 pub stage: Stage,
40 pub git_flow: GitFlowConfig,
42}
43
44#[derive(Debug, thiserror::Error)]
46pub enum HookError {
47 #[error(transparent)]
49 Git(#[from] crate::git::GitError),
50 #[error(transparent)]
52 Version(#[from] version::VersionError),
53 #[error("hook I/O failed: {0}")]
55 Io(#[from] std::io::Error),
56}
57
58impl Hook {
59 pub fn run(&self, ctx: &HookContext) -> Result<(), HookError> {
61 match self {
62 Hook::BranchCreate => branch_create(ctx),
63 Hook::BranchCleanup => branch_cleanup(ctx),
64 Hook::DocsUpdate => docs_update(ctx),
65 Hook::ChangelogAppend => changelog_append(ctx),
66 Hook::VersionBump => version_bump(ctx),
67 }
68 }
69}
70
71pub fn hooks_for_transition(from: Stage, to: Stage) -> Vec<Hook> {
77 match (from, to) {
78 (Stage::Validate, Stage::Ship) => vec![Hook::DocsUpdate, Hook::ChangelogAppend],
79 _ => Vec::new(),
80 }
81}
82
83pub fn hooks_after_ship() -> Vec<Hook> {
85 vec![Hook::VersionBump, Hook::BranchCleanup]
86}
87
88fn branch_create(ctx: &HookContext) -> Result<(), HookError> {
89 let git = GitFlow::new(&ctx.project_root);
90 let branch = git.feature_start(ctx.phase)?;
91 info!("BranchCreate: created {branch}");
92 Ok(())
93}
94
95fn branch_cleanup(ctx: &HookContext) -> Result<(), HookError> {
96 let git = GitFlow::new(&ctx.project_root);
97 let branch = format!("{}phase-{:02}", ctx.git_flow.feature_prefix, ctx.phase);
98 if git.branch_exists(&branch) {
99 match git.delete_branch(&branch, false) {
101 Ok(()) => info!("BranchCleanup: deleted {branch}"),
102 Err(err) => {
103 let message = err.to_string();
104 if message.contains("not fully merged") || message.contains("not yet merged") {
105 warn!(
106 "BranchCleanup: feature branch {branch} is not merged yet — left in place"
107 );
108 } else {
109 warn!("BranchCleanup: could not delete {branch}: {err}");
110 }
111 }
112 }
113 }
114 Ok(())
115}
116
117fn docs_update(ctx: &HookContext) -> Result<(), HookError> {
118 let output = Command::new("sh")
119 .arg("-c")
120 .arg("cargo doc --no-deps 2>&1")
121 .current_dir(&ctx.project_root)
122 .output();
123 match output {
124 Ok(out) if out.status.success() => {
125 let git = GitFlow::new(&ctx.project_root);
127 if let Err(err) = git.commit_all("docs: update generated docs") {
128 warn!("DocsUpdate: commit failed: {err}");
129 } else {
130 info!("DocsUpdate: docs regenerated and committed");
131 }
132 }
133 Ok(_) => warn!("DocsUpdate: cargo doc reported a failure; skipping commit"),
134 Err(err) => warn!("DocsUpdate: could not run cargo doc: {err}"),
135 }
136 Ok(())
137}
138
139fn changelog_append(ctx: &HookContext) -> Result<(), HookError> {
140 let version = version::compute_version(&ctx.project_root)
141 .map(|v| v.to_string())
142 .unwrap_or_else(|_| "unreleased".to_string());
143 let path = ctx.project_root.join("CHANGELOG.md");
144 let existing = std::fs::read_to_string(&path).unwrap_or_default();
145 let updated = crate::ship::prepend_changelog(&existing, &version, &today());
146 std::fs::write(&path, updated)?;
147 info!("ChangelogAppend: wrote entry for {version}");
148 Ok(())
149}
150
151fn version_bump(ctx: &HookContext) -> Result<(), HookError> {
152 let version = version::compute_version(&ctx.project_root)?;
153 match version::write_version(&ctx.project_root, &version) {
155 Ok(path) => info!("VersionBump: wrote {version} to {}", path.display()),
156 Err(err) => warn!("VersionBump: could not write version file: {err}"),
157 }
158 let git = GitFlow::new(&ctx.project_root);
159 let tag = format!("v{version}");
160 match git.tag(&tag) {
161 Ok(()) => info!("VersionBump: tagged {tag}"),
162 Err(err) => warn!("VersionBump: could not tag {tag}: {err}"),
163 }
164 Ok(())
165}
166
167fn today() -> String {
169 Command::new("date")
170 .arg("+%Y-%m-%d")
171 .output()
172 .ok()
173 .filter(|o| o.status.success())
174 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
175 .filter(|s| !s.is_empty())
176 .unwrap_or_else(|| "unreleased".to_string())
177}
178
179pub fn has_version_file(project_root: &Path) -> bool {
182 version::detect_version_file(project_root).is_some()
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn git(root: &Path, args: &[&str]) {
190 let ok = Command::new("git")
191 .args(args)
192 .current_dir(root)
193 .output()
194 .unwrap()
195 .status
196 .success();
197 assert!(ok, "git {args:?} failed");
198 }
199
200 fn init_repo(root: &Path) {
201 git(root, &["init", "-q"]);
202 git(root, &["config", "user.email", "test@example.com"]);
203 git(root, &["config", "user.name", "Test"]);
204 git(root, &["config", "commit.gpgsign", "false"]);
205 git(root, &["config", "tag.gpgsign", "false"]);
206 git(root, &["config", "core.hooksPath", "/dev/null"]);
207 std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
208 git(root, &["add", "."]);
209 git(root, &["commit", "-q", "-m", "init"]);
210 git(root, &["branch", "-M", "main"]);
211 git(root, &["checkout", "-q", "-b", "develop"]);
212 }
213
214 fn ctx(root: &Path, stage: Stage) -> HookContext {
215 HookContext {
216 phase: 11,
217 project_root: root.to_path_buf(),
218 stage,
219 git_flow: GitFlowConfig::default(),
220 }
221 }
222
223 #[test]
224 fn transition_map_finalizes_docs_and_changelog_before_ship() {
225 assert_eq!(
226 hooks_for_transition(Stage::Validate, Stage::Ship),
227 vec![Hook::DocsUpdate, Hook::ChangelogAppend]
228 );
229 assert!(hooks_for_transition(Stage::Define, Stage::Plan).is_empty());
230 assert!(hooks_for_transition(Stage::Code, Stage::Validate).is_empty());
231 }
232
233 #[test]
234 fn validate_to_ship_hooks_append_changelog() {
235 let dir = tempfile::tempdir().unwrap();
236 init_repo(dir.path());
237 let context = ctx(dir.path(), Stage::Ship);
238
239 for hook in hooks_for_transition(Stage::Validate, Stage::Ship) {
240 hook.run(&context).unwrap();
241 }
242
243 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
244 assert!(changelog.contains("## "));
245 }
246
247 #[test]
248 fn after_ship_runs_version_and_cleanup() {
249 assert_eq!(
250 hooks_after_ship(),
251 vec![Hook::VersionBump, Hook::BranchCleanup]
252 );
253 }
254
255 #[test]
256 fn branch_create_makes_feature_branch() {
257 let dir = tempfile::tempdir().unwrap();
258 init_repo(dir.path());
259 Hook::BranchCreate
260 .run(&ctx(dir.path(), Stage::Define))
261 .unwrap();
262 assert!(GitFlow::new(dir.path()).branch_exists("feature/phase-11"));
263 }
264
265 #[test]
266 fn changelog_append_writes_entry() {
267 let dir = tempfile::tempdir().unwrap();
268 init_repo(dir.path());
269 Hook::ChangelogAppend
270 .run(&ctx(dir.path(), Stage::Ship))
271 .unwrap();
272 let changelog = std::fs::read_to_string(dir.path().join("CHANGELOG.md")).unwrap();
273 assert!(changelog.contains("# Changelog"));
274 }
275
276 #[test]
277 fn version_bump_tags_repo() {
278 let dir = tempfile::tempdir().unwrap();
279 init_repo(dir.path());
280 let expected = format!("v{}", version::compute_version(dir.path()).unwrap());
283 Hook::VersionBump
284 .run(&ctx(dir.path(), Stage::Ship))
285 .unwrap();
286 let tags = Command::new("git")
287 .arg("tag")
288 .current_dir(dir.path())
289 .output()
290 .unwrap();
291 assert!(String::from_utf8_lossy(&tags.stdout).contains(&expected));
292 }
293
294 #[test]
295 fn branch_cleanup_is_fail_soft_when_branch_absent() {
296 let dir = tempfile::tempdir().unwrap();
297 init_repo(dir.path());
298 Hook::BranchCleanup
300 .run(&ctx(dir.path(), Stage::Ship))
301 .unwrap();
302 }
303}