Skip to main content

sioul_sync/
google_tasks.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Google Tasks, over its REST API (v1): Google's CalDAV keeps no task, so a
5//! Google account's task lists come this way. Each list is a folder of VTODO
6//! files like any other (`calendars/<account>/tasks-<id>`), read by the rest
7//! of Sioul as it reads a CalDAV list. Google keeps a title, notes, done or
8//! not, a day (no hour), and one level of steps; what it does not keep shows
9//! greyed in the task form (sioul_core::capabilities). Lines Google does not
10//! know stay in the file here.
11//!
12//! What changed here goes first: lists made, renamed or deleted; tasks
13//! inserted, patched and moved under their parent, deleted. Then what changed
14//! there since the last sync (`updatedMin`, deletions included). A task
15//! changed on both sides between two syncs: yours is sent, and Google's
16//! answer is what stays.
17
18use crate::SyncError;
19use crate::dav::Report;
20use jiff::Timestamp;
21use jiff::tz::TimeZone;
22use serde_json::{Value, json};
23use sioul_core::config::Account;
24use sioul_core::lines;
25use sioul_core::tasks::{self, Status};
26use sioul_core::vdir::{self, ItemState, Kind, State};
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::Path;
29use std::sync::Mutex;
30use std::time::Duration;
31
32const API: &str = "https://tasks.googleapis.com/tasks/v1";
33
34fn api() -> String {
35    // A local stand-in for Google, in tests only (never in Sioul's builds).
36    #[cfg(feature = "insecure-test-tls")]
37    if let Some(base) = std::env::var_os("SIOUL_TEST_GOOGLE_TASKS") {
38        return base.to_string_lossy().trim_end_matches('/').to_string();
39    }
40    API.to_string()
41}
42
43/// Where a list is, as its state keeps it.
44fn list_url(id: &str) -> String {
45    format!("{}/lists/{id}", api())
46}
47
48/// Whether a collection's address is a Google Tasks list.
49pub fn is_tasks_list(url: &str) -> bool {
50    url.starts_with(&format!("{}/lists/", api()))
51}
52
53fn list_id(url: &str) -> &str {
54    url.rsplit('/').next().unwrap_or("")
55}
56
57/// The API, with the account's access token: asked again once when refused.
58struct Api<'a> {
59    address: &'a str,
60    agent: ureq::Agent,
61    token: Mutex<String>,
62    /// Where it is: Google's (`api()`).
63    base: String,
64}
65
66impl<'a> Api<'a> {
67    fn new(address: &'a str) -> Result<Api<'a>, SyncError> {
68        let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(60))).http_status_as_error(false).build().into();
69        Ok(Api { address, agent, token: Mutex::new(crate::google::access_token(address, false)?), base: api() })
70    }
71
72    /// One call: its status and its JSON (null when none).
73    fn call(&self, method: &str, path: &str, body: Option<&Value>) -> Result<(u16, Value), SyncError> {
74        let url = format!("{}{path}", self.base);
75        for attempt in 0..2 {
76            let token = self.token.lock().map(|t| t.clone()).unwrap_or_default();
77            let request = ureq::http::Request::builder()
78                .method(method)
79                .uri(&url)
80                .header("Authorization", format!("Bearer {token}"))
81                .header("User-Agent", "Sioul")
82                .header("Content-Type", "application/json")
83                .body(body.map(Value::to_string).unwrap_or_default())
84                .map_err(|e| SyncError::Server(e.to_string()))?;
85            let mut response = self.agent.run(request).map_err(|e| SyncError::Network(e.to_string()))?;
86            let status = response.status().as_u16();
87            if status == 401 && attempt == 0 {
88                let fresh = crate::google::access_token(self.address, true)?;
89                if let Ok(mut token) = self.token.lock() {
90                    *token = fresh;
91                }
92                continue;
93            }
94            if status == 401 {
95                return Err(SyncError::Login(format!("{method} {url}: 401")));
96            }
97            let text = response.body_mut().with_config().limit(16 * 1024 * 1024).read_to_string().unwrap_or_default();
98            // The API turned off in the project, or the access not given: said as Google says it.
99            if status == 403 {
100                let reason: Value = serde_json::from_str(&text).unwrap_or_default();
101                return Err(SyncError::Server(format!("Google Tasks: {}", reason["error"]["message"].as_str().unwrap_or("403"))));
102            }
103            return Ok((status, serde_json::from_str(&text).unwrap_or(Value::Null)));
104        }
105        Err(SyncError::Login(format!("{method} {url}: 401")))
106    }
107
108    /// A call that must succeed.
109    fn ok(&self, method: &str, path: &str, body: Option<&Value>) -> Result<Value, SyncError> {
110        match self.call(method, path, body)? {
111            (200..=299, value) => Ok(value),
112            (status, _) => Err(SyncError::Server(format!("{method} {path}: {status}"))),
113        }
114    }
115
116    /// Every page of a listing.
117    fn all(&self, path: &str) -> Result<Vec<Value>, SyncError> {
118        let mut items = Vec::new();
119        let mut page: Option<String> = None;
120        loop {
121            let at = match &page {
122                Some(token) => format!("{path}&pageToken={}", crate::google::encode(token)),
123                None => path.to_string(),
124            };
125            let answer = self.ok("GET", &at, None)?;
126            items.extend(answer["items"].as_array().cloned().unwrap_or_default());
127            match answer["nextPageToken"].as_str() {
128                Some(token) if !token.is_empty() => page = Some(token.to_string()),
129                _ => return Ok(items),
130            }
131        }
132    }
133}
134
135/// Whether the access given covers tasks (you may untick it on Google's page).
136fn tasks_granted(address: &str) -> bool {
137    #[cfg(feature = "insecure-test-tls")]
138    if std::env::var_os("SIOUL_TEST_GOOGLE_TASKS").is_some() {
139        return true;
140    }
141    crate::google::grant(address).is_some_and(|g| g.scopes.iter().any(|s| s.ends_with("/auth/tasks")))
142}
143
144/// Syncs a Google account's task lists; nothing when tasks were not given.
145pub fn sync(account: &Account) -> Result<Report, SyncError> {
146    let mut report = Report::default();
147    let address = account.address.as_deref().or(account.login()).ok_or(SyncError::NoServer)?;
148    if !tasks_granted(address) {
149        return Ok(report);
150    }
151    let api = Api::new(address)?;
152    let zone = TimeZone::system();
153    send_list_changes(&api, &account.id)?;
154    // The lists known here, by their address.
155    let known: BTreeMap<String, String> = vdir::every_collection(Kind::Calendars)
156        .into_iter()
157        .filter(|c| c.account == account.id)
158        .map(|c| (State::load(&c.state_path()).url, c.id))
159        .filter(|(url, _)| is_tasks_list(url))
160        .collect();
161    let mut kept = BTreeSet::new();
162    for list in api.all("/users/@me/lists?maxResults=100")? {
163        let Some(id) = list["id"].as_str() else { continue };
164        let url = list_url(id);
165        let local = known.get(&url).cloned().unwrap_or_else(|| format!("tasks-{}", vdir::folder_id(id)));
166        sync_list(&api, &account.id, id, &local, list["title"].as_str().unwrap_or("Tasks"), &zone, &mut report)?;
167        kept.insert(url);
168        report.collections += 1;
169    }
170    forget_gone(&account.id, &kept);
171    Ok(report)
172}
173
174/// Lists made, renamed or deleted here, told to Google. A list deleted here
175/// that holds tasks there (added elsewhere meanwhile) comes back.
176fn send_list_changes(api: &Api, account: &str) -> Result<(), SyncError> {
177    for collection in vdir::every_collection(Kind::Calendars).into_iter().filter(|c| c.account == account) {
178        let path = collection.state_path();
179        let mut state = State::load(&path);
180        if state.pending && collection.components.iter().any(|c| c.eq_ignore_ascii_case("VTODO")) {
181            let made = api.ok("POST", "/users/@me/lists", Some(&json!({ "title": collection.name })))?;
182            let id = made["id"].as_str().ok_or_else(|| SyncError::Server("Google Tasks: a list without id".into()))?;
183            state.pending = false;
184            state.url = list_url(id);
185            state.save(&path).map_err(SyncError::Disk)?;
186            continue;
187        }
188        if !is_tasks_list(&state.url) {
189            continue;
190        }
191        let id = list_id(&state.url).to_string();
192        if state.renamed {
193            api.ok("PATCH", &format!("/users/@me/lists/{id}"), Some(&json!({ "title": collection.name })))?;
194            state.renamed = false;
195            state.save(&path).map_err(SyncError::Disk)?;
196        }
197        if state.deleted {
198            let held = api.ok("GET", &format!("/lists/{id}/tasks?maxResults=1&showCompleted=true&showHidden=true"), None)?;
199            if held["items"].as_array().is_some_and(|items| !items.is_empty()) {
200                state.deleted = false;
201                state.save(&path).map_err(SyncError::Disk)?;
202                continue;
203            }
204            match api.call("DELETE", &format!("/users/@me/lists/{id}"), None)? {
205                (200..=299 | 404, _) => {
206                    let _ = std::fs::remove_dir_all(&collection.dir);
207                    let _ = std::fs::remove_file(&path);
208                }
209                (status, _) => return Err(SyncError::Server(format!("DELETE list {id}: {status}"))),
210            }
211        }
212    }
213    Ok(())
214}
215
216/// Lists deleted on Google go here too, unless something here was never sent.
217fn forget_gone(account: &str, kept: &BTreeSet<String>) {
218    for collection in vdir::every_collection(Kind::Calendars).into_iter().filter(|c| c.account == account) {
219        let state = State::load(&collection.state_path());
220        if !is_tasks_list(&state.url) || kept.contains(&state.url) || state.pending {
221            continue;
222        }
223        let known: BTreeSet<&str> = state.items.iter().map(|i| i.file.as_str()).collect();
224        let unsent = files(&collection.dir).iter().any(|f| !known.contains(f.as_str()))
225            || state.items.iter().any(|i| hash_of(&collection.dir.join(&i.file)).is_some_and(|h| h != i.hash));
226        if unsent {
227            continue;
228        }
229        let _ = std::fs::remove_dir_all(&collection.dir);
230        let _ = std::fs::remove_file(collection.state_path());
231    }
232}
233
234fn files(dir: &Path) -> BTreeSet<String> {
235    std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok).filter_map(|e| e.file_name().into_string().ok()).filter(|n| n.ends_with(".ics") && !n.starts_with('.')).collect()
236}
237
238fn hash_of(path: &Path) -> Option<String> {
239    std::fs::read(path).ok().map(|bytes| vdir::content_hash(&bytes))
240}
241
242#[allow(clippy::too_many_arguments)]
243fn sync_list(api: &Api, account: &str, id: &str, local: &str, title: &str, zone: &TimeZone, report: &mut Report) -> Result<(), SyncError> {
244    let dir = vdir::prepare(Kind::Calendars, account, local, title, None).map_err(|e| SyncError::Disk(e.to_string()))?;
245    let state_path = vdir::state_path(account, Kind::Calendars, local);
246    let mut state = State::load(&state_path);
247    state.url = list_url(id);
248    state.components = vec!["VTODO".into()];
249    state.read_only = false;
250    // A little before now: what changes while this runs comes again next time.
251    let started = Timestamp::now() - jiff::SignedDuration::from_secs(120);
252    // The state is kept whatever happens (and after each task made on Google):
253    // a task sent is known at the next sync, never sent twice.
254    let synced = push(api, id, &dir, &mut state, &state_path, zone, report).and_then(|()| pull(api, id, &dir, &mut state, report));
255    if synced.is_ok() {
256        state.sync_token = Some(started.strftime("%Y-%m-%dT%H:%M:%S.000Z").to_string());
257    }
258    state.save(&state_path).map_err(SyncError::Disk)?;
259    synced
260}
261
262/// The UID in a task's file.
263fn uid_in(path: &Path) -> Option<String> {
264    let text = std::fs::read_to_string(path).ok()?;
265    lines::unfold(&text).iter().find(|l| lines::name(l) == "UID").map(|l| lines::value(l).trim().to_string())
266}
267
268/// What changed here, sent: deleted, changed (patched, moved under its
269/// parent), new (inserted, parents first: Google has one level of steps).
270/// The state follows each answer, and is kept after each task made on Google.
271fn push(api: &Api, list: &str, dir: &Path, state: &mut State, state_path: &Path, zone: &TimeZone, report: &mut Report) -> Result<(), SyncError> {
272    // UID → Google's id, for parents.
273    let mut google_of: BTreeMap<String, String> = state.items.iter().filter_map(|i| Some((uid_in(&dir.join(&i.file))?, i.href.clone()))).collect();
274    let mut i = 0;
275    while i < state.items.len() {
276        let item = state.items[i].clone();
277        let path = dir.join(&item.file);
278        let Some(hash) = hash_of(&path) else {
279            match api.call("DELETE", &format!("/lists/{list}/tasks/{}", item.href), None)? {
280                (200..=299 | 404 | 410, _) => report.sent += 1,
281                (status, _) => return Err(SyncError::Server(format!("DELETE task {}: {status}", item.href))),
282            }
283            state.items.remove(i);
284            continue;
285        };
286        if hash == item.hash {
287            i += 1;
288            continue;
289        }
290        let text = std::fs::read_to_string(&path).map_err(|e| SyncError::Disk(e.to_string()))?;
291        let Some(task) = tasks::task_of_text(&text, zone) else {
292            i += 1;
293            continue;
294        };
295        let answer = match api.call("PATCH", &format!("/lists/{list}/tasks/{}", item.href), Some(&fields_of(&task)))? {
296            (200..=299, answer) => answer,
297            // Gone there meanwhile: forgotten, so that yours goes again, as new, below.
298            (404 | 410, _) => {
299                state.items.remove(i);
300                continue;
301            }
302            (status, _) => return Err(SyncError::Server(format!("PATCH task {}: {status}", item.href))),
303        };
304        let wanted = task.parent().and_then(|uid| google_of.get(uid)).cloned();
305        let answer = if answer["parent"].as_str().map(str::to_string) != wanted { moved(api, list, &item.href, wanted.as_deref())? } else { answer };
306        let text = merged(&text, &answer, task.parent());
307        vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
308        report.sent += 1;
309        state.items[i] = ItemState { etag: answer["etag"].as_str().unwrap_or_default().to_string(), hash: vdir::content_hash(text.as_bytes()), ..item };
310        i += 1;
311    }
312    // New here: those whose parent is new too come after it.
313    let known: BTreeSet<String> = state.items.iter().map(|i| i.file.clone()).collect();
314    let mut new: Vec<(String, String, tasks::Task)> = files(dir)
315        .into_iter()
316        .filter(|f| !known.contains(f))
317        .filter_map(|file| {
318            let text = std::fs::read_to_string(dir.join(&file)).ok()?;
319            let task = tasks::task_of_text(&text, zone)?;
320            Some((file, text, task))
321        })
322        .collect();
323    let new_uids: BTreeSet<String> = new.iter().map(|(_, _, t)| t.uid.clone()).collect();
324    new.sort_by_key(|(_, _, t)| t.parent().is_some_and(|p| new_uids.contains(p)));
325    for (file, text, task) in new {
326        let parent = task.parent().and_then(|uid| google_of.get(uid)).cloned();
327        let at = match &parent {
328            Some(parent) => format!("/lists/{list}/tasks?parent={}", crate::google::encode(parent)),
329            None => format!("/lists/{list}/tasks"),
330        };
331        let answer = api.ok("POST", &at, Some(&fields_of(&task)))?;
332        let Some(id) = answer["id"].as_str() else { continue };
333        google_of.insert(task.uid.clone(), id.to_string());
334        let text = merged(&text, &answer, task.parent());
335        state.items.push(ItemState { href: id.to_string(), file: file.clone(), etag: answer["etag"].as_str().unwrap_or_default().to_string(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() });
336        // Known before anything else: killed now, it is not made twice.
337        state.save(state_path).map_err(SyncError::Disk)?;
338        vdir::write_item(&dir.join(&file), &text).map_err(SyncError::Disk)?;
339        report.sent += 1;
340    }
341    Ok(())
342}
343
344/// A task put under another (or at the top, `None`); Google's answer.
345fn moved(api: &Api, list: &str, id: &str, parent: Option<&str>) -> Result<Value, SyncError> {
346    let at = match parent {
347        Some(parent) => format!("/lists/{list}/tasks/{id}/move?parent={}", crate::google::encode(parent)),
348        None => format!("/lists/{list}/tasks/{id}/move"),
349    };
350    api.ok("POST", &at, None)
351}
352
353/// What changed there since the last sync; everything the first time, and
354/// then what is no longer there is gone here too.
355fn pull(api: &Api, list: &str, dir: &Path, state: &mut State, report: &mut Report) -> Result<(), SyncError> {
356    let since = state.sync_token.clone();
357    let mut path = format!("/lists/{list}/tasks?maxResults=100&showCompleted=true&showHidden=true&showDeleted=true");
358    if let Some(since) = &since {
359        path.push_str(&format!("&updatedMin={}", crate::google::encode(since)));
360    }
361    let found = api.all(&path)?;
362    let mut seen = BTreeSet::new();
363    for task in &found {
364        let Some(id) = task["id"].as_str() else { continue };
365        seen.insert(id.to_string());
366        let position = state.items.iter().position(|i| i.href == id);
367        if task["deleted"].as_bool() == Some(true) {
368            if let Some(i) = position {
369                let _ = std::fs::remove_file(dir.join(&state.items[i].file));
370                state.items.remove(i);
371                report.removed += 1;
372            }
373            continue;
374        }
375        let etag = task["etag"].as_str().unwrap_or_default();
376        if position.is_some_and(|i| state.items[i].etag == etag && !etag.is_empty()) {
377            continue;
378        }
379        // The parent by its UID here: one made here has its own.
380        let parent = task["parent"].as_str().map(|p| state.items.iter().find(|i| i.href == p).and_then(|i| uid_in(&dir.join(&i.file))).unwrap_or_else(|| p.to_string()));
381        let (file, before) = match position {
382            Some(i) => (state.items[i].file.clone(), std::fs::read_to_string(dir.join(&state.items[i].file)).unwrap_or_else(|_| skeleton(id))),
383            None => {
384                let taken: BTreeSet<String> = state.items.iter().map(|i| i.file.clone()).chain(files(dir)).collect();
385                let stem = vdir::folder_id(id);
386                let file = std::iter::once(format!("{stem}.ics")).chain((2..).map(|n| format!("{stem}-{n}.ics"))).find(|f| !taken.contains(f)).unwrap_or_default();
387                (file, skeleton(id))
388            }
389        };
390        let text = merged(&before, task, parent.as_deref());
391        vdir::write_item(&dir.join(&file), &text).map_err(SyncError::Disk)?;
392        let item = ItemState { href: id.to_string(), file, etag: etag.to_string(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() };
393        match position {
394            Some(i) => state.items[i] = item,
395            None => state.items.push(item),
396        }
397        report.received += 1;
398    }
399    if since.is_none() {
400        let mut gone = Vec::new();
401        state.items.retain(|i| {
402            let here = seen.contains(&i.href);
403            if !here {
404                gone.push(i.file.clone());
405            }
406            here
407        });
408        for file in gone {
409            let _ = std::fs::remove_file(dir.join(file));
410            report.removed += 1;
411        }
412    }
413    Ok(())
414}
415
416/// A task Google made: its own id as UID.
417fn skeleton(uid: &str) -> String {
418    lines::fold(&["BEGIN:VCALENDAR".into(), "VERSION:2.0".into(), "PRODID:-//Sioul//Sioul//EN".into(), "BEGIN:VTODO".into(), format!("UID:{uid}"), "END:VTODO".into(), "END:VCALENDAR".into()])
419}
420
421/// What Google keeps of a task, as its API takes it.
422pub fn fields_of(task: &tasks::Task) -> Value {
423    let done = matches!(task.status, Status::Completed | Status::Cancelled);
424    let completed = done.then(|| task.completed.and_then(|s| Timestamp::from_second(s).ok()).unwrap_or_else(Timestamp::now).strftime("%Y-%m-%dT%H:%M:%S.000Z").to_string());
425    let due = task.due_date().map(|d| format!("{d}T00:00:00.000Z"));
426    json!({
427        "title": task.title.chars().take(1024).collect::<String>(),
428        "notes": task.notes.chars().take(8192).collect::<String>(),
429        "status": if done { "completed" } else { "needsAction" },
430        "completed": completed,
431        "due": due,
432    })
433}
434
435/// "2026-10-03T12:00:00.000Z" → "20261003T120000Z".
436fn stamp(rfc3339: &str) -> Option<String> {
437    rfc3339.parse::<Timestamp>().ok().map(|t| t.strftime("%Y%m%dT%H%M%SZ").to_string())
438}
439
440/// What Google says of a task, as VTODO lines.
441fn google_lines(task: &Value, parent: Option<&str>) -> Vec<String> {
442    let mut out = Vec::new();
443    if let Some(updated) = task["updated"].as_str().and_then(stamp) {
444        out.push(format!("DTSTAMP:{updated}"));
445        out.push(format!("LAST-MODIFIED:{updated}"));
446    }
447    out.push(format!("SUMMARY:{}", lines::escape(task["title"].as_str().unwrap_or(""))));
448    if let Some(notes) = task["notes"].as_str().filter(|n| !n.is_empty()) {
449        out.push(format!("DESCRIPTION:{}", lines::escape(notes)));
450    }
451    let done = task["status"].as_str() == Some("completed");
452    out.push(format!("STATUS:{}", if done { "COMPLETED" } else { "NEEDS-ACTION" }));
453    if done && let Some(at) = task["completed"].as_str().and_then(stamp) {
454        out.push(format!("COMPLETED:{at}"));
455    }
456    // Google's "due" is a day: "the day the task should be done".
457    if let Some(day) = task["due"].as_str().and_then(|d| d.get(..10)) {
458        out.push(format!("DUE;VALUE=DATE:{}", day.replace('-', "")));
459    }
460    if let Some(parent) = parent {
461        out.push(tasks::relation_line("PARENT", parent, 0));
462    }
463    out
464}
465
466/// The task's file with what Google keeps written again from its answer; the
467/// rest (lines Google does not know, alarms) stays as it was.
468pub fn merged(text: &str, task: &Value, parent: Option<&str>) -> String {
469    const GOOGLES: &[&str] = &["SUMMARY", "DESCRIPTION", "STATUS", "COMPLETED", "DUE", "DTSTAMP", "LAST-MODIFIED", "PERCENT-COMPLETE"];
470    let source = lines::unfold(text);
471    let mut out = Vec::with_capacity(source.len() + 8);
472    let (mut in_todo, mut nested, mut placed) = (false, 0usize, false);
473    for line in source {
474        let name = lines::name(&line);
475        let value = lines::value(&line).trim().to_ascii_uppercase();
476        match (name.as_str(), value.as_str()) {
477            ("BEGIN", "VTODO") if !in_todo => in_todo = true,
478            ("BEGIN", _) if in_todo => {
479                if nested == 0 && !placed {
480                    out.extend(google_lines(task, parent));
481                    placed = true;
482                }
483                nested += 1;
484            }
485            ("END", "VTODO") if in_todo && nested == 0 => {
486                if !placed {
487                    out.extend(google_lines(task, parent));
488                    placed = true;
489                }
490                in_todo = false;
491            }
492            ("END", _) if in_todo && nested > 0 => nested -= 1,
493            _ if in_todo && nested == 0 => {
494                let parent_line = name == "RELATED-TO" && lines::param(&line, "RELTYPE").is_none_or(|t| t.eq_ignore_ascii_case("PARENT"));
495                if GOOGLES.contains(&name.as_str()) || parent_line {
496                    continue;
497                }
498            }
499            _ => {}
500        }
501        out.push(line);
502    }
503    lines::fold(&out)
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::dav::stand_in::{Fault, Reply, Request, Server, home, nth, serve};
510    use std::sync::Arc;
511    use std::sync::atomic::{AtomicUsize, Ordering};
512
513    #[test]
514    fn a_task_both_ways() {
515        let google = json!({ "id": "abc", "etag": "\"1\"", "title": "Call the notary, today", "notes": "Ask for the deed", "status": "completed",
516            "completed": "2026-10-03T09:30:00.000Z", "due": "2026-10-05T00:00:00.000Z", "updated": "2026-10-03T09:31:00.000Z", "parent": "top" });
517        let text = merged(&skeleton("abc"), &google, Some("top-uid"));
518        let task = tasks::task_of_text(&text, &TimeZone::UTC).unwrap();
519        assert_eq!((task.uid.as_str(), task.title.as_str(), task.notes.as_str()), ("abc", "Call the notary, today", "Ask for the deed"));
520        assert_eq!((task.status, task.due.as_str(), task.parent()), (Status::Completed, "2026-10-05", Some("top-uid")));
521        let back = fields_of(&task);
522        assert_eq!(back["status"], "completed");
523        assert_eq!(back["completed"], "2026-10-03T09:30:00.000Z");
524        assert_eq!(back["due"], "2026-10-05T00:00:00.000Z");
525        // What Google does not know stays; what it says replaces the old.
526        let mine = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:mine\r\nSUMMARY:Old\r\nCATEGORIES:joy\r\nRELATED-TO;RELTYPE=DEPENDS-ON:other\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
527        let open = json!({ "title": "New", "status": "needsAction" });
528        let text = merged(mine, &open, None);
529        assert!(text.contains("UID:mine") && text.contains("CATEGORIES:joy") && text.contains("RELATED-TO;RELTYPE=DEPENDS-ON:other"), "{text}");
530        assert!(text.contains("SUMMARY:New\r\nSTATUS:NEEDS-ACTION\r\nBEGIN:VALARM") && !text.contains("Old"), "{text}");
531        assert_eq!(fields_of(&tasks::task_of_text(&text, &TimeZone::UTC).unwrap())["due"], Value::Null);
532    }
533
534    /// Google Tasks in memory, one list, for these tests: its tasks by id
535    /// (deleted ones gone), and faults to inject.
536    #[derive(Default)]
537    struct Google {
538        held: Mutex<BTreeMap<String, Value>>,
539        made: AtomicUsize,
540        faults: Mutex<Vec<Box<dyn FnMut(&Request) -> Option<Fault> + Send>>>,
541    }
542
543    impl Google {
544        fn with(titles: &[(&str, &str)]) -> Arc<Google> {
545            let google = Arc::new(Google::default());
546            for (id, title) in titles {
547                let task = json!({ "id": id, "etag": format!("\"{id}-0\""), "title": title, "status": "needsAction", "updated": "2026-10-01T08:00:00.000Z" });
548                google.held.lock().unwrap().insert(id.to_string(), task);
549            }
550            google
551        }
552
553        fn titles(&self) -> Vec<String> {
554            let mut titles: Vec<String> = self.held.lock().unwrap().values().map(|t| t["title"].as_str().unwrap_or("").to_string()).collect();
555            titles.sort();
556            titles
557        }
558    }
559
560    impl Server for Google {
561        fn misbehaves(&self, request: &Request) -> Option<Fault> {
562            self.faults.lock().unwrap().iter_mut().find_map(|f| f(request))
563        }
564
565        fn answer(&self, request: &Request) -> Reply {
566            let mut held = self.held.lock().unwrap();
567            let (path, query) = request.path.split_once('?').unwrap_or((request.path.as_str(), ""));
568            let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
569            let made = || self.made.fetch_add(1, Ordering::Relaxed) + 1;
570            match (request.method.as_str(), parts.as_slice()) {
571                ("GET", ["lists", _, "tasks"]) => Reply::new(200, json!({ "items": held.values().collect::<Vec<_>>() }).to_string()),
572                ("POST", ["lists", _, "tasks"]) => {
573                    let id = format!("n{}", made());
574                    let mut task: Value = serde_json::from_str(&request.body).unwrap();
575                    task["id"] = json!(id);
576                    task["etag"] = json!(format!("\"{id}-0\""));
577                    task["updated"] = json!("2026-10-05T10:00:00.000Z");
578                    if let Some(parent) = query.split('&').find_map(|p| p.strip_prefix("parent=")) {
579                        task["parent"] = json!(parent);
580                    }
581                    held.insert(id, task.clone());
582                    Reply::new(200, task.to_string())
583                }
584                ("PATCH", ["lists", _, "tasks", id]) => match held.get_mut(*id) {
585                    Some(task) => {
586                        let change: Value = serde_json::from_str(&request.body).unwrap();
587                        for (name, value) in change.as_object().into_iter().flatten() {
588                            task[name] = value.clone();
589                        }
590                        task["etag"] = json!(format!("\"{id}-{}\"", made()));
591                        Reply::new(200, task.to_string())
592                    }
593                    None => Reply::new(404, ""),
594                },
595                ("DELETE", ["lists", _, "tasks", id]) => {
596                    held.remove(*id);
597                    Reply::new(204, "")
598                }
599                _ => Reply::new(404, ""),
600            }
601        }
602    }
603
604    /// The API, at a stand-in.
605    fn api_at(base: &str) -> Api<'static> {
606        let agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
607        Api { address: "you@example.org", agent, token: Mutex::new("test-token".into()), base: base.to_string() }
608    }
609
610    fn sync_once(api: &Api, account: &str) -> Result<Report, SyncError> {
611        let mut report = Report::default();
612        sync_list(api, account, "L", "tasks-L", "Tasks", &TimeZone::UTC, &mut report).map(|()| report)
613    }
614
615    fn list_dir(account: &str) -> std::path::PathBuf {
616        Kind::Calendars.root().join(account).join("tasks-L")
617    }
618
619    /// F4: a task deleted on Google while changed here is made again there, once.
620    #[test]
621    fn a_task_gone_there_and_changed_here_is_made_once() {
622        home();
623        let google = Google::with(&[("g1", "Call")]);
624        let api = api_at(&serve(google.clone()));
625        sync_once(&api, "gt-once").unwrap();
626        let file = list_dir("gt-once").join("g1.ics");
627        google.held.lock().unwrap().remove("g1");
628        std::fs::write(&file, std::fs::read_to_string(&file).unwrap().replace("SUMMARY:Call", "SUMMARY:Call the notary")).unwrap();
629        sync_once(&api, "gt-once").unwrap();
630        assert_eq!(google.titles(), ["Call the notary"]);
631    }
632
633    /// F1, Google Tasks: a task made on Google is known at once: a sync cut
634    /// after it does not make it twice.
635    #[test]
636    fn a_task_made_on_google_is_not_made_twice() {
637        home();
638        let google = Google::with(&[]);
639        let api = api_at(&serve(google.clone()));
640        sync_once(&api, "gt-twice").unwrap();
641        let zone = TimeZone::UTC;
642        for (uid, title) in [("a-uid", "First"), ("b-uid", "Second")] {
643            let edit = tasks::TaskEdit { title: title.into(), ..Default::default() };
644            std::fs::write(list_dir("gt-twice").join(format!("{uid}.ics")), tasks::new_task(&edit, uid, &zone, &jiff::Zoned::now()).unwrap()).unwrap();
645        }
646        google.faults.lock().unwrap().push(Box::new(nth(2, |r| r.method == "POST", Fault::Status(500))));
647        assert!(sync_once(&api, "gt-twice").is_err());
648        sync_once(&api, "gt-twice").unwrap();
649        assert_eq!(google.titles(), ["First", "Second"]);
650    }
651
652    /// Against a stand-in for Google (tools/google-tasks-stand-in.py), with
653    /// SIOUL_TEST_GOOGLE_TASKS, SIOUL_TEST_GOOGLE_TOKEN and XDG_* set to a
654    /// scratch folder: `cargo test -p sioul-sync --features insecure-test-tls -- --ignored stand_in`.
655    #[cfg(feature = "insecure-test-tls")]
656    #[test]
657    #[ignore]
658    fn against_a_stand_in() {
659        let base = std::env::var("SIOUL_TEST_GOOGLE_TASKS").expect("SIOUL_TEST_GOOGLE_TASKS");
660        let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
661        let google = |path: &str| -> Value { serde_json::from_str(&agent.get(&format!("{base}/_state{path}")).call().unwrap().body_mut().read_to_string().unwrap()).unwrap() };
662        let poke = |path: &str, body: Value| {
663            agent.post(&format!("{base}/_poke{path}")).send(body.to_string()).unwrap();
664        };
665        let account: Account = toml::from_str("id = \"g\"\nkind = \"dav\"\naddress = \"you@example.org\"\nhost = \"apidata.googleusercontent.com\"\nauth = \"google\"\n").unwrap();
666        let zone = TimeZone::UTC;
667        let list = || vdir::collections(Kind::Calendars).into_iter().find(|c| c.account == "g" && c.id == "tasks-L1").expect("tasks-L1 here");
668        let read = |uid: &str| tasks::all(&zone).into_iter().find(|t| t.uid == uid);
669
670        // First sync: everything comes, the step under its task.
671        let report = sync(&account).unwrap();
672        assert_eq!((report.collections, report.received), (1, 3), "{report:?}");
673        assert_eq!(read("t2").unwrap().parent(), Some("t1"));
674        assert_eq!(read("t1").unwrap().due, "2026-10-05");
675
676        // Here: one renamed, one new step under it, one deleted. There: one changed.
677        let dir = list().dir;
678        let t1 = dir.join("t1.ics");
679        std::fs::write(&t1, std::fs::read_to_string(&t1).unwrap().replace("SUMMARY:Buy stamps", "SUMMARY:Buy stamps today")).unwrap();
680        let edit = tasks::TaskEdit { title: "Find a pen".into(), parent: "t1".into(), ..Default::default() };
681        std::fs::write(dir.join("pen.ics"), tasks::new_task(&edit, "pen-uid", &zone, &jiff::Zoned::now()).unwrap()).unwrap();
682        std::fs::remove_file(dir.join("t3.ics")).unwrap();
683        poke("/lists/L1/tasks/t2", json!({ "title": "Find the envelope, the big one" }));
684        let report = sync(&account).unwrap();
685        assert_eq!((report.sent, report.received), (3, 1), "{report:?}");
686        let there = google("/lists/L1");
687        let by_title = |title: &str| there["tasks"].as_array().unwrap().iter().find(|t| t["title"] == title).cloned();
688        assert!(by_title("Buy stamps today").is_some(), "{there}");
689        assert_eq!(by_title("Find a pen").expect("the pen")["parent"], "t1");
690        assert_eq!(there["tasks"].as_array().unwrap().iter().find(|t| t["id"] == "t3").unwrap()["deleted"], true);
691        assert_eq!(read("t2").unwrap().title, "Find the envelope, the big one");
692        assert_eq!(read("pen-uid").unwrap().parent(), Some("t1"), "the new step keeps its UID here");
693
694        // A list made here, renamed, deleted.
695        let made = vdir::create(Kind::Calendars, "g", "Errands", None, &["VTODO"]).unwrap();
696        sync(&account).unwrap();
697        assert!(google("").as_array().unwrap().iter().any(|l| l["title"] == "Errands"));
698        let made = vdir::collections(Kind::Calendars).into_iter().find(|c| c.id == made.id).unwrap();
699        vdir::rename_collection(&made, "Errands, Saturday").unwrap();
700        sync(&account).unwrap();
701        assert!(google("").as_array().unwrap().iter().any(|l| l["title"] == "Errands, Saturday"));
702        vdir::delete_collection(&vdir::collections(Kind::Calendars).into_iter().find(|c| c.id == made.id).unwrap()).unwrap();
703        sync(&account).unwrap();
704        assert!(!google("").as_array().unwrap().iter().any(|l| l["title"].as_str().unwrap_or("").starts_with("Errands")));
705        assert!(!vdir::every_collection(Kind::Calendars).iter().any(|c| c.id == made.id));
706
707        // A list deleted on Google goes here too.
708        poke("/delete-list/L1", json!({}));
709        sync(&account).unwrap();
710        assert!(!vdir::every_collection(Kind::Calendars).iter().any(|c| c.id == "tasks-L1"));
711    }
712}