1#![allow(dead_code)]
2
3use std::ffi::OsString;
4use std::path::Path;
5use std::process::Command;
6
7pub use super::errors::BoardError;
8
9const SCHEDULING_FIELDS: &[&str] = &["scheduled_for", "cron_schedule", "cron_last_run"];
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct BoardOutput {
16 pub stdout: String,
17 pub stderr: String,
18}
19
20pub fn run_board(board_dir: &Path, args: &[&str]) -> Result<BoardOutput, BoardError> {
21 run_board_with_program("kanban-md", board_dir, args)
22}
23
24pub fn init(board_dir: &Path) -> Result<BoardOutput, BoardError> {
25 run_board(board_dir, &["init"])
26}
27
28pub fn move_task(
29 board_dir: &Path,
30 task_id: &str,
31 status: &str,
32 claim: Option<&str>,
33) -> Result<(), BoardError> {
34 let saved_fields = extract_scheduling_fields(board_dir, task_id);
36
37 let mut args = vec!["move".to_string(), task_id.to_string(), status.to_string()];
38 if let Some(claim) = claim {
39 args.push("--claim".to_string());
40 args.push(claim.to_string());
41 }
42 run_board_owned(board_dir, &args)?;
43
44 restore_scheduling_fields(board_dir, task_id, &saved_fields);
46 Ok(())
47}
48
49pub fn edit_task(board_dir: &Path, task_id: &str, block_reason: &str) -> Result<(), BoardError> {
50 match run_board(board_dir, &["edit", task_id, "--block", block_reason]) {
51 Ok(_) => Ok(()),
52 Err(BoardError::Permanent { stderr, .. }) if claim_required_for_edit(&stderr) => {
53 let claim = show_task(board_dir, task_id)
54 .ok()
55 .and_then(|task| extract_claimed_by(&task));
56 if let Some(claim) = claim.as_deref() {
57 run_board(
58 board_dir,
59 &["edit", task_id, "--block", block_reason, "--claim", claim],
60 )
61 .map(|_| ())
62 } else {
63 Err(BoardError::ClaimOwnerUnknown {
64 task_id: task_id.to_string(),
65 stderr,
66 })
67 }
68 }
69 Err(error) => Err(error),
70 }
71}
72
73pub fn pick_task(
74 board_dir: &Path,
75 claim: &str,
76 move_to: &str,
77) -> Result<Option<String>, BoardError> {
78 let saved = snapshot_scheduling_fields(board_dir);
80
81 match run_board(board_dir, &["pick", "--claim", claim, "--move", move_to]) {
82 Ok(output) => {
83 let task_id = extract_task_id(&output.stdout, "Picked and moved task #")
84 .or_else(|| extract_task_id(&output.stdout, "Picked task #"));
85 if let Some(ref id) = task_id {
87 if let Some(fields) = saved.get(id.as_str()) {
88 restore_scheduling_fields(board_dir, id, fields);
89 }
90 }
91 Ok(task_id)
92 }
93 Err(BoardError::Permanent { stderr, .. }) if is_empty_pick(&stderr) => Ok(None),
94 Err(error) => Err(error),
95 }
96}
97
98pub fn show_task(board_dir: &Path, task_id: &str) -> Result<String, BoardError> {
99 run_board(board_dir, &["show", task_id])
100 .map(|output| output.stdout)
101 .map_err(|error| match error {
102 BoardError::Permanent { stderr, .. }
103 if stderr
104 .to_ascii_lowercase()
105 .contains(&format!("task #{task_id} not found")) =>
106 {
107 BoardError::TaskNotFound {
108 id: task_id.to_string(),
109 }
110 }
111 other => other,
112 })
113}
114
115pub fn list_tasks(board_dir: &Path, status: Option<&str>) -> Result<String, BoardError> {
116 let mut args = vec!["list".to_string()];
117 if let Some(status) = status {
118 args.push("--status".to_string());
119 args.push(status.to_string());
120 }
121 run_board_owned(board_dir, &args).map(|output| output.stdout)
122}
123
124pub fn create_task(
125 board_dir: &Path,
126 title: &str,
127 body: &str,
128 priority: Option<&str>,
129 tags: Option<&str>,
130 depends_on: Option<&str>,
131) -> Result<String, BoardError> {
132 let mut args = vec![
133 "create".to_string(),
134 title.to_string(),
135 "--body".to_string(),
136 body.to_string(),
137 ];
138 if let Some(priority) = priority {
139 args.push("--priority".to_string());
140 args.push(priority.to_string());
141 }
142 if let Some(tags) = tags {
143 args.push("--tags".to_string());
144 args.push(tags.to_string());
145 }
146 if let Some(depends_on) = depends_on {
147 args.push("--depends-on".to_string());
148 args.push(depends_on.to_string());
149 }
150
151 let output = run_board_owned(board_dir, &args)?;
152 extract_task_id(&output.stdout, "Created task #").ok_or_else(|| BoardError::Permanent {
153 message: format!(
154 "failed to parse task ID from create output: {}",
155 output.stdout.lines().next().unwrap_or_default()
156 ),
157 stderr: output.stderr,
158 })
159}
160
161fn extract_scheduling_fields(board_dir: &Path, task_id: &str) -> Vec<(String, String)> {
164 let task_path = match find_task_file(board_dir, task_id) {
165 Some(path) => path,
166 None => return Vec::new(),
167 };
168 let content = match std::fs::read_to_string(&task_path) {
169 Ok(c) => c,
170 Err(_) => return Vec::new(),
171 };
172 parse_scheduling_fields_from_frontmatter(&content)
173}
174
175fn parse_scheduling_fields_from_frontmatter(content: &str) -> Vec<(String, String)> {
177 let trimmed = content.trim_start();
178 if !trimmed.starts_with("---") {
179 return Vec::new();
180 }
181 let after_open = &trimmed[3..];
182 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
183 let close_pos = match after_open.find("\n---") {
184 Some(pos) => pos,
185 None => return Vec::new(),
186 };
187 let frontmatter = &after_open[..close_pos];
188
189 let mut fields = Vec::new();
190 for line in frontmatter.lines() {
191 for &field in SCHEDULING_FIELDS {
192 if let Some(rest) = line.strip_prefix(field) {
193 if let Some(value) = rest.strip_prefix(':') {
194 let value = value.trim().trim_matches('"').trim_matches('\'');
195 if !value.is_empty() {
196 fields.push((field.to_string(), value.to_string()));
197 }
198 }
199 }
200 }
201 }
202 fields
203}
204
205fn restore_scheduling_fields(board_dir: &Path, task_id: &str, fields: &[(String, String)]) {
207 if fields.is_empty() {
208 return;
209 }
210 let task_path = match find_task_file(board_dir, task_id) {
211 Some(path) => path,
212 None => return,
213 };
214 let content = match std::fs::read_to_string(&task_path) {
215 Ok(c) => c,
216 Err(_) => return,
217 };
218
219 let trimmed = content.trim_start();
220 if !trimmed.starts_with("---") {
221 return;
222 }
223 let after_open = &trimmed[3..];
224 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
225 let close_pos = match after_open.find("\n---") {
226 Some(pos) => pos,
227 None => return,
228 };
229
230 let frontmatter = &after_open[..close_pos];
231 let body = &after_open[close_pos + 4..];
232
233 let mut lines: Vec<String> = frontmatter.lines().map(|l| l.to_string()).collect();
235 for (key, value) in fields {
236 lines.retain(|l| !l.starts_with(key.as_str()) || !l[key.len()..].starts_with(':'));
238 lines.push(format!("{key}: {value}"));
239 }
240
241 let mut updated = String::from("---\n");
242 for line in &lines {
243 updated.push_str(line);
244 updated.push('\n');
245 }
246 updated.push_str("---");
247 updated.push_str(body);
248
249 let _ = std::fs::write(&task_path, updated);
250}
251
252fn snapshot_scheduling_fields(
254 board_dir: &Path,
255) -> std::collections::HashMap<String, Vec<(String, String)>> {
256 let tasks_dir = board_dir.join("tasks");
257 let mut map = std::collections::HashMap::new();
258 let entries = match std::fs::read_dir(&tasks_dir) {
259 Ok(e) => e,
260 Err(_) => return map,
261 };
262 for entry in entries.flatten() {
263 let name = entry.file_name();
264 let name_str = name.to_string_lossy();
265 if !name_str.ends_with(".md") {
266 continue;
267 }
268 if let Some(id_str) = name_str.split('-').next() {
270 if let Ok(id) = id_str.parse::<u32>() {
271 let content = match std::fs::read_to_string(entry.path()) {
272 Ok(c) => c,
273 Err(_) => continue,
274 };
275 let fields = parse_scheduling_fields_from_frontmatter(&content);
276 if !fields.is_empty() {
277 map.insert(id.to_string(), fields);
278 }
279 }
280 }
281 }
282 map
283}
284
285fn find_task_file(board_dir: &Path, task_id: &str) -> Option<std::path::PathBuf> {
287 let tasks_dir = board_dir.join("tasks");
288 let id: u32 = task_id.parse().ok()?;
289 let prefix = format!("{id:03}-");
290 let entries = std::fs::read_dir(&tasks_dir).ok()?;
291 for entry in entries.flatten() {
292 let name = entry.file_name();
293 let name_str = name.to_string_lossy();
294 if name_str.starts_with(&prefix) && name_str.ends_with(".md") {
295 return Some(entry.path());
296 }
297 }
298 None
299}
300
301fn run_board_owned(board_dir: &Path, args: &[String]) -> Result<BoardOutput, BoardError> {
302 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
303 run_board(board_dir, &arg_refs)
304}
305
306fn run_board_with_program(
307 program: &str,
308 board_dir: &Path,
309 args: &[&str],
310) -> Result<BoardOutput, BoardError> {
311 let current_dir = board_dir
312 .parent()
313 .filter(|path| !path.as_os_str().is_empty())
314 .unwrap_or_else(|| Path::new("."));
315 let command = format_board_command(program, board_dir, args);
316 let output = Command::new(program)
317 .current_dir(current_dir)
318 .args(build_board_args(board_dir, args))
319 .output()
320 .map_err(|source| BoardError::Exec {
321 command: command.clone(),
322 source,
323 })?;
324
325 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
326 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
327 if output.status.success() {
328 return Ok(BoardOutput { stdout, stderr });
329 }
330
331 let status = output.status.code().map_or_else(
332 || "terminated by signal".to_string(),
333 |code| format!("exit code {code}"),
334 );
335 let message = format!("`{command}` failed with {status}");
336 Err(classify_failure(message, stderr))
337}
338
339fn format_board_command(program: &str, board_dir: &Path, args: &[&str]) -> String {
340 let mut parts = vec![program.to_string()];
341 parts.extend(args.iter().map(|arg| arg.to_string()));
342 parts.push("--dir".to_string());
343 parts.push(board_dir.display().to_string());
344 parts.join(" ")
345}
346
347fn build_board_args(board_dir: &Path, args: &[&str]) -> Vec<OsString> {
348 let mut assembled = args.iter().map(OsString::from).collect::<Vec<_>>();
349 assembled.push(OsString::from("--dir"));
350 assembled.push(board_dir.as_os_str().to_owned());
351 assembled
352}
353
354fn classify_failure(message: String, stderr: String) -> BoardError {
355 if is_transient_stderr(&stderr) {
356 BoardError::Transient { message, stderr }
357 } else {
358 BoardError::Permanent { message, stderr }
359 }
360}
361
362fn is_transient_stderr(stderr: &str) -> bool {
363 let lower = stderr.to_ascii_lowercase();
364 contains_word(&lower, "lock")
365 || lower.contains("resource temporarily unavailable")
366 || lower.contains("no such file or directory")
367 || lower.contains("i/o error")
368 || lower.contains("try again")
369}
370
371fn is_empty_pick(stderr: &str) -> bool {
372 stderr
373 .to_ascii_lowercase()
374 .contains("no unblocked, unclaimed tasks found")
375}
376
377fn claim_required_for_edit(stderr: &str) -> bool {
378 stderr.to_ascii_lowercase().contains("is claimed by")
379}
380
381fn contains_word(text: &str, needle: &str) -> bool {
382 text.split(|ch: char| !ch.is_ascii_alphabetic())
383 .any(|word| word == needle)
384}
385
386fn extract_claimed_by(task_output: &str) -> Option<String> {
387 for line in task_output.lines() {
388 let trimmed = line.trim();
389 if let Some(rest) = trimmed.strip_prefix("Claimed by:") {
390 let claim = rest.trim();
391 if claim == "--" {
392 return None;
393 }
394 let claim = claim.split(" (").next().unwrap_or(claim).trim();
395 if !claim.is_empty() {
396 return Some(claim.to_string());
397 }
398 }
399 }
400 None
401}
402
403fn extract_task_id(text: &str, prefix: &str) -> Option<String> {
404 let start = text.find(prefix)? + prefix.len();
405 let digits = text[start..]
406 .chars()
407 .take_while(|ch| ch.is_ascii_digit())
408 .collect::<String>();
409 if digits.is_empty() {
410 None
411 } else {
412 Some(digits)
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use std::fs;
420 #[cfg(unix)]
421 use std::os::unix::fs::PermissionsExt;
422 use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
423 use std::sync::{LazyLock, Mutex};
424
425 use tempfile::TempDir;
426
427 static CWD_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
428
429 const REAL_KANBAN_MD: &str = "/opt/homebrew/bin/kanban-md";
430
431 fn with_process_cwd<T>(cwd: &Path, action: impl FnOnce() -> T) -> T {
432 let _guard = CWD_LOCK.lock().unwrap();
433 let original = std::env::current_dir().unwrap();
434 std::env::set_current_dir(cwd).unwrap();
435 let result = catch_unwind(AssertUnwindSafe(action));
436 std::env::set_current_dir(original).unwrap();
437 match result {
438 Ok(value) => value,
439 Err(panic) => resume_unwind(panic),
440 }
441 }
442
443 fn real_kanban_available() -> bool {
444 Path::new(REAL_KANBAN_MD).is_file()
445 }
446
447 fn run_real_board(board_dir: &Path, args: &[&str]) -> Result<BoardOutput, BoardError> {
448 run_board_with_program(REAL_KANBAN_MD, board_dir, args)
449 }
450
451 fn create_task_real(
452 board_dir: &Path,
453 title: &str,
454 body: &str,
455 priority: Option<&str>,
456 tags: Option<&str>,
457 depends_on: Option<&str>,
458 ) -> Result<String, BoardError> {
459 let mut args = vec![
460 "create".to_string(),
461 title.to_string(),
462 "--body".to_string(),
463 body.to_string(),
464 ];
465 if let Some(priority) = priority {
466 args.push("--priority".to_string());
467 args.push(priority.to_string());
468 }
469 if let Some(tags) = tags {
470 args.push("--tags".to_string());
471 args.push(tags.to_string());
472 }
473 if let Some(depends_on) = depends_on {
474 args.push("--depends-on".to_string());
475 args.push(depends_on.to_string());
476 }
477
478 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
479 let output = run_real_board(board_dir, &arg_refs)?;
480 extract_task_id(&output.stdout, "Created task #").ok_or_else(|| BoardError::Permanent {
481 message: format!(
482 "failed to parse task ID from create output: {}",
483 output.stdout.lines().next().unwrap_or_default()
484 ),
485 stderr: output.stderr,
486 })
487 }
488
489 fn pick_task_real(
490 board_dir: &Path,
491 claim: &str,
492 move_to: &str,
493 ) -> Result<Option<String>, BoardError> {
494 match run_real_board(board_dir, &["pick", "--claim", claim, "--move", move_to]) {
495 Ok(output) => Ok(extract_task_id(&output.stdout, "Picked and moved task #")
496 .or_else(|| extract_task_id(&output.stdout, "Picked task #"))),
497 Err(BoardError::Permanent { stderr, .. }) if is_empty_pick(&stderr) => Ok(None),
498 Err(error) => Err(error),
499 }
500 }
501
502 fn with_live_board_cwd<T>(action: impl FnOnce() -> T) -> T {
503 let live_repo = TempDir::new().unwrap();
504 let live_board_dir = live_repo.path().join(".batty/team_config/board");
505 fs::create_dir_all(live_board_dir.parent().unwrap()).unwrap();
506 run_real_board(&live_board_dir, &["init"]).unwrap();
507
508 let nested_cwd = live_repo.path().join("nested/project");
509 fs::create_dir_all(&nested_cwd).unwrap();
510 with_process_cwd(&nested_cwd, action)
511 }
512
513 #[test]
514 fn classifies_transient_errors() {
515 let error = classify_failure(
516 "board command failed".to_string(),
517 "resource temporarily unavailable".to_string(),
518 );
519 assert!(matches!(error, BoardError::Transient { .. }));
520 }
521
522 #[test]
523 fn lock_detection_uses_whole_words() {
524 assert!(is_transient_stderr("board lock is busy"));
525 assert!(!is_transient_stderr("no unblocked, unclaimed tasks found"));
526 }
527
528 #[test]
529 fn classifies_permanent_errors() {
530 let error = classify_failure(
531 "board command failed".to_string(),
532 "unknown command \"wat\" for \"kanban-md\"".to_string(),
533 );
534 assert!(matches!(error, BoardError::Permanent { .. }));
535 }
536
537 #[test]
538 fn board_error_reports_transience() {
539 let transient = BoardError::Transient {
540 message: "temporary".to_string(),
541 stderr: "lock busy".to_string(),
542 };
543 let permanent = BoardError::Permanent {
544 message: "permanent".to_string(),
545 stderr: "bad args".to_string(),
546 };
547 assert!(transient.is_transient());
548 assert!(!permanent.is_transient());
549 }
550
551 #[test]
552 fn missing_program_returns_exec_error() {
553 let temp = TempDir::new().unwrap();
554 let error =
555 run_board_with_program("__batty_missing_kanban__", temp.path(), &["list"]).unwrap_err();
556 assert!(matches!(error, BoardError::Exec { .. }));
557 assert!(
558 error
559 .to_string()
560 .contains("__batty_missing_kanban__ list --dir")
561 );
562 }
563
564 #[test]
565 fn build_board_args_appends_dir_flag() {
566 let args = build_board_args(Path::new("/tmp/test board"), &["move", "60", "review"]);
567 let rendered = args
568 .iter()
569 .map(|arg| arg.to_string_lossy().into_owned())
570 .collect::<Vec<_>>();
571 assert_eq!(
572 rendered,
573 vec!["move", "60", "review", "--dir", "/tmp/test board"]
574 );
575 }
576
577 #[test]
578 fn run_board_uses_board_parent_as_cwd() {
579 let temp = TempDir::new().unwrap();
580 let board_dir = temp.path().join("board");
581 let script_path = temp.path().join("fake-kanban.sh");
582 let output_path = temp.path().join("cwd.txt");
583 let script = format!("#!/bin/sh\npwd > \"{}\"\n", output_path.display());
584 fs::write(&script_path, script).unwrap();
585 #[cfg(unix)]
586 {
587 let mut permissions = fs::metadata(&script_path).unwrap().permissions();
588 permissions.set_mode(0o755);
589 fs::set_permissions(&script_path, permissions).unwrap();
590 }
591
592 run_board_with_program(script_path.to_str().unwrap(), &board_dir, &["list"]).unwrap();
593
594 let cwd = fs::canonicalize(fs::read_to_string(&output_path).unwrap().trim()).unwrap();
595 let expected = fs::canonicalize(temp.path()).unwrap();
596 assert_eq!(cwd, expected);
597 }
598
599 #[test]
600 fn extracts_claim_owner_from_show_output() {
601 let output = "Claimed by: eng-1-2 (since 2026-03-21 01:11)";
602 assert_eq!(extract_claimed_by(output).as_deref(), Some("eng-1-2"));
603 assert_eq!(extract_claimed_by("Claimed by: --"), None);
604 }
605
606 #[test]
607 fn init_create_show_round_trip_when_kanban_available() {
608 if !real_kanban_available() {
609 return;
610 }
611
612 with_live_board_cwd(|| {
613 let temp = TempDir::new().unwrap();
614 let board_dir = temp.path().join("board");
615
616 let init_output = run_real_board(&board_dir, &["init"]).unwrap();
617 assert!(board_dir.is_dir());
618 assert!(init_output.stdout.contains("Initialized board"));
619
620 let task_id = create_task_real(
621 &board_dir,
622 "Test task",
623 "Body text",
624 Some("high"),
625 Some("phase-8,wave-1"),
626 None,
627 )
628 .unwrap();
629 assert_eq!(task_id, "1");
630
631 let show = run_real_board(&board_dir, &["show", &task_id])
632 .unwrap()
633 .stdout;
634 assert!(show.contains("Task #1: Test task"));
635 assert!(show.contains("Body text"));
636
637 let list = run_real_board(&board_dir, &["list", "--status", "backlog"])
638 .unwrap()
639 .stdout;
640 assert!(list.contains("Test task"));
641
642 let picked = pick_task_real(&board_dir, "eng-1-2", "in-progress").unwrap();
643 assert_eq!(picked.as_deref(), Some("1"));
644
645 run_real_board(
646 &board_dir,
647 &["move", &task_id, "review", "--claim", "eng-1-2"],
648 )
649 .unwrap();
650 let review = run_real_board(&board_dir, &["show", &task_id])
651 .unwrap()
652 .stdout;
653 assert!(review.contains("Status: review"));
654
655 run_real_board(
656 &board_dir,
657 &[
658 "edit",
659 &task_id,
660 "--block",
661 "needs manager input",
662 "--claim",
663 "eng-1-2",
664 ],
665 )
666 .unwrap();
667 let task_file = fs::read_to_string(board_dir.join("tasks/001-test-task.md")).unwrap();
668 assert!(task_file.contains("block_reason: needs manager input"));
669 });
670 }
671
672 #[test]
673 fn pick_task_returns_none_when_board_is_empty() {
674 if !real_kanban_available() {
675 return;
676 }
677
678 with_live_board_cwd(|| {
679 let temp = TempDir::new().unwrap();
680 let board_dir = temp.path().join("board");
681 run_real_board(&board_dir, &["init"]).unwrap();
682
683 let picked = pick_task_real(&board_dir, "eng-1-2", "in-progress").unwrap();
684 assert_eq!(picked, None);
685 });
686 }
687
688 #[test]
691 fn extract_task_id_returns_none_when_prefix_not_found() {
692 assert_eq!(extract_task_id("no match here", "Created task #"), None);
693 }
694
695 #[test]
696 fn extract_task_id_returns_none_when_no_digits_after_prefix() {
697 assert_eq!(extract_task_id("Created task #abc", "Created task #"), None);
698 }
699
700 #[test]
701 fn extract_task_id_stops_at_non_digit() {
702 assert_eq!(
703 extract_task_id("Created task #42 is done", "Created task #"),
704 Some("42".to_string())
705 );
706 }
707
708 #[test]
709 fn extract_task_id_from_empty_string() {
710 assert_eq!(extract_task_id("", "Created task #"), None);
711 }
712
713 #[test]
714 fn extract_task_id_with_picked_prefix() {
715 assert_eq!(
716 extract_task_id(
717 "Picked and moved task #7 to in-progress",
718 "Picked and moved task #"
719 ),
720 Some("7".to_string())
721 );
722 }
723
724 #[test]
727 fn io_error_is_transient() {
728 assert!(is_transient_stderr("i/o error during write"));
729 }
730
731 #[test]
732 fn try_again_is_transient() {
733 assert!(is_transient_stderr("operation failed, try again later"));
734 }
735
736 #[test]
737 fn no_such_file_is_transient() {
738 assert!(is_transient_stderr("no such file or directory: /tmp/board"));
739 }
740
741 #[test]
742 fn unknown_command_is_not_transient() {
743 assert!(!is_transient_stderr(
744 "unknown command \"invalid\" for \"kanban-md\""
745 ));
746 }
747
748 #[test]
751 fn empty_pick_detected() {
752 assert!(is_empty_pick("No unblocked, unclaimed tasks found in todo"));
753 }
754
755 #[test]
756 fn non_empty_pick_error_not_detected() {
757 assert!(!is_empty_pick("task #5 is already claimed"));
758 }
759
760 #[test]
763 fn claim_required_detected_in_stderr() {
764 assert!(claim_required_for_edit("task #5 is claimed by eng-1-2"));
765 }
766
767 #[test]
768 fn claim_not_required_for_unrelated_error() {
769 assert!(!claim_required_for_edit("unknown field \"foo\""));
770 }
771
772 #[test]
775 fn extract_claimed_by_empty_input() {
776 assert_eq!(extract_claimed_by(""), None);
777 }
778
779 #[test]
780 fn extract_claimed_by_no_claimed_line() {
781 assert_eq!(extract_claimed_by("Status: todo\nPriority: high"), None);
782 }
783
784 #[test]
785 fn extract_claimed_by_empty_claim_value() {
786 assert_eq!(extract_claimed_by("Claimed by: "), None);
787 }
788
789 #[test]
790 fn extract_claimed_by_without_timestamp() {
791 assert_eq!(
792 extract_claimed_by("Claimed by: manager-1"),
793 Some("manager-1".to_string())
794 );
795 }
796
797 #[test]
798 fn extract_claimed_by_dash_dash_is_none() {
799 assert_eq!(extract_claimed_by("Claimed by: --"), None);
800 }
801
802 #[test]
805 fn contains_word_matches_isolated_word() {
806 assert!(contains_word("the lock is active", "lock"));
807 }
808
809 #[test]
810 fn contains_word_rejects_substring() {
811 assert!(!contains_word("unlock the door", "lock"));
812 }
813
814 #[test]
815 fn contains_word_empty_text() {
816 assert!(!contains_word("", "lock"));
817 }
818
819 #[test]
822 fn format_board_command_includes_all_args() {
823 let result =
824 format_board_command("kanban-md", Path::new("/tmp/board"), &["move", "5", "done"]);
825 assert_eq!(result, "kanban-md move 5 done --dir /tmp/board");
826 }
827
828 #[test]
829 fn format_board_command_no_args() {
830 let result = format_board_command("kanban-md", Path::new("/board"), &[]);
831 assert_eq!(result, "kanban-md --dir /board");
832 }
833
834 #[test]
837 fn classify_failure_lock_in_stderr() {
838 let error = classify_failure("failed".to_string(), "file lock held".to_string());
839 assert!(matches!(error, BoardError::Transient { .. }));
840 }
841
842 #[test]
843 fn classify_failure_empty_stderr_is_permanent() {
844 let error = classify_failure("failed".to_string(), "".to_string());
845 assert!(matches!(error, BoardError::Permanent { .. }));
846 }
847
848 #[test]
851 fn parse_scheduling_fields_extracts_all_three() {
852 let content = "---\nid: 42\ntitle: recurring task\nstatus: todo\nscheduled_for: 2026-04-01T09:00:00Z\ncron_schedule: 0 9 * * 1\ncron_last_run: 2026-03-21T09:00:00Z\n---\n\nBody.\n";
853 let fields = parse_scheduling_fields_from_frontmatter(content);
854 assert_eq!(fields.len(), 3);
855 assert_eq!(
856 fields[0],
857 (
858 "scheduled_for".to_string(),
859 "2026-04-01T09:00:00Z".to_string()
860 )
861 );
862 assert_eq!(
863 fields[1],
864 ("cron_schedule".to_string(), "0 9 * * 1".to_string())
865 );
866 assert_eq!(
867 fields[2],
868 (
869 "cron_last_run".to_string(),
870 "2026-03-21T09:00:00Z".to_string()
871 )
872 );
873 }
874
875 #[test]
876 fn parse_scheduling_fields_extracts_quoted_values() {
877 let content = "---\nid: 1\ntitle: test\nstatus: todo\nscheduled_for: \"2026-06-15T12:00:00Z\"\n---\n\nBody.\n";
878 let fields = parse_scheduling_fields_from_frontmatter(content);
879 assert_eq!(fields.len(), 1);
880 assert_eq!(
881 fields[0],
882 (
883 "scheduled_for".to_string(),
884 "2026-06-15T12:00:00Z".to_string()
885 )
886 );
887 }
888
889 #[test]
890 fn parse_scheduling_fields_returns_empty_when_none_present() {
891 let content = "---\nid: 1\ntitle: test\nstatus: todo\n---\n\nBody.\n";
892 let fields = parse_scheduling_fields_from_frontmatter(content);
893 assert!(fields.is_empty());
894 }
895
896 #[test]
897 fn parse_scheduling_fields_handles_missing_frontmatter() {
898 let fields = parse_scheduling_fields_from_frontmatter("no frontmatter here");
899 assert!(fields.is_empty());
900 }
901
902 #[test]
903 fn parse_scheduling_fields_handles_unclosed_frontmatter() {
904 let fields = parse_scheduling_fields_from_frontmatter("---\nid: 1\ntitle: test\n");
905 assert!(fields.is_empty());
906 }
907
908 #[test]
909 fn parse_scheduling_fields_ignores_empty_values() {
910 let content = "---\nid: 1\ntitle: test\nstatus: todo\nscheduled_for:\n---\n\nBody.\n";
911 let fields = parse_scheduling_fields_from_frontmatter(content);
912 assert!(fields.is_empty());
913 }
914
915 #[test]
916 fn find_task_file_locates_by_id() {
917 let temp = TempDir::new().unwrap();
918 let tasks_dir = temp.path().join("tasks");
919 fs::create_dir_all(&tasks_dir).unwrap();
920 fs::write(
921 tasks_dir.join("042-recurring-task.md"),
922 "---\nid: 42\n---\n",
923 )
924 .unwrap();
925 fs::write(tasks_dir.join("001-other.md"), "---\nid: 1\n---\n").unwrap();
926
927 let found = find_task_file(temp.path(), "42");
928 assert!(found.is_some());
929 assert!(found.unwrap().ends_with("042-recurring-task.md"));
930 }
931
932 #[test]
933 fn find_task_file_returns_none_for_missing_id() {
934 let temp = TempDir::new().unwrap();
935 let tasks_dir = temp.path().join("tasks");
936 fs::create_dir_all(&tasks_dir).unwrap();
937 fs::write(tasks_dir.join("001-test.md"), "---\nid: 1\n---\n").unwrap();
938
939 assert!(find_task_file(temp.path(), "99").is_none());
940 }
941
942 #[test]
943 fn find_task_file_returns_none_for_missing_dir() {
944 let temp = TempDir::new().unwrap();
945 assert!(find_task_file(temp.path(), "1").is_none());
946 }
947
948 #[test]
949 fn extract_and_restore_scheduling_fields_round_trip() {
950 let temp = TempDir::new().unwrap();
951 let tasks_dir = temp.path().join("tasks");
952 fs::create_dir_all(&tasks_dir).unwrap();
953
954 let original = "---\nid: 5\ntitle: recurring\nstatus: todo\nscheduled_for: 2026-04-01T09:00:00Z\ncron_schedule: 0 9 * * 1\ncron_last_run: 2026-03-21T09:00:00Z\n---\n\nTask body.\n";
955 fs::write(tasks_dir.join("005-recurring.md"), original).unwrap();
956
957 let fields = extract_scheduling_fields(temp.path(), "5");
959 assert_eq!(fields.len(), 3);
960
961 let stripped = "---\nid: 5\ntitle: recurring\nstatus: in-progress\nclaimed_by: eng-1-2\n---\n\nTask body.\n";
963 fs::write(tasks_dir.join("005-recurring.md"), stripped).unwrap();
964
965 restore_scheduling_fields(temp.path(), "5", &fields);
967
968 let result = fs::read_to_string(tasks_dir.join("005-recurring.md")).unwrap();
970 assert!(result.contains("scheduled_for: 2026-04-01T09:00:00Z"));
971 assert!(result.contains("cron_schedule: 0 9 * * 1"));
972 assert!(result.contains("cron_last_run: 2026-03-21T09:00:00Z"));
973 assert!(result.contains("status: in-progress"));
975 assert!(result.contains("claimed_by: eng-1-2"));
976 }
977
978 #[test]
979 fn restore_does_nothing_when_no_fields() {
980 let temp = TempDir::new().unwrap();
981 let tasks_dir = temp.path().join("tasks");
982 fs::create_dir_all(&tasks_dir).unwrap();
983
984 let content = "---\nid: 1\ntitle: test\nstatus: done\n---\n\nBody.\n";
985 fs::write(tasks_dir.join("001-test.md"), content).unwrap();
986
987 restore_scheduling_fields(temp.path(), "1", &[]);
988
989 let result = fs::read_to_string(tasks_dir.join("001-test.md")).unwrap();
990 assert_eq!(result, content);
991 }
992
993 #[test]
994 fn restore_handles_missing_task_file() {
995 let temp = TempDir::new().unwrap();
996 let tasks_dir = temp.path().join("tasks");
997 fs::create_dir_all(&tasks_dir).unwrap();
998
999 restore_scheduling_fields(
1001 temp.path(),
1002 "99",
1003 &[("cron_schedule".to_string(), "0 9 * * *".to_string())],
1004 );
1005 }
1006
1007 #[test]
1008 fn snapshot_scheduling_fields_indexes_by_task_id() {
1009 let temp = TempDir::new().unwrap();
1010 let tasks_dir = temp.path().join("tasks");
1011 fs::create_dir_all(&tasks_dir).unwrap();
1012
1013 fs::write(
1014 tasks_dir.join("010-scheduled.md"),
1015 "---\nid: 10\ntitle: scheduled\nstatus: todo\nscheduled_for: 2026-05-01T00:00:00Z\n---\n\nBody.\n",
1016 ).unwrap();
1017 fs::write(
1018 tasks_dir.join("011-plain.md"),
1019 "---\nid: 11\ntitle: plain\nstatus: todo\n---\n\nBody.\n",
1020 )
1021 .unwrap();
1022 fs::write(
1023 tasks_dir.join("012-cron.md"),
1024 "---\nid: 12\ntitle: cron\nstatus: todo\ncron_schedule: 30 8 * * *\n---\n\nBody.\n",
1025 )
1026 .unwrap();
1027
1028 let snapshot = snapshot_scheduling_fields(temp.path());
1029 assert_eq!(snapshot.len(), 2);
1030 assert!(snapshot.contains_key("10"));
1031 assert!(snapshot.contains_key("12"));
1032 assert!(!snapshot.contains_key("11"));
1033 }
1034
1035 #[test]
1036 fn move_task_preserves_scheduling_fields_end_to_end() {
1037 if !real_kanban_available() {
1038 return;
1039 }
1040
1041 with_live_board_cwd(|| {
1042 let temp = TempDir::new().unwrap();
1043 let board_dir = temp.path().join("board");
1044 run_real_board(&board_dir, &["init"]).unwrap();
1045
1046 let task_id = create_task_real(
1048 &board_dir,
1049 "Recurring task",
1050 "This runs on a schedule",
1051 Some("medium"),
1052 None,
1053 None,
1054 )
1055 .unwrap();
1056
1057 let task_file = board_dir.join("tasks/001-recurring-task.md");
1059 let content = fs::read_to_string(&task_file).unwrap();
1060 let patched = content.replace(
1061 "\n---\n",
1062 "\nscheduled_for: 2026-06-01T00:00:00Z\ncron_schedule: 0 9 * * 1\ncron_last_run: 2026-05-25T09:00:00Z\n---\n",
1063 );
1064 fs::write(&task_file, &patched).unwrap();
1065
1066 let before = fs::read_to_string(&task_file).unwrap();
1068 assert!(before.contains("cron_schedule: 0 9 * * 1"));
1069
1070 run_real_board(
1072 &board_dir,
1073 &["move", &task_id, "in-progress", "--claim", "eng-1-2"],
1074 )
1075 .unwrap();
1076
1077 let after_raw = fs::read_to_string(&task_file).unwrap();
1080 let fields_stripped = !after_raw.contains("cron_schedule");
1082
1083 if fields_stripped {
1084 run_real_board(
1086 &board_dir,
1087 &["move", &task_id, "todo", "--claim", "eng-1-2"],
1088 )
1089 .unwrap();
1090 let content2 = fs::read_to_string(&task_file).unwrap();
1091 let patched2 = content2.replace(
1092 "\n---\n",
1093 "\nscheduled_for: 2026-06-01T00:00:00Z\ncron_schedule: 0 9 * * 1\ncron_last_run: 2026-05-25T09:00:00Z\n---\n",
1094 );
1095 fs::write(&task_file, &patched2).unwrap();
1096
1097 let saved = extract_scheduling_fields(&board_dir, &task_id);
1101 assert_eq!(saved.len(), 3);
1102
1103 run_real_board(
1104 &board_dir,
1105 &["move", &task_id, "in-progress", "--claim", "eng-1-2"],
1106 )
1107 .unwrap();
1108
1109 let after = fs::read_to_string(&task_file).unwrap();
1111 assert!(!after.contains("cron_schedule"));
1112
1113 restore_scheduling_fields(&board_dir, &task_id, &saved);
1115
1116 let restored = fs::read_to_string(&task_file).unwrap();
1118 assert!(restored.contains("scheduled_for: 2026-06-01T00:00:00Z"));
1119 assert!(restored.contains("cron_schedule: 0 9 * * 1"));
1120 assert!(restored.contains("cron_last_run: 2026-05-25T09:00:00Z"));
1121 assert!(restored.contains("status: in-progress"));
1122 }
1123 });
1124 }
1125}