Skip to main content

devflow_core/
hooks.rs

1//! Stage-transition hooks.
2//!
3//! Branching, docs, changelog, and version bumps are no longer workflow stages
4//! (as they were in v0.x). They are *hooks* that fire at specific stage
5//! transitions. [`hooks_for_transition`] maps a `(from, to)` stage move to the
6//! hooks that should run, and [`Hook::run`] executes one.
7
8use 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/// A side-effecting action that fires at a stage transition.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Hook {
19    /// Create the `feature/phase-NN` branch from develop.
20    BranchCreate,
21    /// Delete the merged feature branch after Ship.
22    BranchCleanup,
23    /// Regenerate and commit docs.
24    DocsUpdate,
25    /// Append a CHANGELOG entry.
26    ChangelogAppend,
27    /// Compute and write the next version, then tag it.
28    VersionBump,
29}
30
31/// Context passed to every hook.
32#[derive(Debug, Clone)]
33pub struct HookContext {
34    /// Phase the workflow is on.
35    pub phase: u32,
36    /// Project root.
37    pub project_root: PathBuf,
38    /// Stage the workflow is entering.
39    pub stage: Stage,
40    /// Git-flow branch model.
41    pub git_flow: GitFlowConfig,
42}
43
44/// Errors produced by hooks.
45#[derive(Debug, thiserror::Error)]
46pub enum HookError {
47    /// A git-flow operation failed.
48    #[error(transparent)]
49    Git(#[from] crate::git::GitError),
50    /// A version operation failed.
51    #[error(transparent)]
52    Version(#[from] version::VersionError),
53    /// Filesystem operation failed.
54    #[error("hook I/O failed: {0}")]
55    Io(#[from] std::io::Error),
56}
57
58impl Hook {
59    /// Run this hook against the given context.
60    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
71/// Which hooks fire when moving `from` → `to`.
72///
73/// - Validate → Ship: docs + changelog are finalized before shipping.
74/// - Ship → (done): version bump + branch cleanup.
75/// - everything else: none.
76pub 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
83/// Hooks that fire after Ship completes (the workflow's terminal transition).
84pub 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        // Non-force cleanup is intentional: never discard unmerged work.
100        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            // Commit any doc changes; ignore "nothing to commit".
126            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    // Write the computed version into the version file when one exists.
154    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
167/// Today's date as YYYY-MM-DD (best-effort via the `date` command).
168fn 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
179/// Whether a project has a version file, used by callers to decide if a version
180/// bump is meaningful.
181pub 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        // Hybrid SemVer: major 2 (Cargo.toml), minor 0 (no tags), patch from
281        // the commit count since the last tag — one `init` commit → v2.0.1.
282        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        // No feature branch exists — cleanup must still succeed.
299        Hook::BranchCleanup
300            .run(&ctx(dir.path(), Stage::Ship))
301            .unwrap();
302    }
303}