Skip to main content

sioul_core/
blocks.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Time blocks: a task pinned to a time is an event in a calendar, tied to the
5//! task both ways (docs/tasks.md, "Pinned to a time"). An event shows in every
6//! calendar application, a phone's too, and moves there like any other; a line
7//! of the task's own would be Sioul's alone, and a time on the task's start
8//! breaks when its date asked is a date (RFC 5545 §3.8.2.3: servers that
9//! check, Sabre for Nextcloud, refuse the task).
10//!
11//! - **Written**: the task's title, kept in step; its start and end in UTC;
12//!   busy (`TRANSP:OPAQUE`); `X-SIOUL-TASK:<the task's UID>` and an RFC 9253
13//!   link to it (`LINK;LINKREL="…/block-of";VALUE=UID`). The task links back
14//!   (`…/time-block`), to its current block only: a task that comes back every
15//!   week would gather a link a week. No alarm unless Tasks ⚙ asks for one:
16//!   Sioul reminds of a block as of any event, and a phone would remind twice.
17//! - **Read**: the block of a task known here is the task's time, never an
18//!   event of its own to the plan, the day or the record (`without_blocks`). A
19//!   task is pinned to its first block still to come (`Blocks::of`); over, a
20//!   block pins nothing and stays as it was, nothing said of it. A block whose
21//!   task is not known here (not come yet, deleted elsewhere) is an event like
22//!   any other.
23//! - **A repeating task**: a block pins one turn (`X-SIOUL-TURN`, that turn's
24//!   date when it was pinned); once that turn is done, it pins no other.
25//! - **Set right** (`upkeep`), only when something differs: a block not begun
26//!   of a task done or dropped goes, its time freed; one under way ends then;
27//!   one over stays, the record of when the work was done. One not begun of a
28//!   turn done elsewhere goes. One to come takes its task's title again.
29
30use crate::agenda::{self, Occurrence};
31use crate::lines;
32use crate::tasks::{self, Link, Status, Task};
33use crate::vdir::{self, Collection, Kind};
34use jiff::civil::{Date, DateTime};
35use jiff::tz::TimeZone;
36use jiff::{Timestamp, Zoned};
37use serde::Serialize;
38use std::collections::{BTreeMap, BTreeSet};
39
40/// The task a block is the time of, by its UID, in the block.
41pub const TASK: &str = "X-SIOUL-TASK";
42/// The turn of a repeating task a block pins: its date asked, else its day to
43/// start, as they were when it was pinned ("2026-10-09").
44pub const TURN: &str = "X-SIOUL-TURN";
45/// The link from a block to its task (RFC 9253 §6.3: LINKREL takes a
46/// registered relation or a URI; a tag URI, RFC 4151, as task kinds are).
47pub const BLOCK_OF: &str = "tag:aurelienpierre.com,2026:sioul/rel/block-of";
48/// The link from a task to its block.
49pub const TIME_BLOCK: &str = "tag:aurelienpierre.com,2026:sioul/rel/time-block";
50/// The calendar made for blocks at first use, the same on every device: its
51/// folder here and its address on the server (made with `MKCALENDAR` at the
52/// next sync; one another device made already counts as made).
53pub const CALENDAR: &str = "planned-tasks";
54/// A block's alarm, when Tasks ⚙ asks for one: five minutes before it starts.
55pub const ALARM_BEFORE: &str = "-PT5M";
56/// How far ahead blocks are read in their own calendars: as far as the plan lays tasks.
57pub const AHEAD_DAYS: i64 = 3 * 366;
58/// The least a block lasts, in minutes.
59pub const SHORTEST: u32 = 5;
60
61/// Where a task is pinned: its block, and the day it is on.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct Pin {
64    /// The block's file and UID.
65    pub key: String,
66    pub uid: String,
67    /// Unix seconds.
68    pub start: i64,
69    pub end: i64,
70    /// Its start's day, in your time zone.
71    pub date: Date,
72    /// The task's margins, kept around the block (getting there and back, getting ready).
73    pub before: u32,
74    pub after: u32,
75    /// Its calendar can only be read here: it moves elsewhere only.
76    pub read_only: bool,
77}
78
79impl Pin {
80    /// Its minutes, its margins aside.
81    pub fn minutes(&self) -> u32 {
82        u32::try_from((self.end - self.start).max(0) / 60).unwrap_or(u32::MAX)
83    }
84
85    /// The time it holds: the block, its margins around it.
86    pub fn span(&self) -> (i64, i64) {
87        (self.start - i64::from(self.before) * 60, self.end + i64::from(self.after) * 60)
88    }
89}
90
91/// The blocks read, sorted out: where each task is pinned, and what held the
92/// work of a task done.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct Blocks {
95    /// By task UID: its first block still to come.
96    pub pins: BTreeMap<String, Pin>,
97    /// By task UID, a task done: the block that held its work (begun before it
98    /// was done, that day), (start, end) in Unix seconds, to show it there.
99    pub records: BTreeMap<String, (i64, i64)>,
100}
101
102/// The turn of a repeating task a block made now pins: its date asked, else
103/// its day to start ("2026-10-09"); "" for a task that does not come back.
104pub fn turn_of(task: &Task) -> String {
105    if task.repeat.is_empty() {
106        return String::new();
107    }
108    task.due.get(..10).or_else(|| task.start.get(..10)).unwrap_or("").to_string()
109}
110
111/// A block of a turn done since: the task's turn is later than the one it
112/// pinned (a turn done moves the dates on, never back).
113fn of_a_turn_done(block: &Occurrence, task: &Task) -> bool {
114    let turn = turn_of(task);
115    !block.turn.is_empty() && !turn.is_empty() && turn.as_str() > block.turn.as_str()
116}
117
118/// The UIDs of the tasks known here.
119fn known(tasks: &[Task]) -> BTreeSet<&str> {
120    tasks.iter().map(|t| t.uid.as_str()).collect()
121}
122
123/// The events without the blocks of the tasks known here: what holds time as
124/// an event, to the plan, the day and the record. A block is its task's time.
125pub fn without_blocks(mut events: Vec<Occurrence>, tasks: &[Task]) -> Vec<Occurrence> {
126    let known = known(tasks);
127    events.retain(|e| e.task.is_empty() || !known.contains(e.task.as_str()));
128    events
129}
130
131impl Blocks {
132    /// The blocks among `blocks` (events, read once or twice: each counts
133    /// once), as of `now`, their days in `zone`.
134    pub fn of(blocks: &[Occurrence], tasks: &[Task], now: i64, zone: &TimeZone) -> Blocks {
135        let by_uid: BTreeMap<&str, &Task> = tasks.iter().map(|t| (t.uid.as_str(), t)).collect();
136        let day = |seconds: i64| Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()).date());
137        let mut seen = BTreeSet::new();
138        let mut sorted: Vec<&Occurrence> = blocks.iter().filter(|b| !b.task.is_empty() && !b.cancelled && !b.all_day && b.end > b.start).filter(|b| seen.insert((b.key.clone(), b.start))).collect();
139        sorted.sort_by(|a, b| (a.start, a.end, &a.key).cmp(&(b.start, b.end, &b.key)));
140        let mut out = Blocks::default();
141        for block in sorted {
142            let Some(task) = by_uid.get(block.task.as_str()) else { continue };
143            if task.status.is_open() {
144                if block.end > now && !of_a_turn_done(block, task) && !out.pins.contains_key(&task.uid) {
145                    let Some(date) = day(block.start) else { continue };
146                    out.pins.insert(task.uid.clone(), Pin { key: block.key.clone(), uid: block.uid.clone(), start: block.start, end: block.end, date, before: task.margins.before, after: task.margins.after, read_only: block.read_only });
147                }
148            } else if task.status == Status::Completed
149                && let Some(done) = task.completed
150                && block.start <= done
151                && day(block.start) == day(done)
152            {
153                // The latest begun before it was done, that day.
154                out.records.insert(task.uid.clone(), (block.start, block.end));
155            }
156        }
157        out
158    }
159}
160
161/// The calendars blocks are looked for in, beyond the four weeks of events the
162/// plan reads: the one chosen for them (`chosen`, "account/id"), and those
163/// made for them, on every account.
164pub fn calendars(chosen: &str) -> Vec<Collection> {
165    vdir::collections(Kind::Calendars).into_iter().filter(|c| c.id == CALENDAR || (!chosen.is_empty() && format!("{}/{}", c.account, c.id) == chosen)).collect()
166}
167
168/// The blocks of `calendars` between `from` and `to` (Unix seconds); times
169/// without a zone in `zone`.
170pub fn read(calendars: &[Collection], from: i64, to: i64, zone: &TimeZone) -> Vec<Occurrence> {
171    calendars.iter().flat_map(|c| c.items().into_iter().flat_map(|path| agenda::file_occurrences(&path, c, from, to, zone)).collect::<Vec<_>>()).filter(|o| !o.task.is_empty()).collect()
172}
173
174/// A value written as it is: no line break nor other control character.
175fn one_line(value: &str) -> String {
176    value.chars().filter(|c| !c.is_control()).collect()
177}
178
179fn utc(seconds: i64) -> Result<String, String> {
180    Timestamp::from_second(seconds).map(|t| t.strftime("%Y%m%dT%H%M%SZ").to_string()).map_err(|e| e.to_string())
181}
182
183/// A new block for `task`, `start` to `end` (Unix seconds), with its own `uid`;
184/// an alarm five minutes before it when asked (`alarm`), else none.
185pub fn new_block(task: &Task, uid: &str, start: i64, end: i64, alarm: bool, now: &Zoned) -> Result<String, String> {
186    if end <= start {
187        return Err(format!("{start}–{end}?"));
188    }
189    let stamp = utc(now.timestamp().as_second())?;
190    let mut out = vec!["BEGIN:VCALENDAR".to_string(), "VERSION:2.0".to_string(), "PRODID:-//Sioul//Sioul//EN".to_string(), "CALSCALE:GREGORIAN".to_string(), "BEGIN:VEVENT".to_string()];
191    out.extend([format!("UID:{}", one_line(uid)), format!("DTSTAMP:{stamp}"), format!("CREATED:{stamp}"), format!("LAST-MODIFIED:{stamp}")]);
192    out.extend([format!("DTSTART:{}", utc(start)?), format!("DTEND:{}", utc(end)?)]);
193    let title = task.title.trim();
194    if !title.is_empty() {
195        out.push(format!("SUMMARY:{}", lines::escape(title)));
196    }
197    out.push("TRANSP:OPAQUE".to_string());
198    out.push(format!("{TASK}:{}", one_line(&task.uid)));
199    let turn = turn_of(task);
200    if !turn.is_empty() {
201        out.push(format!("{TURN}:{turn}"));
202    }
203    out.push(tasks::link_line(&Link { uri: format!("uid:{}", task.uid), label: String::new(), rel: BLOCK_OF.to_string() }));
204    if alarm {
205        out.extend(["BEGIN:VALARM".to_string(), "ACTION:DISPLAY".to_string(), format!("DESCRIPTION:{}", lines::escape(title)), format!("TRIGGER:{ALARM_BEFORE}"), "END:VALARM".to_string()]);
206    }
207    out.extend(["END:VEVENT".to_string(), "END:VCALENDAR".to_string()]);
208    Ok(lines::fold(&out))
209}
210
211/// The block moved to `start`..`end`, as a drag in the agenda moves an event
212/// (`agenda::moved`): what Sioul does not edit is kept (an alarm another
213/// application added). A block another application made repeat moves its
214/// first time only.
215pub fn moved(text: &str, start: i64, end: i64, zone: &TimeZone) -> Result<String, String> {
216    let (current, _) = agenda::first_times(text, zone).ok_or("no event")?;
217    let repeats = lines::unfold(text).iter().any(|l| matches!(lines::name(l).as_str(), "RRULE" | "RDATE"));
218    agenda::moved(text, current, start, end, repeats, zone).map_err(|e| format!("{e:?}"))
219}
220
221/// The block ending at `end`, its start as it is.
222pub fn ended(text: &str, end: i64, zone: &TimeZone) -> Result<String, String> {
223    let (start, _) = agenda::first_times(text, zone).ok_or("no event")?;
224    moved(text, start, end, zone)
225}
226
227/// The block titled as its task (`SUMMARY`, its stamps and `SEQUENCE` moved
228/// on); None when it is already (its line as Sioul writes it, or read the
229/// same), or holds no event: never written twice for one title.
230pub fn titled(text: &str, title: &str, zone: &TimeZone) -> Option<String> {
231    let wanted = format!("SUMMARY:{}", lines::escape(title.trim()));
232    if lines::unfold(text).iter().any(|l| *l == wanted) {
233        return None;
234    }
235    let mut edit = agenda::edit_of_text(text, zone)?;
236    edit.title = title.trim().to_string();
237    agenda::apply(text, &edit, zone).ok().filter(|written| written != text)
238}
239
240/// The block pinning another turn of its repeating task (`X-SIOUL-TURN`: its
241/// dates changed in Sioul, the block follows them); "" for none.
242pub fn turned(text: &str, turn: &str) -> Option<String> {
243    let without = agenda::remove_lines(text, |l| lines::name(l) == TURN)?;
244    if turn.is_empty() {
245        return Some(without);
246    }
247    agenda::add_lines(&without, &[format!("{TURN}:{}", one_line(turn))])
248}
249
250/// What becomes of a block once its task is done, dropped or left to the plan.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum Closing {
253    /// Over by then: kept, the record of when the work was done.
254    Keep,
255    /// Under way then: it ends then (to the minute, five minutes at least).
256    EndAt(i64),
257    /// Not begun: it goes, its time freed.
258    Delete,
259}
260
261/// What becomes of a block from `start` to `end` when its task is done,
262/// dropped or left to the plan at `at` (Unix seconds).
263pub fn closing(start: i64, end: i64, at: i64) -> Closing {
264    if end <= at {
265        return Closing::Keep;
266    }
267    if start >= at {
268        return Closing::Delete;
269    }
270    let ends = ((at + 59).div_euclid(60) * 60).max(start + i64::from(SHORTEST) * 60);
271    if ends >= end { Closing::Keep } else { Closing::EndAt(ends) }
272}
273
274/// Something to set right in a block (`upkeep`).
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum Fix {
277    /// Not begun, its task done or dropped, or its turn done: it goes.
278    Delete,
279    /// Under way when its task was done or dropped: it ends then.
280    EndAt(i64),
281    /// To come, titled otherwise than its task: its task's title.
282    Title(String),
283}
284
285/// What to set right in `blocks` as of `now`, by file: the blocks of a task
286/// done (from when it was done) or dropped (from now), as `closing` says; a
287/// block not begun of a turn done elsewhere goes; one to come of an open task
288/// takes its title again. Nothing for a block of a task unknown here, nor for
289/// one another application made repeat. Each file once.
290pub fn upkeep(blocks: &[Occurrence], tasks: &[Task], now: i64) -> Vec<(String, Fix)> {
291    let by_uid: BTreeMap<&str, &Task> = tasks.iter().map(|t| (t.uid.as_str(), t)).collect();
292    let mut seen = BTreeSet::new();
293    let mut out = Vec::new();
294    for block in blocks.iter().filter(|b| !b.task.is_empty() && !b.all_day && !b.recurring) {
295        if !seen.insert(block.key.clone()) {
296            continue;
297        }
298        let Some(task) = by_uid.get(block.task.as_str()) else { continue };
299        let fix = match task.status {
300            Status::Completed | Status::Cancelled => {
301                let at = if task.status == Status::Completed { task.completed.unwrap_or(now) } else { now };
302                match closing(block.start, block.end, at) {
303                    Closing::Keep => None,
304                    Closing::EndAt(end) => Some(Fix::EndAt(end)),
305                    Closing::Delete => Some(Fix::Delete),
306                }
307            }
308            _ if of_a_turn_done(block, task) => (block.start > now).then_some(Fix::Delete),
309            _ if block.end > now && !task.title.trim().is_empty() && block.summary.trim() != task.title.trim() => Some(Fix::Title(task.title.trim().to_string())),
310            _ => None,
311        };
312        out.extend(fix.map(|fix| (block.key.clone(), fix)));
313    }
314    out
315}
316
317/// The task's link to its block.
318pub fn task_link(block_uid: &str) -> Link {
319    Link { uri: format!("uid:{block_uid}"), label: String::new(), rel: TIME_BLOCK.to_string() }
320}
321
322/// Whether a task's link is to a block of its.
323pub fn is_block_link(link: &Link) -> bool {
324    link.rel == TIME_BLOCK
325}
326
327/// The task's text pinned to the block `uid`, or to none: its links to blocks
328/// made that one alone, and a time given by a drag before blocks (`X-SIOUL-AT`)
329/// taken out; the text as it was when nothing changes.
330pub fn task_pinned(text: &str, uid: Option<&str>, now: &Zoned) -> Result<String, String> {
331    let source = lines::unfold(text);
332    let is_block_line = |l: &str| lines::name(l) == "LINK" && is_block_link(&tasks::link_of(l));
333    let drop = |l: &str| is_block_line(l) || lines::name(l) == tasks::AT;
334    let wanted = uid.map(task_link);
335    let current: Vec<Link> = source.iter().filter(|l| is_block_line(l)).map(|l| tasks::link_of(l)).collect();
336    let at = source.iter().any(|l| lines::name(l) == tasks::AT);
337    if !at && current.len() == usize::from(wanted.is_some()) && wanted.as_ref().is_none_or(|w| current.contains(w)) {
338        return Ok(text.to_string());
339    }
340    tasks::replace_lines(text, drop, wanted.iter().map(tasks::link_line).collect(), now)
341}
342
343/// A block's length by default, in minutes: what the plan lays for the task
344/// (`laid`: its corrected estimate, less the time spent, with its margins),
345/// its margins aside; else its estimate, else `default_estimate`. By whole
346/// five minutes, a quarter of an hour at least.
347pub fn default_minutes(task: &Task, laid: Option<u32>, default_estimate: u32) -> u32 {
348    let margins = task.margins.before.saturating_add(task.margins.after);
349    let minutes = laid.map(|l| l.saturating_sub(margins)).filter(|m| *m > 0).unwrap_or(if task.estimate > 0 { task.estimate } else { default_estimate });
350    minutes.max(15).div_ceil(5).saturating_mul(5)
351}
352
353/// Where a task's blocks go.
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum Place {
356    Found(Collection),
357    /// The calendar for blocks, to make on this account ("local": kept on this device).
358    Make(String),
359}
360
361/// Where a task's blocks go: the calendar chosen in Tasks ⚙ (`chosen`,
362/// "account/id") while it can be written and takes events; else the one made
363/// for them on the account of the task's list (`list_account`), or on this
364/// device when that list is kept here or its account makes no calendar from
365/// Sioul (`makes`: Google's makes none).
366pub fn place_for(chosen: &str, list_account: &str, calendars: &[Collection], makes: bool) -> Place {
367    if let Some(found) = calendars.iter().find(|c| !chosen.is_empty() && format!("{}/{}", c.account, c.id) == chosen && !c.read_only && c.holds("VEVENT")) {
368        return Place::Found(found.clone());
369    }
370    let account = if list_account.is_empty() || list_account == vdir::LOCAL || !makes { vdir::LOCAL } else { list_account };
371    match calendars.iter().find(|c| c.account == account && c.id == CALENDAR && !c.read_only) {
372        Some(found) => Place::Found(found.clone()),
373        None => Place::Make(account.to_string()),
374    }
375}
376
377/// The calendar for blocks made on `account` ("local": this device only),
378/// named `name` in your language; the next sync makes it on the server.
379pub fn make_calendar(account: &str, name: &str) -> Result<Collection, String> {
380    let dir = vdir::prepare(Kind::Calendars, account, CALENDAR, name, None).map_err(|e| e.to_string())?;
381    let state = vdir::State { pending: account != vdir::LOCAL, components: vec!["VEVENT".to_string()], ..vdir::State::default() };
382    state.save(&vdir::state_path(account, Kind::Calendars, CALENDAR))?;
383    Ok(Collection { kind: Kind::Calendars, account: account.to_string(), id: CALENDAR.to_string(), dir, name: name.to_string(), color: None, read_only: false, components: state.components })
384}
385
386/// Times given by a drag before blocks (`X-SIOUL-AT`) still to honour, each
387/// to become a block once: an open task's, for today or later, as (task, its
388/// start in Unix seconds), in `zone`.
389pub fn given_times<'a>(tasks: &'a [Task], today: Date, zone: &TimeZone) -> Vec<(&'a Task, i64)> {
390    tasks
391        .iter()
392        .filter(|t| t.status.is_open() && !t.at.is_empty())
393        .filter_map(|t| {
394            let date: Date = t.at.get(..10)?.parse().ok()?;
395            if date < today {
396                return None;
397            }
398            let local: DateTime = t.at.parse().ok()?;
399            Some((t, local.to_zoned(zone.clone()).ok()?.timestamp().as_second()))
400        })
401        .collect()
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn paris() -> TimeZone {
409        TimeZone::get("Europe/Paris").unwrap()
410    }
411
412    fn at(text: &str) -> i64 {
413        text.parse::<DateTime>().unwrap().to_zoned(paris()).unwrap().timestamp().as_second()
414    }
415
416    fn now() -> Zoned {
417        "2026-10-06T09:00:00+02:00[Europe/Paris]".parse().unwrap()
418    }
419
420    fn task(uid: &str, title: &str) -> Task {
421        Task { uid: uid.into(), title: title.into(), estimate: 30, ..Task::default() }
422    }
423
424    /// A calendar of its own in a folder of its own, for files read back.
425    fn calendar(name: &str) -> Collection {
426        let dir = std::env::temp_dir().join(format!("sioul-blocks-{}-{name}", std::process::id()));
427        let _ = std::fs::remove_dir_all(&dir);
428        std::fs::create_dir_all(&dir).unwrap();
429        Collection { kind: Kind::Calendars, account: "a".into(), id: CALENDAR.into(), dir, name: "Planned tasks".into(), color: None, read_only: false, components: vec!["VEVENT".into()] }
430    }
431
432    /// The blocks of a calendar's files, as the plan reads them.
433    fn read_back(calendar: &Collection) -> Vec<Occurrence> {
434        read(std::slice::from_ref(calendar), at("2026-10-01T00:00"), at("2026-12-31T00:00"), &paris())
435    }
436
437    #[test]
438    fn a_block_made_read_back_and_tied_both_ways() {
439        let bank = task("bank-1", "Call the bank, about the loan");
440        let text = new_block(&bank, "block-1", at("2026-10-06T14:00"), at("2026-10-06T14:45"), false, &now()).unwrap();
441        // Busy, its task named twice: Sioul's own line and an RFC 9253 link; no alarm unless asked.
442        assert!(text.contains("TRANSP:OPAQUE") && text.contains("X-SIOUL-TASK:bank-1") && text.contains("SUMMARY:Call the bank\\, about the loan"), "{text}");
443        assert!(lines::unfold(&text).iter().any(|l| l == "LINK;LINKREL=\"tag:aurelienpierre.com,2026:sioul/rel/block-of\";VALUE=UID:bank-1"), "{text}");
444        assert!(!text.contains("VALARM") && !text.contains("X-SIOUL-TURN"), "{text}");
445        assert!(agenda::parse(&text).is_some());
446        let with_alarm = new_block(&bank, "block-2", at("2026-10-06T14:00"), at("2026-10-06T14:45"), true, &now()).unwrap();
447        assert!(with_alarm.contains("BEGIN:VALARM") && with_alarm.contains("TRIGGER:-PT5M"), "{with_alarm}");
448        // Read back from its calendar: an event that is the task's time.
449        let place = calendar("made");
450        std::fs::write(place.dir.join("block-1.ics"), &text).unwrap();
451        let found = read_back(&place);
452        assert_eq!(found.len(), 1);
453        assert_eq!((found[0].task.as_str(), found[0].uid.as_str(), found[0].start, found[0].end), ("bank-1", "block-1", at("2026-10-06T14:00"), at("2026-10-06T14:45")));
454        assert_eq!(found[0].alarms, Vec::<i64>::new());
455        // The task links back, to it alone; again, nothing changes.
456        let task_text = tasks::new_task(&tasks::TaskEdit { title: bank.title.clone(), ..tasks::TaskEdit::default() }, "bank-1", &paris(), &now()).unwrap();
457        let linked = task_pinned(&task_text, Some("block-1"), &now()).unwrap();
458        let read = tasks::task_of_text(&linked, &paris()).unwrap();
459        assert_eq!(read.links, vec![task_link("block-1")]);
460        assert!(lines::unfold(&linked).iter().any(|l| l == "LINK;LINKREL=\"tag:aurelienpierre.com,2026:sioul/rel/time-block\";VALUE=UID:block-1"), "{linked}");
461        assert_eq!(task_pinned(&linked, Some("block-1"), &now()).unwrap(), linked);
462        // Pinned elsewhere: the old link goes; to none: no link to a block left.
463        let other = tasks::task_of_text(&task_pinned(&linked, Some("block-9"), &now()).unwrap(), &paris()).unwrap();
464        assert_eq!(other.links, vec![task_link("block-9")]);
465        assert!(tasks::task_of_text(&task_pinned(&linked, None, &now()).unwrap(), &paris()).unwrap().links.is_empty());
466        // Both ways, as the links read them: the task points at the event, the event at the task.
467        let task = Task { links: read.links.clone(), ..bank.clone() };
468        let event = agenda::event_ref(&text, "block-1.ics", &paris()).unwrap();
469        let (empty_mail, empty_links) = (crate::mailindex::MailIndex::default(), crate::links::LocalLinks::default());
470        let world = crate::links::World { tasks: std::slice::from_ref(&task), events: std::slice::from_ref(&event), contacts: &[], vault: None, drafts: &[], mail: &empty_mail, projects: &[], budget: &[], local: &empty_links, sites: &[] };
471        let edges = world.edges();
472        assert!(edges.iter().any(|e| e.from == crate::links::task_uri("bank-1") && e.to == crate::links::event_uri("block-1")), "{edges:?}");
473        assert!(edges.iter().any(|e| e.from == crate::links::event_uri("block-1") && e.to == crate::links::task_uri("bank-1")), "{edges:?}");
474    }
475
476    #[test]
477    fn a_task_pinned_to_its_first_block_to_come() {
478        let zone = paris();
479        let bank = task("bank", "Call the bank");
480        let block = |key: &str, from: &str, to: &str| Occurrence { key: key.into(), uid: key.into(), summary: "Call the bank".into(), start: at(from), end: at(to), task: "bank".into(), ..Occurrence::default() };
481        let blocks = [block("thursday", "2026-10-08T10:00", "2026-10-08T10:30"), block("morning", "2026-10-06T08:00", "2026-10-06T08:30"), block("tuesday", "2026-10-06T14:00", "2026-10-06T14:45")];
482        let now = at("2026-10-06T09:00");
483        let found = Blocks::of(&blocks, std::slice::from_ref(&bank), now, &zone);
484        // This morning's is over: it pins nothing, stays as it was; this afternoon's pins it, Thursday's waits.
485        let pin = &found.pins["bank"];
486        assert_eq!((pin.key.as_str(), pin.minutes(), pin.date), ("tuesday", 45, Date::constant(2026, 10, 6)));
487        // A block of a task unknown here is an event like any other; of a known one, never.
488        let stranger = Occurrence { task: "elsewhere".into(), ..block("stranger", "2026-10-06T16:00", "2026-10-06T17:00") };
489        let events = without_blocks(vec![blocks[2].clone(), stranger.clone(), Occurrence { key: "dentist".into(), ..Occurrence::default() }], std::slice::from_ref(&bank));
490        assert_eq!(events.iter().map(|e| e.key.as_str()).collect::<Vec<_>>(), ["stranger", "dentist"]);
491        // Done: it pins nothing; the block it was done in is its record.
492        let done = Task { status: Status::Completed, completed: Some(at("2026-10-06T14:30")), ..bank.clone() };
493        let found = Blocks::of(&blocks, std::slice::from_ref(&done), at("2026-10-06T15:00"), &zone);
494        assert!(found.pins.is_empty());
495        assert_eq!(found.records.get("bank"), Some(&(at("2026-10-06T14:00"), at("2026-10-06T14:45"))));
496    }
497
498    #[test]
499    fn a_block_moved_then_deleted_in_another_application() {
500        let zone = paris();
501        let bank = task("bank", "Call the bank");
502        let place = calendar("elsewhere");
503        let path = place.dir.join("b.ics");
504        let text = new_block(&bank, "b", at("2026-10-06T14:00"), at("2026-10-06T15:00"), false, &now()).unwrap();
505        std::fs::write(&path, &text).unwrap();
506        let now = at("2026-10-06T09:00");
507        assert_eq!(Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins["bank"].start, at("2026-10-06T14:00"));
508        // Another application moves it to Wednesday 16:00, writing it its own way (in its zone, a sequence of its own).
509        let elsewhere = text.replace("DTSTART:20261006T120000Z", "DTSTART;TZID=Europe/Paris:20261007T160000").replace("DTEND:20261006T130000Z", "DTEND;TZID=Europe/Paris:20261007T170000\r\nSEQUENCE:3");
510        std::fs::write(&path, &elsewhere).unwrap();
511        let pin = Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins["bank"].clone();
512        assert_eq!((pin.start, pin.date), (at("2026-10-07T16:00"), Date::constant(2026, 10, 7)));
513        // Moved again here: its time as Sioul writes it, what the other wrote kept.
514        let back = moved(&elsewhere, at("2026-10-07T10:00"), at("2026-10-07T10:45"), &zone).unwrap();
515        assert_eq!(agenda::first_times(&back, &zone), Some((at("2026-10-07T10:00"), at("2026-10-07T10:45"))));
516        assert!(back.contains("X-SIOUL-TASK:bank") && back.contains("SEQUENCE:4"), "{back}");
517        // Deleted there: the task is no longer pinned.
518        std::fs::remove_file(&path).unwrap();
519        assert!(Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins.is_empty());
520    }
521
522    #[test]
523    fn done_or_dropped_frees_what_is_to_come_and_keeps_the_record() {
524        // A block from 14:00 to 15:00.
525        let (start, end) = (at("2026-10-06T14:00"), at("2026-10-06T15:00"));
526        assert_eq!(closing(start, end, at("2026-10-06T13:00")), Closing::Delete, "not begun: it goes");
527        assert_eq!(closing(start, end, at("2026-10-06T14:20") + 30), Closing::EndAt(at("2026-10-06T14:21")), "under way: it ends then");
528        assert_eq!(closing(start, end, start + 30), Closing::EndAt(start + 300), "five minutes at least");
529        assert_eq!(closing(start, end, at("2026-10-06T16:00")), Closing::Keep, "over: the record of the work");
530        // Done elsewhere at 14:20: seen later, from when it was done.
531        let bank = task("bank", "Call the bank");
532        let block = |key: &str, from: &str, to: &str| Occurrence { key: key.into(), summary: "Call the bank".into(), start: at(from), end: at(to), task: "bank".into(), ..Occurrence::default() };
533        let blocks = [block("monday", "2026-10-05T10:00", "2026-10-05T11:00"), block("now", "2026-10-06T14:00", "2026-10-06T15:00"), block("friday", "2026-10-09T09:00", "2026-10-09T10:00")];
534        let done = Task { status: Status::Completed, completed: Some(at("2026-10-06T14:20")), ..bank.clone() };
535        let fixes = upkeep(&blocks, std::slice::from_ref(&done), at("2026-10-06T16:00"));
536        assert_eq!(fixes, vec![("now".to_string(), Fix::EndAt(at("2026-10-06T14:20"))), ("friday".to_string(), Fix::Delete)]);
537        // Dropped: from now; what is over stays.
538        let dropped = Task { status: Status::Cancelled, ..bank.clone() };
539        let fixes = upkeep(&blocks, std::slice::from_ref(&dropped), at("2026-10-06T14:30"));
540        assert_eq!(fixes, vec![("now".to_string(), Fix::EndAt(at("2026-10-06T14:30"))), ("friday".to_string(), Fix::Delete)]);
541        // The file itself: ended to the minute, its start as it was.
542        let text = new_block(&bank, "now", start, end, false, &now()).unwrap();
543        let shorter = ended(&text, at("2026-10-06T14:20"), &paris()).unwrap();
544        assert_eq!(agenda::first_times(&shorter, &paris()), Some((start, at("2026-10-06T14:20"))));
545    }
546
547    #[test]
548    fn the_title_kept_in_step() {
549        let bank = task("bank", "Call the bank");
550        let text = new_block(&bank, "b", at("2026-10-06T14:00"), at("2026-10-06T15:00"), false, &now()).unwrap();
551        let renamed = Task { title: "Call the bank about the loan".into(), ..bank.clone() };
552        let block = Occurrence { key: "b.ics".into(), summary: "Call the bank".into(), start: at("2026-10-06T14:00"), end: at("2026-10-06T15:00"), task: "bank".into(), ..Occurrence::default() };
553        assert_eq!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&renamed), at("2026-10-06T09:00")), vec![("b.ics".to_string(), Fix::Title("Call the bank about the loan".into()))]);
554        let titled = titled(&text, &renamed.title, &paris()).unwrap();
555        assert!(titled.contains("SUMMARY:Call the bank about the loan") && titled.contains("SEQUENCE:1") && titled.contains("X-SIOUL-TASK:bank"), "{titled}");
556        assert_eq!(super::titled(&titled, &renamed.title, &paris()), None, "already so: nothing written");
557        // Over: left as it was.
558        assert!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&renamed), at("2026-10-06T16:00")).is_empty());
559    }
560
561    #[test]
562    fn a_block_pins_one_turn_of_a_repeating_task() {
563        let zone = paris();
564        let plants = Task { repeat: "weekly".into(), due: "2026-10-09".into(), ..task("plants", "Water the plants") };
565        let text = new_block(&plants, "p", at("2026-10-08T18:00"), at("2026-10-08T18:30"), false, &now()).unwrap();
566        assert!(text.contains("X-SIOUL-TURN:2026-10-09"), "{text}");
567        let block = Occurrence { key: "p.ics".into(), summary: plants.title.clone(), start: at("2026-10-08T18:00"), end: at("2026-10-08T18:30"), task: "plants".into(), turn: "2026-10-09".into(), ..Occurrence::default() };
568        let now = at("2026-10-06T09:00");
569        assert!(Blocks::of(std::slice::from_ref(&block), std::slice::from_ref(&plants), now, &zone).pins.contains_key("plants"));
570        // This turn done elsewhere: the task comes back on the 16th; the block pins that turn no more, and goes.
571        let next = Task { due: "2026-10-16".into(), ..plants.clone() };
572        assert!(Blocks::of(std::slice::from_ref(&block), std::slice::from_ref(&next), now, &zone).pins.is_empty());
573        assert_eq!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&next), now), vec![("p.ics".to_string(), Fix::Delete)]);
574        // Its dates changed in Sioul: the block follows (its turn written again).
575        let followed = turned(&text, "2026-10-16").unwrap();
576        assert!(followed.contains("X-SIOUL-TURN:2026-10-16") && !followed.contains("2026-10-09"), "{followed}");
577        // A task that does not come back has no turn.
578        assert_eq!(turn_of(&task("once", "Once")), "");
579    }
580
581    #[test]
582    fn a_time_given_before_blocks_becomes_one() {
583        let zone = paris();
584        let today = Date::constant(2026, 10, 6);
585        let given = Task { at: "2026-10-06T14:30".into(), ..task("given", "Write the letter") };
586        let yesterday = Task { at: "2026-10-05T10:00".into(), ..task("old", "Old") };
587        let done = Task { at: "2026-10-06T16:00".into(), status: Status::Completed, ..task("done", "Done") };
588        let all = [given.clone(), yesterday, done];
589        let found = given_times(&all, today, &zone);
590        assert_eq!(found.iter().map(|(t, s)| (t.uid.as_str(), *s)).collect::<Vec<_>>(), [("given", at("2026-10-06T14:30"))]);
591        // Its block, at that time, as long as its estimate: it pins the task there.
592        let (task, start) = found[0];
593        let block = new_block(task, "block-given", start, start + i64::from(default_minutes(task, None, 30)) * 60, false, &now()).unwrap();
594        assert_eq!(agenda::first_times(&block, &zone), Some((at("2026-10-06T14:30"), at("2026-10-06T15:00"))));
595        let read = Occurrence { key: "block-given.ics".into(), uid: "block-given".into(), start, end: at("2026-10-06T15:00"), task: "given".into(), ..Occurrence::default() };
596        assert_eq!(Blocks::of(std::slice::from_ref(&read), std::slice::from_ref(&given), at("2026-10-06T09:00"), &zone).pins["given"].start, at("2026-10-06T14:30"));
597        // Pinned to its block: the line goes from the task, the link comes.
598        let text = tasks::new_task(&tasks::TaskEdit { title: given.title.clone(), ..tasks::TaskEdit::default() }, "given", &zone, &now()).unwrap();
599        let old = text.replace("END:VTODO", "X-SIOUL-AT:20261006T123000Z\r\nEND:VTODO");
600        assert_eq!(tasks::task_of_text(&old, &zone).unwrap().at, "2026-10-06T14:30");
601        let pinned = task_pinned(&old, Some("block-given"), &now()).unwrap();
602        let read = tasks::task_of_text(&pinned, &zone).unwrap();
603        assert_eq!((read.at.as_str(), read.links.clone()), ("", vec![task_link("block-given")]));
604        assert!(!pinned.contains("X-SIOUL-AT"), "{pinned}");
605    }
606
607    #[test]
608    fn where_blocks_go() {
609        let here = |account: &str, id: &str| Collection { kind: Kind::Calendars, account: account.into(), id: id.into(), dir: std::env::temp_dir(), name: id.into(), color: None, read_only: false, components: vec![] };
610        let calendars = [here("cloud", "personal"), here("cloud", CALENDAR), here("other", "work")];
611        // As usual: the one made for blocks on the list's account.
612        assert_eq!(place_for("", "cloud", &calendars, true), Place::Found(here("cloud", CALENDAR)));
613        // Another account without one yet: made there; a local list or Google's: on this device.
614        assert_eq!(place_for("", "other", &calendars, true), Place::Make("other".into()));
615        assert_eq!(place_for("", vdir::LOCAL, &calendars, true), Place::Make(vdir::LOCAL.into()));
616        assert_eq!(place_for("", "google", &calendars, false), Place::Make(vdir::LOCAL.into()));
617        // Chosen in Tasks ⚙: there, while it can be written.
618        assert_eq!(place_for("other/work", "cloud", &calendars, true), Place::Found(here("other", "work")));
619        let read_only = [Collection { read_only: true, ..here("other", "work") }, here("cloud", CALENDAR)];
620        assert_eq!(place_for("other/work", "cloud", &read_only, true), Place::Found(here("cloud", CALENDAR)));
621        // Its length by default: what the plan lays, margins aside, by five minutes.
622        let errand = Task { margins: crate::demands::Margins { before: 10, after: 10 }, ..task("e", "Errand") };
623        assert_eq!((default_minutes(&errand, Some(53), 30), default_minutes(&errand, None, 30), default_minutes(&task("t", "T"), Some(8), 30)), (35, 30, 15));
624    }
625}