diff --git a/Cargo.toml b/Cargo.toml index 62ceaed5..60a66607 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ serde = "^1" tempdir = "^0" semver = "0.10.0" moins = "0.4.0" +shell-words = "^1" lazy_static = "1.4.0" toml = "0.5.6" clap = { version = "^2", optional = true } diff --git a/src/hook.rs b/src/hook.rs new file mode 100644 index 00000000..0215e501 --- /dev/null +++ b/src/hook.rs @@ -0,0 +1,138 @@ +use std::{fmt, process::Command, str::FromStr}; + +use crate::Result; + +static ENTRY_SYMBOL: char = '%'; + +pub struct Hook(Vec); + +impl FromStr for Hook { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + bail!("hook must not be an empty string") + } + + let words = shell_words::split(s)?; + + Ok(Hook(words)) + } +} + +impl fmt::Display for Hook { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let command = shell_words::join(self.0.iter()); + f.write_str(&command) + } +} + +impl Hook { + pub fn entries(&mut self) -> impl Iterator { + self.0 + .iter_mut() + .filter(|s| s.starts_with(ENTRY_SYMBOL)) + .map(HookEntry) + } + + pub fn is_ready(&self) -> bool { + !self.0.iter().any(|s| s.starts_with(ENTRY_SYMBOL)) + } + + pub fn run(&self) -> Result<()> { + // Hook should have all entries filled before running + assert!(self.is_ready()); + + let (cmd, args) = self.0.split_first().expect("hook must not be empty"); + + let status = Command::new(&cmd).args(args).status()?; + + if !status.success() { + Err(anyhow!("hook failed with status {}", status)) + } else { + Ok(()) + } + } +} + +pub struct HookEntry<'a>(&'a mut String); + +impl HookEntry<'_> { + pub fn fill<'b, F>(&mut self, f: F) -> Result<()> + where + F: FnOnce(&str) -> Option<&'b str> + 'b, + { + // trim ENTRY_SYMBOL in the beginning + let key = &self.0[1..]; + + let value = f(key).ok_or_else(|| anyhow!("unknown key {}", key))?; + + self.0.clear(); + self.0.push_str(value); + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::Hook; + use crate::Result; + use std::str::FromStr; + + #[test] + fn parse_empty_string() { + assert!(Hook::from_str("").is_err()) + } + + #[test] + fn parse_valid_string() -> Result<()> { + let hook = Hook::from_str("cargo bump %version")?; + assert_eq!( + &hook.0, + &["cargo".to_string(), "bump".into(), "%version".into()] + ); + Ok(()) + } + + #[test] + fn fill_entries() -> Result<()> { + let mut hook = Hook::from_str("cmd %one %two %three")?; + + assert!(!hook.is_ready()); + + hook.entries().try_for_each(|mut entry| { + entry.fill(|key| match key { + "one" => Some("1"), + "two" => Some("2"), + "three" => Some("3"), + _ => None, + }) + })?; + + assert!(hook.is_ready()); + + assert_eq!( + &hook.0, + &["cmd".to_string(), "1".into(), "2".into(), "3".into()] + ); + + Ok(()) + } + + #[test] + fn fill_entries_unknown_key() -> Result<()> { + let mut hook = Hook::from_str("%unknown")?; + + assert!(!hook.is_ready()); + + let result = hook + .entries() + .try_for_each(|mut entry| entry.fill(|_| None)); + assert!(result.is_err()); + + assert!(!hook.is_ready()); + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 5a602091..64d7401d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod error; pub mod filter; pub mod commit; +pub mod hook; pub mod repository; pub mod settings; pub mod version; @@ -22,20 +23,21 @@ use crate::filter::CommitFilters; use crate::repository::Repository; use crate::settings::Settings; use crate::version::{parse_pre_release, VersionIncrement}; -use anyhow::Result; +use anyhow::{Context, Result}; use chrono::Utc; use colored::*; use commit::Commit; use git2::{Oid, RebaseOptions}; +use hook::Hook; use semver::Version; use serde::export::fmt::Display; use serde::export::Formatter; use settings::AuthorSetting; -use std::collections::HashMap; use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{exit, Command, Stdio}; +use std::{collections::HashMap, str::FromStr}; use tempdir::TempDir; pub type CommitsMetadata = HashMap; @@ -393,6 +395,8 @@ impl CocoGitto { .write() .map_err(|err| anyhow!("Unable to write CHANGELOG.md : {}", err))?; + self.run_bump_hooks(&version_str)?; + self.repository.add_all()?; self.repository .commit(&format!("chore(version): {}", next_version))?; @@ -496,6 +500,32 @@ impl CocoGitto { .map_err(|err| anyhow!("`{}` is not a valid oid : {}", input, err)) } } + + fn run_bump_hooks(&self, next_version: &str) -> Result<()> { + let settings = Settings::get(&self.repository)?; + + let hooks = settings + .hooks + .iter() + .map(String::as_str) + .map(Hook::from_str) + .enumerate() + .map(|(idx, result)| result.context(format!("Cannot parse hook at index {}", idx))) + .collect::>>()?; + + for mut hook in hooks { + hook.entries().try_for_each(|mut entry| { + entry.fill(|key| match key { + "version" => Some(next_version), + _ => None, + }) + })?; + + hook.run().context(format!("{}", hook))?; + } + + Ok(()) + } } enum OidOf { diff --git a/tests/cog_bump_test.rs b/tests/cog_bump_test.rs index 06f39e2b..0b479f52 100644 --- a/tests/cog_bump_test.rs +++ b/tests/cog_bump_test.rs @@ -133,3 +133,28 @@ fn pre_release_bump() -> Result<()> { Ok(std::env::set_current_dir(current_dir)?) } + +#[test] +#[cfg(not(tarpaulin))] +#[cfg(target_os = "linux")] +fn bump_with_hook() -> Result<()> { + let current_dir = std::env::current_dir()?; + let mut command = Command::cargo_bin("cog")?; + command.arg("bump").arg("--major"); + + let temp_dir = TempDir::default(); + std::env::set_current_dir(&temp_dir)?; + + std::fs::write("cog.toml", r#"hooks = ["touch %version"]"#)?; + + helper::git_init(".")?; + helper::git_commit("chore: init")?; + helper::git_tag("1.0.0")?; + helper::git_commit("feat: feature")?; + + command.assert().success(); + assert!(temp_dir.join("2.0.0").exists()); + helper::assert_tag("2.0.0")?; + + Ok(std::env::set_current_dir(current_dir)?) +}