Skip to main content

plan_issue/
issue_body.rs

1use std::collections::HashMap;
2
3use nils_common::markdown as common_markdown;
4use nils_markdown::{Engine, RenderError};
5use serde::Serialize;
6
7/// Template body for the task-decomposition block. Bundled with
8/// `include_str!` per Decision 13 in the source document so the
9/// asset travels with the binary and no runtime filesystem lookup
10/// is required.
11const TASK_DECOMPOSITION_TEMPLATE: &str = include_str!("../templates/issue_body.md.tera");
12const TASK_DECOMPOSITION_TEMPLATE_NAME: &str = "issue_body";
13
14#[derive(Debug, Clone, Serialize)]
15struct TaskTableView<'a> {
16    rows: Vec<TaskRowView<'a>>,
17}
18
19#[derive(Debug, Clone, Serialize)]
20struct TaskRowView<'a> {
21    task: &'a str,
22    summary: &'a str,
23    owner: &'a str,
24    branch: &'a str,
25    worktree: &'a str,
26    execution_mode: &'a str,
27    pr: &'a str,
28    status: &'a str,
29    notes: &'a str,
30}
31
32impl<'a> From<&'a TaskRow> for TaskRowView<'a> {
33    fn from(row: &'a TaskRow) -> Self {
34        Self {
35            task: &row.task,
36            summary: &row.summary,
37            owner: &row.owner,
38            branch: &row.branch,
39            worktree: &row.worktree,
40            execution_mode: &row.execution_mode,
41            pr: &row.pr,
42            status: &row.status,
43            notes: &row.notes,
44        }
45    }
46}
47
48/// Render the task-decomposition table block (header row, separator
49/// row, and one row per `rows` entry) through the
50/// [`nils_markdown::Engine`]. Output is byte-equal to the previous
51/// concatenation of [`task_decomposition_header_row`],
52/// [`task_decomposition_separator_row`], and
53/// [`format_task_decomposition_row`] for the same rows.
54pub fn render_task_decomposition_block(rows: &[TaskRow]) -> Result<String, RenderError> {
55    let mut engine = Engine::builder().build();
56    engine.register_template(
57        TASK_DECOMPOSITION_TEMPLATE_NAME,
58        TASK_DECOMPOSITION_TEMPLATE,
59    )?;
60    let view = TaskTableView {
61        rows: rows.iter().map(TaskRowView::from).collect(),
62    };
63    let rendered = engine.render(TASK_DECOMPOSITION_TEMPLATE_NAME, &view)?;
64    // Match the `out.join("\n")` contract used by
65    // `render::render_plan_issue_body`: the table block lives as
66    // elements in a `Vec<String>` and is joined with `\n` later, so
67    // it must not carry a trailing newline of its own. The template
68    // file ends with the standard EOF newline per workspace
69    // convention; strip exactly one trailing `\n` to keep the
70    // template human-friendly and the output byte-stable.
71    Ok(rendered.strip_suffix('\n').unwrap_or(&rendered).to_string())
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct TaskRow {
76    pub task: String,
77    pub summary: String,
78    pub owner: String,
79    pub branch: String,
80    pub worktree: String,
81    pub execution_mode: String,
82    pub pr: String,
83    pub status: String,
84    pub notes: String,
85    pub line_index: usize,
86}
87
88#[derive(Debug, Clone)]
89pub struct TaskTable {
90    lines: Vec<String>,
91    rows: Vec<TaskRow>,
92    trailing_newline: bool,
93}
94
95pub const TASK_DECOMPOSITION_COLUMNS: [&str; 9] = [
96    "Task",
97    "Summary",
98    "Owner",
99    "Branch",
100    "Worktree",
101    "Execution Mode",
102    "PR",
103    "Status",
104    "Notes",
105];
106
107impl TaskTable {
108    pub fn rows(&self) -> &[TaskRow] {
109        &self.rows
110    }
111
112    pub fn rows_mut(&mut self) -> &mut [TaskRow] {
113        &mut self.rows
114    }
115
116    pub fn sprint_row_indexes(&self, sprint: i32) -> Vec<usize> {
117        self.rows
118            .iter()
119            .enumerate()
120            .filter_map(|(idx, row)| (row_sprint(row) == Some(sprint)).then_some(idx))
121            .collect()
122    }
123
124    pub fn render(&self) -> String {
125        let mut lines = self.lines.clone();
126        for row in &self.rows {
127            lines[row.line_index] = format_markdown_row(row);
128        }
129
130        let mut rendered = lines.join("\n");
131        if self.trailing_newline {
132            rendered.push('\n');
133        }
134        rendered
135    }
136}
137
138pub fn parse_task_table(body: &str) -> Result<TaskTable, String> {
139    let trailing_newline = body.ends_with('\n');
140    let lines: Vec<String> = body.lines().map(ToString::to_string).collect();
141
142    let section_idx = lines
143        .iter()
144        .position(|line| line.trim() == "## Task Decomposition")
145        .ok_or_else(|| "issue body missing `## Task Decomposition` section".to_string())?;
146
147    let mut header_idx = None;
148    for (idx, line) in lines.iter().enumerate().skip(section_idx + 1) {
149        let trimmed = line.trim();
150        if trimmed.starts_with("## ") {
151            break;
152        }
153        if trimmed.starts_with('|') {
154            let cells = split_table_cells(trimmed);
155            if normalize_header_cells(&cells) {
156                header_idx = Some(idx);
157                break;
158            }
159        }
160    }
161
162    let header_idx = header_idx.ok_or_else(|| {
163        "task decomposition table header not found or does not match expected columns".to_string()
164    })?;
165
166    let separator_idx = header_idx + 1;
167    if separator_idx >= lines.len() || !lines[separator_idx].trim().starts_with("| ---") {
168        return Err("task decomposition table separator row is missing".to_string());
169    }
170
171    let mut rows = Vec::new();
172    for (idx, line) in lines.iter().enumerate().skip(separator_idx + 1) {
173        let trimmed = line.trim();
174        if trimmed.is_empty() || trimmed.starts_with("## ") || !trimmed.starts_with('|') {
175            break;
176        }
177
178        let cells = split_table_cells(trimmed);
179        if cells.len() != TASK_DECOMPOSITION_COLUMNS.len() {
180            return Err(format!(
181                "task decomposition row has {} columns (expected {}): {}",
182                cells.len(),
183                TASK_DECOMPOSITION_COLUMNS.len(),
184                trimmed
185            ));
186        }
187
188        rows.push(TaskRow {
189            task: cells[0].clone(),
190            summary: cells[1].clone(),
191            owner: cells[2].clone(),
192            branch: cells[3].clone(),
193            worktree: cells[4].clone(),
194            execution_mode: cells[5].clone(),
195            pr: cells[6].clone(),
196            status: cells[7].clone(),
197            notes: cells[8].clone(),
198            line_index: idx,
199        });
200    }
201
202    if rows.is_empty() {
203        return Err("task decomposition table has no task rows".to_string());
204    }
205
206    Ok(TaskTable {
207        lines,
208        rows,
209        trailing_newline,
210    })
211}
212
213#[cfg(test)]
214pub fn task_decomposition_header_row() -> String {
215    format!("| {} |", TASK_DECOMPOSITION_COLUMNS.join(" | "))
216}
217
218#[cfg(test)]
219pub fn task_decomposition_separator_row() -> String {
220    let separators = std::iter::repeat_n("---", TASK_DECOMPOSITION_COLUMNS.len())
221        .collect::<Vec<_>>()
222        .join(" | ");
223    format!("| {separators} |")
224}
225
226pub fn format_task_decomposition_row(cells: [&str; TASK_DECOMPOSITION_COLUMNS.len()]) -> String {
227    let rendered = cells
228        .into_iter()
229        .map(sanitize_table_value)
230        .collect::<Vec<_>>()
231        .join(" | ");
232    format!("| {rendered} |")
233}
234
235pub fn validate_rows(rows: &[TaskRow]) -> Vec<String> {
236    let mut errors = Vec::new();
237
238    let mut isolated_branches: HashMap<String, String> = HashMap::new();
239    let mut isolated_worktrees: HashMap<String, String> = HashMap::new();
240    let mut shared_lane_metadata: HashMap<String, (String, String, String, String)> =
241        HashMap::new();
242    let mut shared_lane_prs: HashMap<String, (String, String)> = HashMap::new();
243
244    for row in rows {
245        let status = row.status.trim().to_ascii_lowercase();
246        if !matches!(
247            status.as_str(),
248            "planned" | "in-progress" | "blocked" | "done"
249        ) {
250            errors.push(format!(
251                "{}: invalid Status `{}`",
252                row.task,
253                row.status.trim()
254            ));
255        }
256
257        let execution_mode = row.execution_mode.trim().to_ascii_lowercase();
258        if !matches!(
259            execution_mode.as_str(),
260            "per-sprint" | "pr-isolated" | "pr-shared" | "tbd"
261        ) {
262            errors.push(format!(
263                "{}: invalid Execution Mode `{}`",
264                row.task,
265                row.execution_mode.trim()
266            ));
267        }
268
269        if matches!(status.as_str(), "in-progress" | "done") {
270            for (label, value) in [
271                ("Owner", row.owner.as_str()),
272                ("Branch", row.branch.as_str()),
273                ("Worktree", row.worktree.as_str()),
274                ("Execution Mode", row.execution_mode.as_str()),
275                ("PR", row.pr.as_str()),
276            ] {
277                if is_placeholder(value) {
278                    errors.push(format!(
279                        "{}: Status `{}` requires non-placeholder {}",
280                        row.task, status, label
281                    ));
282                }
283            }
284        }
285
286        if !matches!(status.as_str(), "planned" | "blocked") {
287            let owner = row.owner.trim().to_ascii_lowercase();
288            if !owner.contains("subagent") {
289                errors.push(format!(
290                    "{}: Owner must include `subagent` for status `{}`",
291                    row.task, status
292                ));
293            }
294            if owner.contains("main-agent") {
295                errors.push(format!(
296                    "{}: Owner cannot reference main-agent for status `{}`",
297                    row.task, status
298                ));
299            }
300        }
301
302        if execution_mode == "pr-isolated" {
303            if !is_placeholder(&row.branch) {
304                let key = row.branch.trim().to_ascii_lowercase();
305                if let Some(prev_task) = isolated_branches.insert(key.clone(), row.task.clone()) {
306                    errors.push(format!(
307                        "{}: pr-isolated Branch `{}` duplicates task {}",
308                        row.task,
309                        row.branch.trim(),
310                        prev_task
311                    ));
312                }
313            }
314
315            if !is_placeholder(&row.worktree) {
316                let key = row.worktree.trim().to_ascii_lowercase();
317                if let Some(prev_task) = isolated_worktrees.insert(key.clone(), row.task.clone()) {
318                    errors.push(format!(
319                        "{}: pr-isolated Worktree `{}` duplicates task {}",
320                        row.task,
321                        row.worktree.trim(),
322                        prev_task
323                    ));
324                }
325            }
326        }
327
328        if let Some((lane_key, lane_label)) = shared_lane_key(row, &execution_mode)
329            && !is_placeholder(&row.owner)
330            && !is_placeholder(&row.branch)
331            && !is_placeholder(&row.worktree)
332        {
333            let owner = row.owner.trim().to_string();
334            let branch = row.branch.trim().to_string();
335            let worktree = row.worktree.trim().to_string();
336
337            if let Some((prev_task, prev_owner, prev_branch, prev_worktree)) =
338                shared_lane_metadata.get(&lane_key)
339            {
340                if prev_owner != &owner || prev_branch != &branch || prev_worktree != &worktree {
341                    errors.push(format!(
342                        "{}: {} lane `{}` Owner/Branch/Worktree (`{}` / `{}` / `{}`) conflicts with task {} (`{}` / `{}` / `{}`)",
343                        row.task,
344                        execution_mode,
345                        lane_label,
346                        owner,
347                        branch,
348                        worktree,
349                        prev_task,
350                        prev_owner,
351                        prev_branch,
352                        prev_worktree
353                    ));
354                }
355            } else {
356                shared_lane_metadata.insert(
357                    lane_key.clone(),
358                    (row.task.clone(), owner, branch, worktree),
359                );
360            }
361
362            if let Some(pr_key) = canonical_pr_key(&row.pr) {
363                let current_pr_display = normalize_pr_display(&row.pr);
364                if let Some((prev_task, prev_pr_key)) = shared_lane_prs.get(&lane_key) {
365                    if prev_pr_key != &pr_key {
366                        errors.push(format!(
367                            "{}: {} lane `{}` PR `{}` conflicts with task {} (`{}`)",
368                            row.task,
369                            execution_mode,
370                            lane_label,
371                            current_pr_display,
372                            prev_task,
373                            prev_pr_key
374                        ));
375                    }
376                } else {
377                    shared_lane_prs.insert(lane_key, (row.task.clone(), pr_key));
378                }
379            }
380        }
381    }
382
383    errors
384}
385
386pub fn runtime_pr_sync_lane(row: &TaskRow) -> Option<(String, String)> {
387    let execution_mode = row.execution_mode.trim().to_ascii_lowercase();
388    shared_lane_key(row, &execution_mode)
389}
390
391fn shared_lane_key(row: &TaskRow, execution_mode: &str) -> Option<(String, String)> {
392    let sprint = row_sprint(row)
393        .map(|value| format!("S{value}"))
394        .unwrap_or_else(|| "unknown".to_string());
395
396    match execution_mode {
397        "per-sprint" => Some((format!("per-sprint:{sprint}"), sprint)),
398        "pr-shared" => {
399            let group = note_value(&row.notes, "pr-group")
400                .filter(|value| !value.trim().is_empty())
401                .unwrap_or_else(|| "unknown-group".to_string());
402            Some((
403                format!("pr-shared:{sprint}:{}", group.to_ascii_lowercase()),
404                format!("{sprint}/{group}"),
405            ))
406        }
407        _ => None,
408    }
409}
410
411fn note_value(notes: &str, key: &str) -> Option<String> {
412    notes
413        .split(';')
414        .map(str::trim)
415        .find_map(|part| part.strip_prefix(&format!("{key}=")).map(str::to_string))
416}
417
418pub fn row_sprint(row: &TaskRow) -> Option<i32> {
419    for token in row.notes.split(';').map(str::trim) {
420        if let Some(value) = token.strip_prefix("sprint=S")
421            && let Ok(number) = value.trim().parse::<i32>()
422        {
423            return Some(number);
424        }
425    }
426
427    parse_sprint_from_task_id(&row.task)
428}
429
430pub fn parse_pr_number(value: &str) -> Option<u64> {
431    let trimmed = value.trim();
432    if is_placeholder(trimmed) {
433        return None;
434    }
435
436    if let Some(rest) = trimmed.strip_prefix('#') {
437        let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect();
438        return digits.parse::<u64>().ok();
439    }
440
441    if trimmed.chars().all(|ch| ch.is_ascii_digit()) {
442        return trimmed.parse::<u64>().ok();
443    }
444
445    if let Some((_, tail)) = trimmed.rsplit_once("/pull/") {
446        let digits: String = tail.chars().take_while(|ch| ch.is_ascii_digit()).collect();
447        return digits.parse::<u64>().ok();
448    }
449
450    None
451}
452
453fn canonical_pr_key(value: &str) -> Option<String> {
454    if is_placeholder(value) {
455        return None;
456    }
457
458    if let Some(pr) = parse_pr_number(value) {
459        return Some(format!("#{pr}"));
460    }
461
462    Some(value.trim().to_ascii_lowercase())
463}
464
465pub fn normalize_pr_display(value: &str) -> String {
466    parse_pr_number(value)
467        .map(|pr| format!("#{pr}"))
468        .unwrap_or_else(|| value.trim().to_string())
469}
470
471pub fn is_placeholder(value: &str) -> bool {
472    let normalized = value.trim().to_ascii_lowercase();
473    if normalized.is_empty() {
474        return true;
475    }
476
477    if matches!(normalized.as_str(), "-" | "none" | "null" | "n/a" | "?") {
478        return true;
479    }
480
481    normalized.starts_with("tbd")
482}
483
484fn normalize_header_cells(cells: &[String]) -> bool {
485    if cells.len() != TASK_DECOMPOSITION_COLUMNS.len() {
486        return false;
487    }
488
489    cells
490        .iter()
491        .zip(TASK_DECOMPOSITION_COLUMNS)
492        .all(|(cell, expected)| cell.trim().eq_ignore_ascii_case(expected))
493}
494
495fn split_table_cells(line: &str) -> Vec<String> {
496    let mut cells: Vec<String> = line
497        .trim()
498        .split('|')
499        .map(|cell| cell.trim().to_string())
500        .collect();
501
502    if cells.first().is_some_and(|cell| cell.is_empty()) {
503        cells.remove(0);
504    }
505    if cells.last().is_some_and(|cell| cell.is_empty()) {
506        cells.pop();
507    }
508
509    cells
510}
511
512fn parse_sprint_from_task_id(task: &str) -> Option<i32> {
513    let normalized = task.trim();
514    if !normalized.starts_with('S') {
515        return None;
516    }
517
518    let rest = &normalized[1..];
519    let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect();
520    if digits.is_empty() {
521        return None;
522    }
523
524    if !rest[digits.len()..].starts_with('T') {
525        return None;
526    }
527
528    digits.parse::<i32>().ok()
529}
530
531fn sanitize_table_value(value: &str) -> String {
532    common_markdown::canonicalize_table_cell(value)
533}
534
535fn format_markdown_row(row: &TaskRow) -> String {
536    format_task_decomposition_row([
537        &row.task,
538        &row.summary,
539        &row.owner,
540        &row.branch,
541        &row.worktree,
542        &row.execution_mode,
543        &row.pr,
544        &row.status,
545        &row.notes,
546    ])
547}
548
549#[cfg(test)]
550mod tests {
551    use super::{
552        TaskRow, format_task_decomposition_row, is_placeholder, normalize_pr_display,
553        parse_pr_number, parse_task_table, render_task_decomposition_block, row_sprint,
554        task_decomposition_header_row, task_decomposition_separator_row, validate_rows,
555    };
556
557    fn sample_task_rows() -> Vec<TaskRow> {
558        vec![
559            TaskRow {
560                task: "S1T1".to_string(),
561                summary: "Add foo".to_string(),
562                owner: "subagent".to_string(),
563                branch: "issue/s1t1".to_string(),
564                worktree: "issue-s1t1".to_string(),
565                execution_mode: "pr-isolated".to_string(),
566                pr: "#101".to_string(),
567                status: "done".to_string(),
568                notes: "-".to_string(),
569                line_index: 0,
570            },
571            TaskRow {
572                task: "S1T2".to_string(),
573                summary: "Pipe|in|summary".to_string(),
574                owner: "main".to_string(),
575                branch: "issue/s1t2".to_string(),
576                worktree: "issue-s1t2".to_string(),
577                execution_mode: "per-sprint".to_string(),
578                pr: "TBD".to_string(),
579                status: "planned".to_string(),
580                notes: "sprint=S1; group=A".to_string(),
581                line_index: 1,
582            },
583            TaskRow {
584                task: "S2T1".to_string(),
585                summary: "Multi\nline\nnotes".to_string(),
586                owner: "subagent".to_string(),
587                branch: "issue/s2t1".to_string(),
588                worktree: "issue-s2t1".to_string(),
589                execution_mode: "pr-shared".to_string(),
590                pr: "#202".to_string(),
591                status: "in-progress".to_string(),
592                notes: "Pipe|here".to_string(),
593                line_index: 2,
594            },
595        ]
596    }
597
598    fn baseline_via_helpers(rows: &[TaskRow]) -> String {
599        let mut out: Vec<String> = vec![
600            task_decomposition_header_row(),
601            task_decomposition_separator_row(),
602        ];
603        for row in rows {
604            out.push(format_task_decomposition_row([
605                &row.task,
606                &row.summary,
607                &row.owner,
608                &row.branch,
609                &row.worktree,
610                &row.execution_mode,
611                &row.pr,
612                &row.status,
613                &row.notes,
614            ]));
615        }
616        out.join("\n")
617    }
618
619    #[test]
620    fn render_task_decomposition_block_matches_helper_composition_for_sample_rows() {
621        let rows = sample_task_rows();
622        let baseline = baseline_via_helpers(&rows);
623        let rendered = render_task_decomposition_block(&rows).expect("render block");
624        pretty_assertions::assert_eq!(baseline, rendered);
625    }
626
627    #[test]
628    fn render_task_decomposition_block_matches_helper_composition_for_empty_rows() {
629        let baseline = baseline_via_helpers(&[]);
630        let rendered = render_task_decomposition_block(&[]).expect("render block");
631        pretty_assertions::assert_eq!(baseline, rendered);
632    }
633
634    #[test]
635    fn render_task_decomposition_block_matches_committed_golden() {
636        let rows = sample_task_rows();
637        let fixture = std::fs::read_to_string(concat!(
638            env!("CARGO_MANIFEST_DIR"),
639            "/tests/golden/issue_body/task_table.golden.md"
640        ))
641        .expect("read golden fixture");
642        let rendered = render_task_decomposition_block(&rows).expect("render block");
643        pretty_assertions::assert_eq!(fixture, rendered);
644    }
645
646    #[test]
647    fn parse_task_table_extracts_rows() {
648        let body = "## Task Decomposition\n\n| Task | Summary | Owner | Branch | Worktree | Execution Mode | PR | Status | Notes |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| S4T1 | A | subagent | issue/s4 | issue-s4 | per-sprint | #1 | done | sprint=S4 |\n";
649        let table = parse_task_table(body).expect("parse table");
650        assert_eq!(table.rows().len(), 1);
651        assert_eq!(table.rows()[0].task, "S4T1");
652    }
653
654    #[test]
655    fn row_sprint_prefers_notes_then_task_id() {
656        let row = TaskRow {
657            task: "S2T1".to_string(),
658            summary: String::new(),
659            owner: String::new(),
660            branch: String::new(),
661            worktree: String::new(),
662            execution_mode: String::new(),
663            pr: String::new(),
664            status: String::new(),
665            notes: "x=1; sprint=S9".to_string(),
666            line_index: 0,
667        };
668        assert_eq!(row_sprint(&row), Some(9));
669    }
670
671    #[test]
672    fn placeholder_and_pr_normalization_cover_common_cases() {
673        assert!(is_placeholder("TBD (per-sprint)"));
674        assert_eq!(parse_pr_number("#221"), Some(221));
675        assert_eq!(
676            parse_pr_number("https://github.com/sympoies/nils-cli/pull/221"),
677            Some(221)
678        );
679        assert_eq!(
680            normalize_pr_display("https://github.com/x/y/pull/17"),
681            "#17"
682        );
683    }
684
685    #[test]
686    fn validate_rows_flags_non_subagent_owner_for_done_rows() {
687        let rows = [TaskRow {
688            task: "S4T1".to_string(),
689            summary: "x".to_string(),
690            owner: "main-agent".to_string(),
691            branch: "issue/s4".to_string(),
692            worktree: "issue-s4".to_string(),
693            execution_mode: "per-sprint".to_string(),
694            pr: "#1".to_string(),
695            status: "done".to_string(),
696            notes: "sprint=S4".to_string(),
697            line_index: 0,
698        }];
699        let errs = validate_rows(&rows);
700        assert!(!errs.is_empty());
701    }
702
703    #[test]
704    fn validate_rows_rejects_per_task_execution_mode() {
705        let rows = [TaskRow {
706            task: "S4T1".to_string(),
707            summary: "x".to_string(),
708            owner: "subagent-s4-t1".to_string(),
709            branch: "issue/s4-t1".to_string(),
710            worktree: "issue-s4-t1".to_string(),
711            execution_mode: "per-task".to_string(),
712            pr: "#1".to_string(),
713            status: "in-progress".to_string(),
714            notes: "sprint=S4".to_string(),
715            line_index: 0,
716        }];
717
718        let errs = validate_rows(&rows);
719        assert_eq!(errs, vec!["S4T1: invalid Execution Mode `per-task`"]);
720    }
721
722    #[test]
723    fn validate_rows_requires_unique_branch_and_worktree_for_pr_isolated_rows() {
724        let rows = [
725            TaskRow {
726                task: "S4T1".to_string(),
727                summary: "x".to_string(),
728                owner: "subagent-s4-t1".to_string(),
729                branch: "issue/s4-shared".to_string(),
730                worktree: "issue-s4-shared".to_string(),
731                execution_mode: "pr-isolated".to_string(),
732                pr: "#1".to_string(),
733                status: "in-progress".to_string(),
734                notes: "sprint=S4".to_string(),
735                line_index: 0,
736            },
737            TaskRow {
738                task: "S4T2".to_string(),
739                summary: "x".to_string(),
740                owner: "subagent-s4-t2".to_string(),
741                branch: "issue/s4-shared".to_string(),
742                worktree: "issue-s4-shared".to_string(),
743                execution_mode: "pr-isolated".to_string(),
744                pr: "#2".to_string(),
745                status: "in-progress".to_string(),
746                notes: "sprint=S4".to_string(),
747                line_index: 1,
748            },
749        ];
750
751        let errs = validate_rows(&rows);
752        assert_eq!(
753            errs,
754            vec![
755                "S4T2: pr-isolated Branch `issue/s4-shared` duplicates task S4T1",
756                "S4T2: pr-isolated Worktree `issue-s4-shared` duplicates task S4T1",
757            ]
758        );
759    }
760
761    #[test]
762    fn validate_rows_detects_conflicting_shared_lane_metadata() {
763        let rows = [
764            TaskRow {
765                task: "S4T1".to_string(),
766                summary: "x".to_string(),
767                owner: "subagent-s4-lane-a".to_string(),
768                branch: "issue/s4-shared-a".to_string(),
769                worktree: "issue-s4-shared-a".to_string(),
770                execution_mode: "pr-shared".to_string(),
771                pr: "#1".to_string(),
772                status: "in-progress".to_string(),
773                notes: "sprint=S4; pr-group=s4-auto-g1".to_string(),
774                line_index: 0,
775            },
776            TaskRow {
777                task: "S4T2".to_string(),
778                summary: "x".to_string(),
779                owner: "subagent-s4-lane-b".to_string(),
780                branch: "issue/s4-shared-b".to_string(),
781                worktree: "issue-s4-shared-b".to_string(),
782                execution_mode: "pr-shared".to_string(),
783                pr: "#1".to_string(),
784                status: "in-progress".to_string(),
785                notes: "sprint=S4; pr-group=s4-auto-g1".to_string(),
786                line_index: 1,
787            },
788        ];
789
790        let errs = validate_rows(&rows);
791        assert!(
792            errs.iter()
793                .any(|err| err.contains("S4T2: pr-shared lane `S4/s4-auto-g1`")),
794            "{errs:?}"
795        );
796    }
797
798    #[test]
799    fn validate_rows_detects_conflicting_per_sprint_lane_metadata() {
800        let rows = [
801            TaskRow {
802                task: "S5T1".to_string(),
803                summary: "x".to_string(),
804                owner: "subagent-s5-lane-a".to_string(),
805                branch: "issue/s5-shared-a".to_string(),
806                worktree: "issue-s5-shared-a".to_string(),
807                execution_mode: "per-sprint".to_string(),
808                pr: "#5".to_string(),
809                status: "in-progress".to_string(),
810                notes: "sprint=S5; pr-group=s5-auto-g1".to_string(),
811                line_index: 0,
812            },
813            TaskRow {
814                task: "S5T2".to_string(),
815                summary: "x".to_string(),
816                owner: "subagent-s5-lane-b".to_string(),
817                branch: "issue/s5-shared-b".to_string(),
818                worktree: "issue-s5-shared-b".to_string(),
819                execution_mode: "per-sprint".to_string(),
820                pr: "#5".to_string(),
821                status: "in-progress".to_string(),
822                notes: "sprint=S5; pr-group=s5-auto-g1".to_string(),
823                line_index: 1,
824            },
825        ];
826
827        let errs = validate_rows(&rows);
828        assert!(
829            errs.iter()
830                .any(|err| err.contains("S5T2: per-sprint lane `S5`")),
831            "{errs:?}"
832        );
833    }
834
835    #[test]
836    fn validate_rows_detects_conflicting_shared_lane_pr_values() {
837        let rows = [
838            TaskRow {
839                task: "S5T1".to_string(),
840                summary: "x".to_string(),
841                owner: "subagent-s5-lane".to_string(),
842                branch: "issue/s5-shared".to_string(),
843                worktree: "issue-s5-shared".to_string(),
844                execution_mode: "pr-shared".to_string(),
845                pr: "#5".to_string(),
846                status: "in-progress".to_string(),
847                notes: "sprint=S5; pr-group=s5-core".to_string(),
848                line_index: 0,
849            },
850            TaskRow {
851                task: "S5T2".to_string(),
852                summary: "x".to_string(),
853                owner: "subagent-s5-lane".to_string(),
854                branch: "issue/s5-shared".to_string(),
855                worktree: "issue-s5-shared".to_string(),
856                execution_mode: "pr-shared".to_string(),
857                pr: "#6".to_string(),
858                status: "in-progress".to_string(),
859                notes: "sprint=S5; pr-group=s5-core".to_string(),
860                line_index: 1,
861            },
862        ];
863
864        let errs = validate_rows(&rows);
865        assert!(
866            errs.iter()
867                .any(|err| err.contains("S5T2: pr-shared lane `S5/s5-core` PR `#6` conflicts")),
868            "{errs:?}"
869        );
870    }
871
872    #[test]
873    fn validate_rows_accepts_equivalent_pr_references_in_shared_lane() {
874        let rows = [
875            TaskRow {
876                task: "S5T1".to_string(),
877                summary: "x".to_string(),
878                owner: "subagent-s5-lane".to_string(),
879                branch: "issue/s5-shared".to_string(),
880                worktree: "issue-s5-shared".to_string(),
881                execution_mode: "per-sprint".to_string(),
882                pr: "#5".to_string(),
883                status: "in-progress".to_string(),
884                notes: "sprint=S5".to_string(),
885                line_index: 0,
886            },
887            TaskRow {
888                task: "S5T2".to_string(),
889                summary: "x".to_string(),
890                owner: "subagent-s5-lane".to_string(),
891                branch: "issue/s5-shared".to_string(),
892                worktree: "issue-s5-shared".to_string(),
893                execution_mode: "per-sprint".to_string(),
894                pr: "https://github.com/x/y/pull/5".to_string(),
895                status: "in-progress".to_string(),
896                notes: "sprint=S5".to_string(),
897                line_index: 1,
898            },
899        ];
900
901        let errs = validate_rows(&rows);
902        assert!(
903            !errs
904                .iter()
905                .any(|err| err.contains("PR") && err.contains("conflicts")),
906            "{errs:?}"
907        );
908    }
909
910    #[test]
911    fn task_table_schema_helpers_and_parser_stay_aligned() {
912        let body = format!(
913            "## Task Decomposition\n\n{}\n{}\n{}\n",
914            task_decomposition_header_row(),
915            task_decomposition_separator_row(),
916            format_task_decomposition_row([
917                "S4T1",
918                "A | B",
919                "subagent",
920                "issue/s4",
921                "issue-s4",
922                "per-sprint",
923                "#1",
924                "done",
925                "sprint=S4"
926            ])
927        );
928
929        let table = parse_task_table(&body).expect("parse table");
930        assert_eq!(table.rows().len(), 1);
931        assert_eq!(table.rows()[0].summary, "A / B");
932    }
933}