use std::path::Path;
use std::process::Command;
use std::time::Instant;
use anyhow::{Result, anyhow};
use crate::toriignore::{HookRules, SizeRules, glob_match};
pub fn run_hooks(label: &str, commands: &[String], repo: &Path) -> Result<()> {
if commands.is_empty() { return Ok(()); }
if std::env::var("TORII_NO_HOOKS").is_ok() {
return Ok(());
}
if !is_trusted(repo, commands)? {
if std::env::var("TORII_TRUST_HOOKS").is_ok() {
mark_trusted(repo, commands)?;
} else if !prompt_trust(repo, label, commands)? {
return Err(anyhow!(
"hook execution declined. Re-run with TORII_TRUST_HOOKS=1 to trust, \
TORII_NO_HOOKS=1 to skip, or --skip-hooks for this invocation."
));
}
}
println!("🪝 {} hooks: {} command(s)", label, commands.len());
for cmd in commands {
let start = Instant::now();
print!(" → {} ", cmd);
use std::io::Write;
std::io::stdout().flush().ok();
let status = Command::new("sh")
.arg("-c")
.arg(cmd)
.current_dir(repo)
.status()
.map_err(|e| anyhow!("failed to spawn `{}`: {}", cmd, e))?;
let dur = start.elapsed();
if !status.success() {
let code = status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into());
return Err(anyhow!(
"hook failed: `{}` exited with {} after {:.2}s — fix the issue or rerun with --skip-hooks",
cmd, code, dur.as_secs_f64()
));
}
println!("✓ ({:.2}s)", dur.as_secs_f64());
}
Ok(())
}
fn trust_file_path() -> Option<std::path::PathBuf> {
dirs::config_dir().map(|d| d.join("torii").join("hook-trust.toml"))
}
fn hash_commands(commands: &[String]) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for c in commands {
for b in c.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h ^= b'\n' as u64;
h = h.wrapping_mul(0x100000001b3);
}
format!("{:016x}", h)
}
fn repo_key(repo: &Path) -> String {
repo.canonicalize()
.unwrap_or_else(|_| repo.to_path_buf())
.to_string_lossy()
.into_owned()
}
fn is_trusted(repo: &Path, commands: &[String]) -> Result<bool> {
let Some(path) = trust_file_path() else { return Ok(false) };
if !path.exists() { return Ok(false); }
let content = std::fs::read_to_string(&path)
.map_err(|e| anyhow!("read {}: {}", path.display(), e))?;
let key = repo_key(repo);
let hash = hash_commands(commands);
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') { continue; }
let Some((k, v)) = line.split_once('=') else { continue };
let k = k.trim().trim_matches('"');
let v = v.trim().trim_matches('"');
if k == key && v == hash { return Ok(true); }
}
Ok(false)
}
fn mark_trusted(repo: &Path, commands: &[String]) -> Result<()> {
let Some(path) = trust_file_path() else { return Ok(()); };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let key = repo_key(repo);
let hash = hash_commands(commands);
let mut buf = String::new();
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
buf.push_str(line);
buf.push('\n');
continue;
}
let key_in_line = trimmed
.split_once('=')
.map(|(k, _)| k.trim().trim_matches('"').to_string())
.unwrap_or_default();
if key_in_line != key {
buf.push_str(line);
buf.push('\n');
}
}
}
}
if buf.is_empty() {
buf.push_str("# torii hook trust store — written by `torii` after explicit user consent\n");
}
buf.push_str(&format!("\"{}\" = \"{}\"\n", key, hash));
std::fs::write(&path, buf)
.map_err(|e| anyhow!("write {}: {}", path.display(), e))?;
Ok(())
}
fn prompt_trust(repo: &Path, label: &str, commands: &[String]) -> Result<bool> {
use std::io::{BufRead, IsTerminal, Write};
if !std::io::stdin().is_terminal() {
eprintln!(
"⚠️ {} hooks defined in {} (untrusted, no tty to prompt).",
label, repo.display()
);
eprintln!(" Run interactively to trust, or set TORII_TRUST_HOOKS=1 / --skip-hooks.");
return Ok(false);
}
println!();
println!("⚠️ This repo defines {} hook(s) that will run via `sh -c`:", label);
for cmd in commands {
println!(" • {}", cmd);
}
println!(" repo: {}", repo.display());
print!(" Trust and run? [y/N] ");
std::io::stdout().flush().ok();
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
let answer = line.trim().to_ascii_lowercase();
let yes = matches!(answer.as_str(), "y" | "yes");
if yes {
mark_trusted(repo, commands)?;
println!(" ✓ trusted; remembered in ~/.config/torii/hook-trust.toml");
}
Ok(yes)
}
pub fn pre_save(rules: &HookRules, repo: &Path) -> Result<()> {
run_hooks("pre-save", &rules.pre_save, repo)
}
pub fn pre_sync(rules: &HookRules, repo: &Path) -> Result<()> {
run_hooks("pre-sync", &rules.pre_sync, repo)
}
pub fn post_save(rules: &HookRules, repo: &Path) {
let _ = run_hooks("post-save", &rules.post_save, repo);
}
pub fn post_sync(rules: &HookRules, repo: &Path) {
let _ = run_hooks("post-sync", &rules.post_sync, repo);
}
pub fn check_size(rules: &SizeRules, repo: &Path, staged_paths: &[String]) -> Result<()> {
if rules.max_bytes.is_none() && rules.warn_bytes.is_none() { return Ok(()); }
let mut blocked: Vec<(String, u64)> = Vec::new();
let mut warned: Vec<(String, u64)> = Vec::new();
for rel in staged_paths {
if rules.exclude.iter().any(|g| glob_match(rel, g)) { continue; }
let abs = repo.join(rel);
let size = match std::fs::metadata(&abs) {
Ok(m) => m.len(),
Err(_) => continue, };
if let Some(max) = rules.max_bytes {
if size > max { blocked.push((rel.clone(), size)); continue; }
}
if let Some(warn) = rules.warn_bytes {
if size > warn { warned.push((rel.clone(), size)); }
}
}
for (path, size) in &warned {
println!("⚠️ large file: {} ({})", path, human_size(*size));
}
if !blocked.is_empty() {
let mut msg = String::from("size limit exceeded:\n");
for (path, size) in &blocked {
msg.push_str(&format!(" {} — {}\n", path, human_size(*size)));
}
msg.push_str("\nAdjust [size] max in .toriignore, exclude these paths, or use git LFS.");
return Err(anyhow!(msg));
}
Ok(())
}
fn human_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB { format!("{:.2} GB", bytes as f64 / GB as f64) }
else if bytes >= MB { format!("{:.2} MB", bytes as f64 / MB as f64) }
else if bytes >= KB { format!("{:.1} KB", bytes as f64 / KB as f64) }
else { format!("{} B", bytes) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::toriignore::SizeRules;
#[test]
fn human_size_boundaries() {
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(2048), "2.0 KB");
assert_eq!(human_size(2 * 1024 * 1024), "2.00 MB");
}
#[test]
fn size_check_blocks_oversize() {
let dir = tempfile::tempdir().unwrap();
let big = dir.path().join("big.bin");
std::fs::write(&big, vec![0u8; 1024 * 1024]).unwrap(); let rules = SizeRules { max_bytes: Some(500 * 1024), warn_bytes: None, exclude: vec![] };
let err = check_size(&rules, dir.path(), &["big.bin".to_string()]).unwrap_err();
assert!(err.to_string().contains("size limit exceeded"));
}
#[test]
fn size_check_respects_exclude() {
let dir = tempfile::tempdir().unwrap();
let big = dir.path().join("artwork.psd");
std::fs::write(&big, vec![0u8; 1024 * 1024]).unwrap();
let rules = SizeRules {
max_bytes: Some(100),
warn_bytes: None,
exclude: vec!["*.psd".to_string()],
};
check_size(&rules, dir.path(), &["artwork.psd".to_string()]).unwrap();
}
#[test]
fn size_check_skips_missing_file() {
let dir = tempfile::tempdir().unwrap();
let rules = SizeRules { max_bytes: Some(100), warn_bytes: None, exclude: vec![] };
check_size(&rules, dir.path(), &["nonexistent".to_string()]).unwrap();
}
}