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)]
178pub enum ProjectStatusFilter {
179 Active,
181 Closed,
183}
184
185impl std::fmt::Display for ProjectStatusFilter {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 match self {
188 Self::Active => {
189 write!(f, "1")
190 }
191 Self::Closed => {
192 write!(f, "5")
193 }
194 }
195 }
196}
197
198#[derive(Debug, Clone)]
200pub enum ProjectFilter {
201 Any,
203 None,
205 TheseProjects(Vec<u64>),
207 NotTheseProjects(Vec<u64>),
209}
210
211impl std::fmt::Display for ProjectFilter {
212 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213 match self {
214 ProjectFilter::Any => write!(f, "*"),
215 ProjectFilter::None => write!(f, "!*"),
216 ProjectFilter::TheseProjects(ids) => {
217 let s: String = ids
218 .iter()
219 .map(|e| e.to_string())
220 .collect::<Vec<_>>()
221 .join(",");
222 write!(f, "{s}")
223 }
224 ProjectFilter::NotTheseProjects(ids) => {
225 let s: String = ids
226 .iter()
227 .map(|e| format!("!{e}"))
228 .collect::<Vec<_>>()
229 .join(",");
230 write!(f, "{s}")
231 }
232 }
233 }
234}
235
236#[derive(Debug, Clone, Builder)]
238#[builder(setter(strip_option))]
239pub struct ListProjects {
240 #[builder(default)]
242 include: Option<Vec<ProjectsInclude>>,
243 #[builder(default)]
245 status: Option<Vec<ProjectStatusFilter>>,
246}
247
248impl ReturnsJsonResponse for ListProjects {}
249impl Pageable for ListProjects {
250 fn response_wrapper_key(&self) -> String {
251 "projects".to_string()
252 }
253}
254
255impl ListProjects {
256 #[must_use]
258 pub fn builder() -> ListProjectsBuilder {
259 ListProjectsBuilder::default()
260 }
261}
262
263impl Endpoint for ListProjects {
264 fn method(&self) -> Method {
265 Method::GET
266 }
267
268 fn endpoint(&self) -> Cow<'static, str> {
269 "projects.json".into()
270 }
271
272 fn parameters(&self) -> QueryParams<'_> {
273 let mut params = QueryParams::default();
274 params.push_opt("include", self.include.as_ref());
275 params.push_opt(
276 "status",
277 self.status.as_ref().map(|statuses| {
278 statuses
279 .iter()
280 .map(|s| s.to_string())
281 .collect::<Vec<String>>()
282 .join(",")
283 }),
284 );
285 params
286 }
287}
288
289#[derive(Debug, Clone)]
291pub enum ProjectInclude {
292 Trackers,
294 IssueCategories,
296 EnabledModules,
298 TimeEntryActivities,
300 IssueCustomFields,
302}
303
304impl std::fmt::Display for ProjectInclude {
305 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306 match self {
307 Self::Trackers => {
308 write!(f, "trackers")
309 }
310 Self::IssueCategories => {
311 write!(f, "issue_categories")
312 }
313 Self::EnabledModules => {
314 write!(f, "enabled_modules")
315 }
316 Self::TimeEntryActivities => {
317 write!(f, "time_entry_activities")
318 }
319 Self::IssueCustomFields => {
320 write!(f, "issue_custom_fields")
321 }
322 }
323 }
324}
325
326#[derive(Debug, Clone, Builder)]
328#[builder(setter(strip_option))]
329pub struct GetProject<'a> {
330 #[builder(setter(into))]
332 project_id_or_name: Cow<'a, str>,
333 #[builder(default)]
335 include: Option<Vec<ProjectInclude>>,
336}
337
338impl ReturnsJsonResponse for GetProject<'_> {}
339impl NoPagination for GetProject<'_> {}
340
341impl<'a> GetProject<'a> {
342 #[must_use]
344 pub fn builder() -> GetProjectBuilder<'a> {
345 GetProjectBuilder::default()
346 }
347}
348
349impl Endpoint for GetProject<'_> {
350 fn method(&self) -> Method {
351 Method::GET
352 }
353
354 fn endpoint(&self) -> Cow<'static, str> {
355 format!("projects/{}.json", &self.project_id_or_name).into()
356 }
357
358 fn parameters(&self) -> QueryParams<'_> {
359 let mut params = QueryParams::default();
360 params.push_opt("include", self.include.as_ref());
361 params
362 }
363}
364
365#[derive(Debug, Clone, Builder)]
367#[builder(setter(strip_option))]
368pub struct ArchiveProject<'a> {
369 #[builder(setter(into))]
371 project_id_or_name: Cow<'a, str>,
372}
373
374impl<'a> ArchiveProject<'a> {
375 #[must_use]
377 pub fn builder() -> ArchiveProjectBuilder<'a> {
378 ArchiveProjectBuilder::default()
379 }
380}
381
382impl Endpoint for ArchiveProject<'_> {
383 fn method(&self) -> Method {
384 Method::PUT
385 }
386
387 fn endpoint(&self) -> Cow<'static, str> {
388 format!("projects/{}/archive.json", &self.project_id_or_name).into()
389 }
390}
391
392#[derive(Debug, Clone, Builder)]
394#[builder(setter(strip_option))]
395pub struct UnarchiveProject<'a> {
396 #[builder(setter(into))]
398 project_id_or_name: Cow<'a, str>,
399}
400
401impl<'a> UnarchiveProject<'a> {
402 #[must_use]
404 pub fn builder() -> UnarchiveProjectBuilder<'a> {
405 UnarchiveProjectBuilder::default()
406 }
407}
408
409impl Endpoint for UnarchiveProject<'_> {
410 fn method(&self) -> Method {
411 Method::PUT
412 }
413
414 fn endpoint(&self) -> Cow<'static, str> {
415 format!("projects/{}/unarchive.json", &self.project_id_or_name).into()
416 }
417}
418
419#[serde_with::skip_serializing_none]
421#[derive(Debug, Clone, Builder, Serialize)]
422#[builder(setter(strip_option))]
423pub struct CreateProject<'a> {
424 #[builder(setter(into))]
426 name: Cow<'a, str>,
427 #[builder(setter(into))]
429 identifier: Cow<'a, str>,
430 #[builder(setter(into), default)]
432 description: Option<Cow<'a, str>>,
433 #[builder(setter(into), default)]
435 homepage: Option<Cow<'a, str>>,
436 #[builder(default)]
438 is_public: Option<bool>,
439 #[builder(default)]
441 parent_id: Option<u64>,
442 #[builder(default)]
444 inherit_members: Option<bool>,
445 #[builder(default)]
447 default_assigned_to_id: Option<u64>,
448 #[builder(default)]
450 default_version_id: Option<u64>,
451 #[builder(default)]
453 tracker_ids: Option<Vec<u64>>,
454 #[builder(default)]
456 enabled_module_names: Option<Vec<Cow<'a, str>>>,
457 #[builder(default)]
459 issue_custom_field_id: Option<Vec<u64>>,
460 #[builder(default)]
462 custom_field_values: Option<HashMap<u64, Cow<'a, str>>>,
463}
464
465impl ReturnsJsonResponse for CreateProject<'_> {}
466impl NoPagination for CreateProject<'_> {}
467
468impl<'a> CreateProject<'a> {
469 #[must_use]
471 pub fn builder() -> CreateProjectBuilder<'a> {
472 CreateProjectBuilder::default()
473 }
474}
475
476impl Endpoint for CreateProject<'_> {
477 fn method(&self) -> Method {
478 Method::POST
479 }
480
481 fn endpoint(&self) -> Cow<'static, str> {
482 "projects.json".into()
483 }
484
485 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
486 Ok(Some((
487 "application/json",
488 serde_json::to_vec(&ProjectWrapper::<CreateProject> {
489 project: (*self).to_owned(),
490 })?,
491 )))
492 }
493}
494
495#[serde_with::skip_serializing_none]
497#[derive(Debug, Clone, Builder, Serialize)]
498#[builder(setter(strip_option))]
499pub struct UpdateProject<'a> {
500 #[serde(skip_serializing)]
502 #[builder(setter(into))]
503 project_id_or_name: Cow<'a, str>,
504 #[builder(setter(into), default)]
506 name: Option<Cow<'a, str>>,
507 #[builder(setter(into), default)]
509 identifier: Option<Cow<'a, str>>,
510 #[builder(setter(into), default)]
512 description: Option<Cow<'a, str>>,
513 #[builder(setter(into), default)]
515 homepage: Option<Cow<'a, str>>,
516 #[builder(default)]
518 is_public: Option<bool>,
519 #[builder(default)]
521 parent_id: Option<u64>,
522 #[builder(default)]
524 inherit_members: Option<bool>,
525 #[builder(default)]
527 default_assigned_to_id: Option<u64>,
528 #[builder(default)]
530 default_version_id: Option<u64>,
531 #[builder(default)]
533 tracker_ids: Option<Vec<u64>>,
534 #[builder(default)]
536 enabled_module_names: Option<Vec<Cow<'a, str>>>,
537 #[builder(default)]
539 issue_custom_field_id: Option<Vec<u64>>,
540 #[builder(default)]
542 custom_field_values: Option<HashMap<u64, Cow<'a, str>>>,
543}
544
545impl<'a> UpdateProject<'a> {
546 #[must_use]
548 pub fn builder() -> UpdateProjectBuilder<'a> {
549 UpdateProjectBuilder::default()
550 }
551}
552
553impl Endpoint for UpdateProject<'_> {
554 fn method(&self) -> Method {
555 Method::PUT
556 }
557
558 fn endpoint(&self) -> Cow<'static, str> {
559 format!("projects/{}.json", self.project_id_or_name).into()
560 }
561
562 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
563 Ok(Some((
564 "application/json",
565 serde_json::to_vec(&ProjectWrapper::<UpdateProject> {
566 project: (*self).to_owned(),
567 })?,
568 )))
569 }
570}
571
572#[derive(Debug, Clone, Builder)]
574#[builder(setter(strip_option))]
575pub struct DeleteProject<'a> {
576 #[builder(setter(into))]
578 project_id_or_name: Cow<'a, str>,
579}
580
581impl<'a> DeleteProject<'a> {
582 #[must_use]
584 pub fn builder() -> DeleteProjectBuilder<'a> {
585 DeleteProjectBuilder::default()
586 }
587}
588
589impl Endpoint for DeleteProject<'_> {
590 fn method(&self) -> Method {
591 Method::DELETE
592 }
593
594 fn endpoint(&self) -> Cow<'static, str> {
595 format!("projects/{}.json", &self.project_id_or_name).into()
596 }
597}
598
599#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
601pub struct ProjectsWrapper<T> {
602 pub projects: Vec<T>,
604}
605
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
609pub struct ProjectWrapper<T> {
610 pub project: T,
612}
613
614#[cfg(test)]
615pub(crate) mod test {
616 use super::*;
617 use crate::api::test_helpers::with_project;
618 use pretty_assertions::assert_eq;
619 use std::error::Error;
620 use tokio::sync::RwLock;
621 use tracing_test::traced_test;
622
623 pub static PROJECT_LOCK: RwLock<()> = RwLock::const_new(());
626
627 #[traced_test]
628 #[test]
629 fn test_list_projects_first_page() -> Result<(), Box<dyn Error>> {
630 let _r_project = PROJECT_LOCK.blocking_read();
631 dotenvy::dotenv()?;
632 let redmine = crate::api::Redmine::from_env(
633 reqwest::blocking::Client::builder()
634 .use_rustls_tls()
635 .build()?,
636 )?;
637 let endpoint = ListProjects::builder().build()?;
638 redmine.json_response_body_page::<_, Project>(&endpoint, 0, 25)?;
639 Ok(())
640 }
641
642 #[traced_test]
643 #[test]
644 fn test_list_projects_all_pages() -> Result<(), Box<dyn Error>> {
645 let _r_project = PROJECT_LOCK.blocking_read();
646 dotenvy::dotenv()?;
647 let redmine = crate::api::Redmine::from_env(
648 reqwest::blocking::Client::builder()
649 .use_rustls_tls()
650 .build()?,
651 )?;
652 let endpoint = ListProjects::builder().build()?;
653 redmine.json_response_body_all_pages::<_, Project>(&endpoint)?;
654 Ok(())
655 }
656
657 #[traced_test]
658 #[tokio::test]
659 async fn test_list_projects_async_first_page() -> Result<(), Box<dyn Error>> {
660 let _r_project = PROJECT_LOCK.read().await;
661 dotenvy::dotenv()?;
662 let redmine = crate::api::RedmineAsync::from_env(
663 reqwest::Client::builder().use_rustls_tls().build()?,
664 )?;
665 let endpoint = ListProjects::builder().build()?;
666 redmine
667 .json_response_body_page::<_, Project>(&endpoint, 0, 25)
668 .await?;
669 Ok(())
670 }
671
672 #[traced_test]
673 #[tokio::test]
674 async fn test_list_projects_async_all_pages() -> Result<(), Box<dyn Error>> {
675 let _r_project = PROJECT_LOCK.read().await;
676 dotenvy::dotenv()?;
677 let redmine = crate::api::RedmineAsync::from_env(
678 reqwest::Client::builder().use_rustls_tls().build()?,
679 )?;
680 let endpoint = ListProjects::builder().build()?;
681 redmine
682 .json_response_body_all_pages::<_, Project>(&endpoint)
683 .await?;
684 Ok(())
685 }
686
687 #[traced_test]
688 #[test]
689 fn test_get_project() -> Result<(), Box<dyn Error>> {
690 let _r_project = PROJECT_LOCK.blocking_read();
691 dotenvy::dotenv()?;
692 let redmine = crate::api::Redmine::from_env(
693 reqwest::blocking::Client::builder()
694 .use_rustls_tls()
695 .build()?,
696 )?;
697 let endpoint = GetProject::builder()
698 .project_id_or_name("sandbox")
699 .build()?;
700 redmine.json_response_body::<_, ProjectWrapper<Project>>(&endpoint)?;
701 Ok(())
702 }
703
704 #[function_name::named]
705 #[traced_test]
706 #[test]
707 fn test_create_project() -> Result<(), Box<dyn Error>> {
708 let name = format!("unittest_{}", function_name!());
709 with_project(&name, |_, _, _| Ok(()))?;
710 Ok(())
711 }
712
713 #[function_name::named]
714 #[traced_test]
715 #[test]
716 fn test_update_project() -> Result<(), Box<dyn Error>> {
717 let name = format!("unittest_{}", function_name!());
718 with_project(&name, |redmine, _id, name| {
719 let update_endpoint = super::UpdateProject::builder()
720 .project_id_or_name(name)
721 .description("Test-Description")
722 .build()?;
723 redmine.ignore_response_body::<_>(&update_endpoint)?;
724 Ok(())
725 })?;
726 Ok(())
727 }
728
729 #[traced_test]
734 #[test]
735 fn test_completeness_project_type() -> Result<(), Box<dyn Error>> {
736 let _r_project = PROJECT_LOCK.blocking_read();
737 dotenvy::dotenv()?;
738 let redmine = crate::api::Redmine::from_env(
739 reqwest::blocking::Client::builder()
740 .use_rustls_tls()
741 .build()?,
742 )?;
743 let endpoint = ListProjects::builder().build()?;
744 let values: Vec<serde_json::Value> = redmine.json_response_body_all_pages(&endpoint)?;
745 for value in values {
746 let o: Project = serde_json::from_value(value.clone())?;
747 let reserialized = serde_json::to_value(o)?;
748 assert_eq!(value, reserialized);
749 }
750 Ok(())
751 }
752
753 #[traced_test]
763 #[test]
764 fn test_completeness_project_type_all_pages_all_project_details() -> Result<(), Box<dyn Error>>
765 {
766 let _r_project = PROJECT_LOCK.blocking_read();
767 dotenvy::dotenv()?;
768 let redmine = crate::api::Redmine::from_env(
769 reqwest::blocking::Client::builder()
770 .use_rustls_tls()
771 .build()?,
772 )?;
773 let endpoint = ListProjects::builder()
774 .include(vec![
775 ProjectsInclude::Trackers,
776 ProjectsInclude::IssueCategories,
777 ProjectsInclude::EnabledModules,
778 ProjectsInclude::TimeEntryActivities,
779 ProjectsInclude::IssueCustomFields,
780 ])
781 .build()?;
782 let projects = redmine.json_response_body_all_pages::<_, Project>(&endpoint)?;
783 for project in projects {
784 tracing::debug!(
785 "Now calling individual GetProject for project id {} name {}",
786 project.id,
787 project.name
788 );
789 let get_endpoint = GetProject::builder()
790 .project_id_or_name(project.id.to_string())
791 .include(vec![
792 ProjectInclude::Trackers,
793 ProjectInclude::IssueCategories,
794 ProjectInclude::EnabledModules,
795 ProjectInclude::TimeEntryActivities,
796 ProjectInclude::IssueCustomFields,
797 ])
798 .build()?;
799 let ProjectWrapper { project: value } = redmine
800 .json_response_body::<_, ProjectWrapper<serde_json::Value>>(&get_endpoint)?;
801 let o: Project = serde_json::from_value(value.clone())?;
802 let reserialized = serde_json::to_value(o)?;
803 assert_eq!(value, reserialized);
804 }
805 Ok(())
806 }
807}