use std::collections::{BTreeMap, HashSet};
use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{Result, TixError};
use crate::relationships::{IssueView, Relationship};
use crate::slug::slugify;
mod dates;
mod frontmatter;
mod history;
mod issue;
mod refs;
pub use dates::*;
pub use frontmatter::*;
pub use history::*;
pub use issue::*;
pub use refs::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Status(pub String);
impl Status {
pub fn new(name: impl Into<String>) -> Self {
Status(name.into())
}
pub fn is(&self, name: &str) -> bool {
self.0 == name
}
pub fn display(&self) -> &str {
&self.0
}
}
impl Default for Status {
fn default() -> Self {
Status("todo".to_string())
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub enum Priority {
Low,
#[default]
Medium,
High,
Other(String),
}
impl Priority {
fn as_str(&self) -> &str {
match self {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
Priority::Other(s) => s,
}
}
pub fn is_undefined(&self) -> bool {
matches!(self, Priority::Other(s) if s.is_empty())
}
}
impl FromStr for Priority {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s.to_ascii_lowercase().as_str() {
"low" => Priority::Low,
"medium" => Priority::Medium,
"high" => Priority::High,
_ => Priority::Other(s.to_string()),
})
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for Priority {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Priority {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let raw = String::deserialize(d)?;
match raw.parse() {
Ok(parsed) => Ok(parsed),
Err(_) => Ok(Priority::Other(raw)),
}
}
}
pub type Labels = Vec<String>;
fn de_assignee<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<String>, D::Error> {
let opt = Option::<String>::deserialize(d)?;
Ok(opt.map(|v| v.strip_prefix('@').unwrap_or(&v).to_string()))
}
fn se_assignee<S: Serializer>(opt: &Option<String>, s: S) -> std::result::Result<S::Ok, S::Error> {
match opt {
None => s.serialize_none(),
Some(value) => {
let is_identity = value.contains('<') && value.contains('>');
let full = if is_identity || value.starts_with('@') {
value.clone()
} else {
format!("@{value}")
};
s.serialize_str(&full)
}
}
}
fn skip_planning_list(v: &Option<Vec<String>>) -> bool {
v.as_deref().is_none_or(|items| items.is_empty())
}
fn se_planning_list<S: Serializer>(
v: &Option<Vec<String>>,
s: S,
) -> std::result::Result<S::Ok, S::Error> {
let items: Vec<&String> = match v {
Some(items) => {
items
.iter()
.fold((Vec::new(), HashSet::new()), |(mut out, mut seen), item| {
if seen.insert(item.as_str()) {
out.push(item);
}
(out, seen)
})
.0
}
None => Vec::new(),
};
items.serialize(s)
}
fn top_level_key(line: &str) -> Option<String> {
if !line.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
return None;
}
let colon = line.find(':')?;
let key = &line[..colon];
if key.is_empty()
|| !key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return None;
}
Some(key.to_string())
}
fn frontmatter_has_retired_links_key(frontmatter: &str) -> bool {
if let Ok(mapping) = serde_yaml::from_str::<serde_yaml::Mapping>(frontmatter) {
let key = serde_yaml::Value::String("links".into());
return match mapping.get(&key) {
Some(value) => !links_value_is_empty(value),
None => false,
};
}
frontmatter.lines().any(|line| {
if line.starts_with(' ') || line.starts_with('\t') {
return false;
}
line.split_once(':')
.map(|(key, _)| key.trim() == "links")
.unwrap_or(false)
})
}
fn links_value_is_empty(value: &serde_yaml::Value) -> bool {
match value {
serde_yaml::Value::Null => true,
serde_yaml::Value::Sequence(items) => items.is_empty(),
serde_yaml::Value::String(s) => s.trim().is_empty() || s.trim() == "[]",
serde_yaml::Value::Mapping(m) => m.is_empty(),
serde_yaml::Value::Tagged(t) => links_value_is_empty(&t.value),
_ => false,
}
}
fn days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
if leap { 29 } else { 28 }
}
_ => 0,
}
}
fn validate_date_msg(s: &str) -> std::result::Result<(), String> {
if validate_date(s) {
Ok(())
} else {
Err(format!(
"`{s}` is not a `YYYY-MM-DD` calendar date (year >= 1970)"
))
}
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
fn parse_comment_head(after: &str) -> (String, String) {
let (author, date) = match after.len() {
len if len >= 12 && after.as_bytes()[len - 1] == b')' => {
let close = len - 1;
let open = after[..close].rfind(" (");
match open {
Some(open) => {
let maybe_date = &after[open + 2..close];
if maybe_date.len() == 10
&& maybe_date.as_bytes()[4] == b'-'
&& maybe_date.as_bytes()[7] == b'-'
{
(&after[..open], maybe_date)
} else {
(after, "")
}
}
None => (after, ""),
}
}
_ => (after, ""),
};
(author.trim_end().to_string(), date.to_string())
}
const HISTORY_SEP: char = '\u{2014}';
const OVERRIDE_MARKER: &str = "); override: ";
const CATEGORY_OPEN: &str = " (";
const DETAIL_ARROW: &str = " -> ";
fn is_list_item(item: &str) -> bool {
for marker in ["- ", "* ", "+ "] {
if item.starts_with(marker) {
return true;
}
}
let Some(dot) = item.find(['.', ')']) else {
return false;
};
dot > 0
&& dot <= 2
&& item[..dot].bytes().all(|b| b.is_ascii_digit())
&& item[dot + 1..].starts_with(' ')
}
const STRICT_TAIL_FIELDS: usize = 3;
const RETENTION_TAIL_FIELDS: usize = 2;
fn is_entry_attempt(item: &str) -> bool {
let Some(rest) = strip_list_marker(item) else {
return false;
};
if rest.contains(HISTORY_SEP) {
return true;
}
entry_shaped_by_ascii_hyphens(rest, RETENTION_TAIL_FIELDS)
}
fn entry_shaped_by_ascii_hyphens(rest: &str, min_tail: usize) -> bool {
let Some(tail) = strip_leading_timestamp(rest) else {
return false;
};
let parts: Vec<&str> = tail
.split('-')
.map(str::trim)
.filter(|p| !p.is_empty())
.collect();
if parts.len() < min_tail {
return false;
}
!starts_like_a_date(parts[0])
}
fn starts_like_a_date(token: &str) -> bool {
let digits = token.bytes().take_while(u8::is_ascii_digit).count();
digits >= 4
}
fn strip_list_marker(line: &str) -> Option<&str> {
let item = line.trim();
if is_list_item(item) {
let space = item.find(char::is_whitespace).unwrap_or(item.len());
return Some(item[space..].trim_start());
}
None
}
fn strip_leading_timestamp(rest: &str) -> Option<&str> {
let b = rest.as_bytes();
if b.len() < 10
|| !b[0..4].iter().all(u8::is_ascii_digit)
|| b[4] != b'-'
|| !b[5..7].iter().all(u8::is_ascii_digit)
|| b[7] != b'-'
|| !b[8..10].iter().all(u8::is_ascii_digit)
{
return None;
}
let mut i = 10;
if b.get(i) == Some(&b'T') {
i += 1;
while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':' || b[i] == b'.') {
i += 1;
}
match b.get(i) {
Some(b'Z') => i += 1,
Some(&b'-') => {
let mut j = i + 1;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
if j > i + 1 {
i = j;
}
}
Some(&b'+') => {
i += 1;
while i < b.len() && (b[i].is_ascii_digit() || b[i] == b':') {
i += 1;
}
}
_ => {}
}
}
Some(rest[i.min(rest.len())..].trim_start_matches(['-', ' ', '\t', '.']))
}
#[cfg(test)]
mod tests;