Skip to main content

kando_core/board/
hooks.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3use std::sync::mpsc;
4use std::sync::{Mutex, OnceLock};
5use std::time::Instant;
6
7use chrono::Utc;
8
9/// Notification sent from a hook thread back to the TUI.
10pub struct HookNotification {
11    /// Name of the hook that ran (e.g. "post-create"). Useful for filtering
12    /// or deduplicating notifications by event type.
13    #[allow(dead_code)]
14    pub hook_name: String,
15    pub message: String,
16    pub is_error: bool,
17}
18
19/// Info about a single hook slot (whether it exists, is executable, etc.).
20pub struct HookInfo {
21    pub name: String,
22    pub path: PathBuf,
23    pub exists: bool,
24    pub executable: bool,
25}
26
27/// All hook events that Kando fires.
28pub const ALL_HOOK_EVENTS: &[&str] = &[
29    "create",
30    "move",
31    "delete",
32    "archive",
33    "restore",
34    "auto-close",
35    "edit",
36    "priority",
37    "tags",
38    "assignees",
39    "blocker",
40    "due",
41    "col-add",
42    "col-remove",
43    "col-rename",
44    "col-move",
45];
46
47// ---------------------------------------------------------------------------
48// Global sender (thread-safe, optional)
49// ---------------------------------------------------------------------------
50
51static HOOK_TX: OnceLock<Mutex<Option<mpsc::Sender<HookNotification>>>> = OnceLock::new();
52
53fn hook_tx() -> &'static Mutex<Option<mpsc::Sender<HookNotification>>> {
54    HOOK_TX.get_or_init(|| Mutex::new(None))
55}
56
57/// Register a channel sender so hook results are forwarded to the TUI.
58/// When no sender is registered (CLI context), hooks still run but output
59/// is only logged to `hooks.log`.
60pub fn register_hook_sender(tx: mpsc::Sender<HookNotification>) {
61    if let Ok(mut guard) = hook_tx().lock() {
62        *guard = Some(tx);
63    }
64}
65
66/// Remove the sender (call on TUI shutdown).
67pub fn deregister_hook_sender() {
68    if let Ok(mut guard) = hook_tx().lock() {
69        *guard = None;
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Hook naming
75// ---------------------------------------------------------------------------
76
77/// Map an activity-log action name to its hook filename.
78pub fn hook_name_for_action(action: &str) -> String {
79    format!("post-{action}")
80}
81
82// ---------------------------------------------------------------------------
83// Hook execution
84// ---------------------------------------------------------------------------
85
86/// Best-effort read of a card's `due` and `blocked` fields from disk.
87/// Returns `(due, blocked)` as strings (empty if not found or not set).
88fn read_card_due_blocked(kando_dir: &Path, card_id: &str) -> (String, String) {
89    let columns_dir = kando_dir.join("columns");
90    let entries = match std::fs::read_dir(&columns_dir) {
91        Ok(e) => e,
92        Err(_) => return (String::new(), String::new()),
93    };
94    for entry in entries.flatten() {
95        if !entry.path().is_dir() {
96            continue;
97        }
98        let card_path = entry.path().join(format!("{card_id}.md"));
99        if let Ok(contents) = std::fs::read_to_string(&card_path) {
100            let due = extract_frontmatter_value(&contents, "due");
101            let blocked = extract_frontmatter_value(&contents, "blocked");
102            return (due, blocked);
103        }
104    }
105    (String::new(), String::new())
106}
107
108/// Extract a single value from TOML frontmatter delimited by `---`.
109fn extract_frontmatter_value(content: &str, key: &str) -> String {
110    let Some(start) = content.find("---") else {
111        return String::new();
112    };
113    let rest = &content[start + 3..];
114    let Some(end) = rest.find("---") else {
115        return String::new();
116    };
117    let frontmatter = &rest[..end];
118    for line in frontmatter.lines() {
119        let trimmed = line.trim();
120        // Match exact key followed by optional whitespace and `=`
121        if let Some(after_key) = trimmed.strip_prefix(key) {
122            // Ensure the key is an exact match (next char must be whitespace or '=')
123            if let Some(first) = after_key.chars().next() {
124                if first != '=' && !first.is_whitespace() {
125                    continue;
126                }
127            }
128            let after_key = after_key.trim_start();
129            if let Some(value) = after_key.strip_prefix('=') {
130                let value = value.trim().trim_matches('"');
131                if !value.is_empty() {
132                    return value.to_string();
133                }
134            }
135        }
136    }
137    String::new()
138}
139
140/// Fire a post-hook for the given action, if one exists.
141///
142/// This is called from `append_activity` so every board mutation automatically
143/// triggers the corresponding hook. The hook runs in a background thread so it
144/// never blocks the caller.
145pub fn fire_hook(
146    kando_dir: &Path,
147    action: &str,
148    card_id: &str,
149    card_title: &str,
150    extras: &[(&str, &str)],
151) {
152    let hooks_dir = kando_dir.join("hooks");
153    let hook_name = hook_name_for_action(action);
154    let hook_path = hooks_dir.join(&hook_name);
155
156    // Early return if hooks dir or hook file doesn't exist
157    if !hook_path.exists() {
158        return;
159    }
160
161    // Early return if not executable (Unix only)
162    if !is_executable(&hook_path) {
163        return;
164    }
165
166    // Build owned copies for the thread
167    let kando_dir = kando_dir.to_path_buf();
168    let action = action.to_string();
169    let card_id = card_id.to_string();
170    let card_title = card_title.to_string();
171    let extras: Vec<(String, String)> = extras
172        .iter()
173        .map(|(k, v)| (k.to_string(), v.to_string()))
174        .collect();
175
176    std::thread::spawn(move || {
177        run_hook(
178            &kando_dir,
179            &hook_path,
180            &hook_name,
181            &action,
182            &card_id,
183            &card_title,
184            &extras,
185        );
186    });
187}
188
189/// Actually execute the hook and handle logging + notification.
190fn run_hook(
191    kando_dir: &Path,
192    hook_path: &Path,
193    hook_name: &str,
194    action: &str,
195    card_id: &str,
196    card_title: &str,
197    extras: &[(String, String)],
198) {
199    let project_dir = kando_dir
200        .parent()
201        .unwrap_or(kando_dir)
202        .canonicalize()
203        .unwrap_or_else(|_| kando_dir.parent().unwrap_or(kando_dir).to_path_buf());
204
205    // Read card due/blocked from disk (best-effort, empty string if not found)
206    let (card_due, card_blocked) = read_card_due_blocked(kando_dir, card_id);
207
208    let start = Instant::now();
209
210    let result = Command::new(hook_path)
211        .env("KANDO_EVENT", action)
212        .env("KANDO_CARD_ID", card_id)
213        .env("KANDO_CARD_TITLE", card_title)
214        .env("KANDO_CARD_DUE", &card_due)
215        .env("KANDO_CARD_BLOCKED", &card_blocked)
216        .env("KANDO_BOARD_DIR", &project_dir)
217        .envs(
218            extras
219                .iter()
220                .map(|(k, v)| (format!("KANDO_{}", k.to_uppercase()), v.as_str())),
221        )
222        .output();
223
224    let duration = start.elapsed();
225
226    match result {
227        Ok(output) => {
228            let code = output.status.code().unwrap_or(-1);
229            let stdout = String::from_utf8_lossy(&output.stdout);
230            let stderr = String::from_utf8_lossy(&output.stderr);
231            let first_line = stdout
232                .lines()
233                .next()
234                .or_else(|| stderr.lines().next())
235                .unwrap_or("")
236                .to_string();
237
238            // Log to hooks.log
239            log_hook_result(
240                kando_dir,
241                hook_name,
242                code,
243                duration.as_millis(),
244                &first_line,
245            );
246
247            // Send notification if TUI is listening
248            let is_error = code != 0;
249            let message = if is_error {
250                if first_line.is_empty() {
251                    format!("{hook_name} failed (exit {code})")
252                } else {
253                    format!("{hook_name}: {first_line}")
254                }
255            } else if first_line.is_empty() {
256                format!("{hook_name} done")
257            } else {
258                format!("{hook_name}: {first_line}")
259            };
260
261            send_notification(HookNotification {
262                hook_name: hook_name.to_string(),
263                message,
264                is_error,
265            });
266        }
267        Err(e) => {
268            let message = format!("{hook_name}: {e}");
269            log_hook_result(kando_dir, hook_name, -1, duration.as_millis(), &message);
270            send_notification(HookNotification {
271                hook_name: hook_name.to_string(),
272                message,
273                is_error: true,
274            });
275        }
276    }
277}
278
279fn send_notification(notif: HookNotification) {
280    if let Ok(guard) = hook_tx().lock() {
281        if let Some(ref tx) = *guard {
282            let _ = tx.send(notif);
283        }
284    }
285}
286
287fn log_hook_result(
288    kando_dir: &Path,
289    hook_name: &str,
290    exit_code: i32,
291    duration_ms: u128,
292    first_line: &str,
293) {
294    use std::fs::OpenOptions;
295    use std::io::Write;
296
297    let log_path = kando_dir.join("hooks.log");
298    let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
299
300    let line = format!("{timestamp}  {hook_name}  exit={exit_code}  {duration_ms}ms  {first_line}\n");
301
302    if let Ok(mut file) = OpenOptions::new()
303        .create(true)
304        .append(true)
305        .open(log_path)
306    {
307        let _ = file.write_all(line.as_bytes());
308    }
309}
310
311// ---------------------------------------------------------------------------
312// Discovery
313// ---------------------------------------------------------------------------
314
315/// List all hook slots with their status.
316pub fn list_hooks(kando_dir: &Path) -> Vec<HookInfo> {
317    let hooks_dir = kando_dir.join("hooks");
318    ALL_HOOK_EVENTS
319        .iter()
320        .map(|event| {
321            let name = hook_name_for_action(event);
322            let path = hooks_dir.join(&name);
323            let exists = path.exists();
324            let executable = exists && is_executable(&path);
325            HookInfo {
326                name,
327                path,
328                exists,
329                executable,
330            }
331        })
332        .collect()
333}
334
335// ---------------------------------------------------------------------------
336// Platform helpers
337// ---------------------------------------------------------------------------
338
339#[cfg(unix)]
340fn is_executable(path: &Path) -> bool {
341    use std::os::unix::fs::PermissionsExt;
342    path.metadata()
343        .map(|m| m.permissions().mode() & 0o111 != 0)
344        .unwrap_or(false)
345}
346
347#[cfg(not(unix))]
348fn is_executable(_path: &Path) -> bool {
349    true // On non-Unix, attempt to run
350}
351
352// ---------------------------------------------------------------------------
353// CRUD helpers
354// ---------------------------------------------------------------------------
355
356fn valid_hooks_list() -> String {
357    ALL_HOOK_EVENTS
358        .iter()
359        .map(|e| format!("post-{e}"))
360        .collect::<Vec<_>>()
361        .join(", ")
362}
363
364/// Check that `name` is a valid hook name (e.g. `post-create`).
365/// Returns the event suffix on success, or an error listing valid names.
366pub fn validate_hook_name(name: &str) -> Result<&str, String> {
367    let suffix = name.strip_prefix("post-").ok_or_else(|| {
368        format!(
369            "Invalid hook name '{name}'. Hook names must start with 'post-'. Valid hooks:\n  {}",
370            valid_hooks_list()
371        )
372    })?;
373
374    if ALL_HOOK_EVENTS.contains(&suffix) {
375        Ok(suffix)
376    } else {
377        Err(format!(
378            "Unknown hook event '{suffix}'. Valid hooks:\n  {}",
379            valid_hooks_list()
380        ))
381    }
382}
383
384/// Create a new hook file with a platform-appropriate starter script.
385pub fn scaffold_hook(kando_dir: &Path, hook_name: &str) -> std::io::Result<PathBuf> {
386    let hooks_dir = kando_dir.join("hooks");
387    std::fs::create_dir_all(&hooks_dir)?;
388
389    let path = hooks_dir.join(hook_name);
390
391    #[cfg(unix)]
392    std::fs::write(&path, "#!/usr/bin/env sh\n")?;
393
394    #[cfg(not(unix))]
395    std::fs::write(&path, "@echo off\r\n")?;
396
397    make_executable(&path)?;
398    Ok(path)
399}
400
401/// Set the executable bit on Unix; no-op on other platforms.
402#[cfg(unix)]
403pub fn make_executable(path: &Path) -> std::io::Result<()> {
404    use std::os::unix::fs::PermissionsExt;
405    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
406}
407
408#[cfg(not(unix))]
409pub fn make_executable(_path: &Path) -> std::io::Result<()> {
410    Ok(())
411}
412
413/// Open `path` in the user's `$EDITOR`.
414pub fn open_in_editor(path: &Path) -> std::io::Result<()> {
415    #[cfg(unix)]
416    let default_editor = "vi";
417    #[cfg(not(unix))]
418    let default_editor = "notepad";
419
420    let editor = std::env::var("EDITOR").unwrap_or_else(|_| default_editor.to_string());
421    let editor_trimmed = editor.trim();
422    if editor_trimmed.is_empty() {
423        return Err(std::io::Error::new(
424            std::io::ErrorKind::NotFound,
425            "$EDITOR is empty or not set",
426        ));
427    }
428
429    #[cfg(unix)]
430    let status = Command::new("sh")
431        .arg("-c")
432        .arg(format!("{editor_trimmed} \"$1\""))
433        .arg("--")
434        .arg(path)
435        .status()?;
436
437    #[cfg(not(unix))]
438    let status = Command::new("cmd")
439        .arg("/c")
440        .arg(editor_trimmed)
441        .arg(path)
442        .status()?;
443
444    if !status.success() {
445        eprintln!("Warning: editor exited with {status}");
446    }
447    Ok(())
448}
449
450// ---------------------------------------------------------------------------
451// Tests
452// ---------------------------------------------------------------------------
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use std::fs;
458    use std::sync::Mutex as StdMutex;
459
460    /// Tests that use the global HOOK_TX must not run concurrently.
461    static TEST_LOCK: StdMutex<()> = StdMutex::new(());
462
463    #[test]
464    fn hook_name_for_action_prefixes_post() {
465        assert_eq!(hook_name_for_action("create"), "post-create");
466        assert_eq!(hook_name_for_action("move"), "post-move");
467        assert_eq!(hook_name_for_action("col-add"), "post-col-add");
468        assert_eq!(hook_name_for_action("auto-close"), "post-auto-close");
469    }
470
471    #[test]
472    fn fire_hook_no_hooks_dir_is_noop() {
473        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
474        let tmp = tempfile::tempdir().unwrap();
475        // No hooks/ dir — should not panic
476        fire_hook(tmp.path(), "create", "001", "Test Card", &[]);
477    }
478
479    #[test]
480    fn list_hooks_returns_all_events() {
481        let tmp = tempfile::tempdir().unwrap();
482        let hooks = list_hooks(tmp.path());
483        assert_eq!(hooks.len(), ALL_HOOK_EVENTS.len());
484        for (hook, event) in hooks.iter().zip(ALL_HOOK_EVENTS.iter()) {
485            assert_eq!(hook.name, format!("post-{event}"));
486            assert!(!hook.exists);
487            assert!(!hook.executable);
488        }
489    }
490
491    #[cfg(unix)]
492    #[test]
493    fn fire_hook_non_executable_is_skipped() {
494        use std::os::unix::fs::PermissionsExt;
495        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
496
497        let tmp = tempfile::tempdir().unwrap();
498        let hooks_dir = tmp.path().join("hooks");
499        fs::create_dir(&hooks_dir).unwrap();
500
501        let hook_path = hooks_dir.join("post-create");
502        fs::write(&hook_path, "#!/usr/bin/env sh\necho hello").unwrap();
503        // Remove execute permission
504        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o644)).unwrap();
505
506        let (tx, rx) = mpsc::channel();
507        register_hook_sender(tx);
508        fire_hook(tmp.path(), "create", "001", "Test", &[]);
509        std::thread::sleep(std::time::Duration::from_millis(100));
510
511        // Should not have received any notification since hook is not executable
512        assert!(rx.try_recv().is_err());
513        deregister_hook_sender();
514    }
515
516    #[cfg(unix)]
517    #[test]
518    fn fire_hook_executable_sends_notification() {
519        use std::os::unix::fs::PermissionsExt;
520        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
521
522        let tmp = tempfile::tempdir().unwrap();
523        let hooks_dir = tmp.path().join("hooks");
524        fs::create_dir(&hooks_dir).unwrap();
525
526        let hook_path = hooks_dir.join("post-create");
527        fs::write(&hook_path, "#!/usr/bin/env sh\necho 'card created'").unwrap();
528        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
529
530        let (tx, rx) = mpsc::channel();
531        register_hook_sender(tx);
532
533        fire_hook(tmp.path(), "create", "001", "Test Card", &[]);
534
535        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
536        assert_eq!(notif.hook_name, "post-create");
537        assert!(notif.message.contains("card created"));
538        assert!(!notif.is_error);
539        deregister_hook_sender();
540    }
541
542    #[cfg(unix)]
543    #[test]
544    fn fire_hook_failing_script_sends_error() {
545        use std::os::unix::fs::PermissionsExt;
546        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
547
548        let tmp = tempfile::tempdir().unwrap();
549        let hooks_dir = tmp.path().join("hooks");
550        fs::create_dir(&hooks_dir).unwrap();
551
552        let hook_path = hooks_dir.join("post-delete");
553        fs::write(&hook_path, "#!/usr/bin/env sh\necho 'oops' >&2\nexit 1").unwrap();
554        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
555
556        let (tx, rx) = mpsc::channel();
557        register_hook_sender(tx);
558
559        fire_hook(tmp.path(), "delete", "002", "Bad Card", &[]);
560
561        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
562        assert!(notif.is_error);
563        assert!(notif.message.contains("oops"));
564        deregister_hook_sender();
565    }
566
567    #[cfg(unix)]
568    #[test]
569    fn list_hooks_detects_executable() {
570        use std::os::unix::fs::PermissionsExt;
571
572        let tmp = tempfile::tempdir().unwrap();
573        let hooks_dir = tmp.path().join("hooks");
574        fs::create_dir(&hooks_dir).unwrap();
575
576        let hook_path = hooks_dir.join("post-create");
577        fs::write(&hook_path, "#!/usr/bin/env sh\necho hi").unwrap();
578        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
579
580        let hooks = list_hooks(tmp.path());
581        let create_hook = hooks.iter().find(|h| h.name == "post-create").unwrap();
582        assert!(create_hook.exists);
583        assert!(create_hook.executable);
584    }
585
586    #[cfg(unix)]
587    #[test]
588    fn list_hooks_detects_non_executable() {
589        use std::os::unix::fs::PermissionsExt;
590
591        let tmp = tempfile::tempdir().unwrap();
592        let hooks_dir = tmp.path().join("hooks");
593        fs::create_dir(&hooks_dir).unwrap();
594
595        let hook_path = hooks_dir.join("post-move");
596        fs::write(&hook_path, "#!/usr/bin/env sh\necho hi").unwrap();
597        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o644)).unwrap();
598
599        let hooks = list_hooks(tmp.path());
600        let move_hook = hooks.iter().find(|h| h.name == "post-move").unwrap();
601        assert!(move_hook.exists);
602        assert!(!move_hook.executable);
603    }
604
605    #[cfg(unix)]
606    #[test]
607    fn env_vars_include_extras_uppercased() {
608        use std::os::unix::fs::PermissionsExt;
609        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
610
611        let tmp = tempfile::tempdir().unwrap();
612        let hooks_dir = tmp.path().join("hooks");
613        fs::create_dir(&hooks_dir).unwrap();
614
615        // Script that prints env vars we care about
616        let hook_path = hooks_dir.join("post-move");
617        fs::write(
618            &hook_path,
619            "#!/usr/bin/env sh\necho \"from=$KANDO_FROM to=$KANDO_TO event=$KANDO_EVENT\"",
620        )
621        .unwrap();
622        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
623
624        let (tx, rx) = mpsc::channel();
625        register_hook_sender(tx);
626
627        fire_hook(
628            tmp.path(),
629            "move",
630            "003",
631            "My Card",
632            &[("from", "backlog"), ("to", "in-progress")],
633        );
634
635        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
636        assert!(notif.message.contains("from=backlog"));
637        assert!(notif.message.contains("to=in-progress"));
638        assert!(notif.message.contains("event=move"));
639        deregister_hook_sender();
640    }
641
642    #[cfg(unix)]
643    #[test]
644    fn hook_log_written_on_execution() {
645        use std::os::unix::fs::PermissionsExt;
646        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
647
648        let tmp = tempfile::tempdir().unwrap();
649        let hooks_dir = tmp.path().join("hooks");
650        fs::create_dir(&hooks_dir).unwrap();
651
652        let hook_path = hooks_dir.join("post-create");
653        fs::write(&hook_path, "#!/usr/bin/env sh\necho logged").unwrap();
654        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
655
656        let (tx, rx) = mpsc::channel();
657        register_hook_sender(tx);
658
659        fire_hook(tmp.path(), "create", "001", "Log Test", &[]);
660
661        // Wait for notification to ensure hook finished
662        let _ = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
663
664        let log_path = tmp.path().join("hooks.log");
665        assert!(log_path.exists());
666        let contents = fs::read_to_string(log_path).unwrap();
667        assert!(contents.contains("post-create"));
668        assert!(contents.contains("exit=0"));
669        assert!(contents.contains("logged"));
670        deregister_hook_sender();
671    }
672
673    #[cfg(unix)]
674    #[test]
675    fn fire_hook_success_no_output_says_done() {
676        use std::os::unix::fs::PermissionsExt;
677        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
678
679        let tmp = tempfile::tempdir().unwrap();
680        let hooks_dir = tmp.path().join("hooks");
681        fs::create_dir(&hooks_dir).unwrap();
682
683        let hook_path = hooks_dir.join("post-create");
684        fs::write(&hook_path, "#!/usr/bin/env sh\n# silent hook").unwrap();
685        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
686
687        let (tx, rx) = mpsc::channel();
688        register_hook_sender(tx);
689
690        fire_hook(tmp.path(), "create", "001", "Silent", &[]);
691
692        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
693        assert!(!notif.is_error);
694        assert_eq!(notif.message, "post-create done");
695        deregister_hook_sender();
696    }
697
698    #[cfg(unix)]
699    #[test]
700    fn fire_hook_failure_no_output_says_failed() {
701        use std::os::unix::fs::PermissionsExt;
702        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
703
704        let tmp = tempfile::tempdir().unwrap();
705        let hooks_dir = tmp.path().join("hooks");
706        fs::create_dir(&hooks_dir).unwrap();
707
708        let hook_path = hooks_dir.join("post-delete");
709        fs::write(&hook_path, "#!/usr/bin/env sh\nexit 42").unwrap();
710        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
711
712        let (tx, rx) = mpsc::channel();
713        register_hook_sender(tx);
714
715        fire_hook(tmp.path(), "delete", "002", "Fail", &[]);
716
717        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
718        assert!(notif.is_error);
719        assert_eq!(notif.message, "post-delete failed (exit 42)");
720        deregister_hook_sender();
721    }
722
723    #[cfg(unix)]
724    #[test]
725    fn fire_hook_stderr_used_when_stdout_empty() {
726        use std::os::unix::fs::PermissionsExt;
727        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
728
729        let tmp = tempfile::tempdir().unwrap();
730        let hooks_dir = tmp.path().join("hooks");
731        fs::create_dir(&hooks_dir).unwrap();
732
733        // Hook writes only to stderr but exits 0
734        let hook_path = hooks_dir.join("post-edit");
735        fs::write(&hook_path, "#!/usr/bin/env sh\necho 'stderr only' >&2").unwrap();
736        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
737
738        let (tx, rx) = mpsc::channel();
739        register_hook_sender(tx);
740
741        fire_hook(tmp.path(), "edit", "003", "Card", &[]);
742
743        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
744        assert!(!notif.is_error);
745        assert!(notif.message.contains("stderr only"));
746        deregister_hook_sender();
747    }
748
749    #[cfg(unix)]
750    #[test]
751    fn hook_log_records_failure_exit_code() {
752        use std::os::unix::fs::PermissionsExt;
753        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
754
755        let tmp = tempfile::tempdir().unwrap();
756        let hooks_dir = tmp.path().join("hooks");
757        fs::create_dir(&hooks_dir).unwrap();
758
759        let hook_path = hooks_dir.join("post-delete");
760        fs::write(&hook_path, "#!/usr/bin/env sh\nexit 7").unwrap();
761        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
762
763        let (tx, rx) = mpsc::channel();
764        register_hook_sender(tx);
765
766        fire_hook(tmp.path(), "delete", "001", "Fail", &[]);
767        let _ = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
768
769        let contents = fs::read_to_string(tmp.path().join("hooks.log")).unwrap();
770        assert!(contents.contains("exit=7"));
771        deregister_hook_sender();
772    }
773
774    #[cfg(unix)]
775    #[test]
776    fn fire_hook_without_sender_still_logs() {
777        use std::os::unix::fs::PermissionsExt;
778        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
779
780        // Ensure no sender is registered
781        deregister_hook_sender();
782
783        let tmp = tempfile::tempdir().unwrap();
784        let hooks_dir = tmp.path().join("hooks");
785        fs::create_dir(&hooks_dir).unwrap();
786
787        let hook_path = hooks_dir.join("post-create");
788        fs::write(&hook_path, "#!/usr/bin/env sh\necho 'cli mode'").unwrap();
789        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
790
791        // Call run_hook directly (synchronous) instead of fire_hook to avoid race
792        run_hook(
793            tmp.path(),
794            &hook_path,
795            "post-create",
796            "create",
797            "001",
798            "CLI Test",
799            &[],
800        );
801
802        let log_path = tmp.path().join("hooks.log");
803        assert!(log_path.exists());
804        let contents = fs::read_to_string(log_path).unwrap();
805        assert!(contents.contains("post-create"));
806        assert!(contents.contains("exit=0"));
807    }
808
809    #[cfg(unix)]
810    #[test]
811    fn fire_hook_multiline_stdout_uses_first_line_only() {
812        use std::os::unix::fs::PermissionsExt;
813        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
814
815        let tmp = tempfile::tempdir().unwrap();
816        let hooks_dir = tmp.path().join("hooks");
817        fs::create_dir(&hooks_dir).unwrap();
818
819        let hook_path = hooks_dir.join("post-create");
820        fs::write(&hook_path, "#!/usr/bin/env sh\necho 'line one'\necho 'line two'").unwrap();
821        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
822
823        let (tx, rx) = mpsc::channel();
824        register_hook_sender(tx);
825
826        fire_hook(tmp.path(), "create", "001", "Multi", &[]);
827
828        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
829        assert!(notif.message.contains("line one"));
830        assert!(!notif.message.contains("line two"));
831        deregister_hook_sender();
832    }
833
834    #[cfg(unix)]
835    #[test]
836    fn fire_hook_stdout_takes_priority_over_stderr() {
837        use std::os::unix::fs::PermissionsExt;
838        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
839
840        let tmp = tempfile::tempdir().unwrap();
841        let hooks_dir = tmp.path().join("hooks");
842        fs::create_dir(&hooks_dir).unwrap();
843
844        let hook_path = hooks_dir.join("post-create");
845        fs::write(
846            &hook_path,
847            "#!/usr/bin/env sh\necho 'from stdout'\necho 'from stderr' >&2",
848        )
849        .unwrap();
850        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
851
852        let (tx, rx) = mpsc::channel();
853        register_hook_sender(tx);
854
855        fire_hook(tmp.path(), "create", "001", "Both", &[]);
856
857        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
858        assert!(notif.message.contains("from stdout"));
859        assert!(!notif.message.contains("from stderr"));
860        deregister_hook_sender();
861    }
862
863    #[cfg(unix)]
864    #[test]
865    fn env_vars_include_kando_board_dir() {
866        use std::os::unix::fs::PermissionsExt;
867        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
868
869        let tmp = tempfile::tempdir().unwrap();
870        let kando_dir = tmp.path().join(".kando");
871        fs::create_dir(&kando_dir).unwrap();
872        let hooks_dir = kando_dir.join("hooks");
873        fs::create_dir(&hooks_dir).unwrap();
874
875        let hook_path = hooks_dir.join("post-create");
876        fs::write(&hook_path, "#!/usr/bin/env sh\necho \"board_dir=$KANDO_BOARD_DIR\"").unwrap();
877        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
878
879        let (tx, rx) = mpsc::channel();
880        register_hook_sender(tx);
881
882        fire_hook(&kando_dir, "create", "001", "Dir Test", &[]);
883
884        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
885        // KANDO_BOARD_DIR should be the project root (parent of .kando), not .kando itself
886        let canonical_root = tmp.path().canonicalize().unwrap();
887        assert!(
888            notif.message.contains(&canonical_root.display().to_string()),
889            "Expected project root in KANDO_BOARD_DIR, got: {}",
890            notif.message,
891        );
892        deregister_hook_sender();
893    }
894
895    #[test]
896    fn fire_hook_hooks_dir_exists_but_hook_missing() {
897        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
898        let tmp = tempfile::tempdir().unwrap();
899        let hooks_dir = tmp.path().join("hooks");
900        fs::create_dir(&hooks_dir).unwrap();
901        // hooks/ exists but no post-create file
902        fire_hook(tmp.path(), "create", "001", "Test", &[]);
903        std::thread::sleep(std::time::Duration::from_millis(50));
904        // Should not panic and should not create a log entry
905        assert!(!tmp.path().join("hooks.log").exists());
906    }
907
908    #[cfg(unix)]
909    #[test]
910    fn list_hooks_mixed_states() {
911        use std::os::unix::fs::PermissionsExt;
912
913        let tmp = tempfile::tempdir().unwrap();
914        let hooks_dir = tmp.path().join("hooks");
915        fs::create_dir(&hooks_dir).unwrap();
916
917        // Executable hook
918        let exec_path = hooks_dir.join("post-create");
919        fs::write(&exec_path, "#!/usr/bin/env sh").unwrap();
920        fs::set_permissions(&exec_path, fs::Permissions::from_mode(0o755)).unwrap();
921
922        // Non-executable hook
923        let noexec_path = hooks_dir.join("post-move");
924        fs::write(&noexec_path, "#!/usr/bin/env sh").unwrap();
925        fs::set_permissions(&noexec_path, fs::Permissions::from_mode(0o644)).unwrap();
926
927        // "post-delete" is NOT created (missing)
928
929        let hooks = list_hooks(tmp.path());
930
931        let create = hooks.iter().find(|h| h.name == "post-create").unwrap();
932        assert!(create.exists && create.executable);
933
934        let mv = hooks.iter().find(|h| h.name == "post-move").unwrap();
935        assert!(mv.exists && !mv.executable);
936
937        let del = hooks.iter().find(|h| h.name == "post-delete").unwrap();
938        assert!(!del.exists && !del.executable);
939    }
940
941    // -----------------------------------------------------------------------
942    // validate_hook_name
943    // -----------------------------------------------------------------------
944
945    #[test]
946    fn validate_hook_name_accepts_post_create() {
947        assert_eq!(validate_hook_name("post-create"), Ok("create"));
948    }
949
950    #[test]
951    fn validate_hook_name_accepts_post_move() {
952        assert_eq!(validate_hook_name("post-move"), Ok("move"));
953    }
954
955    #[test]
956    fn validate_hook_name_accepts_post_col_add() {
957        assert_eq!(validate_hook_name("post-col-add"), Ok("col-add"));
958    }
959
960    #[test]
961    fn validate_hook_name_accepts_post_auto_close() {
962        assert_eq!(validate_hook_name("post-auto-close"), Ok("auto-close"));
963    }
964
965    #[test]
966    fn validate_hook_name_accepts_all_events() {
967        for event in ALL_HOOK_EVENTS {
968            let name = format!("post-{event}");
969            assert_eq!(
970                validate_hook_name(&name),
971                Ok(*event),
972                "Expected Ok for {name}"
973            );
974        }
975    }
976
977    #[test]
978    fn validate_hook_name_rejects_missing_prefix() {
979        let err = validate_hook_name("create").unwrap_err();
980        assert!(err.contains("must start with 'post-'"), "got: {err}");
981    }
982
983    #[test]
984    fn validate_hook_name_rejects_unknown_event() {
985        let err = validate_hook_name("post-foobar").unwrap_err();
986        assert!(err.contains("Unknown hook event 'foobar'"), "got: {err}");
987    }
988
989    #[test]
990    fn validate_hook_name_rejects_empty_string() {
991        assert!(validate_hook_name("").is_err());
992    }
993
994    #[test]
995    fn validate_hook_name_rejects_just_prefix() {
996        let err = validate_hook_name("post-").unwrap_err();
997        assert!(err.contains("Unknown hook event"), "got: {err}");
998    }
999
1000    #[test]
1001    fn validate_hook_name_rejects_wrong_prefix() {
1002        let err = validate_hook_name("pre-create").unwrap_err();
1003        assert!(err.contains("must start with 'post-'"), "got: {err}");
1004    }
1005
1006    #[test]
1007    fn validate_hook_name_rejects_case_mismatch() {
1008        assert!(validate_hook_name("post-Create").is_err());
1009        assert!(validate_hook_name("POST-CREATE").is_err());
1010    }
1011
1012    // -----------------------------------------------------------------------
1013    // scaffold_hook
1014    // -----------------------------------------------------------------------
1015
1016    #[test]
1017    fn scaffold_hook_creates_hooks_dir_and_file() {
1018        let tmp = tempfile::tempdir().unwrap();
1019        let path = scaffold_hook(tmp.path(), "post-create").unwrap();
1020        assert_eq!(path, tmp.path().join("hooks").join("post-create"));
1021        assert!(path.exists());
1022    }
1023
1024    #[cfg(unix)]
1025    #[test]
1026    fn scaffold_hook_file_contains_shebang() {
1027        let tmp = tempfile::tempdir().unwrap();
1028        let path = scaffold_hook(tmp.path(), "post-create").unwrap();
1029        let content = fs::read_to_string(path).unwrap();
1030        assert_eq!(content, "#!/usr/bin/env sh\n");
1031    }
1032
1033    #[cfg(unix)]
1034    #[test]
1035    fn scaffold_hook_file_is_executable() {
1036        use std::os::unix::fs::PermissionsExt;
1037        let tmp = tempfile::tempdir().unwrap();
1038        let path = scaffold_hook(tmp.path(), "post-move").unwrap();
1039        let mode = fs::metadata(&path).unwrap().permissions().mode();
1040        assert_eq!(mode & 0o777, 0o755);
1041    }
1042
1043    #[test]
1044    fn scaffold_hook_hooks_dir_already_exists() {
1045        let tmp = tempfile::tempdir().unwrap();
1046        fs::create_dir(tmp.path().join("hooks")).unwrap();
1047        // Should not error when hooks/ already exists
1048        let path = scaffold_hook(tmp.path(), "post-edit").unwrap();
1049        assert!(path.exists());
1050    }
1051
1052    #[test]
1053    fn scaffold_hook_returns_correct_path() {
1054        let tmp = tempfile::tempdir().unwrap();
1055        let path = scaffold_hook(tmp.path(), "post-delete").unwrap();
1056        assert_eq!(path, tmp.path().join("hooks").join("post-delete"));
1057    }
1058
1059    #[test]
1060    fn scaffold_hook_overwrites_existing_file() {
1061        let tmp = tempfile::tempdir().unwrap();
1062        let hooks_dir = tmp.path().join("hooks");
1063        fs::create_dir(&hooks_dir).unwrap();
1064        let path = hooks_dir.join("post-create");
1065        fs::write(&path, "custom content").unwrap();
1066
1067        // scaffold_hook unconditionally writes — caller is responsible for existence check
1068        scaffold_hook(tmp.path(), "post-create").unwrap();
1069        let content = fs::read_to_string(&path).unwrap();
1070        assert_ne!(content, "custom content");
1071    }
1072
1073    #[test]
1074    fn validate_hook_name_rejects_whitespace() {
1075        assert!(validate_hook_name(" post-create").is_err());
1076        assert!(validate_hook_name("post-create ").is_err());
1077        assert!(validate_hook_name(" ").is_err());
1078    }
1079
1080    // -----------------------------------------------------------------------
1081    // make_executable
1082    // -----------------------------------------------------------------------
1083
1084    #[cfg(unix)]
1085    #[test]
1086    fn make_executable_sets_755() {
1087        use std::os::unix::fs::PermissionsExt;
1088        let tmp = tempfile::tempdir().unwrap();
1089        let path = tmp.path().join("script");
1090        fs::write(&path, "#!/usr/bin/env sh\n").unwrap();
1091        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1092
1093        make_executable(&path).unwrap();
1094        let mode = fs::metadata(&path).unwrap().permissions().mode();
1095        assert_eq!(mode & 0o777, 0o755);
1096    }
1097
1098    #[cfg(unix)]
1099    #[test]
1100    fn make_executable_idempotent() {
1101        use std::os::unix::fs::PermissionsExt;
1102        let tmp = tempfile::tempdir().unwrap();
1103        let path = tmp.path().join("script");
1104        fs::write(&path, "#!/usr/bin/env sh\n").unwrap();
1105        fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
1106
1107        make_executable(&path).unwrap();
1108        let mode = fs::metadata(&path).unwrap().permissions().mode();
1109        assert_eq!(mode & 0o777, 0o755);
1110    }
1111
1112    #[test]
1113    fn make_executable_nonexistent_file_returns_error() {
1114        let tmp = tempfile::tempdir().unwrap();
1115        let path = tmp.path().join("nonexistent");
1116        assert!(make_executable(&path).is_err());
1117    }
1118
1119    // ── extract_frontmatter_value tests ──
1120
1121    #[test]
1122    fn extract_frontmatter_value_basic_due() {
1123        let content = "---\nid = \"1\"\ndue = \"2025-12-31\"\n---\nBody";
1124        assert_eq!(extract_frontmatter_value(content, "due"), "2025-12-31");
1125    }
1126
1127    #[test]
1128    fn extract_frontmatter_value_basic_blocked() {
1129        let content = "---\nblocked = \"waiting on API\"\n---\n";
1130        assert_eq!(extract_frontmatter_value(content, "blocked"), "waiting on API");
1131    }
1132
1133    #[test]
1134    fn extract_frontmatter_value_missing_key() {
1135        let content = "---\nid = \"1\"\ntitle = \"Task\"\n---\nBody";
1136        assert_eq!(extract_frontmatter_value(content, "due"), "");
1137    }
1138
1139    #[test]
1140    fn extract_frontmatter_value_no_frontmatter() {
1141        assert_eq!(extract_frontmatter_value("Just some text", "due"), "");
1142    }
1143
1144    #[test]
1145    fn extract_frontmatter_value_single_delimiter() {
1146        assert_eq!(extract_frontmatter_value("---\ndue = \"2025-01-01\"\n", "due"), "");
1147    }
1148
1149    #[test]
1150    fn extract_frontmatter_value_empty_value() {
1151        let content = "---\ndue = \"\"\n---\n";
1152        assert_eq!(extract_frontmatter_value(content, "due"), "");
1153    }
1154
1155    #[test]
1156    fn extract_frontmatter_value_unquoted() {
1157        let content = "---\ndue = 2025-12-31\n---\n";
1158        assert_eq!(extract_frontmatter_value(content, "due"), "2025-12-31");
1159    }
1160
1161    #[test]
1162    fn extract_frontmatter_value_key_prefix_no_false_positive() {
1163        let content = "---\ndue_offset_days = 7\n---\n";
1164        assert_eq!(extract_frontmatter_value(content, "due"), "");
1165    }
1166
1167    #[test]
1168    fn extract_frontmatter_value_spaces_around_equals() {
1169        let content = "---\ndue  =  \"2025-12-31\"\n---\n";
1170        assert_eq!(extract_frontmatter_value(content, "due"), "2025-12-31");
1171    }
1172
1173    #[test]
1174    fn extract_frontmatter_value_body_not_matched() {
1175        let content = "---\ntitle = \"Task\"\n---\ndue = \"fake\"";
1176        assert_eq!(extract_frontmatter_value(content, "due"), "");
1177    }
1178
1179    // ── read_card_due_blocked tests ──
1180
1181    #[test]
1182    fn read_card_due_blocked_finds_card() {
1183        let tmp = tempfile::tempdir().unwrap();
1184        let col_dir = tmp.path().join("columns").join("backlog");
1185        fs::create_dir_all(&col_dir).unwrap();
1186        fs::write(
1187            col_dir.join("001.md"),
1188            "---\nid = \"001\"\ntitle = \"T\"\ndue = \"2025-06-01\"\nblocked = \"waiting\"\ncreated = \"2025-01-01T00:00:00Z\"\nupdated = \"2025-01-01T00:00:00Z\"\n---\n",
1189        ).unwrap();
1190        let (due, blocked) = read_card_due_blocked(tmp.path(), "001");
1191        assert_eq!(due, "2025-06-01");
1192        assert_eq!(blocked, "waiting");
1193    }
1194
1195    #[test]
1196    fn read_card_due_blocked_card_not_found() {
1197        let tmp = tempfile::tempdir().unwrap();
1198        let col_dir = tmp.path().join("columns").join("backlog");
1199        fs::create_dir_all(&col_dir).unwrap();
1200        let (due, blocked) = read_card_due_blocked(tmp.path(), "999");
1201        assert_eq!(due, "");
1202        assert_eq!(blocked, "");
1203    }
1204
1205    #[test]
1206    fn read_card_due_blocked_no_columns_dir() {
1207        let tmp = tempfile::tempdir().unwrap();
1208        let (due, blocked) = read_card_due_blocked(tmp.path(), "001");
1209        assert_eq!(due, "");
1210        assert_eq!(blocked, "");
1211    }
1212
1213    #[test]
1214    fn read_card_due_blocked_card_without_due_or_blocked() {
1215        let tmp = tempfile::tempdir().unwrap();
1216        let col_dir = tmp.path().join("columns").join("backlog");
1217        fs::create_dir_all(&col_dir).unwrap();
1218        fs::write(
1219            col_dir.join("001.md"),
1220            "---\nid = \"001\"\ntitle = \"T\"\ncreated = \"2025-01-01T00:00:00Z\"\nupdated = \"2025-01-01T00:00:00Z\"\n---\n",
1221        ).unwrap();
1222        let (due, blocked) = read_card_due_blocked(tmp.path(), "001");
1223        assert_eq!(due, "");
1224        assert_eq!(blocked, "");
1225    }
1226
1227    #[test]
1228    fn read_card_due_blocked_finds_card_in_non_first_column() {
1229        let tmp = tempfile::tempdir().unwrap();
1230        fs::create_dir_all(tmp.path().join("columns").join("backlog")).unwrap();
1231        let col_dir = tmp.path().join("columns").join("in-progress");
1232        fs::create_dir_all(&col_dir).unwrap();
1233        fs::write(
1234            col_dir.join("001.md"),
1235            "---\nid = \"001\"\ntitle = \"T\"\ndue = \"2025-06-01\"\ncreated = \"2025-01-01T00:00:00Z\"\nupdated = \"2025-01-01T00:00:00Z\"\n---\n",
1236        ).unwrap();
1237        let (due, blocked) = read_card_due_blocked(tmp.path(), "001");
1238        assert_eq!(due, "2025-06-01");
1239        assert_eq!(blocked, "");
1240    }
1241
1242    #[cfg(unix)]
1243    #[test]
1244    fn fire_hook_env_includes_card_due_and_blocked() {
1245        use std::os::unix::fs::PermissionsExt;
1246        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1247
1248        let tmp = tempfile::tempdir().unwrap();
1249        let kando_dir = tmp.path().join(".kando");
1250
1251        // Create card on disk
1252        let col_dir = kando_dir.join("columns").join("backlog");
1253        fs::create_dir_all(&col_dir).unwrap();
1254        fs::write(
1255            col_dir.join("001.md"),
1256            "---\nid = \"001\"\ntitle = \"T\"\ndue = \"2025-06-15\"\nblocked = \"needs review\"\ncreated = \"2025-01-01T00:00:00Z\"\nupdated = \"2025-01-01T00:00:00Z\"\n---\n",
1257        ).unwrap();
1258
1259        // Create hook
1260        let hooks_dir = kando_dir.join("hooks");
1261        fs::create_dir(&hooks_dir).unwrap();
1262        let hook_path = hooks_dir.join("post-create");
1263        fs::write(
1264            &hook_path,
1265            "#!/usr/bin/env sh\necho \"due=$KANDO_CARD_DUE blocked=$KANDO_CARD_BLOCKED\"",
1266        ).unwrap();
1267        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1268
1269        let (tx, rx) = mpsc::channel();
1270        register_hook_sender(tx);
1271
1272        fire_hook(&kando_dir, "create", "001", "T", &[]);
1273
1274        let notif = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
1275        assert!(notif.message.contains("due=2025-06-15"), "msg: {}", notif.message);
1276        assert!(notif.message.contains("blocked=needs review"), "msg: {}", notif.message);
1277        deregister_hook_sender();
1278    }
1279
1280    /// Save an env var, set a new value, and restore on drop.
1281    struct EnvGuard {
1282        key: &'static str,
1283        original: Option<String>,
1284    }
1285
1286    impl EnvGuard {
1287        fn set(key: &'static str, val: &str) -> Self {
1288            let original = std::env::var(key).ok();
1289            // SAFETY: serialized by TEST_LOCK — no other test touches env concurrently.
1290            unsafe { std::env::set_var(key, val) };
1291            Self { key, original }
1292        }
1293    }
1294
1295    impl Drop for EnvGuard {
1296        fn drop(&mut self) {
1297            match &self.original {
1298                Some(v) => unsafe { std::env::set_var(self.key, v) },
1299                None => unsafe { std::env::remove_var(self.key) },
1300            }
1301        }
1302    }
1303
1304    #[test]
1305    fn open_in_editor_empty_editor_env_returns_io_not_found() {
1306        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1307        let tmp = tempfile::tempdir().unwrap();
1308        let path = tmp.path().join("test.md");
1309        fs::write(&path, "hello").unwrap();
1310
1311        let _env = EnvGuard::set("EDITOR", "");
1312        let result = open_in_editor(&path);
1313
1314        assert!(result.is_err());
1315        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
1316    }
1317
1318    #[test]
1319    fn open_in_editor_with_failing_editor_does_not_panic() {
1320        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1321        let tmp = tempfile::tempdir().unwrap();
1322        let path = tmp.path().join("does-not-exist.md");
1323
1324        let _env = EnvGuard::set("EDITOR", "false");
1325        // `false` exits 1 → open_in_editor prints a warning but returns Ok(()).
1326        let result = open_in_editor(&path);
1327        assert!(result.is_ok(), "expected Ok since sh runs successfully, got: {result:?}");
1328    }
1329}