1use derive_builder::Builder;
14use reqwest::Method;
15use std::borrow::Cow;
16
17use crate::api::custom_fields::CustomFieldName;
18use crate::api::enumerations::TimeEntryActivityEssentials;
19use crate::api::issue_categories::IssueCategoryEssentials;
20use crate::api::issues::AssigneeEssentials;
21use crate::api::trackers::TrackerEssentials;
22use crate::api::versions::VersionEssentials;
23use crate::api::{Endpoint, NoPagination, Pageable, QueryParams, ReturnsJsonResponse};
24use serde::Serialize;
25use std::collections::HashMap;
26
27#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub struct Module {
30 pub id: u64,
32 pub name: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39pub struct ProjectEssentials {
40 pub id: u64,
42 pub name: String,
44}
45
46impl From<Project> for ProjectEssentials {
47 fn from(v: Project) -> Self {
48 ProjectEssentials {
49 id: v.id,
50 name: v.name,
51 }
52 }
53}
54
55impl From<&Project> for ProjectEssentials {
56 fn from(v: &Project) -> Self {
57 ProjectEssentials {
58 id: v.id,
59 name: v.name.to_owned(),
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
68pub struct Project {
69 pub id: u64,
71 pub name: String,
73 pub identifier: String,
75 pub description: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub homepage: Option<String>,
80 pub is_public: Option<bool>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub parent: Option<ProjectEssentials>,
85 pub inherit_members: Option<bool>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub default_assignee: Option<AssigneeEssentials>,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub default_version: Option<VersionEssentials>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub default_version_id: Option<u64>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub tracker_ids: Option<Vec<u64>>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub enabled_module_names: Option<Vec<String>>,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub issue_custom_field_id: Option<Vec<u64>>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub custom_field_values: Option<HashMap<u64, String>>,
108 pub status: u64,
110 #[serde(
112 serialize_with = "crate::api::serialize_rfc3339",
113 deserialize_with = "crate::api::deserialize_rfc3339"
114 )]
115 pub created_on: time::OffsetDateTime,
116 #[serde(
118 serialize_with = "crate::api::serialize_rfc3339",
119 deserialize_with = "crate::api::deserialize_rfc3339"
120 )]
121 pub updated_on: time::OffsetDateTime,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub issue_categories: Option<Vec<IssueCategoryEssentials>>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub time_entry_activities: Option<Vec<TimeEntryActivityEssentials>>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub enabled_modules: Option<Vec<Module>>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub trackers: Option<Vec<TrackerEssentials>>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub issue_custom_fields: Option<Vec<CustomFieldName>>,
137}
138
139#[derive(Debug, Clone)]
141pub enum ProjectsInclude {
142 Trackers,
144 IssueCategories,
146 EnabledModules,
148 TimeEntryActivities,
150 IssueCustomFields,
152}
153
154impl std::fmt::Display for ProjectsInclude {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 match self {
157 Self::Trackers => {
158 write!(f, "trackers")
159 }
160 Self::IssueCategories => {
161 write!(f, "issue_categories")
162 }
163 Self::EnabledModules => {
164 write!(f, "enabled_modules")
165 }
166 Self::TimeEntryActivities => {
167 write!(f, "time_entry_activities")
168 }
169 Self::IssueCustomFields => {
170 write!(f, "issue_custom_fields")
171 }
172 }
173 }
174}
175
176#[derive(Debug, Clone, Builder)]
178#[builder(setter(strip_option))]
179pub struct ListProjects {
180 #[builder(default)]
182 include: Option<Vec<ProjectsInclude>>,
183}
184
185impl ReturnsJsonResponse for ListProjects {}
186impl Pageable for ListProjects {
187 fn response_wrapper_key(&self) -> String {
188 "projects".to_string()
189 }
190}
191
192impl ListProjects {
193 #[must_use]
195 pub fn builder() -> ListProjectsBuilder {
196 ListProjectsBuilder::default()
197 }
198}
199
200impl Endpoint for ListProjects {
201 fn method(&self) -> Method {
202 Method::GET
203 }
204
205 fn endpoint(&self) -> Cow<'static, str> {
206 "projects.json".into()
207 }
208
209 fn parameters(&self) -> QueryParams {
210 let mut params = QueryParams::default();
211 params.push_opt("include", self.include.as_ref());
212 params
213 }
214}
215
216#[derive(Debug, Clone)]
218pub enum ProjectInclude {
219 Trackers,
221 IssueCategories,
223 EnabledModules,
225 TimeEntryActivities,
227 IssueCustomFields,
229}
230
231impl std::fmt::Display for ProjectInclude {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 match self {
234 Self::Trackers => {
235 write!(f, "trackers")
236 }
237 Self::IssueCategories => {
238 write!(f, "issue_categories")
239 }
240 Self::EnabledModules => {
241 write!(f, "enabled_modules")
242 }
243 Self::TimeEntryActivities => {
244 write!(f, "time_entry_activities")
245 }
246 Self::IssueCustomFields => {
247 write!(f, "issue_custom_fields")
248 }
249 }
250 }
251}
252
253#[derive(Debug, Clone, Builder)]
255#[builder(setter(strip_option))]
256pub struct GetProject<'a> {
257 #[builder(setter(into))]
259 project_id_or_name: Cow<'a, str>,
260 #[builder(default)]
262 include: Option<Vec<ProjectInclude>>,
263}
264
265impl ReturnsJsonResponse for GetProject<'_> {}
266impl NoPagination for GetProject<'_> {}
267
268impl<'a> GetProject<'a> {
269 #[must_use]
271 pub fn builder() -> GetProjectBuilder<'a> {
272 GetProjectBuilder::default()
273 }
274}
275
276impl Endpoint for GetProject<'_> {
277 fn method(&self) -> Method {
278 Method::GET
279 }
280
281 fn endpoint(&self) -> Cow<'static, str> {
282 format!("projects/{}.json", &self.project_id_or_name).into()
283 }
284
285 fn parameters(&self) -> QueryParams {
286 let mut params = QueryParams::default();
287 params.push_opt("include", self.include.as_ref());
288 params
289 }
290}
291
292#[derive(Debug, Clone, Builder)]
294#[builder(setter(strip_option))]
295pub struct ArchiveProject<'a> {
296 #[builder(setter(into))]
298 project_id_or_name: Cow<'a, str>,
299}
300
301impl<'a> ArchiveProject<'a> {
302 #[must_use]
304 pub fn builder() -> ArchiveProjectBuilder<'a> {
305 ArchiveProjectBuilder::default()
306 }
307}
308
309impl Endpoint for ArchiveProject<'_> {
310 fn method(&self) -> Method {
311 Method::PUT
312 }
313
314 fn endpoint(&self) -> Cow<'static, str> {
315 format!("projects/{}/archive.json", &self.project_id_or_name).into()
316 }
317}
318
319#[derive(Debug, Clone, Builder)]
321#[builder(setter(strip_option))]
322pub struct UnarchiveProject<'a> {
323 #[builder(setter(into))]
325 project_id_or_name: Cow<'a, str>,
326}
327
328impl<'a> UnarchiveProject<'a> {
329 #[must_use]
331 pub fn builder() -> UnarchiveProjectBuilder<'a> {
332 UnarchiveProjectBuilder::default()
333 }
334}
335
336impl Endpoint for UnarchiveProject<'_> {
337 fn method(&self) -> Method {
338 Method::PUT
339 }
340
341 fn endpoint(&self) -> Cow<'static, str> {
342 format!("projects/{}/unarchive.json", &self.project_id_or_name).into()
343 }
344}
345
346#[serde_with::skip_serializing_none]
348#[derive(Debug, Clone, Builder, Serialize)]
349#[builder(setter(strip_option))]
350pub struct CreateProject<'a> {
351 #[builder(setter(into))]
353 name: Cow<'a, str>,
354 #[builder(setter(into))]
356 identifier: Cow<'a, str>,
357 #[builder(setter(into), default)]
359 description: Option<Cow<'a, str>>,
360 #[builder(setter(into), default)]
362 homepage: Option<Cow<'a, str>>,
363 #[builder(default)]
365 is_public: Option<bool>,
366 #[builder(default)]
368 parent_id: Option<u64>,
369 #[builder(default)]
371 inherit_members: Option<bool>,
372 #[builder(default)]
374 default_assigned_to_id: Option<u64>,
375 #[builder(default)]
377 default_version_id: Option<u64>,
378 #[builder(default)]
380 tracker_ids: Option<Vec<u64>>,
381 #[builder(default)]
383 enabled_module_names: Option<Vec<Cow<'a, str>>>,
384 #[builder(default)]
386 issue_custom_field_id: Option<Vec<u64>>,
387 #[builder(default)]
389 custom_field_values: Option<HashMap<u64, Cow<'a, str>>>,
390}
391
392impl ReturnsJsonResponse for CreateProject<'_> {}
393impl NoPagination for CreateProject<'_> {}
394
395impl<'a> CreateProject<'a> {
396 #[must_use]
398 pub fn builder() -> CreateProjectBuilder<'a> {
399 CreateProjectBuilder::default()
400 }
401}
402
403impl Endpoint for CreateProject<'_> {
404 fn method(&self) -> Method {
405 Method::POST
406 }
407
408 fn endpoint(&self) -> Cow<'static, str> {
409 "projects.json".into()
410 }
411
412 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
413 Ok(Some((
414 "application/json",
415 serde_json::to_vec(&ProjectWrapper::<CreateProject> {
416 project: (*self).to_owned(),
417 })?,
418 )))
419 }
420}
421
422#[serde_with::skip_serializing_none]
424#[derive(Debug, Clone, Builder, Serialize)]
425#[builder(setter(strip_option))]
426pub struct UpdateProject<'a> {
427 #[serde(skip_serializing)]
429 #[builder(setter(into))]
430 project_id_or_name: Cow<'a, str>,
431 #[builder(setter(into), default)]
433 name: Option<Cow<'a, str>>,
434 #[builder(setter(into), default)]
436 identifier: Option<Cow<'a, str>>,
437 #[builder(setter(into), default)]
439 description: Option<Cow<'a, str>>,
440 #[builder(setter(into), default)]
442 homepage: Option<Cow<'a, str>>,
443 #[builder(default)]
445 is_public: Option<bool>,
446 #[builder(default)]
448 parent_id: Option<u64>,
449 #[builder(default)]
451 inherit_members: Option<bool>,
452 #[builder(default)]
454 default_assigned_to_id: Option<u64>,
455 #[builder(default)]
457 default_version_id: Option<u64>,
458 #[builder(default)]
460 tracker_ids: Option<Vec<u64>>,
461 #[builder(default)]
463 enabled_module_names: Option<Vec<Cow<'a, str>>>,
464 #[builder(default)]
466 issue_custom_field_id: Option<Vec<u64>>,
467 #[builder(default)]
469 custom_field_values: Option<HashMap<u64, Cow<'a, str>>>,
470}
471
472impl<'a> UpdateProject<'a> {
473 #[must_use]
475 pub fn builder() -> UpdateProjectBuilder<'a> {
476 UpdateProjectBuilder::default()
477 }
478}
479
480impl Endpoint for UpdateProject<'_> {
481 fn method(&self) -> Method {
482 Method::PUT
483 }
484
485 fn endpoint(&self) -> Cow<'static, str> {
486 format!("projects/{}.json", self.project_id_or_name).into()
487 }
488
489 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
490 Ok(Some((
491 "application/json",
492 serde_json::to_vec(&ProjectWrapper::<UpdateProject> {
493 project: (*self).to_owned(),
494 })?,
495 )))
496 }
497}
498
499#[derive(Debug, Clone, Builder)]
501#[builder(setter(strip_option))]
502pub struct DeleteProject<'a> {
503 #[builder(setter(into))]
505 project_id_or_name: Cow<'a, str>,
506}
507
508impl<'a> DeleteProject<'a> {
509 #[must_use]
511 pub fn builder() -> DeleteProjectBuilder<'a> {
512 DeleteProjectBuilder::default()
513 }
514}
515
516impl Endpoint for DeleteProject<'_> {
517 fn method(&self) -> Method {
518 Method::DELETE
519 }
520
521 fn endpoint(&self) -> Cow<'static, str> {
522 format!("projects/{}.json", &self.project_id_or_name).into()
523 }
524}
525
526#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
528pub struct ProjectsWrapper<T> {
529 pub projects: Vec<T>,
531}
532
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
536pub struct ProjectWrapper<T> {
537 pub project: T,
539}
540
541#[cfg(test)]
542pub(crate) mod test {
543 use super::*;
544 use crate::api::test_helpers::with_project;
545 use pretty_assertions::assert_eq;
546 use std::error::Error;
547 use tokio::sync::RwLock;
548 use tracing_test::traced_test;
549
550 pub static PROJECT_LOCK: RwLock<()> = RwLock::const_new(());
553
554 #[traced_test]
555 #[test]
556 fn test_list_projects_first_page() -> Result<(), Box<dyn Error>> {
557 let _r_project = PROJECT_LOCK.read();
558 dotenvy::dotenv()?;
559 let redmine = crate::api::Redmine::from_env(
560 reqwest::blocking::Client::builder()
561 .use_rustls_tls()
562 .build()?,
563 )?;
564 let endpoint = ListProjects::builder().build()?;
565 redmine.json_response_body_page::<_, Project>(&endpoint, 0, 25)?;
566 Ok(())
567 }
568
569 #[traced_test]
570 #[test]
571 fn test_list_projects_all_pages() -> Result<(), Box<dyn Error>> {
572 let _r_project = PROJECT_LOCK.read();
573 dotenvy::dotenv()?;
574 let redmine = crate::api::Redmine::from_env(
575 reqwest::blocking::Client::builder()
576 .use_rustls_tls()
577 .build()?,
578 )?;
579 let endpoint = ListProjects::builder().build()?;
580 redmine.json_response_body_all_pages::<_, Project>(&endpoint)?;
581 Ok(())
582 }
583
584 #[traced_test]
585 #[tokio::test]
586 async fn test_list_projects_async_first_page() -> Result<(), Box<dyn Error>> {
587 let _r_project = PROJECT_LOCK.read();
588 dotenvy::dotenv()?;
589 let redmine = crate::api::RedmineAsync::from_env(
590 reqwest::Client::builder().use_rustls_tls().build()?,
591 )?;
592 let endpoint = ListProjects::builder().build()?;
593 redmine
594 .json_response_body_page::<_, Project>(&endpoint, 0, 25)
595 .await?;
596 Ok(())
597 }
598
599 #[traced_test]
600 #[tokio::test]
601 async fn test_list_projects_async_all_pages() -> Result<(), Box<dyn Error>> {
602 let _r_project = PROJECT_LOCK.read();
603 dotenvy::dotenv()?;
604 let redmine = crate::api::RedmineAsync::from_env(
605 reqwest::Client::builder().use_rustls_tls().build()?,
606 )?;
607 let endpoint = ListProjects::builder().build()?;
608 redmine
609 .json_response_body_all_pages::<_, Project>(&endpoint)
610 .await?;
611 Ok(())
612 }
613
614 #[traced_test]
615 #[test]
616 fn test_get_project() -> Result<(), Box<dyn Error>> {
617 let _r_project = PROJECT_LOCK.read();
618 dotenvy::dotenv()?;
619 let redmine = crate::api::Redmine::from_env(
620 reqwest::blocking::Client::builder()
621 .use_rustls_tls()
622 .build()?,
623 )?;
624 let endpoint = GetProject::builder()
625 .project_id_or_name("sandbox")
626 .build()?;
627 redmine.json_response_body::<_, ProjectWrapper<Project>>(&endpoint)?;
628 Ok(())
629 }
630
631 #[function_name::named]
632 #[traced_test]
633 #[test]
634 fn test_create_project() -> Result<(), Box<dyn Error>> {
635 let name = format!("unittest_{}", function_name!());
636 with_project(&name, |_, _, _| Ok(()))?;
637 Ok(())
638 }
639
640 #[function_name::named]
641 #[traced_test]
642 #[test]
643 fn test_update_project() -> Result<(), Box<dyn Error>> {
644 let name = format!("unittest_{}", function_name!());
645 with_project(&name, |redmine, _id, name| {
646 let update_endpoint = super::UpdateProject::builder()
647 .project_id_or_name(name)
648 .description("Test-Description")
649 .build()?;
650 redmine.ignore_response_body::<_>(&update_endpoint)?;
651 Ok(())
652 })?;
653 Ok(())
654 }
655
656 #[traced_test]
661 #[test]
662 fn test_completeness_project_type() -> Result<(), Box<dyn Error>> {
663 let _r_project = PROJECT_LOCK.read();
664 dotenvy::dotenv()?;
665 let redmine = crate::api::Redmine::from_env(
666 reqwest::blocking::Client::builder()
667 .use_rustls_tls()
668 .build()?,
669 )?;
670 let endpoint = ListProjects::builder().build()?;
671 let values: Vec<serde_json::Value> = redmine.json_response_body_all_pages(&endpoint)?;
672 for value in values {
673 let o: Project = serde_json::from_value(value.clone())?;
674 let reserialized = serde_json::to_value(o)?;
675 assert_eq!(value, reserialized);
676 }
677 Ok(())
678 }
679
680 #[traced_test]
690 #[test]
691 fn test_completeness_project_type_all_pages_all_project_details() -> Result<(), Box<dyn Error>>
692 {
693 let _r_project = PROJECT_LOCK.read();
694 dotenvy::dotenv()?;
695 let redmine = crate::api::Redmine::from_env(
696 reqwest::blocking::Client::builder()
697 .use_rustls_tls()
698 .build()?,
699 )?;
700 let endpoint = ListProjects::builder()
701 .include(vec![
702 ProjectsInclude::Trackers,
703 ProjectsInclude::IssueCategories,
704 ProjectsInclude::EnabledModules,
705 ProjectsInclude::TimeEntryActivities,
706 ProjectsInclude::IssueCustomFields,
707 ])
708 .build()?;
709 let projects = redmine.json_response_body_all_pages::<_, Project>(&endpoint)?;
710 for project in projects {
711 let get_endpoint = GetProject::builder()
712 .project_id_or_name(project.id.to_string())
713 .include(vec![
714 ProjectInclude::Trackers,
715 ProjectInclude::IssueCategories,
716 ProjectInclude::EnabledModules,
717 ProjectInclude::TimeEntryActivities,
718 ProjectInclude::IssueCustomFields,
719 ])
720 .build()?;
721 let ProjectWrapper { project: value } = redmine
722 .json_response_body::<_, ProjectWrapper<serde_json::Value>>(&get_endpoint)?;
723 let o: Project = serde_json::from_value(value.clone())?;
724 let reserialized = serde_json::to_value(o)?;
725 assert_eq!(value, reserialized);
726 }
727 Ok(())
728 }
729}