use std::fmt;
use crate::{Header, IssueIdentity, IssueLink};
#[derive(Clone, Debug, PartialEq)]
pub enum IssueMarker {
Linked { user: Option<String>, link: IssueLink },
Pending,
Virtual,
}
impl IssueMarker {
pub fn decode(inner: &str) -> Self {
let s = inner.trim();
if s.is_empty() || s == "pending" {
return Self::Pending;
}
if s.starts_with("local:") {
return Self::Pending;
}
if s == "virtual" || s.starts_with("virtual:") {
return Self::Virtual;
}
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(link) = IssueLink::parse(url) {
return Self::Linked { user: Some(user), link };
}
}
if let Some(link) = IssueLink::parse(s) {
return Self::Linked { user: None, link };
}
Self::Pending
}
pub fn parse_from_end(s: &str) -> Option<(Self, &str)> {
let trimmed = s.trim_end();
if trimmed.ends_with("!n") || trimmed.ends_with("!N") {
let rest = trimmed[..trimmed.len() - 2].trim_end();
return Some((Self::Pending, rest));
}
if let Some(marker_end) = trimmed.rfind("-->")
&& let Some(marker_start) = trimmed[..marker_end].rfind("<!--")
{
let inner = trimmed[marker_start + 4..marker_end].trim();
let inner = match inner.strip_prefix("sub ") {
Some(stripped) => {
tracing::warn!("legacy `<!--sub ...-->` marker detected; use `<!-- ... -->` instead");
stripped
}
None => inner,
};
let marker = Self::decode(inner);
let rest = trimmed[..marker_start].trim_end();
return Some((marker, rest));
}
None
}
pub fn is_at_end(s: &str) -> bool {
Self::parse_from_end(s).is_some()
}
pub fn encode(&self) -> String {
match self {
Self::Linked { user: Some(user), link } => format!("@{user} {}", link.as_str()),
Self::Linked { user: None, link } => link.as_str().to_string(),
Self::Pending => "pending".to_string(),
Self::Virtual => "virtual".to_string(),
}
}
pub fn selector(&self, title: &str) -> super::IssueSelector {
match self {
Self::Linked { link, .. } => super::IssueSelector::GitId(link.number()),
Self::Pending | Self::Virtual => super::IssueSelector::title(title),
}
}
}
impl fmt::Display for IssueMarker {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<!-- {} -->", self.encode())
}
}
impl From<&IssueIdentity> for IssueMarker {
fn from(identity: &IssueIdentity) -> Self {
if let Some(meta) = identity.as_linked() {
IssueMarker::Linked {
user: meta.user.clone(),
link: meta.link().clone(),
}
} else if identity.is_virtual {
IssueMarker::Virtual
} else {
IssueMarker::Pending
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Marker {
Issue(IssueMarker),
Comment { user: String, url: String, id: u64 },
NewComment,
BlockersSection(Header),
OmittedStart,
OmittedEnd,
}
impl Marker {
pub fn decode(s: &str) -> Option<Self> {
let trimmed = s.trim();
if trimmed.eq_ignore_ascii_case("!c") {
return Some(Marker::NewComment);
}
if trimmed.eq_ignore_ascii_case("!b") {
return Some(Marker::BlockersSection(Header::new(1, "Blockers")));
}
if let Some(header) = Header::decode(trimmed) {
let content_lower = header.content.to_ascii_lowercase();
let content_trimmed = content_lower.trim_end_matches(':');
if content_trimmed == "blockers" || content_trimmed == "blocker" {
return Some(Marker::BlockersSection(header));
}
}
if !trimmed.starts_with("<!--") || !trimmed.ends_with("-->") {
return None;
}
let inner = trimmed.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
let lower = inner.to_ascii_lowercase();
if lower == "blockers" || lower == "blocker" {
return Some(Marker::BlockersSection(Header::new(1, "Blockers")));
}
if lower == "new comment" {
return Some(Marker::NewComment);
}
if lower.starts_with("omitted") && lower.contains("{{{") {
return Some(Marker::OmittedStart);
}
if lower.starts_with(",}}}") || lower == ",}}}" {
return Some(Marker::OmittedEnd);
}
if inner.contains("#issuecomment-") {
if let Some(rest) = inner.strip_prefix('@')
&& let Some(space_idx) = rest.find(' ')
{
let user = rest[..space_idx].to_string();
let url = rest[space_idx + 1..].trim();
let id = url.split("#issuecomment-").nth(1).and_then(|s| {
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
})?;
return Some(Marker::Comment { user, url: url.to_string(), id });
}
return None;
}
Some(Marker::Issue(IssueMarker::decode(inner)))
}
pub fn encode(&self) -> String {
match self {
Marker::Issue(issue) => issue.to_string(),
Marker::Comment { user, url, .. } => format!("<!-- @{user} {url} -->"),
Marker::NewComment => "<!-- new comment -->".to_string(),
Marker::BlockersSection(header) => header.encode(),
Marker::OmittedStart => "<!--omitted {{{always-->".to_string(),
Marker::OmittedEnd => "<!--,}}}-->".to_string(),
}
}
}
impl fmt::Display for Marker {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.encode())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issue_marker_decode_linked() {
let marker = IssueMarker::decode("@owner https://github.com/owner/repo/issues/123");
assert!(matches!(marker, IssueMarker::Linked { user: Some(ref user), ref link } if user == "owner" && link.number() == 123));
}
#[test]
fn test_issue_marker_decode_pending() {
assert_eq!(IssueMarker::decode(""), IssueMarker::Pending);
assert_eq!(IssueMarker::decode("pending"), IssueMarker::Pending);
assert_eq!(IssueMarker::decode("local:"), IssueMarker::Pending);
assert_eq!(IssueMarker::decode("local:anything"), IssueMarker::Pending);
assert_eq!(IssueMarker::decode("unrecognized stuff"), IssueMarker::Pending);
}
#[test]
fn test_issue_marker_decode_virtual() {
assert_eq!(IssueMarker::decode("virtual"), IssueMarker::Virtual);
assert_eq!(IssueMarker::decode("virtual:"), IssueMarker::Virtual);
}
#[test]
fn test_issue_marker_encode() {
assert_eq!(IssueMarker::Pending.encode(), "pending");
assert_eq!(IssueMarker::Virtual.encode(), "virtual");
let link = IssueLink::parse("https://github.com/owner/repo/issues/123").unwrap();
let linked = IssueMarker::Linked {
user: Some("owner".to_string()),
link,
};
assert_eq!(linked.encode(), "@owner https://github.com/owner/repo/issues/123");
}
#[test]
fn test_issue_marker_roundtrip() {
let link = IssueLink::parse("https://github.com/owner/repo/issues/123").unwrap();
let markers = vec![
IssueMarker::Pending,
IssueMarker::Virtual,
IssueMarker::Linked {
user: Some("owner".to_string()),
link,
},
];
for marker in markers {
let encoded = marker.encode();
let decoded = IssueMarker::decode(&encoded);
assert_eq!(marker, decoded, "Roundtrip failed for {marker:?}");
}
}
#[test]
fn test_decode_issue_marker_via_marker() {
let m = Marker::decode("<!-- @owner https://github.com/owner/repo/issues/123 -->");
assert!(matches!(m, Some(Marker::Issue(IssueMarker::Linked { .. }))));
let m = Marker::decode("<!-- pending -->");
assert!(matches!(m, Some(Marker::Issue(IssueMarker::Pending))));
let m = Marker::decode("<!-- virtual -->");
assert!(matches!(m, Some(Marker::Issue(IssueMarker::Virtual))));
let m = Marker::decode("<!-- local: -->");
assert!(matches!(m, Some(Marker::Issue(IssueMarker::Pending))));
}
#[test]
fn test_decode_comment() {
let m = Marker::decode("<!-- @owner https://github.com/owner/repo/issues/123#issuecomment-456 -->");
assert!(matches!(m, Some(Marker::Comment { user, id, .. }) if user == "owner" && id == 456));
}
#[test]
fn test_decode_blockers_section() {
fn is_blockers_section(marker: Option<Marker>) -> bool {
matches!(marker, Some(Marker::BlockersSection(_)))
}
assert!(is_blockers_section(Marker::decode("# Blockers")));
assert!(is_blockers_section(Marker::decode("## Blockers")));
assert!(is_blockers_section(Marker::decode("### Blockers:")));
assert!(is_blockers_section(Marker::decode(" # Blockers ")));
assert!(is_blockers_section(Marker::decode("<!--blockers-->")));
assert!(is_blockers_section(Marker::decode("<!-- blockers -->")));
assert!(is_blockers_section(Marker::decode("<!--blocker-->")));
assert!(is_blockers_section(Marker::decode("!b")));
assert!(is_blockers_section(Marker::decode("!B")));
assert!(is_blockers_section(Marker::decode(" !b ")));
assert!(!is_blockers_section(Marker::decode("# Blockers and more")));
assert!(!is_blockers_section(Marker::decode("Some text # Blockers")));
if let Some(Marker::BlockersSection(header)) = Marker::decode("## Blockers") {
assert_eq!(header.level, 2);
assert_eq!(header.content, "Blockers");
} else {
panic!("Expected BlockersSection with Header");
}
}
#[test]
fn test_decode_new_comment() {
assert_eq!(Marker::decode("<!--new comment-->"), Some(Marker::NewComment));
assert_eq!(Marker::decode("<!-- new comment -->"), Some(Marker::NewComment));
assert_eq!(Marker::decode("!c"), Some(Marker::NewComment));
assert_eq!(Marker::decode("!C"), Some(Marker::NewComment));
assert_eq!(Marker::decode(" !c "), Some(Marker::NewComment));
}
#[test]
fn test_decode_omitted() {
assert_eq!(Marker::decode("<!--omitted {{{always-->"), Some(Marker::OmittedStart));
assert_eq!(Marker::decode("<!-- omitted {{{always -->"), Some(Marker::OmittedStart));
assert_eq!(Marker::decode("<!--,}}}-->"), Some(Marker::OmittedEnd));
}
#[test]
fn test_encode() {
assert_eq!(Marker::Issue(IssueMarker::Pending).encode(), "<!-- pending -->");
assert_eq!(Marker::Issue(IssueMarker::Virtual).encode(), "<!-- virtual -->");
assert_eq!(Marker::BlockersSection(Header::new(1, "Blockers")).encode(), "# Blockers");
assert_eq!(Marker::BlockersSection(Header::new(2, "Blockers")).encode(), "## Blockers");
assert_eq!(Marker::NewComment.encode(), "<!-- new comment -->");
assert_eq!(Marker::OmittedStart.encode(), "<!--omitted {{{always-->");
assert_eq!(Marker::OmittedEnd.encode(), "<!--,}}}-->");
}
#[test]
fn test_roundtrip() {
let link = IssueLink::parse("https://github.com/owner/repo/issues/123").unwrap();
let markers = vec![
Marker::Issue(IssueMarker::Pending),
Marker::Issue(IssueMarker::Virtual),
Marker::Issue(IssueMarker::Linked {
user: Some("owner".to_string()),
link: link.clone(),
}),
Marker::Comment {
user: "owner".to_string(),
url: "https://github.com/owner/repo/issues/123#issuecomment-456".to_string(),
id: 456,
},
Marker::NewComment,
Marker::OmittedStart,
Marker::OmittedEnd,
];
for marker in markers {
let encoded = marker.encode();
let decoded = Marker::decode(&encoded).unwrap_or_else(|| panic!("Failed to decode: {encoded}"));
assert_eq!(marker, decoded, "Roundtrip failed for {marker:?}");
}
}
#[test]
fn test_issue_marker_parse_from_end() {
let (marker, title) = IssueMarker::parse_from_end("My title !n").unwrap();
assert_eq!(marker, IssueMarker::Pending);
assert_eq!(title, "My title");
let (marker, title) = IssueMarker::parse_from_end("My title !N").unwrap();
assert_eq!(marker, IssueMarker::Pending);
assert_eq!(title, "My title");
let (marker, title) = IssueMarker::parse_from_end("My title <!-- pending -->").unwrap();
assert_eq!(marker, IssueMarker::Pending);
assert_eq!(title, "My title");
let (marker, title) = IssueMarker::parse_from_end("My title <!-- virtual -->").unwrap();
assert_eq!(marker, IssueMarker::Virtual);
assert_eq!(title, "My title");
let (marker, title) = IssueMarker::parse_from_end("My title <!-- @owner https://github.com/owner/repo/issues/123 -->").unwrap();
assert!(matches!(marker, IssueMarker::Linked { user: Some(ref user), .. } if user == "owner"));
assert_eq!(title, "My title");
let (marker, title) = IssueMarker::parse_from_end("My title <!--sub @owner https://github.com/owner/repo/issues/123 -->").unwrap();
assert!(matches!(marker, IssueMarker::Linked { user: Some(ref user), .. } if user == "owner"));
assert_eq!(title, "My title");
assert!(IssueMarker::parse_from_end("My title").is_none());
assert!(IssueMarker::parse_from_end("My title with - [ ] checkbox").is_none());
}
}