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_for_write(repo, tracker) else {
887 return Vec::new();
888 };
889 let steps = plan(repo, cfg, tracker, &body, &slug);
890 if steps.is_empty() {
891 logdim!("#{tracker} has no unchecked checklist items, so there is nothing to extract");
892 return Vec::new();
893 }
894 log!("#{tracker}: {} unchecked checklist item(s)", steps.len());
895 report_overflow(cfg, tracker, &steps);
896 apply(repo, tracker, &steps)
897}
898
899fn apply(repo: &Repo, tracker: i64, steps: &[Step]) -> Vec<i64> {
900 let mut children = Vec::new();
901 for step in steps {
902 let what = style::clip(&style::one_line(&step.item.text), 80);
903 match &step.action {
904 Action::Hold(why) => logdim!(" left '{what}' alone: {why}"),
905 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_nonempty_title_for_write(&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.record_failed_write(repo.read_issue(tracker)) {
1011 Ok(issue) => still_an_item(issue.body_text(), raw),
1012 Err(e) => {
1013 logdim!(" could not re-read #{tracker}: {}", e.first_line());
1014 false
1015 }
1016 }
1017}
1018
1019fn write(repo: &Repo, tracker: i64, raw: &str, change: &Change) -> bool {
1027 let body = match repo.record_failed_write(repo.read_issue(tracker)) {
1028 Ok(issue) => issue.body_text().to_string(),
1029 Err(e) => {
1030 logdim!(" could not re-read #{tracker}: {}", e.first_line());
1031 return false;
1032 }
1033 };
1034 if !still_an_item(&body, raw) {
1035 logdim!(" not editing #{tracker}: that line is no longer a checklist item in it");
1036 return false;
1037 }
1038 let updated = match rewrite(&body, raw, change) {
1039 Ok(updated) => updated,
1040 Err(e) => {
1041 logdim!(" not editing #{tracker}: {}", e.first_line());
1042 return false;
1043 }
1044 };
1045 match repo.edit_issue_body(tracker, &body, &updated, change.inserted()) {
1046 Ok(()) => true,
1047 Err(e) => {
1048 logdim!(" could not edit #{tracker}: {}", e.first_line());
1049 false
1050 }
1051 }
1052}
1053
1054fn report_overflow(cfg: &Config, tracker: i64, steps: &[Step]) {
1055 let left: Vec<String> = steps
1056 .iter()
1057 .filter(|s| s.action == Action::Over)
1058 .map(|s| format!("'{}'", style::clip(&style::one_line(&s.item.text), 60)))
1059 .collect();
1060 if left.is_empty() {
1061 return;
1062 }
1063 logwarn!(
1064 "#{tracker} has more unchecked items than max_tracker_children ({}), so {} were left for \
1065 a later run: {}",
1066 cfg.loop_cfg.max_tracker_children,
1067 left.len(),
1068 left.join(", ")
1069 );
1070}
1071
1072fn read(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1073 match repo.read_issue(tracker) {
1078 Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1079 Err(e) => {
1080 logdim!("could not read #{tracker}: {}", e.first_line());
1081 None
1082 }
1083 }
1084}
1085
1086fn read_for_write(repo: &Repo, tracker: i64) -> Option<(String, String)> {
1087 match repo.record_failed_write(repo.read_issue(tracker)) {
1088 Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
1089 Err(e) => {
1090 logdim!("could not read #{tracker}: {}", e.first_line());
1091 None
1092 }
1093 }
1094}
1095
1096fn home_of(url: &str) -> String {
1104 match url.rfind("/issues/") {
1105 Some(at) => url[..at].to_string(),
1106 None => String::new(),
1107 }
1108}
1109
1110pub fn preview(cfg: &Config, repo: &Repo, tracker: i64) {
1121 let Some((body, slug)) = read(repo, tracker) else {
1122 return;
1123 };
1124 let steps = plan(repo, cfg, tracker, &body, &slug);
1125 if steps.is_empty() {
1126 return;
1127 }
1128 println!("\n#{tracker}, if decompose_trackers let it act on the checklist:");
1129
1130 let mut projected = body.clone();
1131 for step in &steps {
1132 let what = style::clip(&style::one_line(&step.item.text), 80);
1133 match &step.action {
1134 Action::Adopt(number) => println!(" keep '{what}' is already #{number}"),
1135 Action::Tick(number) => println!(" tick '{what}', #{number} is finished"),
1136 Action::Link {
1137 number,
1138 title,
1139 open,
1140 } => {
1141 let state = if *open { "open" } else { "closed" };
1142 println!(" link '{what}' to #{number} '{title}' ({state}), filing nothing");
1143 }
1144 Action::File => println!(" file '{what}'"),
1145 Action::Over => println!(" over '{what}' is past max_tracker_children"),
1146 Action::Hold(why) => println!(" hold '{what}': {why}"),
1147 }
1148 let change = match &step.action {
1152 Action::File => Some(Change::Reference(FILED.to_string())),
1153 other => other.change(),
1154 };
1155 let Some(change) = change else { continue };
1156 match rewrite(&projected, &step.item.raw, &change) {
1157 Ok(next) => projected = next,
1158 Err(e) => println!(" the line will not be rewritten: {e}"),
1159 }
1160 }
1161
1162 let diff = diff(&body, &projected);
1163 if diff.is_empty() {
1164 println!(" nothing would be written to the body");
1165 } else {
1166 println!(" and the body it would write:");
1167 for line in diff {
1168 println!(" {line}");
1169 }
1170 }
1171}
1172
1173const FILED: &str = "#(the issue it files)";
1176
1177fn diff(before: &str, after: &str) -> Vec<String> {
1180 split_keep(before)
1181 .into_iter()
1182 .zip(split_keep(after))
1183 .filter(|(old, new)| old != new)
1184 .flat_map(|(old, new)| {
1185 [
1186 format!("- {}", without_eol(old)),
1187 format!("+ {}", without_eol(new)),
1188 ]
1189 })
1190 .collect()
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196
1197 const HOME: &str = "https://github.com/me/mine";
1199
1200 fn texts(body: &str) -> Vec<String> {
1201 parse(body).into_iter().map(|i| i.text).collect()
1202 }
1203
1204 #[test]
1205 fn the_ordinary_checklist_is_read_as_items() {
1206 let items = parse("Some prose.\n\n- [ ] first\n- [x] second\n");
1207 assert_eq!(2, items.len());
1208 assert_eq!("first", items[0].text);
1209 assert!(!items[0].checked);
1210 assert!(items[1].checked);
1211 assert_eq!(3, items[0].line);
1212 }
1213
1214 #[test]
1217 fn indented_and_nested_items_are_items() {
1218 let body = "- [ ] parent\n - [ ] child\n\t- [ ] tabbed\n * [ ] deeper\n1. [ ] ordered\n2) [ ] also ordered\n";
1219 assert_eq!(
1220 vec![
1221 "parent",
1222 "child",
1223 "tabbed",
1224 "deeper",
1225 "ordered",
1226 "also ordered"
1227 ],
1228 texts(body)
1229 );
1230 }
1231
1232 #[test]
1234 fn something_that_looks_like_an_item_inside_a_fence_is_not_one() {
1235 let body = "\
1236- [ ] real
1237
1238```markdown
1239- [ ] not real
1240```
1241
1242~~~
1243- [ ] also not real
1244~~~
1245
1246- [ ] real again
1247";
1248 assert_eq!(vec!["real", "real again"], texts(body));
1249 }
1250
1251 #[test]
1255 fn an_indented_code_block_is_not_a_checklist() {
1256 let body = "\
1257Write the parts like this:
1258
1259 - [ ] an example, not an item
1260
1261- [ ] real
1262 - [ ] nested
1263- plain bullet
1264 - [ ] nested under a bullet
1265";
1266 assert_eq!(vec!["real", "nested", "nested under a bullet"], texts(body));
1267 }
1268
1269 #[test]
1273 fn code_indented_inside_a_list_item_is_still_code() {
1274 let body = "\
1275- outer
1276
1277 - [ ] an example, not an item
1278
1279 - [ ] nested
1280- plain
1281 - [ ] nested under a bullet
1282 - [ ] and under that one
1283";
1284 assert_eq!(
1285 vec!["nested", "nested under a bullet", "and under that one"],
1286 texts(body)
1287 );
1288 }
1289
1290 #[test]
1293 fn an_item_inside_raw_html_is_not_one() {
1294 let body = "\
1295- [ ] real
1296
1297<pre>
1298- [ ] not real
1299</pre>
1300
1301<textarea>
1302- [ ] also not real
1303</textarea>
1304
1305- [ ] real again
1306";
1307 assert_eq!(vec!["real", "real again"], texts(body));
1308 }
1309
1310 #[test]
1315 fn an_item_inside_a_block_tag_is_not_one() {
1316 let body = "\
1317<div>
1318- [ ] not real
1319</div>
1320
1321<details>
1322<summary>the parts</summary>
1323
1324- [ ] real
1325</details>
1326";
1327 assert_eq!(vec!["real"], texts(body));
1328 }
1329
1330 #[test]
1334 fn a_checkbox_pushed_past_its_own_content_column_is_code() {
1335 assert_eq!(Vec::<String>::new(), texts("- [ ] an example\n"));
1336 assert_eq!(vec!["real"], texts("- [ ] real\n"));
1337 }
1338
1339 #[test]
1342 fn a_fence_indented_into_code_neither_opens_nor_closes() {
1343 let body = "```\n- [ ] not real\n ```\n- [ ] still not real\n";
1344 assert_eq!(Vec::<String>::new(), texts(body));
1345
1346 let body = "Like this:\n\n ```\n- [ ] real\n";
1347 assert_eq!(vec!["real"], texts(body));
1348 }
1349
1350 #[test]
1353 fn an_item_inside_an_html_comment_is_not_one() {
1354 let body = "\
1355- [ ] real
1356
1357<!--
1358- [ ] not real
1359-->
1360
1361- [ ] real again
1362<!-- - [ ] on one line, closed -->
1363- [ ] last
1364";
1365 assert_eq!(vec!["real", "real again", "last"], texts(body));
1366 }
1367
1368 #[test]
1371 fn a_fence_is_closed_only_by_its_own_kind() {
1372 let body = "~~~\n```\n- [ ] not real\n```\n~~~\n- [ ] real\n";
1373 assert_eq!(vec!["real"], texts(body));
1374 }
1375
1376 #[test]
1377 fn a_windows_body_is_read_the_same_way() {
1378 let items = parse("intro\r\n\r\n- [ ] first\r\n- [x] second\r\n");
1379 assert_eq!(2, items.len());
1380 assert_eq!("first", items[0].text);
1381 assert!(items[1].checked);
1382 assert_eq!(
1383 "- [ ] first", items[0].raw,
1384 "the terminator is not part of the handle"
1385 );
1386 }
1387
1388 #[test]
1389 fn a_reference_is_read_from_a_number_or_a_link() {
1390 let items = parse(
1391 "- [ ] one #12\n\
1392 - [ ] two https://github.com/o/r/issues/34\n\
1393 - [ ] [three](https://github.com/o/r/issues/56)\n\
1394 - [ ] four\n",
1395 );
1396 assert_eq!(Some(12), items[0].reference.as_ref().map(|r| r.number));
1397 assert_eq!(Some(34), items[1].reference.as_ref().map(|r| r.number));
1398 assert_eq!(Some(56), items[2].reference.as_ref().map(|r| r.number));
1399 assert_eq!(None, items[3].reference);
1400 }
1401
1402 #[test]
1406 fn a_link_to_a_pull_request_is_a_reference_too() {
1407 let items = parse(
1408 "- [ ] one https://github.com/me/mine/pull/42\n\
1409 - [ ] two https://github.com/me/mine/pull/43/files\n\
1410 - [ ] three https://github.com/other/thing/pull/44\n",
1411 );
1412 assert_eq!(Some(42), items[0].reference.as_ref().unwrap().local(HOME));
1413 assert_eq!(Some(43), items[1].reference.as_ref().unwrap().local(HOME));
1414 assert_eq!(None, items[2].reference.as_ref().unwrap().local(HOME));
1415 }
1416
1417 #[test]
1420 fn an_item_that_is_a_link_to_something_else_names_no_issue() {
1421 let items = parse("- [ ] [the docs](https://example.com/guide)\n");
1422 assert_eq!(None, items[0].reference);
1423 assert_eq!("[the docs](https://example.com/guide)", items[0].text);
1424 }
1425
1426 #[test]
1429 fn a_link_to_another_repository_is_not_adoptable() {
1430 let items = parse("- [ ] see https://github.com/other/thing/issues/7\n");
1431 let reference = items[0].reference.as_ref().expect("a reference");
1432 assert_eq!(None, reference.local(HOME));
1433 assert_eq!(Some(7), reference.local("https://github.com/other/thing"));
1434 }
1435
1436 #[test]
1438 fn a_bare_number_resolves_wherever_it_is_read() {
1439 let items = parse("- [ ] work #7\n");
1440 assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(""));
1441 }
1442
1443 #[test]
1446 fn a_link_to_the_same_path_on_another_host_is_not_this_repository() {
1447 for url in [
1448 "https://gitlab.example/me/mine/issues/7",
1449 "https://github.com/mirror/me/mine/issues/7",
1450 ] {
1451 let items = parse(&format!("- [ ] see {url}\n"));
1452 let reference = items[0].reference.as_ref().expect("a reference");
1453 assert_eq!(None, reference.local(HOME), "{url}");
1454 }
1455 }
1456
1457 #[test]
1459 fn the_scheme_is_not_what_makes_a_link_somebody_elses() {
1460 let items = parse("- [ ] see http://github.com/me/mine/issues/7\n");
1461 assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(HOME));
1462 }
1463
1464 #[test]
1467 fn home_is_read_off_the_trackers_own_url() {
1468 assert_eq!(HOME, home_of("https://github.com/me/mine/issues/29"));
1469 assert_eq!("", home_of(""));
1470 }
1471
1472 #[test]
1475 fn a_number_in_a_code_span_names_nothing() {
1476 let items = parse(
1477 "- [ ] Handle the literal `#12`, tracked in #34\n\
1478 - [ ] Only ``a #12 in a double span``\n",
1479 );
1480 assert_eq!(Some(34), items[0].reference.as_ref().map(|r| r.number));
1481 assert_eq!(None, items[1].reference);
1482 }
1483
1484 #[test]
1488 fn a_number_in_a_comment_names_nothing() {
1489 let items = parse(
1490 "- [ ] ship it <!-- old note: #7 -->\n\
1491 - [ ] and this one <!-- #7 --> #8\n",
1492 );
1493 assert_eq!(None, items[0].reference);
1494 assert_eq!(Some(8), items[1].reference.as_ref().map(|r| r.number));
1495 }
1496
1497 #[test]
1500 fn a_fragment_in_a_link_is_not_an_issue_number() {
1501 let items = parse(
1502 "- [ ] update [docs](https://example.com/guide/#8)\n\
1503 - [ ] see https://example.com/guide#9 and #10\n",
1504 );
1505 assert_eq!(None, items[0].reference);
1506 assert_eq!(Some(10), items[1].reference.as_ref().map(|r| r.number));
1507 }
1508
1509 #[test]
1513 fn the_shorthands_github_links_are_references_too() {
1514 let items = parse(
1515 "- [ ] one me/mine#12\n\
1516 - [ ] two other/thing#13\n\
1517 - [ ] three GH-14\n",
1518 );
1519 assert_eq!(Some(12), items[0].reference.as_ref().unwrap().local(HOME));
1520 let foreign = items[1].reference.as_ref().expect("a reference");
1521 assert_eq!(None, foreign.local(HOME), "somebody else's repository");
1522 assert_eq!("other/thing#13", foreign.names());
1523 assert_eq!(Some(14), items[2].reference.as_ref().unwrap().local(HOME));
1524 }
1525
1526 #[test]
1529 fn a_shorthand_is_read_against_this_repositorys_path() {
1530 let items = parse("- [ ] work me/mine#7\n");
1531 let reference = items[0].reference.as_ref().expect("a reference");
1532 assert_eq!(Some(7), reference.local("https://ghe.example/me/mine"));
1533 assert_eq!(None, reference.local("https://github.com/me/other"));
1534 assert_eq!(None, reference.local(""), "no address to measure against");
1535 }
1536
1537 #[test]
1541 fn a_link_is_read_from_its_destination_and_not_its_label() {
1542 let items = parse(
1543 "- [ ] [other/widgets #7](https://github.com/other/widgets/issues/7)\n\
1544 - [ ] [me/mine #7](https://github.com/me/mine/issues/7)\n",
1545 );
1546 let foreign = items[0].reference.as_ref().expect("a reference");
1547 assert!(
1548 matches!(foreign.origin, Origin::Url(_)),
1549 "the destination, not the label"
1550 );
1551 assert_eq!(None, foreign.local(HOME));
1552 assert_eq!(Some(7), items[1].reference.as_ref().unwrap().local(HOME));
1553 }
1554
1555 #[test]
1559 fn complex_link_labels_still_read_the_destination() {
1560 let items = parse(
1561 "- [ ] [see [#7]](https://github.com/other/widgets/issues/8)\n\
1562 - [ ] [see \\] #7](https://github.com/other/widgets/issues/8)\n",
1563 );
1564 for item in items {
1565 let reference = item.reference.expect("the destination");
1566 assert_eq!(8, reference.number);
1567 assert!(matches!(reference.origin, Origin::Url(_)));
1568 assert_eq!(None, reference.local(HOME));
1569 }
1570 }
1571
1572 #[test]
1573 fn one_child_referenced_by_several_items_is_worked_once() {
1574 assert_eq!(vec![8, 9], unique_children(vec![8, 8, 9, 8]));
1575 }
1576
1577 #[test]
1580 fn a_reference_is_appended_to_its_own_line_and_nowhere_else() {
1581 let body = "intro\n\n- [ ] first\n- [ ] second\n\nmore prose\n";
1582 let out =
1583 rewrite(body, "- [ ] first", &Change::Reference("#40".into())).expect("a rewrite");
1584 assert_eq!(
1585 "intro\n\n- [ ] first #40\n- [ ] second\n\nmore prose\n",
1586 out
1587 );
1588 }
1589
1590 #[test]
1593 fn a_hard_break_survives_the_edit() {
1594 let out = rewrite(
1595 "- [ ] first \nnext\n",
1596 "- [ ] first ",
1597 &Change::Reference("#4".into()),
1598 )
1599 .expect("a rewrite");
1600 assert_eq!("- [ ] first #4 \nnext\n", out);
1601 }
1602
1603 #[test]
1604 fn every_other_line_comes_through_byte_identical() {
1605 let body = "# Plan\r\n\r\n trailing spaces here \r\n- [ ] one\r\n\r\n\r\n\r\nlots of blank lines above\r\n";
1606 let out = rewrite(body, "- [ ] one", &Change::Reference("#9".into())).expect("a rewrite");
1607 let (before, after): (Vec<&str>, Vec<&str>) =
1608 (body.lines().collect(), out.lines().collect());
1609 assert_eq!(before.len(), after.len());
1610 for (i, (a, b)) in before.iter().zip(&after).enumerate() {
1611 if i == 3 {
1612 assert_eq!("- [ ] one #9", *b);
1613 } else {
1614 assert_eq!(a, b, "line {} changed", i + 1);
1615 }
1616 }
1617 assert!(out.contains("trailing spaces here \r\n"));
1618 assert!(out.contains("\r\n\r\n\r\n\r\n"));
1619 }
1620
1621 #[test]
1622 fn a_body_with_no_final_newline_keeps_not_having_one() {
1623 let out = rewrite("- [ ] only", "- [ ] only", &Change::Tick).expect("a rewrite");
1624 assert_eq!("- [x] only", out);
1625 }
1626
1627 #[test]
1628 fn ticking_changes_the_box_and_leaves_the_text() {
1629 let out = rewrite(" - [ ] deep #3\n", " - [ ] deep #3", &Change::Tick).expect("a tick");
1630 assert_eq!(" - [x] deep #3\n", out);
1631 }
1632
1633 #[test]
1636 fn a_ticked_box_is_never_written_again() {
1637 assert!(rewrite("- [x] done\n", "- [x] done", &Change::Tick).is_err());
1638 assert!(!matches!(Change::Tick, Change::Reference(_)));
1639 }
1640
1641 #[test]
1642 fn a_line_that_is_gone_or_ambiguous_is_a_refusal_not_a_guess() {
1643 assert!(rewrite("- [ ] a\n", "- [ ] b", &Change::Tick).is_err());
1644 let twice = "- [ ] same\n- [ ] same\n";
1645 assert!(rewrite(twice, "- [ ] same", &Change::Tick).is_err());
1646 }
1647
1648 #[test]
1649 fn an_item_with_no_text_gets_no_reference() {
1650 assert!(rewrite("- [ ]\n", "- [ ]", &Change::Reference("#1".into())).is_err());
1651 }
1652
1653 fn shapes(body: &str, max: usize) -> Vec<Shape> {
1656 shape(body, HOME, max).into_iter().map(|(_, s)| s).collect()
1657 }
1658
1659 #[test]
1660 fn a_checked_item_is_never_reconsidered() {
1661 assert!(shapes("- [x] done\n", 5).is_empty());
1662 }
1663
1664 #[test]
1665 fn an_item_that_names_an_issue_is_kept_apart_from_one_that_does_not() {
1666 assert_eq!(
1667 vec![Shape::Names(12), Shape::Needs],
1668 shapes("- [ ] one #12\n- [ ] two\n", 5)
1669 );
1670 }
1671
1672 #[test]
1675 fn the_cap_stops_at_the_cap() {
1676 let body = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d\n";
1677 assert_eq!(
1678 vec![Shape::Needs, Shape::Needs, Shape::Over, Shape::Over],
1679 shapes(body, 2)
1680 );
1681 }
1682
1683 #[test]
1685 fn the_cap_counts_only_what_it_acts_on() {
1686 let body = "- [x] a\n- [x] b\n- [ ] c\n";
1687 assert_eq!(vec![Shape::Needs], shapes(body, 1));
1688 }
1689
1690 #[test]
1691 fn two_identical_items_are_left_alone() {
1692 let out = shapes("- [ ] same\n- [ ] same\n", 5);
1693 assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1694 assert!(matches!(out[1], Shape::Hold(_)), "{out:?}");
1695 }
1696
1697 #[test]
1698 fn an_item_naming_another_repository_is_held_rather_than_adopted() {
1699 let out = shapes("- [ ] see https://github.com/other/thing/issues/7\n", 5);
1700 assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
1701 }
1702
1703 #[test]
1706 fn a_realistic_tracker_keeps_every_line_it_was_not_asked_to_change() {
1707 let body = "\
1708Context somebody wrote, with a hard break here:
1709and the rest of it.
1710
1711## Parts
1712
1713- [x] already done
1714- [ ] parse the checklist
1715- [ ] write the link back #40
1716 - [ ] and prove it first
1717
1718```markdown
1719- [ ] an example, not an item
1720```
1721
1722That is all.
1723";
1724 let shapes: Vec<Shape> = shape(body, HOME, 5).into_iter().map(|(_, s)| s).collect();
1725 assert_eq!(
1726 vec![Shape::Needs, Shape::Names(40), Shape::Needs],
1727 shapes,
1728 "the ticked item, the fenced one and the prose are all left out"
1729 );
1730
1731 let out = rewrite(
1732 body,
1733 "- [ ] parse the checklist",
1734 &Change::Reference("#41".into()),
1735 )
1736 .expect("a link");
1737 let out = rewrite(
1738 &out,
1739 " - [ ] and prove it first",
1740 &Change::Reference("#42".into()),
1741 )
1742 .expect("a nested link");
1743 let out = rewrite(&out, "- [ ] write the link back #40", &Change::Tick).expect("a tick");
1744
1745 assert_eq!(
1746 "\
1747Context somebody wrote, with a hard break here:
1748and the rest of it.
1749
1750## Parts
1751
1752- [x] already done
1753- [ ] parse the checklist #41
1754- [x] write the link back #40
1755 - [ ] and prove it first #42
1756
1757```markdown
1758- [ ] an example, not an item
1759```
1760
1761That is all.
1762",
1763 out
1764 );
1765 }
1766
1767 #[test]
1774 fn an_item_linked_by_similarity_is_not_ticked_in_the_same_run() {
1775 for open in [true, false] {
1776 let action = Action::Link {
1777 number: 7,
1778 title: "something close enough".into(),
1779 open,
1780 };
1781 assert_eq!(Some(Change::Reference("#7".into())), action.change());
1782 }
1783 }
1784
1785 #[test]
1788 fn an_item_that_already_named_its_issue_is_ticked_when_that_issue_closes() {
1789 assert_eq!(Some(Change::Tick), Action::Tick(7).change());
1790 }
1791
1792 #[test]
1794 fn nothing_is_written_for_an_item_that_is_already_linked_and_open() {
1795 assert_eq!(None, Action::Adopt(7).change());
1796 assert_eq!(None, Action::Over.change());
1797 assert_eq!(None, Action::Hold("any reason".into()).change());
1798 }
1799
1800 #[test]
1806 fn a_line_that_stopped_being_an_item_is_not_written_to() {
1807 let raw = "- [ ] ship it";
1808 assert!(still_an_item("intro\n\n- [ ] ship it\n", raw));
1809 assert!(!still_an_item("```\n- [ ] ship it\n```\n", raw));
1810 assert!(!still_an_item("<!--\n- [ ] ship it\n-->\n", raw));
1811 assert!(!still_an_item("- [ ] something else\n", raw));
1812 assert!(
1813 !still_an_item("- [ ] ship it\n- [ ] ship it\n", raw),
1814 "two alike is a line the edit could go to either of"
1815 );
1816 }
1817
1818 #[test]
1821 fn the_diff_shows_only_the_lines_that_change() {
1822 let before = "- [ ] one\n- [ ] two\n";
1823 let after =
1824 rewrite(before, "- [ ] two", &Change::Reference("#8".into())).expect("a rewrite");
1825 assert_eq!(
1826 vec!["- - [ ] two".to_string(), "+ - [ ] two #8".to_string()],
1827 diff(before, &after)
1828 );
1829 }
1830}