1pub mod attachments;
24pub mod custom_fields;
25pub mod enumerations;
26pub mod files;
27pub mod groups;
28pub mod issue_categories;
29pub mod issue_relations;
30pub mod issue_statuses;
31pub mod issues;
32pub mod my_account;
33pub mod news;
34pub mod project_memberships;
35pub mod projects;
36pub mod queries;
37pub mod roles;
38pub mod search;
39#[cfg(test)]
40pub mod test_helpers;
41pub mod time_entries;
42pub mod trackers;
43pub mod uploads;
44pub mod users;
45pub mod versions;
46pub mod wiki_pages;
47
48use futures::future::FutureExt as _;
49
50use std::str::from_utf8;
51
52use serde::Deserialize;
53use serde::Deserializer;
54use serde::Serialize as _;
55use serde::de::DeserializeOwned;
56
57use reqwest::Method;
58use std::borrow::Cow;
59
60use reqwest::Url;
61use tracing::{debug, error, trace};
62
63#[derive(derive_more::Debug)]
65#[expect(
66 clippy::struct_field_names,
67 reason = "redmine_url disambiguates from the impersonate_user_id and matches public accessor naming"
68)]
69pub struct Redmine {
70 client: reqwest::blocking::Client,
72 redmine_url: Url,
74 #[debug(skip)]
76 api_key: String,
77 impersonate_user_id: Option<u64>,
79}
80
81#[derive(derive_more::Debug)]
83pub struct RedmineAsync {
84 client: reqwest::Client,
86 redmine_url: Url,
88 #[debug(skip)]
90 api_key: String,
91 impersonate_user_id: Option<u64>,
93}
94
95fn parse_url<'de, D>(deserializer: D) -> Result<url::Url, D::Error>
97where
98 D: Deserializer<'de>,
99{
100 let buf = String::deserialize(deserializer)?;
101
102 url::Url::parse(&buf).map_err(serde::de::Error::custom)
103}
104
105#[derive(Debug, Clone, serde::Deserialize)]
107struct EnvOptions {
108 redmine_api_key: String,
110
111 #[serde(deserialize_with = "parse_url")]
113 redmine_url: url::Url,
114}
115
116#[derive(Debug, Clone)]
119pub struct ResponsePage<T> {
120 pub values: Vec<T>,
122 pub total_count: u64,
124 pub offset: u64,
126 pub limit: u64,
128}
129
130impl Redmine {
131 pub fn new(
137 client: reqwest::blocking::Client,
138 redmine_url: url::Url,
139 api_key: &str,
140 ) -> Result<Self, crate::Error> {
141 Ok(Self {
142 client,
143 redmine_url,
144 api_key: api_key.to_string(),
145 impersonate_user_id: None,
146 })
147 }
148
149 pub fn from_env(client: reqwest::blocking::Client) -> Result<Self, crate::Error> {
159 let env_options = envy::from_env::<EnvOptions>()?;
160
161 let redmine_url = env_options.redmine_url;
162 let api_key = env_options.redmine_api_key;
163
164 Self::new(client, redmine_url, &api_key)
165 }
166
167 pub const fn impersonate_user(&mut self, id: u64) {
171 self.impersonate_user_id = Some(id);
172 }
173
174 #[must_use]
176 pub const fn redmine_url(&self) -> &Url {
177 &self.redmine_url
178 }
179
180 #[must_use]
185 #[expect(
186 clippy::missing_panics_doc,
187 clippy::unwrap_used,
188 reason = "join cannot fail for a constant relative URL"
189 )]
190 pub fn issue_url(&self, issue_id: u64) -> Url {
191 let Self { redmine_url, .. } = self;
192 redmine_url.join(&format!("/issues/{issue_id}")).unwrap()
195 }
196
197 fn rest(
200 &self,
201 method: reqwest::Method,
202 endpoint: &str,
203 parameters: QueryParams,
204 mime_type_and_body: Option<(&str, Vec<u8>)>,
205 ) -> Result<(reqwest::StatusCode, bytes::Bytes), crate::Error> {
206 let Self {
207 client,
208 redmine_url,
209 api_key,
210 impersonate_user_id,
211 } = self;
212 let mut url = redmine_url.join(endpoint)?;
213 parameters.add_to_url(&mut url);
214 debug!(%url, %method, "Calling redmine");
215 let req = client
216 .request(method.clone(), url.clone())
217 .header("x-redmine-api-key", api_key);
218 let req = if let Some(user_id) = impersonate_user_id {
219 req.header("X-Redmine-Switch-User", format!("{user_id}"))
220 } else {
221 req
222 };
223 let req = if let Some((mime, data)) = mime_type_and_body {
224 if let Ok(request_body) = from_utf8(&data) {
225 trace!("Request body (Content-Type: {}):\n{}", mime, request_body);
226 } else {
227 trace!(
228 "Request body (Content-Type: {}) could not be parsed as UTF-8:\n{:?}",
229 mime, data
230 );
231 }
232 req.body(data).header("Content-Type", mime)
233 } else {
234 req
235 };
236 let result = req.send();
237 if let Err(ref e) = result {
238 error!(%url, %method, "Redmine send error: {:?}", e);
239 }
240 let result = result?;
241 let status = result.status();
242 let response_body = result.bytes()?;
243 match from_utf8(&response_body) {
244 Ok(response_body) => {
245 trace!("Response body:\n{}", &response_body);
246 }
247 Err(e) => {
248 trace!(
249 "Response body that could not be parsed as utf8 because of {}:\n{:?}",
250 &e, &response_body
251 );
252 }
253 }
254 if status.is_client_error() {
255 error!(%url, %method, "Redmine status error (client error): {:?} response: {:?}", status, from_utf8(&response_body));
256 return Err(crate::Error::HttpErrorResponse(status));
257 } else if status.is_server_error() {
258 error!(%url, %method, "Redmine status error (server error): {:?} response: {:?}", status, from_utf8(&response_body));
259 return Err(crate::Error::HttpErrorResponse(status));
260 }
261 Ok((status, response_body))
262 }
263
264 pub fn ignore_response_body<E>(&self, endpoint: &E) -> Result<(), crate::Error>
272 where
273 E: Endpoint,
274 {
275 let method = endpoint.method();
276 let url = endpoint.endpoint();
277 let parameters = endpoint.parameters();
278 let mime_type_and_body = endpoint.body()?;
279 self.rest(method, &url, parameters, mime_type_and_body)?;
280 Ok(())
281 }
282
283 pub fn json_response_body<E, R>(&self, endpoint: &E) -> Result<R, crate::Error>
291 where
292 E: Endpoint + ReturnsJsonResponse + NoPagination,
293 R: DeserializeOwned + std::fmt::Debug,
294 {
295 let method = endpoint.method();
296 let url = endpoint.endpoint();
297 let parameters = endpoint.parameters();
298 let mime_type_and_body = endpoint.body()?;
299 let (status, response_body) = self.rest(method, &url, parameters, mime_type_and_body)?;
300 if response_body.is_empty() {
301 Err(crate::Error::EmptyResponseBody(status))
302 } else {
303 let result = serde_json::from_slice::<R>(&response_body);
304 if let Ok(ref parsed_response_body) = result {
305 trace!("Parsed response body:\n{:#?}", parsed_response_body);
306 }
307 Ok(result?)
308 }
309 }
310
311 pub fn json_response_body_page<E, R>(
319 &self,
320 endpoint: &E,
321 offset: u64,
322 limit: u64,
323 ) -> Result<ResponsePage<R>, crate::Error>
324 where
325 E: Endpoint + ReturnsJsonResponse + Pageable,
326 R: DeserializeOwned + std::fmt::Debug,
327 {
328 let method = endpoint.method();
329 let url = endpoint.endpoint();
330 let mut parameters = endpoint.parameters();
331 parameters.push("offset", offset);
332 parameters.push("limit", limit);
333 let mime_type_and_body = endpoint.body()?;
334 let (status, response_body) = self.rest(method, &url, parameters, mime_type_and_body)?;
335 if response_body.is_empty() {
336 Err(crate::Error::EmptyResponseBody(status))
337 } else {
338 let json_value_response_body: serde_json::Value =
339 serde_json::from_slice(&response_body)?;
340 let json_object_response_body = json_value_response_body.as_object();
341 if let Some(json_object_response_body) = json_object_response_body {
342 let total_count = json_object_response_body
343 .get("total_count")
344 .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
345 .as_u64()
346 .ok_or_else(|| {
347 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
348 })?;
349 let offset = json_object_response_body
350 .get("offset")
351 .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
352 .as_u64()
353 .ok_or_else(|| {
354 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
355 })?;
356 let limit = json_object_response_body
357 .get("limit")
358 .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
359 .as_u64()
360 .ok_or_else(|| {
361 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
362 })?;
363 let response_wrapper_key = endpoint.response_wrapper_key();
364 let inner_response_body = json_object_response_body
365 .get(&response_wrapper_key)
366 .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
367 let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
368 if let Ok(ref parsed_response_body) = result {
369 trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
370 }
371 Ok(ResponsePage {
372 values: result?,
373 total_count,
374 offset,
375 limit,
376 })
377 } else {
378 Err(crate::Error::NonObjectResponseBody(status))
379 }
380 }
381 }
382
383 #[expect(
393 clippy::arithmetic_side_effects,
394 reason = "u64 pagination counters; overflow requires impossibly many results"
395 )]
396 pub fn json_response_body_all_pages<E, R>(&self, endpoint: &E) -> Result<Vec<R>, crate::Error>
397 where
398 E: Endpoint + ReturnsJsonResponse + Pageable,
399 R: DeserializeOwned + std::fmt::Debug,
400 {
401 let method = endpoint.method();
402 let url = endpoint.endpoint();
403 let mut offset = 0;
404 let limit = 100;
405 let mut total_results = vec![];
406 loop {
407 let mut page_parameters = endpoint.parameters();
408 page_parameters.push("offset", offset);
409 page_parameters.push("limit", limit);
410 let mime_type_and_body = endpoint.body()?;
411 let (status, response_body) =
412 self.rest(method.clone(), &url, page_parameters, mime_type_and_body)?;
413 if response_body.is_empty() {
414 return Err(crate::Error::EmptyResponseBody(status));
415 }
416 let json_value_response_body: serde_json::Value =
417 serde_json::from_slice(&response_body)?;
418 let json_object_response_body = json_value_response_body.as_object();
419 if let Some(json_object_response_body) = json_object_response_body {
420 let total_count: u64 = json_object_response_body
421 .get("total_count")
422 .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
423 .as_u64()
424 .ok_or_else(|| {
425 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
426 })?;
427 let response_offset: u64 = json_object_response_body
428 .get("offset")
429 .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
430 .as_u64()
431 .ok_or_else(|| {
432 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
433 })?;
434 let response_limit: u64 = json_object_response_body
435 .get("limit")
436 .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
437 .as_u64()
438 .ok_or_else(|| {
439 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
440 })?;
441 let response_wrapper_key = endpoint.response_wrapper_key();
442 let inner_response_body = json_object_response_body
443 .get(&response_wrapper_key)
444 .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
445 let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
446 if let Ok(ref parsed_response_body) = result {
447 trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
448 }
449 total_results.extend(result?);
450 if total_count < (response_offset + response_limit) {
451 break;
452 }
453 offset += limit;
454 } else {
455 return Err(crate::Error::NonObjectResponseBody(status));
456 }
457 }
458 Ok(total_results)
459 }
460
461 pub const fn json_response_body_all_pages_iter<'a, 'e, 'i, E, R>(
464 &'a self,
465 endpoint: &'e E,
466 ) -> AllPages<'i, E, R>
467 where
468 E: Endpoint + ReturnsJsonResponse + Pageable,
469 R: DeserializeOwned + std::fmt::Debug,
470 'a: 'i,
471 'e: 'i,
472 {
473 AllPages::new(self, endpoint)
474 }
475}
476
477impl RedmineAsync {
478 pub fn new(
484 client: reqwest::Client,
485 redmine_url: url::Url,
486 api_key: &str,
487 ) -> Result<std::sync::Arc<Self>, crate::Error> {
488 Ok(std::sync::Arc::new(Self {
489 client,
490 redmine_url,
491 api_key: api_key.to_string(),
492 impersonate_user_id: None,
493 }))
494 }
495
496 pub fn from_env(client: reqwest::Client) -> Result<std::sync::Arc<Self>, crate::Error> {
506 let env_options = envy::from_env::<EnvOptions>()?;
507
508 let redmine_url = env_options.redmine_url;
509 let api_key = env_options.redmine_api_key;
510
511 Self::new(client, redmine_url, &api_key)
512 }
513
514 pub const fn impersonate_user(&mut self, id: u64) {
518 self.impersonate_user_id = Some(id);
519 }
520
521 #[must_use]
523 pub const fn redmine_url(&self) -> &Url {
524 &self.redmine_url
525 }
526
527 #[must_use]
532 #[expect(
533 clippy::missing_panics_doc,
534 clippy::unwrap_used,
535 reason = "join cannot fail for a constant relative URL"
536 )]
537 pub fn issue_url(&self, issue_id: u64) -> Url {
538 let Self { redmine_url, .. } = self;
539 redmine_url.join(&format!("/issues/{issue_id}")).unwrap()
542 }
543
544 async fn rest(
547 self: std::sync::Arc<Self>,
548 method: reqwest::Method,
549 endpoint: &str,
550 parameters: QueryParams<'_>,
551 mime_type_and_body: Option<(&str, Vec<u8>)>,
552 ) -> Result<(reqwest::StatusCode, bytes::Bytes), crate::Error> {
553 let Self {
554 client,
555 redmine_url,
556 api_key,
557 impersonate_user_id,
558 } = self.as_ref();
559 let mut url = redmine_url.join(endpoint)?;
560 parameters.add_to_url(&mut url);
561 debug!(%url, %method, "Calling redmine");
562 let req = client
563 .request(method.clone(), url.clone())
564 .header("x-redmine-api-key", api_key);
565 let req = if let Some(user_id) = impersonate_user_id {
566 req.header("X-Redmine-Switch-User", format!("{user_id}"))
567 } else {
568 req
569 };
570 let req = if let Some((mime, data)) = mime_type_and_body {
571 if let Ok(request_body) = from_utf8(&data) {
572 trace!("Request body (Content-Type: {}):\n{}", mime, request_body);
573 } else {
574 trace!(
575 "Request body (Content-Type: {}) could not be parsed as UTF-8:\n{:?}",
576 mime, data
577 );
578 }
579 req.body(data).header("Content-Type", mime)
580 } else {
581 req
582 };
583 let result = req.send().await;
584 if let Err(ref e) = result {
585 error!(%url, %method, "Redmine send error: {:?}", e);
586 }
587 let result = result?;
588 let status = result.status();
589 let response_body = result.bytes().await?;
590 match from_utf8(&response_body) {
591 Ok(response_body) => {
592 trace!("Response body:\n{}", &response_body);
593 }
594 Err(e) => {
595 trace!(
596 "Response body that could not be parsed as utf8 because of {}:\n{:?}",
597 &e, &response_body
598 );
599 }
600 }
601 if status.is_client_error() {
602 error!(%url, %method, "Redmine status error (client error): {:?} response: {:?}", status, from_utf8(&response_body));
603 } else if status.is_server_error() {
604 error!(%url, %method, "Redmine status error (server error): {:?} response: {:?}", status, from_utf8(&response_body));
605 }
606 Ok((status, response_body))
607 }
608
609 pub async fn ignore_response_body<E>(
617 self: std::sync::Arc<Self>,
618 endpoint: impl EndpointParameter<E>,
619 ) -> Result<(), crate::Error>
620 where
621 E: Endpoint,
622 {
623 let endpoint: std::sync::Arc<E> = endpoint.into_arc();
624 let method = endpoint.method();
625 let url = endpoint.endpoint();
626 let parameters = endpoint.parameters();
627 let mime_type_and_body = endpoint.body()?;
628 self.rest(method, &url, parameters, mime_type_and_body)
629 .await?;
630 Ok(())
631 }
632
633 pub async fn json_response_body<E, R>(
643 self: std::sync::Arc<Self>,
644 endpoint: impl EndpointParameter<E>,
645 ) -> Result<R, crate::Error>
646 where
647 E: Endpoint + ReturnsJsonResponse + NoPagination,
648 R: DeserializeOwned + std::fmt::Debug,
649 {
650 let endpoint: std::sync::Arc<E> = endpoint.into_arc();
651 let method = endpoint.method();
652 let url = endpoint.endpoint();
653 let parameters = endpoint.parameters();
654 let mime_type_and_body = endpoint.body()?;
655 let (status, response_body) = self
656 .rest(method, &url, parameters, mime_type_and_body)
657 .await?;
658 if response_body.is_empty() {
659 Err(crate::Error::EmptyResponseBody(status))
660 } else {
661 let result = serde_json::from_slice::<R>(&response_body);
662 if let Ok(ref parsed_response_body) = result {
663 trace!("Parsed response body:\n{:#?}", parsed_response_body);
664 }
665 Ok(result?)
666 }
667 }
668
669 pub async fn json_response_body_page<E, R>(
677 self: std::sync::Arc<Self>,
678 endpoint: impl EndpointParameter<E>,
679 offset: u64,
680 limit: u64,
681 ) -> Result<ResponsePage<R>, crate::Error>
682 where
683 E: Endpoint + ReturnsJsonResponse + Pageable,
684 R: DeserializeOwned + std::fmt::Debug,
685 {
686 let endpoint: std::sync::Arc<E> = endpoint.into_arc();
687 let method = endpoint.method();
688 let url = endpoint.endpoint();
689 let mut parameters = endpoint.parameters();
690 parameters.push("offset", offset);
691 parameters.push("limit", limit);
692 let mime_type_and_body = endpoint.body()?;
693 let (status, response_body) = self
694 .rest(method, &url, parameters, mime_type_and_body)
695 .await?;
696 if response_body.is_empty() {
697 Err(crate::Error::EmptyResponseBody(status))
698 } else {
699 let json_value_response_body: serde_json::Value =
700 serde_json::from_slice(&response_body)?;
701 let json_object_response_body = json_value_response_body.as_object();
702 if let Some(json_object_response_body) = json_object_response_body {
703 let total_count = json_object_response_body
704 .get("total_count")
705 .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
706 .as_u64()
707 .ok_or_else(|| {
708 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
709 })?;
710 let offset = json_object_response_body
711 .get("offset")
712 .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
713 .as_u64()
714 .ok_or_else(|| {
715 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
716 })?;
717 let limit = json_object_response_body
718 .get("limit")
719 .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
720 .as_u64()
721 .ok_or_else(|| {
722 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
723 })?;
724 let response_wrapper_key = endpoint.response_wrapper_key();
725 let inner_response_body = json_object_response_body
726 .get(&response_wrapper_key)
727 .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
728 let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
729 if let Ok(ref parsed_response_body) = result {
730 trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
731 }
732 Ok(ResponsePage {
733 values: result?,
734 total_count,
735 offset,
736 limit,
737 })
738 } else {
739 Err(crate::Error::NonObjectResponseBody(status))
740 }
741 }
742 }
743
744 #[expect(
754 clippy::arithmetic_side_effects,
755 clippy::clone_on_ref_ptr,
756 reason = "u64 pagination counters; cloning an Arc<Self> is intentional to keep the Arc alive across the await"
757 )]
758 pub async fn json_response_body_all_pages<E, R>(
759 self: std::sync::Arc<Self>,
760 endpoint: impl EndpointParameter<E>,
761 ) -> Result<Vec<R>, crate::Error>
762 where
763 E: Endpoint + ReturnsJsonResponse + Pageable,
764 R: DeserializeOwned + std::fmt::Debug,
765 {
766 let endpoint: std::sync::Arc<E> = endpoint.into_arc();
767 let method = endpoint.method();
768 let url = endpoint.endpoint();
769 let mut offset = 0;
770 let limit = 100;
771 let mut total_results = vec![];
772 loop {
773 let mut page_parameters = endpoint.parameters();
774 page_parameters.push("offset", offset);
775 page_parameters.push("limit", limit);
776 let mime_type_and_body = endpoint.body()?;
777 let (status, response_body) = self
778 .clone()
779 .rest(method.clone(), &url, page_parameters, mime_type_and_body)
780 .await?;
781 if response_body.is_empty() {
782 return Err(crate::Error::EmptyResponseBody(status));
783 }
784 let json_value_response_body: serde_json::Value =
785 serde_json::from_slice(&response_body)?;
786 let json_object_response_body = json_value_response_body.as_object();
787 if let Some(json_object_response_body) = json_object_response_body {
788 let total_count: u64 = json_object_response_body
789 .get("total_count")
790 .ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
791 .as_u64()
792 .ok_or_else(|| {
793 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
794 })?;
795 let response_offset: u64 = json_object_response_body
796 .get("offset")
797 .ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
798 .as_u64()
799 .ok_or_else(|| {
800 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
801 })?;
802 let response_limit: u64 = json_object_response_body
803 .get("limit")
804 .ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
805 .as_u64()
806 .ok_or_else(|| {
807 crate::Error::PaginationKeyHasWrongType("total_count".to_string())
808 })?;
809 let response_wrapper_key = endpoint.response_wrapper_key();
810 let inner_response_body = json_object_response_body
811 .get(&response_wrapper_key)
812 .ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
813 let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
814 if let Ok(ref parsed_response_body) = result {
815 trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
816 }
817 total_results.extend(result?);
818 if total_count < (response_offset + response_limit) {
819 break;
820 }
821 offset += limit;
822 } else {
823 return Err(crate::Error::NonObjectResponseBody(status));
824 }
825 }
826 Ok(total_results)
827 }
828
829 pub fn json_response_body_all_pages_stream<E, R>(
832 self: std::sync::Arc<Self>,
833 endpoint: impl EndpointParameter<E>,
834 ) -> AllPagesAsync<E, R>
835 where
836 E: Endpoint + ReturnsJsonResponse + Pageable,
837 R: DeserializeOwned + std::fmt::Debug,
838 {
839 let endpoint: std::sync::Arc<E> = endpoint.into_arc();
840 AllPagesAsync::new(self, endpoint)
841 }
842}
843
844pub trait ParamValue<'a> {
846 fn as_value(&self) -> Cow<'a, str>;
848}
849
850impl ParamValue<'static> for bool {
851 fn as_value(&self) -> Cow<'static, str> {
852 if *self { "true".into() } else { "false".into() }
853 }
854}
855
856impl<'a> ParamValue<'a> for &'a str {
857 fn as_value(&self) -> Cow<'a, str> {
858 (*self).into()
859 }
860}
861
862impl ParamValue<'static> for String {
863 fn as_value(&self) -> Cow<'static, str> {
864 self.clone().into()
865 }
866}
867
868impl<'a> ParamValue<'a> for &'a String {
869 fn as_value(&self) -> Cow<'a, str> {
870 (*self).into()
871 }
872}
873
874impl<T> ParamValue<'static> for Vec<T>
877where
878 T: ToString,
879{
880 fn as_value(&self) -> Cow<'static, str> {
881 self.iter()
882 .map(|e| e.to_string())
883 .collect::<Vec<_>>()
884 .join(",")
885 .into()
886 }
887}
888
889impl<'a, T> ParamValue<'a> for &'a Vec<T>
892where
893 T: ToString,
894{
895 fn as_value(&self) -> Cow<'a, str> {
896 self.iter()
897 .map(|e| e.to_string())
898 .collect::<Vec<_>>()
899 .join(",")
900 .into()
901 }
902}
903
904impl<'a> ParamValue<'a> for Cow<'a, str> {
905 fn as_value(&self) -> Self {
906 self.clone()
907 }
908}
909
910impl<'a> ParamValue<'a> for &'a Cow<'a, str> {
911 fn as_value(&self) -> Cow<'a, str> {
912 (*self).clone()
913 }
914}
915
916impl ParamValue<'static> for u64 {
917 fn as_value(&self) -> Cow<'static, str> {
918 format!("{self}").into()
919 }
920}
921
922impl ParamValue<'static> for f64 {
923 fn as_value(&self) -> Cow<'static, str> {
924 format!("{self}").into()
925 }
926}
927
928impl ParamValue<'static> for time::OffsetDateTime {
929 #[expect(
930 clippy::unwrap_used,
931 reason = "RFC 3339 formatting cannot fail for a fully-populated OffsetDateTime"
932 )]
933 fn as_value(&self) -> Cow<'static, str> {
934 self.format(&time::format_description::well_known::Rfc3339)
935 .unwrap()
936 .into()
937 }
938}
939
940impl ParamValue<'static> for time::Date {
941 #[expect(
942 clippy::unwrap_used,
943 reason = "the format description literal is a constant valid format"
944 )]
945 fn as_value(&self) -> Cow<'static, str> {
946 let format = time::format_description::parse("[year]-[month]-[day]").unwrap();
947 self.format(&format).unwrap().into()
948 }
949}
950
951#[derive(Debug, Clone)]
954pub enum DateTimeFilterPast {
955 ExactMatch(time::OffsetDateTime),
957 Range(time::OffsetDateTime, time::OffsetDateTime),
959 LessThanOrEqual(time::OffsetDateTime),
961 GreaterThanOrEqual(time::OffsetDateTime),
963 LessThanDaysAgo(u32),
965 MoreThanDaysAgo(u32),
967 WithinPastDays(u32),
969 ExactDaysAgo(u32),
971 Today,
973 Yesterday,
975 ThisWeek,
977 LastWeek,
979 LastTwoWeeks,
981 ThisMonth,
983 LastMonth,
985 ThisYear,
987 Unset,
989 Any,
991}
992
993impl std::fmt::Display for DateTimeFilterPast {
994 #[expect(
995 clippy::expect_used,
996 reason = "the format_description!() macro produces a known-good format that cannot fail at runtime"
997 )]
998 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
999 let format =
1000 time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z");
1001 match self {
1002 Self::ExactMatch(v) => {
1003 write!(
1004 f,
1005 "{}",
1006 v.format(&format).expect(
1007 "Error formatting OffsetDateTime in DateTimeFilterPast::ExactMatch"
1008 )
1009 )
1010 }
1011 Self::Range(v_start, v_end) => {
1012 write!(
1013 f,
1014 "><{}|{}",
1015 v_start.format(&format).expect(
1016 "Error formatting first OffsetDateTime in DateTimeFilterPast::Range"
1017 ),
1018 v_end.format(&format).expect(
1019 "Error formatting second OffsetDateTime in DateTimeFilterPast::Range"
1020 ),
1021 )
1022 }
1023 Self::LessThanOrEqual(v) => {
1024 write!(
1025 f,
1026 "<={}",
1027 v.format(&format).expect(
1028 "Error formatting OffsetDateTime in DateTimeFilterPast::LessThanOrEqual"
1029 )
1030 )
1031 }
1032 Self::GreaterThanOrEqual(v) => {
1033 write!(
1034 f,
1035 ">={}",
1036 v.format(&format).expect(
1037 "Error formatting OffsetDateTime in DateTimeFilterPast::GreaterThanOrEqual"
1038 )
1039 )
1040 }
1041 Self::LessThanDaysAgo(d) => {
1042 write!(f, ">t-{d}")
1043 }
1044 Self::MoreThanDaysAgo(d) => {
1045 write!(f, "<t-{d}")
1046 }
1047 Self::WithinPastDays(d) => {
1048 write!(f, "><t-{d}")
1049 }
1050 Self::ExactDaysAgo(d) => {
1051 write!(f, "t-{d}")
1052 }
1053 Self::Today => {
1054 write!(f, "t")
1055 }
1056 Self::Yesterday => {
1057 write!(f, "ld")
1058 }
1059 Self::ThisWeek => {
1060 write!(f, "w")
1061 }
1062 Self::LastWeek => {
1063 write!(f, "lw")
1064 }
1065 Self::LastTwoWeeks => {
1066 write!(f, "l2w")
1067 }
1068 Self::ThisMonth => {
1069 write!(f, "m")
1070 }
1071 Self::LastMonth => {
1072 write!(f, "lm")
1073 }
1074 Self::ThisYear => {
1075 write!(f, "y")
1076 }
1077 Self::Unset => {
1078 write!(f, "!*")
1079 }
1080 Self::Any => {
1081 write!(f, "*")
1082 }
1083 }
1084 }
1085}
1086
1087#[derive(Debug, Clone)]
1089pub enum StringFieldFilter {
1090 ExactMatch(String),
1092 SubStringMatch(String),
1094}
1095
1096impl std::fmt::Display for StringFieldFilter {
1097 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1098 match self {
1099 Self::ExactMatch(s) => {
1100 write!(f, "{s}")
1101 }
1102 Self::SubStringMatch(s) => {
1103 write!(f, "~{s}")
1104 }
1105 }
1106 }
1107}
1108
1109#[derive(Debug, Clone)]
1111pub struct CustomFieldFilter {
1112 pub id: u64,
1114 pub value: StringFieldFilter,
1116}
1117
1118#[derive(Debug, Clone)]
1120pub enum FloatFilter {
1121 ExactMatch(f64),
1123 Range(f64, f64),
1125 LessThanOrEqual(f64),
1127 GreaterThanOrEqual(f64),
1129 Any,
1131 None,
1133}
1134
1135impl std::fmt::Display for FloatFilter {
1136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1137 match self {
1138 Self::ExactMatch(v) => write!(f, "{v}"),
1139 Self::Range(v_start, v_end) => write!(f, "><{v_start}|{v_end}"),
1140 Self::LessThanOrEqual(v) => write!(f, "<={v}"),
1141 Self::GreaterThanOrEqual(v) => write!(f, ">={v}"),
1142 Self::Any => write!(f, "*"),
1143 Self::None => write!(f, "!*"),
1144 }
1145 }
1146}
1147
1148#[derive(Debug, Clone)]
1150pub enum IntegerFilter {
1151 ExactMatch(u64),
1153 Range(u64, u64),
1155 LessThanOrEqual(u64),
1157 GreaterThanOrEqual(u64),
1159 Any,
1161 None,
1163}
1164
1165impl std::fmt::Display for IntegerFilter {
1166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1167 match self {
1168 Self::ExactMatch(v) => write!(f, "{v}"),
1169 Self::Range(v_start, v_end) => write!(f, "><{v_start}|{v_end}"),
1170 Self::LessThanOrEqual(v) => write!(f, "<={v}"),
1171 Self::GreaterThanOrEqual(v) => write!(f, ">={v}"),
1172 Self::Any => write!(f, "*"),
1173 Self::None => write!(f, "!*"),
1174 }
1175 }
1176}
1177
1178#[derive(Debug, Clone)]
1180pub enum TrackerFilter {
1181 Any,
1183 None,
1185 TheseTrackers(Vec<u64>),
1187 NotTheseTrackers(Vec<u64>),
1189}
1190
1191impl std::fmt::Display for TrackerFilter {
1192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1193 match self {
1194 Self::Any => write!(f, "*"),
1195 Self::None => write!(f, "!*"),
1196 Self::TheseTrackers(ids) => {
1197 let s: String = ids
1198 .iter()
1199 .map(|e| e.to_string())
1200 .collect::<Vec<_>>()
1201 .join(",");
1202 write!(f, "{s}")
1203 }
1204 Self::NotTheseTrackers(ids) => {
1205 let s: String = ids
1206 .iter()
1207 .map(|e| format!("!{e}"))
1208 .collect::<Vec<_>>()
1209 .join(",");
1210 write!(f, "{s}")
1211 }
1212 }
1213 }
1214}
1215
1216#[derive(Debug, Clone)]
1218pub enum ActivityFilter {
1219 Any,
1221 None,
1223 TheseActivities(Vec<u64>),
1225 NotTheseActivities(Vec<u64>),
1227}
1228
1229impl std::fmt::Display for ActivityFilter {
1230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1231 match self {
1232 Self::Any => write!(f, "*"),
1233 Self::None => write!(f, "!*"),
1234 Self::TheseActivities(ids) => {
1235 let s: String = ids
1236 .iter()
1237 .map(|e| e.to_string())
1238 .collect::<Vec<_>>()
1239 .join(",");
1240 write!(f, "{s}")
1241 }
1242 Self::NotTheseActivities(ids) => {
1243 let s: String = ids
1244 .iter()
1245 .map(|e| format!("!{e}"))
1246 .collect::<Vec<_>>()
1247 .join(",");
1248 write!(f, "{s}")
1249 }
1250 }
1251 }
1252}
1253
1254#[derive(Debug, Clone)]
1256pub enum VersionFilter {
1257 Any,
1259 None,
1261 TheseVersions(Vec<u64>),
1263 NotTheseVersions(Vec<u64>),
1265}
1266
1267impl std::fmt::Display for VersionFilter {
1268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1269 match self {
1270 Self::Any => write!(f, "*"),
1271 Self::None => write!(f, "!*"),
1272 Self::TheseVersions(ids) => {
1273 let s: String = ids
1274 .iter()
1275 .map(|e| e.to_string())
1276 .collect::<Vec<_>>()
1277 .join(",");
1278 write!(f, "{s}")
1279 }
1280 Self::NotTheseVersions(ids) => {
1281 let s: String = ids
1282 .iter()
1283 .map(|e| format!("!{e}"))
1284 .collect::<Vec<_>>()
1285 .join(",");
1286 write!(f, "{s}")
1287 }
1288 }
1289 }
1290}
1291
1292#[derive(Debug, Clone)]
1294pub enum DateFilter {
1295 ExactMatch(time::Date),
1297 Range(time::Date, time::Date),
1299 LessThanOrEqual(time::Date),
1301 GreaterThanOrEqual(time::Date),
1303 LessThanDaysAgo(u32),
1305 MoreThanDaysAgo(u32),
1307 WithinPastDays(u32),
1309 ExactDaysAgo(u32),
1311 InLessThanDays(u32),
1313 InMoreThanDays(u32),
1315 WithinFutureDays(u32),
1317 InExactDays(u32),
1319 Today,
1321 Yesterday,
1323 Tomorrow,
1325 ThisWeek,
1327 LastWeek,
1329 LastTwoWeeks,
1331 NextWeek,
1333 ThisMonth,
1335 LastMonth,
1337 NextMonth,
1339 ThisYear,
1341 Unset,
1343 Any,
1345}
1346
1347impl std::fmt::Display for DateFilter {
1348 #[expect(
1349 clippy::expect_used,
1350 reason = "the format_description!() macro produces a known-good format that cannot fail at runtime"
1351 )]
1352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1353 let format = time::macros::format_description!("[year]-[month]-[day]");
1354 match self {
1355 Self::ExactMatch(v) => {
1356 write!(
1357 f,
1358 "{}",
1359 v.format(&format)
1360 .expect("Error formatting Date in DateFilter::ExactMatch")
1361 )
1362 }
1363 Self::Range(v_start, v_end) => {
1364 write!(
1365 f,
1366 "><{}|{}",
1367 v_start
1368 .format(&format)
1369 .expect("Error formatting first Date in DateFilter::Range"),
1370 v_end
1371 .format(&format)
1372 .expect("Error formatting second Date in DateFilter::Range"),
1373 )
1374 }
1375 Self::LessThanOrEqual(v) => {
1376 write!(
1377 f,
1378 "<={}",
1379 v.format(&format)
1380 .expect("Error formatting Date in DateFilter::LessThanOrEqual")
1381 )
1382 }
1383 Self::GreaterThanOrEqual(v) => {
1384 write!(
1385 f,
1386 ">={}",
1387 v.format(&format)
1388 .expect("Error formatting Date in DateFilter::GreaterThanOrEqual")
1389 )
1390 }
1391 Self::LessThanDaysAgo(d) => {
1392 write!(f, ">t-{d}")
1393 }
1394 Self::MoreThanDaysAgo(d) => {
1395 write!(f, "<t-{d}")
1396 }
1397 Self::WithinPastDays(d) => {
1398 write!(f, "><t-{d}")
1399 }
1400 Self::ExactDaysAgo(d) => {
1401 write!(f, "t-{d}")
1402 }
1403 Self::InLessThanDays(d) => {
1404 write!(f, "<t+{d}")
1405 }
1406 Self::InMoreThanDays(d) => {
1407 write!(f, ">t+{d}")
1408 }
1409 Self::WithinFutureDays(d) => {
1410 write!(f, "><t+{d}")
1411 }
1412 Self::InExactDays(d) => {
1413 write!(f, "t+{d}")
1414 }
1415 Self::Today => {
1416 write!(f, "t")
1417 }
1418 Self::Yesterday => {
1419 write!(f, "ld")
1420 }
1421 Self::Tomorrow => {
1422 write!(f, "nd")
1423 }
1424 Self::ThisWeek => {
1425 write!(f, "w")
1426 }
1427 Self::LastWeek => {
1428 write!(f, "lw")
1429 }
1430 Self::LastTwoWeeks => {
1431 write!(f, "l2w")
1432 }
1433 Self::NextWeek => {
1434 write!(f, "nw")
1435 }
1436 Self::ThisMonth => {
1437 write!(f, "m")
1438 }
1439 Self::LastMonth => {
1440 write!(f, "lm")
1441 }
1442 Self::NextMonth => {
1443 write!(f, "nm")
1444 }
1445 Self::ThisYear => {
1446 write!(f, "y")
1447 }
1448 Self::Unset => {
1449 write!(f, "!*")
1450 }
1451 Self::Any => {
1452 write!(f, "*")
1453 }
1454 }
1455 }
1456}
1457
1458#[derive(Debug, Default, Clone)]
1460pub struct QueryParams<'a> {
1461 params: Vec<(Cow<'a, str>, Cow<'a, str>)>,
1463}
1464
1465impl<'a> QueryParams<'a> {
1466 pub fn push<'b, K, V>(&mut self, key: K, value: V) -> &mut Self
1468 where
1469 K: Into<Cow<'a, str>>,
1470 V: ParamValue<'b>,
1471 'b: 'a,
1472 {
1473 self.params.push((key.into(), value.as_value()));
1474 self
1475 }
1476
1477 pub fn push_opt<'b, K, V>(&mut self, key: K, value: Option<V>) -> &mut Self
1479 where
1480 K: Into<Cow<'a, str>>,
1481 V: ParamValue<'b>,
1482 'b: 'a,
1483 {
1484 if let Some(value) = value {
1485 self.params.push((key.into(), value.as_value()));
1486 }
1487 self
1488 }
1489
1490 pub fn extend<'b, I, K, V>(&mut self, iter: I) -> &mut Self
1492 where
1493 I: Iterator<Item = (K, V)>,
1494 K: Into<Cow<'a, str>>,
1495 V: ParamValue<'b>,
1496 'b: 'a,
1497 {
1498 self.params
1499 .extend(iter.map(|(key, value)| (key.into(), value.as_value())));
1500 self
1501 }
1502
1503 pub fn add_to_url(&self, url: &mut Url) {
1505 let mut pairs = url.query_pairs_mut();
1506 pairs.extend_pairs(self.params.iter());
1507 }
1508}
1509
1510pub trait Endpoint {
1512 fn method(&self) -> Method;
1514 fn endpoint(&self) -> Cow<'static, str>;
1516
1517 fn parameters(&self) -> QueryParams<'_> {
1519 QueryParams::default()
1520 }
1521
1522 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
1530 Ok(None)
1531 }
1532}
1533
1534pub trait ReturnsJsonResponse {}
1536
1537#[diagnostic::on_unimplemented(
1541 message = "{Self} is an endpoint that either returns nothing or requires pagination, use `.ignore_response_body(&endpoint)`, `.json_response_body_page(&endpoint, offset, limit)` or `.json_response_body_all_pages(&endpoint)` instead of `.json_response_body(&endpoint)`"
1542)]
1543pub trait NoPagination {}
1544
1545#[diagnostic::on_unimplemented(
1547 message = "{Self} is an endpoint that does not implement pagination or returns nothing, use `.ignore_response_body(&endpoint)` or `.json_response_body(&endpoint)` instead of `.json_response_body_page(&endpoint, offset, limit)` or `.json_response_body_all_pages(&endpoint)`"
1548)]
1549pub trait Pageable {
1550 fn response_wrapper_key(&self) -> String;
1552}
1553
1554pub fn deserialize_rfc3339<'de, D>(deserializer: D) -> Result<time::OffsetDateTime, D::Error>
1562where
1563 D: serde::Deserializer<'de>,
1564{
1565 let s = String::deserialize(deserializer)?;
1566
1567 time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
1568 .map_err(serde::de::Error::custom)
1569}
1570
1571pub fn serialize_rfc3339<S>(t: &time::OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
1579where
1580 S: serde::Serializer,
1581{
1582 let s = t
1583 .format(&time::format_description::well_known::Rfc3339)
1584 .map_err(serde::ser::Error::custom)?;
1585
1586 s.serialize(serializer)
1587}
1588
1589pub fn deserialize_optional_rfc3339<'de, D>(
1597 deserializer: D,
1598) -> Result<Option<time::OffsetDateTime>, D::Error>
1599where
1600 D: serde::Deserializer<'de>,
1601{
1602 let s = <Option<String> as Deserialize<'de>>::deserialize(deserializer)?;
1603
1604 if let Some(s) = s {
1605 Ok(Some(
1606 time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
1607 .map_err(serde::de::Error::custom)?,
1608 ))
1609 } else {
1610 Ok(None)
1611 }
1612}
1613
1614pub fn serialize_optional_rfc3339<S>(
1622 t: &Option<time::OffsetDateTime>,
1623 serializer: S,
1624) -> Result<S::Ok, S::Error>
1625where
1626 S: serde::Serializer,
1627{
1628 if let Some(t) = t {
1629 let s = t
1630 .format(&time::format_description::well_known::Rfc3339)
1631 .map_err(serde::ser::Error::custom)?;
1632
1633 s.serialize(serializer)
1634 } else {
1635 let n: Option<String> = None;
1636 n.serialize(serializer)
1637 }
1638}
1639
1640#[derive(Debug)]
1642pub struct AllPages<'i, E, R> {
1643 redmine: &'i Redmine,
1645 endpoint: &'i E,
1647 offset: u64,
1649 limit: u64,
1651 total_count: Option<u64>,
1653 yielded: u64,
1655 reversed_rest: Vec<R>,
1658}
1659
1660impl<'i, E, R> AllPages<'i, E, R> {
1661 pub const fn new(redmine: &'i Redmine, endpoint: &'i E) -> Self {
1663 Self {
1664 redmine,
1665 endpoint,
1666 offset: 0,
1667 limit: 100,
1668 total_count: None,
1669 yielded: 0,
1670 reversed_rest: Vec::new(),
1671 }
1672 }
1673}
1674
1675impl<E, R> Iterator for AllPages<'_, E, R>
1676where
1677 E: Endpoint + ReturnsJsonResponse + Pageable,
1678 R: DeserializeOwned + std::fmt::Debug,
1679{
1680 type Item = Result<R, crate::Error>;
1681
1682 #[expect(
1683 clippy::arithmetic_side_effects,
1684 reason = "u64 pagination counters; overflow requires impossibly many results"
1685 )]
1686 fn next(&mut self) -> Option<Self::Item> {
1687 if let Some(next) = self.reversed_rest.pop() {
1688 self.yielded += 1;
1689 return Some(Ok(next));
1690 }
1691 if let Some(total_count) = self.total_count
1692 && self.offset > total_count
1693 {
1694 return None;
1695 }
1696 match self
1697 .redmine
1698 .json_response_body_page(self.endpoint, self.offset, self.limit)
1699 {
1700 Err(e) => Some(Err(e)),
1701 Ok(ResponsePage {
1702 values,
1703 total_count,
1704 offset,
1705 limit,
1706 }) => {
1707 self.total_count = Some(total_count);
1708 self.offset = offset + limit;
1709 self.reversed_rest = values;
1710 self.reversed_rest.reverse();
1711 if let Some(next) = self.reversed_rest.pop() {
1712 self.yielded += 1;
1713 return Some(Ok(next));
1714 }
1715 None
1716 }
1717 }
1718 }
1719
1720 #[expect(
1721 clippy::arithmetic_side_effects,
1722 clippy::cast_possible_truncation,
1723 clippy::as_conversions,
1724 reason = "remaining = total_count - yielded; size_hint upper bound is best-effort"
1725 )]
1726 fn size_hint(&self) -> (usize, Option<usize>) {
1727 if let Some(total_count) = self.total_count {
1728 (
1729 self.reversed_rest.len(),
1730 Some((total_count - self.yielded) as usize),
1731 )
1732 } else {
1733 (0, None)
1734 }
1735 }
1736}
1737
1738#[pin_project::pin_project]
1740pub struct AllPagesAsync<E, R> {
1741 #[expect(
1743 clippy::type_complexity,
1744 reason = "boxed pinned future signature is intrinsically nested"
1745 )]
1746 #[pin]
1747 inner: Option<
1748 std::pin::Pin<Box<dyn futures::Future<Output = Result<ResponsePage<R>, crate::Error>>>>,
1749 >,
1750 redmine: std::sync::Arc<RedmineAsync>,
1752 endpoint: std::sync::Arc<E>,
1754 offset: u64,
1756 limit: u64,
1758 total_count: Option<u64>,
1760 yielded: u64,
1762 reversed_rest: Vec<R>,
1765}
1766
1767impl<E, R> std::fmt::Debug for AllPagesAsync<E, R>
1768where
1769 R: std::fmt::Debug,
1770{
1771 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1772 f.debug_struct("AllPagesAsync")
1773 .field("redmine", &self.redmine)
1774 .field("offset", &self.offset)
1775 .field("limit", &self.limit)
1776 .field("total_count", &self.total_count)
1777 .field("yielded", &self.yielded)
1778 .field("reversed_rest", &self.reversed_rest)
1779 .finish()
1780 }
1781}
1782
1783impl<E, R> AllPagesAsync<E, R> {
1784 pub fn new(redmine: std::sync::Arc<RedmineAsync>, endpoint: std::sync::Arc<E>) -> Self {
1786 Self {
1787 inner: None,
1788 redmine,
1789 endpoint,
1790 offset: 0,
1791 limit: 100,
1792 total_count: None,
1793 yielded: 0,
1794 reversed_rest: Vec::new(),
1795 }
1796 }
1797}
1798
1799impl<E, R> futures::stream::Stream for AllPagesAsync<E, R>
1800where
1801 E: Endpoint + ReturnsJsonResponse + Pageable + 'static,
1802 R: DeserializeOwned + std::fmt::Debug + 'static,
1803{
1804 type Item = Result<R, crate::Error>;
1805
1806 #[expect(
1807 clippy::arithmetic_side_effects,
1808 clippy::clone_on_ref_ptr,
1809 clippy::renamed_function_params,
1810 reason = "u64 pagination counters; Arc clones are intentional; ctx parameter name is preserved for clarity"
1811 )]
1812 fn poll_next(
1813 mut self: std::pin::Pin<&mut Self>,
1814 ctx: &mut std::task::Context<'_>,
1815 ) -> std::task::Poll<Option<Self::Item>> {
1816 if let Some(mut inner) = self.inner.take() {
1817 match inner.as_mut().poll(ctx) {
1818 std::task::Poll::Pending => {
1819 self.inner = Some(inner);
1820 std::task::Poll::Pending
1821 }
1822 std::task::Poll::Ready(Err(e)) => std::task::Poll::Ready(Some(Err(e))),
1823 std::task::Poll::Ready(Ok(ResponsePage {
1824 values,
1825 total_count,
1826 offset,
1827 limit,
1828 })) => {
1829 self.total_count = Some(total_count);
1830 self.offset = offset + limit;
1831 self.reversed_rest = values;
1832 self.reversed_rest.reverse();
1833 if let Some(next) = self.reversed_rest.pop() {
1834 self.yielded += 1;
1835 return std::task::Poll::Ready(Some(Ok(next)));
1836 }
1837 std::task::Poll::Ready(None)
1838 }
1839 }
1840 } else {
1841 if let Some(next) = self.reversed_rest.pop() {
1842 self.yielded += 1;
1843 return std::task::Poll::Ready(Some(Ok(next)));
1844 }
1845 if let Some(total_count) = self.total_count
1846 && self.offset > total_count
1847 {
1848 return std::task::Poll::Ready(None);
1849 }
1850 self.inner = Some(
1851 self.redmine
1852 .clone()
1853 .json_response_body_page(self.endpoint.clone(), self.offset, self.limit)
1854 .boxed_local(),
1855 );
1856 self.poll_next(ctx)
1857 }
1858 }
1859
1860 #[expect(
1861 clippy::arithmetic_side_effects,
1862 clippy::cast_possible_truncation,
1863 clippy::as_conversions,
1864 reason = "remaining = total_count - yielded; size_hint upper bound is best-effort"
1865 )]
1866 fn size_hint(&self) -> (usize, Option<usize>) {
1867 if let Some(total_count) = self.total_count {
1868 (
1869 self.reversed_rest.len(),
1870 Some((total_count - self.yielded) as usize),
1871 )
1872 } else {
1873 (0, None)
1874 }
1875 }
1876}
1877
1878pub trait EndpointParameter<E> {
1884 fn into_arc(self) -> std::sync::Arc<E>;
1886}
1887
1888impl<E> EndpointParameter<E> for &E
1889where
1890 E: Clone,
1891{
1892 fn into_arc(self) -> std::sync::Arc<E> {
1893 std::sync::Arc::new(self.to_owned())
1894 }
1895}
1896
1897impl<E> EndpointParameter<E> for std::sync::Arc<E> {
1898 fn into_arc(self) -> Self {
1899 self
1900 }
1901}