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) {
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) && *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                        );
1028                        match linked {
1029                            true if filed.number().is_some() => children.push(number),
1030                            true => {}
1031                            false => logwarn!(
1032                                "  '{what}' went to #{number}, but #{tracker} does not link to it"
1033                            ),
1034                        }
1035                    }
1036                    Err(e) => logdim!("  could not file an issue for '{what}': {e}"),
1037                }
1038            }
1039        }
1040    }
1041    unique_children(children)
1042}
1043
1044fn unique_children(mut children: Vec<i64>) -> Vec<i64> {
1045    let mut seen = BTreeSet::new();
1046    children.retain(|number| seen.insert(*number));
1047    children
1048}
1049
1050/// Whether the tracker still carries this exact line, once and only once, and
1051/// still reads it as a checklist item.
1052///
1053/// Both halves, because an edit that lands mid-run can leave the bytes exactly
1054/// as they were and still change what they mean. Fencing the line, or opening
1055/// a comment above it, is a person saying not this one, and matching the raw
1056/// text alone would file for it and rewrite it inside the fence.
1057fn still_an_item(body: &str, raw: &str) -> bool {
1058    let lines = split_keep(body)
1059        .into_iter()
1060        .filter(|line| without_eol(line) == raw)
1061        .count();
1062    lines == 1 && parse(body).iter().filter(|item| item.raw == raw).count() == 1
1063}
1064
1065/// The same question, asked of the tracker as it stands.
1066///
1067/// Unreadable counts as no: this gates filing, and an issue filed against a
1068/// tracker that cannot be read is one nothing will link.
1069fn still_asked_for(repo: &Repo, tracker: i64, raw: &str) -> bool {
1070    match repo.record_failed_write(repo.read_issue(tracker)) {
1071        Ok(issue) => still_an_item(issue.body_text(), raw),
1072        Err(e) => {
1073            logdim!("  could not re-read #{tracker}: {}", e.first_line());
1074            false
1075        }
1076    }
1077}
1078
1079/// Re-read, rewrite one line, write back.
1080///
1081/// The body is read again here rather than reused from the copy this run
1082/// parsed. A run is long, and somebody editing the tracker while it goes must
1083/// not lose that edit: if the line has moved or changed, or the markdown around
1084/// it has stopped making it an item, this is a skip with a log line, never a
1085/// write.
1086fn write(repo: &Repo, tracker: i64, raw: &str, change: &Change) -> bool {
1087    let body = match repo.record_failed_write(repo.read_issue(tracker)) {
1088        Ok(issue) => issue.body_text().to_string(),
1089        Err(e) => {
1090            logdim!("  could not re-read #{tracker}: {}", e.first_line());
1091            return false;
1092        }
1093    };
1094    if !still_an_item(&body, raw) {
1095        logdim!("  not editing #{tracker}: that line is no longer a checklist item in it");
1096        return false;
1097    }
1098    let updated = match rewrite(&body, raw, change) {
1099        Ok(updated) => updated,
1100        Err(e) => {
1101            logdim!("  not editing #{tracker}: {}", e.first_line());
1102            return false;
1103        }
1104    };
1105    match repo.edit_issue_body(tracker, &body, &updated, change.inserted()) {
1106        Ok(()) => true,
1107        Err(e) => {
1108            logdim!("  could not edit #{tracker}: {}", e.first_line());
1109            false
1110        }
1111    }
1112}
1113
1114/// Name every checkbox line the parser would not touch, and why.
1115///
1116/// The safe direction, but silent: an item inside a code fence, a raw HTML
1117/// block, an HTML comment, or a block quote is left alone, and until now
1118/// nothing said so.
1119fn report_skipped(tracker: i64, body: &str) {
1120    for skip in parse_reporting(body).1 {
1121        logdim!(
1122            "  left line {} of #{tracker} alone, '{}': {}",
1123            skip.line,
1124            style::clip(&style::one_line(&skip.text), 60),
1125            skip.why
1126        );
1127    }
1128}
1129
1130fn report_overflow(cfg: &Config, tracker: i64, steps: &[Step]) {
1131    let left: Vec<String> = steps
1132        .iter()
1133        .filter(|s| s.action == Action::Over)
1134        .map(|s| format!("'{}'", style::clip(&style::one_line(&s.item.text), 60)))
1135        .collect();
1136    if left.is_empty() {
1137        return;
1138    }
1139    logwarn!(
1140        "#{tracker} has more unchecked items than max_tracker_children ({}), so {} were left for \
1141         a later run: {}",
1142        cfg.loop_cfg.max_tracker_children,
1143        left.len(),
1144        left.join(", ")
1145    );
1146}
1147
1148fn read(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1149    // From the API, never from what triage rendered: `body_for_prompt`
1150    // shortens past `max_issue_chars`, and a tracker is precisely the long
1151    // issue that trips it. Parsing a shortened body drops the last items and
1152    // looks identical to a tracker that had fewer.
1153    match repo.read_issue(tracker) {
1154        Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1155        Err(e) => {
1156            logdim!("could not read #{tracker}: {}", e.first_line());
1157            None
1158        }
1159    }
1160}
1161
1162fn read_for_write(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1163    match repo.record_failed_write(repo.read_issue(tracker)) {
1164        Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1165        Err(e) => {
1166            logdim!("could not read #{tracker}: {}", e.first_line());
1167            None
1168        }
1169    }
1170}
1171
1172/// This repository's address, taken from the tracker's own url by dropping the
1173/// `/issues/29` off the end.
1174///
1175/// Read rather than assembled from `owner/repo`, because the host is half the
1176/// answer: a link to `elsewhere.example/me/mine/issues/7` shares the path and
1177/// is not this repository's issue. Empty when there is no url to read, which
1178/// holds every linked item rather than guessing at one.
1179fn home_of(url: &str) -> String {
1180    match url.rfind("/issues/") {
1181        Some(at) => url[..at].to_string(),
1182        None => String::new(),
1183    }
1184}
1185
1186// ---------------------------------------------------------------------------
1187// The read only half
1188// ---------------------------------------------------------------------------
1189
1190/// Print the decomposition `spar run` would perform, and write none of it.
1191///
1192/// `spar triage` is the command you reach for to look before leaping, so a
1193/// preview that files issues and rewrites somebody's issue body is exactly the
1194/// trap that comment names. This is where the first few real trackers should be
1195/// checked.
1196pub fn preview(cfg: &Config, repo: &Repo, tracker: i64) {
1197    let Some((body, slug)) = read(repo, tracker) else {
1198        return;
1199    };
1200    let steps = plan(repo, cfg, tracker, &body, &slug);
1201    if steps.is_empty() {
1202        return;
1203    }
1204    println!("\n#{tracker}, if decompose_trackers let it act on the checklist:");
1205    report_skipped(tracker, &body);
1206
1207    let mut projected = body.clone();
1208    for step in &steps {
1209        let what = style::clip(&style::one_line(&step.item.text), 80);
1210        match &step.action {
1211            Action::Adopt(number) => println!("  keep  '{what}' is already #{number}"),
1212            Action::Tick(number) => println!("  tick  '{what}', #{number} is finished"),
1213            Action::Link {
1214                number,
1215                title,
1216                open,
1217            } => {
1218                let state = if *open { "open" } else { "closed" };
1219                println!("  link  '{what}' to #{number} '{title}' ({state}), filing nothing");
1220            }
1221            Action::File => println!("  file  '{what}'"),
1222            Action::Over => println!("  over  '{what}' is past max_tracker_children"),
1223            Action::Hold(why) => println!("  hold  '{what}': {why}"),
1224        }
1225        // The same mapping the acting path uses, so what is printed here and
1226        // what a run would write cannot drift. Only `File` is the preview's own,
1227        // since the issue it would link to does not exist yet.
1228        let change = match &step.action {
1229            Action::File => Some(Change::Reference(FILED.to_string())),
1230            other => other.change(),
1231        };
1232        let Some(change) = change else { continue };
1233        match rewrite(&projected, &step.item.raw, &change) {
1234            Ok(next) => projected = next,
1235            Err(e) => println!("        the line will not be rewritten: {e}"),
1236        }
1237    }
1238
1239    let diff = diff(&body, &projected);
1240    if diff.is_empty() {
1241        println!("  nothing would be written to the body");
1242    } else {
1243        println!("  and the body it would write:");
1244        for line in diff {
1245            println!("    {line}");
1246        }
1247    }
1248}
1249
1250/// Stands in for a number that does not exist yet, so the preview can show the
1251/// line an item would get without pretending to know which issue it will be.
1252const FILED: &str = "#(the issue it files)";
1253
1254/// The changed lines, old then new. Line local edits only, so the two bodies
1255/// always have the same number of lines and nothing has to be aligned.
1256fn diff(before: &str, after: &str) -> Vec<String> {
1257    split_keep(before)
1258        .into_iter()
1259        .zip(split_keep(after))
1260        .filter(|(old, new)| old != new)
1261        .flat_map(|(old, new)| {
1262            [
1263                format!("- {}", without_eol(old)),
1264                format!("+ {}", without_eol(new)),
1265            ]
1266        })
1267        .collect()
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272    use super::*;
1273
1274    /// Where the tests' own repository lives, as `read` would work it out.
1275    const HOME: &str = "https://github.com/me/mine";
1276
1277    fn texts(body: &str) -> Vec<String> {
1278        parse(body).into_iter().map(|i| i.text).collect()
1279    }
1280
1281    #[test]
1282    fn the_ordinary_checklist_is_read_as_items() {
1283        let items = parse("Some prose.\n\n- [ ] first\n- [x] second\n");
1284        assert_eq!(2, items.len());
1285        assert_eq!("first", items[0].text);
1286        assert!(!items[0].checked);
1287        assert!(items[1].checked);
1288        assert_eq!(3, items[0].line);
1289    }
1290
1291    /// Nested and indented items are ordinary items: every edit here is line
1292    /// local, so the shape of the list around one does not matter.
1293    #[test]
1294    fn indented_and_nested_items_are_items() {
1295        let body = "- [ ] parent\n  - [ ] child\n\t- [ ] tabbed\n    * [ ] deeper\n1. [ ] ordered\n2) [ ] also ordered\n";
1296        assert_eq!(
1297            vec![
1298                "parent",
1299                "child",
1300                "tabbed",
1301                "deeper",
1302                "ordered",
1303                "also ordered"
1304            ],
1305            texts(body)
1306        );
1307    }
1308
1309    /// The failure that would file issues for somebody's example markdown.
1310    #[test]
1311    fn something_that_looks_like_an_item_inside_a_fence_is_not_one() {
1312        let body = "\
1313- [ ] real
1314
1315```markdown
1316- [ ] not real
1317```
1318
1319~~~
1320- [ ] also not real
1321~~~
1322
1323- [ ] real again
1324";
1325        assert_eq!(vec!["real", "real again"], texts(body));
1326    }
1327
1328    /// Four spaces outside a list is a code block on GitHub, and filing an
1329    /// issue for somebody's example is the failure the fence check exists to
1330    /// prevent.
1331    #[test]
1332    fn an_indented_code_block_is_not_a_checklist() {
1333        let body = "\
1334Write the parts like this:
1335
1336    - [ ] an example, not an item
1337
1338- [ ] real
1339  - [ ] nested
1340- plain bullet
1341    - [ ] nested under a bullet
1342";
1343        assert_eq!(vec!["real", "nested", "nested under a bullet"], texts(body));
1344    }
1345
1346    /// Four spaces is measured from the enclosing item's content, not from the
1347    /// margin: under `- outer` the content starts in column 2, so six spaces
1348    /// are an example inside that item and two are a nested task.
1349    #[test]
1350    fn code_indented_inside_a_list_item_is_still_code() {
1351        let body = "\
1352- outer
1353
1354      - [ ] an example, not an item
1355
1356  - [ ] nested
1357- plain
1358    - [ ] nested under a bullet
1359        - [ ] and under that one
1360";
1361        assert_eq!(
1362            vec!["nested", "nested under a bullet", "and under that one"],
1363            texts(body)
1364        );
1365    }
1366
1367    /// GitHub renders the inside of these verbatim, so a checkbox in one is
1368    /// text somebody is showing.
1369    #[test]
1370    fn an_item_inside_raw_html_is_not_one() {
1371        let body = "\
1372- [ ] real
1373
1374<pre>
1375- [ ] not real
1376</pre>
1377
1378<textarea>
1379- [ ] also not real
1380</textarea>
1381
1382- [ ] real again
1383";
1384        assert_eq!(vec!["real", "real again"], texts(body));
1385    }
1386
1387    /// Markdown inside a block tag is not markdown: GitHub prints the checkbox
1388    /// as the text it is. The block ends at a blank line and not at the closing
1389    /// tag, which is what keeps the `<details>` a tracker is often written in
1390    /// working.
1391    #[test]
1392    fn an_item_inside_a_block_tag_is_not_one() {
1393        let body = "\
1394<div>
1395- [ ] not real
1396</div>
1397
1398<details>
1399<summary>the parts</summary>
1400
1401- [ ] real
1402</details>
1403";
1404        assert_eq!(vec!["real"], texts(body));
1405    }
1406
1407    /// More than four columns after the marker put the checkbox in an indented
1408    /// code block inside the item, which is how somebody writes down the syntax
1409    /// itself.
1410    #[test]
1411    fn a_checkbox_pushed_past_its_own_content_column_is_code() {
1412        assert_eq!(Vec::<String>::new(), texts("-     [ ] an example\n"));
1413        assert_eq!(vec!["real"], texts("-    [ ] real\n"));
1414    }
1415
1416    /// A fence four columns in is a fence somebody is showing, so it neither
1417    /// opens a block nor closes the one it sits in.
1418    #[test]
1419    fn a_fence_indented_into_code_neither_opens_nor_closes() {
1420        let body = "```\n- [ ] not real\n    ```\n- [ ] still not real\n";
1421        assert_eq!(Vec::<String>::new(), texts(body));
1422
1423        let body = "Like this:\n\n    ```\n- [ ] real\n";
1424        assert_eq!(vec!["real"], texts(body));
1425    }
1426
1427    /// The items somebody decided against are often kept in a comment. GitHub
1428    /// renders none of it, so neither does this.
1429    #[test]
1430    fn an_item_inside_an_html_comment_is_not_one() {
1431        let body = "\
1432- [ ] real
1433
1434<!--
1435- [ ] not real
1436-->
1437
1438- [ ] real again
1439<!-- - [ ] on one line, closed -->
1440- [ ] last
1441";
1442        assert_eq!(vec!["real", "real again", "last"], texts(body));
1443    }
1444
1445    /// A fence closes on its own glyph only, so a stray one of the other kind
1446    /// inside does not end the block early.
1447    #[test]
1448    fn a_fence_is_closed_only_by_its_own_kind() {
1449        let body = "~~~\n```\n- [ ] not real\n```\n~~~\n- [ ] real\n";
1450        assert_eq!(vec!["real"], texts(body));
1451    }
1452
1453    #[test]
1454    fn a_windows_body_is_read_the_same_way() {
1455        let items = parse("intro\r\n\r\n- [ ] first\r\n- [x] second\r\n");
1456        assert_eq!(2, items.len());
1457        assert_eq!("first", items[0].text);
1458        assert!(items[1].checked);
1459        assert_eq!(
1460            "- [ ] first", items[0].raw,
1461            "the terminator is not part of the handle"
1462        );
1463    }
1464
1465    #[test]
1466    fn a_reference_is_read_from_a_number_or_a_link() {
1467        let items = parse(
1468            "- [ ] one #12\n\
1469             - [ ] two https://github.com/o/r/issues/34\n\
1470             - [ ] [three](https://github.com/o/r/issues/56)\n\
1471             - [ ] four\n",
1472        );
1473        assert_eq!(Some(12), items[0].reference.as_ref().map(|r| r.number));
1474        assert_eq!(Some(34), items[1].reference.as_ref().map(|r| r.number));
1475        assert_eq!(Some(56), items[2].reference.as_ref().map(|r| r.number));
1476        assert_eq!(None, items[3].reference);
1477    }
1478
1479    /// An item is very often written down as the change that closes it.
1480    /// `resolve` answers for a pull request already, so reading only `/issues/`
1481    /// would file a second issue for work in flight.
1482    #[test]
1483    fn a_link_to_a_pull_request_is_a_reference_too() {
1484        let items = parse(
1485            "- [ ] one https://github.com/me/mine/pull/42\n\
1486             - [ ] two https://github.com/me/mine/pull/43/files\n\
1487             - [ ] three https://github.com/other/thing/pull/44\n",
1488        );
1489        assert_eq!(Some(42), items[0].reference.as_ref().unwrap().local(HOME));
1490        assert_eq!(Some(43), items[1].reference.as_ref().unwrap().local(HOME));
1491        assert_eq!(None, items[2].reference.as_ref().unwrap().local(HOME));
1492    }
1493
1494    /// An item whose whole text is a link to somewhere else is not a reference
1495    /// to anything spar can act on.
1496    #[test]
1497    fn an_item_that_is_a_link_to_something_else_names_no_issue() {
1498        let items = parse("- [ ] [the docs](https://example.com/guide)\n");
1499        assert_eq!(None, items[0].reference);
1500        assert_eq!("[the docs](https://example.com/guide)", items[0].text);
1501    }
1502
1503    /// Taking the number out of another repository's link would point the item
1504    /// at whatever happens to carry that number here.
1505    #[test]
1506    fn a_link_to_another_repository_is_not_adoptable() {
1507        let items = parse("- [ ] see https://github.com/other/thing/issues/7\n");
1508        let reference = items[0].reference.as_ref().expect("a reference");
1509        assert_eq!(None, reference.local(HOME));
1510        assert_eq!(Some(7), reference.local("https://github.com/other/thing"));
1511    }
1512
1513    /// A bare number can only mean this repository, so it needs no slug.
1514    #[test]
1515    fn a_bare_number_resolves_wherever_it_is_read() {
1516        let items = parse("- [ ] work #7\n");
1517        assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(""));
1518    }
1519
1520    /// The path is half the answer. Another host serving `me/mine` is somebody
1521    /// else's, and adopting it would tick a local issue nobody named.
1522    #[test]
1523    fn a_link_to_the_same_path_on_another_host_is_not_this_repository() {
1524        for url in [
1525            "https://gitlab.example/me/mine/issues/7",
1526            "https://github.com/mirror/me/mine/issues/7",
1527        ] {
1528            let items = parse(&format!("- [ ] see {url}\n"));
1529            let reference = items[0].reference.as_ref().expect("a reference");
1530            assert_eq!(None, reference.local(HOME), "{url}");
1531        }
1532    }
1533
1534    /// http and https to the same issue are the same issue.
1535    #[test]
1536    fn the_scheme_is_not_what_makes_a_link_somebody_elses() {
1537        let items = parse("- [ ] see http://github.com/me/mine/issues/7\n");
1538        assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(HOME));
1539    }
1540
1541    /// An issue url with the tail taken off is the address every other link is
1542    /// measured against.
1543    #[test]
1544    fn home_is_read_off_the_trackers_own_url() {
1545        assert_eq!(HOME, home_of("https://github.com/me/mine/issues/29"));
1546        assert_eq!("", home_of(""));
1547    }
1548
1549    /// A number in backticks is somebody writing about it, and the reference
1550    /// they meant is the one outside.
1551    #[test]
1552    fn a_number_in_a_code_span_names_nothing() {
1553        let items = parse(
1554            "- [ ] Handle the literal `#12`, tracked in #34\n\
1555             - [ ] Only ``a #12 in a double span``\n",
1556        );
1557        assert_eq!(Some(34), items[0].reference.as_ref().map(|r| r.number));
1558        assert_eq!(None, items[1].reference);
1559    }
1560
1561    /// A comment is not rendered, so a number left in one is a note to a person
1562    /// and never the issue the item is about. Ticking the box because that
1563    /// issue happens to be closed would call somebody's work done.
1564    #[test]
1565    fn a_number_in_a_comment_names_nothing() {
1566        let items = parse(
1567            "- [ ] ship it <!-- old note: #7 -->\n\
1568             - [ ] and this one <!-- #7 --> #8\n",
1569        );
1570        assert_eq!(None, items[0].reference);
1571        assert_eq!(Some(8), items[1].reference.as_ref().map(|r| r.number));
1572    }
1573
1574    /// The `#8` in an address is a fragment of it. Only a link that names an
1575    /// issue by path is read as one.
1576    #[test]
1577    fn a_fragment_in_a_link_is_not_an_issue_number() {
1578        let items = parse(
1579            "- [ ] update [docs](https://example.com/guide/#8)\n\
1580             - [ ] see https://example.com/guide#9 and #10\n",
1581        );
1582        assert_eq!(None, items[0].reference);
1583        assert_eq!(Some(10), items[1].reference.as_ref().map(|r| r.number));
1584    }
1585
1586    /// GitHub links both of these without a url, so an item that carries one is
1587    /// an item that already names its issue. Reading neither filed a second
1588    /// issue for work the tracker had already written down.
1589    #[test]
1590    fn the_shorthands_github_links_are_references_too() {
1591        let items = parse(
1592            "- [ ] one me/mine#12\n\
1593             - [ ] two other/thing#13\n\
1594             - [ ] three GH-14\n",
1595        );
1596        assert_eq!(Some(12), items[0].reference.as_ref().unwrap().local(HOME));
1597        let foreign = items[1].reference.as_ref().expect("a reference");
1598        assert_eq!(None, foreign.local(HOME), "somebody else's repository");
1599        assert_eq!("other/thing#13", foreign.names());
1600        assert_eq!(Some(14), items[2].reference.as_ref().unwrap().local(HOME));
1601    }
1602
1603    /// The shorthand leaves the host out, so it means this repository wherever
1604    /// this repository is served from. The path still has to be this one's.
1605    #[test]
1606    fn a_shorthand_is_read_against_this_repositorys_path() {
1607        let items = parse("- [ ] work me/mine#7\n");
1608        let reference = items[0].reference.as_ref().expect("a reference");
1609        assert_eq!(Some(7), reference.local("https://ghe.example/me/mine"));
1610        assert_eq!(None, reference.local("https://github.com/me/other"));
1611        assert_eq!(None, reference.local(""), "no address to measure against");
1612    }
1613
1614    /// A link's label captions its destination. Reading the label first turned
1615    /// another repository's issue into a bare local number, which is exactly
1616    /// the adoption the foreign repository check exists to refuse.
1617    #[test]
1618    fn a_link_is_read_from_its_destination_and_not_its_label() {
1619        let items = parse(
1620            "- [ ] [other/widgets #7](https://github.com/other/widgets/issues/7)\n\
1621             - [ ] [me/mine #7](https://github.com/me/mine/issues/7)\n",
1622        );
1623        let foreign = items[0].reference.as_ref().expect("a reference");
1624        assert!(
1625            matches!(foreign.origin, Origin::Url(_)),
1626            "the destination, not the label"
1627        );
1628        assert_eq!(None, foreign.local(HOME));
1629        assert_eq!(Some(7), items[1].reference.as_ref().unwrap().local(HOME));
1630    }
1631
1632    /// Nested brackets and escaped closing brackets are both valid inside a
1633    /// link label. Stopping at either one exposes the label's local-looking
1634    /// number and hides the foreign destination.
1635    #[test]
1636    fn complex_link_labels_still_read_the_destination() {
1637        let items = parse(
1638            "- [ ] [see [#7]](https://github.com/other/widgets/issues/8)\n\
1639             - [ ] [see \\] #7](https://github.com/other/widgets/issues/8)\n",
1640        );
1641        for item in items {
1642            let reference = item.reference.expect("the destination");
1643            assert_eq!(8, reference.number);
1644            assert!(matches!(reference.origin, Origin::Url(_)));
1645            assert_eq!(None, reference.local(HOME));
1646        }
1647    }
1648
1649    #[test]
1650    fn one_child_referenced_by_several_items_is_worked_once() {
1651        assert_eq!(vec![8, 9], unique_children(vec![8, 8, 9, 8]));
1652    }
1653
1654    // -- the line surgery -------------------------------------------------
1655
1656    #[test]
1657    fn a_reference_is_appended_to_its_own_line_and_nowhere_else() {
1658        let body = "intro\n\n- [ ] first\n- [ ] second\n\nmore prose\n";
1659        let out =
1660            rewrite(body, "- [ ] first", &Change::Reference("#40".into())).expect("a rewrite");
1661        assert_eq!(
1662            "intro\n\n- [ ] first #40\n- [ ] second\n\nmore prose\n",
1663            out
1664        );
1665    }
1666
1667    /// Two trailing spaces are a markdown hard break, and `style::scrub` would
1668    /// eat them. The reference goes before them.
1669    #[test]
1670    fn a_hard_break_survives_the_edit() {
1671        let out = rewrite(
1672            "- [ ] first  \nnext\n",
1673            "- [ ] first  ",
1674            &Change::Reference("#4".into()),
1675        )
1676        .expect("a rewrite");
1677        assert_eq!("- [ ] first #4  \nnext\n", out);
1678    }
1679
1680    #[test]
1681    fn every_other_line_comes_through_byte_identical() {
1682        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";
1683        let out = rewrite(body, "- [ ] one", &Change::Reference("#9".into())).expect("a rewrite");
1684        let (before, after): (Vec<&str>, Vec<&str>) =
1685            (body.lines().collect(), out.lines().collect());
1686        assert_eq!(before.len(), after.len());
1687        for (i, (a, b)) in before.iter().zip(&after).enumerate() {
1688            if i == 3 {
1689                assert_eq!("- [ ] one #9", *b);
1690            } else {
1691                assert_eq!(a, b, "line {} changed", i + 1);
1692            }
1693        }
1694        assert!(out.contains("trailing spaces here   \r\n"));
1695        assert!(out.contains("\r\n\r\n\r\n\r\n"));
1696    }
1697
1698    #[test]
1699    fn a_body_with_no_final_newline_keeps_not_having_one() {
1700        let out = rewrite("- [ ] only", "- [ ] only", &Change::Tick).expect("a rewrite");
1701        assert_eq!("- [x] only", out);
1702    }
1703
1704    #[test]
1705    fn ticking_changes_the_box_and_leaves_the_text() {
1706        let out = rewrite("  - [ ] deep #3\n", "  - [ ] deep #3", &Change::Tick).expect("a tick");
1707        assert_eq!("  - [x] deep #3\n", out);
1708    }
1709
1710    /// spar checks a box and never unchecks one, so there is no change that
1711    /// could and nothing to do to one that is already ticked.
1712    #[test]
1713    fn a_ticked_box_is_never_written_again() {
1714        assert!(rewrite("- [x] done\n", "- [x] done", &Change::Tick).is_err());
1715        assert!(!matches!(Change::Tick, Change::Reference(_)));
1716    }
1717
1718    #[test]
1719    fn a_line_that_is_gone_or_ambiguous_is_a_refusal_not_a_guess() {
1720        assert!(rewrite("- [ ] a\n", "- [ ] b", &Change::Tick).is_err());
1721        let twice = "- [ ] same\n- [ ] same\n";
1722        assert!(rewrite(twice, "- [ ] same", &Change::Tick).is_err());
1723    }
1724
1725    #[test]
1726    fn an_item_with_no_text_gets_no_reference() {
1727        assert!(rewrite("- [ ]\n", "- [ ]", &Change::Reference("#1".into())).is_err());
1728    }
1729
1730    // -- shaping ----------------------------------------------------------
1731
1732    fn shapes(body: &str, max: usize) -> Vec<Shape> {
1733        shape(body, HOME, max).into_iter().map(|(_, s)| s).collect()
1734    }
1735
1736    #[test]
1737    fn a_checked_item_is_never_reconsidered() {
1738        assert!(shapes("- [x] done\n", 5).is_empty());
1739    }
1740
1741    #[test]
1742    fn an_item_that_names_an_issue_is_kept_apart_from_one_that_does_not() {
1743        assert_eq!(
1744            vec![Shape::Names(12), Shape::Needs],
1745            shapes("- [ ] one #12\n- [ ] two\n", 5)
1746        );
1747    }
1748
1749    /// A cap, not a target, and what it left is named out loud rather than
1750    /// quietly dropped.
1751    #[test]
1752    fn the_cap_stops_at_the_cap() {
1753        let body = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d\n";
1754        assert_eq!(
1755            vec![Shape::Needs, Shape::Needs, Shape::Over, Shape::Over],
1756            shapes(body, 2)
1757        );
1758    }
1759
1760    /// A tick means the work landed, and a decline is not that.
1761    ///
1762    /// With `close_skipped = true`, the default, an item whose issue both
1763    /// agents declined is closed as not planned. Ticking it writes "this was
1764    /// done" into somebody's checklist, and since spar never unchecks a box,
1765    /// correcting it is left to them.
1766    #[test]
1767    fn an_item_closed_as_not_planned_is_not_ticked() {
1768        let issue = |state: &str, reason: Option<&str>| Issue {
1769            number: 12,
1770            title: "migrate the cache to v2".into(),
1771            body: None,
1772            state: state.into(),
1773            state_reason: reason.map(str::to_string),
1774            url: String::new(),
1775            labels: Vec::new(),
1776        };
1777
1778        assert!(matches!(
1779            issue_action(&issue("CLOSED", Some("not_planned"))),
1780            Action::Hold(_)
1781        ));
1782        assert_eq!(
1783            Action::Tick(12),
1784            issue_action(&issue("CLOSED", Some("completed")))
1785        );
1786        assert_eq!(
1787            Action::Tick(12),
1788            issue_action(&issue("CLOSED", None)),
1789            "an issue closed before GitHub recorded a reason still ticks"
1790        );
1791        assert_eq!(Action::Adopt(12), issue_action(&issue("OPEN", None)));
1792    }
1793
1794    /// The cap is on issues spar files, and a linked item needs none.
1795    ///
1796    /// Counting linked items meant five open linked items with
1797    /// max_tracker_children = 5 left a sixth unlinked item Over on every run,
1798    /// and closed items past the cap were never ticked at all.
1799    #[test]
1800    fn the_cap_counts_only_the_items_that_need_an_issue_filed() {
1801        let body = "- [ ] a #1\n- [ ] b #2\n- [ ] c\n- [ ] d\n";
1802        assert_eq!(
1803            vec![Shape::Names(1), Shape::Names(2), Shape::Needs, Shape::Over],
1804            shapes(body, 1)
1805        );
1806    }
1807
1808    /// An inline `<!--` renders as text on GitHub, so treating it as an opener
1809    /// skipped every item after it with nothing said.
1810    #[test]
1811    fn an_inline_comment_marker_does_not_hide_the_rest_of_the_list() {
1812        let body = "- [ ] document the `<!--` syntax\n- [ ] the next one\n";
1813        assert_eq!(vec![Shape::Needs, Shape::Needs], shapes(body, 5));
1814
1815        // A real opener still opens: it starts the line.
1816        let body = "<!-- notes\n- [ ] hidden\n-->\n- [ ] visible\n";
1817        assert_eq!(vec![Shape::Needs], shapes(body, 5));
1818    }
1819
1820    /// A line spar will not rewrite is skipped with a reason, and these had
1821    /// none.
1822    #[test]
1823    fn a_checkbox_spar_will_not_touch_says_why() {
1824        let body = "<!-- later\n- [ ] in a comment\n-->\n\n> - [ ] in a quote\n\n```\n- [ ] in a fence\n```\n";
1825        let reasons: Vec<&str> = parse_reporting(body).1.iter().map(|s| s.why).collect();
1826        assert_eq!(
1827            vec![
1828                "it is inside an HTML comment",
1829                "it is inside a block quote",
1830                "it is inside a code fence",
1831            ],
1832            reasons
1833        );
1834    }
1835
1836    /// A checked item does not spend the budget, since nothing is done to it.
1837    #[test]
1838    fn the_cap_counts_only_what_it_acts_on() {
1839        let body = "- [x] a\n- [x] b\n- [ ] c\n";
1840        assert_eq!(vec![Shape::Needs], shapes(body, 1));
1841    }
1842
1843    #[test]
1844    fn two_identical_items_are_left_alone() {
1845        let out = shapes("- [ ] same\n- [ ] same\n", 5);
1846        assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1847        assert!(matches!(out[1], Shape::Hold(_)), "{out:?}");
1848    }
1849
1850    #[test]
1851    fn an_item_naming_another_repository_is_held_rather_than_adopted() {
1852        let out = shapes("- [ ] see https://github.com/other/thing/issues/7\n", 5);
1853        assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1854    }
1855
1856    /// The whole thing on one body of the shape a person actually writes: what
1857    /// each item is taken for, and exactly what comes out the other side.
1858    #[test]
1859    fn a_realistic_tracker_keeps_every_line_it_was_not_asked_to_change() {
1860        let body = "\
1861Context somebody wrote, with a hard break here:
1862and the rest of it.
1863
1864## Parts
1865
1866- [x] already done
1867- [ ] parse the checklist
1868- [ ] write the link back #40
1869  - [ ] and prove it first
1870
1871```markdown
1872- [ ] an example, not an item
1873```
1874
1875That is all.
1876";
1877        let shapes: Vec<Shape> = shape(body, HOME, 5).into_iter().map(|(_, s)| s).collect();
1878        assert_eq!(
1879            vec![Shape::Needs, Shape::Names(40), Shape::Needs],
1880            shapes,
1881            "the ticked item, the fenced one and the prose are all left out"
1882        );
1883
1884        let out = rewrite(
1885            body,
1886            "- [ ] parse the checklist",
1887            &Change::Reference("#41".into()),
1888        )
1889        .expect("a link");
1890        let out = rewrite(
1891            &out,
1892            "  - [ ] and prove it first",
1893            &Change::Reference("#42".into()),
1894        )
1895        .expect("a nested link");
1896        let out = rewrite(&out, "- [ ] write the link back #40", &Change::Tick).expect("a tick");
1897
1898        assert_eq!(
1899            "\
1900Context somebody wrote, with a hard break here:
1901and the rest of it.
1902
1903## Parts
1904
1905- [x] already done
1906- [ ] parse the checklist #41
1907- [x] write the link back #40
1908  - [ ] and prove it first #42
1909
1910```markdown
1911- [ ] an example, not an item
1912```
1913
1914That is all.
1915",
1916            out
1917        );
1918    }
1919
1920    // -- what each decision writes ----------------------------------------
1921
1922    /// The match is fuzzy, so a wrong adoption that also ticked the box would
1923    /// be spar asserting work is done that nobody did. The link goes in this
1924    /// run and the tick waits for the next, by which time the item carries its
1925    /// own reference and a person has had the chance to see it.
1926    #[test]
1927    fn an_item_linked_by_similarity_is_not_ticked_in_the_same_run() {
1928        for open in [true, false] {
1929            let action = Action::Link {
1930                number: 7,
1931                title: "something close enough".into(),
1932                open,
1933            };
1934            assert_eq!(Some(Change::Reference("#7".into())), action.change());
1935        }
1936    }
1937
1938    /// An item that carried its reference before spar touched it is in no such
1939    /// doubt, so it is ticked on the spot.
1940    #[test]
1941    fn an_item_that_already_named_its_issue_is_ticked_when_that_issue_closes() {
1942        assert_eq!(Some(Change::Tick), Action::Tick(7).change());
1943    }
1944
1945    /// Adopting is reading, not writing: the line already says what it says.
1946    #[test]
1947    fn nothing_is_written_for_an_item_that_is_already_linked_and_open() {
1948        assert_eq!(None, Action::Adopt(7).change());
1949        assert_eq!(None, Action::Over.change());
1950        assert_eq!(None, Action::Hold("any reason".into()).change());
1951    }
1952
1953    // -- the guard before a write -----------------------------------------
1954
1955    /// The bytes of the line are not the whole of what it means. Somebody
1956    /// fencing an item mid-run is saying not this one, and the raw text is
1957    /// still there to match.
1958    #[test]
1959    fn a_line_that_stopped_being_an_item_is_not_written_to() {
1960        let raw = "- [ ] ship it";
1961        assert!(still_an_item("intro\n\n- [ ] ship it\n", raw));
1962        assert!(!still_an_item("```\n- [ ] ship it\n```\n", raw));
1963        assert!(!still_an_item("<!--\n- [ ] ship it\n-->\n", raw));
1964        assert!(!still_an_item("- [ ] something else\n", raw));
1965        assert!(
1966            !still_an_item("- [ ] ship it\n- [ ] ship it\n", raw),
1967            "two alike is a line the edit could go to either of"
1968        );
1969    }
1970
1971    // -- the preview ------------------------------------------------------
1972
1973    #[test]
1974    fn the_diff_shows_only_the_lines_that_change() {
1975        let before = "- [ ] one\n- [ ] two\n";
1976        let after =
1977            rewrite(before, "- [ ] two", &Change::Reference("#8".into())).expect("a rewrite");
1978        assert_eq!(
1979            vec!["- - [ ] two".to_string(), "+ - [ ] two #8".to_string()],
1980            diff(before, &after)
1981        );
1982    }
1983}