Skip to main content

artifactize_core/tools/human/
log.rs

1//! The Tool log: what Human tools print while a review or an Artifact's Detail shows them, line
2//! by line as it arrives.
3//!
4//! A screen opens a [`channel`]. Each tool run it starts is a [`Run`] of one log [`Key`]: a
5//! review's, or an Artifact's own for the tools run from its Detail. Its output lines and its
6//! end reach the channel as [`Event`]s, a launched program's for as long as it runs. A run keeps
7//! draining its program's output whether or not anyone still receives; what nobody shows is
8//! discarded. Nothing is saved.
9
10use std::{
11    path::PathBuf,
12    sync::{
13        Arc,
14        atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
15    },
16};
17
18use time::OffsetDateTime;
19use tokio::sync::mpsc;
20
21use crate::types::{ArtifactName, RequestId};
22
23/// Whose Tool log a run belongs to.
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub enum Key {
26    /// A Human review's: the tools its reviewer ran.
27    Review(RequestId),
28    /// An Artifact's own, in the workspace at `repo`: the tools run from its Detail, for no
29    /// review.
30    Artifact {
31        repo: PathBuf,
32        artifact: ArtifactName,
33    },
34}
35
36impl Key {
37    /// The review this log belongs to, if it is a review's.
38    pub fn request(&self) -> Option<&RequestId> {
39        match self {
40            Self::Review(request) => Some(request),
41            Self::Artifact { .. } => None,
42        }
43    }
44}
45
46impl From<&RequestId> for Key {
47    fn from(request: &RequestId) -> Self {
48        Self::Review(request.clone())
49    }
50}
51
52/// Output bytes one run shows at most: a launched program may print for hours, and the log
53/// is held in memory for as long as the review is open.
54const RUN_BYTES: usize = 1024 * 1024;
55/// A line longer than this is shown in pieces, so output without newlines still appears.
56const LINE_BYTES: usize = 4096;
57/// Shown once, as an error line, when a run printed more than the log keeps.
58const DROPPED: &str = "[more output than the Tool log keeps; the rest is not shown]";
59
60/// Numbers every run of this process, so lines arriving later find their own entry.
61static NEXT_RUN: AtomicU64 = AtomicU64::new(1);
62
63/// One tool run, unique in this process.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
65pub struct RunNo(u64);
66
67impl RunNo {
68    pub fn get(self) -> u64 {
69        self.0
70    }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Stream {
75    Stdout,
76    Stderr,
77}
78
79/// How a run ended, as its last log line says.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum End {
82    /// The program exited: its exit code, or how it ended without one.
83    Exited {
84        code: Option<i32>,
85        text: String,
86    },
87    /// Handed to the desktop's own opener; its output is not the tool's.
88    Launched,
89    /// A builtin finished.
90    Done,
91    Cancelled,
92    TimedOut,
93    /// The run could not start or failed, and why.
94    Failed(String),
95}
96
97impl End {
98    pub(crate) fn exited(status: &std::process::ExitStatus) -> Self {
99        Self::Exited {
100            code: match crate::platform::process_end(status) {
101                crate::platform::ProcessEnd::Exited(code) => Some(code),
102                crate::platform::ProcessEnd::Signaled(_) => None,
103            },
104            text: crate::platform::exit_description(status),
105        }
106    }
107
108    /// The end line: `■ exit 0`, `■ launched`, `■ error: …`.
109    pub fn text(&self) -> String {
110        match self {
111            Self::Exited {
112                code: Some(code), ..
113            } => format!("■ exit {code}"),
114            Self::Exited { text, .. } => format!("■ {text}"),
115            Self::Launched => "■ launched".into(),
116            Self::Done => "■ done".into(),
117            Self::Cancelled => "■ cancelled".into(),
118            Self::TimedOut => "■ timed out".into(),
119            Self::Failed(error) => format!("■ error: {error}"),
120        }
121    }
122
123    /// The run did not end well.
124    pub fn error(&self) -> bool {
125        !matches!(
126            self,
127            Self::Exited { code: Some(0), .. } | Self::Launched | Self::Done
128        )
129    }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum Kind {
134    Start {
135        at: OffsetDateTime,
136        operation: String,
137        /// The Artifact that declares the tool.
138        artifact: String,
139    },
140    Line {
141        stream: Stream,
142        text: String,
143    },
144    End(End),
145}
146
147/// One thing a run logged, for the log of `key`.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Event {
150    pub key: Key,
151    pub run: RunNo,
152    pub kind: Kind,
153}
154
155/// Where runs send their events; cloned into each job a review starts.
156#[derive(Debug, Clone)]
157pub struct Sink(mpsc::UnboundedSender<Event>);
158
159/// The review's end of the log. Every run bounds its output, so the channel is unbounded.
160pub type Events = mpsc::UnboundedReceiver<Event>;
161
162pub fn channel() -> (Sink, Events) {
163    let (sender, receiver) = mpsc::unbounded_channel();
164    (Sink(sender), receiver)
165}
166
167impl Sink {
168    /// Start a run of `operation` of `artifact` in the log of `key`: its start line is logged
169    /// now.
170    pub fn start(&self, key: impl Into<Key>, operation: &str, artifact: &str) -> Run {
171        let run = Run::new(Some((self.clone(), key.into())));
172        run.shared.send(Kind::Start {
173            at: OffsetDateTime::now_utc(),
174            operation: operation.to_owned(),
175            artifact: artifact.to_owned(),
176        });
177        run
178    }
179}
180
181struct Shared {
182    /// Where the run's events go and the log they belong to; `None` when nobody watches.
183    watch: Option<(Sink, Key)>,
184    run: RunNo,
185    /// Output bytes this run may still show.
186    left: AtomicUsize,
187    dropped: AtomicBool,
188}
189
190impl Shared {
191    fn send(&self, kind: Kind) {
192        if let Some((sink, key)) = &self.watch {
193            // A closed review discards what its runs still print.
194            let _ = sink.0.send(Event {
195                key: key.clone(),
196                run: self.run,
197                kind,
198            });
199        }
200    }
201
202    fn line(&self, stream: Stream, bytes: &[u8]) {
203        if !self.watched() {
204            return;
205        }
206        let text =
207            String::from_utf8(crate::runtime::clean_output(bytes)).expect("clean output is UTF-8");
208        let size = text.len().max(1);
209        // A compare-and-swap loop rather than `fetch_update`, which newer toolchains
210        // deprecate under another name.
211        let mut left = self.left.load(Ordering::Relaxed);
212        let kept = loop {
213            let Some(next) = left.checked_sub(size) else {
214                break false;
215            };
216            match self
217                .left
218                .compare_exchange_weak(left, next, Ordering::Relaxed, Ordering::Relaxed)
219            {
220                Ok(_) => break true,
221                Err(current) => left = current,
222            }
223        };
224        if kept {
225            self.send(Kind::Line { stream, text });
226        } else if !self.dropped.swap(true, Ordering::Relaxed) {
227            self.send(Kind::Line {
228                stream: Stream::Stderr,
229                text: DROPPED.into(),
230            });
231        }
232    }
233}
234
235/// One tool run's log: lines while it runs, then exactly one end. A run dropped without an
236/// end was cancelled.
237pub struct Run {
238    shared: Arc<Shared>,
239    ended: bool,
240}
241
242impl std::fmt::Debug for Run {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_struct("Run")
245            .field("run", &self.shared.run)
246            .finish()
247    }
248}
249
250impl Run {
251    fn new(watch: Option<(Sink, Key)>) -> Self {
252        Self {
253            shared: Arc::new(Shared {
254                watch,
255                run: RunNo(NEXT_RUN.fetch_add(1, Ordering::Relaxed)),
256                left: AtomicUsize::new(RUN_BYTES),
257                dropped: AtomicBool::new(false),
258            }),
259            ended: false,
260        }
261    }
262
263    /// A run nobody shows, such as `tool check`'s: launched programs get no pipes at all.
264    pub fn unwatched() -> Self {
265        Self::new(None)
266    }
267
268    pub(crate) fn watched(&self) -> bool {
269        self.shared.watched()
270    }
271
272    /// A splitter of one output stream into this run's lines.
273    pub(crate) fn lines(&self, stream: Stream) -> Lines {
274        Lines {
275            shared: self.shared.clone(),
276            stream,
277            pending: Vec::new(),
278        }
279    }
280
281    /// Log `text`, a finished output, line by line.
282    pub fn text(&self, stream: Stream, text: &str) {
283        let mut lines = self.lines(stream);
284        lines.push(text.as_bytes());
285        lines.finish();
286    }
287
288    pub fn end(mut self, end: End) {
289        self.ended = true;
290        self.shared.send(Kind::End(end));
291    }
292}
293
294impl Drop for Run {
295    fn drop(&mut self) {
296        if !self.ended {
297            self.shared.send(Kind::End(End::Cancelled));
298        }
299    }
300}
301
302/// Bytes of one stream cut into lines: at each newline, and in pieces of [`LINE_BYTES`].
303pub(crate) struct Lines {
304    shared: Arc<Shared>,
305    stream: Stream,
306    pending: Vec<u8>,
307}
308
309impl Lines {
310    pub(crate) fn push(&mut self, bytes: &[u8]) {
311        if !self.shared.watched() {
312            return;
313        }
314        self.pending.extend_from_slice(bytes);
315        while let Some(newline) = self.pending.iter().position(|byte| *byte == b'\n') {
316            let line: Vec<u8> = self.pending.drain(..=newline).collect();
317            let line = &line[..newline];
318            self.shared
319                .line(self.stream, line.strip_suffix(b"\r").unwrap_or(line));
320        }
321        while self.pending.len() >= LINE_BYTES {
322            let end = piece(&self.pending);
323            let piece: Vec<u8> = self.pending.drain(..end).collect();
324            self.shared.line(self.stream, &piece);
325        }
326    }
327
328    /// The stream closed: its last line, if it had no newline.
329    pub(crate) fn finish(&mut self) {
330        if !self.pending.is_empty() {
331            let line = std::mem::take(&mut self.pending);
332            self.shared.line(self.stream, &line);
333        }
334    }
335}
336
337impl Shared {
338    fn watched(&self) -> bool {
339        self.watch.is_some()
340    }
341}
342
343/// Where a long line's first piece ends: [`LINE_BYTES`] in, moved back so that a character
344/// cut in two stays whole for the next piece.
345fn piece(bytes: &[u8]) -> usize {
346    let end = LINE_BYTES.min(bytes.len());
347    match std::str::from_utf8(&bytes[..end]) {
348        Err(error) if error.error_len().is_none() && error.valid_up_to() > 0 => error.valid_up_to(),
349        _ => end,
350    }
351}
352
353/// The Tool log of one review as a screen shows it: one entry per run, in the order they
354/// started, each with its output and, once known, its end.
355#[derive(Debug, Clone, Default, PartialEq, Eq)]
356pub struct ToolLog {
357    entries: Vec<Entry>,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
361pub struct Entry {
362    pub run: RunNo,
363    pub at: OffsetDateTime,
364    pub operation: String,
365    pub artifact: String,
366    pub lines: Vec<(Stream, String)>,
367    pub end: Option<End>,
368}
369
370impl Entry {
371    /// The start line, `▶ HH:MM:SS · operation · Artifact`, at `offset` from UTC.
372    pub fn start_text(&self, offset: time::UtcOffset) -> String {
373        start_text(
374            self.at.to_offset(offset).time(),
375            &self.operation,
376            &self.artifact,
377        )
378    }
379}
380
381/// `▶ HH:MM:SS · operation · Artifact`.
382pub fn start_text(at: time::Time, operation: &str, artifact: &str) -> String {
383    format!(
384        "▶ {:02}:{:02}:{:02} · {operation} · {artifact}",
385        at.hour(),
386        at.minute(),
387        at.second()
388    )
389}
390
391impl ToolLog {
392    /// Take `event` in: a start opens an entry; lines and the end join their run's entry.
393    /// Lines of a run this log never saw start (it was cleared since) are dropped.
394    pub fn apply(&mut self, event: Event) {
395        match event.kind {
396            Kind::Start {
397                at,
398                operation,
399                artifact,
400            } => self.entries.push(Entry {
401                run: event.run,
402                at,
403                operation,
404                artifact,
405                lines: Vec::new(),
406                end: None,
407            }),
408            kind => {
409                let Some(entry) = self.entries.iter_mut().find(|entry| entry.run == event.run)
410                else {
411                    return;
412                };
413                match kind {
414                    Kind::Line { stream, text } => entry.lines.push((stream, text)),
415                    Kind::End(end) => entry.end = Some(end),
416                    Kind::Start { .. } => unreachable!("handled above"),
417                }
418            }
419        }
420    }
421
422    pub fn entries(&self) -> &[Entry] {
423        &self.entries
424    }
425
426    pub fn is_empty(&self) -> bool {
427        self.entries.is_empty()
428    }
429
430    pub fn clear(&mut self) {
431        self.entries.clear();
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn request() -> RequestId {
440        "req-1".parse().unwrap()
441    }
442
443    fn drain(events: &mut Events) -> Vec<Kind> {
444        std::iter::from_fn(|| events.try_recv().ok())
445            .map(|event| event.kind)
446            .collect()
447    }
448
449    fn line(stream: Stream, text: &str) -> Kind {
450        Kind::Line {
451            stream,
452            text: text.into(),
453        }
454    }
455
456    #[test]
457    fn output_arrives_as_lines_of_its_own_stream() {
458        let (sink, mut events) = channel();
459        let run = sink.start(&request(), "open", "notes");
460        let mut out = run.lines(Stream::Stdout);
461        let mut err = run.lines(Stream::Stderr);
462        out.push(b"one\r\ntw");
463        err.push(b"warn\n");
464        out.push(b"o\nthree");
465        out.finish();
466        err.finish();
467        run.end(End::Exited {
468            code: Some(0),
469            text: String::new(),
470        });
471        let kinds = drain(&mut events);
472        assert!(matches!(&kinds[0], Kind::Start { operation, artifact, .. }
473            if operation == "open" && artifact == "notes"));
474        assert_eq!(
475            kinds[1..],
476            [
477                line(Stream::Stdout, "one"),
478                line(Stream::Stderr, "warn"),
479                line(Stream::Stdout, "two"),
480                line(Stream::Stdout, "three"),
481                Kind::End(End::Exited {
482                    code: Some(0),
483                    text: String::new()
484                }),
485            ]
486        );
487    }
488
489    #[test]
490    fn a_line_without_newline_shows_in_pieces_that_keep_characters_whole() {
491        let (sink, mut events) = channel();
492        let run = sink.start(&request(), "dump", "app");
493        let mut out = run.lines(Stream::Stdout);
494        let mut text = "a".repeat(LINE_BYTES - 1);
495        text.push('é');
496        out.push(text.as_bytes());
497        let kinds = drain(&mut events);
498        assert_eq!(
499            kinds[1..],
500            [line(Stream::Stdout, &"a".repeat(LINE_BYTES - 1))]
501        );
502        out.finish();
503        assert_eq!(drain(&mut events), [line(Stream::Stdout, "é")]);
504    }
505
506    #[test]
507    fn a_run_shows_its_output_up_to_its_budget_then_says_the_rest_is_not_shown() {
508        let (sink, mut events) = channel();
509        let run = sink.start(&request(), "spew", "app");
510        let chunk = format!("{}\n", "x".repeat(LINE_BYTES - 1));
511        for _ in 0..(RUN_BYTES / (LINE_BYTES - 1) + 10) {
512            run.lines(Stream::Stdout).push(chunk.as_bytes());
513        }
514        let kinds = drain(&mut events);
515        let shown: usize = kinds
516            .iter()
517            .filter_map(|kind| match kind {
518                Kind::Line {
519                    stream: Stream::Stdout,
520                    text,
521                } => Some(text.len()),
522                _ => None,
523            })
524            .sum();
525        assert!(shown <= RUN_BYTES);
526        assert_eq!(kinds.last(), Some(&line(Stream::Stderr, DROPPED)));
527        assert_eq!(
528            kinds
529                .iter()
530                .filter(|kind| **kind == line(Stream::Stderr, DROPPED))
531                .count(),
532            1
533        );
534    }
535
536    #[test]
537    fn a_run_dropped_without_an_end_was_cancelled_and_a_closed_review_discards_lines() {
538        let (sink, mut events) = channel();
539        drop(sink.start(&request(), "slow", "app"));
540        assert_eq!(drain(&mut events).last(), Some(&Kind::End(End::Cancelled)));
541        let run = sink.start(&request(), "late", "app");
542        drop(events);
543        run.text(Stream::Stdout, "nobody reads this\n");
544        run.end(End::Done);
545    }
546
547    #[test]
548    fn the_log_groups_lines_under_their_run_and_ignores_runs_it_never_saw_start() {
549        let (sink, mut events) = channel();
550        let first = sink.start(&request(), "launch", "app");
551        let second = sink.start(&request(), "check", "lib");
552        second.text(Stream::Stdout, "checked\n");
553        second.end(End::Done);
554        first.text(Stream::Stderr, "app says\n");
555        let mut log = ToolLog::default();
556        for event in std::iter::from_fn(|| events.try_recv().ok()) {
557            log.apply(event);
558        }
559        let [launch, check] = log.entries() else {
560            panic!("two entries: {log:?}");
561        };
562        assert_eq!(launch.lines, [(Stream::Stderr, "app says".to_owned())]);
563        assert_eq!(launch.end, None);
564        assert_eq!(check.lines, [(Stream::Stdout, "checked".to_owned())]);
565        assert_eq!(check.end, Some(End::Done));
566        log.clear();
567        first.end(End::Done);
568        log.apply(events.try_recv().unwrap());
569        assert!(log.is_empty());
570    }
571
572    #[test]
573    fn end_lines_say_how_a_run_ended() {
574        let exit = |code| End::Exited {
575            code: Some(code),
576            text: String::new(),
577        };
578        assert_eq!(exit(0).text(), "■ exit 0");
579        assert!(!exit(0).error());
580        assert_eq!(exit(2).text(), "■ exit 2");
581        assert!(exit(2).error());
582        assert_eq!(End::Launched.text(), "■ launched");
583        assert_eq!(End::TimedOut.text(), "■ timed out");
584        assert_eq!(End::Failed("no".into()).text(), "■ error: no");
585        assert!(End::Cancelled.error());
586        assert_eq!(
587            start_text(time::Time::from_hms(9, 5, 7).unwrap(), "open", "notes"),
588            "▶ 09:05:07 · open · notes"
589        );
590    }
591}