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. An unknown table column or a task with no title is an error.
6//! `none` is no dependency.
7
8use std::collections::{HashMap, HashSet};
9
10pub const PLAN_LABEL: &str = "orch-plan";
11pub const HUMAN_LABEL: &str = "owner:human";
12pub const NO_AGENT_LABEL: &str = "no-agent";
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct PlanError {
16    pub message: String,
17}
18
19impl std::fmt::Display for PlanError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        f.write_str(&self.message)
22    }
23}
24
25impl std::error::Error for PlanError {}
26
27impl PlanError {
28    fn new(message: impl Into<String>) -> Self {
29        Self {
30            message: message.into(),
31        }
32    }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Milestone {
37    pub id: String,
38    pub title: String,
39    /// Text after `## `, used for the GitHub heading anchor
40    pub heading: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Task {
45    pub id: String,
46    pub title: String,
47    pub milestone_id: String,
48    pub milestone_title: String,
49    pub milestone_heading: String,
50    /// Full `###` heading. Empty for a table row, which links to the milestone heading
51    pub anchor_heading: String,
52    pub goal: String,
53    pub scope: String,
54    pub acceptance: Vec<String>,
55    pub depends_on: Vec<String>,
56    pub owner_human: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Roadmap {
61    pub milestones: Vec<Milestone>,
62    pub tasks: Vec<Task>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ChangeKind {
67    Create,
68    Update,
69    Unchanged,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ExistingIssue {
74    pub task_id: String,
75    pub number: u64,
76    pub database_id: u64,
77    pub title: String,
78    pub body: String,
79    pub labels: Vec<String>,
80    pub state: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct TaskPlan {
85    pub task: Task,
86    pub kind: ChangeKind,
87    pub number: Option<u64>,
88    pub database_id: Option<u64>,
89    pub existing_labels: Vec<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct RemovedTask {
94    pub task_id: String,
95    pub number: u64,
96    pub state: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct PlanDiff {
101    pub tasks: Vec<TaskPlan>,
102    pub removed: Vec<RemovedTask>,
103}
104
105/// A selected task depends on a roadmap id outside the import that has no issue yet
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct UnmetExternal {
108    pub task_id: String,
109    pub blocking_task_id: String,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Binding {
114    pub task_id: String,
115    pub number: Option<u64>,
116    pub database_id: Option<u64>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct DepRef {
121    pub number: u64,
122    pub database_id: u64,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum LinkEdit {
127    Add {
128        task_id: String,
129        issue_number: Option<u64>,
130        blocking_task_id: String,
131        blocking_number: Option<u64>,
132        blocking_database_id: Option<u64>,
133    },
134    Remove {
135        task_id: String,
136        issue_number: u64,
137        blocking_number: u64,
138        blocking_database_id: u64,
139    },
140}
141
142struct Draft {
143    id: String,
144    title: String,
145    milestone_id: String,
146    milestone_title: String,
147    milestone_heading: String,
148    anchor_heading: String,
149    goal: String,
150    scope: String,
151    acceptance: Vec<String>,
152    depends_raw: Option<String>,
153    owner_human: bool,
154    in_acceptance: bool,
155    line: usize,
156}
157
158#[derive(Clone, Copy)]
159struct TableCols {
160    task: usize,
161    goal: Option<usize>,
162    scope: Option<usize>,
163    acceptance: Option<usize>,
164    depends: Option<usize>,
165    owner: Option<usize>,
166}
167
168pub fn parse_roadmap(markdown: &str) -> Result<Roadmap, PlanError> {
169    parse_roadmap_for(markdown, None).map(|(roadmap, _)| roadmap)
170}
171
172/// With a milestone, errors that belong to other milestones come back as warnings
173pub fn parse_roadmap_for(
174    markdown: &str,
175    milestone: Option<&str>,
176) -> Result<(Roadmap, Vec<String>), PlanError> {
177    let mut milestones = Vec::new();
178    let mut tasks = Vec::new();
179    let mut errors = Vec::new();
180    let mut current: Option<Milestone> = None;
181    let mut draft: Option<Draft> = None;
182    let mut table: Option<TableCols> = None;
183    let mut seen = HashSet::new();
184    let mut tags: Vec<Option<String>> = Vec::new();
185    let mut tag: Option<String> = None;
186
187    for (idx, raw) in markdown.lines().enumerate() {
188        tags.resize(errors.len(), tag.clone());
189        tag = current.as_ref().map(|item| item.id.clone());
190        let line = raw.trim_end_matches('\r');
191        let line_no = idx + 1;
192
193        if let Some(cols) = table {
194            if line.trim().starts_with('|') {
195                if !is_separator_row(line) {
196                    if let Some(milestone) = current.as_ref() {
197                        match task_from_row(line, &cols, milestone, line_no) {
198                            Ok(Some(task)) => {
199                                push_task(task, &mut tasks, &mut seen, &mut errors);
200                            }
201                            Ok(None) => {}
202                            Err(err) => errors.push(err),
203                        }
204                    }
205                }
206                continue;
207            }
208            table = None;
209        }
210
211        if let Some(rest) = line.strip_prefix("### ") {
212            flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
213            let Some(milestone) = current.as_ref() else {
214                errors.push(format!(
215                    "line {line_no}: task heading is outside a milestone"
216                ));
217                continue;
218            };
219            if let Some((id, title)) = parse_task_heading(rest) {
220                if title.is_empty() {
221                    errors.push(format!("line {line_no}: {id} has no title"));
222                } else {
223                    draft = Some(Draft::new(id, title, milestone, rest.trim(), line_no));
224                }
225            }
226            continue;
227        }
228
229        if let Some(rest) = line.strip_prefix("## ") {
230            flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
231            if let Some((id, title)) = parse_milestone_heading(rest) {
232                let milestone = Milestone {
233                    id,
234                    title,
235                    heading: rest.trim().to_string(),
236                };
237                current = Some(milestone.clone());
238                milestones.push(milestone);
239            } else {
240                current = None;
241            }
242            continue;
243        }
244
245        if let Some((cols, unknown)) = table_cols(line) {
246            flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
247            if current.is_some() {
248                if !unknown.is_empty() {
249                    errors.push(format!(
250                        "line {line_no}: unknown table column {}",
251                        unknown.join(", ")
252                    ));
253                }
254                table = Some(cols);
255            }
256            continue;
257        }
258
259        if let Some(draft) = draft.as_mut() {
260            if let Some((name, value)) = top_level_field(line) {
261                apply_field(draft, name, value);
262                continue;
263            }
264            if draft.in_acceptance && is_acceptance_line(line) {
265                draft.acceptance.push(unindent(line));
266            }
267        }
268    }
269
270    flush_draft(&mut draft, &mut tasks, &mut seen, &mut errors);
271    tags.resize(errors.len(), tag);
272    check_dependencies(&tasks, &mut errors);
273    for message in &errors[tags.len()..] {
274        let id = message
275            .split_whitespace()
276            .next()
277            .and_then(task_milestone_id);
278        tags.push(id.map(str::to_string));
279    }
280
281    let (mut fatal, mut warnings) = (Vec::new(), Vec::new());
282    for (message, tag) in errors.into_iter().zip(tags) {
283        let elsewhere = match (milestone, tag) {
284            (Some(wanted), Some(id)) => !id.eq_ignore_ascii_case(wanted),
285            _ => false,
286        };
287        if elsewhere {
288            warnings.push(message);
289        } else {
290            fatal.push(message);
291        }
292    }
293    if fatal.is_empty() {
294        Ok((Roadmap { milestones, tasks }, warnings))
295    } else {
296        Err(PlanError::new(fatal.join("\n")))
297    }
298}
299
300pub fn select<'a>(
301    roadmap: &'a Roadmap,
302    milestone: Option<&str>,
303) -> Result<Vec<&'a Task>, PlanError> {
304    let Some(wanted) = milestone else {
305        return Ok(roadmap.tasks.iter().collect());
306    };
307    if !roadmap
308        .milestones
309        .iter()
310        .any(|item| item.id.eq_ignore_ascii_case(wanted))
311    {
312        return Err(PlanError::new(format!("no milestone {wanted}")));
313    }
314    Ok(roadmap
315        .tasks
316        .iter()
317        .filter(|task| task.milestone_id.eq_ignore_ascii_case(wanted))
318        .collect())
319}
320
321pub fn issue_title(task: &Task) -> String {
322    if task.title.is_empty() {
323        task.id.clone()
324    } else {
325        format!("{} {}", task.id, task.title)
326    }
327}
328
329pub fn task_anchor(task: &Task) -> String {
330    if task.anchor_heading.is_empty() {
331        github_anchor(&task.milestone_heading)
332    } else {
333        github_anchor(&task.anchor_heading)
334    }
335}
336
337pub fn issue_body(task: &Task, repo: &str, branch: &str, rel_path: &str) -> String {
338    issue_body_with(task, repo, branch, rel_path, &[])
339}
340
341pub fn issue_body_with(
342    task: &Task,
343    repo: &str,
344    branch: &str,
345    rel_path: &str,
346    blocked_external: &[String],
347) -> String {
348    let mut out = String::new();
349    if !task.goal.is_empty() {
350        out.push_str("## Goal\n\n");
351        out.push_str(task.goal.trim());
352        out.push_str("\n\n");
353    }
354    if !task.scope.is_empty() {
355        out.push_str("## Scope\n\n");
356        out.push_str(task.scope.trim());
357        out.push_str("\n\n");
358    }
359    if !task.acceptance.is_empty() {
360        out.push_str("## Acceptance\n\n");
361        for item in &task.acceptance {
362            let item = item.trim_end();
363            if item.starts_with('-') || item.starts_with('*') || item.starts_with(' ') {
364                out.push_str(item);
365            } else {
366                out.push_str("- ");
367                out.push_str(item);
368            }
369            out.push('\n');
370        }
371        out.push('\n');
372    }
373    if !blocked_external.is_empty() {
374        out.push_str("blocked-external: ");
375        out.push_str(&blocked_external.join(", "));
376        out.push_str(" (no issue yet)\n\n");
377    }
378    let url = format!(
379        "https://github.com/{repo}/blob/{branch}/{path}#{anchor}",
380        branch = encode_git_path(branch),
381        path = encode_git_path(rel_path),
382        anchor = urlencoding::encode(&task_anchor(task)),
383    );
384    out.push_str(&format!("[{rel_path}]({url})\n\n"));
385    if !blocked_external.is_empty() {
386        out.push_str("<!-- orch-blocked-external: ");
387        out.push_str(&blocked_external.join(", "));
388        out.push_str(" -->\n");
389    }
390    out.push_str(&marker_for(&task.id));
391    out.push('\n');
392    out
393}
394
395pub fn marker_for(id: &str) -> String {
396    format!("<!-- orch-plan: {id} -->")
397}
398
399pub fn marker_id(body: &str) -> Option<String> {
400    let start = body.find("<!-- orch-plan:")?;
401    let rest = &body[start + "<!-- orch-plan:".len()..];
402    let end = rest.find("-->")?;
403    let id = rest[..end].trim();
404    is_task_id(id).then(|| id.to_string())
405}
406
407/// Labels for a plan issue. Human tasks also get `no-agent`, which the dispatcher already skips
408pub fn labels_for(existing: &[String], human: bool) -> Vec<String> {
409    let drop_no_agent = existing.iter().any(|label| label == HUMAN_LABEL);
410    let mut labels = vec![PLAN_LABEL.to_string()];
411    if human {
412        labels.push(HUMAN_LABEL.to_string());
413        labels.push(NO_AGENT_LABEL.to_string());
414    }
415    for label in existing {
416        if label == PLAN_LABEL || label == HUMAN_LABEL {
417            continue;
418        }
419        if label == NO_AGENT_LABEL && (human || drop_no_agent) {
420            continue;
421        }
422        if !labels.iter().any(|have| have == label) {
423            labels.push(label.clone());
424        }
425    }
426    labels
427}
428
429pub fn diff_import(
430    roadmap: &Roadmap,
431    existing: &[ExistingIssue],
432    milestone: Option<&str>,
433    mut body_for: impl FnMut(&Task) -> String,
434) -> Result<PlanDiff, PlanError> {
435    let selected = select(roadmap, milestone)?;
436    let mut by_id: HashMap<&str, &ExistingIssue> = HashMap::new();
437    let mut errors = Vec::new();
438    for issue in existing {
439        if by_id.insert(issue.task_id.as_str(), issue).is_some() {
440            errors.push(format!(
441                "more than one issue is marked as {}",
442                issue.task_id
443            ));
444        }
445    }
446    if !errors.is_empty() {
447        return Err(PlanError::new(errors.join("\n")));
448    }
449
450    let mut tasks = Vec::new();
451    let mut selected_ids = HashSet::new();
452    for task in selected {
453        selected_ids.insert(task.id.as_str());
454        let title = issue_title(task);
455        let body = body_for(task);
456        let kind = if let Some(issue) = by_id.get(task.id.as_str()) {
457            let same = issue.title == title
458                && normalize_body(&issue.body) == normalize_body(&body)
459                && same_labels(&issue.labels, &labels_for(&issue.labels, task.owner_human));
460            if same {
461                ChangeKind::Unchanged
462            } else {
463                ChangeKind::Update
464            }
465        } else {
466            ChangeKind::Create
467        };
468        let issue = by_id.get(task.id.as_str()).copied();
469        tasks.push(TaskPlan {
470            task: task.clone(),
471            kind,
472            number: issue.map(|item| item.number),
473            database_id: issue.map(|item| item.database_id),
474            existing_labels: issue.map(|item| item.labels.clone()).unwrap_or_default(),
475        });
476    }
477
478    let mut removed = Vec::new();
479    for issue in existing {
480        if selected_ids.contains(issue.task_id.as_str()) {
481            continue;
482        }
483        if let Some(wanted) = milestone {
484            let Some(prefix) = task_milestone_id(&issue.task_id) else {
485                continue;
486            };
487            if !prefix.eq_ignore_ascii_case(wanted) {
488                continue;
489            }
490        }
491        removed.push(RemovedTask {
492            task_id: issue.task_id.clone(),
493            number: issue.number,
494            state: issue.state.clone(),
495        });
496    }
497
498    Ok(PlanDiff { tasks, removed })
499}
500
501/// Dependencies outside the selected set that have no GitHub issue yet.
502///
503/// An id that is not in the roadmap is an error. An id in the roadmap is reported
504pub fn unmet_externals(
505    tasks: &[Task],
506    roadmap: &Roadmap,
507    known_ids: &HashSet<String>,
508) -> Result<Vec<UnmetExternal>, PlanError> {
509    let selected: HashSet<&str> = tasks.iter().map(|task| task.id.as_str()).collect();
510    let roadmap_ids: HashSet<&str> = roadmap.tasks.iter().map(|task| task.id.as_str()).collect();
511    let mut unmet = Vec::new();
512    let mut errors = Vec::new();
513    for task in tasks {
514        for dep in &task.depends_on {
515            if selected.contains(dep.as_str()) || known_ids.contains(dep) {
516                continue;
517            }
518            if roadmap_ids.contains(dep.as_str()) {
519                unmet.push(UnmetExternal {
520                    task_id: task.id.clone(),
521                    blocking_task_id: dep.clone(),
522                });
523            } else {
524                errors.push(format!("{} depends on unknown task {dep}", task.id));
525            }
526        }
527    }
528    if errors.is_empty() {
529        Ok(unmet)
530    } else {
531        Err(PlanError::new(errors.join("\n")))
532    }
533}
534
535/// Task ids to omit when `--skip-unmet` is set, including tasks that depend on a skipped task
536pub fn skip_unmet_ids(tasks: &[Task], unmet: &[UnmetExternal]) -> HashSet<String> {
537    let mut skip: HashSet<String> = unmet.iter().map(|item| item.task_id.clone()).collect();
538    loop {
539        let mut added = false;
540        for task in tasks {
541            if skip.contains(&task.id) {
542                continue;
543            }
544            if task.depends_on.iter().any(|dep| skip.contains(dep)) {
545                skip.insert(task.id.clone());
546                added = true;
547            }
548        }
549        if !added {
550            break;
551        }
552    }
553    skip
554}
555
556pub fn blocked_external_ids<'a>(unmet: &'a [UnmetExternal], task_id: &str) -> Vec<&'a str> {
557    unmet
558        .iter()
559        .filter(|item| item.task_id == task_id)
560        .map(|item| item.blocking_task_id.as_str())
561        .collect()
562}
563
564pub fn diff_links(
565    tasks: &[Task],
566    bindings: &[Binding],
567    current: &HashMap<u64, Vec<DepRef>>,
568) -> Result<Vec<LinkEdit>, PlanError> {
569    let mut edits = Vec::new();
570    let mut errors = Vec::new();
571    let managed: HashSet<u64> = bindings.iter().filter_map(|item| item.number).collect();
572    for task in tasks {
573        let Some(binding) = bindings.iter().find(|item| item.task_id == task.id) else {
574            errors.push(format!("{} has no issue binding", task.id));
575            continue;
576        };
577        let blocked = binding
578            .number
579            .and_then(|number| current.get(&number))
580            .cloned()
581            .unwrap_or_default();
582        let mut desired_numbers = Vec::new();
583        for dep_id in &task.depends_on {
584            let Some(dep) = bindings.iter().find(|item| item.task_id == dep_id.as_str()) else {
585                continue;
586            };
587            if let Some(number) = dep.number {
588                desired_numbers.push(number);
589            }
590            let already = dep
591                .number
592                .is_some_and(|number| blocked.iter().any(|item| item.number == number));
593            if !already {
594                edits.push(LinkEdit::Add {
595                    task_id: task.id.clone(),
596                    issue_number: binding.number,
597                    blocking_task_id: dep_id.clone(),
598                    blocking_number: dep.number,
599                    blocking_database_id: dep.database_id,
600                });
601            }
602        }
603        if let Some(issue_number) = binding.number {
604            for item in &blocked {
605                // Only drop links to other plan issues, a link a person added by hand stays
606                if desired_numbers.contains(&item.number) || !managed.contains(&item.number) {
607                    continue;
608                }
609                edits.push(LinkEdit::Remove {
610                    task_id: task.id.clone(),
611                    issue_number,
612                    blocking_number: item.number,
613                    blocking_database_id: item.database_id,
614                });
615            }
616        }
617    }
618    if errors.is_empty() {
619        Ok(edits)
620    } else {
621        Err(PlanError::new(errors.join("\n")))
622    }
623}
624
625pub fn render(diff: &PlanDiff, links: &[LinkEdit], dry_run: bool) -> String {
626    render_report(diff, links, &[], &[], dry_run)
627}
628
629pub fn render_report(
630    diff: &PlanDiff,
631    links: &[LinkEdit],
632    unmet: &[UnmetExternal],
633    skipped: &[String],
634    dry_run: bool,
635) -> String {
636    let mut out = render_tasks(diff);
637    out.push_str(&render_unmet(unmet, skipped));
638    out.push_str(&render_links(links));
639    out.push_str(&render_summary(diff, links, dry_run));
640    out
641}
642
643fn render_unmet(unmet: &[UnmetExternal], skipped: &[String]) -> String {
644    let skip: HashSet<&str> = skipped.iter().map(String::as_str).collect();
645    let mut out = String::new();
646    for id in skipped {
647        let deps = blocked_external_ids(unmet, id);
648        if deps.is_empty() {
649            out.push_str(&format!("skip {id}\n"));
650        } else {
651            out.push_str(&format!(
652                "skip {id} depends on {}, which has no issue yet\n",
653                deps.join(", ")
654            ));
655        }
656    }
657    let mut seen = HashSet::new();
658    for item in unmet {
659        if skip.contains(item.task_id.as_str()) || !seen.insert(item.task_id.as_str()) {
660            continue;
661        }
662        let deps = blocked_external_ids(unmet, &item.task_id);
663        out.push_str(&format!(
664            "blocked-external {} depends on {}, which has no issue yet\n",
665            item.task_id,
666            deps.join(", ")
667        ));
668    }
669    out
670}
671
672pub fn render_tasks(diff: &PlanDiff) -> String {
673    let mut out = String::new();
674    for plan in &diff.tasks {
675        let human = if plan.task.owner_human {
676            " [human]"
677        } else {
678            ""
679        };
680        let deps = if plan.task.depends_on.is_empty() {
681            "none".to_string()
682        } else {
683            plan.task.depends_on.join(", ")
684        };
685        out.push_str(&format!(
686            "{kind} {title}{human}\n  depends on: {deps}\n",
687            kind = kind_word(plan.kind),
688            title = issue_title(&plan.task),
689        ));
690    }
691    for removed in &diff.removed {
692        out.push_str(&format!(
693            "removed {} issue #{} ({}), not closed\n",
694            removed.task_id, removed.number, removed.state
695        ));
696    }
697    out
698}
699
700pub fn render_links(links: &[LinkEdit]) -> String {
701    let mut out = String::new();
702    for link in links {
703        match link {
704            LinkEdit::Add {
705                task_id,
706                issue_number,
707                blocking_task_id,
708                blocking_number,
709                ..
710            } => {
711                out.push_str(&format!(
712                    "link {task_id}{issue} blocked by {blocking_task_id}{blocking}\n",
713                    issue = number_suffix(*issue_number),
714                    blocking = number_suffix(*blocking_number),
715                ));
716            }
717            LinkEdit::Remove {
718                task_id,
719                blocking_number,
720                ..
721            } => {
722                out.push_str(&format!("unlink {task_id} blocked by #{blocking_number}\n"));
723            }
724        }
725    }
726    out
727}
728
729pub fn render_summary(diff: &PlanDiff, links: &[LinkEdit], dry_run: bool) -> String {
730    let creates = diff
731        .tasks
732        .iter()
733        .filter(|plan| plan.kind == ChangeKind::Create)
734        .count();
735    let updates = diff
736        .tasks
737        .iter()
738        .filter(|plan| plan.kind == ChangeKind::Update)
739        .count();
740    let unchanged = diff
741        .tasks
742        .iter()
743        .filter(|plan| plan.kind == ChangeKind::Unchanged)
744        .count();
745    let prefix = if dry_run { "dry run: " } else { "" };
746    format!(
747        "{prefix}create {creates}, update {updates}, unchanged {unchanged}, link {}, removed {}\n",
748        links.len(),
749        diff.removed.len()
750    )
751}
752
753pub fn github_anchor(heading: &str) -> String {
754    let mut out = String::new();
755    let mut prev_hyphen = false;
756    for ch in heading.chars() {
757        if ch.is_whitespace() {
758            if !out.is_empty() && !prev_hyphen {
759                out.push('-');
760                prev_hyphen = true;
761            }
762            continue;
763        }
764        if ch == '-' || ch == '_' {
765            if !out.is_empty() && !prev_hyphen {
766                out.push(ch);
767                prev_hyphen = true;
768            }
769            continue;
770        }
771        if ch.is_alphanumeric() {
772            for lower in ch.to_lowercase() {
773                out.push(lower);
774            }
775            prev_hyphen = false;
776        }
777    }
778    while out.ends_with('-') || out.ends_with('_') {
779        out.pop();
780    }
781    out
782}
783
784fn encode_git_path(path: &str) -> String {
785    path.split('/')
786        .map(|segment| urlencoding::encode(segment).into_owned())
787        .collect::<Vec<_>>()
788        .join("/")
789}
790
791fn kind_word(kind: ChangeKind) -> &'static str {
792    match kind {
793        ChangeKind::Create => "create",
794        ChangeKind::Update => "update",
795        ChangeKind::Unchanged => "unchanged",
796    }
797}
798
799fn number_suffix(number: Option<u64>) -> String {
800    match number {
801        Some(number) if number > 0 => format!(" (#{number})"),
802        _ => String::new(),
803    }
804}
805
806fn normalize_body(body: &str) -> String {
807    body.replace("\r\n", "\n").trim().to_string()
808}
809
810fn same_labels(left: &[String], right: &[String]) -> bool {
811    let left: HashSet<&str> = left.iter().map(String::as_str).collect();
812    let right: HashSet<&str> = right.iter().map(String::as_str).collect();
813    left == right
814}
815
816fn task_milestone_id(task_id: &str) -> Option<&str> {
817    if !is_task_id(task_id) {
818        return None;
819    }
820    task_id.split_once('-').map(|(prefix, _)| prefix)
821}
822
823fn push_task(
824    task: Task,
825    tasks: &mut Vec<Task>,
826    seen: &mut HashSet<String>,
827    errors: &mut Vec<String>,
828) {
829    if !seen.insert(task.id.clone()) {
830        errors.push(format!("duplicate task id {}", task.id));
831        return;
832    }
833    tasks.push(task);
834}
835
836fn flush_draft(
837    draft: &mut Option<Draft>,
838    tasks: &mut Vec<Task>,
839    seen: &mut HashSet<String>,
840    errors: &mut Vec<String>,
841) {
842    let Some(draft) = draft.take() else {
843        return;
844    };
845    match draft.into_task() {
846        Ok(task) => push_task(task, tasks, seen, errors),
847        Err(err) => errors.push(err),
848    }
849}
850
851fn check_dependencies(tasks: &[Task], errors: &mut Vec<String>) {
852    let ids: HashSet<&str> = tasks.iter().map(|task| task.id.as_str()).collect();
853    for task in tasks {
854        for dep in &task.depends_on {
855            if dep == &task.id {
856                errors.push(format!("{} depends on itself", task.id));
857            } else if !ids.contains(dep.as_str()) {
858                errors.push(format!("{} depends on unknown task {dep}", task.id));
859            }
860        }
861    }
862}
863
864fn apply_field(draft: &mut Draft, name: &str, value: &str) {
865    if name.eq_ignore_ascii_case("goal") {
866        draft.in_acceptance = false;
867        draft.goal = value.to_string();
868    } else if name.eq_ignore_ascii_case("scope") {
869        draft.in_acceptance = false;
870        draft.scope = value.to_string();
871    } else if name.eq_ignore_ascii_case("acceptance") {
872        draft.in_acceptance = true;
873        if !value.is_empty() {
874            draft.acceptance.push(acceptance_item(value));
875        }
876    } else if name.eq_ignore_ascii_case("depends on") {
877        draft.in_acceptance = false;
878        draft.depends_raw = Some(value.to_string());
879    } else if name.eq_ignore_ascii_case("owner") {
880        draft.in_acceptance = false;
881        draft.owner_human = is_human_owner(value);
882    }
883}
884
885fn acceptance_item(value: &str) -> String {
886    if value.starts_with('-') || value.starts_with('*') {
887        value.to_string()
888    } else {
889        format!("- {value}")
890    }
891}
892
893fn is_acceptance_line(line: &str) -> bool {
894    let trimmed = line.trim();
895    !trimmed.is_empty()
896        && (line.starts_with(' ')
897            || line.starts_with('\t')
898            || trimmed.starts_with("- ")
899            || trimmed.starts_with("* "))
900}
901
902fn unindent(line: &str) -> String {
903    let line = line.trim_end();
904    line.strip_prefix("  ")
905        .or_else(|| line.strip_prefix('\t'))
906        .unwrap_or(line)
907        .to_string()
908}
909
910fn top_level_field(line: &str) -> Option<(&str, &str)> {
911    let rest = line.strip_prefix("- ")?;
912    let (name, value) = rest.split_once(':')?;
913    let name = name.trim();
914    is_known_field(name).then_some((name, value.trim()))
915}
916
917fn is_known_field(name: &str) -> bool {
918    name.eq_ignore_ascii_case("goal")
919        || name.eq_ignore_ascii_case("scope")
920        || name.eq_ignore_ascii_case("acceptance")
921        || name.eq_ignore_ascii_case("depends on")
922        || name.eq_ignore_ascii_case("owner")
923}
924
925fn is_human_owner(value: &str) -> bool {
926    let Some(word) = value.split_whitespace().next() else {
927        return false;
928    };
929    word.trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':'))
930        .eq_ignore_ascii_case("human")
931}
932
933fn parse_depends(raw: &str) -> Result<Vec<String>, String> {
934    let trimmed = raw.trim().trim_end_matches('.').trim();
935    if trimmed.is_empty() {
936        return Ok(Vec::new());
937    }
938    let first = trimmed.split_whitespace().next().unwrap_or("");
939    let first = first.trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':'));
940    // A leading none is no dependency, even when the rest of the line names a task
941    if first.eq_ignore_ascii_case("none") {
942        return Ok(Vec::new());
943    }
944    let ids = extract_ids(trimmed);
945    if ids.is_empty() {
946        Err(format!("depends on value \"{trimmed}\" has no task id"))
947    } else {
948        Ok(ids)
949    }
950}
951
952fn extract_ids(raw: &str) -> Vec<String> {
953    let bytes = raw.as_bytes();
954    let mut ids = Vec::new();
955    let mut index = 0;
956    while index < bytes.len() {
957        if bytes[index].is_ascii_alphabetic() {
958            let start = index;
959            index += 1;
960            while index < bytes.len()
961                && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'-')
962            {
963                index += 1;
964            }
965            let token = &raw[start..index];
966            if is_task_id(token) && !ids.iter().any(|id| id == token) {
967                ids.push(token.to_string());
968            }
969            continue;
970        }
971        index += 1;
972    }
973    ids
974}
975
976fn is_task_id(token: &str) -> bool {
977    let Some((milestone, rest)) = token.split_once('-') else {
978        return false;
979    };
980    if !is_milestone_id(milestone) || rest.is_empty() || !rest.as_bytes()[0].is_ascii_digit() {
981        return false;
982    }
983    rest.bytes().all(|byte| byte.is_ascii_alphanumeric())
984}
985
986fn is_milestone_id(token: &str) -> bool {
987    let mut chars = token.chars();
988    let Some(first) = chars.next() else {
989        return false;
990    };
991    if !first.is_ascii_alphabetic() {
992        return false;
993    }
994    let mut saw_digit = false;
995    for ch in chars {
996        if ch.is_ascii_digit() {
997            saw_digit = true;
998        } else if ch.is_ascii_alphabetic() {
999            continue;
1000        } else {
1001            return false;
1002        }
1003    }
1004    saw_digit
1005}
1006
1007fn parse_milestone_heading(rest: &str) -> Option<(String, String)> {
1008    let rest = rest.trim();
1009    let (first, title) = rest.split_once(char::is_whitespace)?;
1010    let id = first.trim_end_matches('.');
1011    is_milestone_id(id).then(|| (id.to_string(), title.trim().to_string()))
1012}
1013
1014fn parse_task_heading(rest: &str) -> Option<(String, String)> {
1015    let rest = rest.trim();
1016    let (id, title) = match rest.split_once(char::is_whitespace) {
1017        Some((id, title)) => (id, title.trim()),
1018        None => (rest, ""),
1019    };
1020    is_task_id(id).then(|| (id.to_string(), title.to_string()))
1021}
1022
1023fn table_cols(line: &str) -> Option<(TableCols, Vec<String>)> {
1024    if !line.trim().starts_with('|') {
1025        return None;
1026    }
1027    let cells = split_cells(line);
1028    let names: Vec<String> = cells.iter().map(|cell| cell.to_ascii_lowercase()).collect();
1029    let task = names.iter().position(|name| name == "task")?;
1030    if !names.iter().any(|name| name == "depends on") {
1031        return None;
1032    }
1033    let unknown = cells
1034        .iter()
1035        .zip(names.iter())
1036        .filter(|(_, name)| !name.is_empty() && !is_known_table_column(name))
1037        .map(|(cell, _)| cell.clone())
1038        .collect();
1039    let pos = |label: &str| names.iter().position(|name| name == label);
1040    Some((
1041        TableCols {
1042            task,
1043            goal: pos("goal"),
1044            scope: pos("scope"),
1045            acceptance: pos("acceptance"),
1046            depends: pos("depends on"),
1047            owner: pos("owner"),
1048        },
1049        unknown,
1050    ))
1051}
1052
1053fn is_known_table_column(name: &str) -> bool {
1054    name.eq_ignore_ascii_case("task") || is_known_field(name)
1055}
1056
1057fn is_separator_row(line: &str) -> bool {
1058    let cells = split_cells(line);
1059    !cells.is_empty()
1060        && cells.iter().all(|cell| {
1061            let token = cell.trim_matches(':');
1062            !token.is_empty() && token.chars().all(|ch| ch == '-')
1063        })
1064}
1065
1066fn split_cells(line: &str) -> Vec<String> {
1067    line.trim()
1068        .trim_matches('|')
1069        .split('|')
1070        .map(|cell| cell.trim().to_string())
1071        .collect()
1072}
1073
1074fn cell(cells: &[String], index: Option<usize>) -> &str {
1075    index
1076        .and_then(|index| cells.get(index))
1077        .map(String::as_str)
1078        .unwrap_or("")
1079}
1080
1081fn task_from_row(
1082    line: &str,
1083    cols: &TableCols,
1084    milestone: &Milestone,
1085    line_no: usize,
1086) -> Result<Option<Task>, String> {
1087    let cells = split_cells(line);
1088    let task_cell = cell(&cells, Some(cols.task));
1089    let Some((id, title)) = parse_task_heading(task_cell) else {
1090        return Ok(None);
1091    };
1092    if title.is_empty() {
1093        return Err(format!("line {line_no}: {id} has no title"));
1094    }
1095    let depends_raw = cell(&cells, cols.depends);
1096    let depends_on =
1097        parse_depends(depends_raw).map_err(|err| format!("line {line_no}: {id} {err}"))?;
1098    let owner = cell(&cells, cols.owner);
1099    Ok(Some(Task {
1100        id,
1101        title,
1102        milestone_id: milestone.id.clone(),
1103        milestone_title: milestone.title.clone(),
1104        milestone_heading: milestone.heading.clone(),
1105        anchor_heading: String::new(),
1106        goal: cell(&cells, cols.goal).to_string(),
1107        scope: cell(&cells, cols.scope).to_string(),
1108        acceptance: {
1109            let value = cell(&cells, cols.acceptance);
1110            if value.is_empty() {
1111                Vec::new()
1112            } else {
1113                vec![acceptance_item(value)]
1114            }
1115        },
1116        depends_on,
1117        owner_human: is_human_owner(owner),
1118    }))
1119}
1120
1121impl Draft {
1122    fn new(
1123        id: String,
1124        title: String,
1125        milestone: &Milestone,
1126        anchor_heading: &str,
1127        line: usize,
1128    ) -> Self {
1129        Self {
1130            id,
1131            title,
1132            milestone_id: milestone.id.clone(),
1133            milestone_title: milestone.title.clone(),
1134            milestone_heading: milestone.heading.clone(),
1135            anchor_heading: anchor_heading.to_string(),
1136            goal: String::new(),
1137            scope: String::new(),
1138            acceptance: Vec::new(),
1139            depends_raw: None,
1140            owner_human: false,
1141            in_acceptance: false,
1142            line,
1143        }
1144    }
1145
1146    fn into_task(self) -> Result<Task, String> {
1147        let depends_on = match self.depends_raw {
1148            Some(raw) => parse_depends(&raw)
1149                .map_err(|err| format!("line {}: {} {err}", self.line, self.id))?,
1150            None => Vec::new(),
1151        };
1152        Ok(Task {
1153            id: self.id,
1154            title: self.title,
1155            milestone_id: self.milestone_id,
1156            milestone_title: self.milestone_title,
1157            milestone_heading: self.milestone_heading,
1158            anchor_heading: self.anchor_heading,
1159            goal: self.goal,
1160            scope: self.scope,
1161            acceptance: self.acceptance,
1162            depends_on,
1163            owner_human: self.owner_human,
1164        })
1165    }
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170    use super::*;
1171
1172    const SAMPLE: &str = r#"
1173## M1. Content model and pack format
1174
1175### M1-01 Set up the workspace
1176
1177- Goal: a Rust workspace that builds.
1178- Scope: the `api/` workspace.
1179- Acceptance:
1180  - `cargo test` passes on a clean checkout.
1181    - including the ignored tests
1182  - CI fails on a formatting error.
1183- Depends on: none.
1184
1185### M1-02 Content model types
1186
1187- Goal: serde types.
1188- Scope: crates/content.
1189- Acceptance:
1190  - A sample file round-trips.
1191- Depends on: M1-01.
1192
1193### M1-03 Content validation
1194
1195- Owner: human.
1196- Goal: catch broken content.
1197- Scope: content-cli validate.
1198- Acceptance:
1199  - One test per check.
1200- Depends on: M1-02.
1201
1202### M1-04 Festival dates
1203
1204- Goal: resolve dates.
1205- Acceptance:
1206  - Tests for 2026.
1207- Depends on: none. Do it before M1-05.
1208
1209### M1-05 Scheduler
1210
1211- Goal: the guardrail.
1212- Scope: resolve().
1213- Acceptance:
1214  - At least 15 fixtures.
1215
1216## M5. Release
1217
1218### M5-02 Privacy policy
1219
1220- Owner: human (counsel), with an agent draft.
1221- Goal: legal pages.
1222- Acceptance:
1223  - Counsel signs off.
1224- Depends on: M1-05.
1225
1226## M6. Full year and plan editor
1227
1228| Task | Goal | Acceptance | Depends on |
1229|---|---|---|---|
1230| M6-01 Units 4 to 10 | Write the rest of the year | Each pack validates | M1-05, M1-01 |
1231| M6-02 Festival modules | Attach modules | Each shows in Hoje | M6-01 |
1232"#;
1233
1234    fn roadmap() -> Roadmap {
1235        parse_roadmap(SAMPLE).unwrap()
1236    }
1237
1238    fn task<'a>(roadmap: &'a Roadmap, id: &str) -> &'a Task {
1239        roadmap.tasks.iter().find(|task| task.id == id).unwrap()
1240    }
1241
1242    const SUFFIXED: &str = "## M2S. API security\n\n### M2S-01 Rate limits\n\n- Goal: limit.\n\n## M3. Billing\n\n### M3-01 Invoices\n\n- Goal: bill.\n- Depends on: M9-01\n";
1243
1244    #[test]
1245    fn milestone_ids_may_end_in_letters() {
1246        let (roadmap, _) =
1247            parse_roadmap_for(&SUFFIXED.replace("M9-01", "M2S-01"), Some("M2S")).unwrap();
1248        let selected = select(&roadmap, Some("M2S")).unwrap();
1249        assert_eq!(selected.len(), 1);
1250        assert_eq!(selected[0].id, "M2S-01");
1251    }
1252
1253    #[test]
1254    fn other_milestone_errors_are_warnings_when_one_is_selected() {
1255        let err = parse_roadmap_for(SUFFIXED, Some("M3")).unwrap_err();
1256        assert!(err.message.contains("M3-01 depends on unknown task M9-01"));
1257        let broken = SUFFIXED
1258            .replace("- Goal: limit.\n", "- Depends on: nothing\n")
1259            .replace("- Depends on: M9-01\n", "");
1260        assert!(parse_roadmap(&broken).is_err());
1261        let (roadmap, warnings) = parse_roadmap_for(&broken, Some("M3")).unwrap();
1262        assert_eq!(select(&roadmap, Some("M3")).unwrap().len(), 1);
1263        assert_eq!(warnings.len(), 1);
1264    }
1265
1266    #[test]
1267    fn parses_nested_acceptance_and_missing_depends_on() {
1268        let roadmap = roadmap();
1269        let first = task(&roadmap, "M1-01");
1270        assert_eq!(
1271            first.acceptance,
1272            vec![
1273                "- `cargo test` passes on a clean checkout.".to_string(),
1274                "  - including the ignored tests".to_string(),
1275                "- CI fails on a formatting error.".to_string(),
1276            ]
1277        );
1278        assert!(first.depends_on.is_empty());
1279        assert_eq!(first.goal, "a Rust workspace that builds.");
1280        assert_eq!(first.scope, "the `api/` workspace.");
1281
1282        let scheduler = task(&roadmap, "M1-05");
1283        assert!(scheduler.depends_on.is_empty());
1284        assert_eq!(
1285            scheduler.acceptance,
1286            vec!["- At least 15 fixtures.".to_string()]
1287        );
1288    }
1289
1290    #[test]
1291    fn none_with_trailing_prose_adds_no_dependency() {
1292        let roadmap = roadmap();
1293        assert!(task(&roadmap, "M1-04").depends_on.is_empty());
1294    }
1295
1296    #[test]
1297    fn parses_owner_human_and_table_dependencies() {
1298        let roadmap = roadmap();
1299        assert!(task(&roadmap, "M1-03").owner_human);
1300        assert!(task(&roadmap, "M5-02").owner_human);
1301        assert!(!task(&roadmap, "M1-01").owner_human);
1302        assert_eq!(
1303            task(&roadmap, "M1-02").depends_on,
1304            vec!["M1-01".to_string()]
1305        );
1306        assert_eq!(
1307            task(&roadmap, "M6-01").depends_on,
1308            vec!["M1-05".to_string(), "M1-01".to_string()]
1309        );
1310        assert_eq!(
1311            task(&roadmap, "M6-02").depends_on,
1312            vec!["M6-01".to_string()]
1313        );
1314        assert!(task(&roadmap, "M6-01").anchor_heading.is_empty());
1315        assert_eq!(task(&roadmap, "M6-01").milestone_id, "M6");
1316    }
1317
1318    #[test]
1319    fn labels_keep_status_and_drop_no_agent_when_a_task_is_no_longer_human() {
1320        let kept = labels_for(
1321            &[
1322                "orch-plan".to_string(),
1323                "owner:human".to_string(),
1324                "no-agent".to_string(),
1325                "status:new".to_string(),
1326            ],
1327            false,
1328        );
1329        assert_eq!(
1330            kept,
1331            vec!["orch-plan".to_string(), "status:new".to_string()]
1332        );
1333
1334        let human = labels_for(&["status:new".to_string(), "no-agent".to_string()], true);
1335        assert_eq!(
1336            human,
1337            vec![
1338                "orch-plan".to_string(),
1339                "owner:human".to_string(),
1340                "no-agent".to_string(),
1341                "status:new".to_string(),
1342            ]
1343        );
1344    }
1345
1346    #[test]
1347    fn unknown_table_column_is_an_error() {
1348        let markdown = r#"
1349## M3. Session
1350
1351| Task | Title | Depends on |
1352|---|---|---|
1353| M3-01 Session backfill | ignored | none |
1354"#;
1355        let err = parse_roadmap(markdown).unwrap_err();
1356        assert!(err.message.contains("Title"), "{}", err.message);
1357        assert!(err.message.contains("line 4"), "{}", err.message);
1358        assert!(
1359            err.message.contains("unknown table column"),
1360            "{}",
1361            err.message
1362        );
1363    }
1364
1365    #[test]
1366    fn table_row_with_only_an_id_has_no_title() {
1367        let markdown = r#"
1368## M3. Session
1369
1370| Task | Depends on |
1371|---|---|
1372| M3-01 | none |
1373"#;
1374        let err = parse_roadmap(markdown).unwrap_err();
1375        assert!(err.message.contains("M3-01"), "{}", err.message);
1376        assert!(err.message.contains("no title"), "{}", err.message);
1377        assert!(err.message.contains("line 6"), "{}", err.message);
1378    }
1379
1380    #[test]
1381    fn heading_task_with_no_title_is_an_error() {
1382        let markdown = r#"
1383## M3. Session
1384
1385### M3-01
1386
1387- Goal: backfill.
1388- Depends on: none.
1389"#;
1390        let err = parse_roadmap(markdown).unwrap_err();
1391        assert!(err.message.contains("M3-01"), "{}", err.message);
1392        assert!(err.message.contains("no title"), "{}", err.message);
1393        assert!(err.message.contains("line 4"), "{}", err.message);
1394    }
1395
1396    #[test]
1397    fn table_owner_column_keeps_title_in_the_task_cell() {
1398        let markdown = r#"
1399## M3. Session
1400
1401| Task | Owner | Depends on |
1402|---|---|---|
1403| M3-01 Session backfill | human | none |
1404| M3-02 Follow up | | M3-01 |
1405"#;
1406        let roadmap = parse_roadmap(markdown).unwrap();
1407        let first = task(&roadmap, "M3-01");
1408        assert_eq!(first.title, "Session backfill");
1409        assert!(first.owner_human);
1410        assert!(first.depends_on.is_empty());
1411        let second = task(&roadmap, "M3-02");
1412        assert_eq!(second.title, "Follow up");
1413        assert!(!second.owner_human);
1414        assert_eq!(second.depends_on, vec!["M3-01".to_string()]);
1415    }
1416
1417    #[test]
1418    fn depends_on_without_a_task_id_is_an_error() {
1419        let markdown = r#"
1420## M1. Content model
1421
1422### M1-01 First
1423
1424- Goal: one.
1425- Depends on: later today.
1426"#;
1427        let err = parse_roadmap(markdown).unwrap_err();
1428        assert!(err.message.contains("no task id"), "{}", err.message);
1429    }
1430
1431    #[test]
1432    fn unknown_dependency_is_an_error() {
1433        let markdown = r#"
1434## M1. Content model
1435
1436### M1-01 First
1437
1438- Goal: one.
1439- Acceptance:
1440  - done.
1441- Depends on: M1-99.
1442"#;
1443        let err = parse_roadmap(markdown).unwrap_err();
1444        assert!(
1445            err.message.contains("unknown task M1-99"),
1446            "{}",
1447            err.message
1448        );
1449        assert!(err.message.contains("M1-01"), "{}", err.message);
1450    }
1451
1452    #[test]
1453    fn issue_body_links_the_heading_and_carries_the_marker() {
1454        let roadmap = roadmap();
1455        let task = task(&roadmap, "M1-01");
1456        let body = issue_body(task, "acme/app", "main", "docs/ROADMAP.md");
1457        assert!(body.contains("## Goal\n\na Rust workspace that builds."));
1458        assert!(body.contains("## Scope\n"));
1459        assert!(body.contains("- `cargo test` passes on a clean checkout."));
1460        assert!(body.contains("  - including the ignored tests"));
1461        assert!(body.contains("<!-- orch-plan: M1-01 -->"));
1462        assert!(body.contains(
1463            "https://github.com/acme/app/blob/main/docs/ROADMAP.md#m1-01-set-up-the-workspace"
1464        ));
1465        assert_eq!(marker_id(&body).as_deref(), Some("M1-01"));
1466        assert_eq!(issue_title(task), "M1-01 Set up the workspace");
1467    }
1468
1469    #[test]
1470    fn table_task_links_to_the_milestone_heading() {
1471        let roadmap = roadmap();
1472        let body = issue_body(
1473            task(&roadmap, "M6-01"),
1474            "acme/app",
1475            "main",
1476            "docs/ROADMAP.md",
1477        );
1478        assert!(body.contains("#m6-full-year-and-plan-editor"));
1479        assert!(!body.contains("## Scope"));
1480    }
1481
1482    #[test]
1483    fn report_lists_dependencies_and_marks_human_tasks() {
1484        let roadmap = roadmap();
1485        let diff = diff_import(&roadmap, &[], None, |task| {
1486            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1487        })
1488        .unwrap();
1489        let links = diff_links(&roadmap.tasks, &bindings_for(&diff), &HashMap::new()).unwrap();
1490        let text = render(&diff, &links, true);
1491        assert!(text.contains("create M1-01 Set up the workspace\n  depends on: none\n"));
1492        assert!(text.contains("create M1-03 Content validation [human]\n  depends on: M1-02\n"));
1493        assert!(text.contains("create M5-02 Privacy policy [human]\n  depends on: M1-05\n"));
1494        assert!(text.contains("link M6-01 blocked by M1-05\n"));
1495        assert!(text.contains("link M6-01 blocked by M1-01\n"));
1496        assert!(text.starts_with("dry run:") || text.contains("\ndry run:"));
1497        assert!(text.contains("create 8,"));
1498    }
1499
1500    #[test]
1501    fn second_import_with_no_file_change_edits_nothing() {
1502        let roadmap = roadmap();
1503        let existing = existing_for(&roadmap);
1504        let diff = diff_import(&roadmap, &existing, None, |task| {
1505            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1506        })
1507        .unwrap();
1508        assert!(diff
1509            .tasks
1510            .iter()
1511            .all(|plan| plan.kind == ChangeKind::Unchanged));
1512        assert!(diff.removed.is_empty());
1513        let current = current_links(&roadmap);
1514        let links =
1515            diff_links(&roadmap.tasks, &bindings_from_existing(&existing), &current).unwrap();
1516        assert!(links.is_empty(), "{links:?}");
1517        let text = render(&diff, &links, false);
1518        assert!(text.contains("unchanged M1-02 Content model types"));
1519        assert_eq!(
1520            text.lines()
1521                .filter(|line| line.starts_with("unchanged "))
1522                .count(),
1523            8
1524        );
1525        assert!(!text
1526            .lines()
1527            .any(|line| line.starts_with("link ") || line.starts_with("unlink ")));
1528        assert!(text.contains("create 0, update 0, unchanged 8, link 0, removed 0\n"));
1529    }
1530
1531    #[test]
1532    fn changed_body_is_an_update_and_a_dropped_task_is_reported() {
1533        let roadmap = roadmap();
1534        let mut existing = existing_for(&roadmap);
1535        existing[0].body = "stale\n<!-- orch-plan: M1-01 -->\n".to_string();
1536        existing.push(ExistingIssue {
1537            task_id: "M1-99".to_string(),
1538            number: 99,
1539            database_id: 990,
1540            title: "M1-99 Gone".to_string(),
1541            body: marker_for("M1-99"),
1542            labels: labels_for(&[], false),
1543            state: "open".to_string(),
1544        });
1545        let diff = diff_import(&roadmap, &existing, None, |task| {
1546            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1547        })
1548        .unwrap();
1549        assert_eq!(diff.tasks[0].kind, ChangeKind::Update);
1550        assert!(diff.tasks[1..]
1551            .iter()
1552            .all(|plan| plan.kind == ChangeKind::Unchanged));
1553        assert_eq!(diff.removed.len(), 1);
1554        assert_eq!(diff.removed[0].task_id, "M1-99");
1555        assert!(render_tasks(&diff).contains("removed M1-99 issue #99 (open), not closed"));
1556    }
1557
1558    #[test]
1559    fn marker_without_label_is_an_update_that_adds_the_label() {
1560        let roadmap = roadmap();
1561        let mut existing = existing_for(&roadmap);
1562        existing[0].labels = vec!["status:new".to_string()];
1563        let diff = diff_import(&roadmap, &existing, None, |task| {
1564            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1565        })
1566        .unwrap();
1567        assert_eq!(diff.tasks[0].kind, ChangeKind::Update);
1568        assert_eq!(diff.tasks[0].number, Some(1));
1569        assert!(labels_for(&diff.tasks[0].existing_labels, false).contains(&PLAN_LABEL.to_string()));
1570    }
1571
1572    #[test]
1573    fn milestone_filter_limits_tasks_and_removed() {
1574        let roadmap = roadmap();
1575        let mut existing = existing_for(&roadmap);
1576        existing.push(ExistingIssue {
1577            task_id: "M6-09".to_string(),
1578            number: 70,
1579            database_id: 700,
1580            title: "M6-09 Gone".to_string(),
1581            body: marker_for("M6-09"),
1582            labels: labels_for(&[], false),
1583            state: "closed".to_string(),
1584        });
1585        let diff = diff_import(&roadmap, &existing, Some("M6"), |task| {
1586            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1587        })
1588        .unwrap();
1589        let ids: Vec<&str> = diff
1590            .tasks
1591            .iter()
1592            .map(|plan| plan.task.id.as_str())
1593            .collect();
1594        assert_eq!(ids, vec!["M6-01", "M6-02"]);
1595        assert_eq!(diff.removed.len(), 1);
1596        assert_eq!(diff.removed[0].task_id, "M6-09");
1597        let err = select(&roadmap, Some("M9")).unwrap_err();
1598        assert!(err.message.contains("no milestone M9"));
1599    }
1600
1601    #[test]
1602    fn milestone_import_links_an_existing_external_issue() {
1603        let roadmap = roadmap();
1604        let existing: Vec<ExistingIssue> = existing_for(&roadmap)
1605            .into_iter()
1606            .filter(|issue| issue.task_id.starts_with("M1-"))
1607            .collect();
1608        let known: HashSet<String> = existing.iter().map(|issue| issue.task_id.clone()).collect();
1609        let selected: Vec<Task> = select(&roadmap, Some("M6"))
1610            .unwrap()
1611            .into_iter()
1612            .cloned()
1613            .collect();
1614        assert!(unmet_externals(&selected, &roadmap, &known)
1615            .unwrap()
1616            .is_empty());
1617        let diff = diff_import(&roadmap, &existing, Some("M6"), |task| {
1618            issue_body(task, "acme/app", "main", "docs/ROADMAP.md")
1619        })
1620        .unwrap();
1621        let links = diff_links(
1622            &selected,
1623            &bindings_with_existing(&diff, &existing),
1624            &HashMap::new(),
1625        )
1626        .unwrap();
1627        assert!(links.iter().any(|edit| matches!(
1628            edit,
1629            LinkEdit::Add { task_id, blocking_task_id, blocking_number: Some(5), .. }
1630                if task_id == "M6-01" && blocking_task_id == "M1-05"
1631        )));
1632        assert!(links.iter().any(|edit| matches!(
1633            edit,
1634            LinkEdit::Add { task_id, blocking_task_id, .. }
1635                if task_id == "M6-01" && blocking_task_id == "M1-01"
1636        )));
1637        assert!(links.iter().any(|edit| matches!(
1638            edit,
1639            LinkEdit::Add { task_id, blocking_task_id, .. }
1640                if task_id == "M6-02" && blocking_task_id == "M6-01"
1641        )));
1642    }
1643
1644    #[test]
1645    fn milestone_import_keeps_going_when_an_external_blocker_has_no_issue() {
1646        let roadmap = roadmap();
1647        let selected: Vec<Task> = select(&roadmap, Some("M6"))
1648            .unwrap()
1649            .into_iter()
1650            .cloned()
1651            .collect();
1652        let unmet = unmet_externals(&selected, &roadmap, &HashSet::new()).unwrap();
1653        assert!(
1654            unmet
1655                .iter()
1656                .any(|item| item.task_id == "M6-01" && item.blocking_task_id == "M1-05"),
1657            "{unmet:?}"
1658        );
1659        assert!(
1660            unmet
1661                .iter()
1662                .any(|item| item.task_id == "M6-01" && item.blocking_task_id == "M1-01"),
1663            "{unmet:?}"
1664        );
1665        let extras: Vec<String> = blocked_external_ids(&unmet, "M6-01")
1666            .into_iter()
1667            .map(str::to_string)
1668            .collect();
1669        let body = issue_body_with(
1670            task(&roadmap, "M6-01"),
1671            "acme/app",
1672            "main",
1673            "docs/ROADMAP.md",
1674            &extras,
1675        );
1676        assert!(body.contains("blocked-external: M1-05, M1-01 (no issue yet)"));
1677        assert!(body.contains("<!-- orch-blocked-external: M1-05, M1-01 -->"));
1678        let diff = diff_import(&roadmap, &[], Some("M6"), |task| {
1679            let extra: Vec<String> = blocked_external_ids(&unmet, &task.id)
1680                .into_iter()
1681                .map(str::to_string)
1682                .collect();
1683            issue_body_with(task, "acme/app", "main", "docs/ROADMAP.md", &extra)
1684        })
1685        .unwrap();
1686        assert_eq!(diff.tasks.len(), 2);
1687        assert!(diff
1688            .tasks
1689            .iter()
1690            .all(|plan| plan.kind == ChangeKind::Create));
1691        let links = diff_links(&selected, &bindings_for(&diff), &HashMap::new()).unwrap();
1692        assert!(
1693            !links.iter().any(|edit| matches!(
1694                edit,
1695                LinkEdit::Add { blocking_task_id, .. }
1696                    if blocking_task_id == "M1-05" || blocking_task_id == "M1-01"
1697            )),
1698            "an unmet external blocker must not be linked: {links:?}"
1699        );
1700        assert!(links.iter().any(|edit| matches!(
1701            edit,
1702            LinkEdit::Add { task_id, blocking_task_id, .. }
1703                if task_id == "M6-02" && blocking_task_id == "M6-01"
1704        )));
1705        let text = render_report(&diff, &links, &unmet, &[], true);
1706        assert!(
1707            text.contains("blocked-external M6-01 depends on M1-05, M1-01, which has no issue yet")
1708        );
1709        let mut known = HashSet::new();
1710        known.insert("M1-05".to_string());
1711        known.insert("M1-01".to_string());
1712        assert!(unmet_externals(&selected, &roadmap, &known)
1713            .unwrap()
1714            .is_empty());
1715    }
1716
1717    #[test]
1718    fn skip_unmet_omits_the_dependent_and_tasks_that_wait_on_it() {
1719        let roadmap = roadmap();
1720        let selected: Vec<Task> = select(&roadmap, Some("M6"))
1721            .unwrap()
1722            .into_iter()
1723            .cloned()
1724            .collect();
1725        let unmet = unmet_externals(&selected, &roadmap, &HashSet::new()).unwrap();
1726        let skip = skip_unmet_ids(&selected, &unmet);
1727        assert!(skip.contains("M6-01"));
1728        assert!(skip.contains("M6-02"));
1729        let text = render_report(
1730            &PlanDiff {
1731                tasks: Vec::new(),
1732                removed: Vec::new(),
1733            },
1734            &[],
1735            &unmet,
1736            &["M6-01".to_string(), "M6-02".to_string()],
1737            true,
1738        );
1739        assert!(text.contains("skip M6-01 depends on M1-05, M1-01, which has no issue yet"));
1740        assert!(text.contains("skip M6-02\n"));
1741        assert!(!text.contains("blocked-external M6-01"));
1742    }
1743
1744    #[test]
1745    fn unmet_externals_rejects_an_unknown_id() {
1746        let roadmap = roadmap();
1747        let mut task = task(&roadmap, "M6-01").clone();
1748        task.depends_on = vec!["M9-99".to_string()];
1749        let err = unmet_externals(&[task], &roadmap, &HashSet::new()).unwrap_err();
1750        assert!(
1751            err.message.contains("unknown task M9-99"),
1752            "{}",
1753            err.message
1754        );
1755        assert!(err.message.contains("M6-01"), "{}", err.message);
1756    }
1757
1758    #[test]
1759    fn link_diff_adds_missing_and_removes_extras_then_is_empty() {
1760        let roadmap = roadmap();
1761        let existing = existing_for(&roadmap);
1762        let bindings = bindings_from_existing(&existing);
1763        let stale = number_of(&existing, "M1-05");
1764        let mut current = HashMap::new();
1765        current.insert(
1766            number_of(&existing, "M1-02"),
1767            vec![
1768                DepRef {
1769                    number: stale,
1770                    database_id: stale * 10,
1771                },
1772                DepRef {
1773                    number: 50,
1774                    database_id: 500,
1775                },
1776            ],
1777        );
1778        let edits = diff_links(&roadmap.tasks, &bindings, &current).unwrap();
1779        assert!(edits.iter().any(|edit| matches!(
1780            edit,
1781            LinkEdit::Add { task_id, blocking_task_id, .. }
1782                if task_id == "M1-02" && blocking_task_id == "M1-01"
1783        )));
1784        assert!(edits.iter().any(|edit| matches!(
1785            edit,
1786            LinkEdit::Remove { task_id, blocking_number, .. }
1787                if task_id == "M1-02" && *blocking_number == stale
1788        )));
1789        assert!(
1790            !edits.iter().any(|edit| matches!(
1791                edit,
1792                LinkEdit::Remove {
1793                    blocking_number: 50,
1794                    ..
1795                }
1796            )),
1797            "a blocker outside the plan must stay: {edits:?}"
1798        );
1799
1800        let synced = current_links(&roadmap);
1801        let again = diff_links(&roadmap.tasks, &bindings, &synced).unwrap();
1802        assert!(again.is_empty(), "{again:?}");
1803    }
1804
1805    #[test]
1806    fn anchor_keeps_letters_and_drops_punctuation() {
1807        assert_eq!(
1808            github_anchor("M1. Content model and pack format"),
1809            "m1-content-model-and-pack-format"
1810        );
1811        assert_eq!(
1812            github_anchor("M3-09 Class screen and Modo áudio"),
1813            "m3-09-class-screen-and-modo-áudio"
1814        );
1815    }
1816
1817    fn existing_for(roadmap: &Roadmap) -> Vec<ExistingIssue> {
1818        roadmap
1819            .tasks
1820            .iter()
1821            .enumerate()
1822            .map(|(index, task)| {
1823                let number = (index + 1) as u64;
1824                ExistingIssue {
1825                    task_id: task.id.clone(),
1826                    number,
1827                    database_id: number * 10,
1828                    title: issue_title(task),
1829                    body: issue_body(task, "acme/app", "main", "docs/ROADMAP.md"),
1830                    labels: labels_for(&["status:new".to_string()], task.owner_human),
1831                    state: "open".to_string(),
1832                }
1833            })
1834            .collect()
1835    }
1836
1837    fn bindings_for(diff: &PlanDiff) -> Vec<Binding> {
1838        diff.tasks
1839            .iter()
1840            .map(|plan| Binding {
1841                task_id: plan.task.id.clone(),
1842                number: plan.number,
1843                database_id: plan.database_id,
1844            })
1845            .collect()
1846    }
1847
1848    fn bindings_with_existing(diff: &PlanDiff, existing: &[ExistingIssue]) -> Vec<Binding> {
1849        let mut bindings = bindings_for(diff);
1850        for issue in existing {
1851            if bindings
1852                .iter()
1853                .any(|binding| binding.task_id == issue.task_id)
1854            {
1855                continue;
1856            }
1857            bindings.push(Binding {
1858                task_id: issue.task_id.clone(),
1859                number: Some(issue.number),
1860                database_id: Some(issue.database_id),
1861            });
1862        }
1863        bindings
1864    }
1865
1866    fn bindings_from_existing(existing: &[ExistingIssue]) -> Vec<Binding> {
1867        existing
1868            .iter()
1869            .map(|issue| Binding {
1870                task_id: issue.task_id.clone(),
1871                number: Some(issue.number),
1872                database_id: Some(issue.database_id),
1873            })
1874            .collect()
1875    }
1876
1877    fn number_of(existing: &[ExistingIssue], id: &str) -> u64 {
1878        existing
1879            .iter()
1880            .find(|issue| issue.task_id == id)
1881            .unwrap()
1882            .number
1883    }
1884
1885    fn current_links(roadmap: &Roadmap) -> HashMap<u64, Vec<DepRef>> {
1886        let existing = existing_for(roadmap);
1887        let mut current = HashMap::new();
1888        for task in &roadmap.tasks {
1889            let blocked = task
1890                .depends_on
1891                .iter()
1892                .map(|dep| DepRef {
1893                    number: number_of(&existing, dep),
1894                    database_id: number_of(&existing, dep) * 10,
1895                })
1896                .collect();
1897            current.insert(number_of(&existing, &task.id), blocked);
1898        }
1899        current
1900    }
1901}