use std::{collections::HashMap, path::PathBuf};
use arrayvec::ArrayString;
use copy_arrayvec::CopyArrayVec;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use url::Url;
use super::events::{indent_into, preserve_paragraph_spacing, wrap_inline_in_paragraphs};
pub const MAX_TITLE_LENGTH: usize = 256;
pub const MAX_INDEX_DEPTH: usize = MAX_LINEAGE_DEPTH + 1;
pub const MAX_LINEAGE_DEPTH: usize = 8;
pub type IssueChildren<T> = HashMap<IssueSelector, T>;
use super::{
IssueMarker, Marker,
blocker::BlockerSequence,
error::{ParseContext, ParseError},
};
#[allow(async_fn_in_trait)]
pub trait LazyIssue<S: Clone + std::fmt::Debug>: Sized {
type Error: std::error::Error;
async fn parent_index(source: &S) -> Result<Option<IssueIndex>, Self::Error>;
async fn identity(&mut self, source: S) -> Result<IssueIdentity, Self::Error>;
async fn contents(&mut self, source: S) -> Result<IssueContents, Self::Error>;
async fn children(&mut self, source: S) -> Result<IssueChildren<Issue>, Self::Error>;
async fn load(source: S) -> Result<Issue, Self::Error>
where
Issue: LazyIssue<S, Error = Self::Error>, {
let parent_index = <Issue as LazyIssue<S>>::parent_index(&source).await?.expect("load requires parent_index to be Some");
let mut issue = Issue::empty_local(parent_index);
<Issue as LazyIssue<S>>::identity(&mut issue, source.clone()).await?;
<Issue as LazyIssue<S>>::contents(&mut issue, source.clone()).await?;
Box::pin(<Issue as LazyIssue<S>>::children(&mut issue, source)).await?;
Ok(issue)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct RepoInfo {
owner: ArrayString<39>,
repo: ArrayString<100>,
}
impl RepoInfo {
pub fn new(owner: &str, repo: &str) -> Self {
Self {
owner: ArrayString::from(owner).expect("owner name too long (max 39 chars)"),
repo: ArrayString::from(repo).expect("repo name too long (max 100 chars)"),
}
}
pub fn owner(&self) -> &str {
self.owner.as_str()
}
pub fn repo(&self) -> &str {
self.repo.as_str()
}
}
impl From<(&str, &str)> for RepoInfo {
fn from((owner, repo): (&str, &str)) -> Self {
Self::new(owner, repo)
}
}
#[derive(Clone, Debug, derive_more::Deref, derive_more::DerefMut, Eq, Hash, PartialEq)]
pub struct IssueLink(Url);
impl IssueLink {
pub fn new(url: Url) -> Option<Self> {
if url.host_str() != Some("github.com") {
return None;
}
let segments: Vec<_> = url.path_segments()?.collect();
if segments.len() < 4 || segments[2] != "issues" {
return None;
}
segments[3].parse::<u64>().ok()?;
Some(Self(url))
}
pub fn parse(url: &str) -> Option<Self> {
let url = Url::parse(url).ok()?;
Self::new(url)
}
pub fn url(&self) -> &Url {
&self.0
}
pub fn repo_info(&self) -> RepoInfo {
let mut segments = self.0.path_segments().unwrap();
let owner = segments.next().unwrap();
let repo = segments.next().unwrap();
RepoInfo::new(owner, repo)
}
pub fn owner(&self) -> &str {
self.0.path_segments().unwrap().next().unwrap()
}
pub fn repo(&self) -> &str {
self.0.path_segments().unwrap().nth(1).unwrap()
}
pub fn number(&self) -> u64 {
self.0.path_segments().unwrap().nth(3).unwrap().parse().unwrap()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub enum CommentIdentity {
Body,
Created { user: String, id: u64 },
#[default]
Pending,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub enum CloseState {
#[default]
Open,
Closed,
NotPlanned,
Duplicate(u64),
}
impl CloseState {
pub fn is_closed(&self) -> bool {
!matches!(self, CloseState::Open)
}
pub fn should_remove(&self) -> bool {
matches!(self, CloseState::Duplicate(_))
}
pub fn to_github_state(&self) -> &'static str {
match self {
CloseState::Open => "open",
_ => "closed",
}
}
pub fn to_github_state_reason(&self) -> Option<&'static str> {
match self {
CloseState::Open => None,
CloseState::Closed => Some("completed"),
CloseState::NotPlanned => Some("not_planned"),
CloseState::Duplicate(_) => Some("duplicate"),
}
}
pub fn from_github(state: &str, state_reason: Option<&str>) -> Self {
assert!(state_reason != Some("duplicate"), "Duplicate issues must be filtered before calling from_github");
match (state, state_reason) {
("open", _) => CloseState::Open,
("closed", Some("not_planned")) => CloseState::NotPlanned,
("closed", Some("completed") | None) => CloseState::Closed,
("closed", Some(unknown)) => {
tracing::warn!("Unknown state_reason '{unknown}', treating as Closed");
CloseState::Closed
}
(unknown, _) => {
tracing::warn!("Unknown state '{unknown}', treating as Open");
CloseState::Open
}
}
}
pub fn is_duplicate_reason(state_reason: Option<&str>) -> bool {
state_reason == Some("duplicate")
}
pub fn from_checkbox(content: &str) -> Result<Self, String> {
let content = content.trim();
match content {
"" | " " => Ok(CloseState::Open),
"x" | "X" => Ok(CloseState::Closed),
"-" => Ok(CloseState::NotPlanned),
s => s.parse::<u64>().map(CloseState::Duplicate).map_err(|_| s.to_string()),
}
}
pub fn to_checkbox_contents(&self) -> String {
match self {
CloseState::Open => " ".to_string(),
CloseState::Closed => "x".to_string(),
CloseState::NotPlanned => "-".to_string(),
CloseState::Duplicate(n) => n.to_string(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct IssueTimestamps {
pub title: Option<Timestamp>,
pub description: Option<Timestamp>,
pub labels: Option<Timestamp>,
pub state: Option<Timestamp>,
pub comments: Vec<Timestamp>,
}
impl IssueTimestamps {
pub fn now() -> Self {
let now = Timestamp::now();
Self {
title: Some(now),
description: Some(now),
labels: Some(now),
state: Some(now),
comments: Vec::new(),
}
}
pub fn update_from_diff(&mut self, old: &super::IssueContents, new: &super::IssueContents) {
let now = Timestamp::now();
if old.title != new.title {
self.title = Some(now);
}
let old_body = old.comments.description();
let new_body = new.comments.description();
if old_body != new_body || old.blockers != new.blockers {
self.description = Some(now);
}
if old.labels != new.labels {
self.labels = Some(now);
}
if old.state != new.state {
self.state = Some(now);
}
let old_comments: Vec<_> = old.comments.iter().skip(1).collect();
let new_comments: Vec<_> = new.comments.iter().skip(1).collect();
let new_comment_count = new_comments.len();
self.comments.resize(new_comment_count, now);
for (i, (old_c, new_c)) in old_comments.iter().zip(&new_comments).enumerate() {
if old_c.body.to_string() != new_c.body.to_string() {
self.comments[i] = now;
}
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LinkedIssueMeta {
pub user: Option<String>,
link: IssueLink,
pub timestamps: IssueTimestamps,
}
impl LinkedIssueMeta {
pub fn new(user: Option<String>, link: IssueLink, timestamps: IssueTimestamps) -> Self {
if user.is_none() {
tracing::warn!("LinkedIssueMeta created without user");
}
Self { user, link, timestamps }
}
pub fn link(&self) -> &IssueLink {
&self.link
}
pub fn owner(&self) -> &str {
self.link.owner()
}
pub fn repo(&self) -> &str {
self.link.repo()
}
pub fn number(&self) -> u64 {
self.link.number()
}
}
#[derive(Clone, Debug)]
pub struct IssueIdentity {
pub parent_index: IssueIndex,
pub is_virtual: bool,
pub remote: Option<Box<LinkedIssueMeta>>,
}
impl IssueIdentity {
pub fn new_linked(parent_index: Option<IssueIndex>, user: Option<String>, link: IssueLink, timestamps: IssueTimestamps) -> Self {
let parent_index = parent_index.unwrap_or_else(|| {
let repo_info = link.repo_info();
IssueIndex::repo_only(repo_info)
});
Self {
parent_index,
is_virtual: false,
remote: Some(Box::new(LinkedIssueMeta::new(user, link, timestamps))),
}
}
pub fn pending(parent_index: IssueIndex) -> Self {
Self {
parent_index,
is_virtual: false,
remote: None,
}
}
pub fn virtual_issue(parent_index: IssueIndex) -> Self {
Self {
parent_index,
is_virtual: true,
remote: None,
}
}
pub fn is_linked(&self) -> bool {
self.remote.is_some()
}
pub fn is_local(&self) -> bool {
self.remote.is_none() && !self.is_virtual
}
pub fn as_linked(&self) -> Option<&LinkedIssueMeta> {
self.remote.as_deref()
}
pub fn mut_linked_issue_meta(&mut self) -> Option<&mut LinkedIssueMeta> {
self.remote.as_deref_mut()
}
pub fn link(&self) -> Option<&IssueLink> {
self.as_linked().map(|m| &m.link)
}
pub fn number(&self) -> Option<u64> {
self.link().map(|l| l.number())
}
pub fn url_str(&self) -> Option<&str> {
self.link().map(|l| l.as_str())
}
pub fn user(&self) -> Option<&str> {
self.as_linked().and_then(|m| m.user.as_deref())
}
pub fn is_owned(&self) -> bool {
self.is_virtual
|| match self.as_linked() {
None => false,
Some(meta) => meta.user.as_ref().is_none_or(|u| crate::current_user::is(u)),
}
}
pub fn timestamps(&self) -> Option<&IssueTimestamps> {
self.as_linked().map(|m| &m.timestamps)
}
pub fn repo_info(&self) -> RepoInfo {
self.parent_index.repo_info()
}
pub fn owner(&self) -> &str {
self.parent_index.owner()
}
pub fn repo(&self) -> &str {
self.parent_index.repo()
}
pub fn git_lineage(&self) -> Result<Vec<u64>, super::error::TitleInGitPathError> {
self.parent_index.git_num_path()
}
pub fn child_parent_index(&self) -> Option<IssueIndex> {
self.number().map(|n| self.parent_index.child(IssueSelector::GitId(n)))
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[allow(clippy::large_enum_variant)] pub enum IssueSelector {
GitId(u64),
Title(ArrayString<MAX_TITLE_LENGTH>),
Regex(ArrayString<MAX_TITLE_LENGTH>),
}
impl IssueSelector {
pub fn title(title: &str) -> Self {
Self::Title(ArrayString::from(title).unwrap_or_else(|_| panic!("title too long (max {MAX_TITLE_LENGTH} chars): {}", title.len())))
}
pub fn try_title(title: &str) -> Option<Self> {
ArrayString::from(title).ok().map(Self::Title)
}
pub fn regex(pattern: &str) -> Self {
Self::Regex(ArrayString::from(pattern).unwrap_or_else(|_| panic!("pattern too long (max {MAX_TITLE_LENGTH} chars): {}", pattern.len())))
}
}
#[derive(Clone, Copy, Debug, derive_more::Deref, derive_more::DerefMut, Eq, PartialEq)]
pub struct IssueIndex {
repo_info: RepoInfo,
#[deref]
#[deref_mut]
index: CopyArrayVec<IssueSelector, MAX_INDEX_DEPTH>,
}
impl IssueIndex {
pub fn root(repo_info: RepoInfo, selector: IssueSelector) -> Self {
let mut index = CopyArrayVec::new();
index.push(selector);
Self { repo_info, index }
}
pub fn with_index(repo_info: RepoInfo, index: Vec<IssueSelector>) -> Self {
Self {
repo_info,
index: index.into_iter().collect(),
}
}
pub fn repo_only(repo_info: RepoInfo) -> Self {
Self {
repo_info,
index: CopyArrayVec::new(),
}
}
pub fn child(&self, selector: IssueSelector) -> Self {
let mut index = self.index;
index.push(selector);
Self { repo_info: self.repo_info, index }
}
pub fn index(&self) -> &[IssueSelector] {
&self.index
}
pub fn repo_info(&self) -> RepoInfo {
self.repo_info
}
pub fn owner(&self) -> &str {
self.repo_info.owner()
}
pub fn repo(&self) -> &str {
self.repo_info.repo()
}
pub fn git_num_path(&self) -> Result<Vec<u64>, super::error::TitleInGitPathError> {
use miette::{NamedSource, SourceSpan};
let mut result = Vec::with_capacity(self.index().len());
let mut offset = format!("{}/{}", self.repo_info.owner(), self.repo_info.repo()).len();
for selector in self.index() {
match selector {
IssueSelector::GitId(n) => {
let s = format!("/{n}");
offset += s.len();
result.push(*n);
}
IssueSelector::Title(title) | IssueSelector::Regex(title) => {
let span: SourceSpan = (offset + 1, title.len()).into(); return Err(super::error::TitleInGitPathError {
index_display: NamedSource::new("IssueIndex", self.to_string()),
span,
});
}
}
}
Ok(result)
}
pub fn issue_number(&self) -> Option<u64> {
match self.index().last() {
Some(IssueSelector::GitId(n)) => Some(*n),
_ => None,
}
}
pub fn parent(&self) -> Option<Self> {
if self.index.is_empty() {
None
} else {
let mut index = self.index;
index.pop();
Some(Self { repo_info: self.repo_info, index })
}
}
}
impl std::fmt::Display for IssueIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.repo_info.owner(), self.repo_info.repo())?;
for selector in self.index() {
match selector {
IssueSelector::GitId(n) => write!(f, "/{n}")?,
IssueSelector::Title(t) | IssueSelector::Regex(t) => write!(f, "/{t}")?,
}
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct IssueIndexParseError(String);
impl std::str::FromStr for IssueIndex {
type Err = IssueIndexParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() < 2 {
return Err(IssueIndexParseError(format!("IssueIndex requires at least owner/repo, got: {s}")));
}
let repo_info = RepoInfo::new(parts[0], parts[1]);
let selectors: Vec<IssueSelector> = parts[2..]
.iter()
.map(|p| match p.parse::<u64>() {
Ok(n) => IssueSelector::GitId(n),
Err(_) => IssueSelector::title(p),
})
.collect();
Ok(Self::with_index(repo_info, selectors))
}
}
impl From<&Issue> for IssueIndex {
fn from(issue: &Issue) -> Self {
let parent_index = issue.identity.parent_index;
if let Some(n) = issue.git_id() {
parent_index.child(IssueSelector::GitId(n))
} else {
parent_index.child(IssueSelector::title(&issue.contents.title))
}
}
}
#[derive(Clone, Debug, Default, derive_more::Deref, PartialEq)]
pub struct Comment {
pub identity: CommentIdentity,
#[deref]
pub body: super::Events,
}
impl Comment {
pub fn id(&self) -> Option<u64> {
match &self.identity {
CommentIdentity::Created { id, .. } => Some(*id),
CommentIdentity::Body | CommentIdentity::Pending => None,
}
}
pub fn user(&self) -> Option<&str> {
match &self.identity {
CommentIdentity::Created { user, .. } => Some(user),
CommentIdentity::Body | CommentIdentity::Pending => None,
}
}
pub fn is_comment(&self) -> bool {
!matches!(self.identity, CommentIdentity::Body)
}
pub fn is_pending(&self) -> bool {
matches!(self.identity, CommentIdentity::Pending)
}
}
#[derive(Clone, Debug, derive_more::Deref, derive_more::DerefMut, PartialEq)]
pub struct Comments(pub Vec<Comment>);
impl Comments {
pub fn description(&self) -> String {
self.first().map(|c| c.body.to_string()).expect("`new` should've asserted that description comment was provided")
}
pub fn new(v: Vec<Comment>) -> Self {
assert!(
v.first().is_some_and(|c| matches!(c.identity, CommentIdentity::Body)),
"Trying to instantiate issue `Comments` without description"
);
{
let first_pending_id = v.iter().position(|c| c.is_pending());
if let Some(id) = first_pending_id {
assert!(v.iter().rev().take(v.len() - id).all(|c| c.is_pending()))
}
}
Self(v)
}
}
impl Default for Comments {
fn default() -> Self {
Self(vec![Comment {
identity: CommentIdentity::Body,
..Default::default()
}])
}
}
impl From<Vec<Comment>> for Comments {
fn from(value: Vec<Comment>) -> Self {
match value.is_empty() {
true => Self::default(),
false => Self::new(value),
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct IssueContents {
pub title: String,
pub labels: Vec<String>,
pub state: CloseState,
pub comments: Comments,
pub blockers: BlockerSequence,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Issue {
pub identity: IssueIdentity,
pub contents: IssueContents,
pub children: HashMap<IssueSelector, Issue>,
}
impl Issue {
pub fn iter_mut(&mut self) -> std::collections::hash_map::IterMut<'_, IssueSelector, Issue> {
self.children.iter_mut()
}
pub fn is_local(&self) -> bool {
self.identity.is_local()
}
pub fn empty_local(parent_index: IssueIndex) -> Self {
Self {
identity: IssueIdentity::pending(parent_index),
contents: IssueContents::default(),
children: HashMap::new(),
}
}
pub fn pending_with_parent(title: impl Into<String>, parent_index: IssueIndex, virtual_project: bool) -> Self {
let identity = if virtual_project {
IssueIdentity::virtual_issue(parent_index)
} else {
IssueIdentity::pending(parent_index)
};
let contents = IssueContents {
title: title.into(),
..Default::default()
};
Self {
identity,
contents,
children: HashMap::new(),
}
}
pub fn pending_from_descriptor(descriptor: &IssueIndex, virtual_project: bool) -> Self {
let index = descriptor.index();
let (title, parent_selectors): (String, Vec<IssueSelector>) = match index.last() {
Some(IssueSelector::Title(t)) => {
let selectors = index[..index.len() - 1].to_vec();
(t.to_string(), selectors)
}
Some(IssueSelector::GitId(_)) => panic!("pending_from_descriptor requires last selector to be Title"),
Some(IssueSelector::Regex(_)) => panic!("pending_from_descriptor requires last selector to be Title, not Regex"),
None => panic!("pending_from_descriptor requires non-empty index"),
};
let parent_index = IssueIndex::with_index(descriptor.repo_info(), parent_selectors);
Self::pending_with_parent(title, parent_index, virtual_project)
}
pub fn is_linked(&self) -> bool {
self.identity.is_linked()
}
pub fn git_id(&self) -> Option<u64> {
self.identity.number()
}
pub fn url_str(&self) -> Option<&str> {
self.identity.url_str()
}
pub fn user(&self) -> Option<&str> {
self.identity.user()
}
fn update_timestamps_from_diff(&mut self, old: &Issue) {
if let Some(linked) = self.identity.mut_linked_issue_meta() {
linked.timestamps.update_from_diff(&old.contents, &self.contents);
}
for (selector, new_child) in &mut self.children {
if let Some(old_child) = old.children.get(selector) {
new_child.update_timestamps_from_diff(old_child);
}
}
}
fn propagate_closed(&mut self) {
if !self.contents.state.is_closed() {
return;
}
let state = self.contents.state.clone();
for child in self.children.values_mut() {
child.contents.state = state.clone();
child.propagate_closed();
}
}
pub fn post_update(&mut self, old: &Issue) {
self.update_timestamps_from_diff(old);
self.propagate_closed();
}
pub fn parent_index(&self) -> IssueIndex {
self.identity.parent_index
}
pub fn selector(&self) -> IssueSelector {
match self.git_id() {
Some(n) => IssueSelector::GitId(n),
None => IssueSelector::title(&self.contents.title),
}
}
pub fn full_index(&self) -> IssueIndex {
self.identity.parent_index.child(self.selector())
}
pub fn lineage(&self) -> Result<Vec<u64>, super::error::TitleInGitPathError> {
self.identity.git_lineage()
}
pub fn repo_info(&self) -> RepoInfo {
self.identity.repo_info()
}
pub fn body(&self) -> super::Events {
let mut events: Vec<super::OwnedEvent> = self.contents.comments.first().map(|c| c.body.to_vec()).unwrap_or_default();
if !self.contents.blockers.is_empty() {
events.push(super::OwnedEvent::Start(super::OwnedTag::Heading {
level: pulldown_cmark::HeadingLevel::H1,
id: None,
classes: Vec::new(),
attrs: Vec::new(),
}));
events.push(super::OwnedEvent::Text("Blockers".to_string()));
events.push(super::OwnedEvent::End(super::OwnedTagEnd::Heading(pulldown_cmark::HeadingLevel::H1)));
events.extend(self.contents.blockers.to_events());
}
events.into()
}
pub fn from_combined(hollow: HollowIssue, virtual_issue: VirtualIssue, parent_idx: IssueIndex, is_virtual: bool) -> Result<Self, super::error::IssueError> {
let identity = IssueIdentity {
parent_index: parent_idx,
is_virtual,
remote: hollow.remote.clone(),
};
let mut children = HashMap::new();
for (selector, virtual_child) in virtual_issue.children {
let child_hollow = match hollow.children.get(&selector) {
Some(ch) => ch.clone(),
None => {
if let IssueSelector::GitId(n) = selector {
return Err(super::error::IssueError::ErroneousComposition {
issue_number: n,
detail: "either internal bug (HollowIssue was constructed incorrectly) or user manually embedded a `<!-- @user url -->` marker, which is not permitted"
.to_string(),
});
}
HollowIssue::default()
}
};
let child_parent_idx = if let Some(meta) = &identity.remote {
parent_idx.child(IssueSelector::GitId(meta.number()))
} else {
parent_idx
};
let child = Self::from_combined(child_hollow, virtual_child, child_parent_idx, is_virtual)?;
children.insert(selector, child);
}
Ok(Issue {
identity,
contents: virtual_issue.contents,
children,
})
}
fn parse_comment_identity(s: &str) -> CommentIdentity {
let s = s.trim();
if let Some(rest) = s.strip_prefix('@')
&& let Some(space_idx) = rest.find(' ')
{
let user = rest[..space_idx].to_string();
let url = rest[space_idx + 1..].trim();
if let Some(id) = url.split("#issuecomment-").nth(1).and_then(|s| s.parse().ok()) {
return CommentIdentity::Created { user, id };
}
}
CommentIdentity::Pending
}
pub fn serialize_virtual(&self) -> String {
self.serialize_virtual_at_depth(0, true)
}
fn serialize_virtual_at_depth(&self, depth: usize, include_children: bool) -> String {
use super::{OwnedEvent, OwnedTag, OwnedTagEnd};
if self.identity.is_virtual {
assert!(self.user().is_none(), "virtual issue must not have a user tag, got: {:?}", self.user());
}
let content_indent = " ".repeat(depth + 1);
let mut out = String::new();
let checkbox_contents = self.contents.state.to_checkbox_contents();
let issue_marker = IssueMarker::from(&self.identity);
let labels_part = if self.contents.labels.is_empty() {
String::new()
} else {
format!("({}) ", self.contents.labels.join(", "))
};
let title_str: String = super::Events::from(vec![
OwnedEvent::Start(OwnedTag::List(None)),
OwnedEvent::Start(OwnedTag::Item),
OwnedEvent::CheckBox(checkbox_contents),
OwnedEvent::Text(format!("{labels_part}{} ", self.contents.title)),
OwnedEvent::InlineHtml(format!("<!-- {} -->", issue_marker.encode())),
OwnedEvent::End(OwnedTagEnd::Item),
OwnedEvent::End(OwnedTagEnd::List(false)),
])
.into();
let depth_indent = " ".repeat(depth);
for line in title_str.lines() {
out.push_str(&depth_indent);
out.push_str(line);
out.push('\n');
}
let is_owned = self.identity.is_owned();
let mut content = String::new();
if let Some(body_comment) = self.contents.comments.first()
&& !body_comment.body.is_empty()
{
let body_str: String = super::Events::from(body_comment.body.to_vec()).into();
if !is_owned {
indent_into(&mut content, &body_str, " ");
} else {
content.push_str(&body_str);
}
}
fn ensure_blank_line(content: &mut String) {
if content.is_empty() {
return;
}
if !content.ends_with('\n') {
content.push('\n');
}
if content.lines().last().is_some_and(|l| !l.trim().is_empty()) {
content.push('\n');
}
}
for comment in self.contents.comments.iter().skip(1) {
if self.identity.is_virtual {
assert!(
!comment.is_comment() || comment.user().is_none(),
"virtual issue must not have linked comments, got: {:?}",
comment.identity
);
}
let comment_is_owned = comment.user().is_none() || comment.user().is_some_and(crate::current_user::is);
ensure_blank_line(&mut content);
let marker_html = match &comment.identity {
CommentIdentity::Body | CommentIdentity::Pending => Marker::NewComment.encode(),
CommentIdentity::Created { user, id } => {
let url = self.url_str().expect("remote must be initialized");
format!("<!-- @{user} {url}#issuecomment-{id} -->")
}
};
content.push_str(&marker_html);
content.push('\n');
if !comment.body.is_empty() {
let body_str: String = super::Events::from(comment.body.to_vec()).into();
if !comment_is_owned {
indent_into(&mut content, &body_str, " ");
} else {
content.push_str(&body_str);
}
}
}
if !self.contents.blockers.is_empty() {
ensure_blank_line(&mut content);
let header = crate::Header::new(1, "Blockers");
content.push_str(&header.encode());
content.push('\n');
let blockers_str: String = String::from(&self.contents.blockers);
content.push_str(&blockers_str);
}
if !content.is_empty() {
indent_into(&mut out, &content, &content_indent);
}
if include_children {
let (mut open, mut closed): (Vec<_>, Vec<_>) = self.children.iter().partition(|(_, child)| !child.contents.state.is_closed());
open.sort_by_key(|(sel, _)| *sel);
closed.sort_by_key(|(sel, _)| *sel);
let sorted_children = open.into_iter().chain(closed);
for (_, child) in sorted_children {
if out.lines().last().is_some_and(|l| !l.trim().is_empty()) {
out.push_str(&content_indent);
out.push('\n');
}
if child.contents.state.is_closed() {
let child_str = child.serialize_virtual_at_depth(depth + 1, true);
let omitted_start = Marker::OmittedStart.encode();
let omitted_end = Marker::OmittedEnd.encode();
let child_content_indent = " ".repeat(depth + 2);
let mut lines = child_str.lines();
if let Some(title_line) = lines.next() {
out.push_str(&format!("{title_line} {omitted_start}\n"));
}
for line in lines {
out.push_str(line);
out.push('\n');
}
out.push_str(&child_content_indent);
out.push_str(&omitted_end);
out.push('\n');
} else {
let child_str = child.serialize_virtual_at_depth(depth + 1, true);
out.push_str(&child_str);
}
}
}
out
}
pub fn serialize_filesystem(&self) -> String {
self.serialize_virtual_at_depth(0, false)
}
pub fn render_github(&self) -> super::Events {
self.body()
}
pub fn find_last_blocker_position(&self) -> Option<(u32, u32)> {
if self.contents.blockers.is_empty() {
return None;
}
let serialized = self.serialize_virtual();
let lines: Vec<&str> = serialized.lines().collect();
let blockers_header = crate::Header::new(1, "Blockers").encode();
let blockers_start_idx = lines.iter().position(|line| line.trim() == blockers_header)?;
let mut last_item_line_num: Option<u32> = None;
let mut last_item_col: Option<u32> = None;
for (offset, line) in lines[blockers_start_idx + 1..].iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("- [") {
break;
}
if trimmed.starts_with("- ") {
let line_num = (blockers_start_idx + 1 + offset + 1) as u32;
let dash_pos = line.find("- ").unwrap_or(0);
let col = (dash_pos + 3) as u32; last_item_line_num = Some(line_num);
last_item_col = Some(col);
}
}
last_item_line_num.zip(last_item_col)
}
pub fn get(&self, lineage: &[u64]) -> Option<&Issue> {
let mut current = self;
for &num in lineage {
current = current.children.get(&IssueSelector::GitId(num))?;
}
Some(current)
}
pub fn get_mut(&mut self, lineage: &[u64]) -> Option<&mut Issue> {
let mut current = self;
for &num in lineage {
current = current.children.get_mut(&IssueSelector::GitId(num))?;
}
Some(current)
}
}
#[derive(Clone, Debug, Default, PartialEq, derive_new::new)]
pub struct HollowIssue {
pub remote: Option<Box<LinkedIssueMeta>>,
pub children: IssueChildren<HollowIssue>,
}
impl From<Issue> for HollowIssue {
fn from(value: Issue) -> Self {
let mut children = HashMap::with_capacity(value.children.capacity());
for (selector, child) in value.children.into_iter() {
children.insert(selector, child.into());
}
HollowIssue {
remote: value.identity.remote,
children,
}
}
}
#[derive(Clone, Debug, PartialEq, derive_new::new)]
pub struct VirtualIssue {
pub selector: IssueSelector,
pub contents: IssueContents,
pub children: IssueChildren<Self>,
}
impl VirtualIssue {
pub fn parse(content: &str, path: PathBuf) -> Result<Self, ParseError> {
let ctx = ParseContext::new(content.to_owned(), path);
let events = super::Events::parse(content);
Self::parse_from_events(&events, &ctx)
}
fn parse_from_events(events: &[super::OwnedEvent], ctx: &ParseContext) -> Result<Self, ParseError> {
Self::parse_from_events_inner(events, ctx, false)
}
fn parse_from_events_inner(events: &[super::OwnedEvent], ctx: &ParseContext, default_pending: bool) -> Result<Self, ParseError> {
use super::{OwnedEvent, OwnedTag, OwnedTagEnd};
let mut pos = 0;
while pos < events.len() && !matches!(&events[pos], OwnedEvent::Start(OwnedTag::Item)) {
pos += 1;
}
if pos >= events.len() {
return Err(ParseError::empty_file());
}
pos += 1;
let close_state = match &events[pos] {
OwnedEvent::CheckBox(inner) => {
pos += 1;
let needle = format!("[{inner}]");
CloseState::from_checkbox(inner).map_err(|content| ParseError::invalid_checkbox(ctx.named_source(), ctx.find_line_span(&needle, 1), content))?
}
_ => return Err(ParseError::invalid_title(ctx.named_source(), ctx.line_span(1), "missing checkbox".into())),
};
let mut title_text = String::new();
while pos < events.len() {
match &events[pos] {
OwnedEvent::InlineHtml(_) => break,
OwnedEvent::Text(t) => {
title_text.push_str(t);
pos += 1;
}
OwnedEvent::Code(c) => {
title_text.push('`');
title_text.push_str(c);
title_text.push('`');
pos += 1;
}
_ => break,
}
}
let identity_info = match &events[pos] {
OwnedEvent::InlineHtml(html) => {
let (marker, _) = IssueMarker::parse_from_end(&format!("x {html}")).ok_or_else(|| ParseError::missing_url_marker(ctx.named_source(), ctx.line_span(1)))?;
pos += 1;
marker
}
_ => {
match IssueMarker::parse_from_end(&title_text) {
Some((marker, rest)) => {
title_text = rest.to_string();
marker
}
None if default_pending => IssueMarker::Pending,
None => return Err(ParseError::missing_url_marker(ctx.named_source(), ctx.line_span(1))),
}
}
};
loop {
match events.get(pos) {
Some(OwnedEvent::InlineHtml(html)) => {
let trimmed = html.trim();
if (trimmed.starts_with("<!--omitted") && trimmed.contains("{{{")) || trimmed.starts_with("<!--,}}}") {
pos += 1;
} else {
break;
}
}
Some(OwnedEvent::Text(t)) if t.trim().is_empty() => {
pos += 1; }
_ => break,
}
}
if matches!(events.get(pos), Some(OwnedEvent::SoftBreak)) {
pos += 1;
}
let title_text = title_text.trim_end();
let (labels, title) = if title_text.starts_with('(') {
if let Some(paren_end) = title_text.find(") ") {
let labels_str = &title_text[1..paren_end];
let labels: Vec<String> = labels_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
(labels, title_text[paren_end + 2..].to_string())
} else {
(vec![], title_text.to_string())
}
} else {
(vec![], title_text.to_string())
};
let selector = identity_info.selector(&title);
let mut comments: Vec<Comment> = Vec::new();
let mut children = HashMap::new();
let mut body_events: Vec<OwnedEvent> = Vec::new();
let mut current_comment_events: Vec<OwnedEvent> = Vec::new();
let mut current_comment_meta: Option<CommentIdentity> = None;
let mut blocker_events: Vec<OwnedEvent> = Vec::new();
let mut in_body = true;
let mut in_blockers = false;
let mut blocker_list_consumed = false;
let mut select_blockers = false;
let flush = |in_body: &mut bool,
current_comment_meta: &mut Option<CommentIdentity>,
body_events: &mut Vec<OwnedEvent>,
current_comment_events: &mut Vec<OwnedEvent>,
comments: &mut Vec<Comment>| {
if *in_body {
*in_body = false;
let events = preserve_paragraph_spacing(wrap_inline_in_paragraphs(std::mem::take(body_events)));
comments.push(Comment {
identity: CommentIdentity::Body,
body: events.into(),
});
} else if let Some(identity) = current_comment_meta.take() {
let events = preserve_paragraph_spacing(wrap_inline_in_paragraphs(std::mem::take(current_comment_events)));
comments.push(Comment { identity, body: events.into() });
}
};
while pos < events.len() {
match &events[pos] {
OwnedEvent::End(OwnedTagEnd::Item) => break,
OwnedEvent::InlineHtml(html) if html.trim().starts_with("<!--omitted") && html.contains("{{{") => {
pos += 1;
continue;
}
OwnedEvent::InlineHtml(html) if html.trim().starts_with("<!--,}}}") => {
pos += 1;
continue;
}
OwnedEvent::Start(OwnedTag::HtmlBlock) | OwnedEvent::End(OwnedTagEnd::HtmlBlock) => {
pos += 1;
continue;
}
OwnedEvent::Html(html) => {
let trimmed = html.trim();
if trimmed.starts_with("<!--omitted") && trimmed.contains("{{{") {
pos += 1;
continue;
}
if trimmed.starts_with("<!--,}}}") {
pos += 1;
continue;
}
if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
let inner = trimmed.strip_prefix("<!--").unwrap().strip_suffix("-->").unwrap().trim();
if inner == "new comment" || inner.eq_ignore_ascii_case("!c") {
flush(&mut in_body, &mut current_comment_meta, &mut body_events, &mut current_comment_events, &mut comments);
current_comment_meta = Some(CommentIdentity::Pending);
pos += 1;
continue;
}
if inner.contains("#issuecomment-") {
flush(&mut in_body, &mut current_comment_meta, &mut body_events, &mut current_comment_events, &mut comments);
current_comment_meta = Some(Issue::parse_comment_identity(inner));
pos += 1;
continue;
}
}
if in_blockers {
blocker_events.push(events[pos].clone());
} else if in_body {
body_events.push(events[pos].clone());
} else if current_comment_meta.is_some() {
current_comment_events.push(events[pos].clone());
}
pos += 1;
}
OwnedEvent::Start(OwnedTag::Paragraph)
if matches!(events.get(pos + 1), Some(OwnedEvent::Text(t)) if t.trim().eq_ignore_ascii_case("!s") || t.trim().eq_ignore_ascii_case("!c"))
&& matches!(events.get(pos + 2), Some(OwnedEvent::End(OwnedTagEnd::Paragraph))) =>
{
let text = match &events[pos + 1] {
OwnedEvent::Text(t) => t.trim().to_ascii_lowercase(),
_ => unreachable!(),
};
if text == "!s" {
select_blockers = true;
} else {
flush(&mut in_body, &mut current_comment_meta, &mut body_events, &mut current_comment_events, &mut comments);
current_comment_meta = Some(CommentIdentity::Pending);
}
pos += 3; }
OwnedEvent::Text(t) if t.trim().eq_ignore_ascii_case("!s") || t.trim().eq_ignore_ascii_case("!c") => {
let strip_trailing = |evs: &mut Vec<OwnedEvent>| {
if matches!(evs.last(), Some(OwnedEvent::Start(OwnedTag::Paragraph))) {
evs.pop();
}
if matches!(evs.last(), Some(OwnedEvent::SoftBreak)) {
evs.pop();
}
};
if in_body {
strip_trailing(&mut body_events);
} else if current_comment_meta.is_some() {
strip_trailing(&mut current_comment_events);
}
if t.trim().eq_ignore_ascii_case("!s") {
select_blockers = true;
} else {
flush(&mut in_body, &mut current_comment_meta, &mut body_events, &mut current_comment_events, &mut comments);
current_comment_meta = Some(CommentIdentity::Pending);
}
pos += 1;
if matches!(events.get(pos), Some(OwnedEvent::SoftBreak)) {
pos += 1;
}
}
OwnedEvent::Start(OwnedTag::Heading {
level: pulldown_cmark::HeadingLevel::H1,
..
}) => {
let heading_start = pos;
pos += 1; let mut heading_text = String::new();
while pos < events.len() && !matches!(&events[pos], OwnedEvent::End(OwnedTagEnd::Heading(pulldown_cmark::HeadingLevel::H1))) {
if let OwnedEvent::Text(t) = &events[pos] {
heading_text.push_str(t);
}
pos += 1;
}
pos += 1;
let heading_trimmed = heading_text.trim();
let (effective, has_select_suffix) = match heading_trimmed.strip_suffix("!s").or_else(|| heading_trimmed.strip_suffix("!S")) {
Some(before) => (before.trim(), true),
None => (heading_trimmed, false),
};
if effective.eq_ignore_ascii_case("blockers") {
if has_select_suffix {
select_blockers = true;
}
flush(&mut in_body, &mut current_comment_meta, &mut body_events, &mut current_comment_events, &mut comments);
in_blockers = true;
} else {
let heading_events = &events[heading_start..pos];
if in_body {
body_events.extend(heading_events.iter().cloned());
} else if current_comment_meta.is_some() {
current_comment_events.extend(heading_events.iter().cloned());
}
}
}
OwnedEvent::Start(OwnedTag::List(_)) => {
let has_checkbox = Self::list_has_checkbox(&events[pos..]);
let is_child_list = has_checkbox;
if is_child_list {
let children_default_pending = blocker_list_consumed;
in_blockers = false;
let list_end = Self::find_matching_end_list(events, pos);
let list_events = &events[pos..list_end];
flush(&mut in_body, &mut current_comment_meta, &mut body_events, &mut current_comment_events, &mut comments);
let mut inner_pos = 1; while inner_pos < list_events.len() {
if matches!(&list_events[inner_pos], OwnedEvent::Start(OwnedTag::Item)) {
let item_start = inner_pos;
let item_end = Self::find_matching_end_item(list_events, inner_pos);
let mut child_events = vec![OwnedEvent::Start(OwnedTag::List(None))];
child_events.extend(list_events[item_start..item_end].iter().cloned());
child_events.push(OwnedEvent::End(OwnedTagEnd::List(false)));
let child = Self::parse_from_events_inner(&child_events, ctx, children_default_pending)?;
children.insert(child.selector, child);
inner_pos = item_end;
} else {
inner_pos += 1;
}
}
pos = list_end;
} else if in_blockers {
if blocker_list_consumed {
return Err(ParseError::invalid_composition(
ctx.named_source(),
ctx.line_span(1),
"non-checkbox list after blockers section".into(),
));
}
let list_end = Self::find_matching_end_list(events, pos);
blocker_events.extend(events[pos..list_end].iter().cloned());
blocker_list_consumed = true;
pos = list_end;
} else {
let list_end = Self::find_matching_end_list(events, pos);
let list_slice = &events[pos..list_end];
if in_body {
body_events.extend(list_slice.iter().cloned());
} else if current_comment_meta.is_some() {
current_comment_events.extend(list_slice.iter().cloned());
}
pos = list_end;
}
}
_ => {
if in_blockers && blocker_list_consumed {
match &events[pos] {
OwnedEvent::SoftBreak | OwnedEvent::HardBreak => {
pos += 1;
continue;
}
OwnedEvent::Html(h) if h.trim().is_empty() => {
pos += 1;
continue;
}
OwnedEvent::Text(t) if t.trim().is_empty() => {
pos += 1;
continue;
}
_ => {
return Err(ParseError::invalid_composition(
ctx.named_source(),
ctx.line_span(1),
format!("unexpected content after blockers section: {:?}", &events[pos]),
));
}
}
} else if in_blockers {
blocker_events.push(events[pos].clone());
} else if in_body {
body_events.push(events[pos].clone());
} else if current_comment_meta.is_some() {
current_comment_events.push(events[pos].clone());
}
pos += 1;
}
}
}
flush(&mut in_body, &mut current_comment_meta, &mut body_events, &mut current_comment_events, &mut comments);
let blockers = if blocker_events.is_empty() {
BlockerSequence::default()
} else {
let blocker_text: String = super::Events::from(blocker_events).into();
BlockerSequence::parse(&blocker_text)
};
Ok(VirtualIssue {
selector,
contents: IssueContents {
title,
labels,
state: close_state,
comments: comments.into(),
blockers: {
let mut seq = blockers;
if select_blockers {
seq.set_state = Some(crate::issue::BlockerSetState::Pending);
}
seq
},
},
children,
})
}
fn list_has_checkbox(events: &[super::OwnedEvent]) -> bool {
use super::{OwnedEvent, OwnedTag, OwnedTagEnd};
let mut depth = 0;
for ev in events {
match ev {
OwnedEvent::Start(OwnedTag::List(_)) => depth += 1,
OwnedEvent::End(OwnedTagEnd::List(_)) => {
depth -= 1;
if depth == 0 {
break;
}
}
OwnedEvent::CheckBox(_) if depth == 1 => return true,
_ => {}
}
}
false
}
fn find_matching_end_list(events: &[super::OwnedEvent], start: usize) -> usize {
use super::{OwnedEvent, OwnedTag, OwnedTagEnd};
let mut depth = 0;
for (i, event) in events.iter().enumerate().skip(start) {
match event {
OwnedEvent::Start(OwnedTag::List(_)) => depth += 1,
OwnedEvent::End(OwnedTagEnd::List(_)) => {
depth -= 1;
if depth == 0 {
return i + 1;
}
}
_ => {}
}
}
events.len()
}
fn find_matching_end_item(events: &[super::OwnedEvent], start: usize) -> usize {
use super::{OwnedEvent, OwnedTag, OwnedTagEnd};
let mut depth = 0;
for (i, event) in events.iter().enumerate().skip(start) {
match event {
OwnedEvent::Start(OwnedTag::Item) => depth += 1,
OwnedEvent::End(OwnedTagEnd::Item) => {
depth -= 1;
if depth == 0 {
return i + 1;
}
}
_ => {}
}
}
events.len()
}
}
impl From<Issue> for VirtualIssue {
fn from(issue: Issue) -> Self {
let selector = issue.selector();
let children = issue.children.into_iter().map(|(sel, child)| (sel, child.into())).collect();
Self {
selector,
contents: issue.contents,
children,
}
}
}
impl PartialEq for IssueIdentity {
fn eq(&self, other: &IssueIdentity) -> bool {
self.parent_index == other.parent_index
}
}
macro_rules! impl_issue_tree_index {
($ty:ty) => {
impl std::ops::Index<u64> for $ty {
type Output = $ty;
fn index(&self, issue_number: u64) -> &Self::Output {
self.children
.get(&IssueSelector::GitId(issue_number))
.unwrap_or_else(|| panic!("no child with issue number {issue_number}"))
}
}
impl std::ops::IndexMut<u64> for $ty {
fn index_mut(&mut self, issue_number: u64) -> &mut Self::Output {
self.children
.get_mut(&IssueSelector::GitId(issue_number))
.unwrap_or_else(|| panic!("no child with issue number {issue_number}"))
}
}
impl std::ops::Index<IssueSelector> for $ty {
type Output = $ty;
fn index(&self, selector: IssueSelector) -> &Self::Output {
self.children.get(&selector).unwrap_or_else(|| panic!("no child with selector {selector:?}"))
}
}
impl std::ops::IndexMut<IssueSelector> for $ty {
fn index_mut(&mut self, selector: IssueSelector) -> &mut Self::Output {
self.children.get_mut(&selector).unwrap_or_else(|| panic!("no child with selector {selector:?}"))
}
}
};
}
impl_issue_tree_index!(Issue);
impl_issue_tree_index!(HollowIssue);
impl_issue_tree_index!(VirtualIssue);
#[cfg(test)]
mod tests {
use super::*;
fn unsafe_mock_parse_virtual(content: &str) -> Issue {
let virtual_issue = VirtualIssue::parse(content, PathBuf::from("test.md")).unwrap();
let hollow = hollow_from_virtual(&virtual_issue);
let parent_idx = IssueIndex::repo_only(("owner", "repo").into());
Issue::from_combined(hollow, virtual_issue, parent_idx, false).unwrap()
}
fn hollow_from_virtual(v: &VirtualIssue) -> HollowIssue {
let remote = match &v.selector {
IssueSelector::GitId(n) => {
let link = IssueLink::parse(&format!("https://github.com/owner/repo/issues/{n}")).unwrap();
Some(Box::new(LinkedIssueMeta::new(None, link, IssueTimestamps::default())))
}
IssueSelector::Title(_) | IssueSelector::Regex(_) => None,
};
let children = v.children.iter().map(|(sel, child)| (*sel, hollow_from_virtual(child))).collect();
HollowIssue::new(remote, children)
}
#[test]
fn test_close_state_from_checkbox() {
let cases = [" ", "", "x", "X", "-", "123", "42", "invalid"];
let results: Vec<_> = cases.iter().map(|c| format!("{c:?} => {:?}", CloseState::from_checkbox(c))).collect();
insta::assert_snapshot!(results.join("\n"), @r#"
" " => Ok(Open)
"" => Ok(Open)
"x" => Ok(Closed)
"X" => Ok(Closed)
"-" => Ok(NotPlanned)
"123" => Ok(Duplicate(123))
"42" => Ok(Duplicate(42))
"invalid" => Err("invalid")
"#);
}
#[test]
fn test_close_state_to_checkbox() {
let cases = [CloseState::Open, CloseState::Closed, CloseState::NotPlanned, CloseState::Duplicate(123)];
let results: Vec<_> = cases.iter().map(|s| format!("{s:?} => {:?}", s.to_checkbox_contents())).collect();
insta::assert_snapshot!(results.join("\n"), @r#"
Open => " "
Closed => "x"
NotPlanned => "-"
Duplicate(123) => "123"
"#);
}
#[test]
fn test_close_state_is_closed() {
let cases = [CloseState::Open, CloseState::Closed, CloseState::NotPlanned, CloseState::Duplicate(123)];
let results: Vec<_> = cases.iter().map(|s| format!("{s:?} => {}", s.is_closed())).collect();
insta::assert_snapshot!(results.join("\n"), @"
Open => false
Closed => true
NotPlanned => true
Duplicate(123) => true
");
}
#[test]
fn test_close_state_should_remove() {
let cases = [CloseState::Open, CloseState::Closed, CloseState::NotPlanned, CloseState::Duplicate(123)];
let results: Vec<_> = cases.iter().map(|s| format!("{s:?} => {}", s.should_remove())).collect();
insta::assert_snapshot!(results.join("\n"), @"
Open => false
Closed => false
NotPlanned => false
Duplicate(123) => true
");
}
#[test]
fn test_close_state_to_github_state() {
let cases = [CloseState::Open, CloseState::Closed, CloseState::NotPlanned, CloseState::Duplicate(123)];
let results: Vec<_> = cases.iter().map(|s| format!("{s:?} => {}", s.to_github_state())).collect();
insta::assert_snapshot!(results.join("\n"), @"
Open => open
Closed => closed
NotPlanned => closed
Duplicate(123) => closed
");
}
#[test]
fn test_parse_invalid_checkbox_returns_error() {
let root_err = VirtualIssue::parse("- [abc] Invalid issue <!-- https://github.com/owner/repo/issues/123 -->\n\n Body\n", PathBuf::from("test.md")).unwrap_err();
let sub_err = VirtualIssue::parse(
"- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [xyz] Bad sub <!--sub https://github.com/owner/repo/issues/2 -->\n",
PathBuf::from("test.md"),
)
.unwrap_err();
insta::assert_snapshot!(format!("root: {root_err}\nsub: {sub_err}"), @r#"
root: tedi::parse::invalid_checkbox
× invalid checkbox content: 'abc'
â•─[test.md:1:1]
1 │ - [abc] Invalid issue <!-- https://github.com/owner/repo/issues/123 -->
· ───────────────────────────────────┬───────────────────────────────────
· ╰── unrecognized checkbox value
2 │
╰────
help: valid checkbox values are: ' ' (open), 'x' (closed), '-' (not
planned), or a number like '123' (duplicate of issue #123)
sub: tedi::parse::invalid_checkbox
× invalid checkbox content: 'xyz'
â•─[test.md:5:1]
4 │
5 │ - [xyz] Bad sub <!--sub https://github.com/owner/repo/issues/2 -->
· ──────────────────────────────────┬─────────────────────────────────
· ╰── unrecognized checkbox value
╰────
help: valid checkbox values are: ' ' (open), 'x' (closed), '-' (not
planned), or a number like '123' (duplicate of issue #123)
"#);
}
#[test]
fn test_parse_and_serialize_not_planned() {
let content = "- [-] Not planned issue <!-- https://github.com/owner/repo/issues/123 -->\n\n Body text\n";
let vi = VirtualIssue::parse(content, PathBuf::from("test.md")).unwrap();
insta::assert_snapshot!(format!("state: {:?}\ntitle: {}", vi.contents.state, vi.contents.title), @"
state: NotPlanned
title: Not planned issue
");
}
#[test]
fn test_parse_and_serialize_duplicate() {
let content = "- [456] Duplicate issue <!-- https://github.com/owner/repo/issues/123 -->\n\n Body text\n";
let vi = VirtualIssue::parse(content, PathBuf::from("test.md")).unwrap();
insta::assert_snapshot!(format!("state: {:?}\ntitle: {}", vi.contents.state, vi.contents.title), @"
state: Duplicate(456)
title: Duplicate issue
");
}
#[test]
fn test_parse_sub_issue_close_types() {
let content = "- [ ] Parent issue <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [x] Closed sub <!-- https://github.com/owner/repo/issues/2 --> <!--omitted {{{always-->\n\n closed body\n <!--,}}}-->\n\n - [-] Not planned sub <!-- https://github.com/owner/repo/issues/3 --> <!--omitted {{{always-->\n\n not planned body\n <!--,}}}-->\n\n - [42] Duplicate sub <!-- https://github.com/owner/repo/issues/4 --> <!--omitted {{{always-->\n\n duplicate body\n <!--,}}}-->\n";
let issue = unsafe_mock_parse_virtual(content);
insta::assert_snapshot!(issue.serialize_virtual(), @r"
- [ ] Parent issue <!-- https://github.com/owner/repo/issues/1 -->
Body
- [x] Closed sub <!-- https://github.com/owner/repo/issues/2 --> <!--omitted {{{always-->
closed body
<!--,}}}-->
- \[-] Not planned sub <!-- https://github.com/owner/repo/issues/3 --> <!--omitted {{{always-->
not planned body
<!--,}}}-->
- \[42] Duplicate sub <!-- https://github.com/owner/repo/issues/4 --> <!--omitted {{{always-->
duplicate body
<!--,}}}-->
");
}
#[test]
fn test_find_last_blocker_position_empty() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n";
let issue = unsafe_mock_parse_virtual(content);
assert!(issue.find_last_blocker_position().is_none());
}
#[test]
fn test_find_last_blocker_position_single_item() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n # Blockers\n - task 1\n";
let issue = unsafe_mock_parse_virtual(content);
insta::assert_snapshot!(format!("{:?}", issue.find_last_blocker_position()), @"Some((5, 5))");
}
#[test]
fn test_find_last_blocker_position_multiple_items() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n # Blockers\n - task 1\n - task 2\n - task 3\n";
let issue = unsafe_mock_parse_virtual(content);
insta::assert_snapshot!(format!("{:?}", issue.find_last_blocker_position()), @"Some((7, 5))");
}
#[test]
fn test_find_last_blocker_position_with_nesting() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n # Blockers\n - Phase 1\n - task a\n - Phase 2\n - task b\n";
let issue = unsafe_mock_parse_virtual(content);
insta::assert_snapshot!(format!("{:?}", issue.find_last_blocker_position()), @"Some((8, 7))");
}
#[test]
fn test_find_last_blocker_position_before_sub_issues() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n # Blockers\n - blocker task\n\n - [ ] Sub issue <!--sub https://github.com/owner/repo/issues/2 -->\n";
let issue = unsafe_mock_parse_virtual(content);
insta::assert_snapshot!(format!("{:?}", issue.find_last_blocker_position()), @"Some((5, 5))");
}
#[test]
fn test_serialize_filesystem_no_children() {
let content = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Parent body\n\n - [ ] Child 1 <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child 1 body\n\n - [ ] Child 2 <!--sub https://github.com/owner/repo/issues/3 -->\n\n Child 2 body\n";
let issue = unsafe_mock_parse_virtual(content);
assert_eq!(issue.children.len(), 2);
insta::assert_snapshot!(issue.serialize_filesystem(), @"
- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->
Parent body
");
}
#[test]
fn test_serialize_filesystem_roundtrip_blocker_escaping() {
let cases = vec![
"- [ ] Title <!-- https://github.com/owner/repo/issues/1 -->\n # Blockers\n - `insert` semantics on `RoutingHub`\n",
"- [ ] Title <!-- https://github.com/owner/repo/issues/1 -->\n # Blockers\n - `insert`semantics on`RoutingHub`\n",
"- [ ] Title <!-- https://github.com/owner/repo/issues/1 -->\n # Blockers\n - move clap interface into \\_strategy\n",
"- [ ] Title <!-- https://github.com/owner/repo/issues/1 -->\n # Blockers\n - some certainty\\*val range\n",
"- [ ] Title <!-- https://github.com/owner/repo/issues/1 -->\n # Blockers\n - text with ` lone backtick\n",
];
for initial_content in cases {
let issue = unsafe_mock_parse_virtual(initial_content);
let s1 = issue.serialize_filesystem();
for cycle in 1..=5 {
let re = unsafe_mock_parse_virtual(&s1);
let sn = re.serialize_filesystem();
assert_eq!(s1, sn, "serialize_filesystem not idempotent at cycle {cycle} for input: {initial_content:?}");
}
}
}
#[test]
fn test_serialize_virtual_includes_children() {
let content =
"- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Parent body\n\n - [ ] Child 1 <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child 1 body\n";
let issue = unsafe_mock_parse_virtual(content);
insta::assert_snapshot!(issue.serialize_virtual(), @"
- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->
Parent body
- [ ] Child 1 <!-- https://github.com/owner/repo/issues/2 -->
Child 1 body
");
}
#[test]
fn test_parse_virtual_includes_inline_children() {
let content = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Parent body\n\n - [ ] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let vi = VirtualIssue::parse(content, PathBuf::from("test.md")).unwrap();
insta::assert_snapshot!(
format!("children: {}\nparent: {}\nchild: {}", vi.children.len(), vi.contents.title, vi[2].contents.title),
@"
children: 1
parent: Parent
child: Child
"
);
}
#[test]
fn test_virtual_roundtrip() {
let content = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Parent body\n\n - [ ] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let issue = unsafe_mock_parse_virtual(content);
let serialized = issue.serialize_virtual();
let reparsed = unsafe_mock_parse_virtual(&serialized);
insta::assert_snapshot!(
format!("title: {}\nchildren: {}", reparsed.contents.title, reparsed.children.len()),
@"
title: Parent
children: 1
"
);
}
#[test]
fn test_select_blockers_standalone_line() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n !s\n # Blockers\n - task 1\n - task 2\n";
let vi = VirtualIssue::parse(content, PathBuf::from("test.md")).unwrap();
assert!(matches!(vi.contents.blockers.set_state, Some(crate::issue::BlockerSetState::Pending)));
insta::assert_snapshot!(unsafe_mock_parse_virtual(content).serialize_virtual(), @"
- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->
Body
# Blockers
- task 1
- task 2
");
}
#[test]
fn test_select_blockers_suffix_on_header() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n # Blockers !s\n - one\n";
let vi = VirtualIssue::parse(content, PathBuf::from("test.md")).unwrap();
assert!(matches!(vi.contents.blockers.set_state, Some(crate::issue::BlockerSetState::Pending)));
insta::assert_snapshot!(vi.contents.blockers.items[0].text, @"one");
}
#[test]
fn test_select_blockers_not_set_by_default() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n # Blockers\n - task\n";
let virtual_issue = VirtualIssue::parse(content, PathBuf::from("test.md")).unwrap();
assert!(virtual_issue.contents.blockers.set_state.is_none());
}
#[test]
fn test_select_blockers_case_insensitive() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n !S\n # Blockers\n - task\n";
let virtual_issue = VirtualIssue::parse(content, PathBuf::from("test.md")).unwrap();
assert!(matches!(virtual_issue.contents.blockers.set_state, Some(crate::issue::BlockerSetState::Pending)));
}
#[test]
fn test_serialize_github_body_only() {
let content = "- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->\n\n This is the body text.\n\n # Blockers\n - task 1\n - task 2\n";
let issue = unsafe_mock_parse_virtual(content);
let github_body: String = issue.render_github().into();
insta::assert_snapshot!(github_body, @"
This is the body text.
# Blockers
- task 1
- task 2
");
}
#[test]
fn test_parse_virtual_child_open_to_closed() {
let initial = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [ ] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let updated = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [x] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let initial_vi = VirtualIssue::parse(initial, PathBuf::from("test.md")).unwrap();
assert_eq!(initial_vi[2].contents.state, CloseState::Open);
let updated_vi = VirtualIssue::parse(updated, PathBuf::from("test.md")).unwrap();
insta::assert_snapshot!(format!("{:?}", updated_vi[2].contents.state), @"Closed");
}
#[test]
fn test_parse_virtual_child_open_to_not_planned() {
let initial = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [ ] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let updated = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [-] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let initial_vi = VirtualIssue::parse(initial, PathBuf::from("test.md")).unwrap();
assert_eq!(initial_vi[2].contents.state, CloseState::Open);
let updated_vi = VirtualIssue::parse(updated, PathBuf::from("test.md")).unwrap();
insta::assert_snapshot!(format!("{:?}", updated_vi[2].contents.state), @"NotPlanned");
}
#[test]
fn test_parse_virtual_child_open_to_duplicate() {
let initial = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [ ] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let updated = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [99] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let initial_vi = VirtualIssue::parse(initial, PathBuf::from("test.md")).unwrap();
assert_eq!(initial_vi[2].contents.state, CloseState::Open);
let updated_vi = VirtualIssue::parse(updated, PathBuf::from("test.md")).unwrap();
insta::assert_snapshot!(format!("{:?}", updated_vi[2].contents.state), @"Duplicate(99)");
}
#[test]
fn test_parse_virtual_child_closed_to_open() {
let initial = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [x] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let updated = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n Body\n\n - [ ] Child <!--sub https://github.com/owner/repo/issues/2 -->\n\n Child body\n";
let initial_vi = VirtualIssue::parse(initial, PathBuf::from("test.md")).unwrap();
assert_eq!(initial_vi[2].contents.state, CloseState::Closed);
let updated_vi = VirtualIssue::parse(updated, PathBuf::from("test.md")).unwrap();
insta::assert_snapshot!(format!("{:?}", updated_vi[2].contents.state), @"Open");
}
#[test]
fn test_serialize_roundtrip_idempotent() {
crate::current_user::set("mock_user".to_string());
let input = "- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->\n\n original body\n";
let issue = unsafe_mock_parse_virtual(input);
let s1 = issue.serialize_virtual();
let issue2 = unsafe_mock_parse_virtual(&s1);
let s2 = issue2.serialize_virtual();
assert_eq!(s1, s2, "serialize_virtual must be idempotent");
}
#[test]
fn test_serialize_roundtrip_custom_checkboxes_idempotent() {
crate::current_user::set("mock_user".to_string());
let input = "- [ ] Parent <!-- @mock_user https://github.com/o/r/issues/1 -->\n\n Body\n\n - [-] Not planned <!--sub @mock_user https://github.com/o/r/issues/2 -->\n\n np body\n\n - [42] Duplicate <!--sub @mock_user https://github.com/o/r/issues/3 -->\n\n dup body\n";
let issue = unsafe_mock_parse_virtual(input);
let s1 = issue.serialize_virtual();
for cycle in 1..=5 {
let re = unsafe_mock_parse_virtual(&s1);
let sn = re.serialize_virtual();
assert_eq!(s1, sn, "serialize_virtual must be idempotent at cycle {cycle}");
}
}
#[test]
fn test_parse_nested_subissues() {
let input = "- [ ] Grandparent <!-- @mock_user https://github.com/o/r/issues/1 -->\n\n grandparent body\n\n - [ ] Parent <!--sub @mock_user https://github.com/o/r/issues/2 -->\n\n original parent body\n\n - [ ] Child <!--sub @mock_user https://github.com/o/r/issues/3 -->\n\n child body\n";
let vi = VirtualIssue::parse(input, PathBuf::from("test.md")).unwrap();
eprintln!("parsed: title={}, children={}", vi.contents.title, vi.children.len());
}
#[test]
fn test_parse_blockers_and_child_at_same_indent() {
let input = "- [ ] Parent <!-- https://github.com/owner/repo/issues/2 -->\n \n body\n \n # Blockers\n - local blocker\n \n - [ ] Child <!--sub https://github.com/owner/repo/issues/3 -->\n \n child body\n";
let vi = VirtualIssue::parse(input, PathBuf::from("test.md")).unwrap();
assert_eq!(vi.contents.blockers.items.len(), 1, "should have 1 blocker");
assert_eq!(vi.children.len(), 1, "should have 1 child");
}
#[test]
fn test_blocker_section_does_not_absorb_checkbox_items() {
let input = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n body\n\n # Blockers\n - vector series\n - all the others\n - ex 10\n\n - [ ] series\n up to and including ex 8\n";
let vi = VirtualIssue::parse(input, PathBuf::from("test.md")).unwrap();
assert_eq!(vi.contents.blockers.items.len(), 1, "should have 1 blocker (vector series)");
assert_eq!(vi.contents.blockers.items[0].text, "vector series");
assert_eq!(vi.contents.blockers.items[0].children.len(), 2, "vector series has 2 children");
assert_eq!(vi.children.len(), 1, "should have 1 child issue (series)");
let child = vi.children.values().next().unwrap();
assert_eq!(child.contents.title, "series");
}
#[test]
fn test_blocker_section_terminates_on_empty_line_before_checkbox() {
let input = "- [ ] Parent <!-- https://github.com/owner/repo/issues/1 -->\n\n # Blockers\n - task A\n - task B\n\n - [ ] new child\n child body\n";
let vi = VirtualIssue::parse(input, PathBuf::from("test.md")).unwrap();
assert_eq!(vi.contents.blockers.items.len(), 2, "should have 2 blockers");
assert_eq!(vi.children.len(), 1, "checkbox item after empty line becomes child");
let child = vi.children.values().next().unwrap();
assert_eq!(child.contents.title, "new child");
}
#[test]
fn test_body_blank_lines_preserved_roundtrip() {
crate::current_user::set("mock_user".to_string());
let input = "- [ ] Issue <!-- @mock_user https://github.com/o/r/issues/1 -->\n\n some text\n\n ```rust\n fn main() {\n println!(\"hello\");\n }\n ```\n\n more text\n";
let issue = unsafe_mock_parse_virtual(input);
let serialized = issue.serialize_virtual();
insta::assert_snapshot!(serialized, @r#"
- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->
some text
````rust
fn main() {
println!("hello");
}
````
more text
"#);
}
#[test]
fn test_body_multiple_paragraphs_preserved_roundtrip() {
crate::current_user::set("mock_user".to_string());
let input = "- [ ] Issue <!-- @mock_user https://github.com/o/r/issues/1 -->\n\n first paragraph\n\n second paragraph\n\n third paragraph\n";
let issue = unsafe_mock_parse_virtual(input);
let serialized = issue.serialize_virtual();
insta::assert_snapshot!(serialized, @"
- [ ] Issue <!-- https://github.com/owner/repo/issues/1 -->
first paragraph
second paragraph
third paragraph
");
}
#[test]
fn test_body_blank_lines_idempotent() {
crate::current_user::set("mock_user".to_string());
let input = "- [ ] Issue <!-- @mock_user https://github.com/o/r/issues/1 -->\n\n some text\n\n ```rust\n fn main() {\n println!(\"hello\");\n }\n ```\n\n more text\n";
let issue = unsafe_mock_parse_virtual(input);
let s1 = issue.serialize_virtual();
let issue2 = unsafe_mock_parse_virtual(&s1);
let s2 = issue2.serialize_virtual();
assert_eq!(s1, s2, "body blank lines must be preserved idempotently");
}
}