1use 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#[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 pub fn is_open(self) -> bool {
68 matches!(self, Status::NeedsAction | Status::InProcess)
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74pub struct Relation {
75 pub kind: String,
77 pub uid: String,
78 pub gap: i64,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
84pub struct Link {
85 pub uri: String,
87 #[serde(default)]
88 pub label: String,
89 #[serde(default)]
91 pub rel: String,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
96pub struct ContactRef {
97 pub name: String,
98 #[serde(default)]
100 pub uri: String,
101}
102
103pub const KINDS: &[&str] = &["call", "write", "online", "out", "read", "think", "make"];
107
108pub const KIND_CONCEPT: &str = "tag:aurelienpierre.com,2026:sioul/task-type/";
111
112pub const OFFICE_CONCEPT: &str = "tag:aurelienpierre.com,2026:sioul/needs/office-hours";
114
115pub 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
121fn 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
129pub struct Task {
130 pub key: String,
132 pub uid: String,
133 pub title: String,
134 pub notes: String,
135 pub location: String,
136 pub status: Status,
137 pub start: String,
139 pub due: String,
141 pub at: String,
147 pub completed: Option<i64>,
149 pub estimate: u32,
151 pub estimate_first: Option<u32>,
155 pub priority: u8,
157 pub categories: Vec<String>,
158 pub kind: String,
160 pub office_hours: bool,
162 pub office_times: String,
166 pub area: String,
169 pub billable: Option<bool>,
172 pub energy: String,
175 pub margins: crate::demands::Margins,
177 pub demands: crate::demands::Demands,
179 pub felt: Vec<crate::demands::Felt>,
181 pub relations: Vec<Relation>,
182 pub links: Vec<Link>,
183 pub contacts: Vec<ContactRef>,
184 pub projects: Vec<String>,
186 pub repeat: String,
188 pub created: i64,
190 pub list: String,
192 pub list_id: String,
193 pub color: Option<String>,
194 pub read_only: bool,
195}
196
197impl Task {
198 pub fn start_date(&self) -> Option<Date> {
200 day_of(&self.start)
201 }
202
203 pub fn due_date(&self) -> Option<Date> {
205 day_of(&self.due)
206 }
207
208 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 pub fn parent(&self) -> Option<&str> {
220 self.relations.iter().find(|r| r.kind == "PARENT").map(|r| r.uid.as_str())
221 }
222
223 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
233pub const ESTIMATE_FIRST: &str = "X-SIOUL-ESTIMATE-FIRST";
235
236pub const AT: &str = "X-SIOUL-AT";
243
244pub 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 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
278pub 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
284pub 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
306fn 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
333fn 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
348fn 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
360fn 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
371fn 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
391pub 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
398pub 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
409fn 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
418pub 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 if task.due.is_empty()
482 && let (Some(minutes), Some(line)) = (duration, start_line)
483 {
484 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 if task.completed.is_some() && !has_status {
492 task.status = Status::Completed;
493 }
494 Some(task)
495}
496
497#[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 #[serde(default)]
509 pub start: String,
510 #[serde(default)]
511 pub due: String,
512 #[serde(default)]
515 pub at: String,
516 #[serde(default)]
518 pub estimate: u32,
519 #[serde(default)]
520 pub priority: u8,
521 #[serde(default)]
522 pub categories: Vec<String>,
523 #[serde(default)]
525 pub kind: String,
526 #[serde(default)]
527 pub office_hours: bool,
528 #[serde(default)]
530 pub office_times: String,
531 #[serde(default)]
533 pub area: String,
534 #[serde(default)]
536 pub billable: Option<bool>,
537 #[serde(default)]
539 pub energy: String,
540 #[serde(default)]
542 pub margins: crate::demands::Margins,
543 #[serde(default)]
545 pub demands: crate::demands::Demands,
546 #[serde(default)]
549 pub felt: crate::demands::Demands,
550 #[serde(default)]
552 pub parent: String,
553 #[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 #[serde(default, alias = "cases")]
562 pub projects: Vec<String>,
563 #[serde(default)]
565 pub repeat: String,
566 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
572 pub wait_gaps: BTreeMap<String, i64>,
573}
574
575impl TaskEdit {
576 pub fn gap_of(&self, uid: &str) -> i64 {
578 self.wait_gaps.get(uid).copied().unwrap_or(0).max(0)
579 }
580
581 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 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 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 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
663fn 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
674fn one_line(value: &str) -> String {
678 value.chars().filter(|c| !c.is_control()).collect()
679}
680
681pub 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
687pub 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
699pub 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
710pub 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
716fn 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#[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 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
801fn 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 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
872pub 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 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
893const STAMPS: &[&str] = &["DTSTAMP", "LAST-MODIFIED", "SEQUENCE"];
895
896fn 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 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
924pub 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 old.energy = before.energy.clone();
931 edit.felt = old.felt;
933 if old == edit {
934 return Ok(text.to_string());
935 }
936 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 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 "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
978pub 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 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 edit.at = String::new();
995 edit.status = Status::NeedsAction;
996 }
997 }
998 apply(text, &edit, zone, now)
999}
1000
1001fn 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 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
1029pub const FELT_KEPT: usize = 5;
1031
1032pub 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 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 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
1065pub 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
1076pub 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
1083pub 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
1088pub 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
1094pub fn lists() -> Vec<Collection> {
1096 vdir::collections(Kind::Calendars).into_iter().filter(|c| c.holds("VTODO")).collect()
1097}
1098
1099pub 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
1114pub 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
1119pub 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
1128pub 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 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 let task = task_of_text(&unsaid, &zone).unwrap();
1160 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 #[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 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 assert!(set_gap(LETTER, "reply", 0, &now()).unwrap().contains("RELATED-TO;RELTYPE=FINISHTOSTART:reply"));
1250 }
1251
1252 #[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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(set_felt(&again, &Demands { anxiety: Some(4), ..Demands::default() }, day(6), &now()).unwrap(), again);
1383 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 let cleared = set_felt(&text, &Demands::default(), day(12), &now()).unwrap();
1392 assert_eq!(task_of_text(&cleared, &paris()).unwrap().felt.len(), 4);
1393 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 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 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 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 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 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 assert_eq!(task.at_on(today.tomorrow().unwrap(), &zone), None);
1443 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 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 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}