use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct HooksConfig {
pub auto_commit: bool,
pub lint_cmd: Option<String>,
pub test_cmd: Option<String>,
}
impl HooksConfig {
pub fn from_config(config: &crate::config::Config) -> Self {
let env = Self::from_env();
Self {
auto_commit: config.auto_commit || env.auto_commit,
lint_cmd: config.lint_cmd.clone().or(env.lint_cmd),
test_cmd: config.test_cmd.clone().or(env.test_cmd),
}
}
pub fn from_env() -> Self {
Self {
auto_commit: std::env::var("COLLET_AUTO_COMMIT")
.map(|v| v == "1" || v == "true")
.unwrap_or(false),
lint_cmd: std::env::var("COLLET_LINT_CMD")
.ok()
.filter(|s| !s.is_empty()),
test_cmd: std::env::var("COLLET_TEST_CMD")
.ok()
.filter(|s| !s.is_empty()),
}
}
pub fn has_any(&self) -> bool {
self.auto_commit || self.lint_cmd.is_some() || self.test_cmd.is_some()
}
}
#[derive(Debug)]
pub struct HookResults {
pub commit: Option<HookOutcome>,
pub lint: Option<HookOutcome>,
pub test: Option<HookOutcome>,
}
#[derive(Debug)]
pub struct HookOutcome {
pub success: bool,
pub output: String,
}
pub fn run_post_edit_hooks(
config: &HooksConfig,
working_dir: &str,
modified_files: &[String],
) -> HookResults {
let mut results = HookResults {
commit: None,
lint: None,
test: None,
};
if modified_files.is_empty() {
return results;
}
if let Some(ref cmd) = config.lint_cmd {
results.lint = Some(run_shell_hook(cmd, working_dir, "lint"));
}
if config.auto_commit {
results.commit = Some(auto_commit(working_dir, modified_files));
}
if let Some(ref cmd) = config.test_cmd {
results.test = Some(run_shell_hook(cmd, working_dir, "test"));
}
results
}
fn auto_commit(working_dir: &str, modified_files: &[String]) -> HookOutcome {
let dir = Path::new(working_dir);
for file in modified_files {
let _ = Command::new("git")
.args(["add", file])
.current_dir(dir)
.output();
}
let status = Command::new("git")
.args(["diff", "--cached", "--quiet"])
.current_dir(dir)
.status();
if status.map(|s| s.success()).unwrap_or(true) {
return HookOutcome {
success: true,
output: "No changes to commit.".to_string(),
};
}
let msg = if modified_files.len() == 1 {
format!("collet: update {}", modified_files[0])
} else {
format!(
"collet: update {} files ({})",
modified_files.len(),
modified_files.join(", ")
)
};
let msg = if msg.len() > 72 {
format!("collet: update {} files", modified_files.len())
} else {
msg
};
match Command::new("git")
.args(["commit", "-m", &msg])
.current_dir(dir)
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
HookOutcome {
success: output.status.success(),
output: if output.status.success() {
format!("Committed: {msg}\n{stdout}")
} else {
format!("Commit failed: {stderr}")
},
}
}
Err(e) => HookOutcome {
success: false,
output: format!("Failed to run git commit: {e}"),
},
}
}
fn run_shell_hook(cmd: &str, working_dir: &str, label: &str) -> HookOutcome {
tracing::info!(cmd = cmd, label = label, "Running post-edit hook");
match Command::new("sh")
.args(["-c", cmd])
.current_dir(working_dir)
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
let preview = if combined.len() > 1000 {
format!(
"{}...\n(truncated)",
crate::util::truncate_bytes(&combined, 1000)
)
} else {
combined
};
HookOutcome {
success: output.status.success(),
output: preview,
}
}
Err(e) => HookOutcome {
success: false,
output: format!("Failed to run {label} hook: {e}"),
},
}
}
pub fn format_results(results: &HookResults) -> Option<String> {
let mut parts = Vec::new();
if let Some(ref lint) = results.lint {
let icon = if lint.success { "✓" } else { "✗" };
parts.push(format!("{icon} **Lint**: {}", truncate_line(&lint.output)));
}
if let Some(ref commit) = results.commit {
let icon = if commit.success { "✓" } else { "✗" };
parts.push(format!(
"{icon} **Commit**: {}",
truncate_line(&commit.output)
));
}
if let Some(ref test) = results.test {
let icon = if test.success { "✓" } else { "✗" };
parts.push(format!("{icon} **Test**: {}", truncate_line(&test.output)));
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
fn truncate_line(s: &str) -> String {
let first_line = s.lines().next().unwrap_or(s);
if first_line.len() > 120 {
format!("{}...", crate::util::truncate_bytes(first_line, 120))
} else {
first_line.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hooks_config_defaults() {
unsafe {
std::env::remove_var("COLLET_AUTO_COMMIT");
std::env::remove_var("COLLET_LINT_CMD");
std::env::remove_var("COLLET_TEST_CMD");
}
let config = HooksConfig::from_env();
assert!(!config.auto_commit);
assert!(config.lint_cmd.is_none());
assert!(config.test_cmd.is_none());
assert!(!config.has_any());
}
#[test]
fn test_hooks_config_has_any() {
let config = HooksConfig {
auto_commit: true,
lint_cmd: None,
test_cmd: None,
};
assert!(config.has_any());
}
#[test]
fn test_run_hooks_empty_files() {
let config = HooksConfig {
auto_commit: true,
lint_cmd: Some("echo lint".to_string()),
test_cmd: Some("echo test".to_string()),
};
let results = run_post_edit_hooks(&config, "/tmp", &[]);
assert!(results.commit.is_none());
assert!(results.lint.is_none());
assert!(results.test.is_none());
}
#[test]
fn test_format_results_empty() {
let results = HookResults {
commit: None,
lint: None,
test: None,
};
assert!(format_results(&results).is_none());
}
#[test]
fn test_format_results_with_outcomes() {
let results = HookResults {
commit: None,
lint: Some(HookOutcome {
success: true,
output: "All clean".to_string(),
}),
test: Some(HookOutcome {
success: false,
output: "1 failure".to_string(),
}),
};
let formatted = format_results(&results).unwrap();
assert!(formatted.contains("✓"));
assert!(formatted.contains("✗"));
assert!(formatted.contains("Lint"));
assert!(formatted.contains("Test"));
}
#[test]
fn test_truncate_line_short() {
assert_eq!(truncate_line("short"), "short");
}
#[test]
fn test_truncate_line_long() {
let long = "x".repeat(200);
let result = truncate_line(&long);
assert!(result.len() < 200);
assert!(result.ends_with("..."));
}
#[test]
fn test_truncate_line_multiline() {
assert_eq!(truncate_line("first\nsecond"), "first");
}
#[test]
fn test_shell_hook_echo() {
let result = run_shell_hook("echo hello", "/tmp", "test");
assert!(result.success);
assert!(result.output.contains("hello"));
}
#[test]
fn test_shell_hook_failure() {
let result = run_shell_hook("false", "/tmp", "test");
assert!(!result.success);
}
}