1use std::collections::{BTreeMap, BTreeSet};
23use std::sync::LazyLock;
24
25use regex::Regex;
26
27use crate::config::Config;
28use crate::error::Result;
29use crate::model::{Issue, ItemKind};
30use crate::repo::Repo;
31use crate::review;
32use crate::style;
33use crate::{bail, log, logdim, logwarn, spar_err};
34
35static 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
48fn item_of(line: &str) -> Option<regex::Captures<'_>> {
55 let caps = ITEM.captures(line)?;
56 (indent_width(&caps["gap"]) <= 4).then_some(caps)
57}
58
59static 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
68static 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
79static 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98enum Html {
99 Verbatim,
101 Block,
103}
104
105static FENCE: LazyLock<Regex> = LazyLock::new(|| {
108 Regex::new(r"^[ \t]*(?P<fence>`{3,}|~{3,})(?P<info>.*)$").expect("fence pattern")
109});
110
111static 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
120static LINK: LazyLock<Regex> =
125 LazyLock::new(|| Regex::new(r"https?://[^\s<>)\]]*").expect("link pattern"));
126
127static COMMENT: LazyLock<Regex> =
130 LazyLock::new(|| Regex::new(r"(?s)<!--.*?(?:-->|$)").expect("comment pattern"));
131
132static 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#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum Origin {
147 Here,
149 Repo(String),
152 Url(String),
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct Reference {
159 pub number: i64,
160 pub origin: Origin,
161}
162
163impl Reference {
164 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 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
197fn 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
208fn 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#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct Item {
222 pub line: usize,
224 pub raw: String,
228 pub text: String,
230 pub checked: bool,
231 pub reference: Option<Reference>,
232}
233
234pub fn parse(body: &str) -> Vec<Item> {
242 parse_reporting(body).0
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct Skipped {
252 pub line: usize,
253 pub text: String,
254 pub why: &'static str,
255}
256
257pub fn parse_reporting(body: &str) -> (Vec<Item>, Vec<Skipped>) {
258 let mut out = Vec::new();
259 let mut skipped: Vec<Skipped> = Vec::new();
260 let note = |index: usize, line: &str, why: &'static str, skipped: &mut Vec<Skipped>| {
261 let quoted = line.trim_start().trim_start_matches('>').trim_start();
262 if item_of(quoted).is_some() || item_of(line).is_some() {
263 skipped.push(Skipped {
264 line: index + 1,
265 text: quoted.to_string(),
266 why,
267 });
268 }
269 };
270 let mut fence: Option<(char, usize, usize)> = None;
273 let mut comment = false;
274 let mut html: Option<Html> = None;
275 let mut open: Vec<usize> = Vec::new();
279
280 for (index, raw) in split_keep(body).into_iter().enumerate() {
281 let line = without_eol(raw);
282 if comment {
285 comment = !line.contains("-->");
286 note(index, line, "it is inside an HTML comment", &mut skipped);
287 continue;
288 }
289 if let Some(kind) = html {
290 let ends = match kind {
291 Html::Verbatim => HTML_CLOSE.is_match(line),
292 Html::Block => line.trim().is_empty(),
293 };
294 if ends {
295 html = None;
296 }
297 note(index, line, "it is inside a raw HTML block", &mut skipped);
298 continue;
299 }
300 let indent = indent_width(line);
301 if let Some((glyph, len, column)) = fence {
302 if let Some(caps) = FENCE.captures(line) {
305 let marker = &caps["fence"];
306 let closes = marker.starts_with(glyph)
307 && marker.len() >= len
308 && caps["info"].trim().is_empty()
309 && indent < column + 4;
310 if closes {
311 fence = None;
312 }
313 }
314 note(index, line, "it is inside a code fence", &mut skipped);
315 continue;
316 }
317 if !line.trim().is_empty() {
320 while open.last().is_some_and(|col| indent < *col) {
321 open.pop();
322 }
323 }
324 let margin = open.last().copied().unwrap_or(0);
328 if !line.trim().is_empty() && indent >= margin + 4 {
329 continue;
330 }
331 if let Some(caps) = FENCE.captures(line) {
334 let marker = &caps["fence"];
335 fence = Some((
336 marker.chars().next().expect("a fence"),
337 marker.len(),
338 indent,
339 ));
340 continue;
341 }
342 if let Some(column) = content_column(line) {
343 open.push(column);
344 }
345 if HTML_VERBATIM.is_match(line) {
346 html = (!HTML_CLOSE.is_match(line)).then_some(Html::Verbatim);
347 continue;
348 }
349 if HTML_BLOCK.is_match(line) {
350 html = Some(Html::Block);
351 continue;
352 }
353 comment = opens_comment(line);
354 let Some(caps) = item_of(line) else {
355 if line.trim_start().starts_with('>') {
356 note(index, line, "it is inside a block quote", &mut skipped);
357 }
358 continue;
359 };
360 let text = caps["rest"].trim().to_string();
361 out.push(Item {
362 line: index + 1,
363 raw: line.to_string(),
364 reference: reference_in(&text),
365 text,
366 checked: &caps["state"] != " ",
367 });
368 }
369 (out, skipped)
370}
371
372fn content_column(line: &str) -> Option<usize> {
377 let caps = LIST.captures(line)?;
378 let gap = indent_width(&caps["gap"]);
379 if gap == 0 && !caps["rest"].is_empty() {
380 return None;
381 }
382 let gap = match (1..=4).contains(&gap) && !caps["rest"].trim().is_empty() {
383 true => gap,
384 false => 1,
385 };
386 Some(indent_width(&caps["indent"]) + caps["marker"].len() + gap)
387}
388
389fn reference_in(text: &str) -> Option<Reference> {
401 let text = &readable(text);
402 let url = URL_REF.captures(text);
403 let outside = blank(text, &LINK);
404 let hash = HASH_REF.captures(&outside);
405 let at = |caps: &Option<regex::Captures>| {
406 caps.as_ref()
407 .map(|c| c.get(0).expect("the whole match").start())
408 };
409 match (at(&url), at(&hash)) {
412 (Some(u), Some(h)) if h < u => hash.map(as_hash),
413 (Some(_), _) => url.map(as_url),
414 (None, Some(_)) => hash.map(as_hash),
415 (None, None) => None,
416 }
417}
418
419fn as_hash(caps: regex::Captures) -> Reference {
420 Reference {
421 number: caps["number"].parse().unwrap_or_default(),
422 origin: match caps.name("slug") {
423 Some(slug) => Origin::Repo(slug.as_str().to_string()),
424 None => Origin::Here,
425 },
426 }
427}
428
429fn as_url(caps: regex::Captures) -> Reference {
430 Reference {
431 number: caps["number"].parse().unwrap_or_default(),
432 origin: Origin::Url(caps["url"].to_string()),
433 }
434}
435
436fn blank(text: &str, what: &Regex) -> String {
439 let mut out = text.as_bytes().to_vec();
440 for found in what.find_iter(text) {
441 out[found.range()].fill(b' ');
442 }
443 String::from_utf8(out).unwrap_or_else(|_| text.to_string())
445}
446
447fn readable(text: &str) -> String {
452 let text = blank(text, &COMMENT);
453 let text = text.as_str();
454 let bytes = text.as_bytes();
455 let mut out = bytes.to_vec();
456 let mut at = 0;
457 while at < bytes.len() {
458 match bytes[at] {
459 b'`' => {
460 let start = at;
461 while at < bytes.len() && bytes[at] == b'`' {
462 at += 1;
463 }
464 if let Some(end) = backtick_run(bytes, at, at - start) {
465 out[start..end].fill(b' ');
466 at = end;
467 }
468 }
469 b'[' => match label_end(bytes, at) {
470 Some(end) => {
471 out[at..end].fill(b' ');
472 at = end;
473 }
474 None => at += 1,
475 },
476 _ => at += 1,
477 }
478 }
479 String::from_utf8(out).unwrap_or_else(|_| text.to_string())
481}
482
483fn backtick_run(bytes: &[u8], from: usize, len: usize) -> Option<usize> {
486 let mut at = from;
487 while at < bytes.len() {
488 if bytes[at] != b'`' {
489 at += 1;
490 continue;
491 }
492 let start = at;
493 while at < bytes.len() && bytes[at] == b'`' {
494 at += 1;
495 }
496 if at - start == len {
497 return Some(at);
498 }
499 }
500 None
501}
502
503fn label_end(bytes: &[u8], open: usize) -> Option<usize> {
506 if bytes.get(open) != Some(&b'[') || escaped(bytes, open) {
507 return None;
508 }
509
510 let mut depth = 1usize;
511 let mut at = open + 1;
512 while at < bytes.len() {
513 if escaped(bytes, at) {
514 at += 1;
515 continue;
516 }
517 match bytes[at] {
518 b'[' => depth += 1,
519 b']' => {
520 depth -= 1;
521 if depth == 0 {
522 return (bytes.get(at + 1) == Some(&b'(')).then_some(at + 1);
523 }
524 }
525 _ => {}
526 }
527 at += 1;
528 }
529 None
530}
531
532fn escaped(bytes: &[u8], at: usize) -> bool {
533 let mut slashes = 0usize;
534 let mut cursor = at;
535 while cursor > 0 && bytes[cursor - 1] == b'\\' {
536 slashes += 1;
537 cursor -= 1;
538 }
539 slashes % 2 == 1
540}
541
542fn split_keep(text: &str) -> Vec<&str> {
545 let mut out = Vec::new();
546 let mut start = 0;
547 for (at, c) in text.char_indices() {
548 if c == '\n' {
549 out.push(&text[start..=at]);
550 start = at + 1;
551 }
552 }
553 if start < text.len() {
554 out.push(&text[start..]);
555 }
556 out
557}
558
559fn indent_width(line: &str) -> usize {
562 line.chars()
563 .take_while(|c| matches!(c, ' ' | '\t'))
564 .map(|c| if c == '\t' { 4 } else { 1 })
565 .sum()
566}
567
568fn opens_comment(line: &str) -> bool {
575 let trimmed = line.trim_start();
576 if !trimmed.starts_with("<!--") {
577 return false;
578 }
579 !trimmed[4..].contains("-->")
580}
581
582fn without_eol(line: &str) -> &str {
583 match line.strip_suffix('\n') {
584 Some(rest) => rest.strip_suffix('\r').unwrap_or(rest),
585 None => line,
586 }
587}
588
589fn eol_of(line: &str) -> &str {
590 if line.ends_with("\r\n") {
591 "\r\n"
592 } else if line.ends_with('\n') {
593 "\n"
594 } else {
595 ""
596 }
597}
598
599#[derive(Debug, Clone, PartialEq, Eq)]
605pub enum Change {
606 Tick,
612 Reference(String),
614}
615
616impl Change {
617 pub fn inserted(&self) -> &str {
620 match self {
621 Change::Tick => "x",
622 Change::Reference(reference) => reference,
623 }
624 }
625}
626
627pub fn rewrite(body: &str, raw: &str, change: &Change) -> Result<String> {
634 let lines = split_keep(body);
635 if lines.concat() != body {
637 bail!("could not split the body into lines without changing it");
638 }
639
640 let hits: Vec<usize> = lines
641 .iter()
642 .enumerate()
643 .filter(|(_, line)| without_eol(line) == raw)
644 .map(|(at, _)| at)
645 .collect();
646 match hits.len() {
647 0 => bail!("that line is no longer in the body"),
648 1 => {}
649 n => bail!("{n} lines read exactly alike, so the edit could go to either"),
650 }
651 let at = hits[0];
652 let replaced = changed(raw, change)?;
653
654 let mut out = String::with_capacity(body.len() + replaced.len());
655 for (index, line) in lines.iter().enumerate() {
656 if index == at {
657 out.push_str(&replaced);
658 out.push_str(eol_of(line));
659 } else {
660 out.push_str(line);
661 }
662 }
663
664 let after = split_keep(&out);
666 if after.len() != lines.len() {
667 bail!(
668 "the edit changed the line count from {} to {}",
669 lines.len(),
670 after.len()
671 );
672 }
673 for (index, (before, now)) in lines.iter().zip(&after).enumerate() {
674 if index != at && before != now {
675 bail!(
676 "the edit would have changed line {}, which it must not",
677 index + 1
678 );
679 }
680 }
681 Ok(out)
682}
683
684fn changed(line: &str, change: &Change) -> Result<String> {
687 let caps = item_of(line).ok_or_else(|| spar_err!("that line is no longer a checklist item"))?;
688
689 match change {
690 Change::Tick => {
691 let at = caps.name("state").expect("a state").start();
692 if &line[at..at + 1] != " " {
693 bail!("that box is already ticked");
694 }
695 Ok(format!("{}x{}", &line[..at], &line[at + 1..]))
696 }
697 Change::Reference(reference) => {
698 let rest = caps.name("rest").expect("a rest");
699 let text = rest.as_str();
700 let body = text.trim_end_matches([' ', '\t']);
703 if body.trim().is_empty() {
704 bail!("that item has no text to attach {reference} to");
705 }
706 Ok(format!(
707 "{}{body} {reference}{}",
708 &line[..rest.start()],
709 &text[body.len()..]
710 ))
711 }
712 }
713}
714
715#[derive(Debug, Clone, PartialEq, Eq)]
721enum Shape {
722 Names(i64),
725 Needs,
727 Hold(String),
729 Over,
731}
732
733#[derive(Debug, Clone, PartialEq, Eq)]
735pub enum Action {
736 Adopt(i64),
738 Tick(i64),
741 Link {
746 number: i64,
747 title: String,
748 open: bool,
749 },
750 File,
752 Hold(String),
754 Over,
756}
757
758impl Action {
759 pub fn change(&self) -> Option<Change> {
771 match self {
772 Action::Tick(_) => Some(Change::Tick),
773 Action::Link { number, .. } => Some(Change::Reference(format!("#{number}"))),
774 Action::Adopt(_) | Action::File | Action::Hold(_) | Action::Over => None,
775 }
776 }
777}
778
779#[derive(Debug, Clone)]
781pub struct Step {
782 pub item: Item,
783 pub action: Action,
784}
785
786fn shape(body: &str, home: &str, max: usize) -> Vec<(Item, Shape)> {
791 let items = parse(body);
792 let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
795 for item in &items {
796 *seen.entry(item.raw.as_str()).or_default() += 1;
797 }
798
799 let mut out = Vec::new();
800 let mut taken = 0usize;
801 for item in &items {
802 if item.checked {
803 continue;
804 }
805 let shape = if seen.get(item.raw.as_str()).copied().unwrap_or(0) > 1 {
806 Shape::Hold("another item is written identically, so a link could go to either".into())
807 } else if item.text.is_empty() {
808 Shape::Hold("the item has no text".into())
809 } else {
810 match &item.reference {
811 Some(reference) => match reference.local(home) {
817 Some(number) => Shape::Names(number),
818 None => Shape::Hold(format!(
819 "it names an issue in another repository: {}",
820 reference.names()
821 )),
822 },
823 None if taken >= max => Shape::Over,
824 None => {
825 taken += 1;
826 Shape::Needs
827 }
828 }
829 };
830 out.push((item.clone(), shape));
831 }
832 out
833}
834
835pub fn plan(repo: &Repo, cfg: &Config, tracker: i64, body: &str, home: &str) -> Vec<Step> {
841 shape(body, home, cfg.loop_cfg.max_tracker_children)
842 .into_iter()
843 .map(|(item, shape)| {
844 let action = match shape {
845 Shape::Hold(why) => Action::Hold(why),
846 Shape::Over => Action::Over,
847 Shape::Names(number) => resolve(repo, number),
848 Shape::Needs => match search(repo, tracker, &item.text) {
855 Some(found) => Action::Link {
856 number: found.number,
857 title: found.title,
858 open: found.open,
859 },
860 None => Action::File,
861 },
862 };
863 Step { item, action }
864 })
865 .collect()
866}
867
868fn resolve(repo: &Repo, number: i64) -> Action {
875 match repo.item_kind(number) {
876 Ok(ItemKind::Issue) => match repo.read_issue(number) {
877 Ok(issue) => issue_action(&issue),
878 Err(e) => Action::Hold(format!("could not read #{number}: {}", e.first_line())),
879 },
880 Ok(ItemKind::Pr) => match repo.pr_state(number).to_uppercase().as_str() {
884 "MERGED" => Action::Tick(number),
885 "" => Action::Hold(format!(
886 "#{number} is a pull request in an unreadable state"
887 )),
888 state => Action::Hold(format!(
889 "#{number} is a pull request, {}",
890 state.to_lowercase()
891 )),
892 },
893 Err(e) => Action::Hold(format!("could not read #{number}: {}", e.first_line())),
894 }
895}
896
897fn issue_action(issue: &Issue) -> Action {
905 let number = issue.number;
906 if issue.closed_as_not_planned() {
907 return Action::Hold(format!(
908 "#{number} was closed as not planned, so the item is not done"
909 ));
910 }
911 if issue.is_closed() {
912 return Action::Tick(number);
913 }
914 Action::Adopt(number)
915}
916
917fn search(repo: &Repo, tracker: i64, text: &str) -> Option<crate::repo::ExistingIssue> {
924 let title = repo.clean_title(text).ok()?;
925 repo.find_similar_issue_apart_from(&title, &child_body(text, tracker), Some(tracker))
926}
927
928fn child_body(text: &str, tracker: i64) -> String {
931 format!("{text}\n\nFrom the checklist in #{tracker}.")
932}
933
934pub fn decompose(cfg: &Config, repo: &Repo, tracker: i64) -> Vec<i64> {
945 let Some((body, slug)) = read_for_write(repo, tracker) else {
946 return Vec::new();
947 };
948 let steps = plan(repo, cfg, tracker, &body, &slug);
949 if steps.is_empty() {
950 logdim!("#{tracker} has no unchecked checklist items, so there is nothing to extract");
951 return Vec::new();
952 }
953 log!("#{tracker}: {} unchecked checklist item(s)", steps.len());
954 report_overflow(cfg, tracker, &steps);
955 report_skipped(tracker, &body);
956 apply(repo, tracker, &steps)
957}
958
959fn apply(repo: &Repo, tracker: i64, steps: &[Step]) -> Vec<i64> {
960 let mut children = Vec::new();
961 for step in steps {
962 let what = style::clip(&style::one_line(&step.item.text), 80);
963 match &step.action {
964 Action::Hold(why) => logdim!(" left '{what}' alone: {why}"),
965 Action::Over => {}
968 Action::Adopt(number) => {
969 log!(" '{what}' is already #{number}");
970 children.push(*number);
971 }
972 Action::Tick(number) => {
973 let Some(change) = step.action.change() else {
974 continue;
975 };
976 if write(repo, tracker, &step.item.raw, &change) {
977 log!(" ticked '{what}' off, #{number} is finished");
978 }
979 }
980 Action::Link {
983 number,
984 title,
985 open,
986 } => {
987 log!(" linking '{what}' to #{number} '{title}', filed nothing");
988 let Some(change) = step.action.change() else {
989 continue;
990 };
991 if write(repo, tracker, &step.item.raw, &change) && *open {
992 children.push(*number);
993 }
994 }
995 Action::File => {
996 let Ok(title) = repo.clean_nonempty_title_for_write(&step.item.text) else {
997 logdim!(" could not clean a title out of '{what}'");
998 continue;
999 };
1000 if !still_asked_for(repo, tracker, &step.item.raw) {
1005 logdim!(" '{what}' is no longer in #{tracker}, so nothing was filed for it");
1006 continue;
1007 }
1008 match review::file_as_issue_apart_from(
1009 repo,
1010 &title,
1011 &child_body(&step.item.text, tracker),
1012 Some(tracker),
1013 ) {
1014 Ok(filed) => {
1015 let number = filed.issue();
1016 log!(" {} for '{what}'", filed.note());
1017 let linked = write(
1023 repo,
1024 tracker,
1025 &step.item.raw,
1026 &Change::Reference(format!("#{number}")),
1027 );
1028 match linked {
1029 true if filed.number().is_some() => children.push(number),
1030 true => {}
1031 false => logwarn!(
1032 " '{what}' went to #{number}, but #{tracker} does not link to it"
1033 ),
1034 }
1035 }
1036 Err(e) => logdim!(" could not file an issue for '{what}': {e}"),
1037 }
1038 }
1039 }
1040 }
1041 unique_children(children)
1042}
1043
1044fn unique_children(mut children: Vec<i64>) -> Vec<i64> {
1045 let mut seen = BTreeSet::new();
1046 children.retain(|number| seen.insert(*number));
1047 children
1048}
1049
1050fn still_an_item(body: &str, raw: &str) -> bool {
1058 let lines = split_keep(body)
1059 .into_iter()
1060 .filter(|line| without_eol(line) == raw)
1061 .count();
1062 lines == 1 && parse(body).iter().filter(|item| item.raw == raw).count() == 1
1063}
1064
1065fn still_asked_for(repo: &Repo, tracker: i64, raw: &str) -> bool {
1070 match repo.record_failed_write(repo.read_issue(tracker)) {
1071 Ok(issue) => still_an_item(issue.body_text(), raw),
1072 Err(e) => {
1073 logdim!(" could not re-read #{tracker}: {}", e.first_line());
1074 false
1075 }
1076 }
1077}
1078
1079fn write(repo: &Repo, tracker: i64, raw: &str, change: &Change) -> bool {
1087 let body = match repo.record_failed_write(repo.read_issue(tracker)) {
1088 Ok(issue) => issue.body_text().to_string(),
1089 Err(e) => {
1090 logdim!(" could not re-read #{tracker}: {}", e.first_line());
1091 return false;
1092 }
1093 };
1094 if !still_an_item(&body, raw) {
1095 logdim!(" not editing #{tracker}: that line is no longer a checklist item in it");
1096 return false;
1097 }
1098 let updated = match rewrite(&body, raw, change) {
1099 Ok(updated) => updated,
1100 Err(e) => {
1101 logdim!(" not editing #{tracker}: {}", e.first_line());
1102 return false;
1103 }
1104 };
1105 match repo.edit_issue_body(tracker, &body, &updated, change.inserted()) {
1106 Ok(()) => true,
1107 Err(e) => {
1108 logdim!(" could not edit #{tracker}: {}", e.first_line());
1109 false
1110 }
1111 }
1112}
1113
1114fn report_skipped(tracker: i64, body: &str) {
1120 for skip in parse_reporting(body).1 {
1121 logdim!(
1122 " left line {} of #{tracker} alone, '{}': {}",
1123 skip.line,
1124 style::clip(&style::one_line(&skip.text), 60),
1125 skip.why
1126 );
1127 }
1128}
1129
1130fn report_overflow(cfg: &Config, tracker: i64, steps: &[Step]) {
1131 let left: Vec<String> = steps
1132 .iter()
1133 .filter(|s| s.action == Action::Over)
1134 .map(|s| format!("'{}'", style::clip(&style::one_line(&s.item.text), 60)))
1135 .collect();
1136 if left.is_empty() {
1137 return;
1138 }
1139 logwarn!(
1140 "#{tracker} has more unchecked items than max_tracker_children ({}), so {} were left for \
1141 a later run: {}",
1142 cfg.loop_cfg.max_tracker_children,
1143 left.len(),
1144 left.join(", ")
1145 );
1146}
1147
1148fn read(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1149 match repo.read_issue(tracker) {
1154 Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1155 Err(e) => {
1156 logdim!("could not read #{tracker}: {}", e.first_line());
1157 None
1158 }
1159 }
1160}
1161
1162fn read_for_write(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1163 match repo.record_failed_write(repo.read_issue(tracker)) {
1164 Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1165 Err(e) => {
1166 logdim!("could not read #{tracker}: {}", e.first_line());
1167 None
1168 }
1169 }
1170}
1171
1172fn home_of(url: &str) -> String {
1180 match url.rfind("/issues/") {
1181 Some(at) => url[..at].to_string(),
1182 None => String::new(),
1183 }
1184}
1185
1186pub fn preview(cfg: &Config, repo: &Repo, tracker: i64) {
1197 let Some((body, slug)) = read(repo, tracker) else {
1198 return;
1199 };
1200 let steps = plan(repo, cfg, tracker, &body, &slug);
1201 if steps.is_empty() {
1202 return;
1203 }
1204 println!("\n#{tracker}, if decompose_trackers let it act on the checklist:");
1205 report_skipped(tracker, &body);
1206
1207 let mut projected = body.clone();
1208 for step in &steps {
1209 let what = style::clip(&style::one_line(&step.item.text), 80);
1210 match &step.action {
1211 Action::Adopt(number) => println!(" keep '{what}' is already #{number}"),
1212 Action::Tick(number) => println!(" tick '{what}', #{number} is finished"),
1213 Action::Link {
1214 number,
1215 title,
1216 open,
1217 } => {
1218 let state = if *open { "open" } else { "closed" };
1219 println!(" link '{what}' to #{number} '{title}' ({state}), filing nothing");
1220 }
1221 Action::File => println!(" file '{what}'"),
1222 Action::Over => println!(" over '{what}' is past max_tracker_children"),
1223 Action::Hold(why) => println!(" hold '{what}': {why}"),
1224 }
1225 let change = match &step.action {
1229 Action::File => Some(Change::Reference(FILED.to_string())),
1230 other => other.change(),
1231 };
1232 let Some(change) = change else { continue };
1233 match rewrite(&projected, &step.item.raw, &change) {
1234 Ok(next) => projected = next,
1235 Err(e) => println!(" the line will not be rewritten: {e}"),
1236 }
1237 }
1238
1239 let diff = diff(&body, &projected);
1240 if diff.is_empty() {
1241 println!(" nothing would be written to the body");
1242 } else {
1243 println!(" and the body it would write:");
1244 for line in diff {
1245 println!(" {line}");
1246 }
1247 }
1248}
1249
1250const FILED: &str = "#(the issue it files)";
1253
1254fn diff(before: &str, after: &str) -> Vec<String> {
1257 split_keep(before)
1258 .into_iter()
1259 .zip(split_keep(after))
1260 .filter(|(old, new)| old != new)
1261 .flat_map(|(old, new)| {
1262 [
1263 format!("- {}", without_eol(old)),
1264 format!("+ {}", without_eol(new)),
1265 ]
1266 })
1267 .collect()
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use super::*;
1273
1274 const HOME: &str = "https://github.com/me/mine";
1276
1277 fn texts(body: &str) -> Vec<String> {
1278 parse(body).into_iter().map(|i| i.text).collect()
1279 }
1280
1281 #[test]
1282 fn the_ordinary_checklist_is_read_as_items() {
1283 let items = parse("Some prose.\n\n- [ ] first\n- [x] second\n");
1284 assert_eq!(2, items.len());
1285 assert_eq!("first", items[0].text);
1286 assert!(!items[0].checked);
1287 assert!(items[1].checked);
1288 assert_eq!(3, items[0].line);
1289 }
1290
1291 #[test]
1294 fn indented_and_nested_items_are_items() {
1295 let body = "- [ ] parent\n - [ ] child\n\t- [ ] tabbed\n * [ ] deeper\n1. [ ] ordered\n2) [ ] also ordered\n";
1296 assert_eq!(
1297 vec![
1298 "parent",
1299 "child",
1300 "tabbed",
1301 "deeper",
1302 "ordered",
1303 "also ordered"
1304 ],
1305 texts(body)
1306 );
1307 }
1308
1309 #[test]
1311 fn something_that_looks_like_an_item_inside_a_fence_is_not_one() {
1312 let body = "\
1313- [ ] real
1314
1315```markdown
1316- [ ] not real
1317```
1318
1319~~~
1320- [ ] also not real
1321~~~
1322
1323- [ ] real again
1324";
1325 assert_eq!(vec!["real", "real again"], texts(body));
1326 }
1327
1328 #[test]
1332 fn an_indented_code_block_is_not_a_checklist() {
1333 let body = "\
1334Write the parts like this:
1335
1336 - [ ] an example, not an item
1337
1338- [ ] real
1339 - [ ] nested
1340- plain bullet
1341 - [ ] nested under a bullet
1342";
1343 assert_eq!(vec!["real", "nested", "nested under a bullet"], texts(body));
1344 }
1345
1346 #[test]
1350 fn code_indented_inside_a_list_item_is_still_code() {
1351 let body = "\
1352- outer
1353
1354 - [ ] an example, not an item
1355
1356 - [ ] nested
1357- plain
1358 - [ ] nested under a bullet
1359 - [ ] and under that one
1360";
1361 assert_eq!(
1362 vec!["nested", "nested under a bullet", "and under that one"],
1363 texts(body)
1364 );
1365 }
1366
1367 #[test]
1370 fn an_item_inside_raw_html_is_not_one() {
1371 let body = "\
1372- [ ] real
1373
1374<pre>
1375- [ ] not real
1376</pre>
1377
1378<textarea>
1379- [ ] also not real
1380</textarea>
1381
1382- [ ] real again
1383";
1384 assert_eq!(vec!["real", "real again"], texts(body));
1385 }
1386
1387 #[test]
1392 fn an_item_inside_a_block_tag_is_not_one() {
1393 let body = "\
1394<div>
1395- [ ] not real
1396</div>
1397
1398<details>
1399<summary>the parts</summary>
1400
1401- [ ] real
1402</details>
1403";
1404 assert_eq!(vec!["real"], texts(body));
1405 }
1406
1407 #[test]
1411 fn a_checkbox_pushed_past_its_own_content_column_is_code() {
1412 assert_eq!(Vec::<String>::new(), texts("- [ ] an example\n"));
1413 assert_eq!(vec!["real"], texts("- [ ] real\n"));
1414 }
1415
1416 #[test]
1419 fn a_fence_indented_into_code_neither_opens_nor_closes() {
1420 let body = "```\n- [ ] not real\n ```\n- [ ] still not real\n";
1421 assert_eq!(Vec::<String>::new(), texts(body));
1422
1423 let body = "Like this:\n\n ```\n- [ ] real\n";
1424 assert_eq!(vec!["real"], texts(body));
1425 }
1426
1427 #[test]
1430 fn an_item_inside_an_html_comment_is_not_one() {
1431 let body = "\
1432- [ ] real
1433
1434<!--
1435- [ ] not real
1436-->
1437
1438- [ ] real again
1439<!-- - [ ] on one line, closed -->
1440- [ ] last
1441";
1442 assert_eq!(vec!["real", "real again", "last"], texts(body));
1443 }
1444
1445 #[test]
1448 fn a_fence_is_closed_only_by_its_own_kind() {
1449 let body = "~~~\n```\n- [ ] not real\n```\n~~~\n- [ ] real\n";
1450 assert_eq!(vec!["real"], texts(body));
1451 }
1452
1453 #[test]
1454 fn a_windows_body_is_read_the_same_way() {
1455 let items = parse("intro\r\n\r\n- [ ] first\r\n- [x] second\r\n");
1456 assert_eq!(2, items.len());
1457 assert_eq!("first", items[0].text);
1458 assert!(items[1].checked);
1459 assert_eq!(
1460 "- [ ] first", items[0].raw,
1461 "the terminator is not part of the handle"
1462 );
1463 }
1464
1465 #[test]
1466 fn a_reference_is_read_from_a_number_or_a_link() {
1467 let items = parse(
1468 "- [ ] one #12\n\
1469 - [ ] two https://github.com/o/r/issues/34\n\
1470 - [ ] [three](https://github.com/o/r/issues/56)\n\
1471 - [ ] four\n",
1472 );
1473 assert_eq!(Some(12), items[0].reference.as_ref().map(|r| r.number));
1474 assert_eq!(Some(34), items[1].reference.as_ref().map(|r| r.number));
1475 assert_eq!(Some(56), items[2].reference.as_ref().map(|r| r.number));
1476 assert_eq!(None, items[3].reference);
1477 }
1478
1479 #[test]
1483 fn a_link_to_a_pull_request_is_a_reference_too() {
1484 let items = parse(
1485 "- [ ] one https://github.com/me/mine/pull/42\n\
1486 - [ ] two https://github.com/me/mine/pull/43/files\n\
1487 - [ ] three https://github.com/other/thing/pull/44\n",
1488 );
1489 assert_eq!(Some(42), items[0].reference.as_ref().unwrap().local(HOME));
1490 assert_eq!(Some(43), items[1].reference.as_ref().unwrap().local(HOME));
1491 assert_eq!(None, items[2].reference.as_ref().unwrap().local(HOME));
1492 }
1493
1494 #[test]
1497 fn an_item_that_is_a_link_to_something_else_names_no_issue() {
1498 let items = parse("- [ ] [the docs](https://example.com/guide)\n");
1499 assert_eq!(None, items[0].reference);
1500 assert_eq!("[the docs](https://example.com/guide)", items[0].text);
1501 }
1502
1503 #[test]
1506 fn a_link_to_another_repository_is_not_adoptable() {
1507 let items = parse("- [ ] see https://github.com/other/thing/issues/7\n");
1508 let reference = items[0].reference.as_ref().expect("a reference");
1509 assert_eq!(None, reference.local(HOME));
1510 assert_eq!(Some(7), reference.local("https://github.com/other/thing"));
1511 }
1512
1513 #[test]
1515 fn a_bare_number_resolves_wherever_it_is_read() {
1516 let items = parse("- [ ] work #7\n");
1517 assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(""));
1518 }
1519
1520 #[test]
1523 fn a_link_to_the_same_path_on_another_host_is_not_this_repository() {
1524 for url in [
1525 "https://gitlab.example/me/mine/issues/7",
1526 "https://github.com/mirror/me/mine/issues/7",
1527 ] {
1528 let items = parse(&format!("- [ ] see {url}\n"));
1529 let reference = items[0].reference.as_ref().expect("a reference");
1530 assert_eq!(None, reference.local(HOME), "{url}");
1531 }
1532 }
1533
1534 #[test]
1536 fn the_scheme_is_not_what_makes_a_link_somebody_elses() {
1537 let items = parse("- [ ] see http://github.com/me/mine/issues/7\n");
1538 assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(HOME));
1539 }
1540
1541 #[test]
1544 fn home_is_read_off_the_trackers_own_url() {
1545 assert_eq!(HOME, home_of("https://github.com/me/mine/issues/29"));
1546 assert_eq!("", home_of(""));
1547 }
1548
1549 #[test]
1552 fn a_number_in_a_code_span_names_nothing() {
1553 let items = parse(
1554 "- [ ] Handle the literal `#12`, tracked in #34\n\
1555 - [ ] Only ``a #12 in a double span``\n",
1556 );
1557 assert_eq!(Some(34), items[0].reference.as_ref().map(|r| r.number));
1558 assert_eq!(None, items[1].reference);
1559 }
1560
1561 #[test]
1565 fn a_number_in_a_comment_names_nothing() {
1566 let items = parse(
1567 "- [ ] ship it <!-- old note: #7 -->\n\
1568 - [ ] and this one <!-- #7 --> #8\n",
1569 );
1570 assert_eq!(None, items[0].reference);
1571 assert_eq!(Some(8), items[1].reference.as_ref().map(|r| r.number));
1572 }
1573
1574 #[test]
1577 fn a_fragment_in_a_link_is_not_an_issue_number() {
1578 let items = parse(
1579 "- [ ] update [docs](https://example.com/guide/#8)\n\
1580 - [ ] see https://example.com/guide#9 and #10\n",
1581 );
1582 assert_eq!(None, items[0].reference);
1583 assert_eq!(Some(10), items[1].reference.as_ref().map(|r| r.number));
1584 }
1585
1586 #[test]
1590 fn the_shorthands_github_links_are_references_too() {
1591 let items = parse(
1592 "- [ ] one me/mine#12\n\
1593 - [ ] two other/thing#13\n\
1594 - [ ] three GH-14\n",
1595 );
1596 assert_eq!(Some(12), items[0].reference.as_ref().unwrap().local(HOME));
1597 let foreign = items[1].reference.as_ref().expect("a reference");
1598 assert_eq!(None, foreign.local(HOME), "somebody else's repository");
1599 assert_eq!("other/thing#13", foreign.names());
1600 assert_eq!(Some(14), items[2].reference.as_ref().unwrap().local(HOME));
1601 }
1602
1603 #[test]
1606 fn a_shorthand_is_read_against_this_repositorys_path() {
1607 let items = parse("- [ ] work me/mine#7\n");
1608 let reference = items[0].reference.as_ref().expect("a reference");
1609 assert_eq!(Some(7), reference.local("https://ghe.example/me/mine"));
1610 assert_eq!(None, reference.local("https://github.com/me/other"));
1611 assert_eq!(None, reference.local(""), "no address to measure against");
1612 }
1613
1614 #[test]
1618 fn a_link_is_read_from_its_destination_and_not_its_label() {
1619 let items = parse(
1620 "- [ ] [other/widgets #7](https://github.com/other/widgets/issues/7)\n\
1621 - [ ] [me/mine #7](https://github.com/me/mine/issues/7)\n",
1622 );
1623 let foreign = items[0].reference.as_ref().expect("a reference");
1624 assert!(
1625 matches!(foreign.origin, Origin::Url(_)),
1626 "the destination, not the label"
1627 );
1628 assert_eq!(None, foreign.local(HOME));
1629 assert_eq!(Some(7), items[1].reference.as_ref().unwrap().local(HOME));
1630 }
1631
1632 #[test]
1636 fn complex_link_labels_still_read_the_destination() {
1637 let items = parse(
1638 "- [ ] [see [#7]](https://github.com/other/widgets/issues/8)\n\
1639 - [ ] [see \\] #7](https://github.com/other/widgets/issues/8)\n",
1640 );
1641 for item in items {
1642 let reference = item.reference.expect("the destination");
1643 assert_eq!(8, reference.number);
1644 assert!(matches!(reference.origin, Origin::Url(_)));
1645 assert_eq!(None, reference.local(HOME));
1646 }
1647 }
1648
1649 #[test]
1650 fn one_child_referenced_by_several_items_is_worked_once() {
1651 assert_eq!(vec![8, 9], unique_children(vec![8, 8, 9, 8]));
1652 }
1653
1654 #[test]
1657 fn a_reference_is_appended_to_its_own_line_and_nowhere_else() {
1658 let body = "intro\n\n- [ ] first\n- [ ] second\n\nmore prose\n";
1659 let out =
1660 rewrite(body, "- [ ] first", &Change::Reference("#40".into())).expect("a rewrite");
1661 assert_eq!(
1662 "intro\n\n- [ ] first #40\n- [ ] second\n\nmore prose\n",
1663 out
1664 );
1665 }
1666
1667 #[test]
1670 fn a_hard_break_survives_the_edit() {
1671 let out = rewrite(
1672 "- [ ] first \nnext\n",
1673 "- [ ] first ",
1674 &Change::Reference("#4".into()),
1675 )
1676 .expect("a rewrite");
1677 assert_eq!("- [ ] first #4 \nnext\n", out);
1678 }
1679
1680 #[test]
1681 fn every_other_line_comes_through_byte_identical() {
1682 let body = "# Plan\r\n\r\n trailing spaces here \r\n- [ ] one\r\n\r\n\r\n\r\nlots of blank lines above\r\n";
1683 let out = rewrite(body, "- [ ] one", &Change::Reference("#9".into())).expect("a rewrite");
1684 let (before, after): (Vec<&str>, Vec<&str>) =
1685 (body.lines().collect(), out.lines().collect());
1686 assert_eq!(before.len(), after.len());
1687 for (i, (a, b)) in before.iter().zip(&after).enumerate() {
1688 if i == 3 {
1689 assert_eq!("- [ ] one #9", *b);
1690 } else {
1691 assert_eq!(a, b, "line {} changed", i + 1);
1692 }
1693 }
1694 assert!(out.contains("trailing spaces here \r\n"));
1695 assert!(out.contains("\r\n\r\n\r\n\r\n"));
1696 }
1697
1698 #[test]
1699 fn a_body_with_no_final_newline_keeps_not_having_one() {
1700 let out = rewrite("- [ ] only", "- [ ] only", &Change::Tick).expect("a rewrite");
1701 assert_eq!("- [x] only", out);
1702 }
1703
1704 #[test]
1705 fn ticking_changes_the_box_and_leaves_the_text() {
1706 let out = rewrite(" - [ ] deep #3\n", " - [ ] deep #3", &Change::Tick).expect("a tick");
1707 assert_eq!(" - [x] deep #3\n", out);
1708 }
1709
1710 #[test]
1713 fn a_ticked_box_is_never_written_again() {
1714 assert!(rewrite("- [x] done\n", "- [x] done", &Change::Tick).is_err());
1715 assert!(!matches!(Change::Tick, Change::Reference(_)));
1716 }
1717
1718 #[test]
1719 fn a_line_that_is_gone_or_ambiguous_is_a_refusal_not_a_guess() {
1720 assert!(rewrite("- [ ] a\n", "- [ ] b", &Change::Tick).is_err());
1721 let twice = "- [ ] same\n- [ ] same\n";
1722 assert!(rewrite(twice, "- [ ] same", &Change::Tick).is_err());
1723 }
1724
1725 #[test]
1726 fn an_item_with_no_text_gets_no_reference() {
1727 assert!(rewrite("- [ ]\n", "- [ ]", &Change::Reference("#1".into())).is_err());
1728 }
1729
1730 fn shapes(body: &str, max: usize) -> Vec<Shape> {
1733 shape(body, HOME, max).into_iter().map(|(_, s)| s).collect()
1734 }
1735
1736 #[test]
1737 fn a_checked_item_is_never_reconsidered() {
1738 assert!(shapes("- [x] done\n", 5).is_empty());
1739 }
1740
1741 #[test]
1742 fn an_item_that_names_an_issue_is_kept_apart_from_one_that_does_not() {
1743 assert_eq!(
1744 vec![Shape::Names(12), Shape::Needs],
1745 shapes("- [ ] one #12\n- [ ] two\n", 5)
1746 );
1747 }
1748
1749 #[test]
1752 fn the_cap_stops_at_the_cap() {
1753 let body = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d\n";
1754 assert_eq!(
1755 vec![Shape::Needs, Shape::Needs, Shape::Over, Shape::Over],
1756 shapes(body, 2)
1757 );
1758 }
1759
1760 #[test]
1767 fn an_item_closed_as_not_planned_is_not_ticked() {
1768 let issue = |state: &str, reason: Option<&str>| Issue {
1769 number: 12,
1770 title: "migrate the cache to v2".into(),
1771 body: None,
1772 state: state.into(),
1773 state_reason: reason.map(str::to_string),
1774 url: String::new(),
1775 labels: Vec::new(),
1776 };
1777
1778 assert!(matches!(
1779 issue_action(&issue("CLOSED", Some("not_planned"))),
1780 Action::Hold(_)
1781 ));
1782 assert_eq!(
1783 Action::Tick(12),
1784 issue_action(&issue("CLOSED", Some("completed")))
1785 );
1786 assert_eq!(
1787 Action::Tick(12),
1788 issue_action(&issue("CLOSED", None)),
1789 "an issue closed before GitHub recorded a reason still ticks"
1790 );
1791 assert_eq!(Action::Adopt(12), issue_action(&issue("OPEN", None)));
1792 }
1793
1794 #[test]
1800 fn the_cap_counts_only_the_items_that_need_an_issue_filed() {
1801 let body = "- [ ] a #1\n- [ ] b #2\n- [ ] c\n- [ ] d\n";
1802 assert_eq!(
1803 vec![Shape::Names(1), Shape::Names(2), Shape::Needs, Shape::Over],
1804 shapes(body, 1)
1805 );
1806 }
1807
1808 #[test]
1811 fn an_inline_comment_marker_does_not_hide_the_rest_of_the_list() {
1812 let body = "- [ ] document the `<!--` syntax\n- [ ] the next one\n";
1813 assert_eq!(vec![Shape::Needs, Shape::Needs], shapes(body, 5));
1814
1815 let body = "<!-- notes\n- [ ] hidden\n-->\n- [ ] visible\n";
1817 assert_eq!(vec![Shape::Needs], shapes(body, 5));
1818 }
1819
1820 #[test]
1823 fn a_checkbox_spar_will_not_touch_says_why() {
1824 let body = "<!-- later\n- [ ] in a comment\n-->\n\n> - [ ] in a quote\n\n```\n- [ ] in a fence\n```\n";
1825 let reasons: Vec<&str> = parse_reporting(body).1.iter().map(|s| s.why).collect();
1826 assert_eq!(
1827 vec![
1828 "it is inside an HTML comment",
1829 "it is inside a block quote",
1830 "it is inside a code fence",
1831 ],
1832 reasons
1833 );
1834 }
1835
1836 #[test]
1838 fn the_cap_counts_only_what_it_acts_on() {
1839 let body = "- [x] a\n- [x] b\n- [ ] c\n";
1840 assert_eq!(vec![Shape::Needs], shapes(body, 1));
1841 }
1842
1843 #[test]
1844 fn two_identical_items_are_left_alone() {
1845 let out = shapes("- [ ] same\n- [ ] same\n", 5);
1846 assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1847 assert!(matches!(out[1], Shape::Hold(_)), "{out:?}");
1848 }
1849
1850 #[test]
1851 fn an_item_naming_another_repository_is_held_rather_than_adopted() {
1852 let out = shapes("- [ ] see https://github.com/other/thing/issues/7\n", 5);
1853 assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1854 }
1855
1856 #[test]
1859 fn a_realistic_tracker_keeps_every_line_it_was_not_asked_to_change() {
1860 let body = "\
1861Context somebody wrote, with a hard break here:
1862and the rest of it.
1863
1864## Parts
1865
1866- [x] already done
1867- [ ] parse the checklist
1868- [ ] write the link back #40
1869 - [ ] and prove it first
1870
1871```markdown
1872- [ ] an example, not an item
1873```
1874
1875That is all.
1876";
1877 let shapes: Vec<Shape> = shape(body, HOME, 5).into_iter().map(|(_, s)| s).collect();
1878 assert_eq!(
1879 vec![Shape::Needs, Shape::Names(40), Shape::Needs],
1880 shapes,
1881 "the ticked item, the fenced one and the prose are all left out"
1882 );
1883
1884 let out = rewrite(
1885 body,
1886 "- [ ] parse the checklist",
1887 &Change::Reference("#41".into()),
1888 )
1889 .expect("a link");
1890 let out = rewrite(
1891 &out,
1892 " - [ ] and prove it first",
1893 &Change::Reference("#42".into()),
1894 )
1895 .expect("a nested link");
1896 let out = rewrite(&out, "- [ ] write the link back #40", &Change::Tick).expect("a tick");
1897
1898 assert_eq!(
1899 "\
1900Context somebody wrote, with a hard break here:
1901and the rest of it.
1902
1903## Parts
1904
1905- [x] already done
1906- [ ] parse the checklist #41
1907- [x] write the link back #40
1908 - [ ] and prove it first #42
1909
1910```markdown
1911- [ ] an example, not an item
1912```
1913
1914That is all.
1915",
1916 out
1917 );
1918 }
1919
1920 #[test]
1927 fn an_item_linked_by_similarity_is_not_ticked_in_the_same_run() {
1928 for open in [true, false] {
1929 let action = Action::Link {
1930 number: 7,
1931 title: "something close enough".into(),
1932 open,
1933 };
1934 assert_eq!(Some(Change::Reference("#7".into())), action.change());
1935 }
1936 }
1937
1938 #[test]
1941 fn an_item_that_already_named_its_issue_is_ticked_when_that_issue_closes() {
1942 assert_eq!(Some(Change::Tick), Action::Tick(7).change());
1943 }
1944
1945 #[test]
1947 fn nothing_is_written_for_an_item_that_is_already_linked_and_open() {
1948 assert_eq!(None, Action::Adopt(7).change());
1949 assert_eq!(None, Action::Over.change());
1950 assert_eq!(None, Action::Hold("any reason".into()).change());
1951 }
1952
1953 #[test]
1959 fn a_line_that_stopped_being_an_item_is_not_written_to() {
1960 let raw = "- [ ] ship it";
1961 assert!(still_an_item("intro\n\n- [ ] ship it\n", raw));
1962 assert!(!still_an_item("```\n- [ ] ship it\n```\n", raw));
1963 assert!(!still_an_item("<!--\n- [ ] ship it\n-->\n", raw));
1964 assert!(!still_an_item("- [ ] something else\n", raw));
1965 assert!(
1966 !still_an_item("- [ ] ship it\n- [ ] ship it\n", raw),
1967 "two alike is a line the edit could go to either of"
1968 );
1969 }
1970
1971 #[test]
1974 fn the_diff_shows_only_the_lines_that_change() {
1975 let before = "- [ ] one\n- [ ] two\n";
1976 let after =
1977 rewrite(before, "- [ ] two", &Change::Reference("#8".into())).expect("a rewrite");
1978 assert_eq!(
1979 vec!["- - [ ] two".to_string(), "+ - [ ] two #8".to_string()],
1980 diff(before, &after)
1981 );
1982 }
1983}