Skip to main content

mana_core/
hooks.rs

1use anyhow::{anyhow, Context, Result};
2use serde::{Deserialize, Serialize};
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::Duration;
7
8use crate::unit::Unit;
9
10/// Maximum time to wait for a hook script before killing it.
11const HOOK_TIMEOUT: Duration = Duration::from_secs(30);
12
13// ---------------------------------------------------------------------------
14// HookEvent Enum
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum HookEvent {
19    PreCreate,
20    PostCreate,
21    PreUpdate,
22    PostUpdate,
23    PreClose,
24    PostClose,
25}
26
27impl HookEvent {
28    /// Convert HookEvent to its string representation for hook file names.
29    pub fn as_str(&self) -> &'static str {
30        match self {
31            HookEvent::PreCreate => "pre-create",
32            HookEvent::PostCreate => "post-create",
33            HookEvent::PreUpdate => "pre-update",
34            HookEvent::PostUpdate => "post-update",
35            HookEvent::PreClose => "pre-close",
36            HookEvent::PostClose => "post-close",
37        }
38    }
39}
40
41// ---------------------------------------------------------------------------
42// HookPayload
43// ---------------------------------------------------------------------------
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct HookPayload {
47    pub event: String,
48    pub unit: Unit,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub reason: Option<String>,
51}
52
53impl HookPayload {
54    /// Create a new HookPayload for the given event and unit.
55    pub fn new(event: HookEvent, unit: Unit, reason: Option<String>) -> Self {
56        Self {
57            event: event.as_str().to_string(),
58            unit,
59            reason,
60        }
61    }
62
63    /// Serialize this payload to JSON.
64    pub fn to_json(&self) -> Result<String> {
65        serde_json::to_string(self).context("Failed to serialize hook payload to JSON")
66    }
67}
68
69// ---------------------------------------------------------------------------
70// Hook Path Management
71// ---------------------------------------------------------------------------
72
73/// Get the path to a hook script based on the event and project directory.
74pub fn get_hook_path(project_dir: &Path, event: HookEvent) -> PathBuf {
75    project_dir.join(".mana").join("hooks").join(event.as_str())
76}
77
78/// Check if a hook file exists and is executable.
79pub fn is_hook_executable(path: &Path) -> bool {
80    if !path.exists() {
81        return false;
82    }
83
84    // On Unix-like systems, check execute bit
85    #[cfg(unix)]
86    {
87        use std::fs;
88        use std::os::unix::fs::PermissionsExt;
89
90        if let Ok(metadata) = fs::metadata(path) {
91            let mode = metadata.permissions().mode();
92            // Check if any execute bit is set (user, group, or other)
93            (mode & 0o111) != 0
94        } else {
95            false
96        }
97    }
98
99    // On Windows, any executable file extension is considered executable
100    #[cfg(windows)]
101    {
102        let path_str = path.to_string_lossy();
103        let exe_extensions = [".exe", ".bat", ".cmd", ".ps1", ".com"];
104        exe_extensions.iter().any(|ext| path_str.ends_with(ext))
105    }
106
107    // Default to true if we can't determine
108    #[cfg(not(any(unix, windows)))]
109    true
110}
111
112// ---------------------------------------------------------------------------
113// Hook Execution
114// ---------------------------------------------------------------------------
115
116/// Execute a hook script with the given payload.
117///
118/// # Arguments
119///
120/// * `event` - The hook event to trigger
121/// * `unit` - The unit to pass to the hook
122/// * `project_dir` - The project root directory (parent of .mana/)
123/// * `reason` - Optional reason (used for pre-close hooks)
124///
125/// # Returns
126///
127/// * `Ok(true)` - Hook passed (exit 0), or hook doesn't exist, or hooks not trusted
128/// * `Ok(false)` - Hook executed but returned non-zero exit code
129/// * `Err` - Hook exists but not executable, timeout, or I/O error
130pub fn execute_hook(
131    event: HookEvent,
132    unit: &Unit,
133    project_dir: &Path,
134    reason: Option<String>,
135) -> Result<bool> {
136    // Security model: Hooks are disabled by default. Users must explicitly enable them
137    // with `mana trust` before any hooks will execute. This ensures users review the
138    // hook scripts in .mana/hooks/ before giving them execution rights.
139    if !is_trusted(project_dir) {
140        return Ok(true);
141    }
142
143    let hook_path = get_hook_path(project_dir, event);
144
145    // If hook doesn't exist, silently return success
146    if !hook_path.exists() {
147        return Ok(true);
148    }
149
150    // If hook exists but is not executable, return error
151    if !is_hook_executable(&hook_path) {
152        return Err(anyhow!(
153            "Hook {} exists but is not executable",
154            hook_path.display()
155        ));
156    }
157
158    // Create the payload
159    let payload = HookPayload::new(event, unit.clone(), reason);
160    let json_payload = payload.to_json()?;
161
162    // Spawn the subprocess. stdout/stderr are discarded — hooks communicate
163    // exclusively via exit code. Using Stdio::null() instead of Stdio::piped()
164    // prevents deadlock: piped() without draining blocks the child once OS pipe
165    // buffers fill (~64KB), causing it to hang until the timeout kills it.
166    let mut child = Command::new(&hook_path)
167        .stdin(Stdio::piped())
168        .stdout(Stdio::null())
169        .stderr(Stdio::null())
170        .current_dir(project_dir)
171        .spawn()
172        .with_context(|| format!("Failed to spawn hook {}", hook_path.display()))?;
173
174    // Write JSON payload to stdin, then close the pipe
175    {
176        let stdin = child
177            .stdin
178            .as_mut()
179            .ok_or_else(|| anyhow!("Failed to open stdin for hook"))?;
180        stdin
181            .write_all(json_payload.as_bytes())
182            .context("Failed to write payload to hook stdin")?;
183    }
184
185    // Poll for completion with timeout
186    let start = std::time::Instant::now();
187    loop {
188        match child.try_wait() {
189            Ok(Some(status)) => return Ok(status.success()),
190            Ok(None) => {
191                if start.elapsed() > HOOK_TIMEOUT {
192                    let _ = child.kill();
193                    let _ = child.wait(); // Reap to prevent zombie process
194                    return Err(anyhow!(
195                        "Hook {} timed out after {}s",
196                        hook_path.display(),
197                        HOOK_TIMEOUT.as_secs()
198                    ));
199                }
200                std::thread::sleep(Duration::from_millis(100));
201            }
202            Err(e) => {
203                return Err(
204                    anyhow!(e).context(format!("Failed to wait for hook {}", hook_path.display()))
205                );
206            }
207        }
208    }
209}
210
211// ---------------------------------------------------------------------------
212// Trust Management
213// ---------------------------------------------------------------------------
214
215/// Check if hooks are trusted (enabled).
216///
217/// Returns true if the .mana/.hooks-trusted file exists, false otherwise.
218/// Does not error if the file doesn't exist.
219pub fn is_trusted(project_dir: &Path) -> bool {
220    project_dir.join(".mana").join(".hooks-trusted").exists()
221}
222
223/// Enable hook trust by creating the .mana/.hooks-trusted file.
224///
225/// # Returns
226///
227/// * `Ok(())` - Trust file created successfully
228/// * `Err` - Failed to create trust file
229pub fn create_trust(project_dir: &Path) -> Result<()> {
230    let trust_path = project_dir.join(".mana").join(".hooks-trusted");
231
232    let parent = trust_path
233        .parent()
234        .ok_or_else(|| anyhow!("Invalid trust path"))?;
235    std::fs::create_dir_all(parent).context("Failed to create .mana directory for trust file")?;
236
237    let metadata = format!("Hooks enabled at {}\n", chrono::Utc::now());
238    std::fs::write(&trust_path, metadata).context("Failed to create trust file")
239}
240
241/// Revoke hook trust by deleting the .mana/.hooks-trusted file.
242///
243/// # Returns
244///
245/// * `Ok(())` - Trust file deleted successfully
246/// * `Err` - Trust file doesn't exist or failed to delete
247pub fn revoke_trust(project_dir: &Path) -> Result<()> {
248    let trust_path = project_dir.join(".mana").join(".hooks-trusted");
249
250    if !trust_path.exists() {
251        return Err(anyhow!("Trust file does not exist"));
252    }
253
254    std::fs::remove_file(&trust_path).context("Failed to revoke hook trust")
255}
256
257// ---------------------------------------------------------------------------
258// Config-Based Hooks (on_close, on_fail, post_plan)
259// ---------------------------------------------------------------------------
260
261/// Template variables for config hook expansion.
262///
263/// Each field maps to a `{name}` placeholder in the hook command template.
264/// Missing variables are left as-is (e.g., `{attempt}` stays literal if not set).
265#[derive(Debug, Default)]
266pub struct HookVars {
267    pub id: Option<String>,
268    pub title: Option<String>,
269    pub status: Option<String>,
270    pub attempt: Option<u32>,
271    pub output: Option<String>,
272    pub parent: Option<String>,
273    pub children: Option<String>,
274    pub branch: Option<String>,
275}
276
277/// Expand template variables in a hook command string.
278///
279/// Replaces `{name}` placeholders with values from `vars`.
280/// Unknown or unset variables are left as-is.
281/// The `{output}` variable is truncated to 1000 chars.
282pub fn expand_template(template: &str, vars: &HookVars) -> String {
283    let mut result = template.to_string();
284
285    if let Some(ref v) = vars.id {
286        result = result.replace("{id}", v);
287    }
288    if let Some(ref v) = vars.title {
289        result = result.replace("{title}", v);
290    }
291    if let Some(ref v) = vars.status {
292        result = result.replace("{status}", v);
293    }
294    if let Some(attempt) = vars.attempt {
295        result = result.replace("{attempt}", &attempt.to_string());
296    }
297    if let Some(ref v) = vars.output {
298        // Truncate output to 1000 chars
299        let truncated = if v.len() > 1000 {
300            &v[..1000]
301        } else {
302            v.as_str()
303        };
304        result = result.replace("{output}", truncated);
305    }
306    if let Some(ref v) = vars.parent {
307        result = result.replace("{parent}", v);
308    }
309    if let Some(ref v) = vars.children {
310        result = result.replace("{children}", v);
311    }
312    if let Some(ref v) = vars.branch {
313        result = result.replace("{branch}", v);
314    }
315
316    result
317}
318
319/// Get the current git branch name, or None if not in a git repo.
320pub fn current_git_branch() -> Option<String> {
321    Command::new("git")
322        .args(["rev-parse", "--abbrev-ref", "HEAD"])
323        .stdout(Stdio::piped())
324        .stderr(Stdio::null())
325        .output()
326        .ok()
327        .and_then(|o| {
328            if o.status.success() {
329                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
330            } else {
331                None
332            }
333        })
334}
335
336/// Execute a config-based hook command asynchronously.
337///
338/// The command is expanded with template variables, then spawned via `sh -c`.
339/// The subprocess runs in the background — we don't wait for it.
340/// Any errors during spawn are logged to stderr but never propagated.
341///
342/// # Arguments
343///
344/// * `hook_name` - Name for logging (e.g., "on_close", "on_fail")
345/// * `template` - The command template with `{var}` placeholders
346/// * `vars` - Template variables to expand
347/// * `project_dir` - Working directory for the subprocess
348pub fn execute_config_hook(hook_name: &str, template: &str, vars: &HookVars, project_dir: &Path) {
349    let cmd = expand_template(template, vars);
350
351    match Command::new("sh")
352        .args(["-c", &cmd])
353        .current_dir(project_dir)
354        .stdin(Stdio::null())
355        .stdout(Stdio::null())
356        .stderr(Stdio::null())
357        .spawn()
358    {
359        Ok(_child) => {
360            // Fire-and-forget: don't wait for completion
361        }
362        Err(e) => {
363            eprintln!("Warning: {} hook failed to spawn: {}", hook_name, e);
364        }
365    }
366}
367
368// ---------------------------------------------------------------------------
369// Tests
370// ---------------------------------------------------------------------------
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use std::fs;
376    use tempfile::TempDir;
377
378    fn create_test_unit() -> Unit {
379        Unit::new("1", "Test Unit")
380    }
381
382    fn create_test_dir() -> TempDir {
383        TempDir::new().unwrap()
384    }
385
386    #[test]
387    fn test_hook_event_string_representation() {
388        assert_eq!(HookEvent::PreCreate.as_str(), "pre-create");
389        assert_eq!(HookEvent::PostCreate.as_str(), "post-create");
390        assert_eq!(HookEvent::PreUpdate.as_str(), "pre-update");
391        assert_eq!(HookEvent::PostUpdate.as_str(), "post-update");
392        assert_eq!(HookEvent::PreClose.as_str(), "pre-close");
393        assert_eq!(HookEvent::PostClose.as_str(), "post-close");
394    }
395
396    #[test]
397    fn test_hook_payload_serializes_to_json() {
398        let unit = create_test_unit();
399        let payload = HookPayload::new(HookEvent::PreCreate, unit.clone(), None);
400
401        let json = payload.to_json().unwrap();
402        assert!(json.contains("\"event\":\"pre-create\""));
403        assert!(json.contains("\"id\":\"1\""));
404        assert!(json.contains("\"title\":\"Test Unit\""));
405        assert!(!json.contains("\"reason\"") || json.contains("\"reason\":null"));
406    }
407
408    #[test]
409    fn test_hook_payload_with_reason() {
410        let unit = create_test_unit();
411        let payload = HookPayload::new(
412            HookEvent::PreClose,
413            unit,
414            Some("Completed successfully".to_string()),
415        );
416
417        let json = payload.to_json().unwrap();
418        assert!(json.contains("\"event\":\"pre-close\""));
419        assert!(json.contains("\"reason\":\"Completed successfully\""));
420    }
421
422    #[test]
423    fn test_get_hook_path() {
424        let temp_dir = create_test_dir();
425        let hook_path = get_hook_path(temp_dir.path(), HookEvent::PreCreate);
426
427        assert!(hook_path.ends_with(".mana/hooks/pre-create"));
428    }
429
430    #[test]
431    fn test_missing_hook_returns_ok_true() {
432        let temp_dir = create_test_dir();
433        let unit = create_test_unit();
434
435        // No hook exists
436        let result = execute_hook(HookEvent::PreCreate, &unit, temp_dir.path(), None);
437        assert!(result.is_ok());
438        assert!(result.unwrap());
439    }
440
441    #[test]
442    fn test_non_executable_hook_returns_error() {
443        let temp_dir = create_test_dir();
444        let project_dir = temp_dir.path();
445        let hooks_dir = project_dir.join(".mana").join("hooks");
446        fs::create_dir_all(&hooks_dir).unwrap();
447
448        // Enable trust so hook execution is attempted
449        create_trust(project_dir).unwrap();
450
451        let hook_path = hooks_dir.join("pre-create");
452        fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
453        // File is not executable
454
455        let unit = create_test_unit();
456        let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
457
458        assert!(result.is_err());
459        assert!(result.unwrap_err().to_string().contains("not executable"));
460    }
461
462    #[test]
463    fn test_successful_hook_execution() {
464        let temp_dir = create_test_dir();
465        let project_dir = temp_dir.path();
466        let hooks_dir = project_dir.join(".mana").join("hooks");
467        fs::create_dir_all(&hooks_dir).unwrap();
468
469        // Enable trust so hook execution is attempted
470        create_trust(project_dir).unwrap();
471
472        let hook_path = hooks_dir.join("pre-create");
473        // Use a simple script that just exits successfully, ignoring stdin
474        fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
475
476        // Make executable on Unix
477        #[cfg(unix)]
478        {
479            use std::os::unix::fs::PermissionsExt;
480            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
481        }
482
483        let unit = create_test_unit();
484        let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
485
486        assert!(result.is_ok(), "Hook execution failed: {:?}", result.err());
487        assert!(result.unwrap());
488    }
489
490    #[test]
491    fn test_hook_execution_with_failure_exit_code() {
492        let temp_dir = create_test_dir();
493        let project_dir = temp_dir.path();
494        let hooks_dir = project_dir.join(".mana").join("hooks");
495        fs::create_dir_all(&hooks_dir).unwrap();
496
497        // Enable trust so hook execution is attempted
498        create_trust(project_dir).unwrap();
499
500        let hook_path = hooks_dir.join("pre-create");
501        fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
502
503        // Make executable on Unix
504        #[cfg(unix)]
505        {
506            use std::os::unix::fs::PermissionsExt;
507            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
508        }
509
510        let unit = create_test_unit();
511        let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
512
513        assert!(result.is_ok(), "Hook execution failed: {:?}", result.err());
514        assert!(!result.unwrap());
515    }
516
517    #[test]
518    fn test_hook_receives_json_payload_on_stdin() {
519        // Test that the payload can be serialized to JSON and would be sent to the hook
520        let unit = create_test_unit();
521        let payload = HookPayload::new(HookEvent::PreCreate, unit, None);
522
523        let json = payload.to_json().unwrap();
524
525        // Verify the JSON contains all expected fields
526        assert!(json.contains("\"event\":\"pre-create\""));
527        assert!(json.contains("\"unit\":{"));
528        assert!(json.contains("\"id\":\"1\""));
529        assert!(json.contains("\"title\":\"Test Unit\""));
530        assert!(json.contains("\"status\":"));
531    }
532
533    #[test]
534    #[cfg(unix)]
535    fn test_hook_timeout() {
536        let temp_dir = create_test_dir();
537        let project_dir = temp_dir.path();
538        let hooks_dir = project_dir.join(".mana").join("hooks");
539        fs::create_dir_all(&hooks_dir).unwrap();
540
541        // Enable trust so hook execution is attempted
542        create_trust(project_dir).unwrap();
543
544        let hook_path = hooks_dir.join("pre-create");
545        // Script that sleeps for longer than timeout
546        fs::write(&hook_path, "#!/bin/bash\nsleep 60\nexit 0").unwrap();
547
548        use std::os::unix::fs::PermissionsExt;
549        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
550
551        let unit = create_test_unit();
552        let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
553
554        assert!(result.is_err());
555        assert!(result.unwrap_err().to_string().contains("timed out"));
556    }
557
558    #[test]
559    fn test_is_hook_executable_with_missing_file() {
560        let temp_dir = create_test_dir();
561        let hook_path = temp_dir.path().join("nonexistent");
562
563        assert!(!is_hook_executable(&hook_path));
564    }
565
566    #[test]
567    #[cfg(unix)]
568    fn test_is_hook_executable_with_executable_file() {
569        let temp_dir = create_test_dir();
570        let hook_path = temp_dir.path().join("executable");
571        fs::write(&hook_path, "#!/bin/bash\necho test").unwrap();
572
573        use std::os::unix::fs::PermissionsExt;
574        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
575
576        assert!(is_hook_executable(&hook_path));
577    }
578
579    #[test]
580    #[cfg(unix)]
581    fn test_is_hook_executable_with_non_executable_file() {
582        let temp_dir = create_test_dir();
583        let hook_path = temp_dir.path().join("non-executable");
584        fs::write(&hook_path, "#!/bin/bash\necho test").unwrap();
585
586        use std::os::unix::fs::PermissionsExt;
587        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o644)).unwrap();
588
589        assert!(!is_hook_executable(&hook_path));
590    }
591
592    #[test]
593    fn test_hook_payload_with_all_unit_fields() {
594        let mut unit = create_test_unit();
595        unit.description = Some("Test description".to_string());
596        unit.acceptance = Some("Test acceptance".to_string());
597        unit.labels = vec!["test".to_string(), "important".to_string()];
598
599        let payload = HookPayload::new(HookEvent::PostCreate, unit, None);
600        let json = payload.to_json().unwrap();
601
602        assert!(json.contains("description"));
603        assert!(json.contains("Test description"));
604        assert!(json.contains("labels"));
605        assert!(json.contains("test"));
606    }
607
608    // =====================================================================
609    // Trust Management Tests
610    // =====================================================================
611
612    #[test]
613    fn test_is_trusted_returns_false_when_trust_file_does_not_exist() {
614        let temp_dir = create_test_dir();
615        let project_dir = temp_dir.path();
616
617        // Create .mana directory
618        fs::create_dir_all(project_dir.join(".mana")).unwrap();
619
620        // Trust should not exist
621        assert!(!is_trusted(project_dir));
622    }
623
624    #[test]
625    fn test_is_trusted_returns_true_when_trust_file_exists() {
626        let temp_dir = create_test_dir();
627        let project_dir = temp_dir.path();
628
629        // Create .mana directory and trust file
630        fs::create_dir_all(project_dir.join(".mana")).unwrap();
631        fs::write(project_dir.join(".mana").join(".hooks-trusted"), "").unwrap();
632
633        // Trust should exist
634        assert!(is_trusted(project_dir));
635    }
636
637    #[test]
638    fn test_create_trust_creates_trust_file() {
639        let temp_dir = create_test_dir();
640        let project_dir = temp_dir.path();
641
642        // Create .mana directory
643        fs::create_dir_all(project_dir.join(".mana")).unwrap();
644
645        // Trust should not exist yet
646        assert!(!is_trusted(project_dir));
647
648        // Create trust
649        let result = create_trust(project_dir);
650        assert!(result.is_ok());
651
652        // Trust should now exist
653        assert!(is_trusted(project_dir));
654
655        // Verify file contains metadata
656        let content = fs::read_to_string(project_dir.join(".mana").join(".hooks-trusted")).unwrap();
657        assert!(content.contains("Hooks enabled"));
658    }
659
660    #[test]
661    fn test_revoke_trust_removes_trust_file() {
662        let temp_dir = create_test_dir();
663        let project_dir = temp_dir.path();
664
665        // Create .mana directory and trust file
666        fs::create_dir_all(project_dir.join(".mana")).unwrap();
667        fs::write(project_dir.join(".mana").join(".hooks-trusted"), "").unwrap();
668
669        // Trust should exist
670        assert!(is_trusted(project_dir));
671
672        // Revoke trust
673        let result = revoke_trust(project_dir);
674        assert!(result.is_ok());
675
676        // Trust should no longer exist
677        assert!(!is_trusted(project_dir));
678    }
679
680    #[test]
681    fn test_revoke_trust_errors_if_file_does_not_exist() {
682        let temp_dir = create_test_dir();
683        let project_dir = temp_dir.path();
684
685        // Create .mana directory
686        fs::create_dir_all(project_dir.join(".mana")).unwrap();
687
688        // Try to revoke non-existent trust
689        let result = revoke_trust(project_dir);
690        assert!(result.is_err());
691        assert!(result
692            .unwrap_err()
693            .to_string()
694            .contains("Trust file does not exist"));
695    }
696
697    #[test]
698    fn test_execute_hook_skips_when_not_trusted() {
699        let temp_dir = create_test_dir();
700        let project_dir = temp_dir.path();
701        let hooks_dir = project_dir.join(".mana").join("hooks");
702        fs::create_dir_all(&hooks_dir).unwrap();
703
704        // Create an executable hook
705        let hook_path = hooks_dir.join("pre-create");
706        fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
707
708        #[cfg(unix)]
709        {
710            use std::os::unix::fs::PermissionsExt;
711            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
712        }
713
714        let unit = create_test_unit();
715
716        // Hook should NOT execute (returns Ok(true) but doesn't run)
717        // If trust is disabled, hook should not even check executability
718        let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
719        assert!(result.is_ok());
720        assert!(result.unwrap()); // Returns true but doesn't execute
721    }
722
723    #[test]
724    fn test_execute_hook_runs_when_trusted() {
725        let temp_dir = create_test_dir();
726        let project_dir = temp_dir.path();
727        let hooks_dir = project_dir.join(".mana").join("hooks");
728        fs::create_dir_all(&hooks_dir).unwrap();
729
730        // Enable trust
731        create_trust(project_dir).unwrap();
732
733        // Create an executable hook that succeeds
734        let hook_path = hooks_dir.join("pre-create");
735        fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
736
737        #[cfg(unix)]
738        {
739            use std::os::unix::fs::PermissionsExt;
740            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
741        }
742
743        let unit = create_test_unit();
744
745        // Hook should execute successfully
746        let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
747        assert!(result.is_ok());
748        assert!(result.unwrap());
749    }
750
751    #[test]
752    fn test_execute_hook_respects_non_trusted_status() {
753        let temp_dir = create_test_dir();
754        let project_dir = temp_dir.path();
755        let hooks_dir = project_dir.join(".mana").join("hooks");
756        fs::create_dir_all(&hooks_dir).unwrap();
757
758        // Create a hook but DO NOT enable trust
759        let hook_path = hooks_dir.join("pre-create");
760        fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
761
762        #[cfg(unix)]
763        {
764            use std::os::unix::fs::PermissionsExt;
765            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
766        }
767
768        let unit = create_test_unit();
769
770        // Hook should NOT execute (returns Ok(true) silently)
771        let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
772        assert!(result.is_ok());
773        assert!(result.unwrap());
774    }
775
776    // =====================================================================
777    // Template Expansion Tests
778    // =====================================================================
779
780    #[test]
781    fn test_expand_template_with_all_vars() {
782        let vars = HookVars {
783            id: Some("42".into()),
784            title: Some("Fix the bug".into()),
785            status: Some("closed".into()),
786            attempt: Some(3),
787            output: Some("FAIL: test_foo".into()),
788            parent: Some("10".into()),
789            children: Some("10.1,10.2".into()),
790            branch: Some("main".into()),
791        };
792
793        let result = expand_template(
794            "echo {id} {title} {status} {attempt} {output} {parent} {children} {branch}",
795            &vars,
796        );
797        assert_eq!(
798            result,
799            "echo 42 Fix the bug closed 3 FAIL: test_foo 10 10.1,10.2 main"
800        );
801    }
802
803    #[test]
804    fn test_expand_template_missing_vars_left_as_is() {
805        let vars = HookVars {
806            id: Some("1".into()),
807            ..Default::default()
808        };
809
810        let result = expand_template("echo {id} {title} {unknown}", &vars);
811        assert_eq!(result, "echo 1 {title} {unknown}");
812    }
813
814    #[test]
815    fn test_expand_template_output_truncated_to_1000_chars() {
816        let long_output = "x".repeat(2000);
817        let vars = HookVars {
818            output: Some(long_output),
819            ..Default::default()
820        };
821
822        let result = expand_template("echo {output}", &vars);
823        // "echo " = 5 chars + 1000 chars of x
824        assert_eq!(result.len(), 5 + 1000);
825    }
826
827    #[test]
828    fn test_expand_template_empty_template() {
829        let vars = HookVars::default();
830        let result = expand_template("", &vars);
831        assert_eq!(result, "");
832    }
833
834    #[test]
835    fn test_expand_template_no_placeholders() {
836        let vars = HookVars {
837            id: Some("1".into()),
838            ..Default::default()
839        };
840        let result = expand_template("echo hello world", &vars);
841        assert_eq!(result, "echo hello world");
842    }
843
844    #[test]
845    fn test_expand_template_multiple_same_var() {
846        let vars = HookVars {
847            id: Some("5".into()),
848            ..Default::default()
849        };
850        let result = expand_template("{id} and {id} again", &vars);
851        assert_eq!(result, "5 and 5 again");
852    }
853
854    // =====================================================================
855    // Config Hook Execution Tests
856    // =====================================================================
857
858    #[test]
859    fn test_execute_config_hook_writes_to_file() {
860        let temp_dir = create_test_dir();
861        let project_dir = temp_dir.path();
862        let output_file = project_dir.join("hook_output.txt");
863
864        let vars = HookVars {
865            id: Some("99".into()),
866            title: Some("Test unit".into()),
867            ..Default::default()
868        };
869
870        // Build the template with the output file path baked in
871        let template = format!("echo '{{id}}' > {}", output_file.display());
872        execute_config_hook("on_close", &template, &vars, project_dir);
873
874        // Wait briefly for async subprocess
875        std::thread::sleep(Duration::from_millis(500));
876
877        let content = fs::read_to_string(&output_file).unwrap();
878        assert_eq!(content.trim(), "99");
879    }
880
881    #[test]
882    fn test_execute_config_hook_failure_does_not_panic() {
883        let temp_dir = create_test_dir();
884        let project_dir = temp_dir.path();
885
886        // Running a command that doesn't exist should not panic
887        execute_config_hook(
888            "on_close",
889            "/nonexistent/command/that/does/not/exist",
890            &HookVars::default(),
891            project_dir,
892        );
893
894        // If we get here, the hook failure was handled gracefully
895    }
896
897    #[test]
898    fn test_execute_config_hook_with_template_expansion() {
899        let temp_dir = create_test_dir();
900        let project_dir = temp_dir.path();
901        let output_file = project_dir.join("expanded.txt");
902
903        let vars = HookVars {
904            id: Some("7".into()),
905            title: Some("My Task".into()),
906            status: Some("closed".into()),
907            branch: Some("feature-x".into()),
908            ..Default::default()
909        };
910
911        let template = format!(
912            "echo '{{id}}|{{title}}|{{status}}|{{branch}}' > {}",
913            output_file.display()
914        );
915        execute_config_hook("on_close", &template, &vars, project_dir);
916
917        std::thread::sleep(Duration::from_millis(500));
918
919        let content = fs::read_to_string(&output_file).unwrap();
920        assert_eq!(content.trim(), "7|My Task|closed|feature-x");
921    }
922}