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        // Message parts together fit the configured usize-sized message limit.
448        let required_bytes = text_lengths(input.source, input.topic, prefix.len() + suffix.len())
449            .into_iter()
450            .map(|bytes| bytes as u128)
451            .sum();
452        let text_bytes = usize::try_from(required_bytes)
453            .ok()
454            .filter(|bytes| *bytes <= self.limits.max_text_bytes)
455            .ok_or(AppendError::TextBudgetExceeded {
456                required_bytes,
457                max_bytes: self.limits.max_text_bytes,
458            })?;
459        let truncated = input.message.len() > self.limits.max_message_bytes;
460        let entry = Entry {
461            sequence,
462            created_at_secs: input.created_at_secs,
463            level: input.level,
464            source: String::from(input.source),
465            topic: input.topic.map(String::from),
466            message: bounded_message(prefix, suffix),
467            truncated,
468        };
469        let mut evicted = 0;
470        while (self.entries.len() >= self.limits.max_entries.get()
471            || self.retained_text_bytes > self.limits.max_text_bytes - text_bytes)
472            && self.remove_oldest()
473        {
474            evicted += 1;
475        }
476        self.entries.push_back(entry);
477        self.retained_text_bytes += text_bytes;
478        self.next_sequence = sequence.checked_add(1);
479        self.last_created_at_secs = Some(input.created_at_secs);
480        Ok(AppendReceipt {
481            sequence,
482            truncated,
483            evicted,
484        })
485    }
486
487    /// Remove at most `max_remove` entries strictly older than the age cutoff.
488    /// An entry remains available through its full configured age second.
489    pub fn prune_expired(
490        &mut self,
491        now_secs: u64,
492        max_age_secs: u64,
493        max_remove: usize,
494    ) -> RetentionBatch {
495        let cutoff = now_secs.saturating_sub(max_age_secs);
496        let mut removed = 0;
497        while removed < max_remove && self.expired_before(cutoff) {
498            self.remove_oldest();
499            removed += 1;
500        }
501        RetentionBatch {
502            removed,
503            more_due: self.expired_before(cutoff),
504        }
505    }
506
507    /// First second at which the oldest row expires; `None` for empty history or overflow.
508    #[must_use]
509    pub fn next_expiry_secs(&self, max_age_secs: u64) -> Option<u64> {
510        self.entries
511            .front()?
512            .created_at_secs
513            .checked_add(max_age_secs)?
514            .checked_add(1)
515    }
516
517    /// Scan newest-first without cloning retained messages or counting every match.
518    /// Follow `next_before` with the same filters, including after an empty page.
519    #[must_use]
520    pub fn page(&self, filter: Filter<'_>, request: PageRequest) -> Page<'_> {
521        let mut entries = Vec::new();
522        let scan = self.visit_page(filter, request, |entry| {
523            entries.push(entry);
524            ControlFlow::Continue(())
525        });
526        Page {
527            entries,
528            scanned: scan.scanned,
529            next_before: scan.next_before,
530        }
531    }
532
533    /// Visit matching rows without allocating a page collection or cloning text.
534    /// `Break` stops after consuming that matched row; the returned cursor resumes
535    /// at the next older candidate. Empty filtered scans can still have a cursor.
536    /// Callback work/allocation belongs to the caller; filter/limits match `page`.
537    pub fn visit_page<'a>(
538        &'a self,
539        filter: Filter<'_>,
540        request: PageRequest,
541        mut visit: impl FnMut(&'a Entry) -> ControlFlow<()>,
542    ) -> PageScan {
543        let mut cursor = request.before.map_or(self.entries.len(), |sequence| {
544            self.entries
545                .binary_search_by_key(&sequence, |entry| entry.sequence)
546                .unwrap_or_else(|index| index)
547        });
548        let mut matched = 0;
549        let mut scanned = 0;
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            if filter.matches(entry) {
558                matched += 1;
559                if visit(entry).is_break() {
560                    break;
561                }
562            }
563        }
564        PageScan {
565            matched,
566            scanned,
567            // Nonzero limits guarantee a visit whenever the initial cursor is nonzero.
568            // The cursor now identifies the last consumed candidate.
569            next_before: if cursor > 0 {
570                self.entries.get(cursor).map(|entry| entry.sequence)
571            } else {
572                None
573            },
574        }
575    }
576
577    const fn validate_label(&self, label: Label, text: &str) -> Result<(), AppendError> {
578        if text.len() > self.limits.max_label_bytes {
579            return Err(AppendError::LabelTooLong {
580                label,
581                actual_bytes: text.len(),
582                max_bytes: self.limits.max_label_bytes,
583            });
584        }
585        Ok(())
586    }
587
588    fn expired_before(&self, cutoff: u64) -> bool {
589        self.entries
590            .front()
591            .is_some_and(|entry| entry.created_at_secs < cutoff)
592    }
593
594    fn remove_oldest(&mut self) -> bool {
595        let Some(entry) = self.entries.pop_front() else {
596            return false;
597        };
598        // Each component was admitted into the cached sum before insertion.
599        for bytes in text_lengths(&entry.source, entry.topic.as_deref(), entry.message.len()) {
600            self.retained_text_bytes -= bytes;
601        }
602        true
603    }
604}
605
606// One retained-text definition; each caller keeps its own overflow/admission boundary.
607fn text_lengths(source: &str, topic: Option<&str>, message_bytes: usize) -> [usize; 3] {
608    [source.len(), topic.map_or(0, str::len), message_bytes]
609}
610
611fn validate_snapshot(snapshot: &LogSnapshot) -> Result<usize, RestoreError> {
612    let LogSnapshot {
613        limits,
614        entries,
615        next_sequence,
616        last_created_at_secs,
617    } = snapshot;
618    let (next_sequence, last_created_at_secs) = (*next_sequence, *last_created_at_secs);
619    if entries.len() > limits.max_entries.get() {
620        return Err(RestoreError::TooManyEntries);
621    }
622    if (next_sequence == Some(0)) != last_created_at_secs.is_none() {
623        return Err(RestoreError::InvalidTimestamp);
624    }
625    // The last row cannot expire at the final representable timestamp, and
626    // count eviction always leaves a newer row in this nonzero-capacity log.
627    if entries.is_empty() && last_created_at_secs == Some(u64::MAX) {
628        return Err(RestoreError::InvalidTimestamp);
629    }
630    let mut previous: Option<&Entry> = None;
631    let mut retained_text_bytes = 0_usize;
632    for entry in entries {
633        if entry.source.len() > limits.max_label_bytes
634            || entry
635                .topic
636                .as_ref()
637                .is_some_and(|topic| topic.len() > limits.max_label_bytes)
638            || !valid_retained_message(entry, limits.max_message_bytes)
639        {
640            return Err(RestoreError::InvalidEntry);
641        }
642        for bytes in text_lengths(&entry.source, entry.topic.as_deref(), entry.message.len()) {
643            retained_text_bytes = retained_text_bytes
644                .checked_add(bytes)
645                .filter(|total| *total <= limits.max_text_bytes)
646                .ok_or(RestoreError::TextBudgetExceeded)?;
647        }
648        if let Some(previous) = previous {
649            if previous.sequence.checked_add(1) != Some(entry.sequence) {
650                return Err(RestoreError::InvalidSequence);
651            }
652            if entry.created_at_secs < previous.created_at_secs {
653                return Err(RestoreError::InvalidTimestamp);
654            }
655        }
656        previous = Some(entry);
657    }
658    if let Some(last) = previous {
659        if last.sequence.checked_add(1) != next_sequence {
660            return Err(RestoreError::InvalidSequence);
661        }
662        if Some(last.created_at_secs) != last_created_at_secs {
663            return Err(RestoreError::InvalidTimestamp);
664        }
665    }
666    Ok(retained_text_bytes)
667}
668
669const fn truncation_suffix(max_bytes: usize) -> &'static str {
670    if max_bytes > TRUNCATION_SUFFIX.len() {
671        TRUNCATION_SUFFIX
672    } else {
673        ""
674    }
675}
676
677fn valid_retained_message(entry: &Entry, max_bytes: usize) -> bool {
678    if entry.message.len() > max_bytes {
679        return false;
680    }
681    if !entry.truncated {
682        return true;
683    }
684    let suffix = truncation_suffix(max_bytes);
685    let Some(prefix) = entry.message.strip_suffix(suffix) else {
686        return false;
687    };
688    let budget = max_bytes - suffix.len();
689    budget.saturating_sub(prefix.len()) < 4
690}
691
692fn bounded_message(prefix: &str, suffix: &str) -> String {
693    let mut retained = String::with_capacity(prefix.len() + suffix.len());
694    retained.push_str(prefix);
695    retained.push_str(suffix);
696    retained
697}
698
699fn message_parts(message: &str, max_bytes: usize) -> (&str, &'static str) {
700    if message.len() <= max_bytes {
701        return (message, "");
702    }
703    let suffix = truncation_suffix(max_bytes);
704    let mut end = max_bytes - suffix.len();
705    while !message.is_char_boundary(end) {
706        end -= 1;
707    }
708    (&message[..end], suffix)
709}
710
711#[cfg(test)]
712mod tests;