Skip to main content

jmap_tasks_client/methods/
task.rs

1//! JMAP Tasks — Task/* method implementations on SessionClient.
2
3use std::collections::HashMap;
4
5use jmap_types::{Id, PatchObject, State};
6
7use super::{ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetResponse};
8
9impl super::SessionClient {
10    /// Fetch Task objects by IDs (draft-tasks-06 §4.5).
11    ///
12    /// If `ids` is `None`, the server returns all Tasks for the account.
13    /// Pass `properties: None` to return all fields.
14    pub async fn task_get(
15        &self,
16        ids: Option<&[Id]>,
17        properties: Option<&[&str]>,
18    ) -> Result<GetResponse<jmap_tasks_types::Task>, jmap_base_client::ClientError> {
19        let (api_url, account_id) = self.session_parts()?;
20        // Omit `ids` / `properties` when None — see the matching comment on
21        // `task_list_get` for the rationale (consistent with set/changes/query).
22        let mut args = serde_json::json!({ "accountId": account_id });
23        if let Some(id_slice) = ids {
24            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
25        }
26        if let Some(props) = properties {
27            args["properties"] = serde_json::Value::Array(
28                props.iter().copied().map(serde_json::Value::from).collect(),
29            );
30        }
31        let req = super::build_request("Task/get", args, super::USING_TASKS);
32        let resp = self.call_internal(api_url, &req).await?;
33        jmap_base_client::extract_response(&resp, super::CALL_ID)
34    }
35
36    /// Fetch changes to Task objects since `since_state` (draft-tasks-06 §4.6).
37    pub async fn task_changes(
38        &self,
39        since_state: &State,
40        max_changes: Option<u64>,
41    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
42        // Defence-in-depth: see task_list_changes.
43        if since_state.as_ref().is_empty() {
44            return Err(jmap_base_client::ClientError::InvalidArgument(
45                "task_changes: since_state may not be empty".into(),
46            ));
47        }
48        let (api_url, account_id) = self.session_parts()?;
49        let mut args = serde_json::json!({
50            "accountId": account_id,
51            "sinceState": since_state,
52        });
53        if let Some(mc) = max_changes {
54            args["maxChanges"] = mc.into();
55        }
56        let req = super::build_request("Task/changes", args, super::USING_TASKS);
57        let resp = self.call_internal(api_url, &req).await?;
58        jmap_base_client::extract_response(&resp, super::CALL_ID)
59    }
60
61    /// Create, update, or destroy Task objects (draft-tasks-06 §4.7).
62    ///
63    /// `update` is `Option<HashMap<Id, PatchObject>>` (RFC 8620 §5.3). Wire
64    /// format is unchanged from a plain JSON object because [`PatchObject`]
65    /// is `#[serde(transparent)]`; the typed parameter binds the JSON Pointer
66    /// key + null-leaf removal contract to the type system.
67    pub async fn task_set(
68        &self,
69        create: Option<serde_json::Value>,
70        update: Option<HashMap<Id, PatchObject>>,
71        destroy: Option<Vec<Id>>,
72    ) -> Result<SetResponse<jmap_tasks_types::Task>, jmap_base_client::ClientError> {
73        let (api_url, account_id) = self.session_parts()?;
74        let mut args = serde_json::json!({
75            "accountId": account_id,
76        });
77        if let Some(c) = create {
78            args["create"] = c;
79        }
80        if let Some(u) = update {
81            args["update"] = serde_json::to_value(&u).map_err(|e| {
82                jmap_base_client::ClientError::InvalidArgument(format!(
83                    "task_set: serializing update map failed: {e}"
84                ))
85            })?;
86        }
87        if let Some(d) = destroy {
88            args["destroy"] = serde_json::to_value(&d).expect("Id Vec Serialize is infallible");
89        }
90        let req = super::build_request("Task/set", args, super::USING_TASKS);
91        let resp = self.call_internal(api_url, &req).await?;
92        jmap_base_client::extract_response(&resp, super::CALL_ID)
93    }
94
95    /// Copy Tasks from another account (draft-tasks-06 §4.8).
96    ///
97    /// `from_account_id` is the source account. The tasks are copied into the
98    /// current primary Tasks account.
99    pub async fn task_copy(
100        &self,
101        from_account_id: &Id,
102        create: serde_json::Value,
103    ) -> Result<SetResponse<jmap_tasks_types::Task>, jmap_base_client::ClientError> {
104        let (api_url, account_id) = self.session_parts()?;
105        let args = serde_json::json!({
106            "fromAccountId": from_account_id,
107            "accountId": account_id,
108            "create": create,
109        });
110        let req = super::build_request("Task/copy", args, super::USING_TASKS);
111        let resp = self.call_internal(api_url, &req).await?;
112        jmap_base_client::extract_response(&resp, super::CALL_ID)
113    }
114
115    /// Query Task IDs with optional filter and sort (draft-tasks-06 §4.13).
116    pub async fn task_query(
117        &self,
118        filter: Option<serde_json::Value>,
119        sort: Option<serde_json::Value>,
120        position: Option<u64>,
121        limit: Option<u64>,
122    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
123        let (api_url, account_id) = self.session_parts()?;
124        let mut args = serde_json::json!({
125            "accountId": account_id,
126        });
127        if let Some(f) = filter {
128            args["filter"] = f;
129        }
130        if let Some(s) = sort {
131            args["sort"] = s;
132        }
133        if let Some(p) = position {
134            args["position"] = p.into();
135        }
136        if let Some(l) = limit {
137            args["limit"] = l.into();
138        }
139        let req = super::build_request("Task/query", args, super::USING_TASKS);
140        let resp = self.call_internal(api_url, &req).await?;
141        jmap_base_client::extract_response(&resp, super::CALL_ID)
142    }
143
144    /// Fetch query-result changes for Task since `since_query_state`
145    /// (draft-tasks-06 §4.14).
146    pub async fn task_query_changes(
147        &self,
148        since_query_state: &State,
149        max_changes: Option<u64>,
150    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
151        // Defence-in-depth: see task_list_changes.
152        if since_query_state.as_ref().is_empty() {
153            return Err(jmap_base_client::ClientError::InvalidArgument(
154                "task_query_changes: since_query_state may not be empty".into(),
155            ));
156        }
157        let (api_url, account_id) = self.session_parts()?;
158        let mut args = serde_json::json!({
159            "accountId": account_id,
160            "sinceQueryState": since_query_state,
161        });
162        if let Some(mc) = max_changes {
163            args["maxChanges"] = mc.into();
164        }
165        let req = super::build_request("Task/queryChanges", args, super::USING_TASKS);
166        let resp = self.call_internal(api_url, &req).await?;
167        jmap_base_client::extract_response(&resp, super::CALL_ID)
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Tests — see tests/task_tests.rs (wiremock-backed end-to-end)
173// ---------------------------------------------------------------------------
174//
175// `task_copy_request_includes_from_account_id` was vacuous: it hand-built
176// `args` Values and fed them to `build_request`, never exercising the
177// production `task_copy` builder. Deleted in JMAP-tco1.20.
178//
179// Real production-path coverage:
180//   - task_get_sends_ids_and_properties
181//   - task_get_all_ids_null
182//   - task_changes_paginated
183//   - task_set_create_round_trip
184//   - task_copy_includes_from_account_id
185//   - task_query_with_filter
186//   - task_query_changes_round_trip
187// in tests/task_tests.rs.
188//
189// Specific-flag passthrough coverage that may be lost is tracked
190// under JMAP-uuoi for follow-up wiremock smoke tests.
191//
192// `build_request`, `CALL_ID`, and `USING_TASKS` themselves have their
193// own focused tests in `methods/mod.rs`.
194//
195// The InvalidArgument guards for empty since_state, from_account_id, and
196// since_query_state live in task_changes / task_copy / task_query_changes
197// production code; testing them requires a wiremock-backed async harness.
198// See JMAP-sc1b.64.
199//
200// The `task_get_empty_id_returns_invalid_argument` inline smoke test was
201// removed by the JMAP-6by7.5 typed-Id refactor. It was vacuous because
202// it only iterated a local `&[""]` slice and asserted `is_empty()` found
203// the empty value, without invoking any production method. Under typed
204// `&[Id]` parameters, an empty-Id input is impossible to express through
205// the API (`Id::new_validated("")` returns `Err` at the call site) so the
206// bug it pretended to test is unrepresentable.