1use jiff::ToSpan;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13
14use crate::temporal::{Date, Due, Duration, Time};
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20pub struct Id(pub String);
21
22impl Id {
23 pub fn new(s: impl Into<String>) -> Self {
24 Self(s.into())
25 }
26 pub fn as_str(&self) -> &str {
27 &self.0
28 }
29 pub fn root() -> Self {
33 Self("__root__".into())
34 }
35 pub fn is_root(&self) -> bool {
36 self.0 == "__root__"
37 }
38 pub fn for_blob(bytes: &[u8]) -> Self {
43 use std::collections::hash_map::DefaultHasher;
44 use std::hash::{Hash, Hasher};
45 let mut h = DefaultHasher::new();
46 bytes.hash(&mut h);
47 Self(format!("blob_{:016x}", h.finish()))
48 }
49}
50
51impl fmt::Display for Id {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 f.write_str(&self.0)
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Status {
63 Draft,
64 Todo,
65 Wip,
66 Paused,
67 Done,
68}
69
70impl Status {
71 pub fn rank(self) -> i8 {
73 match self {
74 Status::Draft => 0,
75 Status::Todo => 1,
76 Status::Wip => 2,
77 Status::Paused => 3,
78 Status::Done => 4,
79 }
80 }
81}
82
83impl fmt::Display for Status {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 let s = match self {
86 Status::Draft => "draft",
87 Status::Todo => "todo",
88 Status::Wip => "wip",
89 Status::Paused => "paused",
90 Status::Done => "done",
91 };
92 f.write_str(s)
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "lowercase")]
98pub enum ActorKind {
99 Person,
100 Agent,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct Actor {
107 pub id: Id,
108 pub kind: ActorKind,
109 pub name: String,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct Assignment {
115 pub actor: Id,
116 pub claimed: bool,
117}
118
119pub trait Component: Clone + 'static + Serialize + serde::de::DeserializeOwned {
134 const NAME: &'static str;
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct Title(pub String);
140impl Component for Title {
141 const NAME: &'static str = "title";
142}
143
144impl Component for Status {
146 const NAME: &'static str = "status";
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct Notes(pub String);
152impl Component for Notes {
153 const NAME: &'static str = "notes";
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158pub struct Schedule(pub Due);
159impl Component for Schedule {
160 const NAME: &'static str = "schedule";
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165pub struct Estimate(pub Duration);
166impl Component for Estimate {
167 const NAME: &'static str = "estimate";
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172pub struct TimeSpent(pub Duration);
173impl Component for TimeSpent {
174 const NAME: &'static str = "timespent";
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
184pub struct TimeLog(pub BTreeMap<Date, Duration>);
185impl Component for TimeLog {
186 const NAME: &'static str = "timelog";
187}
188
189#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Tags(pub BTreeSet<String>);
192impl Component for Tags {
193 const NAME: &'static str = "tags";
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
199pub struct Assignments(pub Vec<Assignment>);
200impl Component for Assignments {
201 const NAME: &'static str = "assignments";
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "lowercase")]
206pub enum AttachmentKind {
207 Link,
208 File,
209 Image,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct Attachment {
218 pub id: Id,
219 pub kind: AttachmentKind,
220 pub title: String,
221 pub url: Option<String>,
222 pub blob: Option<Id>,
223 pub mime: Option<String>,
224}
225
226#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct Attachments(pub Vec<Attachment>);
230impl Component for Attachments {
231 const NAME: &'static str = "attachments";
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240pub struct Archived;
241impl Component for Archived {
242 const NAME: &'static str = "archived";
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct IssueRef {
250 pub provider: String,
251 pub id: String,
252 pub url: Option<String>,
253}
254impl Component for IssueRef {
255 const NAME: &'static str = "issueref";
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct Workspace {
265 pub name: String,
266 pub path: Option<String>,
267}
268impl Component for Workspace {
269 const NAME: &'static str = "workspace";
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
275#[serde(rename_all = "lowercase")]
276pub enum Weekday {
277 Mon,
278 Tue,
279 Wed,
280 Thu,
281 Fri,
282 Sat,
283 Sun,
284}
285
286impl Weekday {
287 fn from_jiff(w: jiff::civil::Weekday) -> Self {
288 match w.to_monday_zero_offset() {
289 0 => Weekday::Mon,
290 1 => Weekday::Tue,
291 2 => Weekday::Wed,
292 3 => Weekday::Thu,
293 4 => Weekday::Fri,
294 5 => Weekday::Sat,
295 _ => Weekday::Sun,
296 }
297 }
298
299 pub fn parse(s: &str) -> Option<Self> {
303 Some(match s.trim().to_ascii_lowercase().as_str() {
304 "mon" | "monday" => Weekday::Mon,
305 "tue" | "tuesday" => Weekday::Tue,
306 "wed" | "wednesday" => Weekday::Wed,
307 "thu" | "thursday" => Weekday::Thu,
308 "fri" | "friday" => Weekday::Fri,
309 "sat" | "saturday" => Weekday::Sat,
310 "sun" | "sunday" => Weekday::Sun,
311 _ => return None,
312 })
313 }
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "lowercase")]
322pub enum RepeatCycle {
323 Daily { every_n_days: u32 },
324 Weekly { weekdays: BTreeSet<Weekday> },
325 Monthly { every_n_months: u32 },
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct Recurrence {
334 pub cycle: RepeatCycle,
335 pub time: Option<Time>,
338}
339impl Component for Recurrence {
340 const NAME: &'static str = "recurrence";
341}
342
343impl Recurrence {
344 pub fn parse(s: &str) -> Result<Self, String> {
354 let lower = s.trim().to_ascii_lowercase();
355 let rest = lower.strip_prefix("every").map(str::trim).unwrap_or(&lower);
356 let invalid = || format!("unrecognized recurrence expression {s:?}");
357 let cycle = match rest {
358 "day" if lower.starts_with("every") => RepeatCycle::Daily { every_n_days: 1 },
359 "daily" => RepeatCycle::Daily { every_n_days: 1 },
360 "week" if lower.starts_with("every") => RepeatCycle::Weekly {
361 weekdays: BTreeSet::new(),
362 },
363 "weekly" => RepeatCycle::Weekly {
364 weekdays: BTreeSet::new(),
365 },
366 "month" if lower.starts_with("every") => RepeatCycle::Monthly { every_n_months: 1 },
367 "monthly" => RepeatCycle::Monthly { every_n_months: 1 },
368 _ if lower.starts_with("every") => {
369 if let Some(n_days) = rest.strip_suffix("days").map(str::trim_end) {
370 RepeatCycle::Daily {
371 every_n_days: n_days.parse().map_err(|_| invalid())?,
372 }
373 } else if let Some(n_months) = rest.strip_suffix("months").map(str::trim_end) {
374 RepeatCycle::Monthly {
375 every_n_months: n_months.parse().map_err(|_| invalid())?,
376 }
377 } else {
378 let weekdays: Option<BTreeSet<Weekday>> =
379 rest.split(',').map(|w| Weekday::parse(w.trim())).collect();
380 RepeatCycle::Weekly {
381 weekdays: weekdays.ok_or_else(invalid)?,
382 }
383 }
384 }
385 _ => return Err(invalid()),
386 };
387 Ok(Recurrence { cycle, time: None })
388 }
389
390 pub fn next_due(&self, current: Due) -> Due {
392 let date = match &self.cycle {
393 RepeatCycle::Daily { every_n_days } => {
394 let n = i64::from((*every_n_days).max(1));
395 current
396 .date
397 .0
398 .checked_add(n.days())
399 .map(Date)
400 .unwrap_or(current.date)
401 }
402 RepeatCycle::Weekly { weekdays } => next_weekday(current.date, weekdays),
403 RepeatCycle::Monthly { every_n_months } => {
404 let n = i64::from((*every_n_months).max(1));
405 current
406 .date
407 .0
408 .checked_add(n.months())
409 .map(Date)
410 .unwrap_or(current.date)
411 }
412 };
413 Due {
414 date,
415 time: self.time.or(current.time),
416 }
417 }
418}
419
420fn next_weekday(from: Date, weekdays: &BTreeSet<Weekday>) -> Date {
423 let mut d = from.0;
424 for _ in 0..7 {
425 d = d.checked_add(1.day()).unwrap_or(d);
426 if weekdays.is_empty() || weekdays.contains(&Weekday::from_jiff(d.weekday())) {
427 return Date(d);
428 }
429 }
430 from
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum DueSpec {
439 Absolute(Due),
440 TimeOnly(Time),
441 Weekday(Weekday),
443}
444
445impl DueSpec {
446 pub fn parse(s: &str) -> Result<Self, String> {
451 let s = s.trim();
452 if let Ok(due) = Due::parse(s) {
453 return Ok(DueSpec::Absolute(due));
454 }
455 if let Ok(time) = Time::parse(s) {
456 return Ok(DueSpec::TimeOnly(time));
457 }
458 Weekday::parse(s).map(DueSpec::Weekday).ok_or_else(|| {
459 format!(
460 "expected a date (YYYY-MM-DD[ HH:MM]), a time (HH:MM), or a weekday name, got {s:?}"
461 )
462 })
463 }
464
465 pub fn resolve(&self, today: Date) -> Due {
466 match self {
467 DueSpec::Absolute(due) => *due,
468 DueSpec::TimeOnly(time) => Due {
469 date: today,
470 time: Some(*time),
471 },
472 DueSpec::Weekday(weekday) => Due {
473 date: next_weekday(today, &BTreeSet::from([*weekday])),
474 time: None,
475 },
476 }
477 }
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
481#[serde(rename_all = "lowercase")]
482pub enum LinkKind {
483 Child,
484 Blocks,
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
490pub struct Position(pub f64);
491
492impl Position {
493 pub fn between(before: Option<f64>, after: Option<f64>) -> f64 {
495 match (before, after) {
496 (None, None) => 0.0,
497 (Some(b), None) => b + 1.0,
498 (None, Some(a)) => a - 1.0,
499 (Some(b), Some(a)) => (b + a) / 2.0,
500 }
501 }
502}
503
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
507pub struct Link {
508 pub from: Id,
509 pub to: Id,
510 pub kind: LinkKind,
511 pub position: Position,
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
515#[serde(rename_all = "lowercase")]
516pub enum CollectionKind {
517 Tree,
518 Query,
519}
520
521#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
523pub struct Collection {
524 pub id: Id,
525 pub name: String,
526 pub kind: CollectionKind,
527 pub spec: Option<Query>,
528}
529
530#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
533pub struct Query {
534 #[serde(default)]
535 pub filter: Filter,
536 #[serde(default)]
537 pub sort: Vec<SortKey>,
538}
539
540#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
541pub struct Filter {
542 pub text: Option<String>,
543 #[serde(default)]
544 pub status: Vec<Status>,
545 pub assignee: Option<Id>,
546 #[serde(default)]
548 pub tags: Vec<String>,
549 pub within: Option<Id>,
550 pub due: Option<DueFilter>,
551 pub claimed: Option<bool>,
552 pub archived: Option<bool>,
556}
557
558#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
559pub enum DueFilter {
560 Today,
561 Overdue,
562 Before(Date),
563 On(Date),
564 After(Date),
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
568#[serde(rename_all = "lowercase")]
569pub enum SortField {
570 Priority,
571 Due,
572 Created,
573 Updated,
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
577#[serde(rename_all = "lowercase")]
578pub enum Dir {
579 Asc,
580 Desc,
581}
582
583#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
584pub struct SortKey {
585 pub key: SortField,
586 pub dir: Dir,
587}
588
589#[cfg(test)]
590mod recurrence_tests {
591 use rstest::rstest;
592
593 use super::*;
594
595 fn due(s: &str) -> Due {
596 Due::parse(s).unwrap()
597 }
598
599 #[rstest]
600 #[case("daily", RepeatCycle::Daily { every_n_days: 1 })]
601 #[case("every day", RepeatCycle::Daily { every_n_days: 1 })]
602 #[case("every 3 days", RepeatCycle::Daily { every_n_days: 3 })]
603 #[case("weekly", RepeatCycle::Weekly { weekdays: BTreeSet::new() })]
604 #[case("every week", RepeatCycle::Weekly { weekdays: BTreeSet::new() })]
605 #[case(
606 "every mon,wed,fri",
607 RepeatCycle::Weekly { weekdays: BTreeSet::from([Weekday::Mon, Weekday::Wed, Weekday::Fri]) }
608 )]
609 #[case("monthly", RepeatCycle::Monthly { every_n_months: 1 })]
610 #[case("every month", RepeatCycle::Monthly { every_n_months: 1 })]
611 #[case("every 2 months", RepeatCycle::Monthly { every_n_months: 2 })]
612 fn parse_accepts_every_grammar_form(#[case] input: &str, #[case] expected_cycle: RepeatCycle) {
613 assert_eq!(
614 Recurrence::parse(input),
615 Ok(Recurrence {
616 cycle: expected_cycle,
617 time: None
618 })
619 );
620 }
621
622 #[rstest]
623 #[case("every")]
624 #[case("every fortnight")]
625 #[case("every mon,someday")]
626 #[case("hourly")]
627 fn parse_rejects_invalid_expressions(#[case] input: &str) {
628 assert!(Recurrence::parse(input).is_err());
629 }
630
631 #[test]
632 fn daily_advances_by_n_days() {
633 let rec = Recurrence {
634 cycle: RepeatCycle::Daily { every_n_days: 3 },
635 time: None,
636 };
637 assert_eq!(rec.next_due(due("2026-07-01")).date, due("2026-07-04").date);
638 }
639
640 #[test]
641 fn weekly_finds_next_matching_weekday() {
642 let rec = Recurrence {
644 cycle: RepeatCycle::Weekly {
645 weekdays: BTreeSet::from([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
646 },
647 time: None,
648 };
649 assert_eq!(rec.next_due(due("2026-07-01")).date, due("2026-07-03").date);
650 }
651
652 #[test]
653 fn monthly_advances_by_n_months_same_day() {
654 let rec = Recurrence {
655 cycle: RepeatCycle::Monthly { every_n_months: 1 },
656 time: None,
657 };
658 assert_eq!(rec.next_due(due("2026-07-15")).date, due("2026-08-15").date);
659 }
660
661 #[test]
662 fn recurrence_time_wins_over_carried_time() {
663 let rec = Recurrence {
664 cycle: RepeatCycle::Daily { every_n_days: 1 },
665 time: Some(Time::parse("09:00").unwrap()),
666 };
667 let next = rec.next_due(due("2026-07-01 18:00"));
668 assert_eq!(next.time, Some(Time::parse("09:00").unwrap()));
669 }
670}
671
672#[cfg(test)]
673mod due_spec_tests {
674 use rstest::rstest;
675
676 use super::*;
677
678 fn date(s: &str) -> Date {
679 Date::parse(s).unwrap()
680 }
681
682 #[test]
683 fn absolute_passes_through_unchanged() {
684 let due = Due::parse("2026-08-01 09:00").unwrap();
685 assert_eq!(DueSpec::Absolute(due).resolve(date("2026-07-01")), due);
686 }
687
688 #[test]
689 fn time_only_resolves_against_today() {
690 let time = Time::parse("14:30").unwrap();
691 let resolved = DueSpec::TimeOnly(time).resolve(date("2026-07-01"));
692 assert_eq!(resolved.date, date("2026-07-01"));
693 assert_eq!(resolved.time, Some(time));
694 }
695
696 #[rstest]
698 #[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(
702 #[case] today: &str,
703 #[case] weekday: Weekday,
704 #[case] expected: &str,
705 ) {
706 let resolved = DueSpec::Weekday(weekday).resolve(date(today));
707 assert_eq!(resolved.date, date(expected));
708 assert_eq!(resolved.time, None);
709 }
710
711 #[rstest]
712 #[case("2026-07-20", DueSpec::Absolute(Due::parse("2026-07-20").unwrap()))]
713 #[case(
714 "2026-07-20 09:00",
715 DueSpec::Absolute(Due::parse("2026-07-20 09:00").unwrap())
716 )]
717 #[case("09:00", DueSpec::TimeOnly(Time::parse("09:00").unwrap()))]
718 #[case("fri", DueSpec::Weekday(Weekday::Fri))]
719 #[case("Friday", DueSpec::Weekday(Weekday::Fri))]
720 fn parse_accepts_every_grammar_form(#[case] input: &str, #[case] expected: DueSpec) {
721 assert_eq!(DueSpec::parse(input), Ok(expected));
722 }
723
724 #[test]
725 fn parse_rejects_nonsense() {
726 assert!(DueSpec::parse("not a date").is_err());
727 }
728}