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