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