Skip to main content

batty_cli/team/
board_cmd.rs

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
10/// YAML frontmatter fields that batty adds but kanban-md doesn't know about.
11/// kanban-md move/pick rewrites frontmatter and drops these, so we preserve
12/// them around any operation that modifies status.
13const 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    // Preserve scheduling fields that kanban-md doesn't know about
36    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 that kanban-md stripped
46    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    // Save scheduling fields for all tasks before pick (we don't know which will be picked)
80    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            // Restore scheduling fields for the picked task
87            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
162/// Extract scheduling fields from a task file's YAML frontmatter.
163/// Returns a map of field_name→field_value for any scheduling fields found.
164fn 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
176/// Parse scheduling fields out of raw file content with YAML frontmatter.
177fn 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
206/// Re-insert scheduling fields into a task file after kanban-md has rewritten it.
207fn 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    // Build new frontmatter lines, appending scheduling fields at the end
235    let mut lines: Vec<String> = frontmatter.lines().map(|l| l.to_string()).collect();
236    for (key, value) in fields {
237        // Remove any existing line for this field (shouldn't exist after move, but be safe)
238        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
253/// Snapshot scheduling fields for all tasks in the board (used before pick).
254fn 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        // Extract task ID from filename prefix (e.g., "001-title.md" → "1")
270        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
286/// Find a task file by numeric ID in the board's tasks directory.
287fn 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    // --- extract_task_id edge cases ---
757
758    #[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    // --- is_transient_stderr edge cases ---
793
794    #[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    // --- is_empty_pick ---
817
818    #[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    // --- claim_required_for_edit ---
829
830    #[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    // --- extract_claimed_by edge cases ---
841
842    #[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    // --- contains_word ---
871
872    #[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    // --- format_board_command ---
888
889    #[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    // --- classify_failure edge cases ---
903
904    #[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    // --- scheduling field preservation ---
917
918    #[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        // Extract fields
1026        let fields = extract_scheduling_fields(temp.path(), "5");
1027        assert_eq!(fields.len(), 3);
1028
1029        // Simulate kanban-md stripping the fields
1030        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 fields
1034        restore_scheduling_fields(temp.path(), "5", &fields);
1035
1036        // Verify the fields are back
1037        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        // And the new status is preserved
1042        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        // Should not panic — gracefully no-op
1068        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            // Create a task
1115            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            // Manually add scheduling fields to the task file
1126            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            // Verify fields are present before move
1135            let before = fs::read_to_string(&task_file).unwrap();
1136            assert!(before.contains("cron_schedule: 0 9 * * 1"));
1137
1138            // Move the task — this would normally strip the fields
1139            run_real_board(
1140                &board_dir,
1141                &["move", &task_id, "in-progress", "--claim", "eng-1-2"],
1142            )
1143            .unwrap();
1144
1145            // Without our fix, these fields would be gone. But we're testing the raw
1146            // kanban-md here. Let's verify they ARE stripped by raw kanban-md:
1147            let after_raw = fs::read_to_string(&task_file).unwrap();
1148            // (This documents the bug — kanban-md strips them)
1149            let fields_stripped = !after_raw.contains("cron_schedule");
1150
1151            if fields_stripped {
1152                // Now test our wrapper: reset, re-add fields, use our move_task
1153                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                // Use a real wrapper that calls the real binary
1166                // We can't use move_task directly because it uses "kanban-md" not the real path,
1167                // but we can test the extract/restore logic directly
1168                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                // Fields are gone after raw move
1178                let after = fs::read_to_string(&task_file).unwrap();
1179                assert!(!after.contains("cron_schedule"));
1180
1181                // Restore them
1182                restore_scheduling_fields(&board_dir, &task_id, &saved);
1183
1184                // Now they're back
1185                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}