Skip to main content

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