Livre le lot backend B8 des tâches de fond : - infrastructure : runner de commandes concret (CommandBackgroundRunner) sur le port BackgroundTaskRunner, tail borné (bounded_tail/BoundedTail), éclatement du module background_task en sous-modules (mod/runner/tail, sink extrait de l'ancien background_task.rs). - application : nouveau module background exposant les cas d'usage SpawnBackgroundCommand, CancelBackgroundTask, RetryBackgroundTask et le port BackgroundCommandArchive. - domain : refactor point-2 de l'arbitrage Architect — sortie du trait BackgroundCommandArchive de la couche domaine vers application. - app-tauri : câblage runtime (commands, dto, state, lib) des commandes spawn/cancel/retry et de la boucle de complétion sink fermée en composition root. Build workspace + tests application/infrastructure verts (QA). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
4.9 KiB
Rust
150 lines
4.9 KiB
Rust
//! UTF-8-safe bounded output tail (B8 sub-task 1).
|
|
//!
|
|
//! A background command can print megabytes; the completion contract only keeps a
|
|
//! bounded *tail* (the most recent bytes). [`BoundedTail`] is a byte ring buffer
|
|
//! capped at construction; [`bounded_tail`] renders the retained bytes as a
|
|
//! `String`, trimming any partial UTF-8 sequence at the front so a multi-byte
|
|
//! character never gets sliced by the cap.
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use domain::background_task::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES;
|
|
|
|
/// Default tail cap when `IDEA_BG_TAIL_BYTES` is unset (8 KiB).
|
|
const DEFAULT_TAIL_BYTES: usize = 8 * 1024;
|
|
/// Floor so a hostile/typo'd env value can't disable the tail entirely.
|
|
const MIN_TAIL_BYTES: usize = 1024;
|
|
|
|
/// Resolves the tail cap in bytes from `IDEA_BG_TAIL_BYTES`.
|
|
///
|
|
/// Defaults to 8 KiB, floored at 1 KiB, and clamped to the domain maximum
|
|
/// ([`BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES`], 16 KiB) so the persisted result
|
|
/// always validates.
|
|
#[must_use]
|
|
pub fn tail_cap_bytes() -> usize {
|
|
std::env::var("IDEA_BG_TAIL_BYTES")
|
|
.ok()
|
|
.and_then(|v| v.trim().parse::<usize>().ok())
|
|
.unwrap_or(DEFAULT_TAIL_BYTES)
|
|
.clamp(MIN_TAIL_BYTES, BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES)
|
|
}
|
|
|
|
/// A byte ring buffer that retains only the last `cap` bytes pushed into it.
|
|
#[derive(Debug)]
|
|
pub struct BoundedTail {
|
|
buf: VecDeque<u8>,
|
|
cap: usize,
|
|
}
|
|
|
|
impl BoundedTail {
|
|
/// Builds a tail retaining at most `cap` bytes (a `cap` of `0` retains
|
|
/// nothing but never panics).
|
|
#[must_use]
|
|
pub fn with_cap(cap: usize) -> Self {
|
|
Self {
|
|
buf: VecDeque::new(),
|
|
cap,
|
|
}
|
|
}
|
|
|
|
/// Appends a chunk, dropping the oldest bytes past the cap.
|
|
pub fn push(&mut self, chunk: &[u8]) {
|
|
if self.cap == 0 {
|
|
return;
|
|
}
|
|
// If the incoming chunk alone exceeds the cap, only its tail can survive.
|
|
let start = chunk.len().saturating_sub(self.cap);
|
|
self.buf.extend(&chunk[start..]);
|
|
let overflow = self.buf.len().saturating_sub(self.cap);
|
|
if overflow > 0 {
|
|
self.buf.drain(0..overflow);
|
|
}
|
|
}
|
|
|
|
/// Returns whether any byte is retained.
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.buf.is_empty()
|
|
}
|
|
|
|
/// Renders the retained bytes as a UTF-8 string, dropping a leading partial
|
|
/// multi-byte sequence left dangling by the cap. Returns `None` when empty.
|
|
#[must_use]
|
|
pub fn to_tail_string(&self) -> Option<String> {
|
|
if self.buf.is_empty() {
|
|
return None;
|
|
}
|
|
let bytes: Vec<u8> = self.buf.iter().copied().collect();
|
|
Some(bounded_tail(&bytes, self.cap))
|
|
}
|
|
}
|
|
|
|
/// Renders the last `cap` bytes of `bytes` as a lossless-at-the-boundary
|
|
/// `String`.
|
|
///
|
|
/// The slice is taken from the end, then any partial UTF-8 sequence at the *front*
|
|
/// (a continuation byte with no lead byte, because the cap cut mid-character) is
|
|
/// skipped so the result is valid UTF-8 without replacement characters at the
|
|
/// seam. Invalid bytes in the interior are still replaced lossily.
|
|
#[must_use]
|
|
pub fn bounded_tail(bytes: &[u8], cap: usize) -> String {
|
|
let start = bytes.len().saturating_sub(cap);
|
|
let mut slice = &bytes[start..];
|
|
// Only realign when we actually truncated the front (start > 0): otherwise a
|
|
// legitimately leading continuation byte is just invalid input, handled below.
|
|
if start > 0 {
|
|
let mut skip = 0;
|
|
while skip < slice.len() && is_utf8_continuation(slice[skip]) {
|
|
skip += 1;
|
|
}
|
|
slice = &slice[skip..];
|
|
}
|
|
String::from_utf8_lossy(slice).into_owned()
|
|
}
|
|
|
|
/// Whether `b` is a UTF-8 continuation byte (`10xxxxxx`).
|
|
const fn is_utf8_continuation(b: u8) -> bool {
|
|
b & 0b1100_0000 == 0b1000_0000
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn keeps_only_the_last_cap_bytes() {
|
|
let mut tail = BoundedTail::with_cap(4);
|
|
tail.push(b"abcdef");
|
|
assert_eq!(tail.to_tail_string().as_deref(), Some("cdef"));
|
|
}
|
|
|
|
#[test]
|
|
fn push_bigger_than_cap_keeps_tail() {
|
|
let mut tail = BoundedTail::with_cap(3);
|
|
tail.push(b"0123456789");
|
|
assert_eq!(tail.to_tail_string().as_deref(), Some("789"));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_tail_is_none() {
|
|
let tail = BoundedTail::with_cap(8);
|
|
assert!(tail.is_empty());
|
|
assert_eq!(tail.to_tail_string(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn cut_multibyte_char_is_trimmed_at_front() {
|
|
// "é" is 0xC3 0xA9. Cap of 1 would keep only the trailing 0xA9 (a lone
|
|
// continuation byte); it must be dropped, yielding an empty string.
|
|
let bytes = "é".as_bytes();
|
|
assert_eq!(bounded_tail(bytes, 1), "");
|
|
// Cap of 2 keeps the whole character.
|
|
assert_eq!(bounded_tail(bytes, 2), "é");
|
|
}
|
|
|
|
#[test]
|
|
fn interior_is_preserved() {
|
|
assert_eq!(bounded_tail("héllo".as_bytes(), 100), "héllo");
|
|
}
|
|
}
|