Skip to main content

ic_logging/log/
mod.rs

1//! Heap log admission, immutable rows, bounded retention and cursor paging.
2//!
3//! Callers own authorization, scheduling, time and any durable storage.
4
5use alloc::{collections::VecDeque, string::String, vec::Vec};
6use core::{num::NonZeroUsize, ops::ControlFlow};
7
8const TRUNCATION_SUFFIX: &str = "...[truncated]";
9
10/// Severity ordering used by minimum-level filters.
11#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
12pub enum Level {
13    /// Development detail.
14    Debug,
15    /// Ordinary informational observation.
16    Info,
17    /// Successful outcome, ordered above informational observations.
18    Ok,
19    /// Recoverable or suspicious condition.
20    Warn,
21    /// Failed operation or invariant observation.
22    Error,
23}
24
25/// Fixed limits chosen by the host before constructing a log.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub struct Limits {
28    /// Maximum retained rows; count eviction removes the oldest row.
29    pub max_entries: NonZeroUsize,
30    /// Maximum retained message bytes, including any truncation suffix.
31    pub max_message_bytes: usize,
32    /// Maximum UTF-8 bytes in each source or topic label.
33    pub max_label_bytes: usize,
34    /// Maximum combined retained message/source/topic UTF-8 bytes.
35    /// Excludes row metadata, allocator overhead and transient checkpoint copies.
36    pub max_text_bytes: usize,
37}
38
39/// Borrowed append input; bounded retained strings are copied only after admission.
40#[derive(Clone, Copy, Debug)]
41pub struct EntryInput<'a> {
42    /// Caller-supplied whole seconds; may not regress within this log.
43    pub created_at_secs: u64,
44    /// Observation severity.
45    pub level: Level,
46    /// Application or component label.
47    pub source: &'a str,
48    /// Optional application-defined topic.
49    pub topic: Option<&'a str>,
50    /// Message to retain, truncated to the configured byte ceiling.
51    pub message: &'a str,
52}
53
54/// Immutable retained row; sequence identities belong to one log instance.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct Entry {
57    /// Monotonic identity, starting at zero and never reused within one log.
58    pub sequence: u64,
59    /// Admitted caller-supplied timestamp.
60    pub created_at_secs: u64,
61    /// Observation severity.
62    pub level: Level,
63    /// Bounded application or component label.
64    pub source: String,
65    /// Optional bounded topic label.
66    pub topic: Option<String>,
67    /// Bounded message text.
68    pub message: String,
69    /// Whether the original message exceeded its byte limit.
70    pub truncated: bool,
71}
72
73/// Label rejected by append admission.
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub enum Label {
76    /// Application or component source.
77    Source,
78    /// Optional topic.
79    Topic,
80}
81
82/// Append refusal; every variant leaves entries and watermarks unchanged.
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub enum AppendError {
85    /// A label exceeded its configured byte ceiling.
86    LabelTooLong {
87        /// Rejected field.
88        label: Label,
89        /// Supplied UTF-8 byte length.
90        actual_bytes: usize,
91        /// Configured ceiling.
92        max_bytes: usize,
93    },
94    /// The timestamp is older than the last admitted append, even after pruning.
95    TimestampRegressed {
96        /// Last admitted timestamp.
97        previous: u64,
98        /// Rejected timestamp.
99        current: u64,
100    },
101    /// All sequence identities have been issued for this log instance.
102    SequenceExhausted,
103    /// This row's retained text cannot fit even in an otherwise empty log.
104    TextBudgetExceeded {
105        /// Required message/source/topic bytes after message truncation.
106        required_bytes: u128,
107        /// Configured total retained-text ceiling.
108        max_bytes: usize,
109    },
110}
111
112impl core::fmt::Display for AppendError {
113    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
114        match self {
115            Self::LabelTooLong {
116                label,
117                actual_bytes,
118                max_bytes,
119            } => {
120                let label = match label {
121                    Label::Source => "source",
122                    Label::Topic => "topic",
123                };
124                write!(
125                    formatter,
126                    "log {label} label exceeds {max_bytes} bytes ({actual_bytes} supplied)"
127                )
128            }
129            Self::TimestampRegressed { previous, current } => write!(
130                formatter,
131                "log timestamp regressed from {previous} to {current} seconds"
132            ),
133            Self::SequenceExhausted => formatter.write_str("log sequence identities are exhausted"),
134            Self::TextBudgetExceeded {
135                required_bytes,
136                max_bytes,
137            } => write!(
138                formatter,
139                "log row requires {required_bytes} text bytes (limit {max_bytes})"
140            ),
141        }
142    }
143}
144
145impl core::error::Error for AppendError {}
146
147/// Receipt for one admitted append; eviction is oldest-first across both ceilings.
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149pub struct AppendReceipt {
150    /// Newly issued identity.
151    pub sequence: u64,
152    /// Whether the original message exceeded its individual byte ceiling.
153    pub truncated: bool,
154    /// Previously retained rows evicted to admit this row.
155    pub evicted: usize,
156}
157
158/// Match any topic, no topic, or one exact topic (including an empty label).
159#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
160pub enum TopicMatch<'a> {
161    /// Do not filter on topic presence or value.
162    #[default]
163    Any,
164    /// Match only rows with no topic.
165    Absent,
166    /// Match a present topic exactly.
167    Exact(&'a str),
168}
169
170/// Optional exact label filters and a minimum severity; all supplied filters apply.
171#[derive(Clone, Copy, Debug, Default)]
172pub struct Filter<'a> {
173    /// Match this exact source when present.
174    pub source: Option<&'a str>,
175    /// Topic presence/value selection.
176    pub topic: TopicMatch<'a>,
177    /// Include this severity and higher when present.
178    pub min_level: Option<Level>,
179}
180
181impl Filter<'_> {
182    fn matches(self, entry: &Entry) -> bool {
183        let source_matches = self.source.is_none_or(|source| source == entry.source);
184        let topic_matches = match self.topic {
185            TopicMatch::Any => true,
186            TopicMatch::Absent => entry.topic.is_none(),
187            TopicMatch::Exact(topic) => entry.topic.as_deref() == Some(topic),
188        };
189        let level_matches = self.min_level.is_none_or(|level| entry.level >= level);
190        source_matches && topic_matches && level_matches
191    }
192}
193
194/// One newest-first scan using an exclusive sequence cursor.
195#[derive(Clone, Copy, Debug)]
196pub struct PageRequest {
197    /// Scan entries older than this sequence, or begin at the newest entry.
198    pub before: Option<u64>,
199    /// Maximum matching rows to return.
200    pub max_results: NonZeroUsize,
201    /// Maximum candidate rows to examine after binary-search positioning.
202    pub max_scanned: NonZeroUsize,
203}
204
205/// Borrowed page; a continuation can be present even when no row matched.
206#[derive(Debug)]
207pub struct Page<'a> {
208    /// Matching rows, newest first.
209    pub entries: Vec<&'a Entry>,
210    /// Number of candidate rows examined, not the total number of matches.
211    pub scanned: usize,
212    /// Exclusive cursor for the next older scan, if retained candidates remain.
213    pub next_before: Option<u64>,
214}
215
216/// Progress of one bounded visitor scan, including a caller-requested early stop.
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
218pub struct PageScan {
219    /// Matching rows delivered to the visitor, including a row returning `Break`.
220    pub matched: usize,
221    /// Candidate rows examined after positioning.
222    pub scanned: usize,
223    /// Continue strictly before this identity when retained candidates remain.
224    pub next_before: Option<u64>,
225}
226
227/// Borrowed complete checkpoint view; obtaining/iterating it allocates nothing.
228/// Hosts own encoding budgets. The immutable borrow keeps rows and watermarks fixed.
229#[derive(Clone, Copy, Debug)]
230pub struct LogCheckpoint<'a> {
231    limits: Limits,
232    entries: &'a VecDeque<Entry>,
233    next_sequence: Option<u64>,
234    last_created_at_secs: Option<u64>,
235}
236
237impl<'a> LogCheckpoint<'a> {
238    /// Fixed retention limits.
239    #[must_use]
240    pub const fn limits(&self) -> Limits {
241        self.limits
242    }
243
244    /// All retained rows, oldest first, without cloning text.
245    pub fn entries(&self) -> impl ExactSizeIterator<Item = &'a Entry> + DoubleEndedIterator + 'a {
246        self.entries.iter()
247    }
248
249    /// Next identity, including permanent exhaustion after complete pruning.
250    #[must_use]
251    pub const fn next_sequence(&self) -> Option<u64> {
252        self.next_sequence
253    }
254
255    /// Last admitted time, including after complete pruning.
256    #[must_use]
257    pub const fn last_created_at_secs(&self) -> Option<u64> {
258        self.last_created_at_secs
259    }
260}
261
262/// Result of a bounded age-retention call.
263#[derive(Clone, Copy, Debug, Eq, PartialEq)]
264pub struct RetentionBatch {
265    /// Number of rows removed by this call.
266    pub removed: usize,
267    /// Whether the oldest remaining row is also expired.
268    pub more_due: bool,
269}
270
271/// Complete passive checkpoint, with rows ordered oldest first.
272///
273/// This is not a byte format or an instance identity. Restore only through
274/// [`Log::restore`]; consumers own durable encoding, identity and allocation.
275#[derive(Clone, Debug, Eq, PartialEq)]
276pub struct LogSnapshot {
277    /// Fixed bounds of the original log.
278    pub limits: Limits,
279    /// Retained contiguous suffix of issued identities, oldest first.
280    pub entries: Vec<Entry>,
281    /// Next unissued identity; `None` means permanent sequence exhaustion.
282    pub next_sequence: Option<u64>,
283    /// Last admitted timestamp, retained even when no rows remain.
284    pub last_created_at_secs: Option<u64>,
285}
286
287/// Refusal of a checkpoint that the maintained engine cannot produce.
288#[derive(Clone, Copy, Debug, Eq, PartialEq)]
289pub enum RestoreError {
290    /// Retained row count exceeds the fixed limit.
291    TooManyEntries,
292    /// Rows are not contiguous or the next identity contradicts the retained tail.
293    InvalidSequence,
294    /// Row times regress or the timestamp watermark is inconsistent.
295    InvalidTimestamp,
296    /// A label/message exceeds its bound or truncated text is inconsistent.
297    InvalidEntry,
298    /// Combined retained text exceeds the checkpoint's configured ceiling.
299    TextBudgetExceeded,
300}
301
302impl core::fmt::Display for RestoreError {
303    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
304        formatter.write_str(match self {
305            Self::TooManyEntries => "log checkpoint exceeds its entry limit",
306            Self::InvalidSequence => "invalid log checkpoint sequence",
307            Self::InvalidTimestamp => "invalid log checkpoint timestamp",
308            Self::InvalidEntry => "invalid bounded log checkpoint entry",
309            Self::TextBudgetExceeded => "log checkpoint exceeds its text budget",
310        })
311    }
312}
313
314impl core::error::Error for RestoreError {}
315
316/// Caller-owned heap history with fixed limits and monotonic append watermarks.
317#[derive(Debug)]
318pub struct Log {
319    limits: Limits,
320    entries: VecDeque<Entry>,
321    retained_text_bytes: usize,
322    next_sequence: Option<u64>,
323    last_created_at_secs: Option<u64>,
324}
325
326impl Log {
327    /// Construct an empty log without preallocating the configured maximum.
328    #[must_use]
329    pub const fn new(limits: Limits) -> Self {
330        Self {
331            limits,
332            entries: VecDeque::new(),
333            retained_text_bytes: 0,
334            next_sequence: Some(0),
335            last_created_at_secs: None,
336        }
337    }
338
339    /// Return the fixed bounds without copying retained messages.
340    #[must_use]
341    pub const fn limits(&self) -> Limits {
342        self.limits
343    }
344
345    /// Combined retained text bytes; excludes metadata, spare capacity and allocator overhead.
346    #[must_use]
347    pub const fn retained_text_bytes(&self) -> usize {
348        self.retained_text_bytes
349    }
350
351    /// Borrow rows and append watermarks together, without copying or consuming the log.
352    #[must_use]
353    pub const fn checkpoint(&self) -> LogCheckpoint<'_> {
354        LogCheckpoint {
355            limits: self.limits,
356            entries: &self.entries,
357            next_sequence: self.next_sequence,
358            last_created_at_secs: self.last_created_at_secs,
359        }
360    }
361
362    /// Copy all retained rows and both append watermarks for explicit checkpointing.
363    /// Work and allocation scale with retained history, not a cursor-page limit.
364    #[must_use]
365    pub fn snapshot(&self) -> LogSnapshot {
366        LogSnapshot {
367            limits: self.limits,
368            entries: self.entries.iter().cloned().collect(),
369            next_sequence: self.next_sequence,
370            last_created_at_secs: self.last_created_at_secs,
371        }
372    }
373
374    /// Consume the log into a checkpoint without cloning retained entries.
375    /// Fixed limits, row order and both append watermarks are preserved, including
376    /// empty histories and permanent sequence exhaustion.
377    #[must_use]
378    pub fn into_snapshot(self) -> LogSnapshot {
379        LogSnapshot {
380            limits: self.limits,
381            entries: self.entries.into(),
382            next_sequence: self.next_sequence,
383            last_created_at_secs: self.last_created_at_secs,
384        }
385    }
386
387    /// Admit a complete checkpoint without replaying entries or reissuing identities.
388    ///
389    /// Empty retained history preserves its original append watermarks. Admission
390    /// proves internal consistency, not source authenticity or host continuity.
391    /// Byte decoders must bound allocation before constructing the checkpoint.
392    ///
393    /// # Errors
394    /// Rejects bounds, row ordering, watermarks and truncation states that cannot
395    /// result from this engine's append/retention operations.
396    pub fn restore(snapshot: LogSnapshot) -> Result<Self, RestoreError> {
397        let retained_text_bytes = validate_snapshot(&snapshot)?;
398        let LogSnapshot {
399            limits,
400            entries,
401            next_sequence,
402            last_created_at_secs,
403        } = snapshot;
404        Ok(Self {
405            limits,
406            entries: entries.into(),
407            retained_text_bytes,
408            next_sequence,
409            last_created_at_secs,
410        })
411    }
412
413    /// Return the retained row count.
414    #[must_use]
415    pub fn len(&self) -> usize {
416        self.entries.len()
417    }
418
419    /// Whether this log currently has no retained rows.
420    #[must_use]
421    pub fn is_empty(&self) -> bool {
422        self.entries.is_empty()
423    }
424
425    /// Admit one row, evicting the oldest rows until both count and text ceilings fit.
426    ///
427    /// # Errors
428    /// Returns a typed refusal for oversized labels, regressed time or exhausted
429    /// sequence identities, or a row that cannot fit the text budget by itself.
430    /// Validation completes before any eviction or mutation. Error priority is
431    /// source, topic, regressed time, sequence exhaustion, then total text budget.
432    pub fn append(&mut self, input: EntryInput<'_>) -> Result<AppendReceipt, AppendError> {
433        self.validate_label(Label::Source, input.source)?;
434        if let Some(topic) = input.topic {
435            self.validate_label(Label::Topic, topic)?;
436        }
437        if let Some(previous) = self.last_created_at_secs
438            && input.created_at_secs < previous
439        {
440            return Err(AppendError::TimestampRegressed {
441                previous,
442                current: input.created_at_secs,
443            });
444        }
445        let sequence = self.next_sequence.ok_or(AppendError::SequenceExhausted)?;
446        let (prefix, suffix) = message_parts(input.message, self.limits.max_message_bytes);
447        let required_bytes = input.source.len() as u128
448            + input.topic.map_or(0, str::len) as u128
449            + prefix.len() as u128
450            + suffix.len() as u128;
451        let text_bytes = usize::try_from(required_bytes)
452            .ok()
453            .filter(|bytes| *bytes <= self.limits.max_text_bytes)
454            .ok_or(AppendError::TextBudgetExceeded {
455                required_bytes,
456                max_bytes: self.limits.max_text_bytes,
457            })?;
458        let truncated = input.message.len() > self.limits.max_message_bytes;
459        let entry = Entry {
460            sequence,
461            created_at_secs: input.created_at_secs,
462            level: input.level,
463            source: String::from(input.source),
464            topic: input.topic.map(String::from),
465            message: bounded_message(input.message, self.limits.max_message_bytes),
466            truncated,
467        };
468        let mut evicted = 0;
469        while (self.entries.len() >= self.limits.max_entries.get()
470            || self.retained_text_bytes > self.limits.max_text_bytes - text_bytes)
471            && self.remove_oldest()
472        {
473            evicted += 1;
474        }
475        self.entries.push_back(entry);
476        self.retained_text_bytes += text_bytes;
477        self.next_sequence = sequence.checked_add(1);
478        self.last_created_at_secs = Some(input.created_at_secs);
479        Ok(AppendReceipt {
480            sequence,
481            truncated,
482            evicted,
483        })
484    }
485
486    /// Remove at most `max_remove` entries strictly older than the age cutoff.
487    /// An entry remains available through its full configured age second.
488    pub fn prune_expired(
489        &mut self,
490        now_secs: u64,
491        max_age_secs: u64,
492        max_remove: usize,
493    ) -> RetentionBatch {
494        let cutoff = now_secs.saturating_sub(max_age_secs);
495        let mut removed = 0;
496        while removed < max_remove && self.expired_before(cutoff) {
497            self.remove_oldest();
498            removed += 1;
499        }
500        RetentionBatch {
501            removed,
502            more_due: self.expired_before(cutoff),
503        }
504    }
505
506    /// First second at which the oldest row expires; `None` for empty history or overflow.
507    #[must_use]
508    pub fn next_expiry_secs(&self, max_age_secs: u64) -> Option<u64> {
509        self.entries
510            .front()?
511            .created_at_secs
512            .checked_add(max_age_secs)?
513            .checked_add(1)
514    }
515
516    /// Scan newest-first without cloning retained messages or counting every match.
517    /// Follow `next_before` with the same filters, including after an empty page.
518    #[must_use]
519    pub fn page(&self, filter: Filter<'_>, request: PageRequest) -> Page<'_> {
520        let mut entries = Vec::new();
521        let scan = self.visit_page(filter, request, |entry| {
522            entries.push(entry);
523            ControlFlow::Continue(())
524        });
525        Page {
526            entries,
527            scanned: scan.scanned,
528            next_before: scan.next_before,
529        }
530    }
531
532    /// Visit matching rows without allocating a page collection or cloning text.
533    /// `Break` stops after consuming that matched row; the returned cursor resumes
534    /// at the next older candidate. Empty filtered scans can still have a cursor.
535    /// Callback work/allocation belongs to the caller; filter/limits match `page`.
536    pub fn visit_page<'a>(
537        &'a self,
538        filter: Filter<'_>,
539        request: PageRequest,
540        mut visit: impl FnMut(&'a Entry) -> ControlFlow<()>,
541    ) -> PageScan {
542        let mut cursor = request.before.map_or(self.entries.len(), |sequence| {
543            self.entries
544                .binary_search_by_key(&sequence, |entry| entry.sequence)
545                .unwrap_or_else(|index| index)
546        });
547        let mut matched = 0;
548        let mut scanned = 0;
549        let mut last_scanned = None;
550        while cursor > 0
551            && scanned < request.max_scanned.get()
552            && matched < request.max_results.get()
553        {
554            cursor -= 1;
555            let entry = &self.entries[cursor];
556            scanned += 1;
557            last_scanned = Some(entry.sequence);
558            if filter.matches(entry) {
559                matched += 1;
560                if visit(entry).is_break() {
561                    break;
562                }
563            }
564        }
565        PageScan {
566            matched,
567            scanned,
568            next_before: if cursor > 0 { last_scanned } else { None },
569        }
570    }
571
572    const fn validate_label(&self, label: Label, text: &str) -> Result<(), AppendError> {
573        if text.len() > self.limits.max_label_bytes {
574            return Err(AppendError::LabelTooLong {
575                label,
576                actual_bytes: text.len(),
577                max_bytes: self.limits.max_label_bytes,
578            });
579        }
580        Ok(())
581    }
582
583    fn expired_before(&self, cutoff: u64) -> bool {
584        self.entries
585            .front()
586            .is_some_and(|entry| entry.created_at_secs < cutoff)
587    }
588
589    fn remove_oldest(&mut self) -> bool {
590        let Some(entry) = self.entries.pop_front() else {
591            return false;
592        };
593        // Each component was admitted into the cached sum before insertion.
594        self.retained_text_bytes -= entry.source.len();
595        self.retained_text_bytes -= entry.topic.as_ref().map_or(0, String::len);
596        self.retained_text_bytes -= entry.message.len();
597        true
598    }
599}
600
601fn validate_snapshot(snapshot: &LogSnapshot) -> Result<usize, RestoreError> {
602    let LogSnapshot {
603        limits,
604        entries,
605        next_sequence,
606        last_created_at_secs,
607    } = snapshot;
608    let (next_sequence, last_created_at_secs) = (*next_sequence, *last_created_at_secs);
609    if entries.len() > limits.max_entries.get() {
610        return Err(RestoreError::TooManyEntries);
611    }
612    if (next_sequence == Some(0)) != last_created_at_secs.is_none() {
613        return Err(RestoreError::InvalidTimestamp);
614    }
615    // The last row cannot expire at the final representable timestamp, and
616    // count eviction always leaves a newer row in this nonzero-capacity log.
617    if entries.is_empty() && last_created_at_secs == Some(u64::MAX) {
618        return Err(RestoreError::InvalidTimestamp);
619    }
620    let mut previous: Option<&Entry> = None;
621    let mut retained_text_bytes = 0_usize;
622    for entry in entries {
623        if entry.source.len() > limits.max_label_bytes
624            || entry
625                .topic
626                .as_ref()
627                .is_some_and(|topic| topic.len() > limits.max_label_bytes)
628            || !valid_retained_message(entry, limits.max_message_bytes)
629        {
630            return Err(RestoreError::InvalidEntry);
631        }
632        for bytes in [
633            entry.source.len(),
634            entry.topic.as_ref().map_or(0, String::len),
635            entry.message.len(),
636        ] {
637            retained_text_bytes = retained_text_bytes
638                .checked_add(bytes)
639                .filter(|total| *total <= limits.max_text_bytes)
640                .ok_or(RestoreError::TextBudgetExceeded)?;
641        }
642        if let Some(previous) = previous {
643            if previous.sequence.checked_add(1) != Some(entry.sequence) {
644                return Err(RestoreError::InvalidSequence);
645            }
646            if entry.created_at_secs < previous.created_at_secs {
647                return Err(RestoreError::InvalidTimestamp);
648            }
649        }
650        previous = Some(entry);
651    }
652    if let Some(last) = previous {
653        if last.sequence.checked_add(1) != next_sequence {
654            return Err(RestoreError::InvalidSequence);
655        }
656        if Some(last.created_at_secs) != last_created_at_secs {
657            return Err(RestoreError::InvalidTimestamp);
658        }
659    }
660    Ok(retained_text_bytes)
661}
662
663const fn truncation_suffix(max_bytes: usize) -> &'static str {
664    if max_bytes > TRUNCATION_SUFFIX.len() {
665        TRUNCATION_SUFFIX
666    } else {
667        ""
668    }
669}
670
671fn valid_retained_message(entry: &Entry, max_bytes: usize) -> bool {
672    if entry.message.len() > max_bytes {
673        return false;
674    }
675    if !entry.truncated {
676        return true;
677    }
678    let suffix = truncation_suffix(max_bytes);
679    let Some(prefix) = entry.message.strip_suffix(suffix) else {
680        return false;
681    };
682    let budget = max_bytes - suffix.len();
683    budget.saturating_sub(prefix.len()) < 4
684}
685
686fn bounded_message(message: &str, max_bytes: usize) -> String {
687    let (prefix, suffix) = message_parts(message, max_bytes);
688    let mut retained = String::with_capacity(prefix.len() + suffix.len());
689    retained.push_str(prefix);
690    retained.push_str(suffix);
691    retained
692}
693
694fn message_parts(message: &str, max_bytes: usize) -> (&str, &'static str) {
695    if message.len() <= max_bytes {
696        return (message, "");
697    }
698    let suffix = truncation_suffix(max_bytes);
699    let mut end = max_bytes - suffix.len();
700    while !message.is_char_boundary(end) {
701        end -= 1;
702    }
703    (&message[..end], suffix)
704}
705
706#[cfg(test)]
707mod tests;