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;
6
7pub use super::errors::BoardError;
8
9/// YAML frontmatter fields that batty adds but kanban-md doesn't know about.
10/// kanban-md move/pick rewrites frontmatter and drops these, so we preserve
11/// them around any operation that modifies status.
12const 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    // Preserve scheduling fields that kanban-md doesn't know about
35    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 that kanban-md stripped
45    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    // Save scheduling fields for all tasks before pick (we don't know which will be picked)
79    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            // Restore scheduling fields for the picked task
86            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
161/// Extract scheduling fields from a task file's YAML frontmatter.
162/// Returns a map of field_name→field_value for any scheduling fields found.
163fn 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
175/// Parse scheduling fields out of raw file content with YAML frontmatter.
176fn 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
205/// Re-insert scheduling fields into a task file after kanban-md has rewritten it.
206fn 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    // Build new frontmatter lines, appending scheduling fields at the end
234    let mut lines: Vec<String> = frontmatter.lines().map(|l| l.to_string()).collect();
235    for (key, value) in fields {
236        // Remove any existing line for this field (shouldn't exist after move, but be safe)
237        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
252/// Snapshot scheduling fields for all tasks in the board (used before pick).
253fn 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        // Extract task ID from filename prefix (e.g., "001-title.md" → "1")
269        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
285/// Find a task file by numeric ID in the board's tasks directory.
286fn 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    // --- extract_task_id edge cases ---
689
690    #[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    // --- is_transient_stderr edge cases ---
725
726    #[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    // --- is_empty_pick ---
749
750    #[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    // --- claim_required_for_edit ---
761
762    #[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    // --- extract_claimed_by edge cases ---
773
774    #[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    // --- contains_word ---
803
804    #[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    // --- format_board_command ---
820
821    #[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    // --- classify_failure edge cases ---
835
836    #[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    // --- scheduling field preservation ---
849
850    #[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        // Extract fields
958        let fields = extract_scheduling_fields(temp.path(), "5");
959        assert_eq!(fields.len(), 3);
960
961        // Simulate kanban-md stripping the fields
962        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 fields
966        restore_scheduling_fields(temp.path(), "5", &fields);
967
968        // Verify the fields are back
969        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        // And the new status is preserved
974        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        // Should not panic — gracefully no-op
1000        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            // Create a task
1047            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            // Manually add scheduling fields to the task file
1058            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            // Verify fields are present before move
1067            let before = fs::read_to_string(&task_file).unwrap();
1068            assert!(before.contains("cron_schedule: 0 9 * * 1"));
1069
1070            // Move the task — this would normally strip the fields
1071            run_real_board(
1072                &board_dir,
1073                &["move", &task_id, "in-progress", "--claim", "eng-1-2"],
1074            )
1075            .unwrap();
1076
1077            // Without our fix, these fields would be gone. But we're testing the raw
1078            // kanban-md here. Let's verify they ARE stripped by raw kanban-md:
1079            let after_raw = fs::read_to_string(&task_file).unwrap();
1080            // (This documents the bug — kanban-md strips them)
1081            let fields_stripped = !after_raw.contains("cron_schedule");
1082
1083            if fields_stripped {
1084                // Now test our wrapper: reset, re-add fields, use our move_task
1085                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                // Use a real wrapper that calls the real binary
1098                // We can't use move_task directly because it uses "kanban-md" not the real path,
1099                // but we can test the extract/restore logic directly
1100                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                // Fields are gone after raw move
1110                let after = fs::read_to_string(&task_file).unwrap();
1111                assert!(!after.contains("cron_schedule"));
1112
1113                // Restore them
1114                restore_scheduling_fields(&board_dir, &task_id, &saved);
1115
1116                // Now they're back
1117                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}