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::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    let mut out = Vec::new();
243    // The glyph and length a close has to match, and the column it opened in,
244    // since a fence four columns further in is code rather than the close.
245    let mut fence: Option<(char, usize, usize)> = None;
246    let mut comment = false;
247    let mut html: Option<Html> = None;
248    // Where the content of each open list item starts, outermost first. Four
249    // spaces mean code, but four spaces from where: the margin outside a list,
250    // and the innermost item's own content column inside one.
251    let mut open: Vec<usize> = Vec::new();
252
253    for (index, raw) in split_keep(body).into_iter().enumerate() {
254        let line = without_eol(raw);
255        // A comment is markdown a person wrote for the next person, often the
256        // items they decided against. GitHub renders none of it.
257        if comment {
258            comment = !line.contains("-->");
259            continue;
260        }
261        if let Some(kind) = html {
262            let ends = match kind {
263                Html::Verbatim => HTML_CLOSE.is_match(line),
264                Html::Block => line.trim().is_empty(),
265            };
266            if ends {
267                html = None;
268            }
269            continue;
270        }
271        let indent = indent_width(line);
272        if let Some((glyph, len, column)) = fence {
273            // Only the same glyph, at least as long, with nothing after it, and
274            // not indented so far past the opening one that it is code.
275            if let Some(caps) = FENCE.captures(line) {
276                let marker = &caps["fence"];
277                let closes = marker.starts_with(glyph)
278                    && marker.len() >= len
279                    && caps["info"].trim().is_empty()
280                    && indent < column + 4;
281                if closes {
282                    fence = None;
283                }
284            }
285            continue;
286        }
287        // A blank line closes nothing: a list survives one, and both markdown
288        // and the person writing it expect the item after it to still be in.
289        if !line.trim().is_empty() {
290            while open.last().is_some_and(|col| indent < *col) {
291                open.pop();
292            }
293        }
294        // Four spaces past wherever the content of this line belongs is a code
295        // block, which is the fence case wearing different clothes. `- outer`
296        // then six spaces is an example inside that item, not a nested task.
297        let margin = open.last().copied().unwrap_or(0);
298        if !line.trim().is_empty() && indent >= margin + 4 {
299            continue;
300        }
301        // After the code check, so that four spaces in are a fence a person is
302        // showing rather than one they are opening.
303        if let Some(caps) = FENCE.captures(line) {
304            let marker = &caps["fence"];
305            fence = Some((
306                marker.chars().next().expect("a fence"),
307                marker.len(),
308                indent,
309            ));
310            continue;
311        }
312        if let Some(column) = content_column(line) {
313            open.push(column);
314        }
315        if HTML_VERBATIM.is_match(line) {
316            html = (!HTML_CLOSE.is_match(line)).then_some(Html::Verbatim);
317            continue;
318        }
319        if HTML_BLOCK.is_match(line) {
320            html = Some(Html::Block);
321            continue;
322        }
323        comment = opens_comment(line);
324        let Some(caps) = item_of(line) else {
325            continue;
326        };
327        let text = caps["rest"].trim().to_string();
328        out.push(Item {
329            line: index + 1,
330            raw: line.to_string(),
331            reference: reference_in(&text),
332            text,
333            checked: &caps["state"] != " ",
334        });
335    }
336    out
337}
338
339/// Where a list line's content starts, in columns, or `None` if it is not one.
340///
341/// Markdown puts the content one column after the marker when the gap is
342/// nothing or wider than four, and at the gap otherwise.
343fn content_column(line: &str) -> Option<usize> {
344    let caps = LIST.captures(line)?;
345    let gap = indent_width(&caps["gap"]);
346    if gap == 0 && !caps["rest"].is_empty() {
347        return None;
348    }
349    let gap = match (1..=4).contains(&gap) && !caps["rest"].trim().is_empty() {
350        true => gap,
351        false => 1,
352    };
353    Some(indent_width(&caps["indent"]) + caps["marker"].len() + gap)
354}
355
356/// The first issue this text names, by link or by number.
357///
358/// Read from the text with comments, code spans and link labels blanked out. A
359/// `#12` in backticks is somebody writing about a number rather than pointing
360/// at one, and a `#7` in a link's label captions the destination: without this,
361/// `[other/widgets #7](https://github.com/other/widgets/issues/7)` becomes a
362/// bare local #7 and the foreign repository check never sees it.
363///
364/// The bare numbers are read from a copy with the links blanked too, so that
365/// only `URL_REF` speaks for what is inside an address. Both copies are the
366/// same length as the original, so the two offsets can still be compared.
367fn reference_in(text: &str) -> Option<Reference> {
368    let text = &readable(text);
369    let url = URL_REF.captures(text);
370    let outside = blank(text, &LINK);
371    let hash = HASH_REF.captures(&outside);
372    let at = |caps: &Option<regex::Captures>| {
373        caps.as_ref()
374            .map(|c| c.get(0).expect("the whole match").start())
375    };
376    // Whichever comes first, so a link is not shadowed by a `#` later in the
377    // same line.
378    match (at(&url), at(&hash)) {
379        (Some(u), Some(h)) if h < u => hash.map(as_hash),
380        (Some(_), _) => url.map(as_url),
381        (None, Some(_)) => hash.map(as_hash),
382        (None, None) => None,
383    }
384}
385
386fn as_hash(caps: regex::Captures) -> Reference {
387    Reference {
388        number: caps["number"].parse().unwrap_or_default(),
389        origin: match caps.name("slug") {
390            Some(slug) => Origin::Repo(slug.as_str().to_string()),
391            None => Origin::Here,
392        },
393    }
394}
395
396fn as_url(caps: regex::Captures) -> Reference {
397    Reference {
398        number: caps["number"].parse().unwrap_or_default(),
399        origin: Origin::Url(caps["url"].to_string()),
400    }
401}
402
403/// The text with every match of `what` replaced by spaces, byte for byte so
404/// that offsets into it still line up with the original.
405fn blank(text: &str, what: &Regex) -> String {
406    let mut out = text.as_bytes().to_vec();
407    for found in what.find_iter(text) {
408        out[found.range()].fill(b' ');
409    }
410    // Whole matches are blanked, so no character is left half replaced.
411    String::from_utf8(out).unwrap_or_else(|_| text.to_string())
412}
413
414/// The text with comments, code spans and inline link labels replaced by
415/// spaces, byte for byte so that offsets into it still line up with the
416/// original. A link's destination is left standing, because that is the part
417/// that names an issue.
418fn readable(text: &str) -> String {
419    let text = blank(text, &COMMENT);
420    let text = text.as_str();
421    let bytes = text.as_bytes();
422    let mut out = bytes.to_vec();
423    let mut at = 0;
424    while at < bytes.len() {
425        match bytes[at] {
426            b'`' => {
427                let start = at;
428                while at < bytes.len() && bytes[at] == b'`' {
429                    at += 1;
430                }
431                if let Some(end) = backtick_run(bytes, at, at - start) {
432                    out[start..end].fill(b' ');
433                    at = end;
434                }
435            }
436            b'[' => match label_end(bytes, at) {
437                Some(end) => {
438                    out[at..end].fill(b' ');
439                    at = end;
440                }
441                None => at += 1,
442            },
443            _ => at += 1,
444        }
445    }
446    // Only whole regions delimited by ASCII are blanked, so this holds.
447    String::from_utf8(out).unwrap_or_else(|_| text.to_string())
448}
449
450/// The end of the next run of exactly `len` backticks, which is what closes a
451/// code span opened by one that long.
452fn backtick_run(bytes: &[u8], from: usize, len: usize) -> Option<usize> {
453    let mut at = from;
454    while at < bytes.len() {
455        if bytes[at] != b'`' {
456            at += 1;
457            continue;
458        }
459        let start = at;
460        while at < bytes.len() && bytes[at] == b'`' {
461            at += 1;
462        }
463        if at - start == len {
464            return Some(at);
465        }
466    }
467    None
468}
469
470/// The end of an inline link's label, including nested and escaped brackets,
471/// when a destination follows.
472fn label_end(bytes: &[u8], open: usize) -> Option<usize> {
473    if bytes.get(open) != Some(&b'[') || escaped(bytes, open) {
474        return None;
475    }
476
477    let mut depth = 1usize;
478    let mut at = open + 1;
479    while at < bytes.len() {
480        if escaped(bytes, at) {
481            at += 1;
482            continue;
483        }
484        match bytes[at] {
485            b'[' => depth += 1,
486            b']' => {
487                depth -= 1;
488                if depth == 0 {
489                    return (bytes.get(at + 1) == Some(&b'(')).then_some(at + 1);
490                }
491            }
492            _ => {}
493        }
494        at += 1;
495    }
496    None
497}
498
499fn escaped(bytes: &[u8], at: usize) -> bool {
500    let mut slashes = 0usize;
501    let mut cursor = at;
502    while cursor > 0 && bytes[cursor - 1] == b'\\' {
503        slashes += 1;
504        cursor -= 1;
505    }
506    slashes % 2 == 1
507}
508
509/// Lines with their terminators kept, so concatenating them is the original
510/// string. CRLF survives, and so does a body that does not end in a newline.
511fn split_keep(text: &str) -> Vec<&str> {
512    let mut out = Vec::new();
513    let mut start = 0;
514    for (at, c) in text.char_indices() {
515        if c == '\n' {
516            out.push(&text[start..=at]);
517            start = at + 1;
518        }
519    }
520    if start < text.len() {
521        out.push(&text[start..]);
522    }
523    out
524}
525
526/// Leading whitespace in columns, a tab counting as the four spaces markdown
527/// gives it when it decides what is indented far enough to be code.
528fn indent_width(line: &str) -> usize {
529    line.chars()
530        .take_while(|c| matches!(c, ' ' | '\t'))
531        .map(|c| if c == '\t' { 4 } else { 1 })
532        .sum()
533}
534
535/// Whether the line leaves an HTML comment open behind it.
536fn opens_comment(line: &str) -> bool {
537    match line.rfind("<!--") {
538        Some(at) => !line[at + 4..].contains("-->"),
539        None => false,
540    }
541}
542
543fn without_eol(line: &str) -> &str {
544    match line.strip_suffix('\n') {
545        Some(rest) => rest.strip_suffix('\r').unwrap_or(rest),
546        None => line,
547    }
548}
549
550fn eol_of(line: &str) -> &str {
551    if line.ends_with("\r\n") {
552        "\r\n"
553    } else if line.ends_with('\n') {
554        "\n"
555    } else {
556        ""
557    }
558}
559
560// ---------------------------------------------------------------------------
561// Line surgery
562// ---------------------------------------------------------------------------
563
564/// The one edit a write may make to one line.
565#[derive(Debug, Clone, PartialEq, Eq)]
566pub enum Change {
567    /// Tick the box, and only ever in that direction. A checked box beside an
568    /// open issue is left alone: unchecking is destroying somebody's record on
569    /// a heuristic, and every state that produces it (the work landed in
570    /// another pull request, the issue was reopened for one detail) is one where
571    /// the person is right and the heuristic is wrong.
572    Tick,
573    /// Append a reference to the item's text.
574    Reference(String),
575}
576
577impl Change {
578    /// What spar is adding, for the style gate. The rest of the body is
579    /// somebody else's writing and is not spar's to clean.
580    pub fn inserted(&self) -> &str {
581        match self {
582            Change::Tick => "x",
583            Change::Reference(reference) => reference,
584        }
585    }
586}
587
588/// The body with one line changed, or an error saying why it will not be.
589///
590/// The line is found by its exact content, and every other line comes through
591/// byte identical. That is proved here rather than assumed: this is the only
592/// place spar rewrites something a person wrote, and a parser that mishandles a
593/// nested list does not produce a bad comment, it produces a mangled plan.
594pub fn rewrite(body: &str, raw: &str, change: &Change) -> Result<String> {
595    let lines = split_keep(body);
596    // If the split is not lossless nothing below it is safe.
597    if lines.concat() != body {
598        bail!("could not split the body into lines without changing it");
599    }
600
601    let hits: Vec<usize> = lines
602        .iter()
603        .enumerate()
604        .filter(|(_, line)| without_eol(line) == raw)
605        .map(|(at, _)| at)
606        .collect();
607    match hits.len() {
608        0 => bail!("that line is no longer in the body"),
609        1 => {}
610        n => bail!("{n} lines read exactly alike, so the edit could go to either"),
611    }
612    let at = hits[0];
613    let replaced = changed(raw, change)?;
614
615    let mut out = String::with_capacity(body.len() + replaced.len());
616    for (index, line) in lines.iter().enumerate() {
617        if index == at {
618            out.push_str(&replaced);
619            out.push_str(eol_of(line));
620        } else {
621            out.push_str(line);
622        }
623    }
624
625    // Byte identity, on the result rather than on the plan for it.
626    let after = split_keep(&out);
627    if after.len() != lines.len() {
628        bail!(
629            "the edit changed the line count from {} to {}",
630            lines.len(),
631            after.len()
632        );
633    }
634    for (index, (before, now)) in lines.iter().zip(&after).enumerate() {
635        if index != at && before != now {
636            bail!(
637                "the edit would have changed line {}, which it must not",
638                index + 1
639            );
640        }
641    }
642    Ok(out)
643}
644
645/// One line, changed. Nothing is reflowed, normalised, or re-emitted from a
646/// parsed model: the untouched parts of the line are copied through as bytes.
647fn changed(line: &str, change: &Change) -> Result<String> {
648    let caps = item_of(line).ok_or_else(|| spar_err!("that line is no longer a checklist item"))?;
649
650    match change {
651        Change::Tick => {
652            let at = caps.name("state").expect("a state").start();
653            if &line[at..at + 1] != " " {
654                bail!("that box is already ticked");
655            }
656            Ok(format!("{}x{}", &line[..at], &line[at + 1..]))
657        }
658        Change::Reference(reference) => {
659            let rest = caps.name("rest").expect("a rest");
660            let text = rest.as_str();
661            // Trailing whitespace is a markdown hard break, so the reference
662            // goes before it rather than after.
663            let body = text.trim_end_matches([' ', '\t']);
664            if body.trim().is_empty() {
665                bail!("that item has no text to attach {reference} to");
666            }
667            Ok(format!(
668                "{}{body} {reference}{}",
669                &line[..rest.start()],
670                &text[body.len()..]
671            ))
672        }
673    }
674}
675
676// ---------------------------------------------------------------------------
677// Deciding
678// ---------------------------------------------------------------------------
679
680/// What an item is, before anything is asked of the network.
681#[derive(Debug, Clone, PartialEq, Eq)]
682enum Shape {
683    /// It names something in this repository. What that something turns out to
684    /// be is the network's answer, not the parser's.
685    Names(i64),
686    /// It names nothing, so it needs one.
687    Needs,
688    /// Left alone, with the reason.
689    Hold(String),
690    /// Past `max_tracker_children`.
691    Over,
692}
693
694/// What spar would do with one unchecked item.
695#[derive(Debug, Clone, PartialEq, Eq)]
696pub enum Action {
697    /// The line already names an open issue here. Nothing is written.
698    Adopt(i64),
699    /// What it names is finished, so the box is ticked. An item that carried its
700    /// reference before spar touched it is not in doubt.
701    Tick(i64),
702    /// An issue covers it and nothing linked them. The link is written; the box
703    /// is not ticked this run whatever state that issue is in, because the
704    /// match is fuzzy and a wrong adoption that also ticks the box is spar
705    /// asserting work is done that nobody did.
706    Link {
707        number: i64,
708        title: String,
709        open: bool,
710    },
711    /// Nothing covers it. One is filed, then linked.
712    File,
713    /// Left alone, with the reason.
714    Hold(String),
715    /// Past `max_tracker_children`, and said out loud rather than dropped.
716    Over,
717}
718
719impl Action {
720    /// The edit this writes to the item's line.
721    ///
722    /// `File` has none yet, because its issue does not exist until it is filed;
723    /// the caller writes the reference the moment it does.
724    ///
725    /// A `Link` writes the reference and never the tick, whatever state the
726    /// issue it matched is in. The match is fuzzy, and a wrong adoption that
727    /// also ticks the box is spar asserting work is done that nobody did.
728    /// Holding the tick for one run puts the link in front of a person first,
729    /// and by the next run the item carries its own reference and is in no more
730    /// doubt than one somebody wrote by hand.
731    pub fn change(&self) -> Option<Change> {
732        match self {
733            Action::Tick(_) => Some(Change::Tick),
734            Action::Link { number, .. } => Some(Change::Reference(format!("#{number}"))),
735            Action::Adopt(_) | Action::File | Action::Hold(_) | Action::Over => None,
736        }
737    }
738}
739
740/// One item and what is to become of it.
741#[derive(Debug, Clone)]
742pub struct Step {
743    pub item: Item,
744    pub action: Action,
745}
746
747/// Every unchecked item and its shape, without touching the network.
748///
749/// Checked items are absent by construction: spar checks a box and never
750/// unchecks one, so there is nothing to decide about them.
751fn shape(body: &str, home: &str, max: usize) -> Vec<(Item, Shape)> {
752    let items = parse(body);
753    // A line that appears twice cannot be rewritten unambiguously, and there is
754    // no reading of two identical items that makes filing two issues right.
755    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
756    for item in &items {
757        *seen.entry(item.raw.as_str()).or_default() += 1;
758    }
759
760    let mut out = Vec::new();
761    let mut taken = 0usize;
762    for item in &items {
763        if item.checked {
764            continue;
765        }
766        let shape = if seen.get(item.raw.as_str()).copied().unwrap_or(0) > 1 {
767            Shape::Hold("another item is written identically, so a link could go to either".into())
768        } else if item.text.is_empty() {
769            Shape::Hold("the item has no text".into())
770        } else if taken >= max {
771            Shape::Over
772        } else {
773            match &item.reference {
774                Some(reference) => match reference.local(home) {
775                    Some(number) => {
776                        taken += 1;
777                        Shape::Names(number)
778                    }
779                    None => Shape::Hold(format!(
780                        "it names an issue in another repository: {}",
781                        reference.names()
782                    )),
783                },
784                None => {
785                    taken += 1;
786                    Shape::Needs
787                }
788            }
789        };
790        out.push((item.clone(), shape));
791    }
792    out
793}
794
795/// What spar would do with each unchecked item, deciding but never writing.
796///
797/// Reads only: the state of an issue an item already names, and the similarity
798/// search for one that does not. `spar triage` prints exactly this and the
799/// acting path applies it, so the preview and the run cannot drift apart.
800pub fn plan(repo: &Repo, cfg: &Config, tracker: i64, body: &str, home: &str) -> Vec<Step> {
801    shape(body, home, cfg.loop_cfg.max_tracker_children)
802        .into_iter()
803        .map(|(item, shape)| {
804            let action = match shape {
805                Shape::Hold(why) => Action::Hold(why),
806                Shape::Over => Action::Over,
807                Shape::Names(number) => resolve(repo, number),
808                // `file_as_issue` runs this search itself, so doing it here
809                // looks redundant and is not: it maps a closed match to
810                // `AlreadyClosed` and returns no url, which is right when
811                // filing a follow-up and wrong here, where a closed match is a
812                // done item that wants a link. Doing it first is also what lets
813                // the log say "linked #40, filed nothing".
814                Shape::Needs => match search(repo, tracker, &item.text) {
815                    Some(found) => Action::Link {
816                        number: found.number,
817                        title: found.title,
818                        open: found.open,
819                    },
820                    None => Action::File,
821                },
822            };
823            Step { item, action }
824        })
825        .collect()
826}
827
828/// What to do about an item that already names something.
829///
830/// What it names is established before anything is read from it. Issues and
831/// pull requests share one number sequence, `gh issue view` answers happily for
832/// either, and adopting a pull request as a child would hand the run something
833/// with no issue behind it.
834fn resolve(repo: &Repo, number: i64) -> Action {
835    match repo.item_kind(number) {
836        Ok(ItemKind::Issue) => match repo.read_issue(number) {
837            Ok(issue) if issue.is_closed() => Action::Tick(number),
838            Ok(_) => Action::Adopt(number),
839            Err(e) => Action::Hold(format!("could not read #{number}: {}", e.first_line())),
840        },
841        // A merged pull request is the work landing, which is what the tick is
842        // for. An open one is somebody's work in progress and not spar's to
843        // take up, and one closed unmerged is not a finished item at all.
844        Ok(ItemKind::Pr) => match repo.pr_state(number).to_uppercase().as_str() {
845            "MERGED" => Action::Tick(number),
846            "" => Action::Hold(format!(
847                "#{number} is a pull request in an unreadable state"
848            )),
849            state => Action::Hold(format!(
850                "#{number} is a pull request, {}",
851                state.to_lowercase()
852            )),
853        },
854        Err(e) => Action::Hold(format!("could not read #{number}: {}", e.first_line())),
855    }
856}
857
858/// An issue that already covers this item, the tracker itself apart.
859///
860/// The tracker quotes every item in its own checklist, so it is the closest
861/// match for each one of them. Adopting it would link an item to the issue it
862/// is written in, and the run would then see the tracker as already handled and
863/// work nothing.
864fn search(repo: &Repo, tracker: i64, text: &str) -> Option<crate::repo::ExistingIssue> {
865    let title = repo.clean_title(text).ok()?;
866    repo.find_similar_issue_apart_from(&title, &child_body(text, tracker), Some(tracker))
867}
868
869/// What a child issue says, when spar has to file one. The item's own words,
870/// and where they came from.
871fn child_body(text: &str, tracker: i64) -> String {
872    format!("{text}\n\nFrom the checklist in #{tracker}.")
873}
874
875// ---------------------------------------------------------------------------
876// Acting
877// ---------------------------------------------------------------------------
878
879/// Work the checklist in one tracker, and hand back the children to work.
880///
881/// The children are ordinary issues from here on: they go through two agent
882/// triage like anything else, which is what makes deterministic extraction
883/// safe. An item that is stale or already fixed is declined there rather than
884/// judged here.
885pub fn decompose(cfg: &Config, repo: &Repo, tracker: i64) -> Vec<i64> {
886    let Some((body, slug)) = read(repo, tracker) else {
887        return Vec::new();
888    };
889    let steps = plan(repo, cfg, tracker, &body, &slug);
890    if steps.is_empty() {
891        logdim!("#{tracker} has no unchecked checklist items, so there is nothing to extract");
892        return Vec::new();
893    }
894    log!("#{tracker}: {} unchecked checklist item(s)", steps.len());
895    report_overflow(cfg, tracker, &steps);
896    apply(repo, tracker, &steps)
897}
898
899fn apply(repo: &Repo, tracker: i64, steps: &[Step]) -> Vec<i64> {
900    let mut children = Vec::new();
901    for step in steps {
902        let what = style::clip(&style::one_line(&step.item.text), 80);
903        match &step.action {
904            Action::Hold(why) => logdim!("  left '{what}' alone: {why}"),
905            // Already named by `report_overflow`, in one line rather than one
906            // line each.
907            Action::Over => {}
908            Action::Adopt(number) => {
909                log!("  '{what}' is already #{number}");
910                children.push(*number);
911            }
912            Action::Tick(number) => {
913                let Some(change) = step.action.change() else {
914                    continue;
915                };
916                if write(repo, tracker, &step.item.raw, &change) {
917                    log!("  ticked '{what}' off, #{number} is finished");
918                }
919            }
920            // Both titles, always. The match is fuzzy, and a wrong adoption
921            // puts a false claim in somebody's plan.
922            Action::Link {
923                number,
924                title,
925                open,
926            } => {
927                log!("  linking '{what}' to #{number} '{title}', filed nothing");
928                let Some(change) = step.action.change() else {
929                    continue;
930                };
931                if write(repo, tracker, &step.item.raw, &change) && *open {
932                    children.push(*number);
933                }
934            }
935            Action::File => {
936                let Ok(title) = repo.clean_title(&step.item.text) else {
937                    logdim!("  could not clean a title out of '{what}'");
938                    continue;
939                };
940                // Asked again, against the tracker as it stands, because filing
941                // is the one step that leaves something behind. An item
942                // somebody deleted while the run was working must not still get
943                // an issue for work the tracker no longer asks for.
944                if !still_asked_for(repo, tracker, &step.item.raw) {
945                    logdim!("  '{what}' is no longer in #{tracker}, so nothing was filed for it");
946                    continue;
947                }
948                match review::file_as_issue_apart_from(
949                    repo,
950                    &title,
951                    &child_body(&step.item.text, tracker),
952                    Some(tracker),
953                ) {
954                    Ok(filed) => {
955                        let number = filed.issue();
956                        log!("  {} for '{what}'", filed.note());
957                        // The link is written the moment the issue exists, not
958                        // once at the end over the whole checklist. The window
959                        // is then one item wide and falls on the side of filing
960                        // twice rather than losing a link, which the similarity
961                        // search catches next run like any other duplicate.
962                        let linked = write(
963                            repo,
964                            tracker,
965                            &step.item.raw,
966                            &Change::Reference(format!("#{number}")),
967                        );
968                        match linked {
969                            true if filed.number().is_some() => children.push(number),
970                            true => {}
971                            false => logwarn!(
972                                "  '{what}' went to #{number}, but #{tracker} does not link to it"
973                            ),
974                        }
975                    }
976                    Err(e) => logdim!("  could not file an issue for '{what}': {e}"),
977                }
978            }
979        }
980    }
981    unique_children(children)
982}
983
984fn unique_children(mut children: Vec<i64>) -> Vec<i64> {
985    let mut seen = BTreeSet::new();
986    children.retain(|number| seen.insert(*number));
987    children
988}
989
990/// Whether the tracker still carries this exact line, once and only once, and
991/// still reads it as a checklist item.
992///
993/// Both halves, because an edit that lands mid-run can leave the bytes exactly
994/// as they were and still change what they mean. Fencing the line, or opening
995/// a comment above it, is a person saying not this one, and matching the raw
996/// text alone would file for it and rewrite it inside the fence.
997fn still_an_item(body: &str, raw: &str) -> bool {
998    let lines = split_keep(body)
999        .into_iter()
1000        .filter(|line| without_eol(line) == raw)
1001        .count();
1002    lines == 1 && parse(body).iter().filter(|item| item.raw == raw).count() == 1
1003}
1004
1005/// The same question, asked of the tracker as it stands.
1006///
1007/// Unreadable counts as no: this gates filing, and an issue filed against a
1008/// tracker that cannot be read is one nothing will link.
1009fn still_asked_for(repo: &Repo, tracker: i64, raw: &str) -> bool {
1010    match repo.read_issue(tracker) {
1011        Ok(issue) => still_an_item(issue.body_text(), raw),
1012        Err(e) => {
1013            logdim!("  could not re-read #{tracker}: {}", e.first_line());
1014            false
1015        }
1016    }
1017}
1018
1019/// Re-read, rewrite one line, write back.
1020///
1021/// The body is read again here rather than reused from the copy this run
1022/// parsed. A run is long, and somebody editing the tracker while it goes must
1023/// not lose that edit: if the line has moved or changed, or the markdown around
1024/// it has stopped making it an item, this is a skip with a log line, never a
1025/// write.
1026fn write(repo: &Repo, tracker: i64, raw: &str, change: &Change) -> bool {
1027    let body = match repo.read_issue(tracker) {
1028        Ok(issue) => issue.body_text().to_string(),
1029        Err(e) => {
1030            logdim!("  could not re-read #{tracker}: {}", e.first_line());
1031            return false;
1032        }
1033    };
1034    if !still_an_item(&body, raw) {
1035        logdim!("  not editing #{tracker}: that line is no longer a checklist item in it");
1036        return false;
1037    }
1038    let updated = match rewrite(&body, raw, change) {
1039        Ok(updated) => updated,
1040        Err(e) => {
1041            logdim!("  not editing #{tracker}: {}", e.first_line());
1042            return false;
1043        }
1044    };
1045    match repo.edit_issue_body(tracker, &body, &updated, change.inserted()) {
1046        Ok(()) => true,
1047        Err(e) => {
1048            logdim!("  could not edit #{tracker}: {}", e.first_line());
1049            false
1050        }
1051    }
1052}
1053
1054fn report_overflow(cfg: &Config, tracker: i64, steps: &[Step]) {
1055    let left: Vec<String> = steps
1056        .iter()
1057        .filter(|s| s.action == Action::Over)
1058        .map(|s| format!("'{}'", style::clip(&style::one_line(&s.item.text), 60)))
1059        .collect();
1060    if left.is_empty() {
1061        return;
1062    }
1063    logwarn!(
1064        "#{tracker} has more unchecked items than max_tracker_children ({}), so {} were left for \
1065         a later run: {}",
1066        cfg.loop_cfg.max_tracker_children,
1067        left.len(),
1068        left.join(", ")
1069    );
1070}
1071
1072fn read(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1073    // From the API, never from what triage rendered: `body_for_prompt`
1074    // shortens past `max_issue_chars`, and a tracker is precisely the long
1075    // issue that trips it. Parsing a shortened body drops the last items and
1076    // looks identical to a tracker that had fewer.
1077    match repo.read_issue(tracker) {
1078        Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1079        Err(e) => {
1080            logdim!("could not read #{tracker}: {}", e.first_line());
1081            None
1082        }
1083    }
1084}
1085
1086/// This repository's address, taken from the tracker's own url by dropping the
1087/// `/issues/29` off the end.
1088///
1089/// Read rather than assembled from `owner/repo`, because the host is half the
1090/// answer: a link to `elsewhere.example/me/mine/issues/7` shares the path and
1091/// is not this repository's issue. Empty when there is no url to read, which
1092/// holds every linked item rather than guessing at one.
1093fn home_of(url: &str) -> String {
1094    match url.rfind("/issues/") {
1095        Some(at) => url[..at].to_string(),
1096        None => String::new(),
1097    }
1098}
1099
1100// ---------------------------------------------------------------------------
1101// The read only half
1102// ---------------------------------------------------------------------------
1103
1104/// Print the decomposition `spar run` would perform, and write none of it.
1105///
1106/// `spar triage` is the command you reach for to look before leaping, so a
1107/// preview that files issues and rewrites somebody's issue body is exactly the
1108/// trap that comment names. This is where the first few real trackers should be
1109/// checked.
1110pub fn preview(cfg: &Config, repo: &Repo, tracker: i64) {
1111    let Some((body, slug)) = read(repo, tracker) else {
1112        return;
1113    };
1114    let steps = plan(repo, cfg, tracker, &body, &slug);
1115    if steps.is_empty() {
1116        return;
1117    }
1118    println!("\n#{tracker}, if decompose_trackers let it act on the checklist:");
1119
1120    let mut projected = body.clone();
1121    for step in &steps {
1122        let what = style::clip(&style::one_line(&step.item.text), 80);
1123        match &step.action {
1124            Action::Adopt(number) => println!("  keep  '{what}' is already #{number}"),
1125            Action::Tick(number) => println!("  tick  '{what}', #{number} is finished"),
1126            Action::Link {
1127                number,
1128                title,
1129                open,
1130            } => {
1131                let state = if *open { "open" } else { "closed" };
1132                println!("  link  '{what}' to #{number} '{title}' ({state}), filing nothing");
1133            }
1134            Action::File => println!("  file  '{what}'"),
1135            Action::Over => println!("  over  '{what}' is past max_tracker_children"),
1136            Action::Hold(why) => println!("  hold  '{what}': {why}"),
1137        }
1138        // The same mapping the acting path uses, so what is printed here and
1139        // what a run would write cannot drift. Only `File` is the preview's own,
1140        // since the issue it would link to does not exist yet.
1141        let change = match &step.action {
1142            Action::File => Some(Change::Reference(FILED.to_string())),
1143            other => other.change(),
1144        };
1145        let Some(change) = change else { continue };
1146        match rewrite(&projected, &step.item.raw, &change) {
1147            Ok(next) => projected = next,
1148            Err(e) => println!("        the line will not be rewritten: {e}"),
1149        }
1150    }
1151
1152    let diff = diff(&body, &projected);
1153    if diff.is_empty() {
1154        println!("  nothing would be written to the body");
1155    } else {
1156        println!("  and the body it would write:");
1157        for line in diff {
1158            println!("    {line}");
1159        }
1160    }
1161}
1162
1163/// Stands in for a number that does not exist yet, so the preview can show the
1164/// line an item would get without pretending to know which issue it will be.
1165const FILED: &str = "#(the issue it files)";
1166
1167/// The changed lines, old then new. Line local edits only, so the two bodies
1168/// always have the same number of lines and nothing has to be aligned.
1169fn diff(before: &str, after: &str) -> Vec<String> {
1170    split_keep(before)
1171        .into_iter()
1172        .zip(split_keep(after))
1173        .filter(|(old, new)| old != new)
1174        .flat_map(|(old, new)| {
1175            [
1176                format!("- {}", without_eol(old)),
1177                format!("+ {}", without_eol(new)),
1178            ]
1179        })
1180        .collect()
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186
1187    /// Where the tests' own repository lives, as `read` would work it out.
1188    const HOME: &str = "https://github.com/me/mine";
1189
1190    fn texts(body: &str) -> Vec<String> {
1191        parse(body).into_iter().map(|i| i.text).collect()
1192    }
1193
1194    #[test]
1195    fn the_ordinary_checklist_is_read_as_items() {
1196        let items = parse("Some prose.\n\n- [ ] first\n- [x] second\n");
1197        assert_eq!(2, items.len());
1198        assert_eq!("first", items[0].text);
1199        assert!(!items[0].checked);
1200        assert!(items[1].checked);
1201        assert_eq!(3, items[0].line);
1202    }
1203
1204    /// Nested and indented items are ordinary items: every edit here is line
1205    /// local, so the shape of the list around one does not matter.
1206    #[test]
1207    fn indented_and_nested_items_are_items() {
1208        let body = "- [ ] parent\n  - [ ] child\n\t- [ ] tabbed\n    * [ ] deeper\n1. [ ] ordered\n2) [ ] also ordered\n";
1209        assert_eq!(
1210            vec![
1211                "parent",
1212                "child",
1213                "tabbed",
1214                "deeper",
1215                "ordered",
1216                "also ordered"
1217            ],
1218            texts(body)
1219        );
1220    }
1221
1222    /// The failure that would file issues for somebody's example markdown.
1223    #[test]
1224    fn something_that_looks_like_an_item_inside_a_fence_is_not_one() {
1225        let body = "\
1226- [ ] real
1227
1228```markdown
1229- [ ] not real
1230```
1231
1232~~~
1233- [ ] also not real
1234~~~
1235
1236- [ ] real again
1237";
1238        assert_eq!(vec!["real", "real again"], texts(body));
1239    }
1240
1241    /// Four spaces outside a list is a code block on GitHub, and filing an
1242    /// issue for somebody's example is the failure the fence check exists to
1243    /// prevent.
1244    #[test]
1245    fn an_indented_code_block_is_not_a_checklist() {
1246        let body = "\
1247Write the parts like this:
1248
1249    - [ ] an example, not an item
1250
1251- [ ] real
1252  - [ ] nested
1253- plain bullet
1254    - [ ] nested under a bullet
1255";
1256        assert_eq!(vec!["real", "nested", "nested under a bullet"], texts(body));
1257    }
1258
1259    /// Four spaces is measured from the enclosing item's content, not from the
1260    /// margin: under `- outer` the content starts in column 2, so six spaces
1261    /// are an example inside that item and two are a nested task.
1262    #[test]
1263    fn code_indented_inside_a_list_item_is_still_code() {
1264        let body = "\
1265- outer
1266
1267      - [ ] an example, not an item
1268
1269  - [ ] nested
1270- plain
1271    - [ ] nested under a bullet
1272        - [ ] and under that one
1273";
1274        assert_eq!(
1275            vec!["nested", "nested under a bullet", "and under that one"],
1276            texts(body)
1277        );
1278    }
1279
1280    /// GitHub renders the inside of these verbatim, so a checkbox in one is
1281    /// text somebody is showing.
1282    #[test]
1283    fn an_item_inside_raw_html_is_not_one() {
1284        let body = "\
1285- [ ] real
1286
1287<pre>
1288- [ ] not real
1289</pre>
1290
1291<textarea>
1292- [ ] also not real
1293</textarea>
1294
1295- [ ] real again
1296";
1297        assert_eq!(vec!["real", "real again"], texts(body));
1298    }
1299
1300    /// Markdown inside a block tag is not markdown: GitHub prints the checkbox
1301    /// as the text it is. The block ends at a blank line and not at the closing
1302    /// tag, which is what keeps the `<details>` a tracker is often written in
1303    /// working.
1304    #[test]
1305    fn an_item_inside_a_block_tag_is_not_one() {
1306        let body = "\
1307<div>
1308- [ ] not real
1309</div>
1310
1311<details>
1312<summary>the parts</summary>
1313
1314- [ ] real
1315</details>
1316";
1317        assert_eq!(vec!["real"], texts(body));
1318    }
1319
1320    /// More than four columns after the marker put the checkbox in an indented
1321    /// code block inside the item, which is how somebody writes down the syntax
1322    /// itself.
1323    #[test]
1324    fn a_checkbox_pushed_past_its_own_content_column_is_code() {
1325        assert_eq!(Vec::<String>::new(), texts("-     [ ] an example\n"));
1326        assert_eq!(vec!["real"], texts("-    [ ] real\n"));
1327    }
1328
1329    /// A fence four columns in is a fence somebody is showing, so it neither
1330    /// opens a block nor closes the one it sits in.
1331    #[test]
1332    fn a_fence_indented_into_code_neither_opens_nor_closes() {
1333        let body = "```\n- [ ] not real\n    ```\n- [ ] still not real\n";
1334        assert_eq!(Vec::<String>::new(), texts(body));
1335
1336        let body = "Like this:\n\n    ```\n- [ ] real\n";
1337        assert_eq!(vec!["real"], texts(body));
1338    }
1339
1340    /// The items somebody decided against are often kept in a comment. GitHub
1341    /// renders none of it, so neither does this.
1342    #[test]
1343    fn an_item_inside_an_html_comment_is_not_one() {
1344        let body = "\
1345- [ ] real
1346
1347<!--
1348- [ ] not real
1349-->
1350
1351- [ ] real again
1352<!-- - [ ] on one line, closed -->
1353- [ ] last
1354";
1355        assert_eq!(vec!["real", "real again", "last"], texts(body));
1356    }
1357
1358    /// A fence closes on its own glyph only, so a stray one of the other kind
1359    /// inside does not end the block early.
1360    #[test]
1361    fn a_fence_is_closed_only_by_its_own_kind() {
1362        let body = "~~~\n```\n- [ ] not real\n```\n~~~\n- [ ] real\n";
1363        assert_eq!(vec!["real"], texts(body));
1364    }
1365
1366    #[test]
1367    fn a_windows_body_is_read_the_same_way() {
1368        let items = parse("intro\r\n\r\n- [ ] first\r\n- [x] second\r\n");
1369        assert_eq!(2, items.len());
1370        assert_eq!("first", items[0].text);
1371        assert!(items[1].checked);
1372        assert_eq!(
1373            "- [ ] first", items[0].raw,
1374            "the terminator is not part of the handle"
1375        );
1376    }
1377
1378    #[test]
1379    fn a_reference_is_read_from_a_number_or_a_link() {
1380        let items = parse(
1381            "- [ ] one #12\n\
1382             - [ ] two https://github.com/o/r/issues/34\n\
1383             - [ ] [three](https://github.com/o/r/issues/56)\n\
1384             - [ ] four\n",
1385        );
1386        assert_eq!(Some(12), items[0].reference.as_ref().map(|r| r.number));
1387        assert_eq!(Some(34), items[1].reference.as_ref().map(|r| r.number));
1388        assert_eq!(Some(56), items[2].reference.as_ref().map(|r| r.number));
1389        assert_eq!(None, items[3].reference);
1390    }
1391
1392    /// An item is very often written down as the change that closes it.
1393    /// `resolve` answers for a pull request already, so reading only `/issues/`
1394    /// would file a second issue for work in flight.
1395    #[test]
1396    fn a_link_to_a_pull_request_is_a_reference_too() {
1397        let items = parse(
1398            "- [ ] one https://github.com/me/mine/pull/42\n\
1399             - [ ] two https://github.com/me/mine/pull/43/files\n\
1400             - [ ] three https://github.com/other/thing/pull/44\n",
1401        );
1402        assert_eq!(Some(42), items[0].reference.as_ref().unwrap().local(HOME));
1403        assert_eq!(Some(43), items[1].reference.as_ref().unwrap().local(HOME));
1404        assert_eq!(None, items[2].reference.as_ref().unwrap().local(HOME));
1405    }
1406
1407    /// An item whose whole text is a link to somewhere else is not a reference
1408    /// to anything spar can act on.
1409    #[test]
1410    fn an_item_that_is_a_link_to_something_else_names_no_issue() {
1411        let items = parse("- [ ] [the docs](https://example.com/guide)\n");
1412        assert_eq!(None, items[0].reference);
1413        assert_eq!("[the docs](https://example.com/guide)", items[0].text);
1414    }
1415
1416    /// Taking the number out of another repository's link would point the item
1417    /// at whatever happens to carry that number here.
1418    #[test]
1419    fn a_link_to_another_repository_is_not_adoptable() {
1420        let items = parse("- [ ] see https://github.com/other/thing/issues/7\n");
1421        let reference = items[0].reference.as_ref().expect("a reference");
1422        assert_eq!(None, reference.local(HOME));
1423        assert_eq!(Some(7), reference.local("https://github.com/other/thing"));
1424    }
1425
1426    /// A bare number can only mean this repository, so it needs no slug.
1427    #[test]
1428    fn a_bare_number_resolves_wherever_it_is_read() {
1429        let items = parse("- [ ] work #7\n");
1430        assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(""));
1431    }
1432
1433    /// The path is half the answer. Another host serving `me/mine` is somebody
1434    /// else's, and adopting it would tick a local issue nobody named.
1435    #[test]
1436    fn a_link_to_the_same_path_on_another_host_is_not_this_repository() {
1437        for url in [
1438            "https://gitlab.example/me/mine/issues/7",
1439            "https://github.com/mirror/me/mine/issues/7",
1440        ] {
1441            let items = parse(&format!("- [ ] see {url}\n"));
1442            let reference = items[0].reference.as_ref().expect("a reference");
1443            assert_eq!(None, reference.local(HOME), "{url}");
1444        }
1445    }
1446
1447    /// http and https to the same issue are the same issue.
1448    #[test]
1449    fn the_scheme_is_not_what_makes_a_link_somebody_elses() {
1450        let items = parse("- [ ] see http://github.com/me/mine/issues/7\n");
1451        assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(HOME));
1452    }
1453
1454    /// An issue url with the tail taken off is the address every other link is
1455    /// measured against.
1456    #[test]
1457    fn home_is_read_off_the_trackers_own_url() {
1458        assert_eq!(HOME, home_of("https://github.com/me/mine/issues/29"));
1459        assert_eq!("", home_of(""));
1460    }
1461
1462    /// A number in backticks is somebody writing about it, and the reference
1463    /// they meant is the one outside.
1464    #[test]
1465    fn a_number_in_a_code_span_names_nothing() {
1466        let items = parse(
1467            "- [ ] Handle the literal `#12`, tracked in #34\n\
1468             - [ ] Only ``a #12 in a double span``\n",
1469        );
1470        assert_eq!(Some(34), items[0].reference.as_ref().map(|r| r.number));
1471        assert_eq!(None, items[1].reference);
1472    }
1473
1474    /// A comment is not rendered, so a number left in one is a note to a person
1475    /// and never the issue the item is about. Ticking the box because that
1476    /// issue happens to be closed would call somebody's work done.
1477    #[test]
1478    fn a_number_in_a_comment_names_nothing() {
1479        let items = parse(
1480            "- [ ] ship it <!-- old note: #7 -->\n\
1481             - [ ] and this one <!-- #7 --> #8\n",
1482        );
1483        assert_eq!(None, items[0].reference);
1484        assert_eq!(Some(8), items[1].reference.as_ref().map(|r| r.number));
1485    }
1486
1487    /// The `#8` in an address is a fragment of it. Only a link that names an
1488    /// issue by path is read as one.
1489    #[test]
1490    fn a_fragment_in_a_link_is_not_an_issue_number() {
1491        let items = parse(
1492            "- [ ] update [docs](https://example.com/guide/#8)\n\
1493             - [ ] see https://example.com/guide#9 and #10\n",
1494        );
1495        assert_eq!(None, items[0].reference);
1496        assert_eq!(Some(10), items[1].reference.as_ref().map(|r| r.number));
1497    }
1498
1499    /// GitHub links both of these without a url, so an item that carries one is
1500    /// an item that already names its issue. Reading neither filed a second
1501    /// issue for work the tracker had already written down.
1502    #[test]
1503    fn the_shorthands_github_links_are_references_too() {
1504        let items = parse(
1505            "- [ ] one me/mine#12\n\
1506             - [ ] two other/thing#13\n\
1507             - [ ] three GH-14\n",
1508        );
1509        assert_eq!(Some(12), items[0].reference.as_ref().unwrap().local(HOME));
1510        let foreign = items[1].reference.as_ref().expect("a reference");
1511        assert_eq!(None, foreign.local(HOME), "somebody else's repository");
1512        assert_eq!("other/thing#13", foreign.names());
1513        assert_eq!(Some(14), items[2].reference.as_ref().unwrap().local(HOME));
1514    }
1515
1516    /// The shorthand leaves the host out, so it means this repository wherever
1517    /// this repository is served from. The path still has to be this one's.
1518    #[test]
1519    fn a_shorthand_is_read_against_this_repositorys_path() {
1520        let items = parse("- [ ] work me/mine#7\n");
1521        let reference = items[0].reference.as_ref().expect("a reference");
1522        assert_eq!(Some(7), reference.local("https://ghe.example/me/mine"));
1523        assert_eq!(None, reference.local("https://github.com/me/other"));
1524        assert_eq!(None, reference.local(""), "no address to measure against");
1525    }
1526
1527    /// A link's label captions its destination. Reading the label first turned
1528    /// another repository's issue into a bare local number, which is exactly
1529    /// the adoption the foreign repository check exists to refuse.
1530    #[test]
1531    fn a_link_is_read_from_its_destination_and_not_its_label() {
1532        let items = parse(
1533            "- [ ] [other/widgets #7](https://github.com/other/widgets/issues/7)\n\
1534             - [ ] [me/mine #7](https://github.com/me/mine/issues/7)\n",
1535        );
1536        let foreign = items[0].reference.as_ref().expect("a reference");
1537        assert!(
1538            matches!(foreign.origin, Origin::Url(_)),
1539            "the destination, not the label"
1540        );
1541        assert_eq!(None, foreign.local(HOME));
1542        assert_eq!(Some(7), items[1].reference.as_ref().unwrap().local(HOME));
1543    }
1544
1545    /// Nested brackets and escaped closing brackets are both valid inside a
1546    /// link label. Stopping at either one exposes the label's local-looking
1547    /// number and hides the foreign destination.
1548    #[test]
1549    fn complex_link_labels_still_read_the_destination() {
1550        let items = parse(
1551            "- [ ] [see [#7]](https://github.com/other/widgets/issues/8)\n\
1552             - [ ] [see \\] #7](https://github.com/other/widgets/issues/8)\n",
1553        );
1554        for item in items {
1555            let reference = item.reference.expect("the destination");
1556            assert_eq!(8, reference.number);
1557            assert!(matches!(reference.origin, Origin::Url(_)));
1558            assert_eq!(None, reference.local(HOME));
1559        }
1560    }
1561
1562    #[test]
1563    fn one_child_referenced_by_several_items_is_worked_once() {
1564        assert_eq!(vec![8, 9], unique_children(vec![8, 8, 9, 8]));
1565    }
1566
1567    // -- the line surgery -------------------------------------------------
1568
1569    #[test]
1570    fn a_reference_is_appended_to_its_own_line_and_nowhere_else() {
1571        let body = "intro\n\n- [ ] first\n- [ ] second\n\nmore prose\n";
1572        let out =
1573            rewrite(body, "- [ ] first", &Change::Reference("#40".into())).expect("a rewrite");
1574        assert_eq!(
1575            "intro\n\n- [ ] first #40\n- [ ] second\n\nmore prose\n",
1576            out
1577        );
1578    }
1579
1580    /// Two trailing spaces are a markdown hard break, and `style::scrub` would
1581    /// eat them. The reference goes before them.
1582    #[test]
1583    fn a_hard_break_survives_the_edit() {
1584        let out = rewrite(
1585            "- [ ] first  \nnext\n",
1586            "- [ ] first  ",
1587            &Change::Reference("#4".into()),
1588        )
1589        .expect("a rewrite");
1590        assert_eq!("- [ ] first #4  \nnext\n", out);
1591    }
1592
1593    #[test]
1594    fn every_other_line_comes_through_byte_identical() {
1595        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";
1596        let out = rewrite(body, "- [ ] one", &Change::Reference("#9".into())).expect("a rewrite");
1597        let (before, after): (Vec<&str>, Vec<&str>) =
1598            (body.lines().collect(), out.lines().collect());
1599        assert_eq!(before.len(), after.len());
1600        for (i, (a, b)) in before.iter().zip(&after).enumerate() {
1601            if i == 3 {
1602                assert_eq!("- [ ] one #9", *b);
1603            } else {
1604                assert_eq!(a, b, "line {} changed", i + 1);
1605            }
1606        }
1607        assert!(out.contains("trailing spaces here   \r\n"));
1608        assert!(out.contains("\r\n\r\n\r\n\r\n"));
1609    }
1610
1611    #[test]
1612    fn a_body_with_no_final_newline_keeps_not_having_one() {
1613        let out = rewrite("- [ ] only", "- [ ] only", &Change::Tick).expect("a rewrite");
1614        assert_eq!("- [x] only", out);
1615    }
1616
1617    #[test]
1618    fn ticking_changes_the_box_and_leaves_the_text() {
1619        let out = rewrite("  - [ ] deep #3\n", "  - [ ] deep #3", &Change::Tick).expect("a tick");
1620        assert_eq!("  - [x] deep #3\n", out);
1621    }
1622
1623    /// spar checks a box and never unchecks one, so there is no change that
1624    /// could and nothing to do to one that is already ticked.
1625    #[test]
1626    fn a_ticked_box_is_never_written_again() {
1627        assert!(rewrite("- [x] done\n", "- [x] done", &Change::Tick).is_err());
1628        assert!(!matches!(Change::Tick, Change::Reference(_)));
1629    }
1630
1631    #[test]
1632    fn a_line_that_is_gone_or_ambiguous_is_a_refusal_not_a_guess() {
1633        assert!(rewrite("- [ ] a\n", "- [ ] b", &Change::Tick).is_err());
1634        let twice = "- [ ] same\n- [ ] same\n";
1635        assert!(rewrite(twice, "- [ ] same", &Change::Tick).is_err());
1636    }
1637
1638    #[test]
1639    fn an_item_with_no_text_gets_no_reference() {
1640        assert!(rewrite("- [ ]\n", "- [ ]", &Change::Reference("#1".into())).is_err());
1641    }
1642
1643    // -- shaping ----------------------------------------------------------
1644
1645    fn shapes(body: &str, max: usize) -> Vec<Shape> {
1646        shape(body, HOME, max).into_iter().map(|(_, s)| s).collect()
1647    }
1648
1649    #[test]
1650    fn a_checked_item_is_never_reconsidered() {
1651        assert!(shapes("- [x] done\n", 5).is_empty());
1652    }
1653
1654    #[test]
1655    fn an_item_that_names_an_issue_is_kept_apart_from_one_that_does_not() {
1656        assert_eq!(
1657            vec![Shape::Names(12), Shape::Needs],
1658            shapes("- [ ] one #12\n- [ ] two\n", 5)
1659        );
1660    }
1661
1662    /// A cap, not a target, and what it left is named out loud rather than
1663    /// quietly dropped.
1664    #[test]
1665    fn the_cap_stops_at_the_cap() {
1666        let body = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d\n";
1667        assert_eq!(
1668            vec![Shape::Needs, Shape::Needs, Shape::Over, Shape::Over],
1669            shapes(body, 2)
1670        );
1671    }
1672
1673    /// A checked item does not spend the budget, since nothing is done to it.
1674    #[test]
1675    fn the_cap_counts_only_what_it_acts_on() {
1676        let body = "- [x] a\n- [x] b\n- [ ] c\n";
1677        assert_eq!(vec![Shape::Needs], shapes(body, 1));
1678    }
1679
1680    #[test]
1681    fn two_identical_items_are_left_alone() {
1682        let out = shapes("- [ ] same\n- [ ] same\n", 5);
1683        assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1684        assert!(matches!(out[1], Shape::Hold(_)), "{out:?}");
1685    }
1686
1687    #[test]
1688    fn an_item_naming_another_repository_is_held_rather_than_adopted() {
1689        let out = shapes("- [ ] see https://github.com/other/thing/issues/7\n", 5);
1690        assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1691    }
1692
1693    /// The whole thing on one body of the shape a person actually writes: what
1694    /// each item is taken for, and exactly what comes out the other side.
1695    #[test]
1696    fn a_realistic_tracker_keeps_every_line_it_was_not_asked_to_change() {
1697        let body = "\
1698Context somebody wrote, with a hard break here:
1699and the rest of it.
1700
1701## Parts
1702
1703- [x] already done
1704- [ ] parse the checklist
1705- [ ] write the link back #40
1706  - [ ] and prove it first
1707
1708```markdown
1709- [ ] an example, not an item
1710```
1711
1712That is all.
1713";
1714        let shapes: Vec<Shape> = shape(body, HOME, 5).into_iter().map(|(_, s)| s).collect();
1715        assert_eq!(
1716            vec![Shape::Needs, Shape::Names(40), Shape::Needs],
1717            shapes,
1718            "the ticked item, the fenced one and the prose are all left out"
1719        );
1720
1721        let out = rewrite(
1722            body,
1723            "- [ ] parse the checklist",
1724            &Change::Reference("#41".into()),
1725        )
1726        .expect("a link");
1727        let out = rewrite(
1728            &out,
1729            "  - [ ] and prove it first",
1730            &Change::Reference("#42".into()),
1731        )
1732        .expect("a nested link");
1733        let out = rewrite(&out, "- [ ] write the link back #40", &Change::Tick).expect("a tick");
1734
1735        assert_eq!(
1736            "\
1737Context somebody wrote, with a hard break here:
1738and the rest of it.
1739
1740## Parts
1741
1742- [x] already done
1743- [ ] parse the checklist #41
1744- [x] write the link back #40
1745  - [ ] and prove it first #42
1746
1747```markdown
1748- [ ] an example, not an item
1749```
1750
1751That is all.
1752",
1753            out
1754        );
1755    }
1756
1757    // -- what each decision writes ----------------------------------------
1758
1759    /// The match is fuzzy, so a wrong adoption that also ticked the box would
1760    /// be spar asserting work is done that nobody did. The link goes in this
1761    /// run and the tick waits for the next, by which time the item carries its
1762    /// own reference and a person has had the chance to see it.
1763    #[test]
1764    fn an_item_linked_by_similarity_is_not_ticked_in_the_same_run() {
1765        for open in [true, false] {
1766            let action = Action::Link {
1767                number: 7,
1768                title: "something close enough".into(),
1769                open,
1770            };
1771            assert_eq!(Some(Change::Reference("#7".into())), action.change());
1772        }
1773    }
1774
1775    /// An item that carried its reference before spar touched it is in no such
1776    /// doubt, so it is ticked on the spot.
1777    #[test]
1778    fn an_item_that_already_named_its_issue_is_ticked_when_that_issue_closes() {
1779        assert_eq!(Some(Change::Tick), Action::Tick(7).change());
1780    }
1781
1782    /// Adopting is reading, not writing: the line already says what it says.
1783    #[test]
1784    fn nothing_is_written_for_an_item_that_is_already_linked_and_open() {
1785        assert_eq!(None, Action::Adopt(7).change());
1786        assert_eq!(None, Action::Over.change());
1787        assert_eq!(None, Action::Hold("any reason".into()).change());
1788    }
1789
1790    // -- the guard before a write -----------------------------------------
1791
1792    /// The bytes of the line are not the whole of what it means. Somebody
1793    /// fencing an item mid-run is saying not this one, and the raw text is
1794    /// still there to match.
1795    #[test]
1796    fn a_line_that_stopped_being_an_item_is_not_written_to() {
1797        let raw = "- [ ] ship it";
1798        assert!(still_an_item("intro\n\n- [ ] ship it\n", raw));
1799        assert!(!still_an_item("```\n- [ ] ship it\n```\n", raw));
1800        assert!(!still_an_item("<!--\n- [ ] ship it\n-->\n", raw));
1801        assert!(!still_an_item("- [ ] something else\n", raw));
1802        assert!(
1803            !still_an_item("- [ ] ship it\n- [ ] ship it\n", raw),
1804            "two alike is a line the edit could go to either of"
1805        );
1806    }
1807
1808    // -- the preview ------------------------------------------------------
1809
1810    #[test]
1811    fn the_diff_shows_only_the_lines_that_change() {
1812        let before = "- [ ] one\n- [ ] two\n";
1813        let after =
1814            rewrite(before, "- [ ] two", &Change::Reference("#8".into())).expect("a rewrite");
1815        assert_eq!(
1816            vec!["- - [ ] two".to_string(), "+ - [ ] two #8".to_string()],
1817            diff(before, &after)
1818        );
1819    }
1820}