Skip to main content

spar/
tracker.rs

1//! A tracking issue's checklist, read as work.
2//!
3//! Triage already declines a tracker and holds it open, which is right and
4//! leaves it standing still: every run judges it again, comments again, and
5//! none of the work written down in it moves. This reads the `- [ ]` lines,
6//! gives each one an issue, and writes the number back beside the item so the
7//! body itself is the record. No state file: the checkbox is the state, where a
8//! person can read it and correct it by hand.
9//!
10//! The trigger is the checklist and never a judgement about what the parts
11//! might be. A tracker with no task list is commented on and held exactly as
12//! before. Writing `- [ ]` lines is something somebody does on purpose, which
13//! makes this opt in per issue as well as per repository.
14//!
15//! Everything else spar writes is additive: a comment, a new issue, a commit on
16//! its own branch. This rewrites text a person wrote, in place, in the issue
17//! most likely to be the shared plan for a piece of work. So the surgery is
18//! line local, every other line is proved byte identical before the write, the
19//! body is re-read immediately before each one, and `spar triage` prints the
20//! whole thing without writing any of it.
21
22use std::collections::{BTreeMap, BTreeSet};
23use std::sync::LazyLock;
24
25use regex::Regex;
26
27use crate::config::Config;
28use crate::error::Result;
29use crate::model::{Issue, ItemKind};
30use crate::repo::Repo;
31use crate::review;
32use crate::style;
33use crate::{bail, log, logdim, logwarn, spar_err};
34
35// ---------------------------------------------------------------------------
36// Parsing
37// ---------------------------------------------------------------------------
38
39/// A markdown task list item. Indentation and the marker are captured rather
40/// than normalised, because nothing here rebuilds a line it did not have to.
41static ITEM: LazyLock<Regex> = LazyLock::new(|| {
42    Regex::new(
43        r"^(?P<indent>[ \t]*)(?:[-*+]|[0-9]{1,9}[.)])(?P<gap>[ \t]+)\[(?P<state>[ xX])\](?P<rest>[ \t].*|)$",
44    )
45    .expect("task item pattern")
46});
47
48/// The line read as a task list item, or `None` if it is not one.
49///
50/// More than four columns after the marker put the checkbox in an indented code
51/// block inside the item: markdown starts the content one column after the
52/// marker, and everything past that is code. `-     [ ] example` is somebody
53/// showing the syntax, which is the fence case again.
54fn item_of(line: &str) -> Option<regex::Captures<'_>> {
55    let caps = ITEM.captures(line)?;
56    (indent_width(&caps["gap"]) <= 4).then_some(caps)
57}
58
59/// Any list line, checkbox or not, so that a task nested under a plain bullet
60/// is still read as nested rather than as indented code. The marker and the
61/// gap after it are captured because they decide where the item's content
62/// starts, and that is what four spaces are measured against.
63static LIST: LazyLock<Regex> = LazyLock::new(|| {
64    Regex::new(r"^(?P<indent>[ \t]*)(?P<marker>[-*+]|[0-9]{1,9}[.)])(?P<gap>[ \t]*)(?P<rest>.*)$")
65        .expect("list pattern")
66});
67
68/// The raw HTML blocks GitHub renders exactly as written, so a checkbox inside
69/// one is text somebody is showing rather than a box anybody can tick. Same
70/// shape as a fence, different clothes again.
71static HTML_VERBATIM: LazyLock<Regex> = LazyLock::new(|| {
72    Regex::new(r"(?i)^[ \t]*<(?:pre|script|style|textarea)\b").expect("html open pattern")
73});
74
75static HTML_CLOSE: LazyLock<Regex> = LazyLock::new(|| {
76    Regex::new(r"(?i)</(?:pre|script|style|textarea)>").expect("html close pattern")
77});
78
79/// A line that starts a block level HTML tag. Markdown inside one of these is
80/// not parsed either: `<div>` then a checkbox on the next line renders as the
81/// literal text `- [ ] thing`. The block runs to the next blank line rather
82/// than to a closing tag, which is what makes the `<details>` a tracker is
83/// often written in still work: the blank line after `</summary>` ends it and
84/// the checklist below is a checklist.
85static HTML_BLOCK: LazyLock<Regex> = LazyLock::new(|| {
86    Regex::new(concat!(
87        r"(?i)^[ \t]*</?(?:address|article|aside|base|basefont|blockquote|body|caption|center",
88        r"|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form",
89        r"|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem",
90        r"|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot",
91        r"|th|thead|title|tr|track|ul)\b"
92    ))
93    .expect("html block pattern")
94});
95
96/// Which kind of raw HTML block is open, since the two end differently.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98enum Html {
99    /// `<pre>` and friends, which run to their closing tag.
100    Verbatim,
101    /// Any other block tag, which runs to the next blank line.
102    Block,
103}
104
105/// A fence, opening or closing. Info string included so that only a bare fence
106/// can close one.
107static FENCE: LazyLock<Regex> = LazyLock::new(|| {
108    Regex::new(r"^[ \t]*(?P<fence>`{3,}|~{3,})(?P<info>.*)$").expect("fence pattern")
109});
110
111/// The three things GitHub turns into an issue link without a url: `#123`,
112/// `owner/repo#123`, and `GH-123`. All at the start of the text or after
113/// something that is neither a word nor a path separator, so that a fragment
114/// left in the middle of an address is not read as a number.
115static HASH_REF: LazyLock<Regex> = LazyLock::new(|| {
116    Regex::new(r"(?:^|[^\w/])(?:(?P<slug>[\w.-]+/[\w.-]+)#|#|GH-)(?P<number>[0-9]{1,9})\b")
117        .expect("hash pattern")
118});
119
120/// A link, of any shape. Blanked before the bare numbers are read, because the
121/// `#8` in `https://example.com/guide/#8` is a fragment in somebody's address
122/// and not a reference to issue 8. The links that do name an issue are read by
123/// `URL_REF` from the text with them still in it.
124static LINK: LazyLock<Regex> =
125    LazyLock::new(|| Regex::new(r"https?://[^\s<>)\]]*").expect("link pattern"));
126
127/// An HTML comment, closed or running to the end of the text. GitHub renders
128/// none of it, so a number parked in one is a note to a person.
129static COMMENT: LazyLock<Regex> =
130    LazyLock::new(|| Regex::new(r"(?s)<!--.*?(?:-->|$)").expect("comment pattern"));
131
132/// A link to an issue or a pull request, on any host. Which repository it
133/// belongs to is decided later, against this one's name.
134///
135/// Pull requests are here because an item is very often written down as the
136/// change that closes it, and because `resolve` already answers for one: a
137/// merged pull request ticks the box, an open one is held. Reading only
138/// `/issues/` would file a second issue for work already in flight.
139static URL_REF: LazyLock<Regex> = LazyLock::new(|| {
140    Regex::new(r"(?P<url>https?://[^\s)\]]*?/(?:issues|pull)/(?P<number>[0-9]{1,9}))\b")
141        .expect("url pattern")
142});
143
144/// Where the issue an item names lives, as the item spells it.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum Origin {
147    /// `#123` or `GH-123`, which can only mean this repository.
148    Here,
149    /// `owner/repo#123`. The host is left out of the shorthand, so it is this
150    /// repository whenever the path matches, wherever this one is served from.
151    Repo(String),
152    /// A link, host and all, which can name anybody's.
153    Url(String),
154}
155
156/// An issue an item's text already names.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct Reference {
159    pub number: i64,
160    pub origin: Origin,
161}
162
163impl Reference {
164    /// The issue number, when the reference is one this repository can act on.
165    ///
166    /// Another repository's issue is not adoptable: taking the number out of it
167    /// would point the item at whatever happens to carry that number here,
168    /// which is the wrong link failure with no fuzziness to blame.
169    ///
170    /// `home` is this repository's own address, host and all, so that another
171    /// host serving the same `owner/repo` path is somebody else's.
172    pub fn local(&self, home: &str) -> Option<i64> {
173        match &self.origin {
174            Origin::Here => Some(self.number),
175            Origin::Repo(slug) => (slug == &owner_repo(home)).then_some(self.number),
176            Origin::Url(url) => {
177                let home = locator(home).trim_end_matches('/');
178                let url = locator(url);
179                let owned = !home.is_empty()
180                    && (url.starts_with(&format!("{home}/issues/"))
181                        || url.starts_with(&format!("{home}/pull/")));
182                owned.then_some(self.number)
183            }
184        }
185    }
186
187    /// The reference as it would be quoted back to somebody, for a log line.
188    pub fn names(&self) -> String {
189        match &self.origin {
190            Origin::Here => format!("#{}", self.number),
191            Origin::Repo(slug) => format!("{slug}#{}", self.number),
192            Origin::Url(url) => url.clone(),
193        }
194    }
195}
196
197/// The last two segments of an address, which are the owner and repository a
198/// `owner/repo#1` shorthand is measured against. Empty when there is no address
199/// to read, which matches no shorthand rather than guessing at one.
200fn owner_repo(home: &str) -> String {
201    let path: Vec<&str> = locator(home).trim_matches('/').split('/').collect();
202    match path.len() {
203        0..=2 => String::new(),
204        n => format!("{}/{}", path[n - 2], path[n - 1]),
205    }
206}
207
208/// A url reduced to what identifies it. The scheme and a leading `www.` are
209/// two spellings of the same place, and matching on the rest from the front
210/// keeps `elsewhere.example/me/mine/issues/7` out of `me/mine`.
211fn locator(url: &str) -> &str {
212    let rest = url
213        .strip_prefix("https://")
214        .or_else(|| url.strip_prefix("http://"))
215        .unwrap_or(url);
216    rest.strip_prefix("www.").unwrap_or(rest)
217}
218
219/// One task list item, as it stands in the body.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct Item {
222    /// 1-based, so a log line names something a person can go and look at.
223    pub line: usize,
224    /// The line exactly as it stands, without its terminator. This is the
225    /// handle: the line is found again by content on re-read, never by index,
226    /// because an edit that lands mid-run moves indexes and does not move text.
227    pub raw: String,
228    /// The item's own text, after the checkbox.
229    pub text: String,
230    pub checked: bool,
231    pub reference: Option<Reference>,
232}
233
234/// Every task list item in the body, in order.
235///
236/// Deliberately dull about what it will not treat as an item: anything the
237/// reader of the issue does not see as a checkbox is not one. That is a fenced
238/// block, an indented code block, an HTML comment, anything inside a block
239/// level HTML tag, and anything that is not a task list line. Nested items are
240/// ordinary items, since every edit here is line local.
241pub fn parse(body: &str) -> Vec<Item> {
242    parse_reporting(body).0
243}
244
245/// A checkbox line the parser deliberately did not read as an item.
246///
247/// The README says a line spar cannot rewrite is "skipped with a reason", and
248/// these were skipped without one. All of them are the safe direction, and none
249/// of them were visible.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct Skipped {
252    pub line: usize,
253    pub text: String,
254    pub why: &'static str,
255}
256
257pub fn parse_reporting(body: &str) -> (Vec<Item>, Vec<Skipped>) {
258    let mut out = Vec::new();
259    let mut skipped: Vec<Skipped> = Vec::new();
260    let note = |index: usize, line: &str, why: &'static str, skipped: &mut Vec<Skipped>| {
261        let quoted = line.trim_start().trim_start_matches('>').trim_start();
262        if item_of(quoted).is_some() || item_of(line).is_some() {
263            skipped.push(Skipped {
264                line: index + 1,
265                text: quoted.to_string(),
266                why,
267            });
268        }
269    };
270    // The glyph and length a close has to match, and the column it opened in,
271    // since a fence four columns further in is code rather than the close.
272    let mut fence: Option<(char, usize, usize)> = None;
273    let mut comment = false;
274    let mut html: Option<Html> = None;
275    // Where the content of each open list item starts, outermost first. Four
276    // spaces mean code, but four spaces from where: the margin outside a list,
277    // and the innermost item's own content column inside one.
278    let mut open: Vec<usize> = Vec::new();
279
280    for (index, raw) in split_keep(body).into_iter().enumerate() {
281        let line = without_eol(raw);
282        // A comment is markdown a person wrote for the next person, often the
283        // items they decided against. GitHub renders none of it.
284        if comment {
285            comment = !line.contains("-->");
286            note(index, line, "it is inside an HTML comment", &mut skipped);
287            continue;
288        }
289        if let Some(kind) = html {
290            let ends = match kind {
291                Html::Verbatim => HTML_CLOSE.is_match(line),
292                Html::Block => line.trim().is_empty(),
293            };
294            if ends {
295                html = None;
296            }
297            note(index, line, "it is inside a raw HTML block", &mut skipped);
298            continue;
299        }
300        let indent = indent_width(line);
301        if let Some((glyph, len, column)) = fence {
302            // Only the same glyph, at least as long, with nothing after it, and
303            // not indented so far past the opening one that it is code.
304            if let Some(caps) = FENCE.captures(line) {
305                let marker = &caps["fence"];
306                let closes = marker.starts_with(glyph)
307                    && marker.len() >= len
308                    && caps["info"].trim().is_empty()
309                    && indent < column + 4;
310                if closes {
311                    fence = None;
312                }
313            }
314            note(index, line, "it is inside a code fence", &mut skipped);
315            continue;
316        }
317        // A blank line closes nothing: a list survives one, and both markdown
318        // and the person writing it expect the item after it to still be in.
319        if !line.trim().is_empty() {
320            while open.last().is_some_and(|col| indent < *col) {
321                open.pop();
322            }
323        }
324        // Four spaces past wherever the content of this line belongs is a code
325        // block, which is the fence case wearing different clothes. `- outer`
326        // then six spaces is an example inside that item, not a nested task.
327        let margin = open.last().copied().unwrap_or(0);
328        if !line.trim().is_empty() && indent >= margin + 4 {
329            continue;
330        }
331        // After the code check, so that four spaces in are a fence a person is
332        // showing rather than one they are opening.
333        if let Some(caps) = FENCE.captures(line) {
334            let marker = &caps["fence"];
335            fence = Some((
336                marker.chars().next().expect("a fence"),
337                marker.len(),
338                indent,
339            ));
340            continue;
341        }
342        if let Some(column) = content_column(line) {
343            open.push(column);
344        }
345        if HTML_VERBATIM.is_match(line) {
346            html = (!HTML_CLOSE.is_match(line)).then_some(Html::Verbatim);
347            continue;
348        }
349        if HTML_BLOCK.is_match(line) {
350            html = Some(Html::Block);
351            continue;
352        }
353        comment = opens_comment(line);
354        let Some(caps) = item_of(line) else {
355            if line.trim_start().starts_with('>') {
356                note(index, line, "it is inside a block quote", &mut skipped);
357            }
358            continue;
359        };
360        let text = caps["rest"].trim().to_string();
361        out.push(Item {
362            line: index + 1,
363            raw: line.to_string(),
364            reference: reference_in(&text),
365            text,
366            checked: &caps["state"] != " ",
367        });
368    }
369    (out, skipped)
370}
371
372/// Where a list line's content starts, in columns, or `None` if it is not one.
373///
374/// Markdown puts the content one column after the marker when the gap is
375/// nothing or wider than four, and at the gap otherwise.
376fn content_column(line: &str) -> Option<usize> {
377    let caps = LIST.captures(line)?;
378    let gap = indent_width(&caps["gap"]);
379    if gap == 0 && !caps["rest"].is_empty() {
380        return None;
381    }
382    let gap = match (1..=4).contains(&gap) && !caps["rest"].trim().is_empty() {
383        true => gap,
384        false => 1,
385    };
386    Some(indent_width(&caps["indent"]) + caps["marker"].len() + gap)
387}
388
389/// The first issue this text names, by link or by number.
390///
391/// Read from the text with comments, code spans and link labels blanked out. A
392/// `#12` in backticks is somebody writing about a number rather than pointing
393/// at one, and a `#7` in a link's label captions the destination: without this,
394/// `[other/widgets #7](https://github.com/other/widgets/issues/7)` becomes a
395/// bare local #7 and the foreign repository check never sees it.
396///
397/// The bare numbers are read from a copy with the links blanked too, so that
398/// only `URL_REF` speaks for what is inside an address. Both copies are the
399/// same length as the original, so the two offsets can still be compared.
400fn reference_in(text: &str) -> Option<Reference> {
401    let text = &readable(text);
402    let url = URL_REF.captures(text);
403    let outside = blank(text, &LINK);
404    let hash = HASH_REF.captures(&outside);
405    let at = |caps: &Option<regex::Captures>| {
406        caps.as_ref()
407            .map(|c| c.get(0).expect("the whole match").start())
408    };
409    // Whichever comes first, so a link is not shadowed by a `#` later in the
410    // same line.
411    match (at(&url), at(&hash)) {
412        (Some(u), Some(h)) if h < u => hash.map(as_hash),
413        (Some(_), _) => url.map(as_url),
414        (None, Some(_)) => hash.map(as_hash),
415        (None, None) => None,
416    }
417}
418
419fn as_hash(caps: regex::Captures) -> Reference {
420    Reference {
421        number: caps["number"].parse().unwrap_or_default(),
422        origin: match caps.name("slug") {
423            Some(slug) => Origin::Repo(slug.as_str().to_string()),
424            None => Origin::Here,
425        },
426    }
427}
428
429fn as_url(caps: regex::Captures) -> Reference {
430    Reference {
431        number: caps["number"].parse().unwrap_or_default(),
432        origin: Origin::Url(caps["url"].to_string()),
433    }
434}
435
436/// The text with every match of `what` replaced by spaces, byte for byte so
437/// that offsets into it still line up with the original.
438fn blank(text: &str, what: &Regex) -> String {
439    let mut out = text.as_bytes().to_vec();
440    for found in what.find_iter(text) {
441        out[found.range()].fill(b' ');
442    }
443    // Whole matches are blanked, so no character is left half replaced.
444    String::from_utf8(out).unwrap_or_else(|_| text.to_string())
445}
446
447/// The text with comments, code spans and inline link labels replaced by
448/// spaces, byte for byte so that offsets into it still line up with the
449/// original. A link's destination is left standing, because that is the part
450/// that names an issue.
451fn readable(text: &str) -> String {
452    let text = blank(text, &COMMENT);
453    let text = text.as_str();
454    let bytes = text.as_bytes();
455    let mut out = bytes.to_vec();
456    let mut at = 0;
457    while at < bytes.len() {
458        match bytes[at] {
459            b'`' => {
460                let start = at;
461                while at < bytes.len() && bytes[at] == b'`' {
462                    at += 1;
463                }
464                if let Some(end) = backtick_run(bytes, at, at - start) {
465                    out[start..end].fill(b' ');
466                    at = end;
467                }
468            }
469            b'[' => match label_end(bytes, at) {
470                Some(end) => {
471                    out[at..end].fill(b' ');
472                    at = end;
473                }
474                None => at += 1,
475            },
476            _ => at += 1,
477        }
478    }
479    // Only whole regions delimited by ASCII are blanked, so this holds.
480    String::from_utf8(out).unwrap_or_else(|_| text.to_string())
481}
482
483/// The end of the next run of exactly `len` backticks, which is what closes a
484/// code span opened by one that long.
485fn backtick_run(bytes: &[u8], from: usize, len: usize) -> Option<usize> {
486    let mut at = from;
487    while at < bytes.len() {
488        if bytes[at] != b'`' {
489            at += 1;
490            continue;
491        }
492        let start = at;
493        while at < bytes.len() && bytes[at] == b'`' {
494            at += 1;
495        }
496        if at - start == len {
497            return Some(at);
498        }
499    }
500    None
501}
502
503/// The end of an inline link's label, including nested and escaped brackets,
504/// when a destination follows.
505fn label_end(bytes: &[u8], open: usize) -> Option<usize> {
506    if bytes.get(open) != Some(&b'[') || escaped(bytes, open) {
507        return None;
508    }
509
510    let mut depth = 1usize;
511    let mut at = open + 1;
512    while at < bytes.len() {
513        if escaped(bytes, at) {
514            at += 1;
515            continue;
516        }
517        match bytes[at] {
518            b'[' => depth += 1,
519            b']' => {
520                depth -= 1;
521                if depth == 0 {
522                    return (bytes.get(at + 1) == Some(&b'(')).then_some(at + 1);
523                }
524            }
525            _ => {}
526        }
527        at += 1;
528    }
529    None
530}
531
532fn escaped(bytes: &[u8], at: usize) -> bool {
533    let mut slashes = 0usize;
534    let mut cursor = at;
535    while cursor > 0 && bytes[cursor - 1] == b'\\' {
536        slashes += 1;
537        cursor -= 1;
538    }
539    slashes % 2 == 1
540}
541
542/// Lines with their terminators kept, so concatenating them is the original
543/// string. CRLF survives, and so does a body that does not end in a newline.
544fn split_keep(text: &str) -> Vec<&str> {
545    let mut out = Vec::new();
546    let mut start = 0;
547    for (at, c) in text.char_indices() {
548        if c == '\n' {
549            out.push(&text[start..=at]);
550            start = at + 1;
551        }
552    }
553    if start < text.len() {
554        out.push(&text[start..]);
555    }
556    out
557}
558
559/// Leading whitespace in columns, a tab counting as the four spaces markdown
560/// gives it when it decides what is indented far enough to be code.
561fn indent_width(line: &str) -> usize {
562    line.chars()
563        .take_while(|c| matches!(c, ' ' | '\t'))
564        .map(|c| if c == '\t' { 4 } else { 1 })
565        .sum()
566}
567
568/// Whether the line leaves an HTML comment open behind it.
569///
570/// CommonMark opens an HTML block only when `<!--` starts the line. An inline
571/// one, for example in a code span like `` - [ ] document the `<!--` syntax ``,
572/// renders as text on GitHub, and treating it as an opener silently skipped
573/// every item after it.
574fn opens_comment(line: &str) -> bool {
575    let trimmed = line.trim_start();
576    if !trimmed.starts_with("<!--") {
577        return false;
578    }
579    !trimmed[4..].contains("-->")
580}
581
582fn without_eol(line: &str) -> &str {
583    match line.strip_suffix('\n') {
584        Some(rest) => rest.strip_suffix('\r').unwrap_or(rest),
585        None => line,
586    }
587}
588
589fn eol_of(line: &str) -> &str {
590    if line.ends_with("\r\n") {
591        "\r\n"
592    } else if line.ends_with('\n') {
593        "\n"
594    } else {
595        ""
596    }
597}
598
599// ---------------------------------------------------------------------------
600// Line surgery
601// ---------------------------------------------------------------------------
602
603/// The one edit a write may make to one line.
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub enum Change {
606    /// Tick the box, and only ever in that direction. A checked box beside an
607    /// open issue is left alone: unchecking is destroying somebody's record on
608    /// a heuristic, and every state that produces it (the work landed in
609    /// another pull request, the issue was reopened for one detail) is one where
610    /// the person is right and the heuristic is wrong.
611    Tick,
612    /// Append a reference to the item's text.
613    Reference(String),
614}
615
616impl Change {
617    /// What spar is adding, for the style gate. The rest of the body is
618    /// somebody else's writing and is not spar's to clean.
619    pub fn inserted(&self) -> &str {
620        match self {
621            Change::Tick => "x",
622            Change::Reference(reference) => reference,
623        }
624    }
625}
626
627/// The body with one line changed, or an error saying why it will not be.
628///
629/// The line is found by its exact content, and every other line comes through
630/// byte identical. That is proved here rather than assumed: this is the only
631/// place spar rewrites something a person wrote, and a parser that mishandles a
632/// nested list does not produce a bad comment, it produces a mangled plan.
633pub fn rewrite(body: &str, raw: &str, change: &Change) -> Result<String> {
634    let lines = split_keep(body);
635    // If the split is not lossless nothing below it is safe.
636    if lines.concat() != body {
637        bail!("could not split the body into lines without changing it");
638    }
639
640    let hits: Vec<usize> = lines
641        .iter()
642        .enumerate()
643        .filter(|(_, line)| without_eol(line) == raw)
644        .map(|(at, _)| at)
645        .collect();
646    match hits.len() {
647        0 => bail!("that line is no longer in the body"),
648        1 => {}
649        n => bail!("{n} lines read exactly alike, so the edit could go to either"),
650    }
651    let at = hits[0];
652    let replaced = changed(raw, change)?;
653
654    let mut out = String::with_capacity(body.len() + replaced.len());
655    for (index, line) in lines.iter().enumerate() {
656        if index == at {
657            out.push_str(&replaced);
658            out.push_str(eol_of(line));
659        } else {
660            out.push_str(line);
661        }
662    }
663
664    // Byte identity, on the result rather than on the plan for it.
665    let after = split_keep(&out);
666    if after.len() != lines.len() {
667        bail!(
668            "the edit changed the line count from {} to {}",
669            lines.len(),
670            after.len()
671        );
672    }
673    for (index, (before, now)) in lines.iter().zip(&after).enumerate() {
674        if index != at && before != now {
675            bail!(
676                "the edit would have changed line {}, which it must not",
677                index + 1
678            );
679        }
680    }
681    Ok(out)
682}
683
684/// One line, changed. Nothing is reflowed, normalised, or re-emitted from a
685/// parsed model: the untouched parts of the line are copied through as bytes.
686fn changed(line: &str, change: &Change) -> Result<String> {
687    let caps = item_of(line).ok_or_else(|| spar_err!("that line is no longer a checklist item"))?;
688
689    match change {
690        Change::Tick => {
691            let at = caps.name("state").expect("a state").start();
692            if &line[at..at + 1] != " " {
693                bail!("that box is already ticked");
694            }
695            Ok(format!("{}x{}", &line[..at], &line[at + 1..]))
696        }
697        Change::Reference(reference) => {
698            let rest = caps.name("rest").expect("a rest");
699            let text = rest.as_str();
700            // Trailing whitespace is a markdown hard break, so the reference
701            // goes before it rather than after.
702            let body = text.trim_end_matches([' ', '\t']);
703            if body.trim().is_empty() {
704                bail!("that item has no text to attach {reference} to");
705            }
706            Ok(format!(
707                "{}{body} {reference}{}",
708                &line[..rest.start()],
709                &text[body.len()..]
710            ))
711        }
712    }
713}
714
715// ---------------------------------------------------------------------------
716// Deciding
717// ---------------------------------------------------------------------------
718
719/// What an item is, before anything is asked of the network.
720#[derive(Debug, Clone, PartialEq, Eq)]
721enum Shape {
722    /// It names something in this repository. What that something turns out to
723    /// be is the network's answer, not the parser's.
724    Names(i64),
725    /// It names nothing, so it needs one.
726    Needs,
727    /// Left alone, with the reason.
728    Hold(String),
729    /// Past `max_tracker_children`.
730    Over,
731}
732
733/// What spar would do with one unchecked item.
734#[derive(Debug, Clone, PartialEq, Eq)]
735pub enum Action {
736    /// The line already names an open issue here. Nothing is written.
737    Adopt(i64),
738    /// What it names is finished, so the box is ticked. An item that carried its
739    /// reference before spar touched it is not in doubt.
740    Tick(i64),
741    /// An issue covers it and nothing linked them. The link is written; the box
742    /// is not ticked this run whatever state that issue is in, because the
743    /// match is fuzzy and a wrong adoption that also ticks the box is spar
744    /// asserting work is done that nobody did.
745    Link {
746        number: i64,
747        title: String,
748        open: bool,
749    },
750    /// Nothing covers it. One is filed, then linked.
751    File,
752    /// Left alone, with the reason.
753    Hold(String),
754    /// Past `max_tracker_children`, and said out loud rather than dropped.
755    Over,
756}
757
758impl Action {
759    /// The edit this writes to the item's line.
760    ///
761    /// `File` has none yet, because its issue does not exist until it is filed;
762    /// the caller writes the reference the moment it does.
763    ///
764    /// A `Link` writes the reference and never the tick, whatever state the
765    /// issue it matched is in. The match is fuzzy, and a wrong adoption that
766    /// also ticks the box is spar asserting work is done that nobody did.
767    /// Holding the tick for one run puts the link in front of a person first,
768    /// and by the next run the item carries its own reference and is in no more
769    /// doubt than one somebody wrote by hand.
770    pub fn change(&self) -> Option<Change> {
771        match self {
772            Action::Tick(_) => Some(Change::Tick),
773            Action::Link { number, .. } => Some(Change::Reference(format!("#{number}"))),
774            Action::Adopt(_) | Action::File | Action::Hold(_) | Action::Over => None,
775        }
776    }
777}
778
779/// One item and what is to become of it.
780#[derive(Debug, Clone)]
781pub struct Step {
782    pub item: Item,
783    pub action: Action,
784}
785
786/// Every unchecked item and its shape, without touching the network.
787///
788/// Checked items are absent by construction: spar checks a box and never
789/// unchecks one, so there is nothing to decide about them.
790fn shape(body: &str, home: &str, max: usize) -> Vec<(Item, Shape)> {
791    let items = parse(body);
792    // A line that appears twice cannot be rewritten unambiguously, and there is
793    // no reading of two identical items that makes filing two issues right.
794    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
795    for item in &items {
796        *seen.entry(item.raw.as_str()).or_default() += 1;
797    }
798
799    let mut out = Vec::new();
800    let mut taken = 0usize;
801    for item in &items {
802        if item.checked {
803            continue;
804        }
805        let shape = if seen.get(item.raw.as_str()).copied().unwrap_or(0) > 1 {
806            Shape::Hold("another item is written identically, so a link could go to either".into())
807        } else if item.text.is_empty() {
808            Shape::Hold("the item has no text".into())
809        } else {
810            match &item.reference {
811                // An item that already links an issue needs a tick or an
812                // adoption, never a new issue, so it costs the cap nothing.
813                // Counting it meant five linked items and
814                // max_tracker_children = 5 left a sixth unlinked item Over on
815                // every run, and closed items past the cap were never ticked.
816                Some(reference) => match reference.local(home) {
817                    Some(number) => Shape::Names(number),
818                    None => Shape::Hold(format!(
819                        "it names an issue in another repository: {}",
820                        reference.names()
821                    )),
822                },
823                None if taken >= max => Shape::Over,
824                None => {
825                    taken += 1;
826                    Shape::Needs
827                }
828            }
829        };
830        out.push((item.clone(), shape));
831    }
832    out
833}
834
835/// What spar would do with each unchecked item, deciding but never writing.
836///
837/// Reads only: the state of an issue an item already names, and the similarity
838/// search for one that does not. `spar triage` prints exactly this and the
839/// acting path applies it, so the preview and the run cannot drift apart.
840pub fn plan(repo: &Repo, cfg: &Config, tracker: i64, body: &str, home: &str) -> Vec<Step> {
841    shape(body, home, cfg.loop_cfg.max_tracker_children)
842        .into_iter()
843        .map(|(item, shape)| {
844            let action = match shape {
845                Shape::Hold(why) => Action::Hold(why),
846                Shape::Over => Action::Over,
847                Shape::Names(number) => resolve(repo, number),
848                // `file_as_issue` runs this search itself, so doing it here
849                // looks redundant and is not: it maps a closed match to
850                // `AlreadyClosed` and returns no url, which is right when
851                // filing a follow-up and wrong here, where a closed match is a
852                // done item that wants a link. Doing it first is also what lets
853                // the log say "linked #40, filed nothing".
854                Shape::Needs => match search(repo, tracker, &item.text) {
855                    Some(found) => Action::Link {
856                        number: found.number,
857                        title: found.title,
858                        open: found.open,
859                    },
860                    None => Action::File,
861                },
862            };
863            Step { item, action }
864        })
865        .collect()
866}
867
868/// What to do about an item that already names something.
869///
870/// What it names is established before anything is read from it. Issues and
871/// pull requests share one number sequence, `gh issue view` answers happily for
872/// either, and adopting a pull request as a child would hand the run something
873/// with no issue behind it.
874fn resolve(repo: &Repo, number: i64) -> Action {
875    match repo.item_kind(number) {
876        Ok(ItemKind::Issue) => match repo.read_issue(number) {
877            Ok(issue) => issue_action(&issue),
878            Err(e) => Action::Hold(format!("could not read #{number}: {}", e.first_line())),
879        },
880        // A merged pull request is the work landing, which is what the tick is
881        // for. An open one is somebody's work in progress and not spar's to
882        // take up, and one closed unmerged is not a finished item at all.
883        Ok(ItemKind::Pr) => match repo.pr_state(number).to_uppercase().as_str() {
884            "MERGED" => Action::Tick(number),
885            "" => Action::Hold(format!(
886                "#{number} is a pull request in an unreadable state"
887            )),
888            state => Action::Hold(format!(
889                "#{number} is a pull request, {}",
890                state.to_lowercase()
891            )),
892        },
893        Err(e) => Action::Hold(format!("could not read #{number}: {}", e.first_line())),
894    }
895}
896
897/// What an item's own issue says about the box beside it.
898///
899/// A tick says the work landed. An issue closed as not planned is the
900/// opposite: somebody, or both agents under `close_skipped`, decided against
901/// it, and "spar decided not to do this" is indistinguishable from "this was
902/// done" once the box is ticked. spar never unchecks one, so the correction
903/// would be somebody else's job.
904fn issue_action(issue: &Issue) -> Action {
905    let number = issue.number;
906    if issue.closed_as_not_planned() {
907        return Action::Hold(format!(
908            "#{number} was closed as not planned, so the item is not done"
909        ));
910    }
911    if issue.is_closed() {
912        return Action::Tick(number);
913    }
914    Action::Adopt(number)
915}
916
917/// An issue that already covers this item, the tracker itself apart.
918///
919/// The tracker quotes every item in its own checklist, so it is the closest
920/// match for each one of them. Adopting it would link an item to the issue it
921/// is written in, and the run would then see the tracker as already handled and
922/// work nothing.
923fn search(repo: &Repo, tracker: i64, text: &str) -> Option<crate::repo::ExistingIssue> {
924    let title = repo.clean_title(text).ok()?;
925    repo.find_similar_issue_apart_from(&title, &child_body(text, tracker), Some(tracker))
926}
927
928/// What a child issue says, when spar has to file one. The item's own words,
929/// and where they came from.
930fn child_body(text: &str, tracker: i64) -> String {
931    format!("{text}\n\nFrom the checklist in #{tracker}.")
932}
933
934// ---------------------------------------------------------------------------
935// Acting
936// ---------------------------------------------------------------------------
937
938/// Work the checklist in one tracker, and hand back the children to work.
939///
940/// The children are ordinary issues from here on: they go through two agent
941/// triage like anything else, which is what makes deterministic extraction
942/// safe. An item that is stale or already fixed is declined there rather than
943/// judged here.
944pub fn decompose(cfg: &Config, repo: &Repo, tracker: i64) -> Vec<i64> {
945    let Some((body, slug)) = read_for_write(repo, tracker) else {
946        return Vec::new();
947    };
948    let steps = plan(repo, cfg, tracker, &body, &slug);
949    if steps.is_empty() {
950        logdim!("#{tracker} has no unchecked checklist items, so there is nothing to extract");
951        return Vec::new();
952    }
953    log!("#{tracker}: {} unchecked checklist item(s)", steps.len());
954    report_overflow(cfg, tracker, &steps);
955    report_skipped(tracker, &body);
956    apply(repo, tracker, &steps)
957}
958
959fn apply(repo: &Repo, tracker: i64, steps: &[Step]) -> Vec<i64> {
960    let mut children = Vec::new();
961    for step in steps {
962        let what = style::clip(&style::one_line(&step.item.text), 80);
963        match &step.action {
964            Action::Hold(why) => logdim!("  left '{what}' alone: {why}"),
965            // Already named by `report_overflow`, in one line rather than one
966            // line each.
967            Action::Over => {}
968            Action::Adopt(number) => {
969                log!("  '{what}' is already #{number}");
970                children.push(*number);
971            }
972            Action::Tick(number) => {
973                let Some(change) = step.action.change() else {
974                    continue;
975                };
976                if write(repo, tracker, &step.item.raw, &change, None) {
977                    log!("  ticked '{what}' off, #{number} is finished");
978                }
979            }
980            // Both titles, always. The match is fuzzy, and a wrong adoption
981            // puts a false claim in somebody's plan.
982            Action::Link {
983                number,
984                title,
985                open,
986            } => {
987                log!("  linking '{what}' to #{number} '{title}', filed nothing");
988                let Some(change) = step.action.change() else {
989                    continue;
990                };
991                if write(repo, tracker, &step.item.raw, &change, None) && *open {
992                    children.push(*number);
993                }
994            }
995            Action::File => {
996                let Ok(title) = repo.clean_nonempty_title_for_write(&step.item.text) else {
997                    logdim!("  could not clean a title out of '{what}'");
998                    continue;
999                };
1000                // Asked again, against the tracker as it stands, because filing
1001                // is the one step that leaves something behind. An item
1002                // somebody deleted while the run was working must not still get
1003                // an issue for work the tracker no longer asks for.
1004                if !still_asked_for(repo, tracker, &step.item.raw) {
1005                    logdim!("  '{what}' is no longer in #{tracker}, so nothing was filed for it");
1006                    continue;
1007                }
1008                match review::file_as_issue_apart_from(
1009                    repo,
1010                    &title,
1011                    &child_body(&step.item.text, tracker),
1012                    Some(tracker),
1013                ) {
1014                    Ok(filed) => {
1015                        let number = filed.issue();
1016                        log!("  {} for '{what}'", filed.note());
1017                        // The link is written the moment the issue exists, not
1018                        // once at the end over the whole checklist. The window
1019                        // is then one item wide and falls on the side of filing
1020                        // twice rather than losing a link, which the similarity
1021                        // search catches next run like any other duplicate.
1022                        let linked = write(
1023                            repo,
1024                            tracker,
1025                            &step.item.raw,
1026                            &Change::Reference(format!("#{number}")),
1027                            None,
1028                        );
1029                        match linked {
1030                            true if filed.number().is_some() => children.push(number),
1031                            true => {}
1032                            false => logwarn!(
1033                                "  '{what}' went to #{number}, but #{tracker} does not link to it"
1034                            ),
1035                        }
1036                    }
1037                    Err(e) => logdim!("  could not file an issue for '{what}': {e}"),
1038                }
1039            }
1040        }
1041    }
1042    unique_children(children)
1043}
1044
1045fn unique_children(mut children: Vec<i64>) -> Vec<i64> {
1046    let mut seen = BTreeSet::new();
1047    children.retain(|number| seen.insert(*number));
1048    children
1049}
1050
1051/// Whether the tracker still carries this exact line, once and only once, and
1052/// still reads it as a checklist item.
1053///
1054/// Both halves, because an edit that lands mid-run can leave the bytes exactly
1055/// as they were and still change what they mean. Fencing the line, or opening
1056/// a comment above it, is a person saying not this one, and matching the raw
1057/// text alone would file for it and rewrite it inside the fence.
1058fn still_an_item(body: &str, raw: &str) -> bool {
1059    let lines = split_keep(body)
1060        .into_iter()
1061        .filter(|line| without_eol(line) == raw)
1062        .count();
1063    lines == 1 && parse(body).iter().filter(|item| item.raw == raw).count() == 1
1064}
1065
1066/// Why `still_an_item` said no, for the log line.
1067fn why_not_an_item(body: &str, raw: &str) -> String {
1068    let lines = split_keep(body)
1069        .into_iter()
1070        .filter(|line| without_eol(line) == raw)
1071        .count();
1072    match lines {
1073        0 => "that line changed or is gone".to_string(),
1074        1 => "that line is no longer a checklist item in it".to_string(),
1075        n => format!("that line appears {n} times, so the edit could go to either"),
1076    }
1077}
1078
1079/// The unchecked task list items in an issue body, which a run treats as the
1080/// issue's acceptance criteria. Parsed from the whole body rather than from the
1081/// prompt's copy, which may be shortened.
1082pub fn criteria(body: &str) -> Vec<Item> {
1083    parse(body)
1084        .into_iter()
1085        .filter(|item| !item.checked)
1086        .collect()
1087}
1088
1089/// The same question, asked of the tracker as it stands.
1090///
1091/// Unreadable counts as no: this gates filing, and an issue filed against a
1092/// tracker that cannot be read is one nothing will link.
1093fn still_asked_for(repo: &Repo, tracker: i64, raw: &str) -> bool {
1094    match repo.record_failed_write(repo.read_issue(tracker)) {
1095        Ok(issue) => still_an_item(issue.body_text(), raw),
1096        Err(e) => {
1097            logdim!("  could not re-read #{tracker}: {}", e.first_line());
1098            false
1099        }
1100    }
1101}
1102
1103/// Re-read, rewrite one line, write back.
1104///
1105/// The body is read again here rather than reused from the copy this run
1106/// parsed. A run is long, and somebody editing the tracker while it goes must
1107/// not lose that edit: if the line has moved or changed, or the markdown around
1108/// it has stopped making it an item, this is a skip with a log line, never a
1109/// write. `at`, when given, is the 1-based line it has to still be on.
1110pub(crate) fn write(
1111    repo: &Repo,
1112    tracker: i64,
1113    raw: &str,
1114    change: &Change,
1115    at: Option<usize>,
1116) -> bool {
1117    let body = match repo.record_failed_write(repo.read_issue(tracker)) {
1118        Ok(issue) => issue.body_text().to_string(),
1119        Err(e) => {
1120            logdim!("  could not re-read #{tracker}: {}", e.first_line());
1121            return false;
1122        }
1123    };
1124    if !still_an_item(&body, raw) {
1125        logdim!("  not editing #{tracker}: {}", why_not_an_item(&body, raw));
1126        return false;
1127    }
1128    if let Some(at) = at {
1129        let now = split_keep(&body)
1130            .iter()
1131            .position(|line| without_eol(line) == raw)
1132            .map_or(0, |index| index + 1);
1133        if now != at {
1134            logdim!("  not editing #{tracker}: that line moved from line {at} to line {now}");
1135            return false;
1136        }
1137    }
1138    let updated = match rewrite(&body, raw, change) {
1139        Ok(updated) => updated,
1140        Err(e) => {
1141            logdim!("  not editing #{tracker}: {}", e.first_line());
1142            return false;
1143        }
1144    };
1145    match repo.edit_issue_body(tracker, &body, &updated, change.inserted()) {
1146        Ok(()) => true,
1147        Err(e) => {
1148            logdim!("  could not edit #{tracker}: {}", e.first_line());
1149            false
1150        }
1151    }
1152}
1153
1154/// Name every checkbox line the parser would not touch, and why.
1155///
1156/// The safe direction, but silent: an item inside a code fence, a raw HTML
1157/// block, an HTML comment, or a block quote is left alone, and until now
1158/// nothing said so.
1159fn report_skipped(tracker: i64, body: &str) {
1160    for skip in parse_reporting(body).1 {
1161        logdim!(
1162            "  left line {} of #{tracker} alone, '{}': {}",
1163            skip.line,
1164            style::clip(&style::one_line(&skip.text), 60),
1165            skip.why
1166        );
1167    }
1168}
1169
1170fn report_overflow(cfg: &Config, tracker: i64, steps: &[Step]) {
1171    let left: Vec<String> = steps
1172        .iter()
1173        .filter(|s| s.action == Action::Over)
1174        .map(|s| format!("'{}'", style::clip(&style::one_line(&s.item.text), 60)))
1175        .collect();
1176    if left.is_empty() {
1177        return;
1178    }
1179    logwarn!(
1180        "#{tracker} has more unchecked items than max_tracker_children ({}), so {} were left for \
1181         a later run: {}",
1182        cfg.loop_cfg.max_tracker_children,
1183        left.len(),
1184        left.join(", ")
1185    );
1186}
1187
1188fn read(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1189    // From the API, never from what triage rendered: `body_for_prompt`
1190    // shortens past `max_issue_chars`, and a tracker is precisely the long
1191    // issue that trips it. Parsing a shortened body drops the last items and
1192    // looks identical to a tracker that had fewer.
1193    match repo.read_issue(tracker) {
1194        Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1195        Err(e) => {
1196            logdim!("could not read #{tracker}: {}", e.first_line());
1197            None
1198        }
1199    }
1200}
1201
1202fn read_for_write(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1203    match repo.record_failed_write(repo.read_issue(tracker)) {
1204        Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1205        Err(e) => {
1206            logdim!("could not read #{tracker}: {}", e.first_line());
1207            None
1208        }
1209    }
1210}
1211
1212/// This repository's address, taken from the tracker's own url by dropping the
1213/// `/issues/29` off the end.
1214///
1215/// Read rather than assembled from `owner/repo`, because the host is half the
1216/// answer: a link to `elsewhere.example/me/mine/issues/7` shares the path and
1217/// is not this repository's issue. Empty when there is no url to read, which
1218/// holds every linked item rather than guessing at one.
1219fn home_of(url: &str) -> String {
1220    match url.rfind("/issues/") {
1221        Some(at) => url[..at].to_string(),
1222        None => String::new(),
1223    }
1224}
1225
1226// ---------------------------------------------------------------------------
1227// The read only half
1228// ---------------------------------------------------------------------------
1229
1230/// Print the decomposition `spar run` would perform, and write none of it.
1231///
1232/// `spar triage` is the command you reach for to look before leaping, so a
1233/// preview that files issues and rewrites somebody's issue body is exactly the
1234/// trap that comment names. This is where the first few real trackers should be
1235/// checked.
1236pub fn preview(cfg: &Config, repo: &Repo, tracker: i64) {
1237    let Some((body, slug)) = read(repo, tracker) else {
1238        return;
1239    };
1240    let steps = plan(repo, cfg, tracker, &body, &slug);
1241    if steps.is_empty() {
1242        return;
1243    }
1244    println!("\n#{tracker}, if decompose_trackers let it act on the checklist:");
1245    report_skipped(tracker, &body);
1246
1247    let mut projected = body.clone();
1248    for step in &steps {
1249        let what = style::clip(&style::one_line(&step.item.text), 80);
1250        match &step.action {
1251            Action::Adopt(number) => println!("  keep  '{what}' is already #{number}"),
1252            Action::Tick(number) => println!("  tick  '{what}', #{number} is finished"),
1253            Action::Link {
1254                number,
1255                title,
1256                open,
1257            } => {
1258                let state = if *open { "open" } else { "closed" };
1259                println!("  link  '{what}' to #{number} '{title}' ({state}), filing nothing");
1260            }
1261            Action::File => println!("  file  '{what}'"),
1262            Action::Over => println!("  over  '{what}' is past max_tracker_children"),
1263            Action::Hold(why) => println!("  hold  '{what}': {why}"),
1264        }
1265        // The same mapping the acting path uses, so what is printed here and
1266        // what a run would write cannot drift. Only `File` is the preview's own,
1267        // since the issue it would link to does not exist yet.
1268        let change = match &step.action {
1269            Action::File => Some(Change::Reference(FILED.to_string())),
1270            other => other.change(),
1271        };
1272        let Some(change) = change else { continue };
1273        match rewrite(&projected, &step.item.raw, &change) {
1274            Ok(next) => projected = next,
1275            Err(e) => println!("        the line will not be rewritten: {e}"),
1276        }
1277    }
1278
1279    let diff = diff(&body, &projected);
1280    if diff.is_empty() {
1281        println!("  nothing would be written to the body");
1282    } else {
1283        println!("  and the body it would write:");
1284        for line in diff {
1285            println!("    {line}");
1286        }
1287    }
1288}
1289
1290/// Stands in for a number that does not exist yet, so the preview can show the
1291/// line an item would get without pretending to know which issue it will be.
1292const FILED: &str = "#(the issue it files)";
1293
1294/// The changed lines, old then new. Line local edits only, so the two bodies
1295/// always have the same number of lines and nothing has to be aligned.
1296fn diff(before: &str, after: &str) -> Vec<String> {
1297    split_keep(before)
1298        .into_iter()
1299        .zip(split_keep(after))
1300        .filter(|(old, new)| old != new)
1301        .flat_map(|(old, new)| {
1302            [
1303                format!("- {}", without_eol(old)),
1304                format!("+ {}", without_eol(new)),
1305            ]
1306        })
1307        .collect()
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use super::*;
1313
1314    #[test]
1315    fn a_criterion_line_that_moved_out_or_doubled_is_not_ticked_and_says_why() {
1316        let raw = "- [ ] Preserve column order.";
1317        let twice = "- [ ] Preserve column order.\n- [ ] Preserve column order.\n";
1318        assert!(!still_an_item(twice, raw));
1319        assert!(why_not_an_item(twice, raw).contains("appears 2 times"));
1320        let changed = "- [ ] Preserve the column order.\n";
1321        assert!(!still_an_item(changed, raw));
1322        assert!(why_not_an_item(changed, raw).contains("changed or is gone"));
1323        let fenced = "```\n- [ ] Preserve column order.\n```\n";
1324        assert!(!still_an_item(fenced, raw));
1325        assert!(why_not_an_item(fenced, raw).contains("no longer a checklist item"));
1326        // Ticked by somebody already: nothing to do, and never an untick.
1327        assert!(rewrite(
1328            "- [x] Preserve column order.\n",
1329            "- [x] Preserve column order.",
1330            &Change::Tick
1331        )
1332        .is_err());
1333    }
1334
1335    #[test]
1336    fn a_tick_leaves_every_other_byte_alone() {
1337        let body = "Intro\r\n- [ ] Preserve column order.\r\n- [ ] Connects on a device\r\n";
1338        let ticked = rewrite(body, "- [ ] Preserve column order.", &Change::Tick).unwrap();
1339        assert_eq!(
1340            "Intro\r\n- [x] Preserve column order.\r\n- [ ] Connects on a device\r\n",
1341            ticked
1342        );
1343        assert_eq!(2, criteria(body).len());
1344        assert_eq!(1, criteria(&ticked).len());
1345    }
1346
1347    /// Where the tests' own repository lives, as `read` would work it out.
1348    const HOME: &str = "https://github.com/me/mine";
1349
1350    fn texts(body: &str) -> Vec<String> {
1351        parse(body).into_iter().map(|i| i.text).collect()
1352    }
1353
1354    #[test]
1355    fn the_ordinary_checklist_is_read_as_items() {
1356        let items = parse("Some prose.\n\n- [ ] first\n- [x] second\n");
1357        assert_eq!(2, items.len());
1358        assert_eq!("first", items[0].text);
1359        assert!(!items[0].checked);
1360        assert!(items[1].checked);
1361        assert_eq!(3, items[0].line);
1362    }
1363
1364    /// Nested and indented items are ordinary items: every edit here is line
1365    /// local, so the shape of the list around one does not matter.
1366    #[test]
1367    fn indented_and_nested_items_are_items() {
1368        let body = "- [ ] parent\n  - [ ] child\n\t- [ ] tabbed\n    * [ ] deeper\n1. [ ] ordered\n2) [ ] also ordered\n";
1369        assert_eq!(
1370            vec![
1371                "parent",
1372                "child",
1373                "tabbed",
1374                "deeper",
1375                "ordered",
1376                "also ordered"
1377            ],
1378            texts(body)
1379        );
1380    }
1381
1382    /// The failure that would file issues for somebody's example markdown.
1383    #[test]
1384    fn something_that_looks_like_an_item_inside_a_fence_is_not_one() {
1385        let body = "\
1386- [ ] real
1387
1388```markdown
1389- [ ] not real
1390```
1391
1392~~~
1393- [ ] also not real
1394~~~
1395
1396- [ ] real again
1397";
1398        assert_eq!(vec!["real", "real again"], texts(body));
1399    }
1400
1401    /// Four spaces outside a list is a code block on GitHub, and filing an
1402    /// issue for somebody's example is the failure the fence check exists to
1403    /// prevent.
1404    #[test]
1405    fn an_indented_code_block_is_not_a_checklist() {
1406        let body = "\
1407Write the parts like this:
1408
1409    - [ ] an example, not an item
1410
1411- [ ] real
1412  - [ ] nested
1413- plain bullet
1414    - [ ] nested under a bullet
1415";
1416        assert_eq!(vec!["real", "nested", "nested under a bullet"], texts(body));
1417    }
1418
1419    /// Four spaces is measured from the enclosing item's content, not from the
1420    /// margin: under `- outer` the content starts in column 2, so six spaces
1421    /// are an example inside that item and two are a nested task.
1422    #[test]
1423    fn code_indented_inside_a_list_item_is_still_code() {
1424        let body = "\
1425- outer
1426
1427      - [ ] an example, not an item
1428
1429  - [ ] nested
1430- plain
1431    - [ ] nested under a bullet
1432        - [ ] and under that one
1433";
1434        assert_eq!(
1435            vec!["nested", "nested under a bullet", "and under that one"],
1436            texts(body)
1437        );
1438    }
1439
1440    /// GitHub renders the inside of these verbatim, so a checkbox in one is
1441    /// text somebody is showing.
1442    #[test]
1443    fn an_item_inside_raw_html_is_not_one() {
1444        let body = "\
1445- [ ] real
1446
1447<pre>
1448- [ ] not real
1449</pre>
1450
1451<textarea>
1452- [ ] also not real
1453</textarea>
1454
1455- [ ] real again
1456";
1457        assert_eq!(vec!["real", "real again"], texts(body));
1458    }
1459
1460    /// Markdown inside a block tag is not markdown: GitHub prints the checkbox
1461    /// as the text it is. The block ends at a blank line and not at the closing
1462    /// tag, which is what keeps the `<details>` a tracker is often written in
1463    /// working.
1464    #[test]
1465    fn an_item_inside_a_block_tag_is_not_one() {
1466        let body = "\
1467<div>
1468- [ ] not real
1469</div>
1470
1471<details>
1472<summary>the parts</summary>
1473
1474- [ ] real
1475</details>
1476";
1477        assert_eq!(vec!["real"], texts(body));
1478    }
1479
1480    /// More than four columns after the marker put the checkbox in an indented
1481    /// code block inside the item, which is how somebody writes down the syntax
1482    /// itself.
1483    #[test]
1484    fn a_checkbox_pushed_past_its_own_content_column_is_code() {
1485        assert_eq!(Vec::<String>::new(), texts("-     [ ] an example\n"));
1486        assert_eq!(vec!["real"], texts("-    [ ] real\n"));
1487    }
1488
1489    /// A fence four columns in is a fence somebody is showing, so it neither
1490    /// opens a block nor closes the one it sits in.
1491    #[test]
1492    fn a_fence_indented_into_code_neither_opens_nor_closes() {
1493        let body = "```\n- [ ] not real\n    ```\n- [ ] still not real\n";
1494        assert_eq!(Vec::<String>::new(), texts(body));
1495
1496        let body = "Like this:\n\n    ```\n- [ ] real\n";
1497        assert_eq!(vec!["real"], texts(body));
1498    }
1499
1500    /// The items somebody decided against are often kept in a comment. GitHub
1501    /// renders none of it, so neither does this.
1502    #[test]
1503    fn an_item_inside_an_html_comment_is_not_one() {
1504        let body = "\
1505- [ ] real
1506
1507<!--
1508- [ ] not real
1509-->
1510
1511- [ ] real again
1512<!-- - [ ] on one line, closed -->
1513- [ ] last
1514";
1515        assert_eq!(vec!["real", "real again", "last"], texts(body));
1516    }
1517
1518    /// A fence closes on its own glyph only, so a stray one of the other kind
1519    /// inside does not end the block early.
1520    #[test]
1521    fn a_fence_is_closed_only_by_its_own_kind() {
1522        let body = "~~~\n```\n- [ ] not real\n```\n~~~\n- [ ] real\n";
1523        assert_eq!(vec!["real"], texts(body));
1524    }
1525
1526    #[test]
1527    fn a_windows_body_is_read_the_same_way() {
1528        let items = parse("intro\r\n\r\n- [ ] first\r\n- [x] second\r\n");
1529        assert_eq!(2, items.len());
1530        assert_eq!("first", items[0].text);
1531        assert!(items[1].checked);
1532        assert_eq!(
1533            "- [ ] first", items[0].raw,
1534            "the terminator is not part of the handle"
1535        );
1536    }
1537
1538    #[test]
1539    fn a_reference_is_read_from_a_number_or_a_link() {
1540        let items = parse(
1541            "- [ ] one #12\n\
1542             - [ ] two https://github.com/o/r/issues/34\n\
1543             - [ ] [three](https://github.com/o/r/issues/56)\n\
1544             - [ ] four\n",
1545        );
1546        assert_eq!(Some(12), items[0].reference.as_ref().map(|r| r.number));
1547        assert_eq!(Some(34), items[1].reference.as_ref().map(|r| r.number));
1548        assert_eq!(Some(56), items[2].reference.as_ref().map(|r| r.number));
1549        assert_eq!(None, items[3].reference);
1550    }
1551
1552    /// An item is very often written down as the change that closes it.
1553    /// `resolve` answers for a pull request already, so reading only `/issues/`
1554    /// would file a second issue for work in flight.
1555    #[test]
1556    fn a_link_to_a_pull_request_is_a_reference_too() {
1557        let items = parse(
1558            "- [ ] one https://github.com/me/mine/pull/42\n\
1559             - [ ] two https://github.com/me/mine/pull/43/files\n\
1560             - [ ] three https://github.com/other/thing/pull/44\n",
1561        );
1562        assert_eq!(Some(42), items[0].reference.as_ref().unwrap().local(HOME));
1563        assert_eq!(Some(43), items[1].reference.as_ref().unwrap().local(HOME));
1564        assert_eq!(None, items[2].reference.as_ref().unwrap().local(HOME));
1565    }
1566
1567    /// An item whose whole text is a link to somewhere else is not a reference
1568    /// to anything spar can act on.
1569    #[test]
1570    fn an_item_that_is_a_link_to_something_else_names_no_issue() {
1571        let items = parse("- [ ] [the docs](https://example.com/guide)\n");
1572        assert_eq!(None, items[0].reference);
1573        assert_eq!("[the docs](https://example.com/guide)", items[0].text);
1574    }
1575
1576    /// Taking the number out of another repository's link would point the item
1577    /// at whatever happens to carry that number here.
1578    #[test]
1579    fn a_link_to_another_repository_is_not_adoptable() {
1580        let items = parse("- [ ] see https://github.com/other/thing/issues/7\n");
1581        let reference = items[0].reference.as_ref().expect("a reference");
1582        assert_eq!(None, reference.local(HOME));
1583        assert_eq!(Some(7), reference.local("https://github.com/other/thing"));
1584    }
1585
1586    /// A bare number can only mean this repository, so it needs no slug.
1587    #[test]
1588    fn a_bare_number_resolves_wherever_it_is_read() {
1589        let items = parse("- [ ] work #7\n");
1590        assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(""));
1591    }
1592
1593    /// The path is half the answer. Another host serving `me/mine` is somebody
1594    /// else's, and adopting it would tick a local issue nobody named.
1595    #[test]
1596    fn a_link_to_the_same_path_on_another_host_is_not_this_repository() {
1597        for url in [
1598            "https://gitlab.example/me/mine/issues/7",
1599            "https://github.com/mirror/me/mine/issues/7",
1600        ] {
1601            let items = parse(&format!("- [ ] see {url}\n"));
1602            let reference = items[0].reference.as_ref().expect("a reference");
1603            assert_eq!(None, reference.local(HOME), "{url}");
1604        }
1605    }
1606
1607    /// http and https to the same issue are the same issue.
1608    #[test]
1609    fn the_scheme_is_not_what_makes_a_link_somebody_elses() {
1610        let items = parse("- [ ] see http://github.com/me/mine/issues/7\n");
1611        assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(HOME));
1612    }
1613
1614    /// An issue url with the tail taken off is the address every other link is
1615    /// measured against.
1616    #[test]
1617    fn home_is_read_off_the_trackers_own_url() {
1618        assert_eq!(HOME, home_of("https://github.com/me/mine/issues/29"));
1619        assert_eq!("", home_of(""));
1620    }
1621
1622    /// A number in backticks is somebody writing about it, and the reference
1623    /// they meant is the one outside.
1624    #[test]
1625    fn a_number_in_a_code_span_names_nothing() {
1626        let items = parse(
1627            "- [ ] Handle the literal `#12`, tracked in #34\n\
1628             - [ ] Only ``a #12 in a double span``\n",
1629        );
1630        assert_eq!(Some(34), items[0].reference.as_ref().map(|r| r.number));
1631        assert_eq!(None, items[1].reference);
1632    }
1633
1634    /// A comment is not rendered, so a number left in one is a note to a person
1635    /// and never the issue the item is about. Ticking the box because that
1636    /// issue happens to be closed would call somebody's work done.
1637    #[test]
1638    fn a_number_in_a_comment_names_nothing() {
1639        let items = parse(
1640            "- [ ] ship it <!-- old note: #7 -->\n\
1641             - [ ] and this one <!-- #7 --> #8\n",
1642        );
1643        assert_eq!(None, items[0].reference);
1644        assert_eq!(Some(8), items[1].reference.as_ref().map(|r| r.number));
1645    }
1646
1647    /// The `#8` in an address is a fragment of it. Only a link that names an
1648    /// issue by path is read as one.
1649    #[test]
1650    fn a_fragment_in_a_link_is_not_an_issue_number() {
1651        let items = parse(
1652            "- [ ] update [docs](https://example.com/guide/#8)\n\
1653             - [ ] see https://example.com/guide#9 and #10\n",
1654        );
1655        assert_eq!(None, items[0].reference);
1656        assert_eq!(Some(10), items[1].reference.as_ref().map(|r| r.number));
1657    }
1658
1659    /// GitHub links both of these without a url, so an item that carries one is
1660    /// an item that already names its issue. Reading neither filed a second
1661    /// issue for work the tracker had already written down.
1662    #[test]
1663    fn the_shorthands_github_links_are_references_too() {
1664        let items = parse(
1665            "- [ ] one me/mine#12\n\
1666             - [ ] two other/thing#13\n\
1667             - [ ] three GH-14\n",
1668        );
1669        assert_eq!(Some(12), items[0].reference.as_ref().unwrap().local(HOME));
1670        let foreign = items[1].reference.as_ref().expect("a reference");
1671        assert_eq!(None, foreign.local(HOME), "somebody else's repository");
1672        assert_eq!("other/thing#13", foreign.names());
1673        assert_eq!(Some(14), items[2].reference.as_ref().unwrap().local(HOME));
1674    }
1675
1676    /// The shorthand leaves the host out, so it means this repository wherever
1677    /// this repository is served from. The path still has to be this one's.
1678    #[test]
1679    fn a_shorthand_is_read_against_this_repositorys_path() {
1680        let items = parse("- [ ] work me/mine#7\n");
1681        let reference = items[0].reference.as_ref().expect("a reference");
1682        assert_eq!(Some(7), reference.local("https://ghe.example/me/mine"));
1683        assert_eq!(None, reference.local("https://github.com/me/other"));
1684        assert_eq!(None, reference.local(""), "no address to measure against");
1685    }
1686
1687    /// A link's label captions its destination. Reading the label first turned
1688    /// another repository's issue into a bare local number, which is exactly
1689    /// the adoption the foreign repository check exists to refuse.
1690    #[test]
1691    fn a_link_is_read_from_its_destination_and_not_its_label() {
1692        let items = parse(
1693            "- [ ] [other/widgets #7](https://github.com/other/widgets/issues/7)\n\
1694             - [ ] [me/mine #7](https://github.com/me/mine/issues/7)\n",
1695        );
1696        let foreign = items[0].reference.as_ref().expect("a reference");
1697        assert!(
1698            matches!(foreign.origin, Origin::Url(_)),
1699            "the destination, not the label"
1700        );
1701        assert_eq!(None, foreign.local(HOME));
1702        assert_eq!(Some(7), items[1].reference.as_ref().unwrap().local(HOME));
1703    }
1704
1705    /// Nested brackets and escaped closing brackets are both valid inside a
1706    /// link label. Stopping at either one exposes the label's local-looking
1707    /// number and hides the foreign destination.
1708    #[test]
1709    fn complex_link_labels_still_read_the_destination() {
1710        let items = parse(
1711            "- [ ] [see [#7]](https://github.com/other/widgets/issues/8)\n\
1712             - [ ] [see \\] #7](https://github.com/other/widgets/issues/8)\n",
1713        );
1714        for item in items {
1715            let reference = item.reference.expect("the destination");
1716            assert_eq!(8, reference.number);
1717            assert!(matches!(reference.origin, Origin::Url(_)));
1718            assert_eq!(None, reference.local(HOME));
1719        }
1720    }
1721
1722    #[test]
1723    fn one_child_referenced_by_several_items_is_worked_once() {
1724        assert_eq!(vec![8, 9], unique_children(vec![8, 8, 9, 8]));
1725    }
1726
1727    // -- the line surgery -------------------------------------------------
1728
1729    #[test]
1730    fn a_reference_is_appended_to_its_own_line_and_nowhere_else() {
1731        let body = "intro\n\n- [ ] first\n- [ ] second\n\nmore prose\n";
1732        let out =
1733            rewrite(body, "- [ ] first", &Change::Reference("#40".into())).expect("a rewrite");
1734        assert_eq!(
1735            "intro\n\n- [ ] first #40\n- [ ] second\n\nmore prose\n",
1736            out
1737        );
1738    }
1739
1740    /// Two trailing spaces are a markdown hard break, and `style::scrub` would
1741    /// eat them. The reference goes before them.
1742    #[test]
1743    fn a_hard_break_survives_the_edit() {
1744        let out = rewrite(
1745            "- [ ] first  \nnext\n",
1746            "- [ ] first  ",
1747            &Change::Reference("#4".into()),
1748        )
1749        .expect("a rewrite");
1750        assert_eq!("- [ ] first #4  \nnext\n", out);
1751    }
1752
1753    #[test]
1754    fn every_other_line_comes_through_byte_identical() {
1755        let body = "# Plan\r\n\r\n  trailing spaces here   \r\n- [ ] one\r\n\r\n\r\n\r\nlots of blank lines above\r\n";
1756        let out = rewrite(body, "- [ ] one", &Change::Reference("#9".into())).expect("a rewrite");
1757        let (before, after): (Vec<&str>, Vec<&str>) =
1758            (body.lines().collect(), out.lines().collect());
1759        assert_eq!(before.len(), after.len());
1760        for (i, (a, b)) in before.iter().zip(&after).enumerate() {
1761            if i == 3 {
1762                assert_eq!("- [ ] one #9", *b);
1763            } else {
1764                assert_eq!(a, b, "line {} changed", i + 1);
1765            }
1766        }
1767        assert!(out.contains("trailing spaces here   \r\n"));
1768        assert!(out.contains("\r\n\r\n\r\n\r\n"));
1769    }
1770
1771    #[test]
1772    fn a_body_with_no_final_newline_keeps_not_having_one() {
1773        let out = rewrite("- [ ] only", "- [ ] only", &Change::Tick).expect("a rewrite");
1774        assert_eq!("- [x] only", out);
1775    }
1776
1777    #[test]
1778    fn ticking_changes_the_box_and_leaves_the_text() {
1779        let out = rewrite("  - [ ] deep #3\n", "  - [ ] deep #3", &Change::Tick).expect("a tick");
1780        assert_eq!("  - [x] deep #3\n", out);
1781    }
1782
1783    /// spar checks a box and never unchecks one, so there is no change that
1784    /// could and nothing to do to one that is already ticked.
1785    #[test]
1786    fn a_ticked_box_is_never_written_again() {
1787        assert!(rewrite("- [x] done\n", "- [x] done", &Change::Tick).is_err());
1788        assert!(!matches!(Change::Tick, Change::Reference(_)));
1789    }
1790
1791    #[test]
1792    fn a_line_that_is_gone_or_ambiguous_is_a_refusal_not_a_guess() {
1793        assert!(rewrite("- [ ] a\n", "- [ ] b", &Change::Tick).is_err());
1794        let twice = "- [ ] same\n- [ ] same\n";
1795        assert!(rewrite(twice, "- [ ] same", &Change::Tick).is_err());
1796    }
1797
1798    #[test]
1799    fn an_item_with_no_text_gets_no_reference() {
1800        assert!(rewrite("- [ ]\n", "- [ ]", &Change::Reference("#1".into())).is_err());
1801    }
1802
1803    // -- shaping ----------------------------------------------------------
1804
1805    fn shapes(body: &str, max: usize) -> Vec<Shape> {
1806        shape(body, HOME, max).into_iter().map(|(_, s)| s).collect()
1807    }
1808
1809    #[test]
1810    fn a_checked_item_is_never_reconsidered() {
1811        assert!(shapes("- [x] done\n", 5).is_empty());
1812    }
1813
1814    #[test]
1815    fn an_item_that_names_an_issue_is_kept_apart_from_one_that_does_not() {
1816        assert_eq!(
1817            vec![Shape::Names(12), Shape::Needs],
1818            shapes("- [ ] one #12\n- [ ] two\n", 5)
1819        );
1820    }
1821
1822    /// A cap, not a target, and what it left is named out loud rather than
1823    /// quietly dropped.
1824    #[test]
1825    fn the_cap_stops_at_the_cap() {
1826        let body = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d\n";
1827        assert_eq!(
1828            vec![Shape::Needs, Shape::Needs, Shape::Over, Shape::Over],
1829            shapes(body, 2)
1830        );
1831    }
1832
1833    /// A tick means the work landed, and a decline is not that.
1834    ///
1835    /// With `close_skipped = true`, the default, an item whose issue both
1836    /// agents declined is closed as not planned. Ticking it writes "this was
1837    /// done" into somebody's checklist, and since spar never unchecks a box,
1838    /// correcting it is left to them.
1839    #[test]
1840    fn an_item_closed_as_not_planned_is_not_ticked() {
1841        let issue = |state: &str, reason: Option<&str>| Issue {
1842            number: 12,
1843            title: "migrate the cache to v2".into(),
1844            body: None,
1845            state: state.into(),
1846            state_reason: reason.map(str::to_string),
1847            url: String::new(),
1848            labels: Vec::new(),
1849            author: None,
1850            comments: Vec::new(),
1851        };
1852
1853        assert!(matches!(
1854            issue_action(&issue("CLOSED", Some("not_planned"))),
1855            Action::Hold(_)
1856        ));
1857        assert_eq!(
1858            Action::Tick(12),
1859            issue_action(&issue("CLOSED", Some("completed")))
1860        );
1861        assert_eq!(
1862            Action::Tick(12),
1863            issue_action(&issue("CLOSED", None)),
1864            "an issue closed before GitHub recorded a reason still ticks"
1865        );
1866        assert_eq!(Action::Adopt(12), issue_action(&issue("OPEN", None)));
1867    }
1868
1869    /// The cap is on issues spar files, and a linked item needs none.
1870    ///
1871    /// Counting linked items meant five open linked items with
1872    /// max_tracker_children = 5 left a sixth unlinked item Over on every run,
1873    /// and closed items past the cap were never ticked at all.
1874    #[test]
1875    fn the_cap_counts_only_the_items_that_need_an_issue_filed() {
1876        let body = "- [ ] a #1\n- [ ] b #2\n- [ ] c\n- [ ] d\n";
1877        assert_eq!(
1878            vec![Shape::Names(1), Shape::Names(2), Shape::Needs, Shape::Over],
1879            shapes(body, 1)
1880        );
1881    }
1882
1883    /// An inline `<!--` renders as text on GitHub, so treating it as an opener
1884    /// skipped every item after it with nothing said.
1885    #[test]
1886    fn an_inline_comment_marker_does_not_hide_the_rest_of_the_list() {
1887        let body = "- [ ] document the `<!--` syntax\n- [ ] the next one\n";
1888        assert_eq!(vec![Shape::Needs, Shape::Needs], shapes(body, 5));
1889
1890        // A real opener still opens: it starts the line.
1891        let body = "<!-- notes\n- [ ] hidden\n-->\n- [ ] visible\n";
1892        assert_eq!(vec![Shape::Needs], shapes(body, 5));
1893    }
1894
1895    /// A line spar will not rewrite is skipped with a reason, and these had
1896    /// none.
1897    #[test]
1898    fn a_checkbox_spar_will_not_touch_says_why() {
1899        let body = "<!-- later\n- [ ] in a comment\n-->\n\n> - [ ] in a quote\n\n```\n- [ ] in a fence\n```\n";
1900        let reasons: Vec<&str> = parse_reporting(body).1.iter().map(|s| s.why).collect();
1901        assert_eq!(
1902            vec![
1903                "it is inside an HTML comment",
1904                "it is inside a block quote",
1905                "it is inside a code fence",
1906            ],
1907            reasons
1908        );
1909    }
1910
1911    /// A checked item does not spend the budget, since nothing is done to it.
1912    #[test]
1913    fn the_cap_counts_only_what_it_acts_on() {
1914        let body = "- [x] a\n- [x] b\n- [ ] c\n";
1915        assert_eq!(vec![Shape::Needs], shapes(body, 1));
1916    }
1917
1918    #[test]
1919    fn two_identical_items_are_left_alone() {
1920        let out = shapes("- [ ] same\n- [ ] same\n", 5);
1921        assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1922        assert!(matches!(out[1], Shape::Hold(_)), "{out:?}");
1923    }
1924
1925    #[test]
1926    fn an_item_naming_another_repository_is_held_rather_than_adopted() {
1927        let out = shapes("- [ ] see https://github.com/other/thing/issues/7\n", 5);
1928        assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1929    }
1930
1931    /// The whole thing on one body of the shape a person actually writes: what
1932    /// each item is taken for, and exactly what comes out the other side.
1933    #[test]
1934    fn a_realistic_tracker_keeps_every_line_it_was_not_asked_to_change() {
1935        let body = "\
1936Context somebody wrote, with a hard break here:
1937and the rest of it.
1938
1939## Parts
1940
1941- [x] already done
1942- [ ] parse the checklist
1943- [ ] write the link back #40
1944  - [ ] and prove it first
1945
1946```markdown
1947- [ ] an example, not an item
1948```
1949
1950That is all.
1951";
1952        let shapes: Vec<Shape> = shape(body, HOME, 5).into_iter().map(|(_, s)| s).collect();
1953        assert_eq!(
1954            vec![Shape::Needs, Shape::Names(40), Shape::Needs],
1955            shapes,
1956            "the ticked item, the fenced one and the prose are all left out"
1957        );
1958
1959        let out = rewrite(
1960            body,
1961            "- [ ] parse the checklist",
1962            &Change::Reference("#41".into()),
1963        )
1964        .expect("a link");
1965        let out = rewrite(
1966            &out,
1967            "  - [ ] and prove it first",
1968            &Change::Reference("#42".into()),
1969        )
1970        .expect("a nested link");
1971        let out = rewrite(&out, "- [ ] write the link back #40", &Change::Tick).expect("a tick");
1972
1973        assert_eq!(
1974            "\
1975Context somebody wrote, with a hard break here:
1976and the rest of it.
1977
1978## Parts
1979
1980- [x] already done
1981- [ ] parse the checklist #41
1982- [x] write the link back #40
1983  - [ ] and prove it first #42
1984
1985```markdown
1986- [ ] an example, not an item
1987```
1988
1989That is all.
1990",
1991            out
1992        );
1993    }
1994
1995    // -- what each decision writes ----------------------------------------
1996
1997    /// The match is fuzzy, so a wrong adoption that also ticked the box would
1998    /// be spar asserting work is done that nobody did. The link goes in this
1999    /// run and the tick waits for the next, by which time the item carries its
2000    /// own reference and a person has had the chance to see it.
2001    #[test]
2002    fn an_item_linked_by_similarity_is_not_ticked_in_the_same_run() {
2003        for open in [true, false] {
2004            let action = Action::Link {
2005                number: 7,
2006                title: "something close enough".into(),
2007                open,
2008            };
2009            assert_eq!(Some(Change::Reference("#7".into())), action.change());
2010        }
2011    }
2012
2013    /// An item that carried its reference before spar touched it is in no such
2014    /// doubt, so it is ticked on the spot.
2015    #[test]
2016    fn an_item_that_already_named_its_issue_is_ticked_when_that_issue_closes() {
2017        assert_eq!(Some(Change::Tick), Action::Tick(7).change());
2018    }
2019
2020    /// Adopting is reading, not writing: the line already says what it says.
2021    #[test]
2022    fn nothing_is_written_for_an_item_that_is_already_linked_and_open() {
2023        assert_eq!(None, Action::Adopt(7).change());
2024        assert_eq!(None, Action::Over.change());
2025        assert_eq!(None, Action::Hold("any reason".into()).change());
2026    }
2027
2028    // -- the guard before a write -----------------------------------------
2029
2030    /// The bytes of the line are not the whole of what it means. Somebody
2031    /// fencing an item mid-run is saying not this one, and the raw text is
2032    /// still there to match.
2033    #[test]
2034    fn a_line_that_stopped_being_an_item_is_not_written_to() {
2035        let raw = "- [ ] ship it";
2036        assert!(still_an_item("intro\n\n- [ ] ship it\n", raw));
2037        assert!(!still_an_item("```\n- [ ] ship it\n```\n", raw));
2038        assert!(!still_an_item("<!--\n- [ ] ship it\n-->\n", raw));
2039        assert!(!still_an_item("- [ ] something else\n", raw));
2040        assert!(
2041            !still_an_item("- [ ] ship it\n- [ ] ship it\n", raw),
2042            "two alike is a line the edit could go to either of"
2043        );
2044    }
2045
2046    // -- the preview ------------------------------------------------------
2047
2048    #[test]
2049    fn the_diff_shows_only_the_lines_that_change() {
2050        let before = "- [ ] one\n- [ ] two\n";
2051        let after =
2052            rewrite(before, "- [ ] two", &Change::Reference("#8".into())).expect("a rewrite");
2053        assert_eq!(
2054            vec!["- - [ ] two".to_string(), "+ - [ ] two #8".to_string()],
2055            diff(before, &after)
2056        );
2057    }
2058}