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