Skip to main content

sioul_core/
tasks.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Tasks: to-dos (VTODO, RFC 5545 §3.6.2) in CalDAV task lists, one per file,
5//! with what ties them to the rest (RFC 9253, "iCalendar Relationships"):
6//!
7//! - **a step of a bigger task**: `RELATED-TO;RELTYPE=PARENT:<uid>`, in the step;
8//! - **waits for another**: `RELATED-TO;RELTYPE=DEPENDS-ON:<uid>`, in the one
9//!   that waits, as Sioul writes it. RFC 9253 §4 puts its temporal links in the
10//!   one that comes first instead (`RELATED-TO;RELTYPE=FINISHTOSTART;GAP=P14D:<uid>`
11//!   for "two weeks after"): read too, and written when there is a gap;
12//! - **its notes, mail, drafts, events**: `LINK` (RFC 9253 §8.2): `mid:` for a
13//!   message (RFC 2392), `sioul:` for the rest (`links`);
14//! - **the people and offices it involves**: `CONTACT`, its `ALTREP` pointing at their card;
15//! - **its project**: `REFID` (docs/notes-folder.md);
16//! - **how long it takes**: `ESTIMATED-DURATION` (draft-ietf-calext-ical-tasks).
17//!
18//! `DTSTART` says from when a task can start, `DUE` the date asked from
19//! outside. Neither turns into "overdue": the plan always starts from today
20//! (`plan`, docs/tasks.md).
21//!
22//! Tasks are changed line by line (`lines`): what Sioul does not edit (alarms,
23//! another application's fields) is kept exactly, and a form saved unchanged
24//! writes nothing.
25
26use crate::lines;
27use crate::vdir::{self, Collection, Kind};
28use jiff::civil::{Date, DateTime};
29use jiff::tz::TimeZone;
30use jiff::{Span, Zoned};
31use serde::{Deserialize, Serialize};
32use std::collections::BTreeMap;
33use std::ops::Range;
34use std::path::{Path, PathBuf};
35
36/// A task's state (RFC 5545 §3.8.1.11).
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum Status {
40    #[default]
41    NeedsAction,
42    InProcess,
43    Completed,
44    Cancelled,
45}
46
47impl Status {
48    fn read(value: &str) -> Status {
49        match value.trim().to_ascii_uppercase().as_str() {
50            "IN-PROCESS" => Status::InProcess,
51            "COMPLETED" => Status::Completed,
52            "CANCELLED" => Status::Cancelled,
53            _ => Status::NeedsAction,
54        }
55    }
56
57    fn written(self) -> &'static str {
58        match self {
59            Status::NeedsAction => "NEEDS-ACTION",
60            Status::InProcess => "IN-PROCESS",
61            Status::Completed => "COMPLETED",
62            Status::Cancelled => "CANCELLED",
63        }
64    }
65
66    /// Still to do: neither done nor dropped.
67    pub fn is_open(self) -> bool {
68        matches!(self, Status::NeedsAction | Status::InProcess)
69    }
70}
71
72/// How a task is tied to another component (RELATED-TO, RFC 9253 §9.1).
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74pub struct Relation {
75    /// "PARENT" (when unsaid), "CHILD", "SIBLING", "DEPENDS-ON", "FINISHTOSTART", "NEXT"…
76    pub kind: String,
77    pub uid: String,
78    /// GAP, in minutes: the wait between one's end and the other's start; negative for a lead.
79    pub gap: i64,
80}
81
82/// Something a task points at (LINK, RFC 9253 §8.2).
83#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
84pub struct Link {
85    /// `mid:…`, `sioul:note/…`, `https://…`; `uid:<uid>` for another component by its UID.
86    pub uri: String,
87    #[serde(default)]
88    pub label: String,
89    /// The link relation (RFC 8288): "describedby" for a note, "via" for what it came from, "related".
90    #[serde(default)]
91    pub rel: String,
92}
93
94/// Someone a task involves (CONTACT, RFC 5545 §3.8.4.2): a name, and where their card is.
95#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
96pub struct ContactRef {
97    pub name: String,
98    /// ALTREP: "sioul:contact/<card's UID>", or empty.
99    #[serde(default)]
100    pub uri: String,
101}
102
103/// The kinds of task, by what doing them takes: a call, writing, a form or
104/// anything done on a website, going out, reading, thinking, making. Tasks of
105/// one kind are easier done together.
106pub const KINDS: &[&str] = &["call", "write", "online", "out", "read", "think", "make"];
107
108/// A task's kind is written as a concept (RFC 9253, CONCEPT), under this
109/// tag (RFC 4151): servers and other applications keep it as it is.
110pub const KIND_CONCEPT: &str = "tag:aurelienpierre.com,2026:sioul/task-type/";
111
112/// A task that can only be done while offices are open (a call to an office, a counter).
113pub const OFFICE_CONCEPT: &str = "tag:aurelienpierre.com,2026:sioul/needs/office-hours";
114
115/// Whether a word can name a kind: lowercase letters, digits and hyphens, as
116/// Sioul's own kinds and those you add ("errand", "rendez-vous").
117pub fn is_kind_id(kind: &str) -> bool {
118    !kind.is_empty() && kind.len() <= 40 && kind.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
119}
120
121/// The kind a concept names, when it is one of Sioul's.
122fn kind_of_concept(value: &str) -> Option<String> {
123    let kind = value.trim().strip_prefix(KIND_CONCEPT)?;
124    is_kind_id(kind).then(|| kind.to_string())
125}
126
127/// One task, as read from its file.
128#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
129pub struct Task {
130    /// The file, to open and change it.
131    pub key: String,
132    pub uid: String,
133    pub title: String,
134    pub notes: String,
135    pub location: String,
136    pub status: Status,
137    /// From when it can start, in your time zone: "2026-10-05", or "2026-10-05T09:00".
138    pub start: String,
139    /// The date asked, in the same form.
140    pub due: String,
141    /// A time given to it for one day by a drag in the day view before time
142    /// blocks (`X-SIOUL-AT`, read still, never written: a task is pinned to a
143    /// time by a block now, `blocks`): "2026-10-06T14:30", in your time zone;
144    /// "" for none. The day lays it then, that day only (`at_on`); another day
145    /// it says nothing. Each one still to come becomes a block, once.
146    pub at: String,
147    /// When it was done, in Unix seconds.
148    pub completed: Option<i64>,
149    /// Minutes it takes, as estimated; 0 when unsaid.
150    pub estimate: u32,
151    /// The first estimate it was given (`X-SIOUL-ESTIMATE-FIRST`), written
152    /// once and never changed by Sioul: what time spent is compared with
153    /// (`capacity`). None for a task estimated before it was kept.
154    pub estimate_first: Option<u32>,
155    /// 1 (first) to 9 (last); 0 when unsaid (RFC 5545 §3.8.1.9).
156    pub priority: u8,
157    pub categories: Vec<String>,
158    /// One of `KINDS`, or "" when unsaid.
159    pub kind: String,
160    /// Only while offices are open: proposed then, planned on their days.
161    pub office_hours: bool,
162    /// Its office's own opening hours (`X-SIOUL-OFFICE-HOURS`), as
163    /// `window::parse_ranges` reads them: "mo-fr 09:00-12:00, 14:00-17:00";
164    /// "" for offices' usual hours.
165    pub office_times: String,
166    /// What it is for (`X-SIOUL-AREA`): "work", "admin", "leisure", "personal";
167    /// "" to go by its categories and projects (`areas::TaskAreas`).
168    pub area: String,
169    /// Its time billed or not (`X-SIOUL-BILLABLE`); None: as its project says
170    /// (work for a client is billed).
171    pub billable: Option<bool>,
172    /// What it takes (`X-SIOUL-ENERGY`): "light", "heavy", or "rest" for what
173    /// gives back (a walk, music); "" for the usual.
174    pub energy: String,
175    /// Minutes kept before and after it: getting there, getting ready (`demands`).
176    pub margins: crate::demands::Margins,
177    /// What it costs and gives back, rated 0 to 10 (`demands`).
178    pub demands: crate::demands::Demands,
179    /// How it went, said after it: one rating per day, the newest last, five at most (`demands::Felt`).
180    pub felt: Vec<crate::demands::Felt>,
181    pub relations: Vec<Relation>,
182    pub links: Vec<Link>,
183    pub contacts: Vec<ContactRef>,
184    /// Its projects (REFID).
185    pub projects: Vec<String>,
186    /// "daily", "weekly", "monthly", "yearly" when it comes back; "" otherwise.
187    pub repeat: String,
188    /// When it was made (CREATED, else DTSTAMP), in Unix seconds: the order among equals.
189    pub created: i64,
190    /// Its list: name, "account/id", colour, and whether it can be changed.
191    pub list: String,
192    pub list_id: String,
193    pub color: Option<String>,
194    pub read_only: bool,
195}
196
197impl Task {
198    /// The day it can start from.
199    pub fn start_date(&self) -> Option<Date> {
200        day_of(&self.start)
201    }
202
203    /// The day asked.
204    pub fn due_date(&self) -> Option<Date> {
205        day_of(&self.due)
206    }
207
208    /// The time given to it for `date` in the day view (Unix seconds, in
209    /// `zone`), when it was given for that day: a time given another day says nothing.
210    pub fn at_on(&self, date: Date, zone: &TimeZone) -> Option<i64> {
211        if day_of(&self.at) != Some(date) {
212            return None;
213        }
214        let local: DateTime = self.at.parse().ok()?;
215        local.to_zoned(zone.clone()).ok().map(|z| z.timestamp().as_second())
216    }
217
218    /// The task it is a step of.
219    pub fn parent(&self) -> Option<&str> {
220        self.relations.iter().find(|r| r.kind == "PARENT").map(|r| r.uid.as_str())
221    }
222
223    /// The tasks it says it waits for (DEPENDS-ON).
224    pub fn depends_on(&self) -> impl Iterator<Item = &Relation> {
225        self.relations.iter().filter(|r| r.kind == "DEPENDS-ON")
226    }
227}
228
229fn day_of(text: &str) -> Option<Date> {
230    text.get(..10)?.parse().ok()
231}
232
233/// The first estimate a task was given, written once (`Task::estimate_first`).
234pub const ESTIMATE_FIRST: &str = "X-SIOUL-ESTIMATE-FIRST";
235
236/// A time given to a task for one day by a drag in the day view (`Task::at`),
237/// in UTC, as Sioul wrote it before time blocks: read still, never written
238/// (a task is pinned to a time by a block, an event every calendar shows,
239/// `blocks`). Not DTSTART: RFC 5545 wants DUE of DTSTART's value type and
240/// after it, and CalDAV servers that check (Sabre: Nextcloud) refuse a task
241/// whose start has a time while its date asked is a date, or has passed.
242pub const AT: &str = "X-SIOUL-AT";
243
244/// An iCalendar duration in minutes (RFC 5545 §3.3.6): "PT15M" → 15,
245/// "P1DT2H" → 1560, "-P1W" → −10080. None when it is not one.
246pub fn minutes_of(text: &str) -> Option<i64> {
247    let text = text.trim();
248    let (sign, rest) = match text.strip_prefix('-') {
249        Some(rest) => (-1, rest),
250        None => (1, text.strip_prefix('+').unwrap_or(text)),
251    };
252    let rest = rest.strip_prefix(['P', 'p'])?;
253    let (mut total, mut number, mut seen) = (0i64, String::new(), false);
254    for c in rest.chars() {
255        match c.to_ascii_uppercase() {
256            '0'..='9' => number.push(c),
257            'T' => {}
258            unit @ ('W' | 'D' | 'H' | 'M' | 'S') => {
259                let n: i64 = number.parse().ok()?;
260                number.clear();
261                seen = true;
262                // Too long to count (a file from elsewhere): not a duration.
263                let minutes = match unit {
264                    'W' => n.checked_mul(7 * 1440)?,
265                    'D' => n.checked_mul(1440)?,
266                    'H' => n.checked_mul(60)?,
267                    'M' => n,
268                    _ => n / 60,
269                };
270                total = total.checked_add(minutes)?;
271            }
272            _ => return None,
273        }
274    }
275    (seen && number.is_empty()).then_some(sign * total)
276}
277
278/// "LIGHT", "heavy", "Rest" → "light", "heavy", "rest"; anything else, the usual: "".
279pub fn energy_of(text: &str) -> String {
280    let text = text.trim().to_ascii_lowercase();
281    if matches!(text.as_str(), "light" | "heavy" | "rest") { text } else { String::new() }
282}
283
284/// Minutes as an iCalendar duration: 15 → "PT15M", 90 → "PT1H30M", 20160 → "P14D".
285pub fn duration_of(minutes: i64) -> String {
286    let sign = if minutes < 0 { "-" } else { "" };
287    let m = minutes.abs();
288    if m > 0 && m % 1440 == 0 {
289        return format!("{sign}P{}D", m / 1440);
290    }
291    let (days, hours, mins) = (m / 1440, (m % 1440) / 60, m % 60);
292    let mut out = format!("{sign}P");
293    if days > 0 {
294        out.push_str(&format!("{days}D"));
295    }
296    out.push('T');
297    if hours > 0 {
298        out.push_str(&format!("{hours}H"));
299    }
300    if mins > 0 || hours == 0 {
301        out.push_str(&format!("{mins}M"));
302    }
303    out
304}
305
306/// The main VTODO, BEGIN to END: not a changed occurrence (RECURRENCE-ID).
307fn master_range(source: &[String]) -> Option<Range<usize>> {
308    let (mut start, mut nested, mut is_override) = (None, 0usize, false);
309    for (i, line) in source.iter().enumerate() {
310        let name = lines::name(line);
311        let value = lines::value(line).trim().to_ascii_uppercase();
312        match (name.as_str(), start) {
313            ("BEGIN", None) if value == "VTODO" => {
314                start = Some(i);
315                nested = 0;
316                is_override = false;
317            }
318            ("BEGIN", Some(_)) => nested += 1,
319            ("END", Some(_)) if nested > 0 => nested -= 1,
320            ("RECURRENCE-ID", Some(_)) if nested == 0 => is_override = true,
321            ("END", Some(begin)) if value == "VTODO" => {
322                if !is_override {
323                    return Some(begin..i + 1);
324                }
325                start = None;
326            }
327            _ => {}
328        }
329    }
330    None
331}
332
333/// The positions of the main VTODO's own lines: not its alarms'.
334fn own_lines(source: &[String], range: &Range<usize>) -> Vec<usize> {
335    let mut out = Vec::new();
336    let mut nested = 0usize;
337    for i in range.start + 1..range.end.saturating_sub(1) {
338        match lines::name(&source[i]).as_str() {
339            "BEGIN" => nested += 1,
340            "END" => nested = nested.saturating_sub(1),
341            _ if nested == 0 => out.push(i),
342            _ => {}
343        }
344    }
345    out
346}
347
348/// A DATE-TIME line in `zone`: UTC, with its TZID, or floating (taken in `zone`).
349fn zoned(line: &str, zone: &TimeZone) -> Option<Zoned> {
350    let value = lines::value(line).trim();
351    let civil = DateTime::strptime("%Y%m%dT%H%M%S", value.trim_end_matches(['Z', 'z'])).ok()?;
352    let source = if value.ends_with(['Z', 'z']) {
353        TimeZone::UTC
354    } else {
355        lines::param(line, "TZID").and_then(|z| TimeZone::get(z.trim_start_matches('/')).ok()).unwrap_or_else(|| zone.clone())
356    };
357    civil.to_zoned(source).ok().map(|z| z.with_time_zone(zone.clone()))
358}
359
360/// A DTSTART or DUE line in your time zone: "2026-10-30" for a date,
361/// "2026-10-30T17:00" for a time.
362fn moment(line: &str, zone: &TimeZone) -> Option<String> {
363    let value = lines::value(line).trim();
364    let is_date = lines::param(line, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("DATE")) || value.len() == 8;
365    if is_date {
366        return Date::strptime("%Y%m%d", value.get(..8)?).ok().map(|d| d.to_string());
367    }
368    zoned(line, zone).map(|z| z.strftime("%Y-%m-%dT%H:%M").to_string())
369}
370
371/// A comma-separated text list (CATEGORIES), its escaped commas kept inside.
372fn split_list(value: &str) -> Vec<String> {
373    let (mut out, mut current, mut escaped) = (Vec::new(), String::new(), false);
374    for c in value.chars() {
375        if escaped {
376            current.push('\\');
377            current.push(c);
378            escaped = false;
379            continue;
380        }
381        match c {
382            '\\' => escaped = true,
383            ',' => out.push(std::mem::take(&mut current)),
384            _ => current.push(c),
385        }
386    }
387    out.push(current);
388    out.into_iter().map(|p| lines::unescape(p.trim())).filter(|p| !p.is_empty()).collect()
389}
390
391/// A RELATED-TO line, read.
392pub fn relation_of(line: &str) -> Relation {
393    let kind = lines::param(line, "RELTYPE").map(|k| k.trim().to_ascii_uppercase()).filter(|k| !k.is_empty()).unwrap_or_else(|| "PARENT".into());
394    let gap = lines::param(line, "GAP").and_then(|g| minutes_of(&g)).unwrap_or(0);
395    Relation { kind, uid: lines::value(line).trim().to_string(), gap }
396}
397
398/// A LINK line, read.
399pub fn link_of(line: &str) -> Link {
400    let value = lines::value(line).trim().to_string();
401    let by_uid = lines::param(line, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("UID"));
402    Link { uri: if by_uid { format!("uid:{value}") } else { value }, label: lines::param(line, "LABEL").unwrap_or_default(), rel: lines::param(line, "LINKREL").unwrap_or_default() }
403}
404
405fn contact(line: &str) -> ContactRef {
406    ContactRef { name: lines::unescape(lines::value(line).trim()), uri: lines::param(line, "ALTREP").unwrap_or_default() }
407}
408
409/// "FREQ=WEEKLY;INTERVAL=2" → "weekly"; a rule without one of the four frequencies → "".
410fn repeat_of(rule: &str) -> String {
411    let frequency = rule.split(';').find_map(|p| p.trim().to_ascii_uppercase().strip_prefix("FREQ=").map(str::to_string)).unwrap_or_default();
412    match frequency.as_str() {
413        "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" => frequency.to_lowercase(),
414        _ => String::new(),
415    }
416}
417
418/// A task from the text of its file, its dates in `zone`; None when it holds no task.
419pub fn task_of_text(text: &str, zone: &TimeZone) -> Option<Task> {
420    let source = lines::unfold(text);
421    let range = master_range(&source)?;
422    let mut task = Task::default();
423    let (mut duration, mut stamp, mut start_line, mut has_status) = (None, 0, None, false);
424    let mut felt: Vec<(String, Option<Date>, String)> = Vec::new();
425    for i in own_lines(&source, &range) {
426        let line = &source[i];
427        let value = lines::value(line);
428        match lines::name(line).as_str() {
429            "UID" => task.uid = value.trim().to_string(),
430            "SUMMARY" => task.title = lines::unescape(value.trim()),
431            "DESCRIPTION" => task.notes = lines::unescape(value.trim()),
432            "LOCATION" => task.location = lines::unescape(value.trim()),
433            "STATUS" => {
434                task.status = Status::read(value);
435                has_status = true;
436            }
437            "DTSTART" => {
438                task.start = moment(line, zone).unwrap_or_default();
439                start_line = Some(line.clone());
440            }
441            "DUE" => task.due = moment(line, zone).unwrap_or_default(),
442            "DURATION" => duration = minutes_of(value),
443            "COMPLETED" => task.completed = zoned(line, zone).map(|z| z.timestamp().as_second()),
444            "ESTIMATED-DURATION" => task.estimate = minutes_of(value).map_or(0, |m| u32::try_from(m.max(0)).unwrap_or(u32::MAX)),
445            "PRIORITY" => task.priority = value.trim().parse::<u8>().unwrap_or(0).min(9),
446            "CATEGORIES" => task.categories.extend(split_list(value)),
447            "CONCEPT" => {
448                if let Some(kind) = kind_of_concept(value) {
449                    task.kind = kind;
450                }
451                if value.trim() == OFFICE_CONCEPT {
452                    task.office_hours = true;
453                }
454            }
455            "RELATED-TO" => task.relations.push(relation_of(line)),
456            "X-SIOUL-BILLABLE" => task.billable = Some(value.trim().eq_ignore_ascii_case("TRUE")),
457            "X-SIOUL-ENERGY" => task.energy = energy_of(value),
458            "X-SIOUL-OFFICE-HOURS" => task.office_times = crate::window::ranges_text(&crate::window::parse_ranges(&lines::unescape(value))),
459            "X-SIOUL-AREA" => task.area = crate::areas::Area::parse(value).map(|a| a.id()).unwrap_or_default(),
460            crate::demands::BEFORE => task.margins.before = crate::demands::minutes_of(value),
461            crate::demands::AFTER => task.margins.after = crate::demands::minutes_of(value),
462            crate::demands::COST => task.demands.read_cost(value),
463            crate::demands::GAIN => task.demands.read_gain(value),
464            name @ (crate::demands::FELT_COST | crate::demands::FELT_GAIN) => felt.push((name.to_string(), lines::param(line, crate::demands::FELT_ON).and_then(|d| crate::demands::felt_date(&d)), value.to_string())),
465            ESTIMATE_FIRST => task.estimate_first = minutes_of(value).and_then(|m| u32::try_from(m).ok()).filter(|m| *m > 0),
466            AT => task.at = zoned(line, zone).map(|z| z.strftime("%Y-%m-%dT%H:%M").to_string()).unwrap_or_default(),
467            "LINK" => task.links.push(link_of(line)),
468            "CONTACT" => task.contacts.push(contact(line)),
469            "REFID" => task.projects.push(lines::unescape(value.trim())),
470            "RRULE" => task.repeat = repeat_of(value),
471            "CREATED" => task.created = zoned(line, zone).map_or(0, |z| z.timestamp().as_second()),
472            "DTSTAMP" => stamp = zoned(line, zone).map_or(0, |z| z.timestamp().as_second()),
473            _ => {}
474        }
475    }
476    if task.created == 0 {
477        task.created = stamp;
478    }
479    task.felt = crate::demands::felt_of(&felt);
480    // DURATION with DTSTART stands for DUE (RFC 5545 §3.6.2).
481    if task.due.is_empty()
482        && let (Some(minutes), Some(line)) = (duration, start_line)
483    {
484        // A duration too long for a date gives none, never a crash.
485        task.due = match (task.start.len(), task.start_date()) {
486            (10, Some(date)) => Span::new().try_days(minutes / 1440).ok().and_then(|span| date.checked_add(span).ok()).map(|d| d.to_string()).unwrap_or_default(),
487            _ => zoned(&line, zone).zip(Span::new().try_minutes(minutes).ok()).and_then(|(z, span)| z.checked_add(span).ok()).map(|z| z.strftime("%Y-%m-%dT%H:%M").to_string()).unwrap_or_default(),
488        };
489    }
490    // Older clients say done with COMPLETED alone.
491    if task.completed.is_some() && !has_status {
492        task.status = Status::Completed;
493    }
494    Some(task)
495}
496
497/// What the task form gives back, and what a task is as the form shows it.
498#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
499pub struct TaskEdit {
500    pub title: String,
501    #[serde(default)]
502    pub notes: String,
503    #[serde(default)]
504    pub location: String,
505    #[serde(default)]
506    pub status: Status,
507    /// "", "2026-10-05" or "2026-10-05T09:00", in your time zone.
508    #[serde(default)]
509    pub start: String,
510    #[serde(default)]
511    pub due: String,
512    /// A time given for one day in the day view before blocks: "2026-10-06T14:30",
513    /// or "" (`Task::at`). Read; changed, its line goes and none is written.
514    #[serde(default)]
515    pub at: String,
516    /// Minutes; 0 when unsaid.
517    #[serde(default)]
518    pub estimate: u32,
519    #[serde(default)]
520    pub priority: u8,
521    #[serde(default)]
522    pub categories: Vec<String>,
523    /// One of `KINDS`, or "".
524    #[serde(default)]
525    pub kind: String,
526    #[serde(default)]
527    pub office_hours: bool,
528    /// Its office's opening hours: "mo-fr 09:00-17:00"; "" for the usual.
529    #[serde(default)]
530    pub office_times: String,
531    /// "work", "admin", "leisure", "personal"; "" to go by its categories.
532    #[serde(default)]
533    pub area: String,
534    /// Billed or not; None: as its project says.
535    #[serde(default)]
536    pub billable: Option<bool>,
537    /// "light", "heavy", "rest", or "" for the usual.
538    #[serde(default)]
539    pub energy: String,
540    /// Minutes kept before and after it.
541    #[serde(default)]
542    pub margins: crate::demands::Margins,
543    /// What it costs and gives back, 0 to 10 each; none unsaid.
544    #[serde(default)]
545    pub demands: crate::demands::Demands,
546    /// How it went, the newest rating said after it: shown, never written by
547    /// the form (`set_felt` writes it, dated).
548    #[serde(default)]
549    pub felt: crate::demands::Demands,
550    /// The task it is a step of, by UID; "" for none.
551    #[serde(default)]
552    pub parent: String,
553    /// The tasks it waits for, by UID (DEPENDS-ON).
554    #[serde(default)]
555    pub waits_for: Vec<String>,
556    #[serde(default)]
557    pub links: Vec<Link>,
558    #[serde(default)]
559    pub contacts: Vec<ContactRef>,
560    /// Its projects, by id (REFID); `cases`, the name before, reads too.
561    #[serde(default, alias = "cases")]
562    pub projects: Vec<String>,
563    /// "", "daily", "weekly", "monthly", "yearly".
564    #[serde(default)]
565    pub repeat: String,
566    /// The gaps the form gave its waits, in minutes, by the UID waited for
567    /// ("the answer comes within two weeks"): such a wait goes in the task
568    /// waited for, as FINISHTOSTART with its GAP (`set_gap`, written by the
569    /// window once this task is), never as a DEPENDS-ON here. Never read back
570    /// from a task: its details say each wait's gap (`taskview`).
571    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
572    pub wait_gaps: BTreeMap<String, i64>,
573}
574
575impl TaskEdit {
576    /// The gap the form gave its wait for `uid`, in minutes; 0 without one.
577    pub fn gap_of(&self, uid: &str) -> i64 {
578        self.wait_gaps.get(uid).copied().unwrap_or(0).max(0)
579    }
580
581    /// The waits with a gap, (UID waited for, minutes): what the window
582    /// writes in those tasks once this one is written.
583    pub fn gapped_waits(&self) -> Vec<(String, i64)> {
584        self.waits_for.iter().filter(|u| self.gap_of(u) > 0).map(|u| (u.clone(), self.gap_of(u))).collect()
585    }
586
587    /// A task as the form shows it.
588    pub fn of(task: &Task) -> TaskEdit {
589        TaskEdit {
590            title: task.title.clone(),
591            notes: task.notes.clone(),
592            location: task.location.clone(),
593            status: task.status,
594            start: task.start.clone(),
595            due: task.due.clone(),
596            at: task.at.clone(),
597            estimate: task.estimate,
598            priority: task.priority,
599            categories: task.categories.clone(),
600            kind: task.kind.clone(),
601            office_hours: task.office_hours,
602            office_times: task.office_times.clone(),
603            area: task.area.clone(),
604            billable: task.billable,
605            energy: task.energy.clone(),
606            margins: task.margins,
607            demands: task.demands,
608            felt: task.felt.last().map(|f| f.demands).unwrap_or_default(),
609            parent: task.parent().unwrap_or_default().to_string(),
610            waits_for: task.depends_on().map(|r| r.uid.clone()).collect(),
611            links: task.links.clone(),
612            contacts: task.contacts.clone(),
613            projects: task.projects.clone(),
614            repeat: task.repeat.clone(),
615            wait_gaps: BTreeMap::new(),
616        }
617    }
618
619    /// Trimmed, without empty or repeated entries, as it will be read back.
620    fn tidy(&self) -> TaskEdit {
621        fn unique<T: PartialEq + Clone>(items: impl Iterator<Item = T>) -> Vec<T> {
622            let mut out: Vec<T> = Vec::new();
623            for item in items {
624                if !out.contains(&item) {
625                    out.push(item);
626                }
627            }
628            out
629        }
630        TaskEdit {
631            title: self.title.trim().to_string(),
632            notes: self.notes.trim().to_string(),
633            location: self.location.trim().to_string(),
634            start: self.start.trim().to_string(),
635            due: self.due.trim().to_string(),
636            at: self.at.trim().to_string(),
637            priority: self.priority.min(9),
638            categories: unique(self.categories.iter().map(|c| c.trim().to_string()).filter(|c| !c.is_empty())),
639            kind: Some(self.kind.trim()).filter(|k| is_kind_id(k)).map(str::to_string).unwrap_or_default(),
640            office_hours: self.office_hours,
641            office_times: if self.office_hours { crate::window::ranges_text(&crate::window::parse_ranges(&self.office_times)) } else { String::new() },
642            area: crate::areas::Area::parse(&self.area).map(|a| a.id()).unwrap_or_default(),
643            margins: crate::demands::Margins { before: self.margins.before.min(24 * 60), after: self.margins.after.min(24 * 60) },
644            demands: self.demands.bounded(),
645            felt: self.felt.bounded(),
646            // Heavy or light follows every cost once one is rated: its highest (docs/capacity.md).
647            energy: self.demands.level().map_or_else(|| energy_of(&self.energy), |level| level.energy().to_string()),
648            parent: self.parent.trim().to_string(),
649            waits_for: unique(self.waits_for.iter().map(|u| u.trim().to_string()).filter(|u| !u.is_empty())),
650            links: unique(self.links.iter().filter(|l| !l.uri.trim().is_empty()).map(|l| Link { uri: l.uri.trim().to_string(), label: l.label.trim().to_string(), rel: l.rel.trim().to_string() })),
651            contacts: unique(self.contacts.iter().filter(|c| !c.name.trim().is_empty() || !c.uri.trim().is_empty()).map(|c| ContactRef { name: c.name.trim().to_string(), uri: c.uri.trim().to_string() })),
652            projects: unique(self.projects.iter().map(|c| c.trim().to_string()).filter(|c| !c.is_empty())),
653            repeat: self.repeat.trim().to_lowercase(),
654            ..self.clone()
655        }
656    }
657}
658
659fn utc(at: &Zoned) -> String {
660    at.timestamp().strftime("%Y%m%dT%H%M%SZ").to_string()
661}
662
663/// A DTSTART or DUE line: a date as a DATE, a time in UTC.
664fn date_line(name: &str, text: &str, zone: &TimeZone) -> Result<String, String> {
665    let bad = || format!("{name}: {text}?");
666    if text.len() == 10 {
667        let date: Date = text.parse().map_err(|_| bad())?;
668        return Ok(format!("{name};VALUE=DATE:{}", date.strftime("%Y%m%d")));
669    }
670    let local: DateTime = text.parse().map_err(|_| bad())?;
671    Ok(format!("{name}:{}", utc(&local.to_zoned(zone.clone()).map_err(|_| bad())?)))
672}
673
674/// A value written as it is (a UID, an address): without line breaks or
675/// other control characters, which would end its line and start another
676/// (an address decoded from `%0D%0A`, a key from an imported file).
677fn one_line(value: &str) -> String {
678    value.chars().filter(|c| !c.is_control()).collect()
679}
680
681/// `RELATED-TO;RELTYPE=<kind>[;GAP=…]:<uid>`.
682pub fn relation_line(kind: &str, uid: &str, gap_minutes: i64) -> String {
683    let gap = if gap_minutes == 0 { String::new() } else { format!(";GAP={}", duration_of(gap_minutes)) };
684    format!("RELATED-TO;RELTYPE={kind}{gap}:{}", one_line(uid))
685}
686
687/// A wait with its gap, written in the task that comes first (`text`), as
688/// RFC 9253 §4 places temporal links: `RELATED-TO;RELTYPE=FINISHTOSTART;GAP=P14D:<successor>`
689/// ("the answer comes within two weeks"; no GAP for 0), any other FINISHTOSTART
690/// or NEXT to the same task replaced. `sioul tasks import` and the window's
691/// form of a wait write it so; the text as it was when it says so already.
692pub fn set_gap(text: &str, successor: &str, gap_minutes: i64, now: &Zoned) -> Result<String, String> {
693    let line = relation_line("FINISHTOSTART", successor, gap_minutes);
694    let other = |l: &str| lines::name(l) == "RELATED-TO" && matches!(relation_of(l).kind.as_str(), "FINISHTOSTART" | "NEXT") && relation_of(l).uid == successor && l != line;
695    let without = remove_lines(text, other, now)?;
696    add_lines(&without, std::slice::from_ref(&line), now)
697}
698
699/// `LINK;LINKREL=…;LABEL=…;VALUE=URI:<uri>`, or `VALUE=UID` for `uid:<uid>`.
700pub fn link_line(link: &Link) -> String {
701    let rel = if link.rel.is_empty() { "related".to_string() } else { lines::param_value(&link.rel) };
702    let label = if link.label.is_empty() { String::new() } else { format!(";LABEL={}", lines::param_value(&link.label)) };
703    let uri = one_line(&link.uri);
704    match uri.strip_prefix("uid:") {
705        Some(uid) => format!("LINK;LINKREL={rel}{label};VALUE=UID:{uid}"),
706        None => format!("LINK;LINKREL={rel}{label};VALUE=URI:{uri}"),
707    }
708}
709
710/// `CONTACT;ALTREP="<uri>":<name>`.
711pub fn contact_line(contact: &ContactRef) -> String {
712    let altrep = if contact.uri.is_empty() { String::new() } else { format!(";ALTREP=\"{}\"", one_line(&contact.uri).replace('"', "%22")) };
713    format!("CONTACT{altrep}:{}", lines::escape(&contact.name))
714}
715
716/// The status lines: STATUS, and COMPLETED with PERCENT-COMPLETE when done.
717fn status_lines(status: Status, now: &Zoned) -> Vec<String> {
718    let mut out = vec![format!("STATUS:{}", status.written())];
719    if status == Status::Completed {
720        out.extend([format!("COMPLETED:{}", utc(now)), "PERCENT-COMPLETE:100".to_string()]);
721    }
722    out
723}
724
725/// What the form changed, field by field.
726#[derive(Clone, Copy)]
727struct Changed {
728    title: bool,
729    notes: bool,
730    location: bool,
731    status: bool,
732    start: bool,
733    due: bool,
734    at: bool,
735    estimate: bool,
736    priority: bool,
737    categories: bool,
738    kind: bool,
739    office: bool,
740    office_times: bool,
741    area: bool,
742    billable: bool,
743    energy: bool,
744    margins: bool,
745    demands: bool,
746    repeat: bool,
747}
748
749impl Changed {
750    fn between(old: &TaskEdit, new: &TaskEdit) -> Changed {
751        Changed {
752            title: old.title != new.title,
753            notes: old.notes != new.notes,
754            location: old.location != new.location,
755            status: old.status != new.status,
756            start: old.start != new.start,
757            due: old.due != new.due,
758            at: old.at != new.at,
759            estimate: old.estimate != new.estimate,
760            priority: old.priority != new.priority,
761            categories: old.categories != new.categories,
762            kind: old.kind != new.kind,
763            office: old.office_hours != new.office_hours,
764            office_times: old.office_times != new.office_times || old.office_hours != new.office_hours,
765            area: old.area != new.area,
766            billable: old.billable != new.billable,
767            energy: old.energy != new.energy,
768            margins: old.margins != new.margins,
769            demands: old.demands != new.demands,
770            repeat: old.repeat != new.repeat,
771        }
772    }
773
774    const ALL: Changed = Changed { title: true, notes: true, location: true, status: true, start: true, due: true, at: true, estimate: true, priority: true, categories: true, kind: true, office: true, office_times: true, area: true, billable: true, energy: true, margins: true, demands: true, repeat: true };
775
776    /// Whether a line of this name is written again.
777    fn rewrites(&self, name: &str) -> bool {
778        match name {
779            "SUMMARY" => self.title,
780            "DESCRIPTION" => self.notes,
781            "LOCATION" => self.location,
782            "STATUS" | "COMPLETED" | "PERCENT-COMPLETE" => self.status,
783            "DTSTART" => self.start || self.due,
784            "DUE" | "DURATION" => self.start || self.due,
785            AT => self.at,
786            "ESTIMATED-DURATION" => self.estimate,
787            "PRIORITY" => self.priority,
788            "CATEGORIES" => self.categories,
789            "X-SIOUL-BILLABLE" => self.billable,
790            "X-SIOUL-ENERGY" => self.energy,
791            "X-SIOUL-OFFICE-HOURS" => self.office_times,
792            "X-SIOUL-AREA" => self.area,
793            crate::demands::BEFORE | crate::demands::AFTER => self.margins,
794            crate::demands::COST | crate::demands::GAIN => self.demands,
795            "RRULE" => self.repeat,
796            _ => false,
797        }
798    }
799}
800
801/// The lines of the fields that changed, as the form has them.
802fn field_lines(edit: &TaskEdit, changed: Changed, zone: &TimeZone, now: &Zoned) -> Result<Vec<String>, String> {
803    let mut out = Vec::new();
804    if changed.title {
805        out.push(format!("SUMMARY:{}", lines::escape(&edit.title)));
806    }
807    if changed.notes && !edit.notes.is_empty() {
808        out.push(format!("DESCRIPTION:{}", lines::escape(&edit.notes)));
809    }
810    if changed.location && !edit.location.is_empty() {
811        out.push(format!("LOCATION:{}", lines::escape(&edit.location)));
812    }
813    if changed.status {
814        out.extend(status_lines(edit.status, now));
815    }
816    if (changed.start || changed.due) && !edit.start.is_empty() {
817        out.push(date_line("DTSTART", &edit.start, zone)?);
818    }
819    if (changed.start || changed.due) && !edit.due.is_empty() {
820        out.push(date_line("DUE", &edit.due, zone)?);
821    }
822    // A time given by a drag before blocks (`AT`) is never written: changed, its line only goes.
823    if changed.estimate && edit.estimate > 0 {
824        out.push(format!("ESTIMATED-DURATION:{}", duration_of(i64::from(edit.estimate))));
825    }
826    if changed.priority && edit.priority > 0 {
827        out.push(format!("PRIORITY:{}", edit.priority));
828    }
829    if changed.categories && !edit.categories.is_empty() {
830        out.push(format!("CATEGORIES:{}", edit.categories.iter().map(|c| lines::escape(c)).collect::<Vec<_>>().join(",")));
831    }
832    if changed.kind && !edit.kind.is_empty() {
833        out.push(format!("CONCEPT:{KIND_CONCEPT}{}", edit.kind));
834    }
835    if changed.office && edit.office_hours {
836        out.push(format!("CONCEPT:{OFFICE_CONCEPT}"));
837    }
838    if changed.office_times && edit.office_hours && !edit.office_times.is_empty() {
839        out.push(format!("X-SIOUL-OFFICE-HOURS:{}", lines::escape(&edit.office_times)));
840    }
841    if changed.area && !edit.area.is_empty() {
842        out.push(format!("X-SIOUL-AREA:{}", edit.area.to_ascii_uppercase()));
843    }
844    if changed.billable && let Some(billable) = edit.billable {
845        out.push(format!("X-SIOUL-BILLABLE:{}", if billable { "TRUE" } else { "FALSE" }));
846    }
847    if changed.energy && !energy_of(&edit.energy).is_empty() {
848        out.push(format!("X-SIOUL-ENERGY:{}", energy_of(&edit.energy).to_ascii_uppercase()));
849    }
850    if changed.margins {
851        if edit.margins.before > 0 {
852            out.push(format!("{}:{}", crate::demands::BEFORE, edit.margins.before));
853        }
854        if edit.margins.after > 0 {
855            out.push(format!("{}:{}", crate::demands::AFTER, edit.margins.after));
856        }
857    }
858    if changed.demands {
859        if edit.demands.has_cost() {
860            out.push(format!("{}:{}", crate::demands::COST, edit.demands.cost_value()));
861        }
862        if let Some(gain) = edit.demands.gain {
863            out.push(format!("{}:{gain}", crate::demands::GAIN));
864        }
865    }
866    if changed.repeat && matches!(edit.repeat.as_str(), "daily" | "weekly" | "monthly" | "yearly") {
867        out.push(format!("RRULE:FREQ={}", edit.repeat.to_ascii_uppercase()));
868    }
869    Ok(out)
870}
871
872/// A new task, with its own UID.
873pub fn new_task(edit: &TaskEdit, uid: &str, zone: &TimeZone, now: &Zoned) -> Result<String, String> {
874    let edit = edit.tidy();
875    let mut out = vec!["BEGIN:VCALENDAR".to_string(), "VERSION:2.0".to_string(), "PRODID:-//Sioul//Sioul//EN".to_string(), "BEGIN:VTODO".to_string()];
876    out.extend([format!("UID:{}", one_line(uid)), format!("DTSTAMP:{}", utc(now)), format!("CREATED:{}", utc(now)), format!("LAST-MODIFIED:{}", utc(now))]);
877    out.extend(field_lines(&edit, Changed::ALL, zone, now)?);
878    if edit.estimate > 0 {
879        out.push(format!("{ESTIMATE_FIRST}:{}", duration_of(i64::from(edit.estimate))));
880    }
881    if !edit.parent.is_empty() {
882        out.push(relation_line("PARENT", &edit.parent, 0));
883    }
884    // A wait with a gap goes in the task waited for (`TaskEdit::wait_gaps`).
885    out.extend(edit.waits_for.iter().filter(|u| edit.gap_of(u) == 0).map(|uid| relation_line("DEPENDS-ON", uid, 0)));
886    out.extend(edit.links.iter().map(link_line));
887    out.extend(edit.contacts.iter().map(contact_line));
888    out.extend(edit.projects.iter().map(|c| format!("REFID:{}", lines::escape(c))));
889    out.extend(["END:VTODO".to_string(), "END:VCALENDAR".to_string()]);
890    Ok(lines::fold(&out))
891}
892
893/// Lines rewritten at every change (RFC 5545 §3.8.7).
894const STAMPS: &[&str] = &["DTSTAMP", "LAST-MODIFIED", "SEQUENCE"];
895
896/// The task's text with the main VTODO's lines that `keep` refuses dropped and
897/// `added` put in, before its alarms; the stamps written again. Nothing
898/// dropped and nothing added: the text as it was.
899fn rewrite(text: &str, keep: impl Fn(&str) -> bool, added: Vec<String>, now: &Zoned) -> Result<String, String> {
900    let source = lines::unfold(text);
901    let range = master_range(&source).ok_or("no task")?;
902    let own = own_lines(&source, &range);
903    let dropped: Vec<usize> = own.iter().copied().filter(|&i| !STAMPS.contains(&lines::name(&source[i]).as_str()) && !keep(&source[i])).collect();
904    if dropped.is_empty() && added.is_empty() {
905        return Ok(text.to_string());
906    }
907    let sequence = own.iter().find(|&&i| lines::name(&source[i]) == "SEQUENCE").and_then(|&i| lines::value(&source[i]).trim().parse::<u32>().ok()).unwrap_or(0);
908    let stamps: Vec<usize> = own.iter().copied().filter(|&i| STAMPS.contains(&lines::name(&source[i]).as_str())).collect();
909    // New lines go after the task's last own line, before its alarms.
910    let at = own.last().map_or(range.start + 1, |&i| i + 1);
911    let mut out = Vec::with_capacity(source.len() + added.len() + 3);
912    for (i, line) in source.iter().enumerate() {
913        if i == at {
914            out.extend([format!("DTSTAMP:{}", utc(now)), format!("LAST-MODIFIED:{}", utc(now)), format!("SEQUENCE:{}", sequence.saturating_add(1))]);
915            out.extend(added.iter().cloned());
916        }
917        if !dropped.contains(&i) && !stamps.contains(&i) {
918            out.push(line.clone());
919        }
920    }
921    Ok(lines::fold(&out))
922}
923
924/// The task's text with the form's changes: only the lines of what changed
925/// are written again; nothing changed, nothing is written.
926pub fn apply(text: &str, edit: &TaskEdit, zone: &TimeZone, now: &Zoned) -> Result<String, String> {
927    let before = task_of_text(text, zone).ok_or("no task")?;
928    let (mut old, mut edit) = (TaskEdit::of(&before).tidy(), edit.tidy());
929    // The word as the file has it: rated costs then write their level over a stale one.
930    old.energy = before.energy.clone();
931    // How it went is written by `set_felt` only, never by the form.
932    edit.felt = old.felt;
933    if old == edit {
934        return Ok(text.to_string());
935    }
936    // A time given for a day gone by says nothing: written for another reason, the task leaves it.
937    if day_of(&edit.at).is_some_and(|day| day < now.date()) {
938        edit.at = String::new();
939    }
940    let changed = Changed::between(&old, &edit);
941    let mut added = field_lines(&edit, changed, zone, now)?;
942    // Its first estimate, the first time it has one; never changed after.
943    if changed.estimate && before.estimate == 0 && before.estimate_first.is_none() && edit.estimate > 0 {
944        added.push(format!("{ESTIMATE_FIRST}:{}", duration_of(i64::from(edit.estimate))));
945    }
946    if edit.parent != old.parent && !edit.parent.is_empty() {
947        added.push(relation_line("PARENT", &edit.parent, 0));
948    }
949    added.extend(edit.waits_for.iter().filter(|u| !old.waits_for.contains(u) && edit.gap_of(u) == 0).map(|uid| relation_line("DEPENDS-ON", uid, 0)));
950    added.extend(edit.links.iter().filter(|l| !old.links.contains(l)).map(link_line));
951    added.extend(edit.contacts.iter().filter(|c| !old.contacts.contains(c)).map(contact_line));
952    added.extend(edit.projects.iter().filter(|c| !old.projects.contains(c)).map(|c| format!("REFID:{}", lines::escape(c))));
953    let keep = |line: &str| {
954        let name = lines::name(line);
955        match name.as_str() {
956            "RELATED-TO" => {
957                let r = relation_of(line);
958                match r.kind.as_str() {
959                    "PARENT" => r.uid == edit.parent,
960                    "DEPENDS-ON" => edit.waits_for.contains(&r.uid) && edit.gap_of(&r.uid) == 0,
961                    _ => true,
962                }
963            }
964            "LINK" => edit.links.contains(&link_of(line)),
965            "CONTACT" => edit.contacts.contains(&contact(line)),
966            "REFID" => edit.projects.contains(&lines::unescape(lines::value(line).trim())),
967            // Only Sioul's own concepts, the kind and the office hours, are written again; others stay.
968            "CONCEPT" => {
969                let value = lines::value(line);
970                !((changed.kind && kind_of_concept(value).is_some()) || (changed.office && value.trim() == OFFICE_CONCEPT))
971            }
972            other => !changed.rewrites(other),
973        }
974    };
975    rewrite(text, keep, added, now)
976}
977
978/// The task marked done, started, open again or dropped. A repeating task
979/// done comes back instead, its dates moved to their next turn after today.
980pub fn set_status(text: &str, status: Status, zone: &TimeZone, now: &Zoned) -> Result<String, String> {
981    let task = task_of_text(text, zone).ok_or("no task")?;
982    let mut edit = TaskEdit::of(&task);
983    edit.status = status;
984    if status == Status::Completed && !task.repeat.is_empty() {
985        let source = lines::unfold(text);
986        let rule = source.iter().find(|l| lines::name(l) == "RRULE").map(|l| lines::value(l).to_string()).unwrap_or_default();
987        let (start, due) = next_turn(&task, &rule, now.date());
988        // A rule that ends (UNTIL, as another application may write it) before the next turn: done for good.
989        let until = rule.to_ascii_uppercase().split(';').find_map(|p| p.trim().strip_prefix("UNTIL=").and_then(|v| Date::strptime("%Y%m%d", v.get(..8)?).ok()));
990        if !until.is_some_and(|until| day_of(&start).or_else(|| day_of(&due)).is_some_and(|next| next > until)) {
991            edit.start = start;
992            edit.due = due;
993            // A time given for one day in the day view stays with that day.
994            edit.at = String::new();
995            edit.status = Status::NeedsAction;
996        }
997    }
998    apply(text, &edit, zone, now)
999}
1000
1001/// A repeating task's dates at their next turn after `today`, the distance
1002/// between them kept; one without dates waits one turn from today.
1003fn next_turn(task: &Task, rule: &str, today: Date) -> (String, String) {
1004    let upper = rule.to_ascii_uppercase();
1005    let interval: i64 = upper.split(';').find_map(|p| p.trim().strip_prefix("INTERVAL=").and_then(|n| n.parse().ok())).unwrap_or(1).max(1);
1006    // `k` turns later; none when the rule (the file's) goes past any date.
1007    let step = |k: i64| -> Option<Span> {
1008        let n = k.checked_mul(interval)?;
1009        match task.repeat.as_str() {
1010            "daily" => Span::new().try_days(n),
1011            "weekly" => Span::new().try_weeks(n),
1012            "monthly" => Span::new().try_months(n),
1013            _ => Span::new().try_years(n),
1014        }
1015        .ok()
1016    };
1017    let moved = |text: &str, k: i64| -> String {
1018        let Some(date) = day_of(text) else { return String::new() };
1019        let Some(next) = step(k).and_then(|span| date.checked_add(span).ok()) else { return text.to_string() };
1020        format!("{next}{}", text.get(10..).unwrap_or(""))
1021    };
1022    let Some(anchor) = task.due_date().or_else(|| task.start_date()) else {
1023        return (step(1).and_then(|span| today.checked_add(span).ok()).map(|d| d.to_string()).unwrap_or_default(), String::new());
1024    };
1025    let k = (1..=10_000).find(|&k| step(k).and_then(|span| anchor.checked_add(span).ok()).is_some_and(|d| d > today)).unwrap_or(1);
1026    (moved(&task.start, k), moved(&task.due, k))
1027}
1028
1029/// The felt ratings kept in a task's file: the newest five days.
1030pub const FELT_KEPT: usize = 5;
1031
1032/// How it went, said after the task on `on`: that day's rating replaced (a
1033/// rating with nothing said takes it away), the newest `FELT_KEPT` days
1034/// kept, everything else left as it is.
1035pub fn set_felt(text: &str, felt: &crate::demands::Demands, on: Date, now: &Zoned) -> Result<String, String> {
1036    use crate::demands::{FELT_COST, FELT_GAIN, FELT_ON, felt_date};
1037    let felt = felt.bounded();
1038    let source = lines::unfold(text);
1039    let range = master_range(&source).ok_or("no task")?;
1040    let is_felt = |line: &str| matches!(lines::name(line).as_str(), FELT_COST | FELT_GAIN);
1041    let date_of = |line: &str| lines::param(line, FELT_ON).and_then(|d| felt_date(&d));
1042    // The days rated, other than `on`, newest first; what passes the newest kept, dropped.
1043    let mut days: Vec<Option<Date>> = own_lines(&source, &range).into_iter().map(|i| &source[i]).filter(|l| is_felt(l)).map(|l| date_of(l)).filter(|d| *d != Some(on)).collect();
1044    days.sort_unstable_by(|a, b| b.cmp(a));
1045    days.dedup();
1046    let room = if felt.is_empty() { FELT_KEPT } else { FELT_KEPT - 1 };
1047    let kept: Vec<Option<Date>> = days.into_iter().take(room).collect();
1048    let day = on.strftime("%Y%m%d").to_string();
1049    let mut added = Vec::new();
1050    if felt.has_cost() {
1051        added.push(format!("{FELT_COST};{FELT_ON}={day}:{}", felt.cost_value()));
1052    }
1053    if let Some(gain) = felt.gain {
1054        added.push(format!("{FELT_GAIN};{FELT_ON}={day}:{gain}"));
1055    }
1056    let drop = |line: &str| is_felt(line) && (date_of(line) == Some(on) || !kept.contains(&date_of(line)));
1057    // The same rating said again, nothing past the newest kept: the text as it was.
1058    let dropped: Vec<String> = own_lines(&source, &range).into_iter().map(|i| source[i].clone()).filter(|l| drop(l)).collect();
1059    if dropped == added {
1060        return Ok(text.to_string());
1061    }
1062    replace_lines(text, drop, added, now)
1063}
1064
1065/// A task done at another time than now (an import of what was done before):
1066/// its COMPLETED line set to `at`; the text as it was when it already says so.
1067pub fn completed_at(text: &str, at: &Zoned) -> Result<String, String> {
1068    let wanted = format!("COMPLETED:{}", utc(at));
1069    if lines::unfold(text).contains(&wanted) {
1070        return Ok(text.to_string());
1071    }
1072    let without = rewrite(text, |l| lines::name(l) != "COMPLETED", Vec::new(), at)?;
1073    rewrite(&without, |_| true, vec![wanted], at)
1074}
1075
1076/// The main VTODO with `added` lines, those not there yet; the text as it was when all are.
1077pub fn add_lines(text: &str, added: &[String], now: &Zoned) -> Result<String, String> {
1078    let present: Vec<String> = lines::unfold(text);
1079    let new: Vec<String> = added.iter().filter(|l| !present.contains(l)).cloned().collect();
1080    rewrite(text, |_| true, new, now)
1081}
1082
1083/// The main VTODO without the lines `drop` picks; the text as it was when none is.
1084pub fn remove_lines(text: &str, drop: impl Fn(&str) -> bool, now: &Zoned) -> Result<String, String> {
1085    rewrite(text, |l| !drop(l), Vec::new(), now)
1086}
1087
1088/// The main VTODO's lines that `drop` takes replaced by `added`, in one
1089/// change: the stamps written once.
1090pub fn replace_lines(text: &str, drop: impl Fn(&str) -> bool, added: Vec<String>, now: &Zoned) -> Result<String, String> {
1091    rewrite(text, |l| !drop(l), added, now)
1092}
1093
1094/// The calendars that hold tasks.
1095pub fn lists() -> Vec<Collection> {
1096    vdir::collections(Kind::Calendars).into_iter().filter(|c| c.holds("VTODO")).collect()
1097}
1098
1099/// One task from its file; None when the file holds an event.
1100pub fn read(path: &Path, list: &Collection, zone: &TimeZone) -> Option<Task> {
1101    let text = std::fs::read_to_string(path).ok()?;
1102    if !text.to_ascii_uppercase().contains("BEGIN:VTODO") {
1103        return None;
1104    }
1105    let mut task = task_of_text(&text, zone)?;
1106    task.key = path.display().to_string();
1107    task.list = list.name.clone();
1108    task.list_id = format!("{}/{}", list.account, list.id);
1109    task.color = list.color.clone();
1110    task.read_only = list.read_only;
1111    Some(task)
1112}
1113
1114/// Every task of every list.
1115pub fn all(zone: &TimeZone) -> Vec<Task> {
1116    lists().iter().flat_map(|list| list.items().into_iter().filter_map(|path| read(&path, list, zone)).collect::<Vec<_>>()).collect()
1117}
1118
1119/// Where a new task goes: a writable list made for tasks, then one that also
1120/// takes them, then one that does not say.
1121pub fn default_list() -> Option<Collection> {
1122    let writable: Vec<Collection> = lists().into_iter().filter(|c| !c.read_only).collect();
1123    let only_tasks = |c: &&Collection| c.components.len() == 1 && c.holds("VTODO");
1124    let says_tasks = |c: &&Collection| c.components.iter().any(|x| x.eq_ignore_ascii_case("VTODO"));
1125    writable.iter().find(only_tasks).or_else(|| writable.iter().find(says_tasks)).or(writable.first()).cloned()
1126}
1127
1128/// The file a new task gets in a list.
1129pub fn new_path(list: &Collection) -> PathBuf {
1130    list.dir.join(format!("{}.ics", vdir::new_name()))
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136
1137    #[test]
1138    fn a_kind_is_a_concept() {
1139        let zone = TimeZone::UTC;
1140        let now = Zoned::now();
1141        let made = new_task(&TaskEdit { title: "Call the bank".into(), kind: "call".into(), ..TaskEdit::default() }, "k", &zone, &now).unwrap();
1142        assert!(made.contains("CONCEPT:tag:aurelienpierre.com,2026:sioul/task-type/call"), "{made}");
1143        let other = made.replace("END:VTODO", "CONCEPT:https://example.org/concepts/money\r\nEND:VTODO");
1144        let task = task_of_text(&other, &zone).unwrap();
1145        assert_eq!(task.kind, "call");
1146        let changed = apply(&other, &TaskEdit { kind: "online".into(), ..TaskEdit::of(&task) }, &zone, &now).unwrap();
1147        let task = task_of_text(&changed, &zone).unwrap();
1148        assert_eq!(task.kind, "online");
1149        assert!(changed.contains("CONCEPT:https://example.org/concepts/money"), "another application's concept stays");
1150        assert!(!changed.contains("task-type/call"));
1151        let unsaid = apply(&changed, &TaskEdit { kind: String::new(), ..TaskEdit::of(&task) }, &zone, &now).unwrap();
1152        assert_eq!(task_of_text(&unsaid, &zone).unwrap().kind, "");
1153        assert_eq!(TaskEdit { kind: "Not a kind!".into(), ..TaskEdit::default() }.tidy().kind, "");
1154        // A kind of yours is a concept too, read back as it was written.
1155        let errand = apply(&unsaid, &TaskEdit { kind: "rendez-vous".into(), ..TaskEdit::of(&task_of_text(&unsaid, &zone).unwrap()) }, &zone, &now).unwrap();
1156        assert!(errand.contains("task-type/rendez-vous"), "{errand}");
1157        assert_eq!(task_of_text(&errand, &zone).unwrap().kind, "rendez-vous");
1158        // Needs an office: a concept of its own, set and taken off without touching the kind.
1159        let task = task_of_text(&unsaid, &zone).unwrap();
1160        // Billed or not, said once, read back; unsaid, the line goes.
1161        let heavy = apply(&unsaid, &TaskEdit { energy: "heavy".into(), ..TaskEdit::of(&task) }, &zone, &now).unwrap();
1162        assert!(heavy.contains("X-SIOUL-ENERGY:HEAVY"), "{heavy}");
1163        assert_eq!(task_of_text(&heavy, &zone).unwrap().energy, "heavy");
1164        let usual = apply(&heavy, &TaskEdit { energy: String::new(), ..TaskEdit::of(&task_of_text(&heavy, &zone).unwrap()) }, &zone, &now).unwrap();
1165        assert!(!usual.contains("X-SIOUL-ENERGY"), "{usual}");
1166        let billed = apply(&unsaid, &TaskEdit { billable: Some(false), ..TaskEdit::of(&task) }, &zone, &now).unwrap();
1167        assert_eq!(task_of_text(&billed, &zone).unwrap().billable, Some(false));
1168        let again = apply(&billed, &TaskEdit { billable: None, ..TaskEdit::of(&task_of_text(&billed, &zone).unwrap()) }, &zone, &now).unwrap();
1169        assert!(!again.contains("X-SIOUL-BILLABLE"), "{again}");
1170        let office = apply(&unsaid, &TaskEdit { office_hours: true, kind: "call".into(), ..TaskEdit::of(&task) }, &zone, &now).unwrap();
1171        let read = task_of_text(&office, &zone).unwrap();
1172        assert!(read.office_hours && read.kind == "call");
1173        let open = apply(&office, &TaskEdit { office_hours: false, ..TaskEdit::of(&read) }, &zone, &now).unwrap();
1174        let read = task_of_text(&open, &zone).unwrap();
1175        assert!(!read.office_hours && read.kind == "call");
1176    }
1177
1178    const LETTER: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Other//EN\r\nBEGIN:VTODO\r\nUID:letter-1\r\nDTSTAMP:20261001T080000Z\r\n\
1179        CREATED:20261001T080000Z\r\nSUMMARY:Send the letter\\, registered\r\nDESCRIPTION:With the form \r\n and the copy.\r\n\
1180        DTSTART;TZID=Europe/Paris:20261005T090000\r\nDUE;VALUE=DATE:20261030\r\nESTIMATED-DURATION:PT1H30M\r\nPRIORITY:2\r\n\
1181        CATEGORIES:admin,taxes\\, 2025\r\nCATEGORIES:you\r\nRELATED-TO:taxes-all\r\nRELATED-TO;RELTYPE=DEPENDS-ON:gather-papers\r\n\
1182        RELATED-TO;RELTYPE=FINISHTOSTART;GAP=P14D:wait-answer\r\nLINK;LINKREL=describedby;LABEL=\"Outbox: 5\";VALUE=URI:sioul:note/admin/letters.md\r\n\
1183        LINK;LINKREL=related;VALUE=UID:event-7\r\nCONTACT;ALTREP=\"sioul:contact/tax-office\":Service des impôts\\, centre\r\nREFID:taxes-2025\r\n\
1184        X-OTHER-APP:keep me\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Not the notes\r\nTRIGGER:-PT1H\r\nEND:VALARM\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
1185
1186    fn paris() -> TimeZone {
1187        TimeZone::get("Europe/Paris").unwrap()
1188    }
1189
1190    fn now() -> Zoned {
1191        "2026-10-03T10:00:00+02:00[Europe/Paris]".parse().unwrap()
1192    }
1193
1194    #[test]
1195    fn reads_every_field() {
1196        let task = task_of_text(LETTER, &paris()).unwrap();
1197        assert_eq!((task.uid.as_str(), task.title.as_str(), task.notes.as_str()), ("letter-1", "Send the letter, registered", "With the form and the copy."));
1198        assert_eq!((task.start.as_str(), task.due.as_str(), task.estimate, task.priority), ("2026-10-05T09:00", "2026-10-30", 90, 2));
1199        assert_eq!(task.categories, vec!["admin", "taxes, 2025", "you"]);
1200        assert_eq!(task.parent(), Some("taxes-all"));
1201        assert_eq!(task.depends_on().map(|r| r.uid.as_str()).collect::<Vec<_>>(), vec!["gather-papers"]);
1202        assert_eq!(task.relations[2], Relation { kind: "FINISHTOSTART".into(), uid: "wait-answer".into(), gap: 14 * 1440 });
1203        assert_eq!(task.links[0], Link { uri: "sioul:note/admin/letters.md".into(), label: "Outbox: 5".into(), rel: "describedby".into() });
1204        assert_eq!(task.links[1].uri, "uid:event-7");
1205        assert_eq!(task.contacts, vec![ContactRef { name: "Service des impôts, centre".into(), uri: "sioul:contact/tax-office".into() }]);
1206        assert_eq!((task.projects.clone(), task.status), (vec!["taxes-2025".to_string()], Status::NeedsAction));
1207    }
1208
1209    #[test]
1210    fn an_unchanged_form_writes_nothing() {
1211        let task = task_of_text(LETTER, &paris()).unwrap();
1212        assert_eq!(apply(LETTER, &TaskEdit::of(&task), &paris(), &now()).unwrap(), LETTER);
1213    }
1214
1215    #[test]
1216    fn a_change_touches_only_its_lines() {
1217        let task = task_of_text(LETTER, &paris()).unwrap();
1218        let mut edit = TaskEdit::of(&task);
1219        edit.title = "Send the registered letter".into();
1220        edit.waits_for = vec!["photocopies".into()];
1221        let text = apply(LETTER, &edit, &paris(), &now()).unwrap();
1222        let (before, after) = (lines::unfold(LETTER), lines::unfold(&text));
1223        let gone: Vec<&String> = before.iter().filter(|l| !after.contains(l)).collect();
1224        let new: Vec<&String> = after.iter().filter(|l| !before.contains(l)).collect();
1225        assert_eq!(gone.iter().map(|l| lines::name(l)).collect::<Vec<_>>(), vec!["DTSTAMP", "SUMMARY", "RELATED-TO"], "{gone:?}");
1226        assert!(new.iter().any(|l| *l == "SUMMARY:Send the registered letter") && new.iter().any(|l| *l == "RELATED-TO;RELTYPE=DEPENDS-ON:photocopies"), "{new:?}");
1227        assert!(text.contains("RELATED-TO;RELTYPE=FINISHTOSTART;GAP=P14D:wait-answer") && text.contains("X-OTHER-APP:keep me") && text.contains("SEQUENCE:1"), "{text}");
1228        let again = task_of_text(&text, &paris()).unwrap();
1229        assert_eq!((again.notes.as_str(), again.estimate, again.title.as_str()), ("With the form and the copy.", 90, "Send the registered letter"));
1230    }
1231
1232    /// A gap written in the task that comes first, as the import and the
1233    /// window's form write it: another gap to the same task replaced, the
1234    /// same one written once, other tasks' links left as they are.
1235    #[test]
1236    fn a_wait_with_a_gap_goes_in_the_task_that_comes_first() {
1237        let text = set_gap(LETTER, "reply", 21 * 1440, &now()).unwrap();
1238        let read = task_of_text(&text, &paris()).unwrap();
1239        assert!(read.relations.contains(&Relation { kind: "FINISHTOSTART".into(), uid: "reply".into(), gap: 21 * 1440 }));
1240        assert!(text.contains("RELATED-TO;RELTYPE=FINISHTOSTART;GAP=P21D:reply") && text.contains("RELATED-TO;RELTYPE=FINISHTOSTART;GAP=P14D:wait-answer"), "{text}");
1241        assert_eq!(set_gap(&text, "reply", 21 * 1440, &now()).unwrap(), text, "said already: nothing written");
1242        // Another gap for the same pair: replaced; a NEXT another program wrote for it too.
1243        let next = text.replace("RELATED-TO;RELTYPE=FINISHTOSTART;GAP=P21D:reply", "RELATED-TO;RELTYPE=NEXT:reply\r\nRELATED-TO;RELTYPE=FINISHTOSTART;GAP=P21D:reply");
1244        let changed = set_gap(&next, "reply", 7 * 1440, &now()).unwrap();
1245        let gaps: Vec<i64> = task_of_text(&changed, &paris()).unwrap().relations.iter().filter(|r| r.uid == "reply").map(|r| r.gap).collect();
1246        assert_eq!(gaps, [7 * 1440]);
1247        assert!(changed.contains("GAP=P14D:wait-answer"), "another task's wait stays");
1248        // No gap: FINISHTOSTART alone, as the import writes `after_gap = { x = 0 }`.
1249        assert!(set_gap(LETTER, "reply", 0, &now()).unwrap().contains("RELATED-TO;RELTYPE=FINISHTOSTART:reply"));
1250    }
1251
1252    /// The form's waits with a gap are no DEPENDS-ON in the task that waits:
1253    /// the window writes them in the tasks waited for (`gapped_waits`).
1254    #[test]
1255    fn a_waits_gap_from_the_form_is_written_in_the_other_task() {
1256        let mut edit = TaskEdit { title: "Call about the answer".into(), waits_for: vec!["letter".into(), "copy".into()], ..TaskEdit::default() };
1257        edit.wait_gaps.insert("letter".into(), 14 * 1440);
1258        let text = new_task(&edit, "call", &paris(), &now()).unwrap();
1259        assert!(text.contains("RELATED-TO;RELTYPE=DEPENDS-ON:copy") && !text.contains(":letter"), "{text}");
1260        assert_eq!(edit.gapped_waits(), [("letter".to_string(), 14 * 1440)]);
1261        // A wait there already, given a gap in the form: its DEPENDS-ON goes.
1262        let task = task_of_text(LETTER, &paris()).unwrap();
1263        let mut edit = TaskEdit::of(&task);
1264        edit.wait_gaps.insert("gather-papers".into(), 7 * 1440);
1265        let changed = apply(LETTER, &edit, &paris(), &now()).unwrap();
1266        assert!(!changed.contains("DEPENDS-ON:gather-papers"), "{changed}");
1267        // Read back, a form says no gap: its details do (`taskview`).
1268        assert!(TaskEdit::of(&task_of_text(&changed, &paris()).unwrap()).wait_gaps.is_empty());
1269        assert!(!serde_json::to_string(&TaskEdit::of(&task)).unwrap().contains("wait_gaps"), "not written when empty");
1270    }
1271
1272    #[test]
1273    fn done_and_open_again() {
1274        let done = set_status(LETTER, Status::Completed, &paris(), &now()).unwrap();
1275        assert!(done.contains("STATUS:COMPLETED") && done.contains("COMPLETED:20261003T080000Z") && done.contains("PERCENT-COMPLETE:100"), "{done}");
1276        let task = task_of_text(&done, &paris()).unwrap();
1277        assert_eq!((task.status, task.completed), (Status::Completed, Some(now().timestamp().as_second())));
1278        let open = set_status(&done, Status::NeedsAction, &paris(), &now()).unwrap();
1279        assert!(open.contains("STATUS:NEEDS-ACTION") && !open.contains("COMPLETED:") && !open.contains("PERCENT-COMPLETE"), "{open}");
1280    }
1281
1282    #[test]
1283    fn a_repeating_task_comes_back() {
1284        let edit = TaskEdit { title: "Water the plants".into(), start: "2026-09-21".into(), due: "2026-09-22".into(), repeat: "weekly".into(), ..TaskEdit::default() };
1285        let text = new_task(&edit, "plants", &paris(), &now()).unwrap();
1286        let done = set_status(&text, Status::Completed, &paris(), &now()).unwrap();
1287        let task = task_of_text(&done, &paris()).unwrap();
1288        // The 22nd and 29th have passed on the 3rd of October: the 6th is next.
1289        assert_eq!((task.start.as_str(), task.due.as_str(), task.status), ("2026-10-05", "2026-10-06", Status::NeedsAction));
1290    }
1291
1292    #[test]
1293    fn new_tasks_read_back_as_written() {
1294        let edit = TaskEdit {
1295            title: "Ask for the certificate".into(),
1296            notes: "Certificate of schooling; dates of the year.".into(),
1297            due: "2026-10-30T17:00".into(),
1298            estimate: 15,
1299            categories: vec!["admin".into(), "you".into()],
1300            parent: "school".into(),
1301            waits_for: vec!["form".into()],
1302            links: vec![Link { uri: "sioul:note/admin/letters.md".into(), label: "Outbox: 2".into(), rel: "describedby".into() }, Link { uri: "mid:abc@example.org".into(), label: String::new(), rel: "via".into() }],
1303            contacts: vec![ContactRef { name: "School, office".into(), uri: "sioul:contact/school".into() }],
1304            projects: vec!["school".into()],
1305            ..TaskEdit::default()
1306        };
1307        let text = new_task(&edit, "certificate", &paris(), &now()).unwrap();
1308        let written = lines::unfold(&text);
1309        assert!(written.iter().any(|l| l == "LINK;LINKREL=describedby;LABEL=\"Outbox: 2\";VALUE=URI:sioul:note/admin/letters.md"), "{text}");
1310        assert!(text.contains("DUE:20261030T160000Z") && text.contains("ESTIMATED-DURATION:PT15M"), "{text}");
1311        assert_eq!(TaskEdit::of(&task_of_text(&text, &paris()).unwrap()), edit);
1312    }
1313
1314    #[test]
1315    fn the_first_estimate_is_kept() {
1316        // Made with an estimate: the first one is that.
1317        let made = new_task(&TaskEdit { title: "Fill the form".into(), estimate: 30, ..TaskEdit::default() }, "f", &paris(), &now()).unwrap();
1318        assert!(made.contains("X-SIOUL-ESTIMATE-FIRST:PT30M"), "{made}");
1319        let task = task_of_text(&made, &paris()).unwrap();
1320        assert_eq!((task.estimate, task.estimate_first), (30, Some(30)));
1321        // The estimate changed after an overrun: the first stays.
1322        let longer = apply(&made, &TaskEdit { estimate: 90, ..TaskEdit::of(&task) }, &paris(), &now()).unwrap();
1323        let task = task_of_text(&longer, &paris()).unwrap();
1324        assert_eq!((task.estimate, task.estimate_first), (90, Some(30)));
1325        assert_eq!(longer.matches("X-SIOUL-ESTIMATE-FIRST").count(), 1);
1326        // Taken away and given again: still the first.
1327        let none = apply(&longer, &TaskEdit { estimate: 0, ..TaskEdit::of(&task) }, &paris(), &now()).unwrap();
1328        let again = apply(&none, &TaskEdit { estimate: 45, ..TaskEdit::of(&task_of_text(&none, &paris()).unwrap()) }, &paris(), &now()).unwrap();
1329        assert_eq!(task_of_text(&again, &paris()).unwrap().estimate_first, Some(30));
1330        // Made without one, estimated later: that is the first.
1331        let bare = new_task(&TaskEdit { title: "Call".into(), ..TaskEdit::default() }, "c", &paris(), &now()).unwrap();
1332        assert!(!bare.contains("ESTIMATE-FIRST"));
1333        let later = apply(&bare, &TaskEdit { estimate: 15, ..TaskEdit::of(&task_of_text(&bare, &paris()).unwrap()) }, &paris(), &now()).unwrap();
1334        assert_eq!(task_of_text(&later, &paris()).unwrap().estimate_first, Some(15));
1335        // A task estimated before the first was kept: none, ever (its first guess is unknown).
1336        let old = LETTER;
1337        let task = task_of_text(old, &paris()).unwrap();
1338        assert_eq!((task.estimate, task.estimate_first), (90, None));
1339        let changed = apply(old, &TaskEdit { estimate: 120, ..TaskEdit::of(&task) }, &paris(), &now()).unwrap();
1340        assert_eq!(task_of_text(&changed, &paris()).unwrap().estimate_first, None);
1341    }
1342
1343    #[test]
1344    fn heaviness_written_from_the_costs() {
1345        use crate::demands::Demands;
1346        let made = new_task(&TaskEdit { title: "Call the bank".into(), energy: "light".into(), demands: Demands { anxiety: Some(8), ..Demands::default() }, ..TaskEdit::default() }, "b", &paris(), &now()).unwrap();
1347        assert!(made.contains("X-SIOUL-ENERGY:HEAVY") && !made.contains("LIGHT"), "the costs say heavy, whatever the word: {made}");
1348        let task = task_of_text(&made, &paris()).unwrap();
1349        // Body and senses written and read back.
1350        let body = apply(&made, &TaskEdit { demands: Demands { anxiety: Some(2), body: Some(5), ..Demands::default() }, ..TaskEdit::of(&task) }, &paris(), &now()).unwrap();
1351        assert!(body.contains("X-SIOUL-COST:ANXIETY=2;BODY=5") && !body.contains("X-SIOUL-ENERGY"), "usual: no line: {body}");
1352        let task = task_of_text(&body, &paris()).unwrap();
1353        assert_eq!((task.demands.body, task.energy.as_str()), (Some(5), ""));
1354        // A file whose word disagrees with its costs (an older Sioul): set right at the next save.
1355        let stale = body.replace("X-SIOUL-COST:ANXIETY=2;BODY=5", "X-SIOUL-COST:ANXIETY=2;BODY=5\r\nX-SIOUL-ENERGY:HEAVY");
1356        let task = task_of_text(&stale, &paris()).unwrap();
1357        assert_eq!(task.energy, "heavy");
1358        let fixed = apply(&stale, &TaskEdit::of(&task), &paris(), &now()).unwrap();
1359        assert!(!fixed.contains("X-SIOUL-ENERGY"), "{fixed}");
1360        // No cost rated: the word chosen stands.
1361        let word = new_task(&TaskEdit { title: "Walk".into(), energy: "rest".into(), demands: Demands { gain: Some(8), ..Demands::default() }, ..TaskEdit::default() }, "w", &paris(), &now()).unwrap();
1362        assert!(word.contains("X-SIOUL-ENERGY:REST"));
1363    }
1364
1365    #[test]
1366    fn felt_ratings_dated_and_kept_five() {
1367        use crate::demands::Demands;
1368        let day = |d: i8| Date::constant(2026, 10, d);
1369        let felt = Demands { anxiety: Some(3), body: Some(1), gain: Some(6), ..Demands::default() };
1370        let rated = set_felt(LETTER, &felt, day(6), &now()).unwrap();
1371        assert!(rated.contains("X-SIOUL-FELT-COST;X-SIOUL-ON=20261006:ANXIETY=3;BODY=1") && rated.contains("X-SIOUL-FELT-GAIN;X-SIOUL-ON=20261006:6"), "{rated}");
1372        assert!(rated.contains("X-OTHER-APP:keep me") && rated.contains("BEGIN:VALARM"));
1373        let task = task_of_text(&rated, &paris()).unwrap();
1374        assert_eq!(task.felt.len(), 1);
1375        assert_eq!((task.felt[0].on, task.felt[0].demands), (Some(day(6)), felt));
1376        assert_eq!(TaskEdit::of(&task).felt, felt);
1377        // The same day again: replaced, not added.
1378        let again = set_felt(&rated, &Demands { anxiety: Some(4), ..Demands::default() }, day(6), &now()).unwrap();
1379        let task = task_of_text(&again, &paris()).unwrap();
1380        assert_eq!((task.felt.len(), task.felt[0].demands.anxiety, task.felt[0].demands.gain), (1, Some(4), None));
1381        // The same rating again: the text as it was.
1382        assert_eq!(set_felt(&again, &Demands { anxiety: Some(4), ..Demands::default() }, day(6), &now()).unwrap(), again);
1383        // Seven days rated: the newest five kept, oldest first.
1384        let mut text = again;
1385        for d in 7..=12 {
1386            text = set_felt(&text, &Demands { cognitive: Some(d as u8 - 6), ..Demands::default() }, day(d), &now()).unwrap();
1387        }
1388        let task = task_of_text(&text, &paris()).unwrap();
1389        assert_eq!(task.felt.iter().map(|f| f.on.unwrap().day()).collect::<Vec<_>>(), vec![8, 9, 10, 11, 12]);
1390        // Nothing said: that day's rating taken away.
1391        let cleared = set_felt(&text, &Demands::default(), day(12), &now()).unwrap();
1392        assert_eq!(task_of_text(&cleared, &paris()).unwrap().felt.len(), 4);
1393        // The form never writes nor clears it.
1394        let task = task_of_text(&cleared, &paris()).unwrap();
1395        let form = apply(&cleared, &TaskEdit { felt: Demands::default(), title: "Send it".into(), ..TaskEdit::of(&task) }, &paris(), &now()).unwrap();
1396        assert_eq!(task_of_text(&form, &paris()).unwrap().felt.len(), 4);
1397        assert_eq!(apply(&cleared, &TaskEdit { felt: Demands::default(), ..TaskEdit::of(&task) }, &paris(), &now()).unwrap(), cleared, "felt alone changed in the form: nothing written");
1398    }
1399
1400    #[test]
1401    fn durations() {
1402        assert_eq!((minutes_of("PT15M"), minutes_of("P1DT2H"), minutes_of("-P1W"), minutes_of("P"), minutes_of("15")), (Some(15), Some(1560), Some(-10080), None, None));
1403        assert_eq!((duration_of(15), duration_of(90), duration_of(20160), duration_of(0)), ("PT15M".to_string(), "PT1H30M".to_string(), "P14D".to_string(), "PT0M".to_string()));
1404        let line = add_lines(LETTER, &[relation_line("DEPENDS-ON", "gather-papers", 0)], &now()).unwrap();
1405        assert_eq!(line, LETTER, "already there: unchanged");
1406        let removed = remove_lines(LETTER, |l| lines::name(l) == "REFID", &now()).unwrap();
1407        assert!(!removed.contains("REFID") && removed.contains("X-OTHER-APP"));
1408    }
1409
1410    #[test]
1411    fn files_from_elsewhere_never_crash_nor_inject() {
1412        // Durations and rules too long for any date: read as none, done without a crash.
1413        assert_eq!((minutes_of("P99999999999999999W"), minutes_of("PT9223372036854775807M1H")), (None, None));
1414        let odd = "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:odd\r\nDTSTART;VALUE=DATE:20261005\r\nDURATION:P9999999999D\r\n\
1415            RRULE:FREQ=YEARLY;INTERVAL=99999999999\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
1416        let task = task_of_text(odd, &paris()).unwrap();
1417        assert_eq!((task.start.as_str(), task.due.as_str(), task.repeat.as_str()), ("2026-10-05", "", "yearly"));
1418        assert!(set_status(odd, Status::Completed, &paris(), &now()).is_ok());
1419        // A rule that ended: done for good, not back.
1420        let ended = odd.replace("DURATION:P9999999999D\r\n", "").replace("INTERVAL=99999999999", "UNTIL=20261231T000000Z");
1421        let done = task_of_text(&set_status(&ended, Status::Completed, &paris(), &now()).unwrap(), &paris()).unwrap();
1422        assert_eq!(done.status, Status::Completed);
1423        // Line breaks in what is written as it is stay out of the file.
1424        let link = link_line(&Link { uri: "uid:a\r\nATTENDEE:mailto:x@example.org".into(), ..Link::default() });
1425        assert!(!link.contains(['\r', '\n']) && relation_line("DEPENDS-ON", "b\nX", 0) == "RELATED-TO;RELTYPE=DEPENDS-ON:bX", "{link}");
1426    }
1427
1428    #[test]
1429    fn a_time_given_for_one_day_is_read_never_written() {
1430        let zone = paris();
1431        // A step with a date asked, given a time today by a Sioul of before blocks: read, its dates as they were.
1432        let made = new_task(&TaskEdit { title: "Call the bank".into(), start: "2026-10-01".into(), due: "2026-10-09".into(), ..TaskEdit::default() }, "bank", &zone, &now()).unwrap();
1433        let given = made.replace("END:VTODO", "X-SIOUL-AT:20261006T123000Z\r\nEND:VTODO");
1434        // Never written now: a time given goes into a block (`blocks`), the task's file as it was.
1435        let written = apply(&made, &TaskEdit { at: "2026-10-06T14:30".into(), ..TaskEdit::of(&task_of_text(&made, &zone).unwrap()) }, &zone, &now()).unwrap();
1436        assert_eq!(written, made);
1437        let task = task_of_text(&given, &zone).unwrap();
1438        let today: Date = "2026-10-06".parse().unwrap();
1439        assert_eq!(task.at, "2026-10-06T14:30");
1440        assert_eq!(task.at_on(today, &zone), Some(1_791_289_800));
1441        // Another day it says nothing.
1442        assert_eq!(task.at_on(today.tomorrow().unwrap(), &zone), None);
1443        // Left to the plan: the line goes, nothing else changes.
1444        let placed = apply(&given, &TaskEdit { at: String::new(), ..TaskEdit::of(&task) }, &zone, &now()).unwrap();
1445        assert!(!placed.contains("X-SIOUL-AT") && placed.contains("DTSTART;VALUE=DATE:20261001"), "{placed}");
1446        // Changed another day for another reason: the time given that day goes; saved unchanged, nothing is written.
1447        let next_day: Zoned = "2026-10-07T09:00:00+02:00[Europe/Paris]".parse().unwrap();
1448        assert_eq!(apply(&given, &TaskEdit::of(&task), &zone, &next_day).unwrap(), given);
1449        let later = apply(&given, &TaskEdit { estimate: 20, ..TaskEdit::of(&task) }, &zone, &next_day).unwrap();
1450        assert!(!later.contains("X-SIOUL-AT") && later.contains("ESTIMATED-DURATION:PT20M"), "{later}");
1451        // A repeating task done: its next turn comes without the time given for this one.
1452        let weekly = apply(&given, &TaskEdit { repeat: "weekly".into(), ..TaskEdit::of(&task) }, &zone, &now()).unwrap();
1453        let next = set_status(&weekly, Status::Completed, &zone, &now()).unwrap();
1454        assert!(!next.contains("X-SIOUL-AT") && next.contains("STATUS:NEEDS-ACTION"), "{next}");
1455    }
1456}