aster_forge_api/
lib.rs

1//! Shared API response and pagination helpers for Aster services.
2//!
3//! This crate contains small HTTP-facing types that are useful across service boundaries:
4//! bounded limit query parsing, limit/offset pagination, cursor-page response shapes, cursor
5//! validation helpers, overfetch trimming, and simple sort-order serialization. It deliberately
6//! avoids depending on any concrete web framework or product entity so handlers can adapt it to
7//! Axum, Actix, `OpenAPI` generation, or test-only fixtures.
8#![cfg_attr(
9    not(test),
10    deny(
11        clippy::unwrap_used,
12        clippy::unreachable,
13        clippy::expect_used,
14        clippy::panic,
15        clippy::unimplemented,
16        clippy::todo
17    )
18)]
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use std::future::Future;
23#[cfg(all(debug_assertions, feature = "openapi"))]
24use utoipa::{IntoParams, ToSchema};
25
26/// Default page size for folder-list style endpoints.
27pub const DEFAULT_FOLDER_LIMIT: u64 = 200;
28/// Default page size for file-list style endpoints.
29pub const DEFAULT_FILE_LIMIT: u64 = 100;
30/// Default page size for cursor-based endpoints.
31pub const DEFAULT_PAGE_LIMIT: u64 = 100;
32/// Maximum accepted page size for offset and cursor pagination.
33pub const MAX_PAGE_SIZE: u64 = 1000;
34
35/// Result type returned by API helper functions.
36pub type Result<T> = std::result::Result<T, ApiError>;
37
38/// Error type for generic API helper failures.
39#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
40#[error("{message}")]
41pub struct ApiError {
42    message: String,
43}
44
45impl ApiError {
46    /// Creates an API helper error with a message.
47    pub fn new(message: impl Into<String>) -> Self {
48        Self {
49            message: message.into(),
50        }
51    }
52
53    /// Returns the stored error message.
54    #[must_use]
55    pub fn message(&self) -> &str {
56        &self.message
57    }
58}
59
60/// Query parameters for limit/offset pagination.
61#[derive(Debug, Clone, Copy, Default, Deserialize)]
62#[cfg_attr(
63    all(debug_assertions, feature = "openapi"),
64    derive(IntoParams, ToSchema)
65)]
66pub struct LimitOffsetQuery {
67    /// Requested page size.
68    pub limit: Option<u64>,
69    /// Requested offset from the beginning of the result set.
70    pub offset: Option<u64>,
71}
72
73impl LimitOffsetQuery {
74    /// Returns the requested limit clamped to `[1, max]`, or `default` when absent.
75    #[must_use]
76    pub fn limit_or(&self, default: u64, max: u64) -> u64 {
77        self.limit.map_or(default, |v| v.clamp(1, max))
78    }
79
80    /// Returns the requested offset, or zero when absent.
81    #[must_use]
82    pub fn offset(&self) -> u64 {
83        self.offset.unwrap_or(0)
84    }
85}
86
87/// Query parameters for limit-only cursor pagination.
88#[derive(Debug, Clone, Copy, Default, Deserialize)]
89#[cfg_attr(
90    all(debug_assertions, feature = "openapi"),
91    derive(IntoParams, ToSchema)
92)]
93pub struct LimitQuery {
94    /// Requested page size.
95    pub limit: Option<u64>,
96}
97
98impl LimitQuery {
99    /// Returns the requested limit clamped to `[1, max]`, or `default` when absent.
100    #[must_use]
101    pub fn limit_or(&self, default: u64, max: u64) -> u64 {
102        self.limit.map_or(default, |value| value.clamp(1, max))
103    }
104
105    /// Returns the requested limit clamped against the crate's default cursor limits.
106    #[must_use]
107    pub fn limit(&self) -> u64 {
108        self.limit_or(DEFAULT_PAGE_LIMIT, MAX_PAGE_SIZE)
109    }
110}
111
112/// Cursor query for resources ordered by creation time and numeric id.
113#[derive(Debug, Clone, Copy, Default, Deserialize)]
114#[cfg_attr(
115    all(debug_assertions, feature = "openapi"),
116    derive(IntoParams, ToSchema)
117)]
118pub struct CreatedAtCursorQuery {
119    /// Cursor creation timestamp.
120    pub after_created_at: Option<DateTime<Utc>>,
121    /// Cursor numeric id used as a stable tie breaker.
122    pub after_id: Option<i64>,
123}
124
125/// Cursor query for resources ordered by update time and numeric id.
126#[derive(Debug, Clone, Copy, Default, Deserialize)]
127#[cfg_attr(
128    all(debug_assertions, feature = "openapi"),
129    derive(IntoParams, ToSchema)
130)]
131pub struct UpdatedAtCursorQuery {
132    /// Cursor update timestamp.
133    pub after_updated_at: Option<DateTime<Utc>>,
134    /// Cursor numeric id used as a stable tie breaker.
135    pub after_id: Option<i64>,
136}
137
138/// Three-state nullable field used by PATCH-style request DTOs.
139///
140/// `Absent` means the request omitted the field and the existing value should be preserved.
141/// `Null` means the request explicitly supplied `null` and the existing value should be cleared.
142/// `Value` means the request supplied a concrete replacement value.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum NullablePatch<T> {
145    /// Field was omitted from the request.
146    #[default]
147    Absent,
148    /// Field was present with JSON `null`.
149    Null,
150    /// Field was present with a concrete value.
151    Value(T),
152}
153
154impl<T> NullablePatch<T> {
155    /// Returns whether the request supplied this field as either `null` or a concrete value.
156    pub fn is_present(&self) -> bool {
157        !matches!(self, Self::Absent)
158    }
159}
160
161/// Deserializes an optional PATCH field while preserving explicit `null`.
162///
163/// Use this with `#[serde(default, deserialize_with = "...")]` on `Option<NullablePatch<T>>`
164/// fields when the surrounding DTO needs to distinguish omitted fields from explicit nulls.
165///
166/// # Errors
167///
168/// Returns the deserializer's error when the present value cannot be deserialized as `T`.
169pub fn deserialize_nullable_patch_option<'de, D, T>(
170    deserializer: D,
171) -> std::result::Result<Option<NullablePatch<T>>, D::Error>
172where
173    D: serde::Deserializer<'de>,
174    T: Deserialize<'de>,
175{
176    Option::<T>::deserialize(deserializer).map(|value| Some(NullablePatch::from(value)))
177}
178
179impl<T> From<Option<T>> for NullablePatch<T> {
180    fn from(value: Option<T>) -> Self {
181        match value {
182            Some(value) => Self::Value(value),
183            None => Self::Null,
184        }
185    }
186}
187
188impl<'de, T> Deserialize<'de> for NullablePatch<T>
189where
190    T: Deserialize<'de>,
191{
192    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
193    where
194        D: serde::Deserializer<'de>,
195    {
196        Ok(match Option::<T>::deserialize(deserializer)? {
197            Some(value) => Self::Value(value),
198            None => Self::Null,
199        })
200    }
201}
202
203#[cfg(all(debug_assertions, feature = "openapi"))]
204#[doc(hidden)]
205pub trait ApiSchema: ToSchema {}
206
207#[cfg(all(debug_assertions, feature = "openapi"))]
208impl<T: ToSchema> ApiSchema for T {}
209
210#[cfg(not(all(debug_assertions, feature = "openapi")))]
211#[doc(hidden)]
212pub trait ApiSchema {}
213
214#[cfg(not(all(debug_assertions, feature = "openapi")))]
215impl<T> ApiSchema for T {}
216
217/// Serialized offset page response.
218#[derive(Debug, Clone, Serialize)]
219#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
220pub struct OffsetPage<T: Serialize + ApiSchema> {
221    /// Items in the current page.
222    pub items: Vec<T>,
223    /// Total number of items matching the query.
224    pub total: u64,
225    /// Effective page size.
226    pub limit: u64,
227    /// Offset used for this page.
228    pub offset: u64,
229}
230
231impl<T: Serialize + ApiSchema> OffsetPage<T> {
232    /// Creates a new offset page.
233    #[must_use]
234    pub fn new(items: Vec<T>, total: u64, limit: u64, offset: u64) -> Self {
235        Self {
236            items,
237            total,
238            limit,
239            offset,
240        }
241    }
242}
243
244/// Serialized cursor page response.
245#[derive(Debug, Clone, Serialize)]
246#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
247pub struct CursorPage<T: Serialize + ApiSchema, C: Serialize + ApiSchema> {
248    /// Items in the current page.
249    pub items: Vec<T>,
250    /// Total number of items matching the query.
251    pub total: u64,
252    /// Effective page size.
253    pub limit: u64,
254    /// Cursor that can be sent back to fetch the next page.
255    pub next_cursor: Option<C>,
256}
257
258impl<T: Serialize + ApiSchema, C: Serialize + ApiSchema> CursorPage<T, C> {
259    /// Creates a new cursor page.
260    pub fn new(items: Vec<T>, total: u64, limit: u64, next_cursor: Option<C>) -> Self {
261        Self {
262            items,
263            total,
264            limit,
265            next_cursor,
266        }
267    }
268}
269
270/// Numeric id cursor for resources sorted by id.
271#[derive(Debug, Clone, Serialize)]
272#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
273pub struct IdCursor {
274    /// Cursor id.
275    pub id: i64,
276}
277
278/// String value plus numeric id cursor for resources sorted by text and then id.
279#[derive(Debug, Clone, Serialize)]
280#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
281pub struct StringIdCursor {
282    /// Cursor string value.
283    pub value: String,
284    /// Cursor numeric id used as a stable tie breaker.
285    pub id: i64,
286}
287
288/// Sort-order, name, and numeric id cursor for manually ordered named resources.
289#[derive(Debug, Clone, Serialize)]
290#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
291pub struct SortOrderNameIdCursor {
292    /// Cursor sort order value.
293    pub sort_order: i32,
294    /// Cursor display or storage name.
295    pub name: String,
296    /// Cursor numeric id used as a stable tie breaker.
297    pub id: i64,
298}
299
300/// Enabled flag, priority, and numeric id cursor for prioritized toggle-like resources.
301#[derive(Debug, Clone, Serialize)]
302#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
303pub struct EnabledPriorityIdCursor {
304    /// Cursor enabled flag.
305    pub enabled: bool,
306    /// Cursor priority value.
307    pub priority: i32,
308    /// Cursor numeric id used as a stable tie breaker.
309    pub id: i64,
310}
311
312/// Timestamp plus numeric id cursor for resources sorted by time and then id.
313#[derive(Debug, Clone, Serialize)]
314#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
315pub struct DateTimeIdCursor {
316    /// Cursor timestamp.
317    #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
318    pub value: DateTime<Utc>,
319    /// Cursor numeric id used as a stable tie breaker.
320    pub id: i64,
321}
322
323/// Timestamp plus string id cursor for resources sorted by time and then string id.
324#[derive(Debug, Clone, Serialize)]
325#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
326pub struct DateTimeStringCursor {
327    /// Cursor timestamp.
328    #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))]
329    pub value: DateTime<Utc>,
330    /// Cursor string id used as a stable tie breaker.
331    pub id: String,
332}
333
334/// Loads an offset page by clamping `limit`, invoking `fetch`, and wrapping the result.
335///
336/// # Errors
337///
338/// Returns the error produced by `fetch`.
339pub async fn load_offset_page<T, F, Fut>(
340    limit: u64,
341    offset: u64,
342    max_limit: u64,
343    fetch: F,
344) -> Result<OffsetPage<T>>
345where
346    T: Serialize + ApiSchema,
347    F: FnOnce(u64, u64) -> Fut,
348    Fut: Future<Output = Result<(Vec<T>, u64)>>,
349{
350    let limit = limit.clamp(1, max_limit);
351    let (items, total) = fetch(limit, offset).await?;
352    Ok(OffsetPage::new(items, total, limit, offset))
353}
354
355/// Validates a timestamp plus numeric id cursor pair.
356///
357/// # Errors
358///
359/// Returns an error when only one cursor component is present or the id is not positive.
360pub fn parse_datetime_id_cursor(
361    value: Option<DateTime<Utc>>,
362    id: Option<i64>,
363    value_name: &str,
364) -> Result<Option<(DateTime<Utc>, i64)>> {
365    match (value, id) {
366        (None, None) => Ok(None),
367        (Some(value), Some(id)) if id > 0 => Ok(Some((value, id))),
368        (Some(_), Some(_)) => Err(ApiError::new(format!(
369            "{value_name} cursor id must be positive",
370        ))),
371        _ => Err(ApiError::new(format!(
372            "{value_name} cursor requires both value and id",
373        ))),
374    }
375}
376
377/// Validates a timestamp plus string id cursor pair.
378///
379/// # Errors
380///
381/// Returns an error when only one cursor component is present or the string id is blank.
382pub fn parse_datetime_string_cursor(
383    value: Option<DateTime<Utc>>,
384    id: Option<String>,
385    value_name: &str,
386) -> Result<Option<(DateTime<Utc>, String)>> {
387    match (value, id) {
388        (None, None) => Ok(None),
389        (Some(value), Some(id)) if !id.trim().is_empty() => Ok(Some((value, id))),
390        (Some(_), Some(_)) => Err(ApiError::new(format!(
391            "{value_name} cursor id must not be empty",
392        ))),
393        _ => Err(ApiError::new(format!(
394            "{value_name} cursor requires both value and id",
395        ))),
396    }
397}
398
399/// Validates an optional positive numeric id cursor.
400///
401/// # Errors
402///
403/// Returns an error when the supplied id is zero or negative.
404pub fn parse_id_cursor(id: Option<i64>, value_name: &str) -> Result<Option<i64>> {
405    match id {
406        None => Ok(None),
407        Some(id) if id > 0 => Ok(Some(id)),
408        Some(_) => Err(ApiError::new(format!(
409            "{value_name} cursor id must be positive",
410        ))),
411    }
412}
413
414/// Validates a string value plus numeric id cursor pair.
415///
416/// # Errors
417///
418/// Returns an error when the tuple is incomplete, the string value is blank, or the id is not
419/// positive.
420pub fn parse_string_id_cursor(
421    value: Option<String>,
422    id: Option<i64>,
423    value_name: &str,
424) -> Result<Option<(String, i64)>> {
425    match (value, id) {
426        (None, None) => Ok(None),
427        (Some(value), Some(id)) if !value.trim().is_empty() && id > 0 => Ok(Some((value, id))),
428        (Some(_), Some(id)) if id <= 0 => Err(ApiError::new(format!(
429            "{value_name} cursor id must be positive",
430        ))),
431        (Some(_), Some(_)) => Err(ApiError::new(format!(
432            "{value_name} cursor value must not be empty",
433        ))),
434        _ => Err(ApiError::new(format!(
435            "{value_name} cursor requires both value and id",
436        ))),
437    }
438}
439
440/// Validates a sort-order, name, and numeric id cursor tuple.
441///
442/// # Errors
443///
444/// Returns an error when the tuple is incomplete, the name is blank, or the id is not positive.
445pub fn parse_sort_order_name_id_cursor(
446    sort_order: Option<i32>,
447    name: Option<String>,
448    id: Option<i64>,
449    value_name: &str,
450) -> Result<Option<(i32, String, i64)>> {
451    match (sort_order, name, id) {
452        (None, None, None) => Ok(None),
453        (Some(sort_order), Some(name), Some(id)) if !name.trim().is_empty() && id > 0 => {
454            Ok(Some((sort_order, name, id)))
455        }
456        (Some(_), Some(_), Some(id)) if id <= 0 => Err(ApiError::new(format!(
457            "{value_name} cursor id must be positive",
458        ))),
459        (Some(_), Some(_), Some(_)) => Err(ApiError::new(format!(
460            "{value_name} cursor name must not be empty",
461        ))),
462        _ => Err(ApiError::new(format!(
463            "{value_name} cursor requires sort_order, name, and id",
464        ))),
465    }
466}
467
468/// Validates an enabled flag, priority, and numeric id cursor tuple.
469///
470/// # Errors
471///
472/// Returns an error when the tuple is incomplete or the id is not positive.
473pub fn parse_enabled_priority_id_cursor(
474    enabled: Option<bool>,
475    priority: Option<i32>,
476    id: Option<i64>,
477    value_name: &str,
478) -> Result<Option<(bool, i32, i64)>> {
479    match (enabled, priority, id) {
480        (None, None, None) => Ok(None),
481        (Some(enabled), Some(priority), Some(id)) if id > 0 => Ok(Some((enabled, priority, id))),
482        (Some(_), Some(_), Some(_)) => Err(ApiError::new(format!(
483            "{value_name} cursor id must be positive",
484        ))),
485        _ => Err(ApiError::new(format!(
486            "{value_name} cursor requires enabled, priority, and id",
487        ))),
488    }
489}
490
491/// Repository page slice returned after fetching one extra row to detect a next page.
492#[derive(Debug, Clone)]
493pub struct CursorSlice<T> {
494    /// Items to expose to the caller after overfetch trimming.
495    pub items: Vec<T>,
496    /// Total number of items matching the query.
497    pub total: u64,
498    /// Whether the repository found at least one item beyond the requested limit.
499    pub has_more: bool,
500}
501
502impl<T> CursorSlice<T> {
503    /// Creates an empty slice with a known total count.
504    #[must_use]
505    pub fn empty(total: u64) -> Self {
506        Self {
507            items: Vec::new(),
508            total,
509            has_more: false,
510        }
511    }
512
513    /// Builds a cursor slice from a repository result that fetched `limit + 1` rows.
514    ///
515    /// # Errors
516    ///
517    /// Returns an error when the item count or truncation limit cannot be represented by the
518    /// required integer type on the current platform.
519    pub fn from_overfetch(mut items: Vec<T>, total: u64, limit: u64) -> Result<Self> {
520        let item_count = u64::try_from(items.len())
521            .map_err(|_| ApiError::new("cursor slice item count is too large"))?;
522        let has_more = item_count > limit;
523        if has_more {
524            let limit =
525                usize::try_from(limit).map_err(|_| ApiError::new("cursor limit is too large"))?;
526            items.truncate(limit);
527        }
528        Ok(Self {
529            items,
530            total,
531            has_more,
532        })
533    }
534}
535
536/// Sort direction used by API query parameters.
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
538#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
539#[serde(rename_all = "snake_case")]
540#[derive(Default)]
541pub enum SortOrder {
542    /// Ascending order.
543    #[default]
544    Asc,
545    /// Descending order.
546    Desc,
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use serde_json::json;
553
554    #[test]
555    fn limit_offset_query_applies_defaults_and_bounds() {
556        let query = LimitOffsetQuery {
557            limit: None,
558            offset: None,
559        };
560        assert_eq!(query.limit_or(50, 100), 50);
561        assert_eq!(query.offset(), 0);
562
563        let query = LimitOffsetQuery {
564            limit: Some(0),
565            offset: Some(25),
566        };
567        assert_eq!(query.limit_or(50, 100), 1);
568        assert_eq!(query.offset(), 25);
569
570        let query = LimitOffsetQuery {
571            limit: Some(500),
572            offset: Some(10),
573        };
574        assert_eq!(query.limit_or(50, 100), 100);
575    }
576
577    #[test]
578    fn limit_query_applies_defaults_and_bounds() {
579        let query = LimitQuery { limit: None };
580        assert_eq!(query.limit_or(50, 100), 50);
581        assert_eq!(query.limit(), DEFAULT_PAGE_LIMIT);
582
583        let query = LimitQuery { limit: Some(0) };
584        assert_eq!(query.limit_or(50, 100), 1);
585
586        let query = LimitQuery { limit: Some(500) };
587        assert_eq!(query.limit_or(50, 100), 100);
588    }
589
590    #[derive(Debug, Deserialize, PartialEq, Eq)]
591    struct PatchDto {
592        #[serde(default)]
593        title: NullablePatch<String>,
594        #[serde(default, deserialize_with = "deserialize_nullable_patch_option")]
595        description: Option<NullablePatch<String>>,
596    }
597
598    #[test]
599    fn nullable_patch_deserializes_absent_null_and_value_fields() {
600        let dto: PatchDto = serde_json::from_value(json!({})).unwrap();
601        assert_eq!(dto.title, NullablePatch::Absent);
602        assert_eq!(dto.description, None);
603        assert!(!dto.title.is_present());
604
605        let dto: PatchDto =
606            serde_json::from_value(json!({ "title": null, "description": null })).unwrap();
607        assert_eq!(dto.title, NullablePatch::Null);
608        assert_eq!(dto.description, Some(NullablePatch::Null));
609        assert!(dto.title.is_present());
610
611        let dto: PatchDto = serde_json::from_value(json!({
612            "title": "new title",
613            "description": "new description"
614        }))
615        .unwrap();
616        assert_eq!(dto.title, NullablePatch::Value("new title".to_string()));
617        assert_eq!(
618            dto.description,
619            Some(NullablePatch::Value("new description".to_string()))
620        );
621    }
622
623    #[test]
624    fn offset_page_serializes_expected_shape() {
625        let page = OffsetPage::new(vec!["a", "b"], 10, 2, 4);
626        let value = serde_json::to_value(page).unwrap();
627
628        assert_eq!(
629            value,
630            json!({
631                "items": ["a", "b"],
632                "total": 10,
633                "limit": 2,
634                "offset": 4
635            })
636        );
637    }
638
639    #[test]
640    fn cursor_page_serializes_expected_shape() {
641        let page = CursorPage::new(vec!["a", "b"], 10, 2, Some(IdCursor { id: 42 }));
642        let value = serde_json::to_value(page).unwrap();
643
644        assert_eq!(
645            value,
646            json!({
647                "items": ["a", "b"],
648                "total": 10,
649                "limit": 2,
650                "next_cursor": { "id": 42 }
651            })
652        );
653    }
654
655    #[tokio::test]
656    async fn load_offset_page_clamps_limit_and_forwards_offset() {
657        let page = load_offset_page(500, 30, 100, |limit, offset| async move {
658            assert_eq!(limit, 100);
659            assert_eq!(offset, 30);
660            Ok((vec![1, 2, 3], 9))
661        })
662        .await
663        .unwrap();
664
665        assert_eq!(page.items, vec![1, 2, 3]);
666        assert_eq!(page.total, 9);
667        assert_eq!(page.limit, 100);
668        assert_eq!(page.offset, 30);
669    }
670
671    #[tokio::test]
672    async fn load_offset_page_propagates_fetch_error() {
673        let error = load_offset_page::<u8, _, _>(10, 0, 100, |_limit, _offset| async {
674            Err(ApiError::new("fetch failed"))
675        })
676        .await
677        .unwrap_err();
678
679        assert_eq!(error.message(), "fetch failed");
680    }
681
682    #[test]
683    fn sort_order_serializes_snake_case() {
684        assert_eq!(serde_json::to_value(SortOrder::Asc).unwrap(), json!("asc"));
685        assert_eq!(
686            serde_json::to_value(SortOrder::Desc).unwrap(),
687            json!("desc")
688        );
689    }
690
691    #[test]
692    fn parse_id_cursor_accepts_absent_or_positive_id() {
693        assert_eq!(parse_id_cursor(None, "profile").unwrap(), None);
694        assert_eq!(parse_id_cursor(Some(7), "profile").unwrap(), Some(7));
695
696        let error = parse_id_cursor(Some(0), "profile").unwrap_err();
697        assert_eq!(error.message(), "profile cursor id must be positive");
698    }
699
700    #[test]
701    fn parse_datetime_id_cursor_requires_both_parts() {
702        let timestamp = DateTime::parse_from_rfc3339("2024-01-02T03:04:05Z")
703            .unwrap()
704            .with_timezone(&Utc);
705
706        assert_eq!(
707            parse_datetime_id_cursor(Some(timestamp), Some(9), "audit")
708                .unwrap()
709                .unwrap(),
710            (timestamp, 9)
711        );
712        assert_eq!(parse_datetime_id_cursor(None, None, "audit").unwrap(), None);
713
714        let error = parse_datetime_id_cursor(Some(timestamp), None, "audit").unwrap_err();
715        assert_eq!(error.message(), "audit cursor requires both value and id");
716
717        let error = parse_datetime_id_cursor(Some(timestamp), Some(-1), "audit").unwrap_err();
718        assert_eq!(error.message(), "audit cursor id must be positive");
719    }
720
721    #[test]
722    fn parse_datetime_string_cursor_rejects_empty_id() {
723        let timestamp = DateTime::parse_from_rfc3339("2024-01-02T03:04:05Z")
724            .unwrap()
725            .with_timezone(&Utc);
726
727        assert_eq!(
728            parse_datetime_string_cursor(Some(timestamp), Some("abc".to_string()), "session")
729                .unwrap()
730                .unwrap(),
731            (timestamp, "abc".to_string())
732        );
733
734        let error = parse_datetime_string_cursor(Some(timestamp), Some(" ".to_string()), "session")
735            .unwrap_err();
736        assert_eq!(error.message(), "session cursor id must not be empty");
737    }
738
739    #[test]
740    fn parse_string_id_cursor_rejects_incomplete_or_empty_values() {
741        assert_eq!(
742            parse_string_id_cursor(Some("oauth".to_string()), Some(3), "provider")
743                .unwrap()
744                .unwrap(),
745            ("oauth".to_string(), 3)
746        );
747
748        let error = parse_string_id_cursor(Some(" ".to_string()), Some(3), "provider").unwrap_err();
749        assert_eq!(error.message(), "provider cursor value must not be empty");
750
751        let error =
752            parse_string_id_cursor(Some("oauth".to_string()), Some(0), "provider").unwrap_err();
753        assert_eq!(error.message(), "provider cursor id must be positive");
754
755        let error =
756            parse_string_id_cursor(Some("oauth".to_string()), None, "provider").unwrap_err();
757        assert_eq!(
758            error.message(),
759            "provider cursor requires both value and id"
760        );
761    }
762
763    #[test]
764    fn parse_sort_order_name_id_cursor_validates_tuple() {
765        assert_eq!(
766            parse_sort_order_name_id_cursor(Some(10), Some("cape".to_string()), Some(2), "tag")
767                .unwrap()
768                .unwrap(),
769            (10, "cape".to_string(), 2)
770        );
771        assert_eq!(
772            parse_sort_order_name_id_cursor(None, None, None, "tag").unwrap(),
773            None
774        );
775
776        let error =
777            parse_sort_order_name_id_cursor(Some(10), Some(" ".to_string()), Some(2), "tag")
778                .unwrap_err();
779        assert_eq!(error.message(), "tag cursor name must not be empty");
780
781        let error =
782            parse_sort_order_name_id_cursor(Some(10), Some("cape".to_string()), None, "tag")
783                .unwrap_err();
784        assert_eq!(
785            error.message(),
786            "tag cursor requires sort_order, name, and id"
787        );
788    }
789
790    #[test]
791    fn parse_enabled_priority_id_cursor_validates_tuple() {
792        assert_eq!(
793            parse_enabled_priority_id_cursor(Some(true), Some(10), Some(2), "server")
794                .unwrap()
795                .unwrap(),
796            (true, 10, 2)
797        );
798        assert_eq!(
799            parse_enabled_priority_id_cursor(None, None, None, "server").unwrap(),
800            None
801        );
802
803        let error =
804            parse_enabled_priority_id_cursor(Some(true), Some(10), Some(0), "server").unwrap_err();
805        assert_eq!(error.message(), "server cursor id must be positive");
806
807        let error =
808            parse_enabled_priority_id_cursor(Some(true), Some(10), None, "server").unwrap_err();
809        assert_eq!(
810            error.message(),
811            "server cursor requires enabled, priority, and id"
812        );
813    }
814
815    #[test]
816    fn cursor_slice_trims_overfetch_and_reports_has_more() {
817        let slice = CursorSlice::from_overfetch(vec![1, 2, 3], 10, 2).unwrap();
818        assert_eq!(slice.items, vec![1, 2]);
819        assert_eq!(slice.total, 10);
820        assert!(slice.has_more);
821
822        let slice = CursorSlice::from_overfetch(vec![1, 2], 2, 2).unwrap();
823        assert_eq!(slice.items, vec![1, 2]);
824        assert_eq!(slice.total, 2);
825        assert!(!slice.has_more);
826
827        let slice = CursorSlice::<u8>::empty(7);
828        assert!(slice.items.is_empty());
829        assert_eq!(slice.total, 7);
830        assert!(!slice.has_more);
831    }
832}