use jiff::ToSpan;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use crate::temporal::{Date, Due, Duration, Time};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Id(pub String);
impl Id {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn root() -> Self {
Self("__root__".into())
}
pub fn is_root(&self) -> bool {
self.0 == "__root__"
}
pub fn for_blob(bytes: &[u8]) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
bytes.hash(&mut h);
Self(format!("blob_{:016x}", h.finish()))
}
}
impl fmt::Display for Id {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Draft,
Todo,
Wip,
Paused,
Done,
}
impl Status {
pub fn rank(self) -> i8 {
match self {
Status::Draft => 0,
Status::Todo => 1,
Status::Wip => 2,
Status::Paused => 3,
Status::Done => 4,
}
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Status::Draft => "draft",
Status::Todo => "todo",
Status::Wip => "wip",
Status::Paused => "paused",
Status::Done => "done",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ActorKind {
Person,
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Actor {
pub id: Id,
pub kind: ActorKind,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Assignment {
pub actor: Id,
pub claimed: bool,
}
pub trait Component: Clone + 'static + Serialize + serde::de::DeserializeOwned {
const NAME: &'static str;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Title(pub String);
impl Component for Title {
const NAME: &'static str = "title";
}
impl Component for Status {
const NAME: &'static str = "status";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Notes(pub String);
impl Component for Notes {
const NAME: &'static str = "notes";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Schedule(pub Due);
impl Component for Schedule {
const NAME: &'static str = "schedule";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Estimate(pub Duration);
impl Component for Estimate {
const NAME: &'static str = "estimate";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimeSpent(pub Duration);
impl Component for TimeSpent {
const NAME: &'static str = "timespent";
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimeLog(pub BTreeMap<Date, Duration>);
impl Component for TimeLog {
const NAME: &'static str = "timelog";
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tags(pub BTreeSet<String>);
impl Component for Tags {
const NAME: &'static str = "tags";
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Assignments(pub Vec<Assignment>);
impl Component for Assignments {
const NAME: &'static str = "assignments";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AttachmentKind {
Link,
File,
Image,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attachment {
pub id: Id,
pub kind: AttachmentKind,
pub title: String,
pub url: Option<String>,
pub blob: Option<Id>,
pub mime: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attachments(pub Vec<Attachment>);
impl Component for Attachments {
const NAME: &'static str = "attachments";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Archived;
impl Component for Archived {
const NAME: &'static str = "archived";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IssueRef {
pub provider: String,
pub id: String,
pub url: Option<String>,
}
impl Component for IssueRef {
const NAME: &'static str = "issueref";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Workspace {
pub name: String,
pub path: Option<String>,
}
impl Component for Workspace {
const NAME: &'static str = "workspace";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Weekday {
Mon,
Tue,
Wed,
Thu,
Fri,
Sat,
Sun,
}
impl Weekday {
fn from_jiff(w: jiff::civil::Weekday) -> Self {
match w.to_monday_zero_offset() {
0 => Weekday::Mon,
1 => Weekday::Tue,
2 => Weekday::Wed,
3 => Weekday::Thu,
4 => Weekday::Fri,
5 => Weekday::Sat,
_ => Weekday::Sun,
}
}
pub fn parse(s: &str) -> Option<Self> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"mon" | "monday" => Weekday::Mon,
"tue" | "tuesday" => Weekday::Tue,
"wed" | "wednesday" => Weekday::Wed,
"thu" | "thursday" => Weekday::Thu,
"fri" | "friday" => Weekday::Fri,
"sat" | "saturday" => Weekday::Sat,
"sun" | "sunday" => Weekday::Sun,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RepeatCycle {
Daily { every_n_days: u32 },
Weekly { weekdays: BTreeSet<Weekday> },
Monthly { every_n_months: u32 },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recurrence {
pub cycle: RepeatCycle,
pub time: Option<Time>,
}
impl Component for Recurrence {
const NAME: &'static str = "recurrence";
}
impl Recurrence {
pub fn parse(s: &str) -> Result<Self, String> {
let lower = s.trim().to_ascii_lowercase();
let rest = lower.strip_prefix("every").map(str::trim).unwrap_or(&lower);
let invalid = || format!("unrecognized recurrence expression {s:?}");
let cycle = match rest {
"day" if lower.starts_with("every") => RepeatCycle::Daily { every_n_days: 1 },
"daily" => RepeatCycle::Daily { every_n_days: 1 },
"week" if lower.starts_with("every") => RepeatCycle::Weekly {
weekdays: BTreeSet::new(),
},
"weekly" => RepeatCycle::Weekly {
weekdays: BTreeSet::new(),
},
"month" if lower.starts_with("every") => RepeatCycle::Monthly { every_n_months: 1 },
"monthly" => RepeatCycle::Monthly { every_n_months: 1 },
_ if lower.starts_with("every") => {
if let Some(n_days) = rest.strip_suffix("days").map(str::trim_end) {
RepeatCycle::Daily {
every_n_days: n_days.parse().map_err(|_| invalid())?,
}
} else if let Some(n_months) = rest.strip_suffix("months").map(str::trim_end) {
RepeatCycle::Monthly {
every_n_months: n_months.parse().map_err(|_| invalid())?,
}
} else {
let weekdays: Option<BTreeSet<Weekday>> =
rest.split(',').map(|w| Weekday::parse(w.trim())).collect();
RepeatCycle::Weekly {
weekdays: weekdays.ok_or_else(invalid)?,
}
}
}
_ => return Err(invalid()),
};
Ok(Recurrence { cycle, time: None })
}
pub fn next_due(&self, current: Due) -> Due {
let date = match &self.cycle {
RepeatCycle::Daily { every_n_days } => {
let n = i64::from((*every_n_days).max(1));
current
.date
.0
.checked_add(n.days())
.map(Date)
.unwrap_or(current.date)
}
RepeatCycle::Weekly { weekdays } => next_weekday(current.date, weekdays),
RepeatCycle::Monthly { every_n_months } => {
let n = i64::from((*every_n_months).max(1));
current
.date
.0
.checked_add(n.months())
.map(Date)
.unwrap_or(current.date)
}
};
Due {
date,
time: self.time.or(current.time),
}
}
}
fn next_weekday(from: Date, weekdays: &BTreeSet<Weekday>) -> Date {
let mut d = from.0;
for _ in 0..7 {
d = d.checked_add(1.day()).unwrap_or(d);
if weekdays.is_empty() || weekdays.contains(&Weekday::from_jiff(d.weekday())) {
return Date(d);
}
}
from
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DueSpec {
Absolute(Due),
TimeOnly(Time),
Weekday(Weekday),
}
impl DueSpec {
pub fn parse(s: &str) -> Result<Self, String> {
let s = s.trim();
if let Ok(due) = Due::parse(s) {
return Ok(DueSpec::Absolute(due));
}
if let Ok(time) = Time::parse(s) {
return Ok(DueSpec::TimeOnly(time));
}
Weekday::parse(s).map(DueSpec::Weekday).ok_or_else(|| {
format!(
"expected a date (YYYY-MM-DD[ HH:MM]), a time (HH:MM), or a weekday name, got {s:?}"
)
})
}
pub fn resolve(&self, today: Date) -> Due {
match self {
DueSpec::Absolute(due) => *due,
DueSpec::TimeOnly(time) => Due {
date: today,
time: Some(*time),
},
DueSpec::Weekday(weekday) => Due {
date: next_weekday(today, &BTreeSet::from([*weekday])),
time: None,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LinkKind {
Child,
Blocks,
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct Position(pub f64);
impl Position {
pub fn between(before: Option<f64>, after: Option<f64>) -> f64 {
match (before, after) {
(None, None) => 0.0,
(Some(b), None) => b + 1.0,
(None, Some(a)) => a - 1.0,
(Some(b), Some(a)) => (b + a) / 2.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Link {
pub from: Id,
pub to: Id,
pub kind: LinkKind,
pub position: Position,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CollectionKind {
Tree,
Query,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Collection {
pub id: Id,
pub name: String,
pub kind: CollectionKind,
pub spec: Option<Query>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Query {
#[serde(default)]
pub filter: Filter,
#[serde(default)]
pub sort: Vec<SortKey>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Filter {
pub text: Option<String>,
#[serde(default)]
pub status: Vec<Status>,
pub assignee: Option<Id>,
#[serde(default)]
pub tags: Vec<String>,
pub within: Option<Id>,
pub due: Option<DueFilter>,
pub claimed: Option<bool>,
pub archived: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DueFilter {
Today,
Overdue,
Before(Date),
On(Date),
After(Date),
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortField {
Priority,
Due,
Created,
Updated,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Dir {
Asc,
Desc,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SortKey {
pub key: SortField,
pub dir: Dir,
}
#[cfg(test)]
mod recurrence_tests {
use rstest::rstest;
use super::*;
fn due(s: &str) -> Due {
Due::parse(s).unwrap()
}
#[rstest]
#[case("daily", RepeatCycle::Daily { every_n_days: 1 })]
#[case("every day", RepeatCycle::Daily { every_n_days: 1 })]
#[case("every 3 days", RepeatCycle::Daily { every_n_days: 3 })]
#[case("weekly", RepeatCycle::Weekly { weekdays: BTreeSet::new() })]
#[case("every week", RepeatCycle::Weekly { weekdays: BTreeSet::new() })]
#[case(
"every mon,wed,fri",
RepeatCycle::Weekly { weekdays: BTreeSet::from([Weekday::Mon, Weekday::Wed, Weekday::Fri]) }
)]
#[case("monthly", RepeatCycle::Monthly { every_n_months: 1 })]
#[case("every month", RepeatCycle::Monthly { every_n_months: 1 })]
#[case("every 2 months", RepeatCycle::Monthly { every_n_months: 2 })]
fn parse_accepts_every_grammar_form(#[case] input: &str, #[case] expected_cycle: RepeatCycle) {
assert_eq!(
Recurrence::parse(input),
Ok(Recurrence {
cycle: expected_cycle,
time: None
})
);
}
#[rstest]
#[case("every")]
#[case("every fortnight")]
#[case("every mon,someday")]
#[case("hourly")]
fn parse_rejects_invalid_expressions(#[case] input: &str) {
assert!(Recurrence::parse(input).is_err());
}
#[test]
fn daily_advances_by_n_days() {
let rec = Recurrence {
cycle: RepeatCycle::Daily { every_n_days: 3 },
time: None,
};
assert_eq!(rec.next_due(due("2026-07-01")).date, due("2026-07-04").date);
}
#[test]
fn weekly_finds_next_matching_weekday() {
let rec = Recurrence {
cycle: RepeatCycle::Weekly {
weekdays: BTreeSet::from([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
},
time: None,
};
assert_eq!(rec.next_due(due("2026-07-01")).date, due("2026-07-03").date);
}
#[test]
fn monthly_advances_by_n_months_same_day() {
let rec = Recurrence {
cycle: RepeatCycle::Monthly { every_n_months: 1 },
time: None,
};
assert_eq!(rec.next_due(due("2026-07-15")).date, due("2026-08-15").date);
}
#[test]
fn recurrence_time_wins_over_carried_time() {
let rec = Recurrence {
cycle: RepeatCycle::Daily { every_n_days: 1 },
time: Some(Time::parse("09:00").unwrap()),
};
let next = rec.next_due(due("2026-07-01 18:00"));
assert_eq!(next.time, Some(Time::parse("09:00").unwrap()));
}
}
#[cfg(test)]
mod due_spec_tests {
use rstest::rstest;
use super::*;
fn date(s: &str) -> Date {
Date::parse(s).unwrap()
}
#[test]
fn absolute_passes_through_unchanged() {
let due = Due::parse("2026-08-01 09:00").unwrap();
assert_eq!(DueSpec::Absolute(due).resolve(date("2026-07-01")), due);
}
#[test]
fn time_only_resolves_against_today() {
let time = Time::parse("14:30").unwrap();
let resolved = DueSpec::TimeOnly(time).resolve(date("2026-07-01"));
assert_eq!(resolved.date, date("2026-07-01"));
assert_eq!(resolved.time, Some(time));
}
#[rstest]
#[case("2026-07-01", Weekday::Wed, "2026-07-08")] #[case("2026-07-01", Weekday::Fri, "2026-07-03")] #[case("2026-07-01", Weekday::Mon, "2026-07-06")] fn weekday_resolves_to_next_occurrence_strictly_after_today(
#[case] today: &str,
#[case] weekday: Weekday,
#[case] expected: &str,
) {
let resolved = DueSpec::Weekday(weekday).resolve(date(today));
assert_eq!(resolved.date, date(expected));
assert_eq!(resolved.time, None);
}
#[rstest]
#[case("2026-07-20", DueSpec::Absolute(Due::parse("2026-07-20").unwrap()))]
#[case(
"2026-07-20 09:00",
DueSpec::Absolute(Due::parse("2026-07-20 09:00").unwrap())
)]
#[case("09:00", DueSpec::TimeOnly(Time::parse("09:00").unwrap()))]
#[case("fri", DueSpec::Weekday(Weekday::Fri))]
#[case("Friday", DueSpec::Weekday(Weekday::Fri))]
fn parse_accepts_every_grammar_form(#[case] input: &str, #[case] expected: DueSpec) {
assert_eq!(DueSpec::parse(input), Ok(expected));
}
#[test]
fn parse_rejects_nonsense() {
assert!(DueSpec::parse("not a date").is_err());
}
}