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/// Whether a time block kept on this device alone (`block`, its calendar)
378/// goes to its task's server (21 October 2026): the task's list is a server's
379/// (`list_account`), so the task reaches your other devices while its time,
380/// here alone, would not, and the agendas would differ. Unless you chose that
381/// calendar for blocks (Tasks ⚙, `chosen`, "account/id").
382pub fn kept_back(block: &Collection, list_account: &str, chosen: &str) -> bool {
383    block.account == vdir::LOCAL && !list_account.is_empty() && list_account != vdir::LOCAL && chosen != format!("{}/{}", block.account, block.id)
384}
385
386/// The calendar for blocks made on `account` ("local": this device only),
387/// named `name` in your language; the next sync makes it on the server.
388pub fn make_calendar(account: &str, name: &str) -> Result<Collection, String> {
389    // Its state first: a folder never shows without what it takes (`vdir::create`).
390    let state = vdir::State { pending: account != vdir::LOCAL, components: vec!["VEVENT".to_string()], ..vdir::State::default() };
391    state.save(&vdir::state_path(account, Kind::Calendars, CALENDAR))?;
392    let dir = vdir::prepare(Kind::Calendars, account, CALENDAR, name, None).map_err(|e| e.to_string())?;
393    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 })
394}
395
396/// Times given by a drag before blocks (`X-SIOUL-AT`) still to honour, each
397/// to become a block once: an open task's, for today or later, as (task, its
398/// start in Unix seconds), in `zone`.
399pub fn given_times<'a>(tasks: &'a [Task], today: Date, zone: &TimeZone) -> Vec<(&'a Task, i64)> {
400    tasks
401        .iter()
402        .filter(|t| t.status.is_open() && !t.at.is_empty())
403        .filter_map(|t| {
404            let date: Date = t.at.get(..10)?.parse().ok()?;
405            if date < today {
406                return None;
407            }
408            let local: DateTime = t.at.parse().ok()?;
409            Some((t, local.to_zoned(zone.clone()).ok()?.timestamp().as_second()))
410        })
411        .collect()
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    /// The owner's dated task of 21 October 2026 showed in the computer's
419    /// agenda and not on the phone: its block was in a calendar of the
420    /// computer's own. A block of a task in a server's list goes there.
421    #[test]
422    fn a_block_kept_here_alone_goes_where_its_task_goes() {
423        let calendar = |account: &str, id: &str| Collection { kind: Kind::Calendars, account: account.into(), id: id.into(), dir: std::path::PathBuf::new(), name: String::new(), color: None, read_only: false, components: Vec::new() };
424        let here = calendar(vdir::LOCAL, CALENDAR);
425        assert!(kept_back(&here, "murena", ""), "a server's list: its block goes there");
426        assert!(!kept_back(&here, vdir::LOCAL, ""), "a list kept here: its block stays here too");
427        assert!(!kept_back(&here, "murena", "local/planned-tasks"), "chosen in Tasks ⚙: it stays");
428        assert!(!kept_back(&calendar("murena", CALENDAR), "murena", ""), "on the server already");
429    }
430
431    fn paris() -> TimeZone {
432        TimeZone::get("Europe/Paris").unwrap()
433    }
434
435    fn at(text: &str) -> i64 {
436        text.parse::<DateTime>().unwrap().to_zoned(paris()).unwrap().timestamp().as_second()
437    }
438
439    fn now() -> Zoned {
440        "2026-10-06T09:00:00+02:00[Europe/Paris]".parse().unwrap()
441    }
442
443    fn task(uid: &str, title: &str) -> Task {
444        Task { uid: uid.into(), title: title.into(), estimate: 30, ..Task::default() }
445    }
446
447    /// A calendar of its own in a folder of its own, for files read back.
448    fn calendar(name: &str) -> Collection {
449        let dir = std::env::temp_dir().join(format!("sioul-blocks-{}-{name}", std::process::id()));
450        let _ = std::fs::remove_dir_all(&dir);
451        std::fs::create_dir_all(&dir).unwrap();
452        Collection { kind: Kind::Calendars, account: "a".into(), id: CALENDAR.into(), dir, name: "Planned tasks".into(), color: None, read_only: false, components: vec!["VEVENT".into()] }
453    }
454
455    /// The blocks of a calendar's files, as the plan reads them.
456    fn read_back(calendar: &Collection) -> Vec<Occurrence> {
457        read(std::slice::from_ref(calendar), at("2026-10-01T00:00"), at("2026-12-31T00:00"), &paris())
458    }
459
460    #[test]
461    fn a_block_made_read_back_and_tied_both_ways() {
462        let bank = task("bank-1", "Call the bank, about the loan");
463        let text = new_block(&bank, "block-1", at("2026-10-06T14:00"), at("2026-10-06T14:45"), false, &now()).unwrap();
464        // Busy, its task named twice: Sioul's own line and an RFC 9253 link; no alarm unless asked.
465        assert!(text.contains("TRANSP:OPAQUE") && text.contains("X-SIOUL-TASK:bank-1") && text.contains("SUMMARY:Call the bank\\, about the loan"), "{text}");
466        assert!(lines::unfold(&text).iter().any(|l| l == "LINK;LINKREL=\"tag:aurelienpierre.com,2026:sioul/rel/block-of\";VALUE=UID:bank-1"), "{text}");
467        assert!(!text.contains("VALARM") && !text.contains("X-SIOUL-TURN"), "{text}");
468        assert!(agenda::parse(&text).is_some());
469        let with_alarm = new_block(&bank, "block-2", at("2026-10-06T14:00"), at("2026-10-06T14:45"), true, &now()).unwrap();
470        assert!(with_alarm.contains("BEGIN:VALARM") && with_alarm.contains("TRIGGER:-PT5M"), "{with_alarm}");
471        // Read back from its calendar: an event that is the task's time.
472        let place = calendar("made");
473        std::fs::write(place.dir.join("block-1.ics"), &text).unwrap();
474        let found = read_back(&place);
475        assert_eq!(found.len(), 1);
476        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")));
477        assert_eq!(found[0].alarms, Vec::<i64>::new());
478        // The task links back, to it alone; again, nothing changes.
479        let task_text = tasks::new_task(&tasks::TaskEdit { title: bank.title.clone(), ..tasks::TaskEdit::default() }, "bank-1", &paris(), &now()).unwrap();
480        let linked = task_pinned(&task_text, Some("block-1"), &now()).unwrap();
481        let read = tasks::task_of_text(&linked, &paris()).unwrap();
482        assert_eq!(read.links, vec![task_link("block-1")]);
483        assert!(lines::unfold(&linked).iter().any(|l| l == "LINK;LINKREL=\"tag:aurelienpierre.com,2026:sioul/rel/time-block\";VALUE=UID:block-1"), "{linked}");
484        assert_eq!(task_pinned(&linked, Some("block-1"), &now()).unwrap(), linked);
485        // Pinned elsewhere: the old link goes; to none: no link to a block left.
486        let other = tasks::task_of_text(&task_pinned(&linked, Some("block-9"), &now()).unwrap(), &paris()).unwrap();
487        assert_eq!(other.links, vec![task_link("block-9")]);
488        assert!(tasks::task_of_text(&task_pinned(&linked, None, &now()).unwrap(), &paris()).unwrap().links.is_empty());
489        // Both ways, as the links read them: the task points at the event, the event at the task.
490        let task = Task { links: read.links.clone(), ..bank.clone() };
491        let event = agenda::event_ref(&text, "block-1.ics", &paris()).unwrap();
492        let (empty_mail, empty_links) = (crate::mailindex::MailIndex::default(), crate::links::LocalLinks::default());
493        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: &[] };
494        let edges = world.edges();
495        assert!(edges.iter().any(|e| e.from == crate::links::task_uri("bank-1") && e.to == crate::links::event_uri("block-1")), "{edges:?}");
496        assert!(edges.iter().any(|e| e.from == crate::links::event_uri("block-1") && e.to == crate::links::task_uri("bank-1")), "{edges:?}");
497    }
498
499    #[test]
500    fn a_task_pinned_to_its_first_block_to_come() {
501        let zone = paris();
502        let bank = task("bank", "Call the bank");
503        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() };
504        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")];
505        let now = at("2026-10-06T09:00");
506        let found = Blocks::of(&blocks, std::slice::from_ref(&bank), now, &zone);
507        // This morning's is over: it pins nothing, stays as it was; this afternoon's pins it, Thursday's waits.
508        let pin = &found.pins["bank"];
509        assert_eq!((pin.key.as_str(), pin.minutes(), pin.date), ("tuesday", 45, Date::constant(2026, 10, 6)));
510        // A block of a task unknown here is an event like any other; of a known one, never.
511        let stranger = Occurrence { task: "elsewhere".into(), ..block("stranger", "2026-10-06T16:00", "2026-10-06T17:00") };
512        let events = without_blocks(vec![blocks[2].clone(), stranger.clone(), Occurrence { key: "dentist".into(), ..Occurrence::default() }], std::slice::from_ref(&bank));
513        assert_eq!(events.iter().map(|e| e.key.as_str()).collect::<Vec<_>>(), ["stranger", "dentist"]);
514        // Done: it pins nothing; the block it was done in is its record.
515        let done = Task { status: Status::Completed, completed: Some(at("2026-10-06T14:30")), ..bank.clone() };
516        let found = Blocks::of(&blocks, std::slice::from_ref(&done), at("2026-10-06T15:00"), &zone);
517        assert!(found.pins.is_empty());
518        assert_eq!(found.records.get("bank"), Some(&(at("2026-10-06T14:00"), at("2026-10-06T14:45"))));
519    }
520
521    #[test]
522    fn a_block_moved_then_deleted_in_another_application() {
523        let zone = paris();
524        let bank = task("bank", "Call the bank");
525        let place = calendar("elsewhere");
526        let path = place.dir.join("b.ics");
527        let text = new_block(&bank, "b", at("2026-10-06T14:00"), at("2026-10-06T15:00"), false, &now()).unwrap();
528        std::fs::write(&path, &text).unwrap();
529        let now = at("2026-10-06T09:00");
530        assert_eq!(Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins["bank"].start, at("2026-10-06T14:00"));
531        // Another application moves it to Wednesday 16:00, writing it its own way (in its zone, a sequence of its own).
532        let elsewhere = text.replace("DTSTART:20261006T120000Z", "DTSTART;TZID=Europe/Paris:20261007T160000").replace("DTEND:20261006T130000Z", "DTEND;TZID=Europe/Paris:20261007T170000\r\nSEQUENCE:3");
533        std::fs::write(&path, &elsewhere).unwrap();
534        let pin = Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins["bank"].clone();
535        assert_eq!((pin.start, pin.date), (at("2026-10-07T16:00"), Date::constant(2026, 10, 7)));
536        // Moved again here: its time as Sioul writes it, what the other wrote kept.
537        let back = moved(&elsewhere, at("2026-10-07T10:00"), at("2026-10-07T10:45"), &zone).unwrap();
538        assert_eq!(agenda::first_times(&back, &zone), Some((at("2026-10-07T10:00"), at("2026-10-07T10:45"))));
539        assert!(back.contains("X-SIOUL-TASK:bank") && back.contains("SEQUENCE:4"), "{back}");
540        // Deleted there: the task is no longer pinned.
541        std::fs::remove_file(&path).unwrap();
542        assert!(Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins.is_empty());
543    }
544
545    #[test]
546    fn done_or_dropped_frees_what_is_to_come_and_keeps_the_record() {
547        // A block from 14:00 to 15:00.
548        let (start, end) = (at("2026-10-06T14:00"), at("2026-10-06T15:00"));
549        assert_eq!(closing(start, end, at("2026-10-06T13:00")), Closing::Delete, "not begun: it goes");
550        assert_eq!(closing(start, end, at("2026-10-06T14:20") + 30), Closing::EndAt(at("2026-10-06T14:21")), "under way: it ends then");
551        assert_eq!(closing(start, end, start + 30), Closing::EndAt(start + 300), "five minutes at least");
552        assert_eq!(closing(start, end, at("2026-10-06T16:00")), Closing::Keep, "over: the record of the work");
553        // Done elsewhere at 14:20: seen later, from when it was done.
554        let bank = task("bank", "Call the bank");
555        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() };
556        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")];
557        let done = Task { status: Status::Completed, completed: Some(at("2026-10-06T14:20")), ..bank.clone() };
558        let fixes = upkeep(&blocks, std::slice::from_ref(&done), at("2026-10-06T16:00"));
559        assert_eq!(fixes, vec![("now".to_string(), Fix::EndAt(at("2026-10-06T14:20"))), ("friday".to_string(), Fix::Delete)]);
560        // Dropped: from now; what is over stays.
561        let dropped = Task { status: Status::Cancelled, ..bank.clone() };
562        let fixes = upkeep(&blocks, std::slice::from_ref(&dropped), at("2026-10-06T14:30"));
563        assert_eq!(fixes, vec![("now".to_string(), Fix::EndAt(at("2026-10-06T14:30"))), ("friday".to_string(), Fix::Delete)]);
564        // The file itself: ended to the minute, its start as it was.
565        let text = new_block(&bank, "now", start, end, false, &now()).unwrap();
566        let shorter = ended(&text, at("2026-10-06T14:20"), &paris()).unwrap();
567        assert_eq!(agenda::first_times(&shorter, &paris()), Some((start, at("2026-10-06T14:20"))));
568    }
569
570    #[test]
571    fn the_title_kept_in_step() {
572        let bank = task("bank", "Call the bank");
573        let text = new_block(&bank, "b", at("2026-10-06T14:00"), at("2026-10-06T15:00"), false, &now()).unwrap();
574        let renamed = Task { title: "Call the bank about the loan".into(), ..bank.clone() };
575        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() };
576        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()))]);
577        let titled = titled(&text, &renamed.title, &paris()).unwrap();
578        assert!(titled.contains("SUMMARY:Call the bank about the loan") && titled.contains("SEQUENCE:1") && titled.contains("X-SIOUL-TASK:bank"), "{titled}");
579        assert_eq!(super::titled(&titled, &renamed.title, &paris()), None, "already so: nothing written");
580        // Over: left as it was.
581        assert!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&renamed), at("2026-10-06T16:00")).is_empty());
582    }
583
584    #[test]
585    fn a_block_pins_one_turn_of_a_repeating_task() {
586        let zone = paris();
587        let plants = Task { repeat: "weekly".into(), due: "2026-10-09".into(), ..task("plants", "Water the plants") };
588        let text = new_block(&plants, "p", at("2026-10-08T18:00"), at("2026-10-08T18:30"), false, &now()).unwrap();
589        assert!(text.contains("X-SIOUL-TURN:2026-10-09"), "{text}");
590        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() };
591        let now = at("2026-10-06T09:00");
592        assert!(Blocks::of(std::slice::from_ref(&block), std::slice::from_ref(&plants), now, &zone).pins.contains_key("plants"));
593        // This turn done elsewhere: the task comes back on the 16th; the block pins that turn no more, and goes.
594        let next = Task { due: "2026-10-16".into(), ..plants.clone() };
595        assert!(Blocks::of(std::slice::from_ref(&block), std::slice::from_ref(&next), now, &zone).pins.is_empty());
596        assert_eq!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&next), now), vec![("p.ics".to_string(), Fix::Delete)]);
597        // Its dates changed in Sioul: the block follows (its turn written again).
598        let followed = turned(&text, "2026-10-16").unwrap();
599        assert!(followed.contains("X-SIOUL-TURN:2026-10-16") && !followed.contains("2026-10-09"), "{followed}");
600        // A task that does not come back has no turn.
601        assert_eq!(turn_of(&task("once", "Once")), "");
602    }
603
604    #[test]
605    fn a_time_given_before_blocks_becomes_one() {
606        let zone = paris();
607        let today = Date::constant(2026, 10, 6);
608        let given = Task { at: "2026-10-06T14:30".into(), ..task("given", "Write the letter") };
609        let yesterday = Task { at: "2026-10-05T10:00".into(), ..task("old", "Old") };
610        let done = Task { at: "2026-10-06T16:00".into(), status: Status::Completed, ..task("done", "Done") };
611        let all = [given.clone(), yesterday, done];
612        let found = given_times(&all, today, &zone);
613        assert_eq!(found.iter().map(|(t, s)| (t.uid.as_str(), *s)).collect::<Vec<_>>(), [("given", at("2026-10-06T14:30"))]);
614        // Its block, at that time, as long as its estimate: it pins the task there.
615        let (task, start) = found[0];
616        let block = new_block(task, "block-given", start, start + i64::from(default_minutes(task, None, 30)) * 60, false, &now()).unwrap();
617        assert_eq!(agenda::first_times(&block, &zone), Some((at("2026-10-06T14:30"), at("2026-10-06T15:00"))));
618        let read = Occurrence { key: "block-given.ics".into(), uid: "block-given".into(), start, end: at("2026-10-06T15:00"), task: "given".into(), ..Occurrence::default() };
619        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"));
620        // Pinned to its block: the line goes from the task, the link comes.
621        let text = tasks::new_task(&tasks::TaskEdit { title: given.title.clone(), ..tasks::TaskEdit::default() }, "given", &zone, &now()).unwrap();
622        let old = text.replace("END:VTODO", "X-SIOUL-AT:20261006T123000Z\r\nEND:VTODO");
623        assert_eq!(tasks::task_of_text(&old, &zone).unwrap().at, "2026-10-06T14:30");
624        let pinned = task_pinned(&old, Some("block-given"), &now()).unwrap();
625        let read = tasks::task_of_text(&pinned, &zone).unwrap();
626        assert_eq!((read.at.as_str(), read.links.clone()), ("", vec![task_link("block-given")]));
627        assert!(!pinned.contains("X-SIOUL-AT"), "{pinned}");
628    }
629
630    #[test]
631    fn where_blocks_go() {
632        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![] };
633        let calendars = [here("cloud", "personal"), here("cloud", CALENDAR), here("other", "work")];
634        // As usual: the one made for blocks on the list's account.
635        assert_eq!(place_for("", "cloud", &calendars, true), Place::Found(here("cloud", CALENDAR)));
636        // Another account without one yet: made there; a local list or Google's: on this device.
637        assert_eq!(place_for("", "other", &calendars, true), Place::Make("other".into()));
638        assert_eq!(place_for("", vdir::LOCAL, &calendars, true), Place::Make(vdir::LOCAL.into()));
639        assert_eq!(place_for("", "google", &calendars, false), Place::Make(vdir::LOCAL.into()));
640        // Chosen in Tasks ⚙: there, while it can be written.
641        assert_eq!(place_for("other/work", "cloud", &calendars, true), Place::Found(here("other", "work")));
642        let read_only = [Collection { read_only: true, ..here("other", "work") }, here("cloud", CALENDAR)];
643        assert_eq!(place_for("other/work", "cloud", &read_only, true), Place::Found(here("cloud", CALENDAR)));
644        // Its length by default: what the plan lays, margins aside, by five minutes.
645        let errand = Task { margins: crate::demands::Margins { before: 10, after: 10 }, ..task("e", "Errand") };
646        assert_eq!((default_minutes(&errand, Some(53), 30), default_minutes(&errand, None, 30), default_minutes(&task("t", "T"), Some(8), 30)), (35, 30, 15));
647    }
648}