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
9pub struct HookNotification {
11 #[allow(dead_code)]
14 pub hook_name: String,
15 pub message: String,
16 pub is_error: bool,
17}
18
19pub struct HookInfo {
21 pub name: String,
22 pub path: PathBuf,
23 pub exists: bool,
24 pub executable: bool,
25}
26
27pub 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
47static 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
57pub fn register_hook_sender(tx: mpsc::Sender<HookNotification>) {
61 if let Ok(mut guard) = hook_tx().lock() {
62 *guard = Some(tx);
63 }
64}
65
66pub fn deregister_hook_sender() {
68 if let Ok(mut guard) = hook_tx().lock() {
69 *guard = None;
70 }
71}
72
73pub fn hook_name_for_action(action: &str) -> String {
79 format!("post-{action}")
80}
81
82fn 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
108fn 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 if let Some(after_key) = trimmed.strip_prefix(key) {
122 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
140pub 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 if !hook_path.exists() {
158 return;
159 }
160
161 if !is_executable(&hook_path) {
163 return;
164 }
165
166 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
189fn 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 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_hook_result(
240 kando_dir,
241 hook_name,
242 code,
243 duration.as_millis(),
244 &first_line,
245 );
246
247 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
311pub 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#[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 }
351
352fn valid_hooks_list() -> String {
357 ALL_HOOK_EVENTS
358 .iter()
359 .map(|e| format!("post-{e}"))
360 .collect::<Vec<_>>()
361 .join(", ")
362}
363
364pub 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
384pub 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#[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
413pub 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#[cfg(test)]
455mod tests {
456 use super::*;
457 use std::fs;
458 use std::sync::Mutex as StdMutex;
459
460 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 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 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 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 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 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 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 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 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 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 fire_hook(tmp.path(), "create", "001", "Test", &[]);
903 std::thread::sleep(std::time::Duration::from_millis(50));
904 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 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 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 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 #[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 #[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 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(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 #[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 #[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 #[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 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 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 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 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 let result = open_in_editor(&path);
1327 assert!(result.is_ok(), "expected Ok since sh runs successfully, got: {result:?}");
1328 }
1329}