1use derive_builder::Builder;
20use reqwest::Method;
21use serde::Serialize;
22use std::borrow::Cow;
23
24use crate::api::attachments::Attachment;
25use crate::api::users::UserEssentials;
26use crate::api::{Endpoint, NoPagination, QueryParams, ReturnsJsonResponse};
27
28#[derive(Debug, Clone)]
30pub enum WikiPageInclude {
31 Attachments,
33}
34
35impl std::fmt::Display for WikiPageInclude {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::Attachments => {
39 write!(f, "attachments")
40 }
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
47pub struct WikiPageParent {
48 pub title: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
56pub struct WikiPageEssentials {
57 pub title: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub parent: Option<WikiPageParent>,
62 pub version: u64,
64 #[serde(
66 serialize_with = "crate::api::serialize_rfc3339",
67 deserialize_with = "crate::api::deserialize_rfc3339"
68 )]
69 pub created_on: time::OffsetDateTime,
70 #[serde(
72 serialize_with = "crate::api::serialize_rfc3339",
73 deserialize_with = "crate::api::deserialize_rfc3339"
74 )]
75 pub updated_on: time::OffsetDateTime,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub attachments: Option<Vec<Attachment>>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub protected: Option<bool>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
88pub struct WikiPage {
89 pub title: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub parent: Option<WikiPageParent>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub author: Option<UserEssentials>,
97 pub text: String,
99 pub version: u64,
101 pub comments: String,
103 #[serde(
105 serialize_with = "crate::api::serialize_rfc3339",
106 deserialize_with = "crate::api::deserialize_rfc3339"
107 )]
108 pub created_on: time::OffsetDateTime,
109 #[serde(
111 serialize_with = "crate::api::serialize_rfc3339",
112 deserialize_with = "crate::api::deserialize_rfc3339"
113 )]
114 pub updated_on: time::OffsetDateTime,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub attachments: Option<Vec<Attachment>>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub protected: Option<bool>,
121}
122
123#[derive(Debug, Clone, Builder)]
125#[builder(setter(strip_option))]
126pub struct ListProjectWikiPages<'a> {
127 #[builder(setter(into))]
129 project_id_or_name: Cow<'a, str>,
130 #[builder(default)]
132 include: Option<Vec<WikiPageInclude>>,
133}
134
135impl<'a> ReturnsJsonResponse for ListProjectWikiPages<'a> {}
136impl<'a> NoPagination for ListProjectWikiPages<'a> {}
137
138impl<'a> ListProjectWikiPages<'a> {
139 #[must_use]
141 pub fn builder() -> ListProjectWikiPagesBuilder<'a> {
142 ListProjectWikiPagesBuilder::default()
143 }
144}
145
146impl<'a> Endpoint for ListProjectWikiPages<'a> {
147 fn method(&self) -> Method {
148 Method::GET
149 }
150
151 fn endpoint(&self) -> Cow<'static, str> {
152 format!("projects/{}/wiki/index.json", self.project_id_or_name).into()
153 }
154
155 fn parameters(&self) -> QueryParams<'_> {
156 let mut params = QueryParams::default();
157 params.push_opt("include", self.include.as_ref());
158 params
159 }
160}
161
162#[derive(Debug, Clone, Builder)]
164#[builder(setter(strip_option))]
165pub struct GetProjectWikiPage<'a> {
166 #[builder(setter(into))]
168 project_id_or_name: Cow<'a, str>,
169 #[builder(setter(into))]
171 title: Cow<'a, str>,
172 #[builder(default)]
174 include: Option<Vec<WikiPageInclude>>,
175}
176
177impl ReturnsJsonResponse for GetProjectWikiPage<'_> {}
178impl NoPagination for GetProjectWikiPage<'_> {}
179
180impl<'a> GetProjectWikiPage<'a> {
181 #[must_use]
183 pub fn builder() -> GetProjectWikiPageBuilder<'a> {
184 GetProjectWikiPageBuilder::default()
185 }
186}
187
188impl Endpoint for GetProjectWikiPage<'_> {
189 fn method(&self) -> Method {
190 Method::GET
191 }
192
193 fn endpoint(&self) -> Cow<'static, str> {
194 format!(
195 "projects/{}/wiki/{}.json",
196 &self.project_id_or_name, &self.title
197 )
198 .into()
199 }
200
201 fn parameters(&self) -> QueryParams<'_> {
202 let mut params = QueryParams::default();
203 params.push_opt("include", self.include.as_ref());
204 params
205 }
206}
207
208#[derive(Debug, Clone, Builder)]
210#[builder(setter(strip_option))]
211pub struct GetProjectWikiPageVersion<'a> {
212 #[builder(setter(into))]
214 project_id_or_name: Cow<'a, str>,
215 #[builder(setter(into))]
217 title: Cow<'a, str>,
218 version: u64,
220 #[builder(default)]
222 include: Option<Vec<WikiPageInclude>>,
223}
224
225impl ReturnsJsonResponse for GetProjectWikiPageVersion<'_> {}
226impl NoPagination for GetProjectWikiPageVersion<'_> {}
227
228impl<'a> GetProjectWikiPageVersion<'a> {
229 #[must_use]
231 pub fn builder() -> GetProjectWikiPageVersionBuilder<'a> {
232 GetProjectWikiPageVersionBuilder::default()
233 }
234}
235
236impl Endpoint for GetProjectWikiPageVersion<'_> {
237 fn method(&self) -> Method {
238 Method::GET
239 }
240
241 fn endpoint(&self) -> Cow<'static, str> {
242 format!(
243 "projects/{}/wiki/{}/{}.json",
244 &self.project_id_or_name, &self.title, &self.version,
245 )
246 .into()
247 }
248
249 fn parameters(&self) -> QueryParams<'_> {
250 let mut params = QueryParams::default();
251 params.push_opt("include", self.include.as_ref());
252 params
253 }
254}
255
256#[derive(Debug, Clone, Builder, serde::Serialize, serde::Deserialize)]
258#[builder(setter(strip_option))]
259pub struct CreateOrUpdateProjectWikiPage<'a> {
260 #[serde(skip_serializing)]
262 #[builder(setter(into))]
263 project_id_or_name: Cow<'a, str>,
264 #[serde(skip_serializing)]
266 #[builder(setter(into))]
267 title: Cow<'a, str>,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 #[builder(default)]
271 version: Option<u64>,
272 #[builder(setter(into))]
274 text: Cow<'a, str>,
275 #[builder(setter(into))]
277 comments: Cow<'a, str>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 #[builder(default)]
281 redirect_existing_links: Option<bool>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 #[builder(default)]
285 is_start_page: Option<bool>,
286}
287
288impl<'a> CreateOrUpdateProjectWikiPage<'a> {
289 #[must_use]
291 pub fn builder() -> CreateOrUpdateProjectWikiPageBuilder<'a> {
292 CreateOrUpdateProjectWikiPageBuilder::default()
293 }
294}
295
296impl Endpoint for CreateOrUpdateProjectWikiPage<'_> {
297 fn method(&self) -> Method {
298 Method::PUT
299 }
300
301 fn endpoint(&self) -> Cow<'static, str> {
302 format!(
303 "projects/{}/wiki/{}.json",
304 &self.project_id_or_name, &self.title
305 )
306 .into()
307 }
308
309 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
310 Ok(Some((
311 "application/json",
312 serde_json::to_vec(&WikiPageWrapper::<CreateOrUpdateProjectWikiPage> {
313 wiki_page: (*self).to_owned(),
314 })?,
315 )))
316 }
317}
318
319#[derive(Debug, Clone, Builder)]
321#[builder(setter(strip_option))]
322pub struct DeleteProjectWikiPage<'a> {
323 #[builder(setter(into))]
325 project_id_or_name: Cow<'a, str>,
326 #[builder(setter(into))]
328 title: Cow<'a, str>,
329 #[builder(default)]
331 todo: Option<Cow<'a, str>>,
332 #[builder(default)]
334 reassign_to_id: Option<u64>,
335}
336
337impl<'a> DeleteProjectWikiPage<'a> {
338 #[must_use]
340 pub fn builder() -> DeleteProjectWikiPageBuilder<'a> {
341 DeleteProjectWikiPageBuilder::default()
342 }
343}
344
345impl<'a> Endpoint for DeleteProjectWikiPage<'a> {
346 fn method(&self) -> Method {
347 Method::DELETE
348 }
349
350 fn endpoint(&self) -> Cow<'static, str> {
351 format!(
352 "projects/{}/wiki/{}.json",
353 &self.project_id_or_name, &self.title
354 )
355 .into()
356 }
357
358 fn parameters(&self) -> QueryParams<'_> {
359 let mut params = QueryParams::default();
360 params.push_opt("todo", self.todo.as_ref());
361 params.push_opt("reassign_to_id", self.reassign_to_id);
362 params
363 }
364}
365
366#[derive(Debug, Clone, Builder)]
368#[builder(setter(strip_option))]
369pub struct DeleteProjectWikiPageVersion<'a> {
370 #[builder(setter(into))]
372 project_id_or_name: Cow<'a, str>,
373 #[builder(setter(into))]
375 title: Cow<'a, str>,
376 version: u64,
378}
379
380impl<'a> DeleteProjectWikiPageVersion<'a> {
381 #[must_use]
383 pub fn builder() -> DeleteProjectWikiPageVersionBuilder<'a> {
384 DeleteProjectWikiPageVersionBuilder::default()
385 }
386}
387
388impl<'a> Endpoint for DeleteProjectWikiPageVersion<'a> {
389 fn method(&self) -> Method {
390 Method::DELETE
391 }
392
393 fn endpoint(&self) -> Cow<'static, str> {
394 format!(
395 "projects/{}/wiki/{}/{}/destroy_version.json",
396 &self.project_id_or_name, &self.title, &self.version
397 )
398 .into()
399 }
400}
401
402#[derive(Debug, Clone, Builder)]
404#[builder(setter(strip_option))]
405pub struct GetProjectWikiPageAnnotate<'a> {
406 #[builder(setter(into))]
408 project_id_or_name: Cow<'a, str>,
409 #[builder(setter(into))]
411 title: Cow<'a, str>,
412 version: u64,
414}
415
416impl<'a> GetProjectWikiPageAnnotate<'a> {
417 #[must_use]
419 pub fn builder() -> GetProjectWikiPageAnnotateBuilder<'a> {
420 GetProjectWikiPageAnnotateBuilder::default()
421 }
422}
423
424impl NoPagination for GetProjectWikiPageAnnotate<'_> {}
425
426impl Endpoint for GetProjectWikiPageAnnotate<'_> {
427 fn method(&self) -> Method {
428 Method::GET
429 }
430
431 fn endpoint(&self) -> Cow<'static, str> {
432 format!(
433 "projects/{}/wiki/{}/annotate.json",
434 &self.project_id_or_name, &self.title
435 )
436 .into()
437 }
438
439 fn parameters(&self) -> QueryParams<'_> {
440 let mut params = QueryParams::default();
441 params.push("v", self.version);
442 params
443 }
444}
445
446#[derive(Debug, Clone, Builder)]
448#[builder(setter(strip_option))]
449pub struct ExportProjectWikiPage<'a> {
450 #[builder(setter(into))]
452 project_id_or_name: Cow<'a, str>,
453 #[builder(setter(into))]
455 title: Cow<'a, str>,
456 #[builder(default)]
458 version: Option<u64>,
459}
460
461impl<'a> ExportProjectWikiPage<'a> {
462 #[must_use]
464 pub fn builder() -> ExportProjectWikiPageBuilder<'a> {
465 ExportProjectWikiPageBuilder::default()
466 }
467}
468
469impl NoPagination for ExportProjectWikiPage<'_> {}
470
471impl Endpoint for ExportProjectWikiPage<'_> {
472 fn method(&self) -> Method {
473 Method::GET
474 }
475
476 fn endpoint(&self) -> Cow<'static, str> {
477 format!(
478 "projects/{}/wiki/{}/export.json",
479 &self.project_id_or_name, &self.title
480 )
481 .into()
482 }
483
484 fn parameters(&self) -> QueryParams<'_> {
485 let mut params = QueryParams::default();
486 params.push_opt("v", self.version);
487 params
488 }
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
493pub struct WikiPagesWrapper<T> {
494 pub wiki_pages: Vec<T>,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
501pub struct WikiPageWrapper<T> {
502 pub wiki_page: T,
504}
505
506#[cfg(test)]
507pub(crate) mod test {
508 use crate::api::projects::{ListProjects, Project, ProjectsInclude, test::PROJECT_LOCK};
509
510 use super::*;
511 use std::error::Error;
512 use tokio::sync::RwLock;
513 use tracing_test::traced_test;
514
515 pub static PROJECT_WIKI_PAGE_LOCK: RwLock<()> = RwLock::const_new(());
518
519 #[traced_test]
520 #[test]
521 fn test_list_project_wiki_pages() -> Result<(), Box<dyn Error>> {
522 let _r_project = PROJECT_LOCK.blocking_read();
523 let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
524 dotenvy::dotenv()?;
525 let redmine = crate::api::Redmine::from_env(
526 reqwest::blocking::Client::builder()
527 .use_rustls_tls()
528 .build()?,
529 )?;
530 let endpoint = ListProjectWikiPages::builder()
531 .project_id_or_name("25")
532 .build()?;
533 redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)?;
534 Ok(())
535 }
536
537 #[traced_test]
542 #[test]
543 fn test_completeness_wiki_page_essentials() -> Result<(), Box<dyn Error>> {
544 let _r_project = PROJECT_LOCK.blocking_read();
545 let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
546 dotenvy::dotenv()?;
547 let redmine = crate::api::Redmine::from_env(
548 reqwest::blocking::Client::builder()
549 .use_rustls_tls()
550 .build()?,
551 )?;
552 let endpoint = ListProjects::builder()
553 .include(vec![ProjectsInclude::EnabledModules])
554 .build()?;
555 let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
556 let mut checked_projects = 0;
557 for project in projects {
558 let project = project?;
559 if !project
560 .enabled_modules
561 .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
562 {
563 continue;
565 }
566 let endpoint = ListProjectWikiPages::builder()
567 .project_id_or_name(project.id.to_string())
568 .include(vec![WikiPageInclude::Attachments])
569 .build()?;
570 let Ok(WikiPagesWrapper { wiki_pages: values }) =
571 redmine.json_response_body::<_, WikiPagesWrapper<serde_json::Value>>(&endpoint)
572 else {
573 continue;
581 };
582 checked_projects += 1;
583 for value in values {
584 let o: WikiPageEssentials = serde_json::from_value(value.clone())?;
585 let reserialized = serde_json::to_value(o)?;
586 assert_eq!(value, reserialized);
587 }
588 }
589 assert!(checked_projects > 0);
590 Ok(())
591 }
592
593 #[traced_test]
594 #[test]
595 fn test_get_project_wiki_page() -> Result<(), Box<dyn Error>> {
596 let _r_project = PROJECT_LOCK.blocking_read();
597 let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
598 dotenvy::dotenv()?;
599 let redmine = crate::api::Redmine::from_env(
600 reqwest::blocking::Client::builder()
601 .use_rustls_tls()
602 .build()?,
603 )?;
604 let endpoint = GetProjectWikiPage::builder()
605 .project_id_or_name("25")
606 .title("Administration")
607 .build()?;
608 redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
609 Ok(())
610 }
611
612 #[traced_test]
617 #[test]
618 fn test_completeness_wiki_page() -> Result<(), Box<dyn Error>> {
619 let _r_project = PROJECT_LOCK.blocking_read();
620 let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
621 dotenvy::dotenv()?;
622 let redmine = crate::api::Redmine::from_env(
623 reqwest::blocking::Client::builder()
624 .use_rustls_tls()
625 .build()?,
626 )?;
627 let endpoint = ListProjects::builder()
628 .include(vec![ProjectsInclude::EnabledModules])
629 .build()?;
630 let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
631 let mut checked_pages = 0;
632 for project in projects {
633 let project = project?;
634 if !project
635 .enabled_modules
636 .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
637 {
638 continue;
640 }
641 let endpoint = ListProjectWikiPages::builder()
642 .project_id_or_name(project.id.to_string())
643 .include(vec![WikiPageInclude::Attachments])
644 .build()?;
645 let Ok(WikiPagesWrapper { wiki_pages }) =
646 redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)
647 else {
648 continue;
656 };
657 checked_pages += 1;
658 for wiki_page in wiki_pages {
659 let endpoint = GetProjectWikiPage::builder()
660 .project_id_or_name(project.id.to_string())
661 .title(wiki_page.title)
662 .include(vec![WikiPageInclude::Attachments])
663 .build()?;
664 let WikiPageWrapper { wiki_page: value } = redmine
665 .json_response_body::<_, WikiPageWrapper<serde_json::Value>>(&endpoint)?;
666 let o: WikiPage = serde_json::from_value(value.clone())?;
667 let reserialized = serde_json::to_value(o)?;
668 assert_eq!(value, reserialized);
669 }
670 }
671 assert!(checked_pages > 0);
672 Ok(())
673 }
674
675 #[traced_test]
676 #[test]
677 fn test_get_project_wiki_page_version() -> Result<(), Box<dyn Error>> {
678 let _r_project = PROJECT_LOCK.blocking_read();
679 let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
680 dotenvy::dotenv()?;
681 let redmine = crate::api::Redmine::from_env(
682 reqwest::blocking::Client::builder()
683 .use_rustls_tls()
684 .build()?,
685 )?;
686 let endpoint = GetProjectWikiPageVersion::builder()
687 .project_id_or_name("25")
688 .title("Administration")
689 .version(18)
690 .build()?;
691 redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
692 Ok(())
693 }
694
695 #[traced_test]
696 #[test]
697 fn test_create_update_and_delete_project_wiki_page() -> Result<(), Box<dyn Error>> {
698 let _r_project = PROJECT_LOCK.blocking_read();
699 let _w_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_write();
700 dotenvy::dotenv()?;
701 let redmine = crate::api::Redmine::from_env(
702 reqwest::blocking::Client::builder()
703 .use_rustls_tls()
704 .build()?,
705 )?;
706 let endpoint = GetProjectWikiPage::builder()
707 .project_id_or_name("25")
708 .title("CreateWikiPageTest")
709 .build()?;
710 if redmine.ignore_response_body(&endpoint).is_ok() {
711 let endpoint = DeleteProjectWikiPage::builder()
713 .project_id_or_name("25")
714 .title("CreateWikiPageTest")
715 .build()?;
716 redmine.ignore_response_body(&endpoint)?;
717 }
718 let endpoint = CreateOrUpdateProjectWikiPage::builder()
719 .project_id_or_name("25")
720 .title("CreateWikiPageTest")
721 .text("Test Content")
722 .comments("Create Page Test")
723 .build()?;
724 redmine.ignore_response_body(&endpoint)?;
725 let endpoint = CreateOrUpdateProjectWikiPage::builder()
726 .project_id_or_name("25")
727 .title("CreateWikiPageTest")
728 .text("Test Content Updates")
729 .version(1)
730 .comments("Update Page Test")
731 .build()?;
732 redmine.ignore_response_body(&endpoint)?;
733 let endpoint = DeleteProjectWikiPage::builder()
734 .project_id_or_name("25")
735 .title("CreateWikiPageTest")
736 .build()?;
737 redmine.ignore_response_body(&endpoint)?;
738 Ok(())
739 }
740
741 #[traced_test]
742 #[test]
743 fn test_wiki_page_lifecycle() -> Result<(), Box<dyn Error>> {
744 use crate::api::test_helpers::with_project;
745
746 with_project("test_wiki_page_lifecycle", |redmine, project_id, _| {
747 tracing::debug!("Creating wiki page TestWikiPage");
748 let endpoint = CreateOrUpdateProjectWikiPage::builder()
749 .project_id_or_name(project_id.to_string())
750 .title("TestWikiPage")
751 .text("Test Content")
752 .comments("Create Page Test")
753 .build()?;
754 redmine.ignore_response_body(&endpoint)?;
755
756 tracing::debug!("Verifying existence, content and version of wiki page TestWikiPage");
757 let get_endpoint = GetProjectWikiPage::builder()
758 .project_id_or_name(project_id.to_string())
759 .title("TestWikiPage")
760 .build()?;
761 let WikiPageWrapper { wiki_page } =
762 redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&get_endpoint)?;
763 assert_eq!(wiki_page.text, "Test Content");
764 assert_eq!(wiki_page.version, 1);
765
766 tracing::debug!("Updating wiki page TestWikiPage");
767 let update_endpoint = CreateOrUpdateProjectWikiPage::builder()
768 .project_id_or_name(project_id.to_string())
769 .title("TestWikiPage")
770 .text("Test Content Updates")
771 .version(1)
772 .comments("Update Page Test")
773 .build()?;
774 redmine.ignore_response_body(&update_endpoint)?;
775
776 tracing::debug!(
777 "Verifying existence, content and version of updated wiki page TestWikiPage"
778 );
779 let get_endpoint = GetProjectWikiPage::builder()
780 .project_id_or_name(project_id.to_string())
781 .title("TestWikiPage")
782 .build()?;
783 let WikiPageWrapper { wiki_page } =
784 redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&get_endpoint)?;
785 assert_eq!(wiki_page.text, "Test Content Updates");
786 assert_eq!(wiki_page.version, 2);
787
788 tracing::debug!("Verifying existence and content of wiki page TestWikiPage version 1");
789 let version_endpoint = GetProjectWikiPageVersion::builder()
790 .project_id_or_name(project_id.to_string())
791 .title("TestWikiPage")
792 .version(1)
793 .build()?;
794 let WikiPageWrapper { wiki_page } =
795 redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&version_endpoint)?;
796 assert_eq!(wiki_page.text, "Test Content");
797
798 tracing::debug!("Deleting wiki page TestWikiPage");
799 let delete_endpoint = DeleteProjectWikiPage::builder()
800 .project_id_or_name(project_id.to_string())
801 .title("TestWikiPage")
802 .build()?;
803 redmine.ignore_response_body(&delete_endpoint)?;
804
805 Ok(())
806 })
807 }
808}