Add post-change hooks feature
After rendering a template, `tmpl` will now run any commands specified in the `hooks` property of a template instruction. Hooks can either be "immediate," meaning they will run as soon as the new file is written, or "deferred," and will run after all templates have been rendered. Deferred hooks are deduplicated: if multiple templates specify the exact same command, it will only be run once. Hook commands can include the `%s` placeholder, which will be replaced by the path of the rendered file.master
parent
bd7b80dede
commit
6d0bfeedaf
|
@ -44,6 +44,15 @@ version = "1.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
|
@ -209,6 +218,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -776,6 +786,12 @@ dependencies = [
|
|||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
|
@ -820,6 +836,12 @@ dependencies = [
|
|||
"syn 2.0.48",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
|
@ -899,10 +921,12 @@ name = "tmpl"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argparse",
|
||||
"blake2",
|
||||
"file-mode",
|
||||
"pwd-grp",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"shlex",
|
||||
"tera",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
|
|
|
@ -7,10 +7,12 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
argparse = "0.2.2"
|
||||
blake2 = "0.10.6"
|
||||
file-mode = { version = "0.1.2", features = ["serde"] }
|
||||
pwd-grp = "0.1.1"
|
||||
serde = { version = "1.0.195", features = ["derive"] }
|
||||
serde_yaml = "0.9.30"
|
||||
shlex = "1.2.0"
|
||||
tera = "1.19.1"
|
||||
thiserror = "1.0.56"
|
||||
tracing = { version = "0.1.40", features = ["log"] }
|
||||
|
|
129
src/main.rs
129
src/main.rs
|
@ -1,17 +1,27 @@
|
|||
mod model;
|
||||
mod templating;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{Read, Seek, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use argparse::{ArgumentParser, Store, StoreOption};
|
||||
use argparse::{ArgumentParser, Store, StoreOption, StoreTrue};
|
||||
use blake2::{Blake2b512, Digest};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_yaml::Value;
|
||||
use shlex::Shlex;
|
||||
use tera::{Context, Tera};
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use model::Instructions;
|
||||
|
||||
macro_rules! joinargs {
|
||||
($args:ident) => {
|
||||
shlex::join($args.iter().map(|s| s.as_str()))
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum LoadError {
|
||||
#[error("{0}")]
|
||||
|
@ -44,6 +54,7 @@ struct Args {
|
|||
values: String,
|
||||
templates: Option<String>,
|
||||
destdir: Option<PathBuf>,
|
||||
skip_hooks: bool,
|
||||
}
|
||||
|
||||
fn parse_args(args: &mut Args) {
|
||||
|
@ -66,6 +77,11 @@ fn parse_args(args: &mut Args) {
|
|||
StoreOption,
|
||||
"Destination directory",
|
||||
);
|
||||
parser.refer(&mut args.skip_hooks).add_option(
|
||||
&["--skip-hooks", "-S"],
|
||||
StoreTrue,
|
||||
"Skip running hooks after rendering templates",
|
||||
);
|
||||
parser.parse_args_or_exit();
|
||||
}
|
||||
|
||||
|
@ -96,9 +112,13 @@ fn main() {
|
|||
};
|
||||
let templates = args.templates.as_deref().unwrap_or("templates");
|
||||
let destdir = args.destdir.as_deref().unwrap_or(Path::new("/"));
|
||||
if let Err(e) =
|
||||
process_instructions(templates, destdir, instructions, values)
|
||||
{
|
||||
if let Err(e) = process_instructions(
|
||||
templates,
|
||||
destdir,
|
||||
instructions,
|
||||
values,
|
||||
args.skip_hooks,
|
||||
) {
|
||||
error!("Failed to process instructions: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
@ -120,6 +140,7 @@ fn process_instructions(
|
|||
destdir: impl AsRef<Path>,
|
||||
instructions: Instructions,
|
||||
values: Value,
|
||||
skip_hooks: bool,
|
||||
) -> Result<(), ProcessInstructionsError> {
|
||||
let mut tera = Tera::new(&format!("{}/**", templates))?;
|
||||
tera.register_filter(
|
||||
|
@ -128,6 +149,7 @@ fn process_instructions(
|
|||
);
|
||||
|
||||
let ctx = Context::from_serialize(&values)?;
|
||||
let mut post_hooks = HashSet::new();
|
||||
for i in instructions.render {
|
||||
let out = match tera.render(&i.template, &ctx) {
|
||||
Ok(o) => o,
|
||||
|
@ -139,10 +161,37 @@ fn process_instructions(
|
|||
};
|
||||
let mut dest = PathBuf::from(destdir.as_ref());
|
||||
dest.push(i.dest.strip_prefix("/").unwrap_or(i.dest.as_path()));
|
||||
let orig_cksm = checksum(&dest).ok();
|
||||
if let Err(e) = write_file(&dest, out.as_bytes()) {
|
||||
error!("Failed to write output file {}: {}", dest.display(), e);
|
||||
continue;
|
||||
}
|
||||
let new_cksm = checksum(&dest).ok();
|
||||
if orig_cksm != new_cksm {
|
||||
info!("File {} was changed", dest.display());
|
||||
if let Some(hooks) = i.hooks {
|
||||
if let Some(changed) = hooks.changed {
|
||||
for hook in changed {
|
||||
if let Some(args) = parse_hook(&hook.run, &dest) {
|
||||
if hook.immediate {
|
||||
if !skip_hooks {
|
||||
run_hook(args);
|
||||
} else {
|
||||
info!(
|
||||
"Skipping hook: {}",
|
||||
joinargs!(args)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
post_hooks.insert(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("File {} was NOT changed", dest.display());
|
||||
}
|
||||
if i.owner.is_some() || i.group.is_some() {
|
||||
if let Err(e) =
|
||||
chown(&dest, i.owner.as_deref(), i.group.as_deref())
|
||||
|
@ -156,6 +205,14 @@ fn process_instructions(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
for args in post_hooks {
|
||||
if !skip_hooks {
|
||||
run_hook(args);
|
||||
} else {
|
||||
info!("Skipping hook: {}", joinargs!(args));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -173,7 +230,7 @@ fn write_file(
|
|||
let mut f = std::fs::File::create(&dest)?;
|
||||
f.write_all(data)?;
|
||||
let size = f.stream_position()?;
|
||||
info!("Wrote output: {} ({} bytes)", dest.as_ref().display(), size);
|
||||
debug!("Wrote output: {} ({} bytes)", dest.as_ref().display(), size);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -240,3 +297,63 @@ fn format_error(e: &dyn std::error::Error) -> String {
|
|||
}
|
||||
msg
|
||||
}
|
||||
|
||||
fn checksum(path: impl AsRef<Path>) -> std::io::Result<Vec<u8>> {
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let mut blake = Blake2b512::new();
|
||||
loop {
|
||||
let mut buf = vec![0u8; 16384];
|
||||
match f.read_exact(&mut buf) {
|
||||
Ok(_) => blake.update(buf),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
blake.update(buf);
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(blake.finalize().to_vec())
|
||||
}
|
||||
|
||||
fn parse_hook(command: &str, path: &Path) -> Option<Vec<String>> {
|
||||
let mut bad_path = false;
|
||||
let args: Vec<_> = Shlex::new(command)
|
||||
.map(|a| {
|
||||
if a == "%s" {
|
||||
if let Some(p) = path.as_os_str().to_str() {
|
||||
p.into()
|
||||
} else {
|
||||
bad_path = true;
|
||||
a
|
||||
}
|
||||
} else {
|
||||
a
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if bad_path {
|
||||
warn!("Cannot run hook: path is not valid UTF-8");
|
||||
return None;
|
||||
}
|
||||
if args.is_empty() {
|
||||
warn!(
|
||||
"Invalid hook for {} ({}): empty argument list",
|
||||
path.display(),
|
||||
command
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(args)
|
||||
}
|
||||
|
||||
fn run_hook(args: Vec<String>) {
|
||||
info!("Running hook: {}", joinargs!(args));
|
||||
if let Err(e) = _run_hook(args) {
|
||||
error!("Error running hook: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn _run_hook(args: Vec<String>) -> std::io::Result<()> {
|
||||
Command::new(&args[0]).args(&args[1..]).spawn()?.wait()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
13
src/model.rs
13
src/model.rs
|
@ -2,6 +2,18 @@ use std::path::PathBuf;
|
|||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Hook {
|
||||
pub run: String,
|
||||
#[serde(default)]
|
||||
pub immediate: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Hooks {
|
||||
pub changed: Option<Vec<Hook>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RenderInstruction {
|
||||
pub template: String,
|
||||
|
@ -9,6 +21,7 @@ pub struct RenderInstruction {
|
|||
pub owner: Option<String>,
|
||||
pub group: Option<String>,
|
||||
pub mode: Option<String>,
|
||||
pub hooks: Option<Hooks>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
Loading…
Reference in New Issue