use alloc::{collections::VecDeque, string::String, vec::Vec};
use core::num::NonZeroUsize;
const TRUNCATION_SUFFIX: &str = "...[truncated]";
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Level {
Debug,
Info,
Ok,
Warn,
Error,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Limits {
pub max_entries: NonZeroUsize,
pub max_message_bytes: usize,
pub max_label_bytes: usize,
}
#[derive(Clone, Copy, Debug)]
pub struct EntryInput<'a> {
pub created_at_secs: u64,
pub level: Level,
pub source: &'a str,
pub topic: Option<&'a str>,
pub message: &'a str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Entry {
pub sequence: u64,
pub created_at_secs: u64,
pub level: Level,
pub source: String,
pub topic: Option<String>,
pub message: String,
pub truncated: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Label {
Source,
Topic,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AppendError {
LabelTooLong {
label: Label,
actual_bytes: usize,
max_bytes: usize,
},
TimestampRegressed {
previous: u64,
current: u64,
},
SequenceExhausted,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Filter<'a> {
pub source: Option<&'a str>,
pub topic: Option<&'a str>,
pub min_level: Option<Level>,
}
impl Filter<'_> {
fn matches(self, entry: &Entry) -> bool {
let source_matches = self.source.is_none_or(|source| source == entry.source);
let topic_matches = self
.topic
.is_none_or(|topic| Some(topic) == entry.topic.as_deref());
let level_matches = self.min_level.is_none_or(|level| entry.level >= level);
source_matches && topic_matches && level_matches
}
}
#[derive(Clone, Copy, Debug)]
pub struct PageRequest {
pub before: Option<u64>,
pub max_results: NonZeroUsize,
pub max_scanned: NonZeroUsize,
}
#[derive(Debug)]
pub struct Page<'a> {
pub entries: Vec<&'a Entry>,
pub scanned: usize,
pub next_before: Option<u64>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetentionBatch {
pub removed: usize,
pub more_due: bool,
}
#[derive(Debug)]
pub struct Log {
limits: Limits,
entries: VecDeque<Entry>,
next_sequence: Option<u64>,
last_created_at_secs: Option<u64>,
}
impl Log {
#[must_use]
pub const fn new(limits: Limits) -> Self {
Self {
limits,
entries: VecDeque::new(),
next_sequence: Some(0),
last_created_at_secs: None,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn append(&mut self, input: EntryInput<'_>) -> Result<u64, AppendError> {
self.validate_label(Label::Source, input.source)?;
if let Some(topic) = input.topic {
self.validate_label(Label::Topic, topic)?;
}
if let Some(previous) = self.last_created_at_secs
&& input.created_at_secs < previous
{
return Err(AppendError::TimestampRegressed {
previous,
current: input.created_at_secs,
});
}
let sequence = self.next_sequence.ok_or(AppendError::SequenceExhausted)?;
let entry = Entry {
sequence,
created_at_secs: input.created_at_secs,
level: input.level,
source: String::from(input.source),
topic: input.topic.map(String::from),
message: bounded_message(input.message, self.limits.max_message_bytes),
truncated: input.message.len() > self.limits.max_message_bytes,
};
if self.entries.len() == self.limits.max_entries.get() {
self.entries.pop_front();
}
self.entries.push_back(entry);
self.next_sequence = sequence.checked_add(1);
self.last_created_at_secs = Some(input.created_at_secs);
Ok(sequence)
}
pub fn prune_expired(
&mut self,
now_secs: u64,
max_age_secs: u64,
max_remove: usize,
) -> RetentionBatch {
let cutoff = now_secs.saturating_sub(max_age_secs);
let mut removed = 0;
while removed < max_remove && self.expired_before(cutoff) {
self.entries.pop_front();
removed += 1;
}
RetentionBatch {
removed,
more_due: self.expired_before(cutoff),
}
}
#[must_use]
pub fn next_expiry_secs(&self, max_age_secs: u64) -> Option<u64> {
self.entries
.front()?
.created_at_secs
.checked_add(max_age_secs)?
.checked_add(1)
}
#[must_use]
pub fn page(&self, filter: Filter<'_>, request: PageRequest) -> Page<'_> {
let mut cursor = request.before.map_or(self.entries.len(), |sequence| {
self.entries
.binary_search_by_key(&sequence, |entry| entry.sequence)
.unwrap_or_else(|index| index)
});
let mut entries = Vec::new();
let mut scanned = 0;
let mut last_scanned = None;
while cursor > 0
&& scanned < request.max_scanned.get()
&& entries.len() < request.max_results.get()
{
cursor -= 1;
let entry = &self.entries[cursor];
scanned += 1;
last_scanned = Some(entry.sequence);
if filter.matches(entry) {
entries.push(entry);
}
}
Page {
entries,
scanned,
next_before: if cursor > 0 { last_scanned } else { None },
}
}
const fn validate_label(&self, label: Label, text: &str) -> Result<(), AppendError> {
if text.len() > self.limits.max_label_bytes {
return Err(AppendError::LabelTooLong {
label,
actual_bytes: text.len(),
max_bytes: self.limits.max_label_bytes,
});
}
Ok(())
}
fn expired_before(&self, cutoff: u64) -> bool {
self.entries
.front()
.is_some_and(|entry| entry.created_at_secs < cutoff)
}
}
fn bounded_message(message: &str, max_bytes: usize) -> String {
if message.len() <= max_bytes {
return String::from(message);
}
let suffix = if max_bytes > TRUNCATION_SUFFIX.len() {
TRUNCATION_SUFFIX
} else {
""
};
let mut end = max_bytes - suffix.len();
while !message.is_char_boundary(end) {
end -= 1;
}
let mut retained = String::from(&message[..end]);
retained.push_str(suffix);
retained
}
#[cfg(test)]
mod tests;