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::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 let mut out = Vec::new();
243 let mut fence: Option<(char, usize, usize)> = None;
246 let mut comment = false;
247 let mut html: Option<Html> = None;
248 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 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 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 if !line.trim().is_empty() {
290 while open.last().is_some_and(|col| indent < *col) {
291 open.pop();
292 }
293 }
294 let margin = open.last().copied().unwrap_or(0);
298 if !line.trim().is_empty() && indent >= margin + 4 {
299 continue;
300 }
301 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
339fn 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
356fn 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 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
403fn 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 String::from_utf8(out).unwrap_or_else(|_| text.to_string())
412}
413
414fn 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 String::from_utf8(out).unwrap_or_else(|_| text.to_string())
448}
449
450fn 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
470fn 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
509fn 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
526fn 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
535fn 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#[derive(Debug, Clone, PartialEq, Eq)]
566pub enum Change {
567 Tick,
573 Reference(String),
575}
576
577impl Change {
578 pub fn inserted(&self) -> &str {
581 match self {
582 Change::Tick => "x",
583 Change::Reference(reference) => reference,
584 }
585 }
586}
587
588pub fn rewrite(body: &str, raw: &str, change: &Change) -> Result<String> {
595 let lines = split_keep(body);
596 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 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
645fn 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 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#[derive(Debug, Clone, PartialEq, Eq)]
682enum Shape {
683 Names(i64),
686 Needs,
688 Hold(String),
690 Over,
692}
693
694#[derive(Debug, Clone, PartialEq, Eq)]
696pub enum Action {
697 Adopt(i64),
699 Tick(i64),
702 Link {
707 number: i64,
708 title: String,
709 open: bool,
710 },
711 File,
713 Hold(String),
715 Over,
717}
718
719impl Action {
720 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#[derive(Debug, Clone)]
742pub struct Step {
743 pub item: Item,
744 pub action: Action,
745}
746
747fn shape(body: &str, home: &str, max: usize) -> Vec<(Item, Shape)> {
752 let items = parse(body);
753 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
795pub 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 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
828fn 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 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
858fn 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
869fn child_body(text: &str, tracker: i64) -> String {
872 format!("{text}\n\nFrom the checklist in #{tracker}.")
873}
874
875pub fn decompose(cfg: &Config, repo: &Repo, tracker: i64) -> Vec<i64> {
886 let Some((body, slug)) = read(repo, tracker) else {
887 return Vec::new();
888 };
889 let steps = plan(repo, cfg, tracker, &body, &slug);
890 if steps.is_empty() {
891 logdim!("#{tracker} has no unchecked checklist items, so there is nothing to extract");
892 return Vec::new();
893 }
894 log!("#{tracker}: {} unchecked checklist item(s)", steps.len());
895 report_overflow(cfg, tracker, &steps);
896 apply(repo, tracker, &steps)
897}
898
899fn apply(repo: &Repo, tracker: i64, steps: &[Step]) -> Vec<i64> {
900 let mut children = Vec::new();
901 for step in steps {
902 let what = style::clip(&style::one_line(&step.item.text), 80);
903 match &step.action {
904 Action::Hold(why) => logdim!(" left '{what}' alone: {why}"),
905 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 Action::Link {
923 number,
924 title,
925 open,
926 } => {
927 log!(" linking '{what}' to #{number} '{title}', filed nothing");
928 let Some(change) = step.action.change() else {
929 continue;
930 };
931 if write(repo, tracker, &step.item.raw, &change) && *open {
932 children.push(*number);
933 }
934 }
935 Action::File => {
936 let Ok(title) = repo.clean_title(&step.item.text) else {
937 logdim!(" could not clean a title out of '{what}'");
938 continue;
939 };
940 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 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
990fn 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
1005fn still_asked_for(repo: &Repo, tracker: i64, raw: &str) -> bool {
1010 match repo.read_issue(tracker) {
1011 Ok(issue) => still_an_item(issue.body_text(), raw),
1012 Err(e) => {
1013 logdim!(" could not re-read #{tracker}: {}", e.first_line());
1014 false
1015 }
1016 }
1017}
1018
1019fn write(repo: &Repo, tracker: i64, raw: &str, change: &Change) -> bool {
1027 let body = match repo.read_issue(tracker) {
1028 Ok(issue) => issue.body_text().to_string(),
1029 Err(e) => {
1030 logdim!(" could not re-read #{tracker}: {}", e.first_line());
1031 return false;
1032 }
1033 };
1034 if !still_an_item(&body, raw) {
1035 logdim!(" not editing #{tracker}: that line is no longer a checklist item in it");
1036 return false;
1037 }
1038 let updated = match rewrite(&body, raw, change) {
1039 Ok(updated) => updated,
1040 Err(e) => {
1041 logdim!(" not editing #{tracker}: {}", e.first_line());
1042 return false;
1043 }
1044 };
1045 match repo.edit_issue_body(tracker, &body, &updated, change.inserted()) {
1046 Ok(()) => true,
1047 Err(e) => {
1048 logdim!(" could not edit #{tracker}: {}", e.first_line());
1049 false
1050 }
1051 }
1052}
1053
1054fn report_overflow(cfg: &Config, tracker: i64, steps: &[Step]) {
1055 let left: Vec<String> = steps
1056 .iter()
1057 .filter(|s| s.action == Action::Over)
1058 .map(|s| format!("'{}'", style::clip(&style::one_line(&s.item.text), 60)))
1059 .collect();
1060 if left.is_empty() {
1061 return;
1062 }
1063 logwarn!(
1064 "#{tracker} has more unchecked items than max_tracker_children ({}), so {} were left for \
1065 a later run: {}",
1066 cfg.loop_cfg.max_tracker_children,
1067 left.len(),
1068 left.join(", ")
1069 );
1070}
1071
1072fn read(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1073 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 home_of(url: &str) -> String {
1094 match url.rfind("/issues/") {
1095 Some(at) => url[..at].to_string(),
1096 None => String::new(),
1097 }
1098}
1099
1100pub fn preview(cfg: &Config, repo: &Repo, tracker: i64) {
1111 let Some((body, slug)) = read(repo, tracker) else {
1112 return;
1113 };
1114 let steps = plan(repo, cfg, tracker, &body, &slug);
1115 if steps.is_empty() {
1116 return;
1117 }
1118 println!("\n#{tracker}, if decompose_trackers let it act on the checklist:");
1119
1120 let mut projected = body.clone();
1121 for step in &steps {
1122 let what = style::clip(&style::one_line(&step.item.text), 80);
1123 match &step.action {
1124 Action::Adopt(number) => println!(" keep '{what}' is already #{number}"),
1125 Action::Tick(number) => println!(" tick '{what}', #{number} is finished"),
1126 Action::Link {
1127 number,
1128 title,
1129 open,
1130 } => {
1131 let state = if *open { "open" } else { "closed" };
1132 println!(" link '{what}' to #{number} '{title}' ({state}), filing nothing");
1133 }
1134 Action::File => println!(" file '{what}'"),
1135 Action::Over => println!(" over '{what}' is past max_tracker_children"),
1136 Action::Hold(why) => println!(" hold '{what}': {why}"),
1137 }
1138 let change = match &step.action {
1142 Action::File => Some(Change::Reference(FILED.to_string())),
1143 other => other.change(),
1144 };
1145 let Some(change) = change else { continue };
1146 match rewrite(&projected, &step.item.raw, &change) {
1147 Ok(next) => projected = next,
1148 Err(e) => println!(" the line will not be rewritten: {e}"),
1149 }
1150 }
1151
1152 let diff = diff(&body, &projected);
1153 if diff.is_empty() {
1154 println!(" nothing would be written to the body");
1155 } else {
1156 println!(" and the body it would write:");
1157 for line in diff {
1158 println!(" {line}");
1159 }
1160 }
1161}
1162
1163const FILED: &str = "#(the issue it files)";
1166
1167fn diff(before: &str, after: &str) -> Vec<String> {
1170 split_keep(before)
1171 .into_iter()
1172 .zip(split_keep(after))
1173 .filter(|(old, new)| old != new)
1174 .flat_map(|(old, new)| {
1175 [
1176 format!("- {}", without_eol(old)),
1177 format!("+ {}", without_eol(new)),
1178 ]
1179 })
1180 .collect()
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185 use super::*;
1186
1187 const HOME: &str = "https://github.com/me/mine";
1189
1190 fn texts(body: &str) -> Vec<String> {
1191 parse(body).into_iter().map(|i| i.text).collect()
1192 }
1193
1194 #[test]
1195 fn the_ordinary_checklist_is_read_as_items() {
1196 let items = parse("Some prose.\n\n- [ ] first\n- [x] second\n");
1197 assert_eq!(2, items.len());
1198 assert_eq!("first", items[0].text);
1199 assert!(!items[0].checked);
1200 assert!(items[1].checked);
1201 assert_eq!(3, items[0].line);
1202 }
1203
1204 #[test]
1207 fn indented_and_nested_items_are_items() {
1208 let body = "- [ ] parent\n - [ ] child\n\t- [ ] tabbed\n * [ ] deeper\n1. [ ] ordered\n2) [ ] also ordered\n";
1209 assert_eq!(
1210 vec![
1211 "parent",
1212 "child",
1213 "tabbed",
1214 "deeper",
1215 "ordered",
1216 "also ordered"
1217 ],
1218 texts(body)
1219 );
1220 }
1221
1222 #[test]
1224 fn something_that_looks_like_an_item_inside_a_fence_is_not_one() {
1225 let body = "\
1226- [ ] real
1227
1228```markdown
1229- [ ] not real
1230```
1231
1232~~~
1233- [ ] also not real
1234~~~
1235
1236- [ ] real again
1237";
1238 assert_eq!(vec!["real", "real again"], texts(body));
1239 }
1240
1241 #[test]
1245 fn an_indented_code_block_is_not_a_checklist() {
1246 let body = "\
1247Write the parts like this:
1248
1249 - [ ] an example, not an item
1250
1251- [ ] real
1252 - [ ] nested
1253- plain bullet
1254 - [ ] nested under a bullet
1255";
1256 assert_eq!(vec!["real", "nested", "nested under a bullet"], texts(body));
1257 }
1258
1259 #[test]
1263 fn code_indented_inside_a_list_item_is_still_code() {
1264 let body = "\
1265- outer
1266
1267 - [ ] an example, not an item
1268
1269 - [ ] nested
1270- plain
1271 - [ ] nested under a bullet
1272 - [ ] and under that one
1273";
1274 assert_eq!(
1275 vec!["nested", "nested under a bullet", "and under that one"],
1276 texts(body)
1277 );
1278 }
1279
1280 #[test]
1283 fn an_item_inside_raw_html_is_not_one() {
1284 let body = "\
1285- [ ] real
1286
1287<pre>
1288- [ ] not real
1289</pre>
1290
1291<textarea>
1292- [ ] also not real
1293</textarea>
1294
1295- [ ] real again
1296";
1297 assert_eq!(vec!["real", "real again"], texts(body));
1298 }
1299
1300 #[test]
1305 fn an_item_inside_a_block_tag_is_not_one() {
1306 let body = "\
1307<div>
1308- [ ] not real
1309</div>
1310
1311<details>
1312<summary>the parts</summary>
1313
1314- [ ] real
1315</details>
1316";
1317 assert_eq!(vec!["real"], texts(body));
1318 }
1319
1320 #[test]
1324 fn a_checkbox_pushed_past_its_own_content_column_is_code() {
1325 assert_eq!(Vec::<String>::new(), texts("- [ ] an example\n"));
1326 assert_eq!(vec!["real"], texts("- [ ] real\n"));
1327 }
1328
1329 #[test]
1332 fn a_fence_indented_into_code_neither_opens_nor_closes() {
1333 let body = "```\n- [ ] not real\n ```\n- [ ] still not real\n";
1334 assert_eq!(Vec::<String>::new(), texts(body));
1335
1336 let body = "Like this:\n\n ```\n- [ ] real\n";
1337 assert_eq!(vec!["real"], texts(body));
1338 }
1339
1340 #[test]
1343 fn an_item_inside_an_html_comment_is_not_one() {
1344 let body = "\
1345- [ ] real
1346
1347<!--
1348- [ ] not real
1349-->
1350
1351- [ ] real again
1352<!-- - [ ] on one line, closed -->
1353- [ ] last
1354";
1355 assert_eq!(vec!["real", "real again", "last"], texts(body));
1356 }
1357
1358 #[test]
1361 fn a_fence_is_closed_only_by_its_own_kind() {
1362 let body = "~~~\n```\n- [ ] not real\n```\n~~~\n- [ ] real\n";
1363 assert_eq!(vec!["real"], texts(body));
1364 }
1365
1366 #[test]
1367 fn a_windows_body_is_read_the_same_way() {
1368 let items = parse("intro\r\n\r\n- [ ] first\r\n- [x] second\r\n");
1369 assert_eq!(2, items.len());
1370 assert_eq!("first", items[0].text);
1371 assert!(items[1].checked);
1372 assert_eq!(
1373 "- [ ] first", items[0].raw,
1374 "the terminator is not part of the handle"
1375 );
1376 }
1377
1378 #[test]
1379 fn a_reference_is_read_from_a_number_or_a_link() {
1380 let items = parse(
1381 "- [ ] one #12\n\
1382 - [ ] two https://github.com/o/r/issues/34\n\
1383 - [ ] [three](https://github.com/o/r/issues/56)\n\
1384 - [ ] four\n",
1385 );
1386 assert_eq!(Some(12), items[0].reference.as_ref().map(|r| r.number));
1387 assert_eq!(Some(34), items[1].reference.as_ref().map(|r| r.number));
1388 assert_eq!(Some(56), items[2].reference.as_ref().map(|r| r.number));
1389 assert_eq!(None, items[3].reference);
1390 }
1391
1392 #[test]
1396 fn a_link_to_a_pull_request_is_a_reference_too() {
1397 let items = parse(
1398 "- [ ] one https://github.com/me/mine/pull/42\n\
1399 - [ ] two https://github.com/me/mine/pull/43/files\n\
1400 - [ ] three https://github.com/other/thing/pull/44\n",
1401 );
1402 assert_eq!(Some(42), items[0].reference.as_ref().unwrap().local(HOME));
1403 assert_eq!(Some(43), items[1].reference.as_ref().unwrap().local(HOME));
1404 assert_eq!(None, items[2].reference.as_ref().unwrap().local(HOME));
1405 }
1406
1407 #[test]
1410 fn an_item_that_is_a_link_to_something_else_names_no_issue() {
1411 let items = parse("- [ ] [the docs](https://example.com/guide)\n");
1412 assert_eq!(None, items[0].reference);
1413 assert_eq!("[the docs](https://example.com/guide)", items[0].text);
1414 }
1415
1416 #[test]
1419 fn a_link_to_another_repository_is_not_adoptable() {
1420 let items = parse("- [ ] see https://github.com/other/thing/issues/7\n");
1421 let reference = items[0].reference.as_ref().expect("a reference");
1422 assert_eq!(None, reference.local(HOME));
1423 assert_eq!(Some(7), reference.local("https://github.com/other/thing"));
1424 }
1425
1426 #[test]
1428 fn a_bare_number_resolves_wherever_it_is_read() {
1429 let items = parse("- [ ] work #7\n");
1430 assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(""));
1431 }
1432
1433 #[test]
1436 fn a_link_to_the_same_path_on_another_host_is_not_this_repository() {
1437 for url in [
1438 "https://gitlab.example/me/mine/issues/7",
1439 "https://github.com/mirror/me/mine/issues/7",
1440 ] {
1441 let items = parse(&format!("- [ ] see {url}\n"));
1442 let reference = items[0].reference.as_ref().expect("a reference");
1443 assert_eq!(None, reference.local(HOME), "{url}");
1444 }
1445 }
1446
1447 #[test]
1449 fn the_scheme_is_not_what_makes_a_link_somebody_elses() {
1450 let items = parse("- [ ] see http://github.com/me/mine/issues/7\n");
1451 assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(HOME));
1452 }
1453
1454 #[test]
1457 fn home_is_read_off_the_trackers_own_url() {
1458 assert_eq!(HOME, home_of("https://github.com/me/mine/issues/29"));
1459 assert_eq!("", home_of(""));
1460 }
1461
1462 #[test]
1465 fn a_number_in_a_code_span_names_nothing() {
1466 let items = parse(
1467 "- [ ] Handle the literal `#12`, tracked in #34\n\
1468 - [ ] Only ``a #12 in a double span``\n",
1469 );
1470 assert_eq!(Some(34), items[0].reference.as_ref().map(|r| r.number));
1471 assert_eq!(None, items[1].reference);
1472 }
1473
1474 #[test]
1478 fn a_number_in_a_comment_names_nothing() {
1479 let items = parse(
1480 "- [ ] ship it <!-- old note: #7 -->\n\
1481 - [ ] and this one <!-- #7 --> #8\n",
1482 );
1483 assert_eq!(None, items[0].reference);
1484 assert_eq!(Some(8), items[1].reference.as_ref().map(|r| r.number));
1485 }
1486
1487 #[test]
1490 fn a_fragment_in_a_link_is_not_an_issue_number() {
1491 let items = parse(
1492 "- [ ] update [docs](https://example.com/guide/#8)\n\
1493 - [ ] see https://example.com/guide#9 and #10\n",
1494 );
1495 assert_eq!(None, items[0].reference);
1496 assert_eq!(Some(10), items[1].reference.as_ref().map(|r| r.number));
1497 }
1498
1499 #[test]
1503 fn the_shorthands_github_links_are_references_too() {
1504 let items = parse(
1505 "- [ ] one me/mine#12\n\
1506 - [ ] two other/thing#13\n\
1507 - [ ] three GH-14\n",
1508 );
1509 assert_eq!(Some(12), items[0].reference.as_ref().unwrap().local(HOME));
1510 let foreign = items[1].reference.as_ref().expect("a reference");
1511 assert_eq!(None, foreign.local(HOME), "somebody else's repository");
1512 assert_eq!("other/thing#13", foreign.names());
1513 assert_eq!(Some(14), items[2].reference.as_ref().unwrap().local(HOME));
1514 }
1515
1516 #[test]
1519 fn a_shorthand_is_read_against_this_repositorys_path() {
1520 let items = parse("- [ ] work me/mine#7\n");
1521 let reference = items[0].reference.as_ref().expect("a reference");
1522 assert_eq!(Some(7), reference.local("https://ghe.example/me/mine"));
1523 assert_eq!(None, reference.local("https://github.com/me/other"));
1524 assert_eq!(None, reference.local(""), "no address to measure against");
1525 }
1526
1527 #[test]
1531 fn a_link_is_read_from_its_destination_and_not_its_label() {
1532 let items = parse(
1533 "- [ ] [other/widgets #7](https://github.com/other/widgets/issues/7)\n\
1534 - [ ] [me/mine #7](https://github.com/me/mine/issues/7)\n",
1535 );
1536 let foreign = items[0].reference.as_ref().expect("a reference");
1537 assert!(
1538 matches!(foreign.origin, Origin::Url(_)),
1539 "the destination, not the label"
1540 );
1541 assert_eq!(None, foreign.local(HOME));
1542 assert_eq!(Some(7), items[1].reference.as_ref().unwrap().local(HOME));
1543 }
1544
1545 #[test]
1549 fn complex_link_labels_still_read_the_destination() {
1550 let items = parse(
1551 "- [ ] [see [#7]](https://github.com/other/widgets/issues/8)\n\
1552 - [ ] [see \\] #7](https://github.com/other/widgets/issues/8)\n",
1553 );
1554 for item in items {
1555 let reference = item.reference.expect("the destination");
1556 assert_eq!(8, reference.number);
1557 assert!(matches!(reference.origin, Origin::Url(_)));
1558 assert_eq!(None, reference.local(HOME));
1559 }
1560 }
1561
1562 #[test]
1563 fn one_child_referenced_by_several_items_is_worked_once() {
1564 assert_eq!(vec![8, 9], unique_children(vec![8, 8, 9, 8]));
1565 }
1566
1567 #[test]
1570 fn a_reference_is_appended_to_its_own_line_and_nowhere_else() {
1571 let body = "intro\n\n- [ ] first\n- [ ] second\n\nmore prose\n";
1572 let out =
1573 rewrite(body, "- [ ] first", &Change::Reference("#40".into())).expect("a rewrite");
1574 assert_eq!(
1575 "intro\n\n- [ ] first #40\n- [ ] second\n\nmore prose\n",
1576 out
1577 );
1578 }
1579
1580 #[test]
1583 fn a_hard_break_survives_the_edit() {
1584 let out = rewrite(
1585 "- [ ] first \nnext\n",
1586 "- [ ] first ",
1587 &Change::Reference("#4".into()),
1588 )
1589 .expect("a rewrite");
1590 assert_eq!("- [ ] first #4 \nnext\n", out);
1591 }
1592
1593 #[test]
1594 fn every_other_line_comes_through_byte_identical() {
1595 let body = "# Plan\r\n\r\n trailing spaces here \r\n- [ ] one\r\n\r\n\r\n\r\nlots of blank lines above\r\n";
1596 let out = rewrite(body, "- [ ] one", &Change::Reference("#9".into())).expect("a rewrite");
1597 let (before, after): (Vec<&str>, Vec<&str>) =
1598 (body.lines().collect(), out.lines().collect());
1599 assert_eq!(before.len(), after.len());
1600 for (i, (a, b)) in before.iter().zip(&after).enumerate() {
1601 if i == 3 {
1602 assert_eq!("- [ ] one #9", *b);
1603 } else {
1604 assert_eq!(a, b, "line {} changed", i + 1);
1605 }
1606 }
1607 assert!(out.contains("trailing spaces here \r\n"));
1608 assert!(out.contains("\r\n\r\n\r\n\r\n"));
1609 }
1610
1611 #[test]
1612 fn a_body_with_no_final_newline_keeps_not_having_one() {
1613 let out = rewrite("- [ ] only", "- [ ] only", &Change::Tick).expect("a rewrite");
1614 assert_eq!("- [x] only", out);
1615 }
1616
1617 #[test]
1618 fn ticking_changes_the_box_and_leaves_the_text() {
1619 let out = rewrite(" - [ ] deep #3\n", " - [ ] deep #3", &Change::Tick).expect("a tick");
1620 assert_eq!(" - [x] deep #3\n", out);
1621 }
1622
1623 #[test]
1626 fn a_ticked_box_is_never_written_again() {
1627 assert!(rewrite("- [x] done\n", "- [x] done", &Change::Tick).is_err());
1628 assert!(!matches!(Change::Tick, Change::Reference(_)));
1629 }
1630
1631 #[test]
1632 fn a_line_that_is_gone_or_ambiguous_is_a_refusal_not_a_guess() {
1633 assert!(rewrite("- [ ] a\n", "- [ ] b", &Change::Tick).is_err());
1634 let twice = "- [ ] same\n- [ ] same\n";
1635 assert!(rewrite(twice, "- [ ] same", &Change::Tick).is_err());
1636 }
1637
1638 #[test]
1639 fn an_item_with_no_text_gets_no_reference() {
1640 assert!(rewrite("- [ ]\n", "- [ ]", &Change::Reference("#1".into())).is_err());
1641 }
1642
1643 fn shapes(body: &str, max: usize) -> Vec<Shape> {
1646 shape(body, HOME, max).into_iter().map(|(_, s)| s).collect()
1647 }
1648
1649 #[test]
1650 fn a_checked_item_is_never_reconsidered() {
1651 assert!(shapes("- [x] done\n", 5).is_empty());
1652 }
1653
1654 #[test]
1655 fn an_item_that_names_an_issue_is_kept_apart_from_one_that_does_not() {
1656 assert_eq!(
1657 vec![Shape::Names(12), Shape::Needs],
1658 shapes("- [ ] one #12\n- [ ] two\n", 5)
1659 );
1660 }
1661
1662 #[test]
1665 fn the_cap_stops_at_the_cap() {
1666 let body = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d\n";
1667 assert_eq!(
1668 vec![Shape::Needs, Shape::Needs, Shape::Over, Shape::Over],
1669 shapes(body, 2)
1670 );
1671 }
1672
1673 #[test]
1675 fn the_cap_counts_only_what_it_acts_on() {
1676 let body = "- [x] a\n- [x] b\n- [ ] c\n";
1677 assert_eq!(vec![Shape::Needs], shapes(body, 1));
1678 }
1679
1680 #[test]
1681 fn two_identical_items_are_left_alone() {
1682 let out = shapes("- [ ] same\n- [ ] same\n", 5);
1683 assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1684 assert!(matches!(out[1], Shape::Hold(_)), "{out:?}");
1685 }
1686
1687 #[test]
1688 fn an_item_naming_another_repository_is_held_rather_than_adopted() {
1689 let out = shapes("- [ ] see https://github.com/other/thing/issues/7\n", 5);
1690 assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1691 }
1692
1693 #[test]
1696 fn a_realistic_tracker_keeps_every_line_it_was_not_asked_to_change() {
1697 let body = "\
1698Context somebody wrote, with a hard break here:
1699and the rest of it.
1700
1701## Parts
1702
1703- [x] already done
1704- [ ] parse the checklist
1705- [ ] write the link back #40
1706 - [ ] and prove it first
1707
1708```markdown
1709- [ ] an example, not an item
1710```
1711
1712That is all.
1713";
1714 let shapes: Vec<Shape> = shape(body, HOME, 5).into_iter().map(|(_, s)| s).collect();
1715 assert_eq!(
1716 vec![Shape::Needs, Shape::Names(40), Shape::Needs],
1717 shapes,
1718 "the ticked item, the fenced one and the prose are all left out"
1719 );
1720
1721 let out = rewrite(
1722 body,
1723 "- [ ] parse the checklist",
1724 &Change::Reference("#41".into()),
1725 )
1726 .expect("a link");
1727 let out = rewrite(
1728 &out,
1729 " - [ ] and prove it first",
1730 &Change::Reference("#42".into()),
1731 )
1732 .expect("a nested link");
1733 let out = rewrite(&out, "- [ ] write the link back #40", &Change::Tick).expect("a tick");
1734
1735 assert_eq!(
1736 "\
1737Context somebody wrote, with a hard break here:
1738and the rest of it.
1739
1740## Parts
1741
1742- [x] already done
1743- [ ] parse the checklist #41
1744- [x] write the link back #40
1745 - [ ] and prove it first #42
1746
1747```markdown
1748- [ ] an example, not an item
1749```
1750
1751That is all.
1752",
1753 out
1754 );
1755 }
1756
1757 #[test]
1764 fn an_item_linked_by_similarity_is_not_ticked_in_the_same_run() {
1765 for open in [true, false] {
1766 let action = Action::Link {
1767 number: 7,
1768 title: "something close enough".into(),
1769 open,
1770 };
1771 assert_eq!(Some(Change::Reference("#7".into())), action.change());
1772 }
1773 }
1774
1775 #[test]
1778 fn an_item_that_already_named_its_issue_is_ticked_when_that_issue_closes() {
1779 assert_eq!(Some(Change::Tick), Action::Tick(7).change());
1780 }
1781
1782 #[test]
1784 fn nothing_is_written_for_an_item_that_is_already_linked_and_open() {
1785 assert_eq!(None, Action::Adopt(7).change());
1786 assert_eq!(None, Action::Over.change());
1787 assert_eq!(None, Action::Hold("any reason".into()).change());
1788 }
1789
1790 #[test]
1796 fn a_line_that_stopped_being_an_item_is_not_written_to() {
1797 let raw = "- [ ] ship it";
1798 assert!(still_an_item("intro\n\n- [ ] ship it\n", raw));
1799 assert!(!still_an_item("```\n- [ ] ship it\n```\n", raw));
1800 assert!(!still_an_item("<!--\n- [ ] ship it\n-->\n", raw));
1801 assert!(!still_an_item("- [ ] something else\n", raw));
1802 assert!(
1803 !still_an_item("- [ ] ship it\n- [ ] ship it\n", raw),
1804 "two alike is a line the edit could go to either of"
1805 );
1806 }
1807
1808 #[test]
1811 fn the_diff_shows_only_the_lines_that_change() {
1812 let before = "- [ ] one\n- [ ] two\n";
1813 let after =
1814 rewrite(before, "- [ ] two", &Change::Reference("#8".into())).expect("a rewrite");
1815 assert_eq!(
1816 vec!["- - [ ] two".to_string(), "+ - [ ] two #8".to_string()],
1817 diff(before, &after)
1818 );
1819 }
1820}