1use derive_builder::Builder;
19use reqwest::Method;
20use std::borrow::Cow;
21
22use crate::api::custom_fields::CustomFieldEssentialsWithValue;
23use crate::api::enumerations::TimeEntryActivityEssentials;
24use crate::api::issues::IssueEssentials;
25use crate::api::projects::ProjectEssentials;
26use crate::api::users::UserEssentials;
27use crate::api::{Endpoint, Pageable, QueryParams, ReturnsJsonResponse};
28use serde::Serialize;
29
30#[derive(Debug, Clone, Serialize, serde::Deserialize)]
34pub struct TimeEntry {
35 pub id: u64,
37 pub user: UserEssentials,
39 pub hours: f64,
41 pub activity: TimeEntryActivityEssentials,
43 #[serde(default)]
45 pub comments: Option<String>,
46 pub issue: Option<IssueEssentials>,
48 pub project: Option<ProjectEssentials>,
50 pub spent_on: Option<time::Date>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
55
56 #[serde(
58 serialize_with = "crate::api::serialize_rfc3339",
59 deserialize_with = "crate::api::deserialize_rfc3339"
60 )]
61 pub created_on: time::OffsetDateTime,
62 #[serde(
64 serialize_with = "crate::api::serialize_rfc3339",
65 deserialize_with = "crate::api::deserialize_rfc3339"
66 )]
67 pub updated_on: time::OffsetDateTime,
68}
69
70#[derive(Debug, Clone, Builder)]
72#[builder(setter(strip_option))]
73pub struct ListTimeEntries<'a> {
74 #[builder(default)]
76 user_id: Option<u64>,
77 #[builder(setter(into), default)]
79 project_id_or_name: Option<Cow<'a, str>>,
80 #[builder(default)]
82 issue_id: Option<u64>,
83 #[builder(default)]
85 activity_id: Option<u64>,
86 #[builder(default)]
88 spent_on: Option<time::Date>,
89 #[builder(default)]
91 from: Option<time::Date>,
92 #[builder(default)]
94 to: Option<time::Date>,
95}
96
97impl ReturnsJsonResponse for ListTimeEntries<'_> {}
98impl Pageable for ListTimeEntries<'_> {
99 fn response_wrapper_key(&self) -> String {
100 "time_entries".to_string()
101 }
102}
103
104impl<'a> ListTimeEntries<'a> {
105 #[must_use]
107 pub fn builder() -> ListTimeEntriesBuilder<'a> {
108 ListTimeEntriesBuilder::default()
109 }
110}
111
112impl Endpoint for ListTimeEntries<'_> {
113 fn method(&self) -> Method {
114 Method::GET
115 }
116
117 fn endpoint(&self) -> Cow<'static, str> {
118 "time_entries.json".into()
119 }
120
121 fn parameters(&self) -> QueryParams {
122 let mut params = QueryParams::default();
123 params.push_opt("user_id", self.user_id);
124 params.push_opt("project_id", self.project_id_or_name.as_ref());
125 params.push_opt("issue_id", self.issue_id);
126 params.push_opt("activity_id", self.activity_id);
127 params.push_opt("spent_on", self.spent_on);
128 params.push_opt("from", self.from);
129 params.push_opt("to", self.to);
130 params
131 }
132}
133
134#[derive(Debug, Clone, Builder)]
136#[builder(setter(strip_option))]
137pub struct GetTimeEntry {
138 id: u64,
140}
141
142impl ReturnsJsonResponse for GetTimeEntry {}
143
144impl GetTimeEntry {
145 #[must_use]
147 pub fn builder() -> GetTimeEntryBuilder {
148 GetTimeEntryBuilder::default()
149 }
150}
151
152impl Endpoint for GetTimeEntry {
153 fn method(&self) -> Method {
154 Method::GET
155 }
156
157 fn endpoint(&self) -> Cow<'static, str> {
158 format!("time_entries/{}.json", self.id).into()
159 }
160}
161
162#[serde_with::skip_serializing_none]
164#[derive(Debug, Clone, Builder, Serialize)]
165#[builder(setter(strip_option), build_fn(validate = "Self::validate"))]
166pub struct CreateTimeEntry<'a> {
167 #[builder(default)]
169 issue_id: Option<u64>,
170 #[builder(default)]
172 project_id: Option<u64>,
173 #[builder(default)]
175 spent_on: Option<time::Date>,
176 hours: f64,
178 #[builder(default)]
180 activity_id: Option<u64>,
181 #[builder(default)]
183 comments: Option<Cow<'a, str>>,
184 #[builder(default)]
186 user_id: Option<u64>,
187}
188
189impl ReturnsJsonResponse for CreateTimeEntry<'_> {}
190
191impl CreateTimeEntryBuilder<'_> {
192 fn validate(&self) -> Result<(), String> {
194 if self.issue_id.is_none() && self.project_id.is_none() {
195 Err("Either issue_id or project_id need to be specified".to_string())
196 } else {
197 Ok(())
198 }
199 }
200}
201
202impl<'a> CreateTimeEntry<'a> {
203 #[must_use]
205 pub fn builder() -> CreateTimeEntryBuilder<'a> {
206 CreateTimeEntryBuilder::default()
207 }
208}
209
210impl Endpoint for CreateTimeEntry<'_> {
211 fn method(&self) -> Method {
212 Method::POST
213 }
214
215 fn endpoint(&self) -> Cow<'static, str> {
216 "time_entries.json".into()
217 }
218
219 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
220 Ok(Some((
221 "application/json",
222 serde_json::to_vec(&TimeEntryWrapper::<CreateTimeEntry> {
223 time_entry: (*self).to_owned(),
224 })?,
225 )))
226 }
227}
228
229#[serde_with::skip_serializing_none]
231#[derive(Debug, Clone, Builder, Serialize)]
232#[builder(setter(strip_option))]
233pub struct UpdateTimeEntry<'a> {
234 #[serde(skip_serializing)]
236 id: u64,
237 #[builder(default)]
239 issue_id: Option<u64>,
240 #[builder(default)]
242 project_id: Option<u64>,
243 #[builder(default)]
245 spent_on: Option<time::Date>,
246 #[builder(default)]
248 hours: Option<f64>,
249 #[builder(default)]
251 activity_id: Option<u64>,
252 #[builder(default)]
254 comments: Option<Cow<'a, str>>,
255 #[builder(default)]
257 user_id: Option<u64>,
258}
259
260impl<'a> UpdateTimeEntry<'a> {
261 #[must_use]
263 pub fn builder() -> UpdateTimeEntryBuilder<'a> {
264 UpdateTimeEntryBuilder::default()
265 }
266}
267
268impl Endpoint for UpdateTimeEntry<'_> {
269 fn method(&self) -> Method {
270 Method::PUT
271 }
272
273 fn endpoint(&self) -> Cow<'static, str> {
274 format!("time_entries/{}.json", self.id).into()
275 }
276
277 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
278 Ok(Some((
279 "application/json",
280 serde_json::to_vec(&TimeEntryWrapper::<UpdateTimeEntry> {
281 time_entry: (*self).to_owned(),
282 })?,
283 )))
284 }
285}
286
287#[derive(Debug, Clone, Builder)]
289#[builder(setter(strip_option))]
290pub struct DeleteTimeEntry {
291 id: u64,
293}
294
295impl DeleteTimeEntry {
296 #[must_use]
298 pub fn builder() -> DeleteTimeEntryBuilder {
299 DeleteTimeEntryBuilder::default()
300 }
301}
302
303impl Endpoint for DeleteTimeEntry {
304 fn method(&self) -> Method {
305 Method::DELETE
306 }
307
308 fn endpoint(&self) -> Cow<'static, str> {
309 format!("time_entries/{}.json", &self.id).into()
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
315pub struct TimeEntriesWrapper<T> {
316 pub time_entries: Vec<T>,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
323pub struct TimeEntryWrapper<T> {
324 pub time_entry: T,
326}
327
328#[cfg(test)]
329mod test {
330 use super::*;
331 use pretty_assertions::assert_eq;
332 use std::error::Error;
333 use tokio::sync::RwLock;
334 use tracing_test::traced_test;
335
336 static TIME_ENTRY_LOCK: RwLock<()> = RwLock::const_new(());
339
340 #[traced_test]
341 #[test]
342 fn test_list_time_entries_no_pagination() -> Result<(), Box<dyn Error>> {
343 let _r_time_entries = TIME_ENTRY_LOCK.read();
344 dotenvy::dotenv()?;
345 let redmine = crate::api::Redmine::from_env()?;
346 let endpoint = ListTimeEntries::builder().build()?;
347 redmine.json_response_body::<_, TimeEntriesWrapper<TimeEntry>>(&endpoint)?;
348 Ok(())
349 }
350
351 #[traced_test]
352 #[test]
353 fn test_list_time_entries_first_page() -> Result<(), Box<dyn Error>> {
354 let _r_time_entries = TIME_ENTRY_LOCK.read();
355 dotenvy::dotenv()?;
356 let redmine = crate::api::Redmine::from_env()?;
357 let endpoint = ListTimeEntries::builder().build()?;
358 redmine.json_response_body_page::<_, TimeEntry>(&endpoint, 0, 25)?;
359 Ok(())
360 }
361
362 #[traced_test]
376 #[test]
377 fn test_get_time_entry() -> Result<(), Box<dyn Error>> {
378 let _r_time_entries = TIME_ENTRY_LOCK.read();
379 dotenvy::dotenv()?;
380 let redmine = crate::api::Redmine::from_env()?;
381 let endpoint = GetTimeEntry::builder().id(832).build()?;
382 redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&endpoint)?;
383 Ok(())
384 }
385
386 #[traced_test]
387 #[test]
388 fn test_create_time_entry() -> Result<(), Box<dyn Error>> {
389 let _w_time_entries = TIME_ENTRY_LOCK.write();
390 dotenvy::dotenv()?;
391 let redmine = crate::api::Redmine::from_env()?;
392 let create_endpoint = super::CreateTimeEntry::builder()
393 .issue_id(25095)
394 .hours(1.0)
395 .activity_id(8)
396 .build()?;
397 redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
398 Ok(())
399 }
400
401 #[traced_test]
402 #[test]
403 fn test_update_time_entry() -> Result<(), Box<dyn Error>> {
404 let _w_time_entries = TIME_ENTRY_LOCK.write();
405 dotenvy::dotenv()?;
406 let redmine = crate::api::Redmine::from_env()?;
407 let create_endpoint = super::CreateTimeEntry::builder()
408 .issue_id(25095)
409 .hours(1.0)
410 .activity_id(8)
411 .build()?;
412 let TimeEntryWrapper { time_entry } =
413 redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
414 let update_endpoint = super::UpdateTimeEntry::builder()
415 .id(time_entry.id)
416 .hours(2.0)
417 .build()?;
418 redmine.ignore_response_body::<_>(&update_endpoint)?;
419 Ok(())
420 }
421
422 #[traced_test]
427 #[test]
428 fn test_completeness_time_entry_type() -> Result<(), Box<dyn Error>> {
429 let _r_time_entries = TIME_ENTRY_LOCK.read();
430 dotenvy::dotenv()?;
431 let redmine = crate::api::Redmine::from_env()?;
432 let endpoint = ListTimeEntries::builder().build()?;
433 let TimeEntriesWrapper {
434 time_entries: values,
435 } = redmine.json_response_body::<_, TimeEntriesWrapper<serde_json::Value>>(&endpoint)?;
436 for value in values {
437 let o: TimeEntry = serde_json::from_value(value.clone())?;
438 let reserialized = serde_json::to_value(o)?;
439 assert_eq!(value, reserialized);
440 }
441 Ok(())
442 }
443}