Skip to main content

todoapp_core/
model.rs

1//! Entities, capability components, and value objects (spec §3, §7).
2//!
3//! Storage is one component per capability (spec §7): the durable `task` entity
4//! is just identity + timestamps, and each capability is a separate component
5//! whose *presence* means the task has it. [`TaskState`] is the in-memory
6//! *aggregate* — a task assembled from the components a caller projected (see
7//! [`crate::Projection`]) — and is what `decide`/`apply` operate on.
8
9use jiff::ToSpan;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13
14use crate::temporal::{Date, Due, Duration, Time};
15
16/// Stable identity for tasks, actors, collections. Opaque string (a random ULID
17/// in real adapters; a sequence in tests). Serializes transparently as that
18/// string.
19#[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    /// The invisible structural root (spec §7 virtual-root sentinel). Never a
30    /// `task` entity — only ever a `child` link `from`. The reserved string
31    /// can't collide with a 26-char base32 ULID.
32    pub fn root() -> Self {
33        Self("__root__".into())
34    }
35    pub fn is_root(&self) -> bool {
36        self.0 == "__root__"
37    }
38    /// Content-addressed id for a blob: same bytes ⇒ same id (cheap incidental
39    /// dedup, not a content-hash identity guarantee — collisions are possible
40    /// but not a practical concern at this scale). Shared by every `BlobStore`
41    /// adapter so they agree on ids for the same bytes.
42    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/// Required `Status` capability (spec §8). `blocked` is *derived*, not stored.
58/// Transitions between any two values are unrestricted (no guard) — `rank` is
59/// just for ordering/display, not a legality check.
60#[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    /// Position in the `draft→todo→wip→paused→done` chain, for ordering/display only.
72    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/// A human or agent. Not persisted via a port in M1 (the spec lists no
104/// `ActorRepository`); `Assignment`/`Claim` only ever reference an actor `Id`.
105#[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/// One assignee on a task; `claimed` flips when that actor claims it (§8).
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct Assignment {
115    pub actor: Id,
116    pub claimed: bool,
117}
118
119/// A capability component (spec §3): a unit of data keyed by task `Id` in the
120/// store. **Presence of the value *is* the capability** — there is no monolithic
121/// `Task` struct; a task is the set of components attached to its id, fetched and
122/// mutated one capability at a time (`store.get::<Status>(id)` /
123/// `store.set(id, Status::Wip)`). `NAME` keys the per-capability map/table
124/// (spec §7). Adding a capability = a new `Component` type; the generic store
125/// needs no change.
126///
127/// The in-memory store only needs `Clone + 'static` (typed `Box<dyn Any>`); the
128/// serde bounds are for durable stores that map a component to its row(s).
129///
130/// The `Serialize`/`DeserializeOwned` bound lets a store map a component
131/// generically to/from its row(s): the Turso adapter (M2) bridges each value
132/// through `serde_json::to_value`/`from_value` to its typed `c_*` column(s).
133pub trait Component: Clone + 'static + Serialize + serde::de::DeserializeOwned {
134    const NAME: &'static str;
135}
136
137/// Required `Title` capability.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct Title(pub String);
140impl Component for Title {
141    const NAME: &'static str = "title";
142}
143
144/// Required `Status` capability (the enum is the component value itself).
145impl Component for Status {
146    const NAME: &'static str = "status";
147}
148
149/// `Notes` capability: Markdown body.
150#[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/// `Schedule` capability: a due date, optionally with a time-of-day.
157#[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/// `Estimate` capability (effort estimate).
164#[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/// `TimeSpent` capability (accumulated time).
171#[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/// `TimeLog` capability: a per-day breakdown of time spent, keyed by calendar
178/// date. `TimeSpent` stays the fast-path cumulative total — it's recomputed
179/// as this map's sum whenever it's set (see the `Event::TimeLogSet` apply
180/// arm), so aggregation (FR-13) keeps reading `TimeSpent` unchanged. Mixing
181/// this with the plain `AddTimeSpent` command (no date) can leave the two
182/// slightly inconsistent — a known, accepted edge case, not guarded against.
183#[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/// `Tags` capability: the whole set is one component value (empty ⇒ remove it).
190#[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/// `Assignment` capability: the whole assignee list is one component value
197/// (empty ⇒ remove it). Its presence/contents drive `Claim` (spec §8).
198#[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/// One attachment: a `Link` never has a `blob` (it's just a URL); `File`/
213/// `Image` may or may not have one — `url` keeps the original source
214/// path/URL either way (e.g. from an import), `blob` is `Some` once actual
215/// bytes have been stored via [`crate::BlobStore`].
216#[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/// `Attachments` capability: the whole list is one component value (empty ⇒
227/// remove it), like `Tags`/`Assignments`.
228#[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/// `Archived` capability: an orthogonal flag, independent of `Status` (a task
235/// can be `done` and archived, or archived without being `done`) — presence
236/// *is* the flag, no payload needed. Hidden from default views by callers
237/// passing `Filter { archived: Some(false), .. }` (spec §13 Q4 direction);
238/// `QueryEngine`/`Filter` itself stay neutral (`None` = no restriction).
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240pub struct Archived;
241impl Component for Archived {
242    const NAME: &'static str = "archived";
243}
244
245/// `IssueRef` capability: a static reference to an external issue tracker's
246/// issue (e.g. imported from another tool). `provider` is freeform (no closed
247/// enum) — no live sync, no computed URL.
248#[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/// `Workspace` capability: binds a task (and, by ancestor lookup, its subtree)
259/// to a project folder/repo. `name` is the stable cross-machine identity;
260/// `path` is only the *default* local folder — per-machine overrides live in
261/// local config (keyed by `name`), never in the store, so a shared database
262/// stays portable.
263#[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/// A day of the week, for [`RepeatCycle::Weekly`]. A local enum (not jiff's)
273/// so serde stays as simple as [`Status`]'s.
274#[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    /// A weekday name, case-insensitive: `mon`..`sun` or `monday`..`sunday`.
300    /// Shared by `[...]` title syntax and any other free-text due-date input
301    /// (TUI edit form, CLI `--due`).
302    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/// A recurrence rule (spec §3): how often a [`Recurrence`]-carrying task's due
317/// date advances when it's completed (see `Recurrence::next_due`). Covers the
318/// common cases (daily interval, weekly weekday set, monthly same-day) — not a
319/// full RRULE engine.
320#[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/// `Recurrence` capability: a task carrying this **resets in place** on
329/// completion instead of staying `done` — spec decision: no per-occurrence
330/// task spawning, the same task's `Schedule` advances and its `Status` goes
331/// back to `todo` (see the `Event::StatusSet(Status::Done)` apply arm).
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct Recurrence {
334    pub cycle: RepeatCycle,
335    /// Time-of-day to carry onto the recomputed due date; falls back to the
336    /// current due's time if unset.
337    pub time: Option<Time>,
338}
339impl Component for Recurrence {
340    const NAME: &'static str = "recurrence";
341}
342
343impl Recurrence {
344    /// A minimal todoist/org-mode-style recurrence expression, mapped onto
345    /// [`RepeatCycle`]'s daily-interval/weekly-weekday-set/monthly-interval
346    /// cases — not a full RRULE engine:
347    /// `daily` | `every day` | `every N days`
348    /// `weekly` | `every week` | `every <weekday>[,<weekday>...]`
349    /// `monthly` | `every month` | `every N months`
350    ///
351    /// Shared by `[...]` title syntax, the CLI `--recurrence` flag, and any
352    /// other free-text recurrence input.
353    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    /// The next due date/time after `current`, per this rule.
391    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
420/// The next date after `from` whose weekday is in `weekdays` (or, if empty,
421/// just the next day — an under-specified rule still advances).
422fn 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/// An unresolved due-date value parsed from `[...]` title syntax (`FR-34`):
434/// pure and reference-date-agnostic, since `todoapp-core` has no `Clock`
435/// access (spec §5). [`DueSpec::resolve`] turns it into a concrete [`Due`]
436/// once a caller (`todoapp-app`, which holds a `Clock`) supplies "today".
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum DueSpec {
439    Absolute(Due),
440    TimeOnly(Time),
441    /// Next occurrence of this weekday, strictly after `today`.
442    Weekday(Weekday),
443}
444
445impl DueSpec {
446    /// Accepts anything [`Due::parse`] does (`YYYY-MM-DD[ HH:MM]`), a bare
447    /// `HH:MM` time, or a weekday name — the same relaxed grammar `[...]`
448    /// title syntax uses, shared with any other free-text due-date input
449    /// (TUI edit form, CLI `--due`).
450    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/// Fractional index (spec §7): insert between two neighbours by averaging, so a
488/// reorder or subtree move touches one row, never the siblings.
489#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
490pub struct Position(pub f64);
491
492impl Position {
493    /// A position strictly between `before` and `after` (either may be open).
494    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/// A typed, ordered directed edge. `child` is a single-parent tree; `blocks` is
505/// a DAG (invariants enforced in `todoapp-app`).
506#[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/// A saved tree or saved query (spec §7). `spec` holds the query for `query` kind.
522#[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// ---- Query model (spec §7 "Query model") ----------------------------------
531
532#[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    /// all-of (spec §13 default).
547    #[serde(default)]
548    pub tags: Vec<String>,
549    pub within: Option<Id>,
550    pub due: Option<DueFilter>,
551    pub claimed: Option<bool>,
552    /// `None` = no restriction (matches archived and non-archived alike);
553    /// hiding archived tasks by default is a caller-side choice, not a
554    /// query-engine special case.
555    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        // 2026-07-01 is a Wednesday; next Mon/Wed/Fri after it is Friday.
643        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    // 2026-07-01 is a Wednesday.
697    #[rstest]
698    #[case("2026-07-01", Weekday::Wed, "2026-07-08")] // today is Wed -> next Wed, a week out
699    #[case("2026-07-01", Weekday::Fri, "2026-07-03")] // later this week
700    #[case("2026-07-01", Weekday::Mon, "2026-07-06")] // earlier in the week -> wraps
701    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}