Skip to main content

orch/
plan.rs

1//! Roadmap files and the issue plan `orch plan import` applies.
2//!
3//! A milestone is `## M1. Title`. A task is `### M1-03 Title` with Goal, Scope,
4//! Acceptance, Depends on and Owner lines. Later milestones may be a table with
5//! those columns. `none` is no dependency.
6
7use 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    /// Text after `## `, used for the GitHub heading anchor
39    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    /// Full `###` heading. Empty for a table row, which links to the milestone heading
50    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
337/// Labels for a plan issue. Human tasks also get `no-agent`, which the dispatcher already skips
338pub 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                // Only drop links to other plan issues, a link a person added by hand stays
494                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    // A leading none is no dependency, even when the rest of the line names a task
789    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), &current).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, &current).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}