collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Post-agent hooks: auto-commit, auto-lint, auto-test.
//!
//! These hooks run after the agent completes tool execution that modified files.

use std::path::Path;
use std::process::Command;

/// Configuration for post-edit hooks.
#[derive(Debug, Clone)]
pub struct HooksConfig {
    /// Auto-commit after file modifications. Default: false.
    /// Set via `COLLET_AUTO_COMMIT=1`.
    pub auto_commit: bool,
    /// Lint command to run after edits (e.g., "cargo clippy --fix").
    /// Set via `COLLET_LINT_CMD`.
    pub lint_cmd: Option<String>,
    /// Test command to run after edits (e.g., "cargo test").
    /// Set via `COLLET_TEST_CMD`.
    pub test_cmd: Option<String>,
}

impl HooksConfig {
    /// Create from a resolved Config (preferred — uses config file + env).
    ///
    /// Falls back to `from_env()` for any field not set in the config file,
    /// so that `COLLET_AUTO_COMMIT` / `COLLET_LINT_CMD` / `COLLET_TEST_CMD`
    /// remain effective when the config file omits those fields.
    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),
        }
    }

    /// Legacy: create from env vars only.
    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()
    }
}

/// Result of running post-edit hooks.
#[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,
}

/// Run all configured post-edit hooks.
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;
    }

    // 1. Auto-lint (runs before commit so lint fixes are included)
    if let Some(ref cmd) = config.lint_cmd {
        results.lint = Some(run_shell_hook(cmd, working_dir, "lint"));
    }

    // 2. Auto-commit
    if config.auto_commit {
        results.commit = Some(auto_commit(working_dir, modified_files));
    }

    // 3. Auto-test (runs after commit)
    if let Some(ref cmd) = config.test_cmd {
        results.test = Some(run_shell_hook(cmd, working_dir, "test"));
    }

    results
}

/// Auto-commit modified files with an AI-generated commit message.
fn auto_commit(working_dir: &str, modified_files: &[String]) -> HookOutcome {
    let dir = Path::new(working_dir);

    // Stage modified files
    for file in modified_files {
        let _ = Command::new("git")
            .args(["add", file])
            .current_dir(dir)
            .output();
    }

    // Check if there are staged changes
    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(),
        };
    }

    // Generate a commit message from the file list
    let msg = if modified_files.len() == 1 {
        format!("collet: update {}", modified_files[0])
    } else {
        format!(
            "collet: update {} files ({})",
            modified_files.len(),
            modified_files.join(", ")
        )
    };

    // Truncate message if too long
    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}"),
        },
    }
}

/// Run an arbitrary shell command as a hook.
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}");

            // Truncate output
            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}"),
        },
    }
}

/// Format hook results for display in chat.
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() {
        // SAFETY: test-only, single-threaded test
        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);
    }
}