Skip to main content

scv_core/
progress.rs

1//! Short status lines a running tool reports for display, bounded and paced.
2
3use std::{
4    collections::VecDeque,
5    fmt,
6    sync::{Arc, Mutex, PoisonError},
7    time::Duration,
8};
9
10/// Longest progress line a tool can report; longer lines are cut.
11pub const MAX_PROGRESS_LINE_BYTES: usize = 200;
12/// Largest progress event: the newest lines reported since the previous
13/// event, with older ones dropped first.
14pub const MAX_PROGRESS_EVENT_BYTES: usize = 512;
15/// Minimum spacing of one call's progress events (at most two a second).
16pub(crate) const PROGRESS_INTERVAL: Duration = Duration::from_millis(500);
17
18/// Where a running tool reports short status lines, such as a delegated
19/// agent's commands. Each report becomes one bounded line; the runtime
20/// forwards the pending lines to the client at most twice a second and never
21/// adds them to the model's history. The default sink discards reports, so a
22/// tool may always report.
23#[derive(Clone, Default)]
24pub struct ProgressSink {
25    pending: Option<Arc<Mutex<PendingProgress>>>,
26}
27
28impl fmt::Debug for ProgressSink {
29    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30        formatter
31            .debug_struct("ProgressSink")
32            .field("enabled", &self.is_enabled())
33            .finish()
34    }
35}
36
37impl ProgressSink {
38    /// A sink that keeps reports until the runtime takes them.
39    pub fn buffered() -> Self {
40        Self {
41            pending: Some(Arc::default()),
42        }
43    }
44
45    pub fn is_enabled(&self) -> bool {
46        self.pending.is_some()
47    }
48
49    /// Report one status line. Control characters and line breaks become
50    /// spaces and the line is cut to `MAX_PROGRESS_LINE_BYTES`.
51    pub fn report(&self, text: &str) {
52        let Some(pending) = &self.pending else {
53            return;
54        };
55        let line = progress_line(text);
56        if !line.is_empty() {
57            pending
58                .lock()
59                .unwrap_or_else(PoisonError::into_inner)
60                .push(line);
61        }
62    }
63
64    /// The lines reported since the previous call, as one event text of at
65    /// most `MAX_PROGRESS_EVENT_BYTES`, or `None` when nothing is pending.
66    pub fn take(&self) -> Option<String> {
67        self.pending
68            .as_ref()?
69            .lock()
70            .unwrap_or_else(PoisonError::into_inner)
71            .take()
72    }
73}
74
75/// Marker for lines dropped from the front of an event.
76const PROGRESS_ELIDED: &str = "…";
77
78#[derive(Debug, Default)]
79struct PendingProgress {
80    lines: VecDeque<String>,
81    /// Joined length of `lines`, separators included.
82    bytes: usize,
83    dropped: bool,
84}
85
86impl PendingProgress {
87    fn push(&mut self, line: String) {
88        self.bytes += line.len() + usize::from(!self.lines.is_empty());
89        self.lines.push_back(line);
90        // Leave room for the elision marker and its separator.
91        let budget = MAX_PROGRESS_EVENT_BYTES - PROGRESS_ELIDED.len() - 1;
92        while self.bytes > budget && self.lines.len() > 1 {
93            if let Some(oldest) = self.lines.pop_front() {
94                self.bytes -= oldest.len() + 1;
95                self.dropped = true;
96            }
97        }
98    }
99
100    fn take(&mut self) -> Option<String> {
101        if self.lines.is_empty() {
102            return None;
103        }
104        let mut text = String::with_capacity(self.bytes + PROGRESS_ELIDED.len() + 1);
105        if std::mem::take(&mut self.dropped) {
106            text.push_str(PROGRESS_ELIDED);
107            text.push('\n');
108        }
109        for (index, line) in self.lines.drain(..).enumerate() {
110            if index > 0 {
111                text.push('\n');
112            }
113            text.push_str(&line);
114        }
115        self.bytes = 0;
116        Some(text)
117    }
118}
119
120/// One bounded display line: control characters become spaces, runs of
121/// whitespace collapse, and the result is cut on a character boundary.
122fn progress_line(text: &str) -> String {
123    let mut line = String::new();
124    for word in text
125        .split(|character: char| character.is_whitespace() || character.is_control())
126        .filter(|word| !word.is_empty())
127    {
128        if !line.is_empty() {
129            line.push(' ');
130        }
131        line.push_str(word);
132        if line.len() > MAX_PROGRESS_LINE_BYTES {
133            break;
134        }
135    }
136    if line.len() <= MAX_PROGRESS_LINE_BYTES {
137        return line;
138    }
139    let mut end = MAX_PROGRESS_LINE_BYTES - PROGRESS_ELIDED.len();
140    while !line.is_char_boundary(end) {
141        end -= 1;
142    }
143    line.truncate(end);
144    line.push_str(PROGRESS_ELIDED);
145    line
146}
147
148#[cfg(test)]
149mod tests;