use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use super::{Events, Marker, OwnedEvent, OwnedTag, OwnedTagEnd};
pub fn split_blockers(text: &str) -> (String, BlockerSequence) {
let lines: Vec<&str> = text.lines().collect();
let marker_idx = lines.iter().position(|line| matches!(Marker::decode(line), Some(Marker::BlockersSection(_))));
match marker_idx {
Some(idx) => {
let before: String = lines[..idx].join("\n");
(before, BlockerSequence::parse(&lines[idx + 1..].join("\n")))
}
None => (text.to_string(), BlockerSequence::default()),
}
}
pub fn join_with_blockers(body_events: &[OwnedEvent], blockers: &BlockerSequence) -> Events {
let mut events = body_events.to_vec();
if blockers.is_empty() {
return events.into();
}
events.push(OwnedEvent::Start(OwnedTag::Heading {
level: pulldown_cmark::HeadingLevel::H1,
id: None,
classes: Vec::new(),
attrs: Vec::new(),
}));
events.push(OwnedEvent::Text("Blockers".to_string()));
events.push(OwnedEvent::End(OwnedTagEnd::Heading(pulldown_cmark::HeadingLevel::H1)));
events.extend(blockers.to_events());
events.into()
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BlockerItem {
pub text: String,
pub comments: Vec<String>,
pub children: Vec<BlockerItem>,
}
impl BlockerItem {
pub fn issue_ref(&self) -> Option<super::issue_ref::IssueRef> {
let trimmed = self.text.trim();
if trimmed.is_empty() || trimmed.contains(' ') {
return None;
}
super::issue_ref::IssueRef::parse_word(trimmed)
}
}
#[derive(Clone, Copy, Debug)]
pub enum BlockerSetState {
Pending,
Applied,
}
#[derive(Clone, Debug, Default)]
pub struct BlockerSequence {
pub items: Vec<BlockerItem>,
pub set_state: Option<BlockerSetState>,
}
impl BlockerSequence {
pub fn parse(content: &str) -> Self {
let lines: Vec<&str> = content.lines().collect();
Self::build_from_lines(&lines)
}
fn build_from_lines(lines: &[&str]) -> Self {
let mut root_items: Vec<BlockerItem> = Vec::new();
Self::parse_items_at_indent(lines, &mut 0, 0, &mut root_items);
Self {
items: root_items,
..Default::default()
}
}
fn parse_items_at_indent(lines: &[&str], pos: &mut usize, indent: usize, items: &mut Vec<BlockerItem>) {
let indent_str = " ".repeat(indent);
while *pos < lines.len() {
let line = lines[*pos];
if line.trim().is_empty() {
*pos += 1;
continue;
}
let Some(content) = line.strip_prefix(&indent_str) else {
break;
};
if content.starts_with(" ") {
break;
}
if let Some(item_text) = content.strip_prefix("- ") {
*pos += 1;
let mut item = BlockerItem {
text: item_text.to_string(),
comments: Vec::new(),
children: Vec::new(),
};
let child_indent = indent + 1;
let child_indent_str = " ".repeat(child_indent);
while *pos < lines.len() {
let next_line = lines[*pos];
if next_line.trim().is_empty() {
*pos += 1;
continue;
}
let Some(child_content) = next_line.strip_prefix(&child_indent_str) else {
break;
};
if child_content.starts_with(" ") {
break;
}
if child_content.starts_with("- ") {
Self::parse_items_at_indent(lines, pos, child_indent, &mut item.children);
} else {
if !item.children.is_empty() {
eprintln!("Warning: comment after nested blockers at indent level {child_indent}: {child_content:?}");
}
item.comments.push(child_content.to_string());
*pos += 1;
}
}
items.push(item);
} else {
eprintln!("Warning: orphan comment (no preceding blocker item): {content:?}");
*pos += 1;
}
}
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn deepest_issue_ref(&self) -> Option<super::issue_ref::IssueRef> {
fn walk(items: &[BlockerItem]) -> Option<super::issue_ref::IssueRef> {
let last = items.last()?;
let mine = last.issue_ref();
if last.children.is_empty() {
return mine;
}
walk(&last.children).or(mine)
}
walk(&self.items)
}
pub fn ensure_set(&mut self, issue_path: &Path) {
if matches!(self.set_state, Some(BlockerSetState::Pending)) {
if let Err(e) = MilestoneBlockerCache::set_by_path(issue_path) {
tracing::warn!("failed to persist blocker selection: {e}");
return;
}
tracing::info!(path = %issue_path.display(), "blocker selection persisted via !s");
self.set_state = Some(BlockerSetState::Applied);
}
}
pub fn to_events(&self) -> Events {
if self.items.is_empty() {
return Events::default();
}
let mut events = Vec::new();
events.push(OwnedEvent::Start(OwnedTag::List(None)));
for item in &self.items {
item_to_events(item, &mut events);
}
events.push(OwnedEvent::End(OwnedTagEnd::List(false)));
events.into()
}
}
impl From<&BlockerSequence> for String {
fn from(seq: &BlockerSequence) -> Self {
let mut out = String::new();
for item in &seq.items {
render_item_text(&mut out, item, 0);
}
if out.ends_with('\n') {
out.pop();
}
out
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MilestoneBlockerCache {
pub current_index: usize,
pub milestone_description: String,
#[serde(default)]
pub ref_targets: HashMap<String, String>,
}
impl MilestoneBlockerCache {
fn cache_path() -> PathBuf {
v_utils::xdg_cache_file!("milestone_blockers.json")
}
pub fn load() -> Option<Self> {
let cache_path = Self::cache_path();
let content = std::fs::read_to_string(&cache_path).ok()?;
let mut cache: Self = serde_json::from_str(&content).ok()?;
let count = cache.embedded_links().len();
if count > 0 {
cache.current_index = cache.current_index.min(count - 1);
} else {
cache.current_index = 0;
}
Some(cache)
}
pub fn save(&self) -> Result<(), std::io::Error> {
let cache_path = Self::cache_path();
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self).expect("MilestoneBlockerCache serialization");
std::fs::write(&cache_path, content)
}
pub fn embedded_links(&self) -> Vec<super::IssueLink> {
let mut doc = super::MilestoneDoc::parse(&self.milestone_description);
doc.resolve_bare_refs();
doc.issue_links()
}
pub fn current_link(&self) -> Option<super::IssueLink> {
let links = self.embedded_links();
links.into_iter().nth(self.current_index)
}
pub fn resolve_link_to_path(link: &super::IssueLink) -> Option<PathBuf> {
use crate::local::{FsReader, Local};
Local::find_by_number(link.repo_info(), link.number(), FsReader)
}
pub fn current_path(&self) -> Option<PathBuf> {
let link = self.current_link()?;
Self::resolve_link_to_path(&link)
}
pub fn set_by_path(issue_path: &Path) -> Result<(), std::io::Error> {
let Some(mut cache) = Self::load() else {
tracing::warn!("no milestone blocker cache exists; !s has no effect");
return Ok(());
};
let links = cache.embedded_links();
let found = links
.iter()
.enumerate()
.find(|(_, link)| Self::resolve_link_to_path(link).map(|p| p == issue_path).unwrap_or(false));
match found {
Some((idx, _)) => {
cache.current_index = idx;
cache.save()
}
None => {
tracing::warn!(
path = %issue_path.display(),
"issue not found in milestone cache; !s ignored"
);
Ok(())
}
}
}
pub fn move_by(delta: isize) -> Result<super::IssueLink, String> {
let mut cache = Self::load().ok_or("No milestone blocker cache. Run `todo milestones edit` first.")?;
let links = cache.embedded_links();
if links.is_empty() {
return Err("No issues in milestone. Add issues to the milestone first.".into());
}
if links.len() == 1 {
return Err("Only one issue in milestone. Nothing to move to.".into());
}
let len = links.len() as isize;
let start = cache.current_index;
for _ in 0..links.len() {
cache.current_index = ((cache.current_index as isize + delta).rem_euclid(len)) as usize;
let candidate = &links[cache.current_index];
if !cache.ref_targets.contains_key(candidate.as_str()) {
break;
}
}
let link = links[cache.current_index].clone();
if cache.ref_targets.contains_key(link.as_str()) && cache.current_index == start {
return Err("All issues in milestone have blocker refs. Nothing to stop at.".into());
}
cache.save().map_err(|e| format!("Failed to save milestone cache: {e}"))?;
Ok(link)
}
pub fn set_by_pattern(pattern: Option<&str>) -> Result<super::IssueLink, String> {
let mut cache = Self::load().ok_or("No milestone blocker cache. Run `todo milestones edit` first.")?;
let links = cache.embedded_links();
if links.is_empty() {
return Err("No issues in milestone.".into());
}
let all_indexed: Vec<(usize, super::IssueLink)> = links.into_iter().enumerate().collect();
let (matches, fzf_query): (Vec<(usize, super::IssueLink)>, &str) = match pattern {
None => (all_indexed, ""),
Some(p) => {
let pattern_lower = p.to_lowercase();
let filtered: Vec<_> = all_indexed
.into_iter()
.filter(|(_, link)| Self::display_for_link(link).to_lowercase().contains(&pattern_lower))
.collect();
(filtered, p)
}
};
let (idx, link) = match matches.len() {
0 => return Err(format!("No issue matching '{}' in milestone.", pattern.unwrap_or(""))),
1 if pattern.is_some() => matches.into_iter().next().unwrap(),
_ => {
let displays: Vec<String> = matches.iter().map(|(_, link)| Self::display_for_link(link)).collect();
let selected = crate::local::Local::fzf_select(&displays, fzf_query).map_err(|e| format!("fzf failed: {e}"))?;
matches
.into_iter()
.find(|(_, link)| Self::display_for_link(link) == selected)
.ok_or_else(|| format!("fzf returned unknown entry: {selected}"))?
}
};
cache.current_index = idx;
cache.save().map_err(|e| format!("Failed to save milestone cache: {e}"))?;
Ok(link)
}
fn display_for_link(link: &super::IssueLink) -> String {
Self::resolve_link_to_path(link)
.and_then(|p| p.strip_prefix(crate::local::Local::issues_dir()).ok().map(|rel| rel.to_string_lossy().to_string()))
.unwrap_or_else(|| format!("{}/{}#{}", link.owner(), link.repo(), link.number()))
}
pub fn update_from_description(description: &str) -> Result<(), std::io::Error> {
let old = Self::load();
let old_link = old.as_ref().and_then(|c| c.current_link());
let old_index = old.as_ref().map(|c| c.current_index).unwrap_or(0);
let mut cache = MilestoneBlockerCache {
current_index: 0,
milestone_description: description.to_string(),
ref_targets: HashMap::new(),
};
let new_links = cache.embedded_links();
if let Some(old_link) = old_link {
if let Some(pos) = new_links
.iter()
.position(|l| l.number() == old_link.number() && l.owner() == old_link.owner() && l.repo() == old_link.repo())
{
cache.current_index = pos;
}
} else if !new_links.is_empty() {
cache.current_index = old_index.min(new_links.len() - 1);
}
cache.save()
}
pub fn refresh_ref_targets(&mut self) {
self.ref_targets.clear();
let links = self.embedded_links();
for link in &links {
let Some(path) = Self::resolve_link_to_path(link) else { continue };
let Ok(content) = std::fs::read_to_string(&path) else { continue };
let (_, blockers) = split_blockers(&content);
let mut issue_ref = match blockers.deepest_issue_ref() {
Some(r) => r,
None => continue,
};
let ctx = format!("{}/{}", link.owner(), link.repo());
issue_ref.resolve_with_context(&ctx);
if let Some(target_link) = issue_ref.to_issue_link() {
self.ref_targets.insert(link.as_str().to_string(), target_link.as_str().to_string());
}
}
}
pub fn cleanup_dead_refs(&mut self, empty_issue_url: &str) -> Vec<String> {
let mut cleaned = Vec::new();
self.cleanup_dead_refs_inner(empty_issue_url, &mut cleaned);
cleaned
}
fn cleanup_dead_refs_inner(&mut self, empty_issue_url: &str, cleaned: &mut Vec<String>) {
let pointing_at_empty: Vec<String> = self
.ref_targets
.iter()
.filter(|(_, target)| target.as_str() == empty_issue_url)
.map(|(source, _)| source.clone())
.collect();
for source_url in pointing_at_empty {
self.ref_targets.remove(&source_url);
let Some(source_link) = super::IssueLink::parse(&source_url) else { continue };
let Some(path) = Self::resolve_link_to_path(&source_link) else { continue };
let Ok(content) = std::fs::read_to_string(&path) else { continue };
let lines: Vec<&str> = content.lines().collect();
let marker_idx = lines.iter().position(|line| matches!(Marker::decode(line), Some(Marker::BlockersSection(_))));
let Some(marker_idx) = marker_idx else { continue };
let before = &lines[..=marker_idx]; let after_marker = lines[marker_idx + 1..].join("\n");
let mut blockers = BlockerSequence::parse(&after_marker);
let popped = pop_deepest(&mut blockers.items);
if popped.is_none() {
continue;
}
let mut new_content = before.join("\n");
let blockers_str: String = String::from(&blockers);
if !blockers_str.is_empty() {
new_content.push('\n');
new_content.push_str(&blockers_str);
}
new_content.push('\n');
if std::fs::write(&path, &new_content).is_err() {
continue;
}
cleaned.push(format!("{}/{}#{}", source_link.owner(), source_link.repo(), source_link.number()));
if blockers.is_empty() {
self.cleanup_dead_refs_inner(&source_url, cleaned);
}
}
}
}
fn render_item_text(out: &mut String, item: &BlockerItem, indent: usize) {
let prefix = " ".repeat(indent);
out.push_str(&prefix);
out.push_str("- ");
out.push_str(&item.text);
out.push('\n');
for comment in &item.comments {
out.push_str(&prefix);
out.push_str(" ");
out.push_str(comment);
out.push('\n');
}
for child in &item.children {
render_item_text(out, child, indent + 1);
}
}
fn pop_deepest(items: &mut Vec<BlockerItem>) -> Option<BlockerItem> {
let last = items.last_mut()?;
if let Some(popped) = pop_deepest(&mut last.children) {
return Some(popped);
}
items.pop()
}
impl PartialEq for BlockerSequence {
fn eq(&self, other: &Self) -> bool {
self.items == other.items
}
}
fn inline_text_to_events(text: &str, events: &mut Vec<OwnedEvent>) {
let parsed = super::Events::parse(text);
let slice: &[OwnedEvent] = &parsed;
let inner = match slice {
[OwnedEvent::Start(OwnedTag::Paragraph), inner @ .., OwnedEvent::End(OwnedTagEnd::Paragraph)] => inner,
other => other,
};
events.extend(inner.iter().cloned());
}
fn item_to_events(item: &BlockerItem, events: &mut Vec<OwnedEvent>) {
events.push(OwnedEvent::Start(OwnedTag::Item));
inline_text_to_events(&item.text, events);
for comment in &item.comments {
events.push(OwnedEvent::SoftBreak);
inline_text_to_events(comment, events);
}
if !item.children.is_empty() {
events.push(OwnedEvent::Start(OwnedTag::List(None)));
for child in &item.children {
item_to_events(child, events);
}
events.push(OwnedEvent::End(OwnedTagEnd::List(false)));
}
events.push(OwnedEvent::End(OwnedTagEnd::Item));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_items() {
let seq = BlockerSequence::parse("- task 1\n- task 2");
assert_eq!(seq.items.len(), 2);
assert_eq!(seq.items[0].text, "task 1");
assert_eq!(seq.items[1].text, "task 2");
}
#[test]
fn test_parse_items_with_comments() {
let content = "- task 1\n comment on task 1\n another comment\n- task 2";
let seq = BlockerSequence::parse(content);
assert_eq!(seq.items.len(), 2);
assert_eq!(seq.items[0].text, "task 1");
assert_eq!(seq.items[0].comments, vec!["comment on task 1", "another comment"]);
assert_eq!(seq.items[1].text, "task 2");
assert!(seq.items[1].comments.is_empty());
}
#[test]
fn test_parse_nested_items() {
let content = "- parent\n - child 1\n - child 2";
let seq = BlockerSequence::parse(content);
assert_eq!(seq.items.len(), 1);
assert_eq!(seq.items[0].text, "parent");
assert_eq!(seq.items[0].children.len(), 2);
assert_eq!(seq.items[0].children[0].text, "child 1");
assert_eq!(seq.items[0].children[1].text, "child 2");
}
#[test]
fn test_parse_comments_then_children() {
let content = "- parent\n comment\n - child";
let seq = BlockerSequence::parse(content);
assert_eq!(seq.items.len(), 1);
assert_eq!(seq.items[0].comments, vec!["comment"]);
assert_eq!(seq.items[0].children.len(), 1);
assert_eq!(seq.items[0].children[0].text, "child");
}
#[test]
fn test_parse_deeply_nested() {
let content = "- level 0\n - level 1\n - level 2\n - level 3";
let seq = BlockerSequence::parse(content);
assert_eq!(seq.items[0].text, "level 0");
assert_eq!(seq.items[0].children[0].text, "level 1");
assert_eq!(seq.items[0].children[0].children[0].text, "level 2");
assert_eq!(seq.items[0].children[0].children[0].children[0].text, "level 3");
}
#[test]
fn test_serialize_roundtrip() {
let content = "- task 1\n comment\n - child\n- task 2";
let seq = BlockerSequence::parse(content);
let serialized = String::from(&seq);
let reparsed = BlockerSequence::parse(&serialized);
assert_eq!(seq, reparsed);
}
#[test]
fn test_serialize_nested_roundtrip() {
let content = "- parent\n comment on parent\n - child 1\n comment on child\n - child 2\n- sibling";
let seq = BlockerSequence::parse(content);
let serialized = String::from(&seq);
let reparsed = BlockerSequence::parse(&serialized);
assert_eq!(seq, reparsed);
}
#[test]
fn test_split_blockers_no_marker() {
let text = "Some content\nwithout blockers";
let (content, blockers) = split_blockers(text);
assert_eq!(content, text);
assert!(blockers.is_empty());
}
#[test]
fn test_split_blockers_with_marker() {
let text = "Description here\n\n# Blockers\n- task 1\n- task 2";
let (content, blockers) = split_blockers(text);
insta::assert_snapshot!(format!("content: {content:?}\nblockers: {}", String::from(&blockers)), @r#"
content: "Description here\n"
blockers: - task 1
- task 2
"#);
}
#[test]
fn test_join_with_blockers_empty() {
let body = Events::parse("Some content");
let blockers = BlockerSequence::default();
let result: String = join_with_blockers(&body, &blockers).into();
insta::assert_snapshot!(result, @"Some content");
}
#[test]
fn test_join_with_blockers_non_empty() {
let body = Events::parse("Description");
let blockers = BlockerSequence::parse("- task 1\n- task 2");
let result: String = join_with_blockers(&body, &blockers).into();
insta::assert_snapshot!(result, @"
Description
# Blockers
- task 1
- task 2
");
}
#[test]
fn test_split_join_roundtrip() {
let original = "Body text\n\n# Blockers\n- task 1\n- task 2";
let (content, blockers) = split_blockers(original);
let body_events = Events::parse(&content);
let rejoined: String = join_with_blockers(&body_events, &blockers).into();
let (content2, blockers2) = split_blockers(&rejoined);
insta::assert_snapshot!(
format!("content: {content2:?}\nblockers: {}", String::from(&blockers2)),
@r#"
content: "Body text"
blockers: - task 1
- task 2
"#
);
}
#[test]
fn test_blocker_sequence_strips_empty_lines() {
let content = "\n\n- task 1\n- task 2\n\n\n";
let seq = BlockerSequence::parse(content);
let serialized = String::from(&seq);
insta::assert_snapshot!(serialized, @r"
- task 1
- task 2
");
}
#[test]
fn test_is_empty() {
let empty = BlockerSequence::default();
let with_content = BlockerSequence::parse("- task");
insta::assert_snapshot!(
format!("default: {}\nwith_content: {}", empty.is_empty(), with_content.is_empty()),
@"
default: true
with_content: false
"
);
}
#[test]
fn test_milestone_cache_serde_roundtrip() {
let cache = MilestoneBlockerCache {
current_index: 1,
milestone_description: "# Sprint\n- [ ] Issue A <!-- @user https://github.com/o/r/issues/1 -->".to_string(),
ref_targets: HashMap::new(),
};
let json = serde_json::to_string_pretty(&cache).unwrap();
let deserialized: MilestoneBlockerCache = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.current_index, cache.current_index);
assert_eq!(deserialized.milestone_description, cache.milestone_description);
}
#[test]
fn test_milestone_cache_embedded_links() {
let cache = MilestoneBlockerCache {
current_index: 0,
milestone_description:
"# Sprint\n\n- [ ] Issue A <!-- @user https://github.com/o/r/issues/1 -->\n\t# Blockers\n\t- task\n\n- [ ] Issue B <!-- @user https://github.com/o/r/issues/2 -->".to_string(),
ref_targets: HashMap::new(),
};
let links = cache.embedded_links();
assert_eq!(links.len(), 2);
assert_eq!(links[0].number(), 1);
assert_eq!(links[1].number(), 2);
}
#[test]
fn test_milestone_cache_current_link() {
let cache = MilestoneBlockerCache {
current_index: 1,
milestone_description: "- [ ] A <!-- @u https://github.com/o/r/issues/1 -->\n\n- [ ] B <!-- @u https://github.com/o/r/issues/2 -->".to_string(),
ref_targets: HashMap::new(),
};
let link = cache.current_link().unwrap();
assert_eq!(link.number(), 2);
}
#[test]
fn test_milestone_cache_empty_description() {
let cache = MilestoneBlockerCache {
current_index: 0,
milestone_description: "# Sprint\nNo issues yet".to_string(),
ref_targets: HashMap::new(),
};
assert!(cache.embedded_links().is_empty());
assert!(cache.current_link().is_none());
}
#[test]
fn test_deepest_issue_ref_simple() {
let seq = BlockerSequence::parse("- o/r#42");
let r = seq.deepest_issue_ref().unwrap();
assert_eq!(r.to_string(), "o/r#42");
}
#[test]
fn test_deepest_issue_ref_nested() {
let seq = BlockerSequence::parse("- parent\n - o/r#99");
let r = seq.deepest_issue_ref().unwrap();
assert_eq!(r.to_string(), "o/r#99");
}
#[test]
fn test_deepest_issue_ref_none_for_text() {
let seq = BlockerSequence::parse("- some plain task");
assert!(seq.deepest_issue_ref().is_none());
}
#[test]
fn test_deepest_issue_ref_parent_ref_with_text_child() {
let seq = BlockerSequence::parse("- o/r#10\n - plain task");
let r = seq.deepest_issue_ref().unwrap();
assert_eq!(r.to_string(), "o/r#10");
}
#[test]
fn test_issue_ref_blocker_roundtrip_no_escaping() {
let content = "- #13\n- #42";
let seq = BlockerSequence::parse(content);
let serialized = String::from(&seq);
let reparsed = BlockerSequence::parse(&serialized);
assert_eq!(seq, reparsed, "structural roundtrip must preserve blocker items");
insta::assert_snapshot!(serialized, @"
- #13
- #42
");
}
}