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, NoPagination, 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 {}
143impl NoPagination for GetTimeEntry {}
144
145impl GetTimeEntry {
146 #[must_use]
148 pub fn builder() -> GetTimeEntryBuilder {
149 GetTimeEntryBuilder::default()
150 }
151}
152
153impl Endpoint for GetTimeEntry {
154 fn method(&self) -> Method {
155 Method::GET
156 }
157
158 fn endpoint(&self) -> Cow<'static, str> {
159 format!("time_entries/{}.json", self.id).into()
160 }
161}
162
163#[serde_with::skip_serializing_none]
165#[derive(Debug, Clone, Builder, Serialize)]
166#[builder(setter(strip_option), build_fn(validate = "Self::validate"))]
167pub struct CreateTimeEntry<'a> {
168 #[builder(default)]
170 issue_id: Option<u64>,
171 #[builder(default)]
173 project_id: Option<u64>,
174 #[builder(default)]
176 spent_on: Option<time::Date>,
177 hours: f64,
179 #[builder(default)]
181 activity_id: Option<u64>,
182 #[builder(default)]
184 comments: Option<Cow<'a, str>>,
185 #[builder(default)]
187 user_id: Option<u64>,
188}
189
190impl ReturnsJsonResponse for CreateTimeEntry<'_> {}
191impl NoPagination for CreateTimeEntry<'_> {}
192
193impl CreateTimeEntryBuilder<'_> {
194 fn validate(&self) -> Result<(), String> {
196 if self.issue_id.is_none() && self.project_id.is_none() {
197 Err("Either issue_id or project_id need to be specified".to_string())
198 } else {
199 Ok(())
200 }
201 }
202}
203
204impl<'a> CreateTimeEntry<'a> {
205 #[must_use]
207 pub fn builder() -> CreateTimeEntryBuilder<'a> {
208 CreateTimeEntryBuilder::default()
209 }
210}
211
212impl Endpoint for CreateTimeEntry<'_> {
213 fn method(&self) -> Method {
214 Method::POST
215 }
216
217 fn endpoint(&self) -> Cow<'static, str> {
218 "time_entries.json".into()
219 }
220
221 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
222 Ok(Some((
223 "application/json",
224 serde_json::to_vec(&TimeEntryWrapper::<CreateTimeEntry> {
225 time_entry: (*self).to_owned(),
226 })?,
227 )))
228 }
229}
230
231#[serde_with::skip_serializing_none]
233#[derive(Debug, Clone, Builder, Serialize)]
234#[builder(setter(strip_option))]
235pub struct UpdateTimeEntry<'a> {
236 #[serde(skip_serializing)]
238 id: u64,
239 #[builder(default)]
241 issue_id: Option<u64>,
242 #[builder(default)]
244 project_id: Option<u64>,
245 #[builder(default)]
247 spent_on: Option<time::Date>,
248 #[builder(default)]
250 hours: Option<f64>,
251 #[builder(default)]
253 activity_id: Option<u64>,
254 #[builder(default)]
256 comments: Option<Cow<'a, str>>,
257 #[builder(default)]
259 user_id: Option<u64>,
260}
261
262impl<'a> UpdateTimeEntry<'a> {
263 #[must_use]
265 pub fn builder() -> UpdateTimeEntryBuilder<'a> {
266 UpdateTimeEntryBuilder::default()
267 }
268}
269
270impl Endpoint for UpdateTimeEntry<'_> {
271 fn method(&self) -> Method {
272 Method::PUT
273 }
274
275 fn endpoint(&self) -> Cow<'static, str> {
276 format!("time_entries/{}.json", self.id).into()
277 }
278
279 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
280 Ok(Some((
281 "application/json",
282 serde_json::to_vec(&TimeEntryWrapper::<UpdateTimeEntry> {
283 time_entry: (*self).to_owned(),
284 })?,
285 )))
286 }
287}
288
289#[derive(Debug, Clone, Builder)]
291#[builder(setter(strip_option))]
292pub struct DeleteTimeEntry {
293 id: u64,
295}
296
297impl DeleteTimeEntry {
298 #[must_use]
300 pub fn builder() -> DeleteTimeEntryBuilder {
301 DeleteTimeEntryBuilder::default()
302 }
303}
304
305impl Endpoint for DeleteTimeEntry {
306 fn method(&self) -> Method {
307 Method::DELETE
308 }
309
310 fn endpoint(&self) -> Cow<'static, str> {
311 format!("time_entries/{}.json", &self.id).into()
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
317pub struct TimeEntriesWrapper<T> {
318 pub time_entries: Vec<T>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
325pub struct TimeEntryWrapper<T> {
326 pub time_entry: T,
328}
329
330#[cfg(test)]
331mod test {
332 use crate::api::ResponsePage;
333
334 use super::*;
335 use pretty_assertions::assert_eq;
336 use std::error::Error;
337 use tokio::sync::RwLock;
338 use tracing_test::traced_test;
339
340 static TIME_ENTRY_LOCK: RwLock<()> = RwLock::const_new(());
343
344 #[traced_test]
345 #[test]
346 fn test_list_time_entries_first_page() -> Result<(), Box<dyn Error>> {
347 let _r_time_entries = TIME_ENTRY_LOCK.read();
348 dotenvy::dotenv()?;
349 let redmine = crate::api::Redmine::from_env()?;
350 let endpoint = ListTimeEntries::builder().build()?;
351 redmine.json_response_body_page::<_, TimeEntry>(&endpoint, 0, 25)?;
352 Ok(())
353 }
354
355 #[traced_test]
369 #[test]
370 fn test_get_time_entry() -> Result<(), Box<dyn Error>> {
371 let _r_time_entries = TIME_ENTRY_LOCK.read();
372 dotenvy::dotenv()?;
373 let redmine = crate::api::Redmine::from_env()?;
374 let endpoint = GetTimeEntry::builder().id(832).build()?;
375 redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&endpoint)?;
376 Ok(())
377 }
378
379 #[traced_test]
380 #[test]
381 fn test_create_time_entry() -> Result<(), Box<dyn Error>> {
382 let _w_time_entries = TIME_ENTRY_LOCK.write();
383 dotenvy::dotenv()?;
384 let redmine = crate::api::Redmine::from_env()?;
385 let create_endpoint = super::CreateTimeEntry::builder()
386 .issue_id(25095)
387 .hours(1.0)
388 .activity_id(8)
389 .build()?;
390 redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
391 Ok(())
392 }
393
394 #[traced_test]
395 #[test]
396 fn test_update_time_entry() -> Result<(), Box<dyn Error>> {
397 let _w_time_entries = TIME_ENTRY_LOCK.write();
398 dotenvy::dotenv()?;
399 let redmine = crate::api::Redmine::from_env()?;
400 let create_endpoint = super::CreateTimeEntry::builder()
401 .issue_id(25095)
402 .hours(1.0)
403 .activity_id(8)
404 .build()?;
405 let TimeEntryWrapper { time_entry } =
406 redmine.json_response_body::<_, TimeEntryWrapper<TimeEntry>>(&create_endpoint)?;
407 let update_endpoint = super::UpdateTimeEntry::builder()
408 .id(time_entry.id)
409 .hours(2.0)
410 .build()?;
411 redmine.ignore_response_body::<_>(&update_endpoint)?;
412 Ok(())
413 }
414
415 #[traced_test]
420 #[test]
421 fn test_completeness_time_entry_type_first_page() -> Result<(), Box<dyn Error>> {
422 let _r_time_entries = TIME_ENTRY_LOCK.read();
423 dotenvy::dotenv()?;
424 let redmine = crate::api::Redmine::from_env()?;
425 let endpoint = ListTimeEntries::builder().build()?;
426 let ResponsePage {
427 values,
428 total_count: _,
429 offset: _,
430 limit: _,
431 } = redmine.json_response_body_page::<_, serde_json::Value>(&endpoint, 0, 100)?;
432 for value in values {
433 let o: TimeEntry = serde_json::from_value(value.clone())?;
434 let reserialized = serde_json::to_value(o)?;
435 assert_eq!(value, reserialized);
436 }
437 Ok(())
438 }
439}