1use std::collections::{HashMap, HashSet};
8
9pub const PLAN_LABEL: &str = "orch-plan";
10pub const HUMAN_LABEL: &str = "owner:human";
11pub const NO_AGENT_LABEL: &str = "no-agent";
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct PlanError {
15 pub message: String,
16}
17
18impl std::fmt::Display for PlanError {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 f.write_str(&self.message)
21 }
22}
23
24impl std::error::Error for PlanError {}
25
26impl PlanError {
27 fn new(message: impl Into<String>) -> Self {
28 Self {
29 message: message.into(),
30 }
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Milestone {
36 pub id: String,
37 pub title: String,
38 pub heading: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Task {
44 pub id: String,
45 pub title: String,
46 pub milestone_id: String,
47 pub milestone_title: String,
48 pub milestone_heading: String,
49 pub anchor_heading: String,
51 pub goal: String,
52 pub scope: String,
53 pub acceptance: Vec<String>,
54 pub depends_on: Vec<String>,
55 pub owner_human: bool,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Roadmap {
60 pub milestones: Vec<Milestone>,
61 pub tasks: Vec<Task>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ChangeKind {
66 Create,
67 Update,
68 Unchanged,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ExistingIssue {
73 pub task_id: String,
74 pub number: u64,
75 pub database_id: u64,
76 pub title: String,
77 pub body: String,
78 pub labels: Vec<String>,
79 pub state: String,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct TaskPlan {
84 pub task: Task,
85 pub kind: ChangeKind,
86 pub number: Option<u64>,
87 pub database_id: Option<u64>,
88 pub existing_labels: Vec<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct RemovedTask {
93 pub task_id: String,
94 pub number: u64,
95 pub state: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct PlanDiff {
100 pub tasks: Vec<TaskPlan>,
101 pub removed: Vec<RemovedTask>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Binding {
106 pub task_id: String,
107 pub number: Option<u64>,
108 pub database_id: Option<u64>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct DepRef {
113 pub number: u64,
114 pub database_id: u64,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum LinkEdit {
119 Add {
120 task_id: String,
121 issue_number: Option<u64>,
122 blocking_task_id: String,
123 blocking_number: Option<u64>,
124 blocking_database_id: Option<u64>,
125 },
126 Remove {
127 task_id: String,
128 issue_number: u64,
129 blocking_number: u64,
130 blocking_database_id: u64,
131 },
132}
133
134struct Draft {
135 id: String,
136 title: String,
137 milestone_id: String,
138 milestone_title: String,
139 milestone_heading: String,
140 anchor_heading: String,
141 goal: String,
142 scope: String,
143 acceptance: Vec<String>,
144 depends_raw: Option<String>,
145 owner_human: bool,
146 in_acceptance: bool,
147 line: usize,
148}
149
150#[derive(Clone, Copy)]
151struct TableCols {
152 task: usize,
153 goal: Option<usize>,
154 scope: Option<usize>,
155 acceptance: Option<usize>,
156 depends: Option<usize>,
157 owner: Option<usize>,
158}
159
160pub fn parse_roadmap(markdown: &str) -> Result<Roadmap, PlanError> {
161 let mut milestones = Vec::new();
162 let mut tasks = Vec::new();
163 let mut errors = Vec::new();
164 let mut current: Option<Milestone> = None;
165 let mut draft: Option<Draft> = None;
166 let mut table: Option<TableCols> = None;
167 let mut seen = HashSet::new();
168
169 for (idx, raw) in markdown.lines().enumerate() {
170 let line = raw.trim_end_matches('\r');
171 let line_no = idx + 1;
172
173 if let Some(cols) = table {
174 if line.trim().starts_with('|') {
175 if !is_separator_row(line) {
176 if let Some(milestone) = current.as_ref() {
177 match task_from_row(line, &cols, milestone, line_no) {
178 Ok(Some(task)) => {
179 push_task(task, &mut tasks, &mut seen, &mut errors);
180 }
181 Ok(None) => {}
182 Err(err) => errors.push(err),
183 }
184 }
185 }
186 continue;
187 }
188 table = None;
189 }
190
191 if let Some(rest) = line.strip_prefix("### ") {
192 flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
193 let Some(milestone) = current.as_ref() else {
194 errors.push(format!(
195 "line {line_no}: task heading is outside a milestone"
196 ));
197 continue;
198 };
199 if let Some((id, title)) = parse_task_heading(rest) {
200 draft = Some(Draft::new(id, title, milestone, rest.trim(), line_no));
201 }
202 continue;
203 }
204
205 if let Some(rest) = line.strip_prefix("## ") {
206 flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
207 if let Some((id, title)) = parse_milestone_heading(rest) {
208 let milestone = Milestone {
209 id,
210 title,
211 heading: rest.trim().to_string(),
212 };
213 current = Some(milestone.clone());
214 milestones.push(milestone);
215 } else {
216 current = None;
217 }
218 continue;
219 }
220
221 if let Some(cols) = table_cols(line) {
222 flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
223 if current.is_some() {
224 table = Some(cols);
225 }
226 continue;
227 }
228
229 if let Some(draft) = draft.as_mut() {
230 if let Some((name, value)) = top_level_field(line) {
231 apply_field(draft, name, value);
232 continue;
233 }
234 if draft.in_acceptance && is_acceptance_line(line) {
235 draft.acceptance.push(unindent(line));
236 }
237 }
238 }
239
240 flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
241 check_dependencies(&tasks, &mut errors);
242
243 if errors.is_empty() {
244 Ok(Roadmap { milestones, tasks })
245 } else {
246 Err(PlanError::new(errors.join("\n")))
247 }
248}
249
250pub fn select<'a>(
251 roadmap: &'a Roadmap,
252 milestone: Option<&str>,
253) -> Result<Vec<&'a Task>, PlanError> {
254 let Some(wanted) = milestone else {
255 return Ok(roadmap.tasks.iter().collect());
256 };
257 if !roadmap
258 .milestones
259 .iter()
260 .any(|item| item.id.eq_ignore_ascii_case(wanted))
261 {
262 return Err(PlanError::new(format!("no milestone {wanted}")));
263 }
264 Ok(roadmap
265 .tasks
266 .iter()
267 .filter(|task| task.milestone_id.eq_ignore_ascii_case(wanted))
268 .collect())
269}
270
271pub fn issue_title(task: &Task) -> String {
272 if task.title.is_empty() {
273 task.id.clone()
274 } else {
275 format!("{} {}", task.id, task.title)
276 }
277}
278
279pub fn task_anchor(task: &Task) -> String {
280 if task.anchor_heading.is_empty() {
281 github_anchor(&task.milestone_heading)
282 } else {
283 github_anchor(&task.anchor_heading)
284 }
285}
286
287pub fn issue_body(task: &Task, repo: &str, branch: &str, rel_path: &str) -> String {
288 let mut out = String::new();
289 if !task.goal.is_empty() {
290 out.push_str("## Goal\n\n");
291 out.push_str(task.goal.trim());
292 out.push_str("\n\n");
293 }
294 if !task.scope.is_empty() {
295 out.push_str("## Scope\n\n");
296 out.push_str(task.scope.trim());
297 out.push_str("\n\n");
298 }
299 if !task.acceptance.is_empty() {
300 out.push_str("## Acceptance\n\n");
301 for item in &task.acceptance {
302 let item = item.trim_end();
303 if item.starts_with('-') || item.starts_with('*') || item.starts_with(' ') {
304 out.push_str(item);
305 } else {
306 out.push_str("- ");
307 out.push_str(item);
308 }
309 out.push('\n');
310 }
311 out.push('\n');
312 }
313 let url = format!(
314 "https://github.com/{repo}/blob/{branch}/{path}#{anchor}",
315 branch = encode_git_path(branch),
316 path = encode_git_path(rel_path),
317 anchor = urlencoding::encode(&task_anchor(task)),
318 );
319 out.push_str(&format!("[{rel_path}]({url})\n\n"));
320 out.push_str(&marker_for(&task.id));
321 out.push('\n');
322 out
323}
324
325pub fn marker_for(id: &str) -> String {
326 format!("<!-- orch-plan: {id} -->")
327}
328
329pub fn marker_id(body: &str) -> Option<String> {
330 let start = body.find("<!-- orch-plan:")?;
331 let rest = &body[start + "<!-- orch-plan:".len()..];
332 let end = rest.find("-->")?;
333 let id = rest[..end].trim();
334 is_task_id(id).then(|| id.to_string())
335}
336
337pub fn labels_for(existing: &[String], human: bool) -> Vec<String> {
339 let drop_no_agent = existing.iter().any(|label| label == HUMAN_LABEL);
340 let mut labels = vec![PLAN_LABEL.to_string()];
341 if human {
342 labels.push(HUMAN_LABEL.to_string());
343 labels.push(NO_AGENT_LABEL.to_string());
344 }
345 for label in existing {
346 if label == PLAN_LABEL || label == HUMAN_LABEL {
347 continue;
348 }
349 if label == NO_AGENT_LABEL && (human || drop_no_agent) {
350 continue;
351 }
352 if !labels.iter().any(|have| have == label) {
353 labels.push(label.clone());
354 }
355 }
356 labels
357}
358
359pub fn diff_import(
360 roadmap: &Roadmap,
361 existing: &[ExistingIssue],
362 milestone: Option<&str>,
363 mut body_for: impl FnMut(&Task) -> String,
364) -> Result<PlanDiff, PlanError> {
365 let selected = select(roadmap, milestone)?;
366 let mut by_id: HashMap<&str, &ExistingIssue> = HashMap::new();
367 let mut errors = Vec::new();
368 for issue in existing {
369 if by_id.insert(issue.task_id.as_str(), issue).is_some() {
370 errors.push(format!(
371 "more than one issue is marked as {}",
372 issue.task_id
373 ));
374 }
375 }
376 if !errors.is_empty() {
377 return Err(PlanError::new(errors.join("\n")));
378 }
379
380 let mut tasks = Vec::new();
381 let mut selected_ids = HashSet::new();
382 for task in selected {
383 selected_ids.insert(task.id.as_str());
384 let title = issue_title(task);
385 let body = body_for(task);
386 let kind = if let Some(issue) = by_id.get(task.id.as_str()) {
387 let same = issue.title == title
388 && normalize_body(&issue.body) == normalize_body(&body)
389 && same_labels(&issue.labels, &labels_for(&issue.labels, task.owner_human));
390 if same {
391 ChangeKind::Unchanged
392 } else {
393 ChangeKind::Update
394 }
395 } else {
396 ChangeKind::Create
397 };
398 let issue = by_id.get(task.id.as_str()).copied();
399 tasks.push(TaskPlan {
400 task: task.clone(),
401 kind,
402 number: issue.map(|item| item.number),
403 database_id: issue.map(|item| item.database_id),
404 existing_labels: issue.map(|item| item.labels.clone()).unwrap_or_default(),
405 });
406 }
407
408 let mut removed = Vec::new();
409 for issue in existing {
410 if selected_ids.contains(issue.task_id.as_str()) {
411 continue;
412 }
413 if let Some(wanted) = milestone {
414 let Some(prefix) = task_milestone_id(&issue.task_id) else {
415 continue;
416 };
417 if !prefix.eq_ignore_ascii_case(wanted) {
418 continue;
419 }
420 }
421 removed.push(RemovedTask {
422 task_id: issue.task_id.clone(),
423 number: issue.number,
424 state: issue.state.clone(),
425 });
426 }
427
428 Ok(PlanDiff { tasks, removed })
429}
430
431pub fn missing_blockers(tasks: &[Task], known_ids: &HashSet<String>) -> Vec<String> {
432 let selected: HashSet<&str> = tasks.iter().map(|task| task.id.as_str()).collect();
433 let mut missing = Vec::new();
434 for task in tasks {
435 for dep in &task.depends_on {
436 if selected.contains(dep.as_str()) || known_ids.contains(dep) {
437 continue;
438 }
439 missing.push(format!(
440 "{} depends on {dep}, which has no issue yet",
441 task.id
442 ));
443 }
444 }
445 missing
446}
447
448pub fn diff_links(
449 tasks: &[Task],
450 bindings: &[Binding],
451 current: &HashMap<u64, Vec<DepRef>>,
452) -> Result<Vec<LinkEdit>, PlanError> {
453 let mut edits = Vec::new();
454 let mut errors = Vec::new();
455 let managed: HashSet<u64> = bindings.iter().filter_map(|item| item.number).collect();
456 for task in tasks {
457 let Some(binding) = bindings.iter().find(|item| item.task_id == task.id) else {
458 errors.push(format!("{} has no issue binding", task.id));
459 continue;
460 };
461 let blocked = binding
462 .number
463 .and_then(|number| current.get(&number))
464 .cloned()
465 .unwrap_or_default();
466 let mut desired_numbers = Vec::new();
467 for dep_id in &task.depends_on {
468 let Some(dep) = bindings.iter().find(|item| item.task_id == dep_id.as_str()) else {
469 errors.push(format!(
470 "{} depends on {dep_id}, which has no issue yet",
471 task.id
472 ));
473 continue;
474 };
475 if let Some(number) = dep.number {
476 desired_numbers.push(number);
477 }
478 let already = dep
479 .number
480 .is_some_and(|number| blocked.iter().any(|item| item.number == number));
481 if !already {
482 edits.push(LinkEdit::Add {
483 task_id: task.id.clone(),
484 issue_number: binding.number,
485 blocking_task_id: dep_id.clone(),
486 blocking_number: dep.number,
487 blocking_database_id: dep.database_id,
488 });
489 }
490 }
491 if let Some(issue_number) = binding.number {
492 for item in &blocked {
493 if desired_numbers.contains(&item.number) || !managed.contains(&item.number) {
495 continue;
496 }
497 edits.push(LinkEdit::Remove {
498 task_id: task.id.clone(),
499 issue_number,
500 blocking_number: item.number,
501 blocking_database_id: item.database_id,
502 });
503 }
504 }
505 }
506 if errors.is_empty() {
507 Ok(edits)
508 } else {
509 Err(PlanError::new(errors.join("\n")))
510 }
511}
512
513pub fn render(diff: &PlanDiff, links: &[LinkEdit], dry_run: bool) -> String {
514 let mut out = render_tasks(diff);
515 out.push_str(&render_links(links));
516 out.push_str(&render_summary(diff, links, dry_run));
517 out
518}
519
520pub fn render_tasks(diff: &PlanDiff) -> String {
521 let mut out = String::new();
522 for plan in &diff.tasks {
523 let human = if plan.task.owner_human {
524 " [human]"
525 } else {
526 ""
527 };
528 let deps = if plan.task.depends_on.is_empty() {
529 "none".to_string()
530 } else {
531 plan.task.depends_on.join(", ")
532 };
533 out.push_str(&format!(
534 "{kind} {title}{human}\n depends on: {deps}\n",
535 kind = kind_word(plan.kind),
536 title = issue_title(&plan.task),
537 ));
538 }
539 for removed in &diff.removed {
540 out.push_str(&format!(
541 "removed {} issue #{} ({}), not closed\n",
542 removed.task_id, removed.number, removed.state
543 ));
544 }
545 out
546}
547
548pub fn render_links(links: &[LinkEdit]) -> String {
549 let mut out = String::new();
550 for link in links {
551 match link {
552 LinkEdit::Add {
553 task_id,
554 issue_number,
555 blocking_task_id,
556 blocking_number,
557 ..
558 } => {
559 out.push_str(&format!(
560 "link {task_id}{issue} blocked by {blocking_task_id}{blocking}\n",
561 issue = number_suffix(*issue_number),
562 blocking = number_suffix(*blocking_number),
563 ));
564 }
565 LinkEdit::Remove {
566 task_id,
567 blocking_number,
568 ..
569 } => {
570 out.push_str(&format!("unlink {task_id} blocked by #{blocking_number}\n"));
571 }
572 }
573 }
574 out
575}
576
577pub fn render_summary(diff: &PlanDiff, links: &[LinkEdit], dry_run: bool) -> String {
578 let creates = diff
579 .tasks
580 .iter()
581 .filter(|plan| plan.kind == ChangeKind::Create)
582 .count();
583 let updates = diff
584 .tasks
585 .iter()
586 .filter(|plan| plan.kind == ChangeKind::Update)
587 .count();
588 let unchanged = diff
589 .tasks
590 .iter()
591 .filter(|plan| plan.kind == ChangeKind::Unchanged)
592 .count();
593 let prefix = if dry_run { "dry run: " } else { "" };
594 format!(
595 "{prefix}create {creates}, update {updates}, unchanged {unchanged}, link {}, removed {}\n",
596 links.len(),
597 diff.removed.len()
598 )
599}
600
601pub fn github_anchor(heading: &str) -> String {
602 let mut out = String::new();
603 let mut prev_hyphen = false;
604 for ch in heading.chars() {
605 if ch.is_whitespace() {
606 if !out.is_empty() && !prev_hyphen {
607 out.push('-');
608 prev_hyphen = true;
609 }
610 continue;
611 }
612 if ch == '-' || ch == '_' {
613 if !out.is_empty() && !prev_hyphen {
614 out.push(ch);
615 prev_hyphen = true;
616 }
617 continue;
618 }
619 if ch.is_alphanumeric() {
620 for lower in ch.to_lowercase() {
621 out.push(lower);
622 }
623 prev_hyphen = false;
624 }
625 }
626 while out.ends_with('-') || out.ends_with('_') {
627 out.pop();
628 }
629 out
630}
631
632fn encode_git_path(path: &str) -> String {
633 path.split('/')
634 .map(|segment| urlencoding::encode(segment).into_owned())
635 .collect::<Vec<_>>()
636 .join("/")
637}
638
639fn kind_word(kind: ChangeKind) -> &'static str {
640 match kind {
641 ChangeKind::Create => "create",
642 ChangeKind::Update => "update",
643 ChangeKind::Unchanged => "unchanged",
644 }
645}
646
647fn number_suffix(number: Option<u64>) -> String {
648 match number {
649 Some(number) if number > 0 => format!(" (#{number})"),
650 _ => String::new(),
651 }
652}
653
654fn normalize_body(body: &str) -> String {
655 body.replace("\r\n", "\n").trim().to_string()
656}
657
658fn same_labels(left: &[String], right: &[String]) -> bool {
659 let left: HashSet<&str> = left.iter().map(String::as_str).collect();
660 let right: HashSet<&str> = right.iter().map(String::as_str).collect();
661 left == right
662}
663
664fn task_milestone_id(task_id: &str) -> Option<&str> {
665 if !is_task_id(task_id) {
666 return None;
667 }
668 task_id.split_once('-').map(|(prefix, _)| prefix)
669}
670
671fn push_task(
672 task: Task,
673 tasks: &mut Vec<Task>,
674 seen: &mut HashSet<String>,
675 errors: &mut Vec<String>,
676) {
677 if !seen.insert(task.id.clone()) {
678 errors.push(format!("duplicate task id {}", task.id));
679 return;
680 }
681 tasks.push(task);
682}
683
684fn flush_draft(
685 draft: &mut Option<Draft>,
686 tasks: &mut Vec<Task>,
687 seen: &mut HashSet<String>,
688 errors: &mut Vec<String>,
689) {
690 let Some(draft) = draft.take() else {
691 return;
692 };
693 match draft.into_task() {
694 Ok(task) => push_task(task, tasks, seen, errors),
695 Err(err) => errors.push(err),
696 }
697}
698
699fn check_dependencies(tasks: &[Task], errors: &mut Vec<String>) {
700 let ids: HashSet<&str> = tasks.iter().map(|task| task.id.as_str()).collect();
701 for task in tasks {
702 for dep in &task.depends_on {
703 if dep == &task.id {
704 errors.push(format!("{} depends on itself", task.id));
705 } else if !ids.contains(dep.as_str()) {
706 errors.push(format!("{} depends on unknown task {dep}", task.id));
707 }
708 }
709 }
710}
711
712fn apply_field(draft: &mut Draft, name: &str, value: &str) {
713 if name.eq_ignore_ascii_case("goal") {
714 draft.in_acceptance = false;
715 draft.goal = value.to_string();
716 } else if name.eq_ignore_ascii_case("scope") {
717 draft.in_acceptance = false;
718 draft.scope = value.to_string();
719 } else if name.eq_ignore_ascii_case("acceptance") {
720 draft.in_acceptance = true;
721 if !value.is_empty() {
722 draft.acceptance.push(acceptance_item(value));
723 }
724 } else if name.eq_ignore_ascii_case("depends on") {
725 draft.in_acceptance = false;
726 draft.depends_raw = Some(value.to_string());
727 } else if name.eq_ignore_ascii_case("owner") {
728 draft.in_acceptance = false;
729 draft.owner_human = is_human_owner(value);
730 }
731}
732
733fn acceptance_item(value: &str) -> String {
734 if value.starts_with('-') || value.starts_with('*') {
735 value.to_string()
736 } else {
737 format!("- {value}")
738 }
739}
740
741fn is_acceptance_line(line: &str) -> bool {
742 let trimmed = line.trim();
743 !trimmed.is_empty()
744 && (line.starts_with(' ')
745 || line.starts_with('\t')
746 || trimmed.starts_with("- ")
747 || trimmed.starts_with("* "))
748}
749
750fn unindent(line: &str) -> String {
751 let line = line.trim_end();
752 line.strip_prefix(" ")
753 .or_else(|| line.strip_prefix('\t'))
754 .unwrap_or(line)
755 .to_string()
756}
757
758fn top_level_field(line: &str) -> Option<(&str, &str)> {
759 let rest = line.strip_prefix("- ")?;
760 let (name, value) = rest.split_once(':')?;
761 let name = name.trim();
762 is_known_field(name).then_some((name, value.trim()))
763}
764
765fn is_known_field(name: &str) -> bool {
766 name.eq_ignore_ascii_case("goal")
767 || name.eq_ignore_ascii_case("scope")
768 || name.eq_ignore_ascii_case("acceptance")
769 || name.eq_ignore_ascii_case("depends on")
770 || name.eq_ignore_ascii_case("owner")
771}
772
773fn is_human_owner(value: &str) -> bool {
774 let Some(word) = value.split_whitespace().next() else {
775 return false;
776 };
777 word.trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':'))
778 .eq_ignore_ascii_case("human")
779}
780
781fn parse_depends(raw: &str) -> Result<Vec<String>, String> {
782 let trimmed = raw.trim().trim_end_matches('.').trim();
783 if trimmed.is_empty() {
784 return Ok(Vec::new());
785 }
786 let first = trimmed.split_whitespace().next().unwrap_or("");
787 let first = first.trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':'));
788 if first.eq_ignore_ascii_case("none") {
790 return Ok(Vec::new());
791 }
792 let ids = extract_ids(trimmed);
793 if ids.is_empty() {
794 Err(format!("depends on value \"{trimmed}\" has no task id"))
795 } else {
796 Ok(ids)
797 }
798}
799
800fn extract_ids(raw: &str) -> Vec<String> {
801 let bytes = raw.as_bytes();
802 let mut ids = Vec::new();
803 let mut index = 0;
804 while index < bytes.len() {
805 if bytes[index].is_ascii_alphabetic() {
806 let start = index;
807 index += 1;
808 while index < bytes.len()
809 && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'-')
810 {
811 index += 1;
812 }
813 let token = &raw[start..index];
814 if is_task_id(token) && !ids.iter().any(|id| id == token) {
815 ids.push(token.to_string());
816 }
817 continue;
818 }
819 index += 1;
820 }
821 ids
822}
823
824fn is_task_id(token: &str) -> bool {
825 let Some((milestone, rest)) = token.split_once('-') else {
826 return false;
827 };
828 if !is_milestone_id(milestone) || rest.is_empty() || !rest.as_bytes()[0].is_ascii_digit() {
829 return false;
830 }
831 rest.bytes().all(|byte| byte.is_ascii_alphanumeric())
832}
833
834fn is_milestone_id(token: &str) -> bool {
835 let mut chars = token.chars();
836 let Some(first) = chars.next() else {
837 return false;
838 };
839 if !first.is_ascii_alphabetic() {
840 return false;
841 }
842 let mut saw_digit = false;
843 for ch in chars {
844 if ch.is_ascii_digit() {
845 saw_digit = true;
846 } else if ch.is_ascii_alphabetic() && !saw_digit {
847 continue;
848 } else {
849 return false;
850 }
851 }
852 saw_digit
853}
854
855fn parse_milestone_heading(rest: &str) -> Option<(String, String)> {
856 let rest = rest.trim();
857 let (first, title) = rest.split_once(char::is_whitespace)?;
858 let id = first.trim_end_matches('.');
859 is_milestone_id(id).then(|| (id.to_string(), title.trim().to_string()))
860}
861
862fn parse_task_heading(rest: &str) -> Option<(String, String)> {
863 let rest = rest.trim();
864 let (id, title) = match rest.split_once(char::is_whitespace) {
865 Some((id, title)) => (id, title.trim()),
866 None => (rest, ""),
867 };
868 is_task_id(id).then(|| (id.to_string(), title.to_string()))
869}
870
871fn table_cols(line: &str) -> Option<TableCols> {
872 if !line.trim().starts_with('|') {
873 return None;
874 }
875 let cells = split_cells(line);
876 let names: Vec<String> = cells.iter().map(|cell| cell.to_ascii_lowercase()).collect();
877 let task = names.iter().position(|name| name == "task")?;
878 if !names.iter().any(|name| name == "depends on") {
879 return None;
880 }
881 let pos = |label: &str| names.iter().position(|name| name == label);
882 Some(TableCols {
883 task,
884 goal: pos("goal"),
885 scope: pos("scope"),
886 acceptance: pos("acceptance"),
887 depends: pos("depends on"),
888 owner: pos("owner"),
889 })
890}
891
892fn is_separator_row(line: &str) -> bool {
893 let cells = split_cells(line);
894 !cells.is_empty()
895 && cells.iter().all(|cell| {
896 let token = cell.trim_matches(':');
897 !token.is_empty() && token.chars().all(|ch| ch == '-')
898 })
899}
900
901fn split_cells(line: &str) -> Vec<String> {
902 line.trim()
903 .trim_matches('|')
904 .split('|')
905 .map(|cell| cell.trim().to_string())
906 .collect()
907}
908
909fn cell(cells: &[String], index: Option<usize>) -> &str {
910 index
911 .and_then(|index| cells.get(index))
912 .map(String::as_str)
913 .unwrap_or("")
914}
915
916fn task_from_row(
917 line: &str,
918 cols: &TableCols,
919 milestone: &Milestone,
920 line_no: usize,
921) -> Result<Option<Task>, String> {
922 let cells = split_cells(line);
923 let task_cell = cell(&cells, Some(cols.task));
924 let Some((id, title)) = parse_task_heading(task_cell) else {
925 return Ok(None);
926 };
927 let depends_raw = cell(&cells, cols.depends);
928 let depends_on =
929 parse_depends(depends_raw).map_err(|err| format!("line {line_no}: {id} {err}"))?;
930 let owner = cell(&cells, cols.owner);
931 Ok(Some(Task {
932 id,
933 title,
934 milestone_id: milestone.id.clone(),
935 milestone_title: milestone.title.clone(),
936 milestone_heading: milestone.heading.clone(),
937 anchor_heading: String::new(),
938 goal: cell(&cells, cols.goal).to_string(),
939 scope: cell(&cells, cols.scope).to_string(),
940 acceptance: {
941 let value = cell(&cells, cols.acceptance);
942 if value.is_empty() {
943 Vec::new()
944 } else {
945 vec![acceptance_item(value)]
946 }
947 },
948 depends_on,
949 owner_human: is_human_owner(owner),
950 }))
951}
952
953impl Draft {
954 fn new(
955 id: String,
956 title: String,
957 milestone: &Milestone,
958 anchor_heading: &str,
959 line: usize,
960 ) -> Self {
961 Self {
962 id,
963 title,
964 milestone_id: milestone.id.clone(),
965 milestone_title: milestone.title.clone(),
966 milestone_heading: milestone.heading.clone(),
967 anchor_heading: anchor_heading.to_string(),
968 goal: String::new(),
969 scope: String::new(),
970 acceptance: Vec::new(),
971 depends_raw: None,
972 owner_human: false,
973 in_acceptance: false,
974 line,
975 }
976 }
977
978 fn into_task(self) -> Result<Task, String> {
979 let depends_on = match self.depends_raw {
980 Some(raw) => parse_depends(&raw)
981 .map_err(|err| format!("line {}: {} {err}", self.line, self.id))?,
982 None => Vec::new(),
983 };
984 Ok(Task {
985 id: self.id,
986 title: self.title,
987 milestone_id: self.milestone_id,
988 milestone_title: self.milestone_title,
989 milestone_heading: self.milestone_heading,
990 anchor_heading: self.anchor_heading,
991 goal: self.goal,
992 scope: self.scope,
993 acceptance: self.acceptance,
994 depends_on,
995 owner_human: self.owner_human,
996 })
997 }
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003
1004 const SAMPLE: &str = r#"
1005## M1. Content model and pack format
1006
1007### M1-01 Set up the workspace
1008
1009- Goal: a Rust workspace that builds.
1010- Scope: the `api/` workspace.
1011- Acceptance:
1012 - `cargo test` passes on a clean checkout.
1013 - including the ignored tests
1014 - CI fails on a formatting error.
1015- Depends on: none.
1016
1017### M1-02 Content model types
1018
1019- Goal: serde types.
1020- Scope: crates/content.
1021- Acceptance:
1022 - A sample file round-trips.
1023- Depends on: M1-01.
1024
1025### M1-03 Content validation
1026
1027- Owner: human.
1028- Goal: catch broken content.
1029- Scope: content-cli validate.
1030- Acceptance:
1031 - One test per check.
1032- Depends on: M1-02.
1033
1034### M1-04 Festival dates
1035
1036- Goal: resolve dates.
1037- Acceptance:
1038 - Tests for 2026.
1039- Depends on: none. Do it before M1-05.
1040
1041### M1-05 Scheduler
1042
1043- Goal: the guardrail.
1044- Scope: resolve().
1045- Acceptance:
1046 - At least 15 fixtures.
1047
1048## M5. Release
1049
1050### M5-02 Privacy policy
1051
1052- Owner: human (counsel), with an agent draft.
1053- Goal: legal pages.
1054- Acceptance:
1055 - Counsel signs off.
1056- Depends on: M1-05.
1057
1058## M6. Full year and plan editor
1059
1060| Task | Goal | Acceptance | Depends on |
1061|---|---|---|---|
1062| M6-01 Units 4 to 10 | Write the rest of the year | Each pack validates | M1-05, M1-01 |
1063| M6-02 Festival modules | Attach modules | Each shows in Hoje | M6-01 |
1064"#;
1065
1066 fn roadmap() -> Roadmap {
1067 parse_roadmap(SAMPLE).unwrap()
1068 }
1069
1070 fn task<'a>(roadmap: &'a Roadmap, id: &str) -> &'a Task {
1071 roadmap.tasks.iter().find(|task| task.id == id).unwrap()
1072 }
1073
1074 #[test]
1075 fn parses_nested_acceptance_and_missing_depends_on() {
1076 let roadmap = roadmap();
1077 let first = task(&roadmap, "M1-01");
1078 assert_eq!(
1079 first.acceptance,
1080 vec![
1081 "- `cargo test` passes on a clean checkout.".to_string(),
1082 " - including the ignored tests".to_string(),
1083 "- CI fails on a formatting error.".to_string(),
1084 ]
1085 );
1086 assert!(first.depends_on.is_empty());
1087 assert_eq!(first.goal, "a Rust workspace that builds.");
1088 assert_eq!(first.scope, "the `api/` workspace.");
1089
1090 let scheduler = task(&roadmap, "M1-05");
1091 assert!(scheduler.depends_on.is_empty());
1092 assert_eq!(
1093 scheduler.acceptance,
1094 vec!["- At least 15 fixtures.".to_string()]
1095 );
1096 }
1097
1098 #[test]
1099 fn none_with_trailing_prose_adds_no_dependency() {
1100 let roadmap = roadmap();
1101 assert!(task(&roadmap, "M1-04").depends_on.is_empty());
1102 }
1103
1104 #[test]
1105 fn parses_owner_human_and_table_dependencies() {
1106 let roadmap = roadmap();
1107 assert!(task(&roadmap, "M1-03").owner_human);
1108 assert!(task(&roadmap, "M5-02").owner_human);
1109 assert!(!task(&roadmap, "M1-01").owner_human);
1110 assert_eq!(
1111 task(&roadmap, "M1-02").depends_on,
1112 vec!["M1-01".to_string()]
1113 );
1114 assert_eq!(
1115 task(&roadmap, "M6-01").depends_on,
1116 vec!["M1-05".to_string(), "M1-01".to_string()]
1117 );
1118 assert_eq!(
1119 task(&roadmap, "M6-02").depends_on,
1120 vec!["M6-01".to_string()]
1121 );
1122 assert!(task(&roadmap, "M6-01").anchor_heading.is_empty());
1123 assert_eq!(task(&roadmap, "M6-01").milestone_id, "M6");
1124 }
1125
1126 #[test]
1127 fn labels_keep_status_and_drop_no_agent_when_a_task_is_no_longer_human() {
1128 let kept = labels_for(
1129 &[
1130 "orch-plan".to_string(),
1131 "owner:human".to_string(),
1132 "no-agent".to_string(),
1133 "status:new".to_string(),
1134 ],
1135 false,
1136 );
1137 assert_eq!(
1138 kept,
1139 vec!["orch-plan".to_string(), "status:new".to_string()]
1140 );
1141
1142 let human = labels_for(&["status:new".to_string(), "no-agent".to_string()], true);
1143 assert_eq!(
1144 human,
1145 vec![
1146 "orch-plan".to_string(),
1147 "owner:human".to_string(),
1148 "no-agent".to_string(),
1149 "status:new".to_string(),
1150 ]
1151 );
1152 }
1153
1154 #[test]
1155 fn depends_on_without_a_task_id_is_an_error() {
1156 let markdown = r#"
1157## M1. Content model
1158
1159### M1-01 First
1160
1161- Goal: one.
1162- Depends on: later today.
1163"#;
1164 let err = parse_roadmap(markdown).unwrap_err();
1165 assert!(err.message.contains("no task id"), "{}", err.message);
1166 }
1167
1168 #[test]
1169 fn unknown_dependency_is_an_error() {
1170 let markdown = r#"
1171## M1. Content model
1172
1173### M1-01 First
1174
1175- Goal: one.
1176- Acceptance:
1177 - done.
1178- Depends on: M1-99.
1179"#;
1180 let err = parse_roadmap(markdown).unwrap_err();
1181 assert!(
1182 err.message.contains("unknown task M1-99"),
1183 "{}",
1184 err.message
1185 );
1186 assert!(err.message.contains("M1-01"), "{}", err.message);
1187 }
1188
1189 #[test]
1190 fn issue_body_links_the_heading_and_carries_the_marker() {
1191 let roadmap = roadmap();
1192 let task = task(&roadmap, "M1-01");
1193 let body = issue_body(task, "acme/app", "main", "docs/ROADMAP.md");
1194 assert!(body.contains("## Goal\n\na Rust workspace that builds."));
1195 assert!(body.contains("## Scope\n"));
1196 assert!(body.contains("- `cargo test` passes on a clean checkout."));
1197 assert!(body.contains(" - including the ignored tests"));
1198 assert!(body.contains("<!-- orch-plan: M1-01 -->"));
1199 assert!(body.contains(
1200 "https://github.com/acme/app/blob/main/docs/ROADMAP.md#m1-01-set-up-the-workspace"
1201 ));
1202 assert_eq!(marker_id(&body).as_deref(), Some("M1-01"));
1203 assert_eq!(issue_title(task), "M1-01 Set up the workspace");
1204 }
1205
1206 #[test]
1207 fn table_task_links_to_the_milestone_heading() {
1208 let roadmap = roadmap();
1209 let body = issue_body(
1210 task(&roadmap, "M6-01"),
1211 "acme/app",
1212 "main",
1213 "docs/ROADMAP.md",
1214 );
1215 assert!(body.contains("#m6-full-year-and-plan-editor"));
1216 assert!(!body.contains("## Scope"));
1217 }
1218
1219 #[test]
1220 fn report_lists_dependencies_and_marks_human_tasks() {
1221 let roadmap = roadmap();
1222 let diff = diff_import(&roadmap, &[], None, |task| {
1223 issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1224 })
1225 .unwrap();
1226 let links = diff_links(&roadmap.tasks, &bindings_for(&diff), &HashMap::new()).unwrap();
1227 let text = render(&diff, &links, true);
1228 assert!(text.contains("create M1-01 Set up the workspace\n depends on: none\n"));
1229 assert!(text.contains("create M1-03 Content validation [human]\n depends on: M1-02\n"));
1230 assert!(text.contains("create M5-02 Privacy policy [human]\n depends on: M1-05\n"));
1231 assert!(text.contains("link M6-01 blocked by M1-05\n"));
1232 assert!(text.contains("link M6-01 blocked by M1-01\n"));
1233 assert!(text.starts_with("dry run:") || text.contains("\ndry run:"));
1234 assert!(text.contains("create 8,"));
1235 }
1236
1237 #[test]
1238 fn second_import_with_no_file_change_edits_nothing() {
1239 let roadmap = roadmap();
1240 let existing = existing_for(&roadmap);
1241 let diff = diff_import(&roadmap, &existing, None, |task| {
1242 issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1243 })
1244 .unwrap();
1245 assert!(diff
1246 .tasks
1247 .iter()
1248 .all(|plan| plan.kind == ChangeKind::Unchanged));
1249 assert!(diff.removed.is_empty());
1250 let current = current_links(&roadmap);
1251 let links =
1252 diff_links(&roadmap.tasks, &bindings_from_existing(&existing), ¤t).unwrap();
1253 assert!(links.is_empty(), "{links:?}");
1254 let text = render(&diff, &links, false);
1255 assert!(text.contains("unchanged M1-02 Content model types"));
1256 assert_eq!(
1257 text.lines()
1258 .filter(|line| line.starts_with("unchanged "))
1259 .count(),
1260 8
1261 );
1262 assert!(!text
1263 .lines()
1264 .any(|line| line.starts_with("link ") || line.starts_with("unlink ")));
1265 assert!(text.contains("create 0, update 0, unchanged 8, link 0, removed 0\n"));
1266 }
1267
1268 #[test]
1269 fn changed_body_is_an_update_and_a_dropped_task_is_reported() {
1270 let roadmap = roadmap();
1271 let mut existing = existing_for(&roadmap);
1272 existing[0].body = "stale\n<!-- orch-plan: M1-01 -->\n".to_string();
1273 existing.push(ExistingIssue {
1274 task_id: "M1-99".to_string(),
1275 number: 99,
1276 database_id: 990,
1277 title: "M1-99 Gone".to_string(),
1278 body: marker_for("M1-99"),
1279 labels: labels_for(&[], false),
1280 state: "open".to_string(),
1281 });
1282 let diff = diff_import(&roadmap, &existing, None, |task| {
1283 issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1284 })
1285 .unwrap();
1286 assert_eq!(diff.tasks[0].kind, ChangeKind::Update);
1287 assert!(diff.tasks[1..]
1288 .iter()
1289 .all(|plan| plan.kind == ChangeKind::Unchanged));
1290 assert_eq!(diff.removed.len(), 1);
1291 assert_eq!(diff.removed[0].task_id, "M1-99");
1292 assert!(render_tasks(&diff).contains("removed M1-99 issue #99 (open), not closed"));
1293 }
1294
1295 #[test]
1296 fn milestone_filter_limits_tasks_and_removed() {
1297 let roadmap = roadmap();
1298 let mut existing = existing_for(&roadmap);
1299 existing.push(ExistingIssue {
1300 task_id: "M6-09".to_string(),
1301 number: 70,
1302 database_id: 700,
1303 title: "M6-09 Gone".to_string(),
1304 body: marker_for("M6-09"),
1305 labels: labels_for(&[], false),
1306 state: "closed".to_string(),
1307 });
1308 let diff = diff_import(&roadmap, &existing, Some("M6"), |task| {
1309 issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1310 })
1311 .unwrap();
1312 let ids: Vec<&str> = diff
1313 .tasks
1314 .iter()
1315 .map(|plan| plan.task.id.as_str())
1316 .collect();
1317 assert_eq!(ids, vec!["M6-01", "M6-02"]);
1318 assert_eq!(diff.removed.len(), 1);
1319 assert_eq!(diff.removed[0].task_id, "M6-09");
1320 let err = select(&roadmap, Some("M9")).unwrap_err();
1321 assert!(err.message.contains("no milestone M9"));
1322 }
1323
1324 #[test]
1325 fn milestone_import_reports_a_blocker_with_no_issue() {
1326 let roadmap = roadmap();
1327 let selected: Vec<Task> = select(&roadmap, Some("M6"))
1328 .unwrap()
1329 .into_iter()
1330 .cloned()
1331 .collect();
1332 let missing = missing_blockers(&selected, &HashSet::new());
1333 assert!(
1334 missing
1335 .iter()
1336 .any(|line| line.contains("M6-01 depends on M1-05")),
1337 "{missing:?}"
1338 );
1339 let mut known = HashSet::new();
1340 known.insert("M1-05".to_string());
1341 known.insert("M1-01".to_string());
1342 assert!(missing_blockers(&selected, &known).is_empty());
1343 }
1344
1345 #[test]
1346 fn link_diff_adds_missing_and_removes_extras_then_is_empty() {
1347 let roadmap = roadmap();
1348 let existing = existing_for(&roadmap);
1349 let bindings = bindings_from_existing(&existing);
1350 let stale = number_of(&existing, "M1-05");
1351 let mut current = HashMap::new();
1352 current.insert(
1353 number_of(&existing, "M1-02"),
1354 vec![
1355 DepRef {
1356 number: stale,
1357 database_id: stale * 10,
1358 },
1359 DepRef {
1360 number: 50,
1361 database_id: 500,
1362 },
1363 ],
1364 );
1365 let edits = diff_links(&roadmap.tasks, &bindings, ¤t).unwrap();
1366 assert!(edits.iter().any(|edit| matches!(
1367 edit,
1368 LinkEdit::Add { task_id, blocking_task_id, .. }
1369 if task_id == "M1-02" && blocking_task_id == "M1-01"
1370 )));
1371 assert!(edits.iter().any(|edit| matches!(
1372 edit,
1373 LinkEdit::Remove { task_id, blocking_number, .. }
1374 if task_id == "M1-02" && *blocking_number == stale
1375 )));
1376 assert!(
1377 !edits.iter().any(|edit| matches!(
1378 edit,
1379 LinkEdit::Remove {
1380 blocking_number: 50,
1381 ..
1382 }
1383 )),
1384 "a blocker outside the plan must stay: {edits:?}"
1385 );
1386
1387 let synced = current_links(&roadmap);
1388 let again = diff_links(&roadmap.tasks, &bindings, &synced).unwrap();
1389 assert!(again.is_empty(), "{again:?}");
1390 }
1391
1392 #[test]
1393 fn anchor_keeps_letters_and_drops_punctuation() {
1394 assert_eq!(
1395 github_anchor("M1. Content model and pack format"),
1396 "m1-content-model-and-pack-format"
1397 );
1398 assert_eq!(
1399 github_anchor("M3-09 Class screen and Modo áudio"),
1400 "m3-09-class-screen-and-modo-áudio"
1401 );
1402 }
1403
1404 fn existing_for(roadmap: &Roadmap) -> Vec<ExistingIssue> {
1405 roadmap
1406 .tasks
1407 .iter()
1408 .enumerate()
1409 .map(|(index, task)| {
1410 let number = (index + 1) as u64;
1411 ExistingIssue {
1412 task_id: task.id.clone(),
1413 number,
1414 database_id: number * 10,
1415 title: issue_title(task),
1416 body: issue_body(task, "acme/app", "main", "docs/ROADMAP.md"),
1417 labels: labels_for(&["status:new".to_string()], task.owner_human),
1418 state: "open".to_string(),
1419 }
1420 })
1421 .collect()
1422 }
1423
1424 fn bindings_for(diff: &PlanDiff) -> Vec<Binding> {
1425 diff.tasks
1426 .iter()
1427 .map(|plan| Binding {
1428 task_id: plan.task.id.clone(),
1429 number: plan.number,
1430 database_id: plan.database_id,
1431 })
1432 .collect()
1433 }
1434
1435 fn bindings_from_existing(existing: &[ExistingIssue]) -> Vec<Binding> {
1436 existing
1437 .iter()
1438 .map(|issue| Binding {
1439 task_id: issue.task_id.clone(),
1440 number: Some(issue.number),
1441 database_id: Some(issue.database_id),
1442 })
1443 .collect()
1444 }
1445
1446 fn number_of(existing: &[ExistingIssue], id: &str) -> u64 {
1447 existing
1448 .iter()
1449 .find(|issue| issue.task_id == id)
1450 .unwrap()
1451 .number
1452 }
1453
1454 fn current_links(roadmap: &Roadmap) -> HashMap<u64, Vec<DepRef>> {
1455 let existing = existing_for(roadmap);
1456 let mut current = HashMap::new();
1457 for task in &roadmap.tasks {
1458 let blocked = task
1459 .depends_on
1460 .iter()
1461 .map(|dep| DepRef {
1462 number: number_of(&existing, dep),
1463 database_id: number_of(&existing, dep) * 10,
1464 })
1465 .collect();
1466 current.insert(number_of(&existing, &task.id), blocked);
1467 }
1468 current
1469 }
1470}