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, None) {
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, None) && *open {
992 children.push(*number);
993 }
994 }
995 Action::File => {
996 let Ok(title) = repo.clean_nonempty_title_for_write(&step.item.text) else {
997 logdim!(" could not clean a title out of '{what}'");
998 continue;
999 };
1000 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 None,
1028 );
1029 match linked {
1030 true if filed.number().is_some() => children.push(number),
1031 true => {}
1032 false => logwarn!(
1033 " '{what}' went to #{number}, but #{tracker} does not link to it"
1034 ),
1035 }
1036 }
1037 Err(e) => logdim!(" could not file an issue for '{what}': {e}"),
1038 }
1039 }
1040 }
1041 }
1042 unique_children(children)
1043}
1044
1045fn unique_children(mut children: Vec<i64>) -> Vec<i64> {
1046 let mut seen = BTreeSet::new();
1047 children.retain(|number| seen.insert(*number));
1048 children
1049}
1050
1051fn still_an_item(body: &str, raw: &str) -> bool {
1059 let lines = split_keep(body)
1060 .into_iter()
1061 .filter(|line| without_eol(line) == raw)
1062 .count();
1063 lines == 1 && parse(body).iter().filter(|item| item.raw == raw).count() == 1
1064}
1065
1066fn why_not_an_item(body: &str, raw: &str) -> String {
1068 let lines = split_keep(body)
1069 .into_iter()
1070 .filter(|line| without_eol(line) == raw)
1071 .count();
1072 match lines {
1073 0 => "that line changed or is gone".to_string(),
1074 1 => "that line is no longer a checklist item in it".to_string(),
1075 n => format!("that line appears {n} times, so the edit could go to either"),
1076 }
1077}
1078
1079pub fn criteria(body: &str) -> Vec<Item> {
1083 parse(body)
1084 .into_iter()
1085 .filter(|item| !item.checked)
1086 .collect()
1087}
1088
1089fn still_asked_for(repo: &Repo, tracker: i64, raw: &str) -> bool {
1094 match repo.record_failed_write(repo.read_issue(tracker)) {
1095 Ok(issue) => still_an_item(issue.body_text(), raw),
1096 Err(e) => {
1097 logdim!(" could not re-read #{tracker}: {}", e.first_line());
1098 false
1099 }
1100 }
1101}
1102
1103pub(crate) fn write(
1111 repo: &Repo,
1112 tracker: i64,
1113 raw: &str,
1114 change: &Change,
1115 at: Option<usize>,
1116) -> bool {
1117 let body = match repo.record_failed_write(repo.read_issue(tracker)) {
1118 Ok(issue) => issue.body_text().to_string(),
1119 Err(e) => {
1120 logdim!(" could not re-read #{tracker}: {}", e.first_line());
1121 return false;
1122 }
1123 };
1124 if !still_an_item(&body, raw) {
1125 logdim!(" not editing #{tracker}: {}", why_not_an_item(&body, raw));
1126 return false;
1127 }
1128 if let Some(at) = at {
1129 let now = split_keep(&body)
1130 .iter()
1131 .position(|line| without_eol(line) == raw)
1132 .map_or(0, |index| index + 1);
1133 if now != at {
1134 logdim!(" not editing #{tracker}: that line moved from line {at} to line {now}");
1135 return false;
1136 }
1137 }
1138 let updated = match rewrite(&body, raw, change) {
1139 Ok(updated) => updated,
1140 Err(e) => {
1141 logdim!(" not editing #{tracker}: {}", e.first_line());
1142 return false;
1143 }
1144 };
1145 match repo.edit_issue_body(tracker, &body, &updated, change.inserted()) {
1146 Ok(()) => true,
1147 Err(e) => {
1148 logdim!(" could not edit #{tracker}: {}", e.first_line());
1149 false
1150 }
1151 }
1152}
1153
1154fn report_skipped(tracker: i64, body: &str) {
1160 for skip in parse_reporting(body).1 {
1161 logdim!(
1162 " left line {} of #{tracker} alone, '{}': {}",
1163 skip.line,
1164 style::clip(&style::one_line(&skip.text), 60),
1165 skip.why
1166 );
1167 }
1168}
1169
1170fn report_overflow(cfg: &Config, tracker: i64, steps: &[Step]) {
1171 let left: Vec<String> = steps
1172 .iter()
1173 .filter(|s| s.action == Action::Over)
1174 .map(|s| format!("'{}'", style::clip(&style::one_line(&s.item.text), 60)))
1175 .collect();
1176 if left.is_empty() {
1177 return;
1178 }
1179 logwarn!(
1180 "#{tracker} has more unchecked items than max_tracker_children ({}), so {} were left for \
1181 a later run: {}",
1182 cfg.loop_cfg.max_tracker_children,
1183 left.len(),
1184 left.join(", ")
1185 );
1186}
1187
1188fn read(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1189 match repo.read_issue(tracker) {
1194 Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1195 Err(e) => {
1196 logdim!("could not read #{tracker}: {}", e.first_line());
1197 None
1198 }
1199 }
1200}
1201
1202fn read_for_write(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1203 match repo.record_failed_write(repo.read_issue(tracker)) {
1204 Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1205 Err(e) => {
1206 logdim!("could not read #{tracker}: {}", e.first_line());
1207 None
1208 }
1209 }
1210}
1211
1212fn home_of(url: &str) -> String {
1220 match url.rfind("/issues/") {
1221 Some(at) => url[..at].to_string(),
1222 None => String::new(),
1223 }
1224}
1225
1226pub fn preview(cfg: &Config, repo: &Repo, tracker: i64) {
1237 let Some((body, slug)) = read(repo, tracker) else {
1238 return;
1239 };
1240 let steps = plan(repo, cfg, tracker, &body, &slug);
1241 if steps.is_empty() {
1242 return;
1243 }
1244 println!("\n#{tracker}, if decompose_trackers let it act on the checklist:");
1245 report_skipped(tracker, &body);
1246
1247 let mut projected = body.clone();
1248 for step in &steps {
1249 let what = style::clip(&style::one_line(&step.item.text), 80);
1250 match &step.action {
1251 Action::Adopt(number) => println!(" keep '{what}' is already #{number}"),
1252 Action::Tick(number) => println!(" tick '{what}', #{number} is finished"),
1253 Action::Link {
1254 number,
1255 title,
1256 open,
1257 } => {
1258 let state = if *open { "open" } else { "closed" };
1259 println!(" link '{what}' to #{number} '{title}' ({state}), filing nothing");
1260 }
1261 Action::File => println!(" file '{what}'"),
1262 Action::Over => println!(" over '{what}' is past max_tracker_children"),
1263 Action::Hold(why) => println!(" hold '{what}': {why}"),
1264 }
1265 let change = match &step.action {
1269 Action::File => Some(Change::Reference(FILED.to_string())),
1270 other => other.change(),
1271 };
1272 let Some(change) = change else { continue };
1273 match rewrite(&projected, &step.item.raw, &change) {
1274 Ok(next) => projected = next,
1275 Err(e) => println!(" the line will not be rewritten: {e}"),
1276 }
1277 }
1278
1279 let diff = diff(&body, &projected);
1280 if diff.is_empty() {
1281 println!(" nothing would be written to the body");
1282 } else {
1283 println!(" and the body it would write:");
1284 for line in diff {
1285 println!(" {line}");
1286 }
1287 }
1288}
1289
1290const FILED: &str = "#(the issue it files)";
1293
1294fn diff(before: &str, after: &str) -> Vec<String> {
1297 split_keep(before)
1298 .into_iter()
1299 .zip(split_keep(after))
1300 .filter(|(old, new)| old != new)
1301 .flat_map(|(old, new)| {
1302 [
1303 format!("- {}", without_eol(old)),
1304 format!("+ {}", without_eol(new)),
1305 ]
1306 })
1307 .collect()
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313
1314 #[test]
1315 fn a_criterion_line_that_moved_out_or_doubled_is_not_ticked_and_says_why() {
1316 let raw = "- [ ] Preserve column order.";
1317 let twice = "- [ ] Preserve column order.\n- [ ] Preserve column order.\n";
1318 assert!(!still_an_item(twice, raw));
1319 assert!(why_not_an_item(twice, raw).contains("appears 2 times"));
1320 let changed = "- [ ] Preserve the column order.\n";
1321 assert!(!still_an_item(changed, raw));
1322 assert!(why_not_an_item(changed, raw).contains("changed or is gone"));
1323 let fenced = "```\n- [ ] Preserve column order.\n```\n";
1324 assert!(!still_an_item(fenced, raw));
1325 assert!(why_not_an_item(fenced, raw).contains("no longer a checklist item"));
1326 assert!(rewrite(
1328 "- [x] Preserve column order.\n",
1329 "- [x] Preserve column order.",
1330 &Change::Tick
1331 )
1332 .is_err());
1333 }
1334
1335 #[test]
1336 fn a_tick_leaves_every_other_byte_alone() {
1337 let body = "Intro\r\n- [ ] Preserve column order.\r\n- [ ] Connects on a device\r\n";
1338 let ticked = rewrite(body, "- [ ] Preserve column order.", &Change::Tick).unwrap();
1339 assert_eq!(
1340 "Intro\r\n- [x] Preserve column order.\r\n- [ ] Connects on a device\r\n",
1341 ticked
1342 );
1343 assert_eq!(2, criteria(body).len());
1344 assert_eq!(1, criteria(&ticked).len());
1345 }
1346
1347 const HOME: &str = "https://github.com/me/mine";
1349
1350 fn texts(body: &str) -> Vec<String> {
1351 parse(body).into_iter().map(|i| i.text).collect()
1352 }
1353
1354 #[test]
1355 fn the_ordinary_checklist_is_read_as_items() {
1356 let items = parse("Some prose.\n\n- [ ] first\n- [x] second\n");
1357 assert_eq!(2, items.len());
1358 assert_eq!("first", items[0].text);
1359 assert!(!items[0].checked);
1360 assert!(items[1].checked);
1361 assert_eq!(3, items[0].line);
1362 }
1363
1364 #[test]
1367 fn indented_and_nested_items_are_items() {
1368 let body = "- [ ] parent\n - [ ] child\n\t- [ ] tabbed\n * [ ] deeper\n1. [ ] ordered\n2) [ ] also ordered\n";
1369 assert_eq!(
1370 vec![
1371 "parent",
1372 "child",
1373 "tabbed",
1374 "deeper",
1375 "ordered",
1376 "also ordered"
1377 ],
1378 texts(body)
1379 );
1380 }
1381
1382 #[test]
1384 fn something_that_looks_like_an_item_inside_a_fence_is_not_one() {
1385 let body = "\
1386- [ ] real
1387
1388```markdown
1389- [ ] not real
1390```
1391
1392~~~
1393- [ ] also not real
1394~~~
1395
1396- [ ] real again
1397";
1398 assert_eq!(vec!["real", "real again"], texts(body));
1399 }
1400
1401 #[test]
1405 fn an_indented_code_block_is_not_a_checklist() {
1406 let body = "\
1407Write the parts like this:
1408
1409 - [ ] an example, not an item
1410
1411- [ ] real
1412 - [ ] nested
1413- plain bullet
1414 - [ ] nested under a bullet
1415";
1416 assert_eq!(vec!["real", "nested", "nested under a bullet"], texts(body));
1417 }
1418
1419 #[test]
1423 fn code_indented_inside_a_list_item_is_still_code() {
1424 let body = "\
1425- outer
1426
1427 - [ ] an example, not an item
1428
1429 - [ ] nested
1430- plain
1431 - [ ] nested under a bullet
1432 - [ ] and under that one
1433";
1434 assert_eq!(
1435 vec!["nested", "nested under a bullet", "and under that one"],
1436 texts(body)
1437 );
1438 }
1439
1440 #[test]
1443 fn an_item_inside_raw_html_is_not_one() {
1444 let body = "\
1445- [ ] real
1446
1447<pre>
1448- [ ] not real
1449</pre>
1450
1451<textarea>
1452- [ ] also not real
1453</textarea>
1454
1455- [ ] real again
1456";
1457 assert_eq!(vec!["real", "real again"], texts(body));
1458 }
1459
1460 #[test]
1465 fn an_item_inside_a_block_tag_is_not_one() {
1466 let body = "\
1467<div>
1468- [ ] not real
1469</div>
1470
1471<details>
1472<summary>the parts</summary>
1473
1474- [ ] real
1475</details>
1476";
1477 assert_eq!(vec!["real"], texts(body));
1478 }
1479
1480 #[test]
1484 fn a_checkbox_pushed_past_its_own_content_column_is_code() {
1485 assert_eq!(Vec::<String>::new(), texts("- [ ] an example\n"));
1486 assert_eq!(vec!["real"], texts("- [ ] real\n"));
1487 }
1488
1489 #[test]
1492 fn a_fence_indented_into_code_neither_opens_nor_closes() {
1493 let body = "```\n- [ ] not real\n ```\n- [ ] still not real\n";
1494 assert_eq!(Vec::<String>::new(), texts(body));
1495
1496 let body = "Like this:\n\n ```\n- [ ] real\n";
1497 assert_eq!(vec!["real"], texts(body));
1498 }
1499
1500 #[test]
1503 fn an_item_inside_an_html_comment_is_not_one() {
1504 let body = "\
1505- [ ] real
1506
1507<!--
1508- [ ] not real
1509-->
1510
1511- [ ] real again
1512<!-- - [ ] on one line, closed -->
1513- [ ] last
1514";
1515 assert_eq!(vec!["real", "real again", "last"], texts(body));
1516 }
1517
1518 #[test]
1521 fn a_fence_is_closed_only_by_its_own_kind() {
1522 let body = "~~~\n```\n- [ ] not real\n```\n~~~\n- [ ] real\n";
1523 assert_eq!(vec!["real"], texts(body));
1524 }
1525
1526 #[test]
1527 fn a_windows_body_is_read_the_same_way() {
1528 let items = parse("intro\r\n\r\n- [ ] first\r\n- [x] second\r\n");
1529 assert_eq!(2, items.len());
1530 assert_eq!("first", items[0].text);
1531 assert!(items[1].checked);
1532 assert_eq!(
1533 "- [ ] first", items[0].raw,
1534 "the terminator is not part of the handle"
1535 );
1536 }
1537
1538 #[test]
1539 fn a_reference_is_read_from_a_number_or_a_link() {
1540 let items = parse(
1541 "- [ ] one #12\n\
1542 - [ ] two https://github.com/o/r/issues/34\n\
1543 - [ ] [three](https://github.com/o/r/issues/56)\n\
1544 - [ ] four\n",
1545 );
1546 assert_eq!(Some(12), items[0].reference.as_ref().map(|r| r.number));
1547 assert_eq!(Some(34), items[1].reference.as_ref().map(|r| r.number));
1548 assert_eq!(Some(56), items[2].reference.as_ref().map(|r| r.number));
1549 assert_eq!(None, items[3].reference);
1550 }
1551
1552 #[test]
1556 fn a_link_to_a_pull_request_is_a_reference_too() {
1557 let items = parse(
1558 "- [ ] one https://github.com/me/mine/pull/42\n\
1559 - [ ] two https://github.com/me/mine/pull/43/files\n\
1560 - [ ] three https://github.com/other/thing/pull/44\n",
1561 );
1562 assert_eq!(Some(42), items[0].reference.as_ref().unwrap().local(HOME));
1563 assert_eq!(Some(43), items[1].reference.as_ref().unwrap().local(HOME));
1564 assert_eq!(None, items[2].reference.as_ref().unwrap().local(HOME));
1565 }
1566
1567 #[test]
1570 fn an_item_that_is_a_link_to_something_else_names_no_issue() {
1571 let items = parse("- [ ] [the docs](https://example.com/guide)\n");
1572 assert_eq!(None, items[0].reference);
1573 assert_eq!("[the docs](https://example.com/guide)", items[0].text);
1574 }
1575
1576 #[test]
1579 fn a_link_to_another_repository_is_not_adoptable() {
1580 let items = parse("- [ ] see https://github.com/other/thing/issues/7\n");
1581 let reference = items[0].reference.as_ref().expect("a reference");
1582 assert_eq!(None, reference.local(HOME));
1583 assert_eq!(Some(7), reference.local("https://github.com/other/thing"));
1584 }
1585
1586 #[test]
1588 fn a_bare_number_resolves_wherever_it_is_read() {
1589 let items = parse("- [ ] work #7\n");
1590 assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(""));
1591 }
1592
1593 #[test]
1596 fn a_link_to_the_same_path_on_another_host_is_not_this_repository() {
1597 for url in [
1598 "https://gitlab.example/me/mine/issues/7",
1599 "https://github.com/mirror/me/mine/issues/7",
1600 ] {
1601 let items = parse(&format!("- [ ] see {url}\n"));
1602 let reference = items[0].reference.as_ref().expect("a reference");
1603 assert_eq!(None, reference.local(HOME), "{url}");
1604 }
1605 }
1606
1607 #[test]
1609 fn the_scheme_is_not_what_makes_a_link_somebody_elses() {
1610 let items = parse("- [ ] see http://github.com/me/mine/issues/7\n");
1611 assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(HOME));
1612 }
1613
1614 #[test]
1617 fn home_is_read_off_the_trackers_own_url() {
1618 assert_eq!(HOME, home_of("https://github.com/me/mine/issues/29"));
1619 assert_eq!("", home_of(""));
1620 }
1621
1622 #[test]
1625 fn a_number_in_a_code_span_names_nothing() {
1626 let items = parse(
1627 "- [ ] Handle the literal `#12`, tracked in #34\n\
1628 - [ ] Only ``a #12 in a double span``\n",
1629 );
1630 assert_eq!(Some(34), items[0].reference.as_ref().map(|r| r.number));
1631 assert_eq!(None, items[1].reference);
1632 }
1633
1634 #[test]
1638 fn a_number_in_a_comment_names_nothing() {
1639 let items = parse(
1640 "- [ ] ship it <!-- old note: #7 -->\n\
1641 - [ ] and this one <!-- #7 --> #8\n",
1642 );
1643 assert_eq!(None, items[0].reference);
1644 assert_eq!(Some(8), items[1].reference.as_ref().map(|r| r.number));
1645 }
1646
1647 #[test]
1650 fn a_fragment_in_a_link_is_not_an_issue_number() {
1651 let items = parse(
1652 "- [ ] update [docs](https://example.com/guide/#8)\n\
1653 - [ ] see https://example.com/guide#9 and #10\n",
1654 );
1655 assert_eq!(None, items[0].reference);
1656 assert_eq!(Some(10), items[1].reference.as_ref().map(|r| r.number));
1657 }
1658
1659 #[test]
1663 fn the_shorthands_github_links_are_references_too() {
1664 let items = parse(
1665 "- [ ] one me/mine#12\n\
1666 - [ ] two other/thing#13\n\
1667 - [ ] three GH-14\n",
1668 );
1669 assert_eq!(Some(12), items[0].reference.as_ref().unwrap().local(HOME));
1670 let foreign = items[1].reference.as_ref().expect("a reference");
1671 assert_eq!(None, foreign.local(HOME), "somebody else's repository");
1672 assert_eq!("other/thing#13", foreign.names());
1673 assert_eq!(Some(14), items[2].reference.as_ref().unwrap().local(HOME));
1674 }
1675
1676 #[test]
1679 fn a_shorthand_is_read_against_this_repositorys_path() {
1680 let items = parse("- [ ] work me/mine#7\n");
1681 let reference = items[0].reference.as_ref().expect("a reference");
1682 assert_eq!(Some(7), reference.local("https://ghe.example/me/mine"));
1683 assert_eq!(None, reference.local("https://github.com/me/other"));
1684 assert_eq!(None, reference.local(""), "no address to measure against");
1685 }
1686
1687 #[test]
1691 fn a_link_is_read_from_its_destination_and_not_its_label() {
1692 let items = parse(
1693 "- [ ] [other/widgets #7](https://github.com/other/widgets/issues/7)\n\
1694 - [ ] [me/mine #7](https://github.com/me/mine/issues/7)\n",
1695 );
1696 let foreign = items[0].reference.as_ref().expect("a reference");
1697 assert!(
1698 matches!(foreign.origin, Origin::Url(_)),
1699 "the destination, not the label"
1700 );
1701 assert_eq!(None, foreign.local(HOME));
1702 assert_eq!(Some(7), items[1].reference.as_ref().unwrap().local(HOME));
1703 }
1704
1705 #[test]
1709 fn complex_link_labels_still_read_the_destination() {
1710 let items = parse(
1711 "- [ ] [see [#7]](https://github.com/other/widgets/issues/8)\n\
1712 - [ ] [see \\] #7](https://github.com/other/widgets/issues/8)\n",
1713 );
1714 for item in items {
1715 let reference = item.reference.expect("the destination");
1716 assert_eq!(8, reference.number);
1717 assert!(matches!(reference.origin, Origin::Url(_)));
1718 assert_eq!(None, reference.local(HOME));
1719 }
1720 }
1721
1722 #[test]
1723 fn one_child_referenced_by_several_items_is_worked_once() {
1724 assert_eq!(vec![8, 9], unique_children(vec![8, 8, 9, 8]));
1725 }
1726
1727 #[test]
1730 fn a_reference_is_appended_to_its_own_line_and_nowhere_else() {
1731 let body = "intro\n\n- [ ] first\n- [ ] second\n\nmore prose\n";
1732 let out =
1733 rewrite(body, "- [ ] first", &Change::Reference("#40".into())).expect("a rewrite");
1734 assert_eq!(
1735 "intro\n\n- [ ] first #40\n- [ ] second\n\nmore prose\n",
1736 out
1737 );
1738 }
1739
1740 #[test]
1743 fn a_hard_break_survives_the_edit() {
1744 let out = rewrite(
1745 "- [ ] first \nnext\n",
1746 "- [ ] first ",
1747 &Change::Reference("#4".into()),
1748 )
1749 .expect("a rewrite");
1750 assert_eq!("- [ ] first #4 \nnext\n", out);
1751 }
1752
1753 #[test]
1754 fn every_other_line_comes_through_byte_identical() {
1755 let body = "# Plan\r\n\r\n trailing spaces here \r\n- [ ] one\r\n\r\n\r\n\r\nlots of blank lines above\r\n";
1756 let out = rewrite(body, "- [ ] one", &Change::Reference("#9".into())).expect("a rewrite");
1757 let (before, after): (Vec<&str>, Vec<&str>) =
1758 (body.lines().collect(), out.lines().collect());
1759 assert_eq!(before.len(), after.len());
1760 for (i, (a, b)) in before.iter().zip(&after).enumerate() {
1761 if i == 3 {
1762 assert_eq!("- [ ] one #9", *b);
1763 } else {
1764 assert_eq!(a, b, "line {} changed", i + 1);
1765 }
1766 }
1767 assert!(out.contains("trailing spaces here \r\n"));
1768 assert!(out.contains("\r\n\r\n\r\n\r\n"));
1769 }
1770
1771 #[test]
1772 fn a_body_with_no_final_newline_keeps_not_having_one() {
1773 let out = rewrite("- [ ] only", "- [ ] only", &Change::Tick).expect("a rewrite");
1774 assert_eq!("- [x] only", out);
1775 }
1776
1777 #[test]
1778 fn ticking_changes_the_box_and_leaves_the_text() {
1779 let out = rewrite(" - [ ] deep #3\n", " - [ ] deep #3", &Change::Tick).expect("a tick");
1780 assert_eq!(" - [x] deep #3\n", out);
1781 }
1782
1783 #[test]
1786 fn a_ticked_box_is_never_written_again() {
1787 assert!(rewrite("- [x] done\n", "- [x] done", &Change::Tick).is_err());
1788 assert!(!matches!(Change::Tick, Change::Reference(_)));
1789 }
1790
1791 #[test]
1792 fn a_line_that_is_gone_or_ambiguous_is_a_refusal_not_a_guess() {
1793 assert!(rewrite("- [ ] a\n", "- [ ] b", &Change::Tick).is_err());
1794 let twice = "- [ ] same\n- [ ] same\n";
1795 assert!(rewrite(twice, "- [ ] same", &Change::Tick).is_err());
1796 }
1797
1798 #[test]
1799 fn an_item_with_no_text_gets_no_reference() {
1800 assert!(rewrite("- [ ]\n", "- [ ]", &Change::Reference("#1".into())).is_err());
1801 }
1802
1803 fn shapes(body: &str, max: usize) -> Vec<Shape> {
1806 shape(body, HOME, max).into_iter().map(|(_, s)| s).collect()
1807 }
1808
1809 #[test]
1810 fn a_checked_item_is_never_reconsidered() {
1811 assert!(shapes("- [x] done\n", 5).is_empty());
1812 }
1813
1814 #[test]
1815 fn an_item_that_names_an_issue_is_kept_apart_from_one_that_does_not() {
1816 assert_eq!(
1817 vec![Shape::Names(12), Shape::Needs],
1818 shapes("- [ ] one #12\n- [ ] two\n", 5)
1819 );
1820 }
1821
1822 #[test]
1825 fn the_cap_stops_at_the_cap() {
1826 let body = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d\n";
1827 assert_eq!(
1828 vec![Shape::Needs, Shape::Needs, Shape::Over, Shape::Over],
1829 shapes(body, 2)
1830 );
1831 }
1832
1833 #[test]
1840 fn an_item_closed_as_not_planned_is_not_ticked() {
1841 let issue = |state: &str, reason: Option<&str>| Issue {
1842 number: 12,
1843 title: "migrate the cache to v2".into(),
1844 body: None,
1845 state: state.into(),
1846 state_reason: reason.map(str::to_string),
1847 url: String::new(),
1848 labels: Vec::new(),
1849 author: None,
1850 comments: Vec::new(),
1851 };
1852
1853 assert!(matches!(
1854 issue_action(&issue("CLOSED", Some("not_planned"))),
1855 Action::Hold(_)
1856 ));
1857 assert_eq!(
1858 Action::Tick(12),
1859 issue_action(&issue("CLOSED", Some("completed")))
1860 );
1861 assert_eq!(
1862 Action::Tick(12),
1863 issue_action(&issue("CLOSED", None)),
1864 "an issue closed before GitHub recorded a reason still ticks"
1865 );
1866 assert_eq!(Action::Adopt(12), issue_action(&issue("OPEN", None)));
1867 }
1868
1869 #[test]
1875 fn the_cap_counts_only_the_items_that_need_an_issue_filed() {
1876 let body = "- [ ] a #1\n- [ ] b #2\n- [ ] c\n- [ ] d\n";
1877 assert_eq!(
1878 vec![Shape::Names(1), Shape::Names(2), Shape::Needs, Shape::Over],
1879 shapes(body, 1)
1880 );
1881 }
1882
1883 #[test]
1886 fn an_inline_comment_marker_does_not_hide_the_rest_of_the_list() {
1887 let body = "- [ ] document the `<!--` syntax\n- [ ] the next one\n";
1888 assert_eq!(vec![Shape::Needs, Shape::Needs], shapes(body, 5));
1889
1890 let body = "<!-- notes\n- [ ] hidden\n-->\n- [ ] visible\n";
1892 assert_eq!(vec![Shape::Needs], shapes(body, 5));
1893 }
1894
1895 #[test]
1898 fn a_checkbox_spar_will_not_touch_says_why() {
1899 let body = "<!-- later\n- [ ] in a comment\n-->\n\n> - [ ] in a quote\n\n```\n- [ ] in a fence\n```\n";
1900 let reasons: Vec<&str> = parse_reporting(body).1.iter().map(|s| s.why).collect();
1901 assert_eq!(
1902 vec![
1903 "it is inside an HTML comment",
1904 "it is inside a block quote",
1905 "it is inside a code fence",
1906 ],
1907 reasons
1908 );
1909 }
1910
1911 #[test]
1913 fn the_cap_counts_only_what_it_acts_on() {
1914 let body = "- [x] a\n- [x] b\n- [ ] c\n";
1915 assert_eq!(vec![Shape::Needs], shapes(body, 1));
1916 }
1917
1918 #[test]
1919 fn two_identical_items_are_left_alone() {
1920 let out = shapes("- [ ] same\n- [ ] same\n", 5);
1921 assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1922 assert!(matches!(out[1], Shape::Hold(_)), "{out:?}");
1923 }
1924
1925 #[test]
1926 fn an_item_naming_another_repository_is_held_rather_than_adopted() {
1927 let out = shapes("- [ ] see https://github.com/other/thing/issues/7\n", 5);
1928 assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1929 }
1930
1931 #[test]
1934 fn a_realistic_tracker_keeps_every_line_it_was_not_asked_to_change() {
1935 let body = "\
1936Context somebody wrote, with a hard break here:
1937and the rest of it.
1938
1939## Parts
1940
1941- [x] already done
1942- [ ] parse the checklist
1943- [ ] write the link back #40
1944 - [ ] and prove it first
1945
1946```markdown
1947- [ ] an example, not an item
1948```
1949
1950That is all.
1951";
1952 let shapes: Vec<Shape> = shape(body, HOME, 5).into_iter().map(|(_, s)| s).collect();
1953 assert_eq!(
1954 vec![Shape::Needs, Shape::Names(40), Shape::Needs],
1955 shapes,
1956 "the ticked item, the fenced one and the prose are all left out"
1957 );
1958
1959 let out = rewrite(
1960 body,
1961 "- [ ] parse the checklist",
1962 &Change::Reference("#41".into()),
1963 )
1964 .expect("a link");
1965 let out = rewrite(
1966 &out,
1967 " - [ ] and prove it first",
1968 &Change::Reference("#42".into()),
1969 )
1970 .expect("a nested link");
1971 let out = rewrite(&out, "- [ ] write the link back #40", &Change::Tick).expect("a tick");
1972
1973 assert_eq!(
1974 "\
1975Context somebody wrote, with a hard break here:
1976and the rest of it.
1977
1978## Parts
1979
1980- [x] already done
1981- [ ] parse the checklist #41
1982- [x] write the link back #40
1983 - [ ] and prove it first #42
1984
1985```markdown
1986- [ ] an example, not an item
1987```
1988
1989That is all.
1990",
1991 out
1992 );
1993 }
1994
1995 #[test]
2002 fn an_item_linked_by_similarity_is_not_ticked_in_the_same_run() {
2003 for open in [true, false] {
2004 let action = Action::Link {
2005 number: 7,
2006 title: "something close enough".into(),
2007 open,
2008 };
2009 assert_eq!(Some(Change::Reference("#7".into())), action.change());
2010 }
2011 }
2012
2013 #[test]
2016 fn an_item_that_already_named_its_issue_is_ticked_when_that_issue_closes() {
2017 assert_eq!(Some(Change::Tick), Action::Tick(7).change());
2018 }
2019
2020 #[test]
2022 fn nothing_is_written_for_an_item_that_is_already_linked_and_open() {
2023 assert_eq!(None, Action::Adopt(7).change());
2024 assert_eq!(None, Action::Over.change());
2025 assert_eq!(None, Action::Hold("any reason".into()).change());
2026 }
2027
2028 #[test]
2034 fn a_line_that_stopped_being_an_item_is_not_written_to() {
2035 let raw = "- [ ] ship it";
2036 assert!(still_an_item("intro\n\n- [ ] ship it\n", raw));
2037 assert!(!still_an_item("```\n- [ ] ship it\n```\n", raw));
2038 assert!(!still_an_item("<!--\n- [ ] ship it\n-->\n", raw));
2039 assert!(!still_an_item("- [ ] something else\n", raw));
2040 assert!(
2041 !still_an_item("- [ ] ship it\n- [ ] ship it\n", raw),
2042 "two alike is a line the edit could go to either of"
2043 );
2044 }
2045
2046 #[test]
2049 fn the_diff_shows_only_the_lines_that_change() {
2050 let before = "- [ ] one\n- [ ] two\n";
2051 let after =
2052 rewrite(before, "- [ ] two", &Change::Reference("#8".into())).expect("a rewrite");
2053 assert_eq!(
2054 vec!["- - [ ] two".to_string(), "+ - [ ] two #8".to_string()],
2055 diff(before, &after)
2056 );
2057 }
2058}