devlog/
hook.rs

1//! A hook is an executable program called while executing a devlog command.
2//! It allows users to customize devlog for their workflows.
3//! Hooks are located in the `hooks` subdirectory of the devlog repository.
4
5use crate::config::Config;
6use crate::error::Error;
7use std::ffi::OsStr;
8use std::fs::{create_dir_all, OpenOptions};
9use std::io::Write;
10use std::os::unix::fs::PermissionsExt;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14const HOOK_DIR_NAME: &'static str = "hooks";
15
16/// Defines the types of hooks a user can configure.
17pub enum HookType {
18    /// Invoked before opening a devlog entry in a text editor.
19    /// It takes a single argument: the full path to the devlog entry file.
20    BeforeEdit,
21
22    /// Invoked after the text editor program exits with status success.
23    /// It takes a single argument: the full path to the devlog entry file.
24    AfterEdit,
25
26    /// Invoked before rolling over a devlog entry file.
27    /// It takes a single argument: the full path to the devlog entry file.
28    BeforeRollover,
29
30    /// Invoked after rolling over a devlog entry file.
31    /// It takes two arguments: first, the full path to the old devlog entry file;
32    /// second, the full path to the new devlog entry file.
33    AfterRollover,
34}
35
36impl HookType {
37    /// Returns the name of the hook.
38    /// This is the same as the hook's filename on disk.
39    pub fn name(&self) -> String {
40        match self {
41            HookType::BeforeEdit => "before-edit",
42            HookType::AfterEdit => "after-edit",
43            HookType::BeforeRollover => "before-rollover",
44            HookType::AfterRollover => "after-rollover",
45        }
46        .to_string()
47    }
48}
49
50const ALL_HOOK_TYPES: &[HookType] = &[
51    HookType::BeforeEdit,
52    HookType::AfterEdit,
53    HookType::BeforeRollover,
54    HookType::AfterRollover,
55];
56
57const HOOK_TEMPLATE: &'static str = "#!/usr/bin/env sh
58# To enable this hook, make this file executable.
59echo \"$0 $@\"
60";
61
62/// Creates template hook files in the specified repository.
63/// By default, the hook files are non-executable, which means they are disabled.
64pub fn init_hooks(repo_dir: &Path) -> Result<(), Error> {
65    let hook_dir = hook_dir_path(repo_dir);
66    create_dir_all(&hook_dir)?;
67    for hook_type in ALL_HOOK_TYPES {
68        let mut p = hook_dir.clone();
69        p.push(hook_type.name());
70
71        if !p.exists() {
72            let mut f = OpenOptions::new()
73                .create(true)
74                .truncate(true)
75                .write(true)
76                .open(&p)
77                .unwrap();
78            write!(f, "{}", HOOK_TEMPLATE)?;
79        }
80    }
81    Ok(())
82}
83
84/// Executes a hook command if available.
85/// If no hook is available (e.g. because the hook file is non-executable)
86/// then this is a no-op.
87pub fn execute_hook<W: Write>(
88    w: &mut W,
89    config: &Config,
90    hook_type: &HookType,
91    args: &[&OsStr],
92) -> Result<(), Error> {
93    if let Some(mut cmd) = hook_cmd(config.repo_dir(), hook_type)? {
94        let status = cmd.args(args).status()?;
95        if !status.success() {
96            if let Some(code) = status.code() {
97                write!(w, "{} hook exited with status {}\n", hook_type.name(), code)?;
98            }
99        }
100    }
101    Ok(())
102}
103
104/// Retrieves the executable hook command if it exists.
105pub fn hook_cmd(repo_dir: &Path, hook_type: &HookType) -> Result<Option<Command>, Error> {
106    let mut p = hook_dir_path(repo_dir);
107    p.push(hook_type.name());
108    is_valid(&p).map(|valid| if valid { Some(Command::new(&p)) } else { None })
109}
110
111fn hook_dir_path(repo_dir: &Path) -> PathBuf {
112    let mut p = repo_dir.to_path_buf();
113    p.push(HOOK_DIR_NAME);
114    p
115}
116
117fn is_valid(p: &Path) -> Result<bool, Error> {
118    Ok(p.exists() && is_executable(p)?)
119}
120
121fn is_executable(p: &Path) -> Result<bool, Error> {
122    p.metadata()
123        .map(|metadata| {
124            let perm = metadata.permissions();
125            perm.mode() & 0o111 != 0
126        })
127        .map_err(From::from)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::fs::{create_dir, set_permissions, File, Permissions};
134    use std::io::Read;
135    use std::os::unix::fs::PermissionsExt;
136    use tempfile::tempdir;
137
138    fn create_hook_dir(repo_dir: &Path) {
139        let mut hook_dir = repo_dir.to_path_buf();
140        hook_dir.push(HOOK_DIR_NAME);
141        create_dir(&hook_dir).unwrap();
142    }
143
144    fn create_hook_file(repo_dir: &Path, hook_type: HookType, executable: bool) {
145        let mut p = repo_dir.to_path_buf();
146        p.push(HOOK_DIR_NAME);
147        p.push(hook_type.name());
148
149        let mut f = OpenOptions::new()
150            .create(true)
151            .write(true)
152            .open(&p)
153            .unwrap();
154        write!(f, "#!/usr/bin/env sh\necho 'Hello world!'\n").unwrap();
155
156        if executable {
157            set_permissions(&p, Permissions::from_mode(0o555)).unwrap()
158        }
159    }
160
161    #[test]
162    fn test_init_hooks() {
163        let repo_dir = tempdir().unwrap();
164        init_hooks(repo_dir.path()).unwrap();
165
166        // Initially, all hooks are disabled
167        for hook_type in ALL_HOOK_TYPES {
168            let result = hook_cmd(repo_dir.path(), hook_type).unwrap();
169            assert!(result.is_none());
170        }
171
172        // Enable them by updating permissions
173        for hook_type in ALL_HOOK_TYPES {
174            let mut p = repo_dir.path().to_path_buf();
175            p.push(HOOK_DIR_NAME);
176            p.push(hook_type.name());
177            set_permissions(&p, Permissions::from_mode(0o555)).unwrap()
178        }
179
180        // Now all hooks should be enabled and execute successfully
181        for hook_type in ALL_HOOK_TYPES {
182            let result = hook_cmd(repo_dir.path(), hook_type).unwrap();
183            assert!(result.is_some());
184
185            let mut cmd = result.unwrap();
186            let status = cmd.status().unwrap();
187            assert!(status.success())
188        }
189    }
190
191    #[test]
192    fn test_init_hooks_some_already_exist() {
193        let repo_dir = tempdir().unwrap();
194
195        // Manually create an existing hook
196        let mut p = repo_dir.path().to_path_buf();
197        p.push(HOOK_DIR_NAME);
198        create_dir_all(&p).unwrap();
199
200        p.push(HookType::AfterEdit.name());
201        let mut f = File::create(&p).unwrap();
202        write!(f, "existing hook").unwrap();
203
204        // Initialize hooks
205        init_hooks(repo_dir.path()).unwrap();
206
207        // Verify that all hooks exist
208        for hook_type in ALL_HOOK_TYPES {
209            let mut p = repo_dir.path().to_path_buf();
210            p.push(HOOK_DIR_NAME);
211            p.push(hook_type.name());
212            assert!(p.exists());
213        }
214
215        // Verify that the existing hook wasn't modified
216        let mut f = File::open(&p).unwrap();
217        let mut s = String::new();
218        f.read_to_string(&mut s).unwrap();
219        assert_eq!(s, "existing hook");
220    }
221
222    #[test]
223    fn test_hook_dir_does_not_exist() {
224        let repo_dir = tempdir().unwrap();
225        let result = hook_cmd(repo_dir.path(), &HookType::BeforeEdit).unwrap();
226        assert!(result.is_none());
227    }
228
229    #[test]
230    fn test_hook_file_does_not_exist() {
231        let repo_dir = tempdir().unwrap();
232        create_hook_dir(repo_dir.path());
233        let result = hook_cmd(repo_dir.path(), &HookType::BeforeEdit).unwrap();
234        assert!(result.is_none());
235    }
236
237    #[test]
238    fn test_hook_file_is_not_executable() {
239        let repo_dir = tempdir().unwrap();
240        create_hook_dir(repo_dir.path());
241        create_hook_file(repo_dir.path(), HookType::BeforeEdit, false);
242        let result = hook_cmd(repo_dir.path(), &HookType::BeforeEdit).unwrap();
243        assert!(result.is_none());
244    }
245
246    #[test]
247    fn test_hook_valid_hook_cmd() {
248        let repo_dir = tempdir().unwrap();
249        create_hook_dir(repo_dir.path());
250        create_hook_file(repo_dir.path(), HookType::BeforeEdit, true);
251
252        let result = hook_cmd(repo_dir.path(), &HookType::BeforeEdit).unwrap();
253        assert!(result.is_some());
254
255        let mut cmd = result.unwrap();
256        let status = cmd.status().unwrap();
257        assert!(status.success())
258    }
259}