1use std::collections::HashMap;
2
3use nils_common::markdown as common_markdown;
4use nils_markdown::{Engine, RenderError};
5use serde::Serialize;
6
7const 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
48pub 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 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}