use crate::error::{GradatumError, ValidationError};
use crate::identity::NoteId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimelineCursor {
pub anchor_ms: i64,
pub note_id: String,
}
impl TimelineCursor {
#[must_use]
pub fn encode(&self) -> String {
format!("{}.{}", self.anchor_ms, self.note_id)
}
pub fn decode(s: &str) -> Result<Self, GradatumError> {
let (ms, id) = s.split_once('.').ok_or_else(|| {
GradatumError::Validation(ValidationError::InvalidInput(
"cursor malformé: séparateur absent".into(),
))
})?;
let anchor_ms = ms.parse::<i64>().map_err(|_| {
GradatumError::Validation(ValidationError::InvalidInput(
"cursor malformé: anchor_ms".into(),
))
})?;
if id.is_empty() {
return Err(GradatumError::Validation(ValidationError::InvalidInput(
"cursor malformé: note_id vide".into(),
)));
}
if id.len() > 32 {
return Err(GradatumError::Validation(ValidationError::InvalidInput(
"cursor malformé: note_id surdimensionné".into(),
)));
}
Ok(Self {
anchor_ms,
note_id: id.to_string(),
})
}
}
#[derive(Debug, Clone)]
pub struct TimelineFilter {
pub doc_kind: Option<Vec<String>>,
pub from_ms: Option<i64>,
pub to_ms: Option<i64>,
pub limit: usize,
pub cursor: Option<TimelineCursor>,
pub as_of_ms: Option<i64>,
pub include_expired: bool,
}
impl Default for TimelineFilter {
fn default() -> Self {
Self {
doc_kind: None,
from_ms: None,
to_ms: None,
limit: 50,
cursor: None,
as_of_ms: None,
include_expired: false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TimelineRow {
pub note_id: NoteId,
pub anchor_ms: i64,
pub anchor_src: String,
pub doc_kind: String,
pub title: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_roundtrip() {
let c = TimelineCursor {
anchor_ms: 1_734_000_000_000,
note_id: "01HZ".to_string(),
};
let encoded = c.encode();
assert_eq!(encoded, "1734000000000.01HZ");
assert_eq!(TimelineCursor::decode(&encoded).unwrap(), c);
}
#[test]
fn cursor_decode_rejects_missing_separator() {
assert!(TimelineCursor::decode("1734000000000").is_err());
}
#[test]
fn cursor_decode_rejects_bad_ms() {
assert!(TimelineCursor::decode("notanumber.01HZ").is_err());
}
#[test]
fn cursor_decode_rejects_empty_id() {
assert!(TimelineCursor::decode("123.").is_err());
}
#[test]
fn cursor_decode_rejects_oversized_id() {
let oversized = "0".repeat(33);
assert!(TimelineCursor::decode(&format!("123.{oversized}")).is_err());
assert!(TimelineCursor::decode(&format!("123.{}", "0".repeat(32))).is_ok());
}
}