use crate::adapter::sqlite_repos::{DEFAULT_EVENT_LIST_LIMIT, SqliteEventRepository};
use crate::adapter::unit_of_work::UnitOfWork;
use crate::error::CarryCtxError;
use crate::repository::event::{EventFilter, EventRecord, EventRepository};
#[derive(serde::Serialize)]
pub struct CursorList {
pub events: Vec<EventRecord>,
pub next_cursor: Option<String>,
}
pub fn list_events(
project_id: &str,
filter: &EventFilter,
cursor: Option<&str>,
uow: &UnitOfWork,
) -> Result<CursorList, CarryCtxError> {
let conn = uow.connection();
let repo = SqliteEventRepository::new(conn);
let (before_ts, before_id) = match cursor {
Some(token) => {
let (ts, id) = decode_cursor(token)?;
(Some(ts), Some(id))
}
None => (None, None),
};
let effective_limit = filter.limit.unwrap_or(DEFAULT_EVENT_LIST_LIMIT);
let fetch_limit = effective_limit
.checked_add(1)
.ok_or_else(|| CarryCtxError::validation_error("Event list limit is too large."))?;
let adjusted_filter = EventFilter {
project_id: project_id.to_string(),
task_id: filter.task_id.clone(),
agent_id: filter.agent_id.clone(),
session_id: filter.session_id.clone(),
event_type: filter.event_type.clone(),
since: filter.since.clone(),
until: filter.until.clone(),
limit: Some(fetch_limit),
};
let mut events =
repo.list_before_cursor(&adjusted_filter, before_ts.as_deref(), before_id.as_deref())?;
let next_cursor = if events.len() > effective_limit as usize {
events.truncate(effective_limit as usize);
events.last().map(|e| encode_cursor(&e.occurred_at, &e.id))
} else {
None
};
Ok(CursorList {
events,
next_cursor,
})
}
const CURSOR_PEPPER: &str = "carryctx.event.cursor.v1";
fn cursor_checksum(payload: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(CURSOR_PEPPER.as_bytes());
hasher.update([0u8]);
hasher.update(payload.as_bytes());
let digest = hasher.finalize();
hex::encode(&digest[..4])
}
const BASE64URL_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
fn base64url_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [
chunk[0],
chunk.get(1).copied().unwrap_or(0),
chunk.get(2).copied().unwrap_or(0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
out.push(BASE64URL_ALPHABET[(n >> 18) as usize & 63] as char);
out.push(BASE64URL_ALPHABET[(n >> 12) as usize & 63] as char);
if chunk.len() > 1 {
out.push(BASE64URL_ALPHABET[(n >> 6) as usize & 63] as char);
}
if chunk.len() > 2 {
out.push(BASE64URL_ALPHABET[n as usize & 63] as char);
}
}
out
}
fn base64url_decode(token: &str) -> Option<Vec<u8>> {
fn value_of(byte: u8) -> Option<u32> {
match byte {
b'A'..=b'Z' => Some(u32::from(byte - b'A')),
b'a'..=b'z' => Some(u32::from(byte - b'a') + 26),
b'0'..=b'9' => Some(u32::from(byte - b'0') + 52),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
if token.is_empty() {
return Some(Vec::new());
}
let bytes = token.as_bytes();
if bytes.len() % 4 == 1 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3 + 2);
for chunk in bytes.chunks(4) {
let mut n: u32 = 0;
for (i, &byte) in chunk.iter().enumerate() {
n |= value_of(byte)? << (18 - 6 * i);
}
out.push((n >> 16) as u8);
if chunk.len() > 2 {
out.push((n >> 8) as u8);
}
if chunk.len() > 3 {
out.push(n as u8);
}
}
Some(out)
}
fn encode_cursor(occurred_at: &str, id: &str) -> String {
let payload = format!("{occurred_at}|{id}");
format!(
"{}.{}",
base64url_encode(payload.as_bytes()),
cursor_checksum(&payload)
)
}
fn decode_cursor(token: &str) -> Result<(String, String), CarryCtxError> {
const INVALID: &str =
"Invalid event cursor format; use a cursor previously returned by this command.";
if let Some(split_index) = token.rfind('.') {
let (body, checksum) = token.split_at(split_index);
let checksum = &checksum[1..];
return match base64url_decode(body).and_then(|bytes| String::from_utf8(bytes).ok()) {
Some(payload) if cursor_checksum(&payload) == checksum => {
parse_cursor_payload(&payload)
}
_ => Err(CarryCtxError::validation_error(INVALID)),
};
}
parse_cursor_payload(token)
}
fn parse_cursor_payload(payload: &str) -> Result<(String, String), CarryCtxError> {
const INVALID: &str =
"Invalid event cursor format; use a cursor previously returned by this command.";
let (ts, id) = payload
.split_once('|')
.ok_or_else(|| CarryCtxError::validation_error(INVALID))?;
if ts.is_empty() || id.is_empty() || id.contains('|') {
return Err(CarryCtxError::validation_error(INVALID));
}
Ok((ts.to_string(), id.to_string()))
}
pub fn show_event(
project_id: &str,
event_id: &str,
uow: &UnitOfWork,
) -> Result<EventRecord, CarryCtxError> {
let conn = uow.connection();
let repo = SqliteEventRepository::new(conn);
let event = repo.find_by_id(project_id, event_id)?;
event.ok_or_else(|| CarryCtxError::resource_not_found(format!("Event '{event_id}' not found.")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_round_trip() {
let token = encode_cursor("2026-08-24T10:00:00+00:00", "01JABCDEF");
assert_eq!(
decode_cursor(&token).unwrap(),
(
"2026-08-24T10:00:00+00:00".to_string(),
"01JABCDEF".to_string()
)
);
}
#[test]
fn cursor_token_is_opaque_not_plaintext() {
let token = encode_cursor("2026-08-24T10:00:00+00:00", "01JABCDEF");
assert!(
!token.contains('|'),
"token must not leak the raw keyset tuple: {token}"
);
assert!(token.contains('.'), "token must carry a checksum suffix");
}
#[test]
fn cursor_rejects_garbage() {
assert!(decode_cursor("no-separator").is_err());
assert!(decode_cursor("|only-id").is_err());
assert!(decode_cursor("only-ts|").is_err());
assert!(decode_cursor("").is_err());
}
#[test]
fn cursor_rejects_tampered_tokens() {
let token = encode_cursor("2026-08-24T10:00:00+00:00", "01JABCDEF");
let (body, mac) = token.split_once('.').unwrap();
let mut tampered_body = body.as_bytes().to_vec();
let last = tampered_body
.iter_mut()
.rev()
.find(|b| **b != b'A')
.unwrap();
*last = b'A';
assert!(
decode_cursor(&format!(
"{}.{mac}",
String::from_utf8(tampered_body).unwrap()
))
.is_err()
);
assert!(decode_cursor(&format!("{body}.00000000")).is_err());
assert!(decode_cursor(body).is_err());
}
#[test]
fn cursor_still_accepts_legacy_plaintext_tokens() {
assert_eq!(
decode_cursor("2026-08-24T10:00:00+00:00|01JABCDEF").unwrap(),
(
"2026-08-24T10:00:00+00:00".to_string(),
"01JABCDEF".to_string()
)
);
}
#[test]
fn cursor_accepts_fractional_second_timestamps() {
let token = encode_cursor("2026-08-25T06:53:42.256236685+00:00", "01JABCDEF");
assert_eq!(
decode_cursor(&token).unwrap(),
(
"2026-08-25T06:53:42.256236685+00:00".to_string(),
"01JABCDEF".to_string()
)
);
}
}