Skip to main content

zng_task/process/
tap.rs

1//! Helper types for recording stdout/err and parsing the error output.
2//!
3//! Both [`StdoutTap`] and [`StderrTap`] record a child process stream while still propagating to
4//! the parent stream. After the child closes the stream the recording can be converted to string
5//! and parsed to retrieve data such as a panic printout.
6//!
7//! # ANSI Escape Sequences
8//!
9//! Use [`contains_ansi_csi`] and [`remove_ansi_csi`] to convert styled output to plain text.
10//!
11//! # Panic
12//!
13//! Use the [`PanicInfo::find`] to find and parse the last panic printout from stderr. Use [`PanicInfo::set_hook`]
14//! on the child process to ensure the panic message is formatted in a compatible way.
15
16use std::{
17    collections::VecDeque,
18    fmt,
19    io::{self, BufRead as _, Read, Write as _},
20    mem,
21    process::{ChildStderr, ChildStdout},
22};
23
24use futures_lite::{AsyncRead, AsyncReadExt};
25use zng_txt::{ToTxt as _, Txt, formatx};
26
27/// Record stdout of a child process while also passing though the output to the running process output.
28///
29/// Both blocking and async APIs are provided, the blocking API is slightly more efficient.
30pub struct StdoutTap(StdTap<false>);
31impl fmt::Debug for StdoutTap {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.debug_tuple("StdoutTap").finish_non_exhaustive()
34    }
35}
36impl StdoutTap {
37    /// Start recording and passing.
38    pub fn new_blocking(stream: ChildStdout) -> Self {
39        Self(StdTap::new_blocking(stream))
40    }
41
42    /// Start recording and passing.
43    pub fn new(stream: super::ChildStdout) -> Self {
44        Self(StdTap::new(stream))
45    }
46}
47
48/// Record stderr of a child process while also passing though the output to the running process output.
49///
50/// Both blocking and async APIs are provided, the blocking API is slightly more efficient.
51pub struct StderrTap(StdTap<true>);
52impl fmt::Debug for StderrTap {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.debug_tuple("StderrTap").finish_non_exhaustive()
55    }
56}
57impl StderrTap {
58    /// Start recording and passing.
59    pub fn new_blocking(stream: ChildStderr) -> Self {
60        Self(StdTap::new_blocking(stream))
61    }
62
63    /// Start recording and passing.
64    pub fn new(stream: super::ChildStderr) -> Self {
65        Self(StdTap::new(stream))
66    }
67
68    /// Block until the child process closes stderr and attempts to parse the last panic info from it.
69    ///
70    /// If cannot find a panic returns `Err` with the captured stderr converted to [`Txt`].
71    ///
72    /// Note that the exit code for a fatal panic is `101`, checking the exit code is the reliable
73    /// way to verify the child process exited due to panic.
74    pub fn into_panic_blocking(self) -> Result<PanicInfo, Txt> {
75        let s = self.into_string_blocking(false);
76        match PanicInfo::find(&s) {
77            Some(p) => Ok(p),
78            None => Err(s.into()),
79        }
80    }
81
82    /// Await until the child process closes stderr and attempts to parse the last panic info from it.
83    ///
84    /// If cannot find a panic returns `Err` with the captured stderr converted to [`Txt`].
85    ///
86    /// Note that the exit code for a fatal panic is `101`, checking the exit code is the reliable
87    /// way to verify the child process exited due to panic.
88    pub async fn into_panic(self) -> Result<PanicInfo, Txt> {
89        blocking::unblock(move || self.into_panic_blocking()).await
90    }
91}
92
93macro_rules! impl_common {
94    ($($StreamTap:ident;)+) => {
95        $(
96impl $StreamTap {
97    /// Placeholder tap that records nothing.
98    pub fn dummy() -> Self {
99        Self(StdTap::dummy())
100    }
101
102    /// Block until the child process closes the stream and converts the capture to [`String`].
103    pub fn into_string_blocking(self, remove_ansi_csi: bool) -> String {
104        let s = deque_to_string(self.0.capture());
105        if remove_ansi_csi && contains_ansi_csi(&s) {
106            self::remove_ansi_csi_str(&s)
107        } else {
108            s
109        }
110    }
111
112    /// Await until the child process closes the stream and converts the capture to [`String`].
113    pub async fn into_string(self, remove_ansi_csi: bool) -> String {
114        blocking::unblock(move || self.into_string_blocking(remove_ansi_csi)).await
115    }
116
117    /// Block until the child process closes the stream and converts the capture to [`Txt`].
118    pub fn into_txt_blocking(self, remove_ansi_csi: bool) -> Txt {
119        self.into_string_blocking(remove_ansi_csi).into()
120    }
121
122    /// Await until the child process closes the stream and converts the capture to [`Txt`].
123    pub async fn into_txt(self, remove_ansi_csi: bool) -> Txt {
124        blocking::unblock(move || self.into_txt_blocking(remove_ansi_csi)).await
125    }
126}
127        )+
128    };
129}
130impl_common! {
131    StdoutTap;
132    StderrTap;
133}
134
135struct StdTap<const E: bool>(Option<std::thread::JoinHandle<VecDeque<u8>>>);
136
137impl<const E: bool> StdTap<E> {
138    fn new_blocking(std_stream: impl Read + Send + 'static) -> Self {
139        Self(Some(tap(std_stream, E)))
140    }
141
142    fn new(stream: impl AsyncRead + Send + Unpin + 'static) -> Self {
143        Self(Some(tap_async(stream, E)))
144    }
145
146    fn dummy() -> Self {
147        Self(None)
148    }
149
150    fn capture(self) -> VecDeque<u8> {
151        match self.0 {
152            Some(j) => match j.join() {
153                Ok(d) => d,
154                Err(p) => std::panic::resume_unwind(p),
155            },
156            None => VecDeque::new(),
157        }
158    }
159}
160
161fn tap(mut stream: impl Read + Send + 'static, is_err: bool) -> std::thread::JoinHandle<VecDeque<u8>> {
162    tap_thread(is_err)
163        .spawn(move || tap_read_loop(&mut stream, is_err))
164        .expect("failed to spawn thread")
165}
166fn tap_thread(is_err: bool) -> std::thread::Builder {
167    std::thread::Builder::new()
168        .name(format!("{}-reader", if is_err { "stderr" } else { "stdout" }))
169        .stack_size(256 * 1024)
170}
171fn tap_read_loop(stream: &mut dyn Read, is_err: bool) -> VecDeque<u8> {
172    let mut tap = Tap::new();
173    loop {
174        let r = stream.read(&mut tap.buffer);
175        if tap.push(r, is_err) {
176            break;
177        }
178    }
179    tap.rec
180}
181
182fn tap_async(mut stream: impl AsyncRead + Send + Unpin + 'static, is_err: bool) -> std::thread::JoinHandle<VecDeque<u8>> {
183    tap_thread(is_err)
184        .spawn(move || tap_async_read_loop(&mut stream, is_err))
185        .expect("failed to spawn thread")
186}
187
188fn tap_async_read_loop(stream: &mut (dyn AsyncRead + Unpin), is_err: bool) -> VecDeque<u8> {
189    let mut tap = Tap::new();
190    loop {
191        let r = crate::block_on(stream.read(&mut tap.buffer));
192        if tap.push(r, is_err) {
193            break;
194        }
195    }
196    tap.rec
197}
198struct Tap {
199    rec: VecDeque<u8>,
200    buffer: [u8; 16_384],
201}
202impl Tap {
203    fn new() -> Self {
204        Self {
205            rec: VecDeque::with_capacity(16_384),
206            buffer: [0; 16_384],
207        }
208    }
209
210    fn push(&mut self, read_r: io::Result<usize>, is_err: bool) -> bool {
211        const MAX_CAPTURE: usize = 8_388_608;
212
213        match read_r {
214            Ok(n) => {
215                if n == 0 {
216                    return true;
217                }
218
219                let new = &self.buffer[..n];
220                let next_len = self.rec.len() + new.len();
221                if next_len > MAX_CAPTURE {
222                    let overflow = self.rec.len() + new.len() - MAX_CAPTURE;
223                    self.rec.drain(..overflow);
224                }
225                self.rec.extend(new);
226
227                let r = if is_err {
228                    let mut s = std::io::stderr();
229                    s.write_all(new).and_then(|_| s.flush())
230                } else {
231                    let mut s = std::io::stdout();
232                    s.write_all(new).and_then(|_| s.flush())
233                };
234                if let Err(e) = r {
235                    panic!("{} write error, {}", if is_err { "stderr" } else { "stdout" }, e)
236                }
237            }
238            Err(e) => panic!("{} read error, {}", if is_err { "stderr" } else { "stdout" }, e),
239        }
240
241        false
242    }
243}
244
245fn deque_to_string(deq: VecDeque<u8>) -> String {
246    let deq: Vec<u8> = deq.into();
247    match String::from_utf8_lossy(&deq) {
248        std::borrow::Cow::Borrowed(_) => {
249            // SAFETY: from_utf8_lossy only returns `Borrowed` when the input is valid utf-8
250            unsafe { String::from_utf8_unchecked(deq) }
251        }
252        std::borrow::Cow::Owned(s) => s,
253    }
254}
255
256/// Panic parsed from a `stderr` dump.
257///
258/// # Compatibility
259///
260/// The parser can seek only the latest Rust stable panic format, to ensure compatibility call
261/// [`PanicInfo::set_hook`] on the child process is possible.
262#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
263#[non_exhaustive]
264pub struct PanicInfo {
265    /// Name of thread that panicked.
266    pub thread: Txt,
267    /// Panic message.
268    pub message: Txt,
269    /// Path to file that defines the panic.
270    pub file: Txt,
271    /// Line of code that defines the panic.
272    pub line: u32,
273    /// Column in the line of code that defines the panic.
274    pub column: u32,
275    /// Widget where the panic happened.
276    ///
277    /// Only available in processes that use [`PanicInfo::set_hook`].
278    pub widget_path: Txt,
279    /// Stack backtrace.
280    pub backtrace: Txt,
281}
282
283/// Alternate mode `{:#}` writes raw backtrace without cleanup and code snippets.
284///
285/// See also [`PanicInfo::display_no_backtrace`]
286impl fmt::Display for PanicInfo {
287    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288        fmt::Display::fmt(&self.display_no_backtrace(), f)?;
289        if f.alternate() {
290            writeln!(f, "stack backtrace:\n{}", self.backtrace)
291        } else {
292            writeln!(f, "stack backtrace:")?;
293            let mut snippet = 9;
294            for frame in self.backtrace_frames().skip_while(|f| f.is_after_panic) {
295                write!(f, "{frame}")?;
296                if snippet > 0 {
297                    let code = frame.code_snippet();
298                    if !code.is_empty() {
299                        snippet -= 1;
300                        writeln!(f, "{code}")?;
301                    }
302                }
303            }
304            Ok(())
305        }
306    }
307}
308impl PanicInfo {
309    /// Returns an object that implements [`fmt::Display`] to write only the thread name, location, message and widget path.
310    pub fn display_no_backtrace(&self) -> impl fmt::Display {
311        struct D<'a>(&'a PanicInfo);
312        impl<'a> fmt::Display for D<'a> {
313            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314                let p = &self.0;
315                writeln!(f, "thread '{}' panicked at {}:{}:{}:", p.thread, p.file, p.line, p.column)?;
316                for line in p.message.lines() {
317                    writeln!(f, "   {line}")?;
318                }
319                if !p.widget_path.is_empty() {
320                    writeln!(f, "widget path:\n   {}", p.widget_path)?;
321                }
322                Ok(())
323            }
324        }
325        D(self)
326    }
327}
328impl PanicInfo {
329    /// Gets if `stderr` contains a panic that can be parsed by [`find`].
330    ///
331    /// [`find`]: Self::find
332    pub fn contains(stderr: &str) -> bool {
333        Self::find_impl(stderr, false).is_some()
334    }
335
336    /// Gets if `stderr` contains a panic that can be parsed by [`find`] and traced a widget/window path.
337    ///
338    /// [`find`]: Self::find
339    pub fn contains_widget(stderr: &str) -> bool {
340        match Self::find_impl(stderr, false) {
341            Some(p) => !p.widget_path.is_empty(),
342            None => false,
343        }
344    }
345
346    /// Try parse `stderr` for the last panic printout.
347    ///
348    /// Only reliably works if the panic fully printed correctly and was formatted by
349    /// [`PanicInfo::set_hook`].
350    pub fn find(stderr: &str) -> Option<Self> {
351        Self::find_impl(stderr, true)
352    }
353
354    fn find_impl(stderr: &str, parse: bool) -> Option<Self> {
355        let mut thread = "";
356        let mut location = "";
357        let mut message = "";
358        let mut widget_path = "";
359        let mut backtrace = "";
360
361        let mut nl_message = false;
362        let mut nl_widget_path = false;
363        let mut nl_backtrace = false;
364
365        for line in stderr.lines() {
366            if let Some(panic) = line.strip_prefix("thread '")
367                && let Some((t, l)) = panic.split_once(" panicked at ")
368            {
369                thread = t;
370                location = l;
371                message = "";
372                nl_message = true;
373                widget_path = "";
374                nl_widget_path = false;
375                backtrace = "";
376                nl_backtrace = false;
377            } else if line == "widget path:" {
378                nl_widget_path = true;
379            } else if line == "stack backtrace:" {
380                nl_backtrace = true;
381            } else if mem::take(&mut nl_message) {
382                let i = line.as_ptr() as usize - stderr.as_ptr() as usize;
383                message = &stderr[i..];
384            } else if mem::take(&mut nl_widget_path) {
385                widget_path = line.trim();
386            } else if mem::take(&mut nl_backtrace) {
387                let i = line.as_ptr() as usize - stderr.as_ptr() as usize;
388                backtrace = &stderr[i..];
389            }
390        }
391
392        if thread.is_empty() {
393            return None;
394        }
395
396        if !parse {
397            return Some(Self {
398                thread: Txt::from(""),
399                message: Txt::from(""),
400                file: Txt::from(""),
401                line: 0,
402                column: 0,
403                widget_path: if !widget_path.is_empty() {
404                    Txt::from("true")
405                } else {
406                    Txt::from("")
407                },
408                backtrace: Txt::from(""),
409            });
410        }
411
412        let mut location = location.rsplitn(3, ':');
413        let column: u32 = location.next().unwrap_or("0").parse().unwrap_or(0);
414        let line: u32 = location.next().unwrap_or("0").parse().unwrap_or(0);
415        let file = location.next().unwrap_or("");
416
417        let mut thread = thread.split('\'');
418        let mut thread_name = thread.next().unwrap_or("<unnamed>");
419        let thread_id = thread.next().unwrap_or("");
420        if thread_name == "<unnamed>"
421            && let Some(id) = thread_id.strip_prefix('(')
422            && let Some(id) = id.strip_suffix(')')
423        {
424            thread_name = id;
425        }
426
427        let mut m = String::new();
428        let mut sep = "";
429        for line in message.lines() {
430            if let Some(line) = line.strip_prefix("   ") {
431                m.push_str(sep);
432                m.push_str(line);
433                sep = "\n";
434            } else {
435                if m.is_empty() && line != "widget path:" && line != "stack backtrace:" {
436                    // not formatted by us, probably by Rust
437                    line.clone_into(&mut m);
438                }
439                break;
440            }
441        }
442        let message = m;
443
444        let mut backtrace_end = backtrace.len();
445        'backtrace_seek: for line in backtrace.lines() {
446            let s = line.trim_start();
447            if s.is_empty() {
448                break;
449            } else if !s.starts_with("at ") {
450                for c in s.chars() {
451                    if !c.is_ascii_digit() {
452                        if c != ':' {
453                            break 'backtrace_seek;
454                        }
455                        break;
456                    }
457                }
458            }
459            // matches "\s*\d+:" OR "\s*at "
460            backtrace_end = line.as_ptr() as usize - backtrace.as_ptr() as usize + line.len();
461        }
462        backtrace = &backtrace[..backtrace_end];
463
464        Some(Self {
465            thread: thread_name.to_txt(),
466            message: message.into(),
467            file: file.to_txt(),
468            line,
469            column,
470            widget_path: widget_path.to_txt(),
471            backtrace: backtrace.to_txt(),
472        })
473    }
474
475    /// Iterate over frames parsed from the `backtrace`.
476    pub fn backtrace_frames(&self) -> impl Iterator<Item = BacktraceFrame> + '_ {
477        BacktraceFrame::parse(&self.backtrace)
478    }
479}
480
481/// Represents a frame parsed from a stack backtrace.
482#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
483#[non_exhaustive]
484pub struct BacktraceFrame {
485    /// Position on the backtrace.
486    pub n: usize,
487
488    /// Function name.
489    pub name: Txt,
490    /// Source code file.
491    pub file: Txt,
492    /// Source code line.
493    pub line: u32,
494
495    /// If this frame is inside the Rust panic code.
496    pub is_after_panic: bool,
497}
498impl fmt::Display for BacktraceFrame {
499    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500        writeln!(f, "{:>4}: {}", self.n, self.name)?;
501        if !self.file.is_empty() {
502            writeln!(f, "      at {}:{}", self.file, self.line)?;
503        }
504        Ok(())
505    }
506}
507impl BacktraceFrame {
508    /// Iterate over frames parsed from the `backtrace`.
509    pub fn parse(mut backtrace: &str) -> impl Iterator<Item = BacktraceFrame> + '_ {
510        let mut is_after_panic = backtrace.lines().any(|l| l.ends_with("core::panicking::panic_fmt"));
511        std::iter::from_fn(move || {
512            if backtrace.is_empty() {
513                None
514            } else {
515                let n_name = backtrace.lines().next().unwrap();
516                let (n, name) = if let Some((n, name)) = n_name.split_once(':') {
517                    let n = match n.trim_start().parse() {
518                        Ok(n) => n,
519                        Err(_) => {
520                            backtrace = "";
521                            return None;
522                        }
523                    };
524                    let name = name.trim();
525                    if name.is_empty() {
526                        backtrace = "";
527                        return None;
528                    }
529                    (n, name)
530                } else {
531                    backtrace = "";
532                    return None;
533                };
534
535                backtrace = backtrace.get(n_name.len() + 1..).unwrap_or("");
536                let r = if backtrace.trim_start().starts_with("at ") {
537                    let file_line = backtrace.lines().next().unwrap();
538                    let (file, line) = if let Some((file, line)) = file_line.rsplit_once(':') {
539                        let file = file.trim_start().strip_prefix("at ").unwrap();
540                        let line = match line.trim_end().parse() {
541                            Ok(l) => l,
542                            Err(_) => {
543                                backtrace = "";
544                                return None;
545                            }
546                        };
547                        (file, line)
548                    } else {
549                        backtrace = "";
550                        return None;
551                    };
552
553                    backtrace = &backtrace[file_line.len() + 1..];
554
555                    BacktraceFrame {
556                        n,
557                        name: name.to_txt(),
558                        file: file.to_txt(),
559                        line,
560                        is_after_panic,
561                    }
562                } else {
563                    BacktraceFrame {
564                        n,
565                        name: name.to_txt(),
566                        file: Txt::from(""),
567                        line: 0,
568                        is_after_panic,
569                    }
570                };
571
572                if is_after_panic && name.ends_with("core::panicking::panic_fmt") {
573                    is_after_panic = false;
574                }
575
576                Some(r)
577            }
578        })
579    }
580
581    /// Reads the code line + four surrounding lines if the code file can be found.
582    pub fn code_snippet(&self) -> Txt {
583        if !self.file.is_empty()
584            && self.line > 0
585            && let Ok(file) = std::fs::File::open(&self.file)
586        {
587            use std::fmt::Write as _;
588            let mut r = String::new();
589
590            let reader = std::io::BufReader::new(file);
591
592            let line_s = self.line - 2.min(self.line - 1);
593            let lines = reader.lines().skip(line_s as usize - 1).take(5);
594            for (line, line_n) in lines.zip(line_s..) {
595                let line = match line {
596                    Ok(l) => l,
597                    Err(_) => return Txt::from(""),
598                };
599
600                if line_n == self.line {
601                    writeln!(&mut r, "      {line_n:>4} > {line}").unwrap();
602                } else {
603                    writeln!(&mut r, "      {line_n:>4} │ {line}").unwrap();
604                }
605            }
606
607            return r.into();
608        }
609        Txt::from("")
610    }
611}
612impl PanicInfo {
613    /// Set a panic hook that will print panics to stderr in a format compatible with [`PanicInfo`] parsing.
614    ///
615    /// The `widget_trace_path` should be a closure that return `WIDGET.trace_path()` if the process can run
616    /// an `APP`, otherwise it must be `Txt::default`.
617    ///
618    /// The panic hook calls simply [`eprint_panic`].
619    ///
620    /// [`eprint_panic`]: PanicInfo::eprint_panic
621    pub fn set_hook(widget_trace_path: impl Fn() -> Txt + Send + Sync + 'static) {
622        std::panic::set_hook(Box::new(move |a| {
623            let path = widget_trace_path();
624            Self::eprint_panic(a, &path);
625        }));
626    }
627
628    /// Print panic to stderr in a format compatible with [`PanicInfo`] parsing.
629    ///
630    /// This function is called by the hook set by [`set_hook`].
631    ///
632    /// [`set_hook`]: PanicInfo::set_hook
633    pub fn eprint_panic(info: &std::panic::PanicHookInfo, widget_trace_path: &str) {
634        let backtrace = std::backtrace::Backtrace::capture();
635        let panic = PanicFromHook::from_hook(info);
636        if widget_trace_path.is_empty() {
637            eprintln!("{panic}\nstack backtrace:\n{backtrace}");
638        } else {
639            eprintln!("{panic}widget path:\n   {widget_trace_path}\nstack backtrace:\n{backtrace}");
640        }
641    }
642}
643
644#[derive(Debug)]
645pub(crate) struct PanicFromHook {
646    pub thread: Txt,
647    pub msg: Txt,
648    pub file: Txt,
649    pub line: u32,
650    pub column: u32,
651}
652impl PanicFromHook {
653    pub fn from_hook(info: &std::panic::PanicHookInfo) -> Self {
654        let current_thread = std::thread::current();
655        let thread = match current_thread.name() {
656            Some(n) => n.to_txt(),
657            None => formatx!("{:?}", std::thread::current().id()),
658        };
659        let msg = crate::extract_panic_message(info.payload()).unwrap_or("Box<dyn  Any>").to_txt();
660
661        let (file, line, column) = if let Some(l) = info.location() {
662            (l.file(), l.line(), l.column())
663        } else {
664            ("<unknown>", 0, 0)
665        };
666        Self {
667            thread: thread.to_txt(),
668            msg,
669            file: file.to_txt(),
670            line,
671            column,
672        }
673    }
674}
675impl std::error::Error for PanicFromHook {}
676impl fmt::Display for PanicFromHook {
677    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
678        writeln!(
679            f,
680            "thread '{}' panicked at {}:{}:{}:",
681            self.thread, self.file, self.line, self.column
682        )?;
683        for line in self.msg.lines() {
684            writeln!(f, "   {line}")?;
685        }
686        Ok(())
687    }
688}
689
690fn remove_ansi_csi_str(mut s: &str) -> String {
691    fn is_esc_end(byte: u8) -> bool {
692        (0x40..=0x7e).contains(&byte)
693    }
694
695    let mut r = String::new();
696    while let Some(i) = s.find(CSI) {
697        r.push_str(&s[..i]);
698        s = &s[i + CSI.len()..];
699        let mut esc_end = 0;
700        while esc_end < s.len() && !is_esc_end(s.as_bytes()[esc_end]) {
701            esc_end += 1;
702        }
703        esc_end += 1;
704        s = &s[esc_end..];
705    }
706    r.push_str(s);
707    r
708}
709
710/// Remove ANSI escape sequences (CSI) from `s`.
711pub fn remove_ansi_csi(s: &str) -> Txt {
712    remove_ansi_csi_str(s).into()
713}
714
715/// If `s` contains ANSI escape sequences (CSI).
716pub fn contains_ansi_csi(s: &str) -> bool {
717    s.contains(CSI)
718}
719
720const CSI: &str = "\x1b[";
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    #[test]
727    fn remove_ansi() {
728        let r = remove_ansi_csi_str(
729            "\x1b[32m INFO\x1b[0m \x1b[2mzng_env::process\x1b[0m\x1b[2m:\x1b[0m pid: 16196, name: crash-dialog-process",
730        );
731        assert_eq!(r, " INFO zng_env::process: pid: 16196, name: crash-dialog-process");
732    }
733}