Skip to main content

badterm_core/
session.rs

1//! A live connection to a BBS: protocol decoding, terminal state, ZMODEM
2//! transfers and session capture. Networking lives in [`crate::transport`].
3
4use std::collections::VecDeque;
5use std::fs::File;
6use std::io::{BufWriter, Write};
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result};
11
12use crate::ansi::{TermEvent, Terminal};
13use crate::autologin::{AutoLogin, Step};
14use crate::dialer::Entry;
15use crate::screen::Screen;
16use crate::telnet::Telnet;
17use crate::transfer::{
18    self, Download, Kermit, Outcome, TrailerFilter, Transfer, Upload, XyProtocol, XyReceive, XySend,
19};
20pub use crate::transport::{AuthPrompt, ConnectOptions, NetEvent, PromptKind, Writer, spawn_connection};
21
22/// How long the BBS must be quiet before the text at the cursor is taken
23/// for a prompt.
24const PROMPT_QUIET: Duration = Duration::from_millis(250);
25
26/// How soon after answering a BBS that hangs up may be called busy.
27const BUSY_WINDOW: Duration = Duration::from_secs(30);
28
29/// Whether the screen says the BBS has no free line.
30fn says_busy(screen: &Screen) -> bool {
31    const WORDS: [&str; 6] = ["busy", "no free node", "all nodes", "all lines", "try again later", "too many users"];
32    (0..screen.rows()).any(|y| {
33        let line = Screen::row_text(screen.row(y)).to_ascii_lowercase();
34        WORDS.iter().any(|w| line.contains(w))
35    })
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum State {
40    Connecting,
41    Connected,
42    Closed,
43}
44
45/// Where a session keeps the files it produces.
46#[derive(Clone, Debug)]
47pub struct SessionConfig {
48    pub scrollback: usize,
49    pub known_hosts: PathBuf,
50    pub download_dir: PathBuf,
51    /// Offer the SSH agent's keys when logging in.
52    pub ssh_agent: bool,
53    /// Where default SSH keys (`id_ed25519`, ...) are looked for.
54    pub ssh_key_dir: Option<PathBuf>,
55    /// The modem, for modem entries.
56    pub modem: crate::modem::ModemConfig,
57}
58
59/// Things the front end should react to.
60#[derive(Clone, Debug, PartialEq)]
61pub enum SessionEvent {
62    /// The connection is up (for SSH, the login succeeded).
63    Connected,
64    Term(TermEvent),
65    Notice(String),
66    /// The BBS is waiting to receive files (it sent ZRINIT); ask the user
67    /// what to upload, then call [`Session::start_upload`] or
68    /// [`Session::decline_upload`].
69    UploadRequested,
70    /// The login has a question for the user: see [`Session::auth_prompt`],
71    /// and reply with [`Session::answer_prompt`].
72    AuthPrompt,
73    TransferStarted,
74    TransferFinished(Outcome),
75}
76
77pub struct Session {
78    pub entry: Entry,
79    pub term: Terminal,
80    pub state: State,
81    /// Why the session closed, if it failed.
82    pub error: Option<String>,
83    config: SessionConfig,
84    telnet: Option<Telnet>,
85    writer: Option<Writer>,
86    capture: Option<Capture>,
87    transfer: Option<Transfer>,
88    /// Bytes received since the BBS asked for an upload, held until the user
89    /// picks files (they include the ZRINIT the sender needs to see).
90    upload_wait: Option<Vec<u8>>,
91    auth_prompt: Option<AuthPrompt>,
92    login: AutoLogin,
93    /// When data last arrived, until a login prompt check has looked at it.
94    quiet_since: Option<Instant>,
95    /// Baud rate emulation: received data waiting to be shown, how many
96    /// bytes may go now, and when that was worked out.
97    paced: VecDeque<u8>,
98    pace_credit: f64,
99    pace_at: Instant,
100    /// The end of the last data chunk, so a ZMODEM header split across two
101    /// reads is still recognized.
102    scan_tail: Vec<u8>,
103    /// Hides ZMODEM's closing bytes from the terminal after a transfer.
104    trailer: Option<TrailerFilter>,
105    started: Instant,
106    connected_at: Option<Instant>,
107    events: Vec<SessionEvent>,
108}
109
110impl Session {
111    pub fn new(entry: Entry, cols: usize, rows: usize, config: SessionConfig) -> Session {
112        let login = AutoLogin::new(&entry);
113        let mut term = Terminal::new(cols, rows, config.scrollback);
114        term.encoding = entry.encoding;
115        term.screen.base_font = entry.font;
116        term.screen.base_ice = entry.ice.unwrap_or(false);
117        term.screen.ice_colors = term.screen.base_ice;
118        term.screen.look.fonts[0] = entry.font;
119        // A C64 or Atari system: its colors and font.
120        term.start_emulation();
121        let telnet = entry.protocol.is_telnet().then(|| Telnet::new(&entry.term_type, cols as u16, rows as u16));
122        Session {
123            entry,
124            term,
125            state: State::Connecting,
126            error: None,
127            config,
128            telnet,
129            writer: None,
130            capture: None,
131            transfer: None,
132            upload_wait: None,
133            auth_prompt: None,
134            login,
135            quiet_since: None,
136            paced: VecDeque::new(),
137            pace_credit: 0.0,
138            pace_at: Instant::now(),
139            scan_tail: Vec::new(),
140            trailer: None,
141            started: Instant::now(),
142            connected_at: None,
143            events: Vec::new(),
144        }
145    }
146
147    /// Start connecting in the background; events arrive through `sink` and
148    /// must be passed to [`Session::handle`].
149    pub fn connect<F>(&self, sink: F)
150    where
151        F: Fn(NetEvent) + Send + 'static,
152    {
153        let opts = ConnectOptions {
154            cols: self.term.screen.cols() as u16,
155            rows: self.term.screen.rows() as u16,
156            known_hosts: self.config.known_hosts.clone(),
157            ssh_agent: self.config.ssh_agent,
158            ssh_key_dir: self.config.ssh_key_dir.clone(),
159            modem: self.config.modem.clone(),
160        };
161        spawn_connection(&self.entry, &opts, sink);
162    }
163
164    pub fn handle(&mut self, ev: NetEvent) {
165        match ev {
166            NetEvent::Connected(w) => {
167                self.writer = Some(w);
168                self.state = State::Connected;
169                self.connected_at = Some(Instant::now());
170                self.login.connected();
171                self.events.push(SessionEvent::Connected);
172            }
173            NetEvent::Data(bytes) => self.receive(&bytes),
174            NetEvent::Notice(msg) => self.events.push(SessionEvent::Notice(msg)),
175            NetEvent::Prompt(p) => {
176                self.auth_prompt = Some(p);
177                self.events.push(SessionEvent::AuthPrompt);
178            }
179            NetEvent::Closed(err) => {
180                self.auth_prompt = None;
181                self.state = State::Closed;
182                self.error = err;
183                self.writer = None;
184                self.end_transfer(Some(Outcome::Failed("the connection closed".into())));
185            }
186        }
187    }
188
189    fn receive(&mut self, bytes: &[u8]) {
190        let data = match &mut self.telnet {
191            Some(t) => {
192                let (mut data, mut reply) = (Vec::with_capacity(bytes.len()), Vec::new());
193                t.receive(bytes, &mut data, &mut reply);
194                if !reply.is_empty() {
195                    self.write_raw(&reply);
196                }
197                data
198            }
199            None => bytes.to_vec(),
200        };
201        // Transfers are not slowed down.
202        if self.entry.baud == 0 || self.transfer.is_some() && self.paced.is_empty() {
203            self.process(data);
204            self.quiet_since = Some(Instant::now());
205        } else {
206            if self.paced.is_empty() {
207                self.pace_at = Instant::now();
208            }
209            self.paced.extend(data);
210            self.pump();
211        }
212    }
213
214    /// Whether received data is waiting for baud rate emulation: call
215    /// [`Session::pump`] often (every frame) while it is.
216    pub fn pacing(&self) -> bool {
217        !self.paced.is_empty()
218    }
219
220    /// Show as much paced data as the emulated baud rate allows by now: ten
221    /// bits per byte, as with a modem's start and stop bits.
222    pub fn pump(&mut self) {
223        if self.paced.is_empty() {
224            return;
225        }
226        let now = Instant::now();
227        self.pace_credit += now.duration_since(self.pace_at).as_secs_f64() * self.entry.baud as f64 / 10.0;
228        self.pace_at = now;
229        let n = (self.pace_credit as usize).min(self.paced.len());
230        if n == 0 {
231            return;
232        }
233        self.pace_credit -= n as f64;
234        let chunk: Vec<u8> = self.paced.drain(..n).collect();
235        self.process(chunk);
236        // A transfer that started in that chunk gets the rest at once.
237        if self.transfer.is_some() {
238            let rest: Vec<u8> = self.paced.drain(..).collect();
239            self.process(rest);
240        }
241        if self.paced.is_empty() {
242            self.pace_credit = 0.0;
243        }
244        self.quiet_since = Some(now);
245    }
246
247    /// Answer a login prompt, once the BBS has been quiet for a moment (so
248    /// half a line is not taken for a prompt).
249    fn check_login_prompt(&mut self) {
250        let Some(t) = self.quiet_since else { return };
251        if t.elapsed() < PROMPT_QUIET || self.transfer.is_some() || self.upload_wait.is_some() {
252            return;
253        }
254        self.quiet_since = None;
255        let screen = &self.term.screen;
256        let (x, y) = screen.cursor();
257        let row = screen.row(y);
258        let prompt = Screen::row_text(&row[..x.min(row.len())]);
259        if let Some(step) = self.login.answer(&prompt) {
260            self.send_login_step(step);
261        }
262    }
263
264    /// Send the next part of the stored login (Alt-L): the user name, then
265    /// the password. False when there is nothing (more) to send.
266    pub fn send_login(&mut self) -> bool {
267        match self.login.next_step() {
268            Some(step) => {
269                self.send_login_step(step);
270                true
271            }
272            None => false,
273        }
274    }
275
276    /// Whether the entry has a user name or password to log in with.
277    pub fn has_login(&self) -> bool {
278        self.login.has_login()
279    }
280
281    fn send_login_step(&mut self, step: Step) {
282        let (text, what) = match step {
283            Step::User(u) => (u, "user name"),
284            Step::Password(p) => (p, "password"),
285        };
286        let bytes = self.term.encoding.encode_text(&format!("{text}\r"));
287        self.send(&bytes);
288        self.events.push(SessionEvent::Notice(format!("Sent your {what}")));
289    }
290
291    /// Route decoded data to an active transfer, or detect a new one, or
292    /// show it on the terminal.
293    fn process(&mut self, mut data: Vec<u8>) {
294        if let Some(f) = &mut self.trailer
295            && !f.strip(&mut data)
296        {
297            self.trailer = None;
298        }
299        if let Some(t) = &mut self.transfer {
300            let mut out = Vec::new();
301            t.feed(&data, &mut out);
302            self.send(&out);
303            self.end_transfer(None);
304            return;
305        }
306        if let Some(buf) = &mut self.upload_wait {
307            buf.extend_from_slice(&data);
308            return;
309        }
310
311        let mut scan = std::mem::take(&mut self.scan_tail);
312        let already_shown = scan.len();
313        scan.extend_from_slice(&data);
314        if let Some(i) = transfer::find(&scan, transfer::ZRQINIT) {
315            self.show(&scan[already_shown.min(i)..i]);
316            match Download::new(self.config.download_dir.clone()) {
317                Ok(d) => {
318                    self.transfer = Some(Transfer::Download(d));
319                    self.events.push(SessionEvent::TransferStarted);
320                    self.process(scan[i..].to_vec());
321                }
322                Err(e) => self.events.push(SessionEvent::Notice(format!("Cannot start download: {e:#}"))),
323            }
324            return;
325        }
326        if let Some(i) = transfer::find(&scan, transfer::ZRINIT) {
327            self.show(&scan[already_shown.min(i)..i]);
328            self.upload_wait = Some(scan[i..].to_vec());
329            self.events.push(SessionEvent::UploadRequested);
330            return;
331        }
332        // A Kermit sending: its Send-Init packet.
333        if let Some(i) = crate::kermit::find_send_init(&scan) {
334            self.show(&scan[already_shown.min(i)..i]);
335            self.transfer = Some(Transfer::Kermit(Kermit::receive(self.config.download_dir.clone())));
336            self.events.push(SessionEvent::TransferStarted);
337            self.process(scan[i..].to_vec());
338            return;
339        }
340        let keep = scan.len().min(transfer::ZRQINIT.len() - 1);
341        self.scan_tail = scan[scan.len() - keep..].to_vec();
342        self.show(&data);
343    }
344
345    fn show(&mut self, data: &[u8]) {
346        if data.is_empty() {
347            return;
348        }
349        if let Some(c) = &mut self.capture {
350            c.write(data, self.term.encoding);
351        }
352        self.term.feed(data);
353        let replies = self.term.take_replies();
354        if !replies.is_empty() {
355            self.send(&replies);
356        }
357        self.events.extend(self.term.take_events().into_iter().map(SessionEvent::Term));
358    }
359
360    /// If the transfer finished (or `force` gives a reason to stop it),
361    /// report the outcome and return its trailing bytes to the terminal.
362    fn end_transfer(&mut self, force: Option<Outcome>) {
363        let done = self.transfer.as_ref().is_some_and(Transfer::is_finished);
364        if !done && force.is_none() {
365            return;
366        }
367        let Some(t) = self.transfer.take() else { return };
368        let (outcome, leftover) = t.finish();
369        let outcome = if done { outcome } else { force.unwrap_or(outcome) };
370        self.events.push(SessionEvent::TransferFinished(outcome));
371        self.scan_tail.clear();
372        self.trailer = Some(TrailerFilter::new());
373        self.process(leftover);
374    }
375
376    pub fn take_events(&mut self) -> Vec<SessionEvent> {
377        std::mem::take(&mut self.events)
378    }
379
380    pub fn transfer(&self) -> Option<&Transfer> {
381        self.transfer.as_ref()
382    }
383
384    /// The login question waiting for an answer, if any.
385    pub fn auth_prompt(&self) -> Option<&AuthPrompt> {
386        self.auth_prompt.as_ref()
387    }
388
389    /// Answer the waiting login question; `None` cancels the login.
390    pub fn answer_prompt(&mut self, answer: Option<String>) {
391        if let Some(p) = self.auth_prompt.take() {
392            p.answer(answer);
393        }
394    }
395
396    /// Whether the BBS is waiting for the user to choose files to upload.
397    pub fn upload_requested(&self) -> bool {
398        self.upload_wait.is_some()
399    }
400
401    /// Send files with ZMODEM. If a batch is already being sent, the files
402    /// are added to it.
403    pub fn start_upload(&mut self, paths: Vec<PathBuf>) -> Result<()> {
404        if let Some(t) = &mut self.transfer {
405            for p in paths {
406                t.enqueue(p)?;
407            }
408            return Ok(());
409        }
410        let up = Upload::new(paths)?;
411        self.transfer = Some(Transfer::Upload(up));
412        self.events.push(SessionEvent::TransferStarted);
413        // Replay what the BBS sent while waiting (its ZRINIT), if anything.
414        let waiting = self.upload_wait.take().unwrap_or_default();
415        self.process(waiting);
416        Ok(())
417    }
418
419    /// Send files with XMODEM or YMODEM, once the BBS's receiver starts.
420    pub fn start_xy_upload(&mut self, proto: XyProtocol, paths: Vec<PathBuf>) -> Result<()> {
421        if self.transfer.is_some() {
422            anyhow::bail!("a transfer is already in progress");
423        }
424        let up = XySend::new(proto, paths)?;
425        self.upload_wait = None;
426        self.transfer = Some(Transfer::XyUpload(up));
427        self.events.push(SessionEvent::TransferStarted);
428        Ok(())
429    }
430
431    /// Receive with XMODEM or YMODEM: asks the BBS's sender to start.
432    /// `name` is what an XMODEM file is saved as (YMODEM sends names).
433    pub fn start_xy_download(&mut self, proto: XyProtocol, name: Option<String>) -> Result<()> {
434        if self.transfer.is_some() {
435            anyhow::bail!("a transfer is already in progress");
436        }
437        let mut t = Transfer::XyDownload(XyReceive::new(proto, self.config.download_dir.clone(), name));
438        let mut out = Vec::new();
439        t.tick(Instant::now(), &mut out);
440        self.transfer = Some(t);
441        self.send(&out);
442        self.events.push(SessionEvent::TransferStarted);
443        Ok(())
444    }
445
446    /// Send files with Kermit to the BBS's Kermit, once it is receiving.
447    pub fn start_kermit_upload(&mut self, paths: Vec<PathBuf>) -> Result<()> {
448        if self.transfer.is_some() {
449            anyhow::bail!("a transfer is already in progress");
450        }
451        let mut t = Transfer::Kermit(Kermit::send(paths)?);
452        let mut out = Vec::new();
453        t.start(&mut out);
454        self.upload_wait = None;
455        self.transfer = Some(t);
456        self.send(&out);
457        self.events.push(SessionEvent::TransferStarted);
458        Ok(())
459    }
460
461    /// Receive with Kermit: for a BBS whose Kermit is already sending (one
462    /// that starts afterwards is picked up by itself).
463    pub fn start_kermit_download(&mut self) -> Result<()> {
464        if self.transfer.is_some() {
465            anyhow::bail!("a transfer is already in progress");
466        }
467        self.transfer = Some(Transfer::Kermit(Kermit::receive(self.config.download_dir.clone())));
468        self.events.push(SessionEvent::TransferStarted);
469        Ok(())
470    }
471
472    /// The user chose not to upload: make the BBS's receiver give up.
473    pub fn decline_upload(&mut self) {
474        if self.upload_wait.take().is_some() {
475            self.send(transfer::CANCEL);
476        }
477    }
478
479    pub fn cancel_transfer(&mut self) {
480        if let Some(t) = &mut self.transfer {
481            let mut out = Vec::new();
482            t.cancel(&mut out);
483            self.send(&out);
484            self.end_transfer(None);
485        }
486    }
487
488    /// Call periodically (a few times a second) to drive transfer timeouts.
489    pub fn tick(&mut self) {
490        self.pump();
491        // Sound reports, and reading again after waiting for a sound.
492        self.term.poll(Instant::now());
493        let replies = self.term.take_replies();
494        if !replies.is_empty() {
495            self.send(&replies);
496        }
497        self.events.extend(self.term.take_events().into_iter().map(SessionEvent::Term));
498        if let Some(t) = &mut self.transfer {
499            let mut out = Vec::new();
500            t.tick(Instant::now(), &mut out);
501            self.send(&out);
502            self.end_transfer(None);
503        }
504        self.check_login_prompt();
505    }
506
507    /// When [`Session::tick`] next has something to do for the terminal.
508    pub fn next_tick(&self) -> Option<Instant> {
509        self.term.next_poll()
510    }
511
512    /// Send application data (keystrokes, paste, transfer data) to the remote.
513    pub fn send(&mut self, data: &[u8]) {
514        if data.is_empty() {
515            return;
516        }
517        let encoded = match &self.telnet {
518            Some(t) => t.encode(data),
519            None => data.to_vec(),
520        };
521        self.write_raw(&encoded);
522    }
523
524    fn write_raw(&mut self, data: &[u8]) {
525        if let Some(w) = &mut self.writer
526            && let Err(e) = w.write_all(data)
527        {
528            self.error = Some(e.to_string());
529            self.hang_up();
530        }
531    }
532
533    pub fn hang_up(&mut self) {
534        self.answer_prompt(None);
535        if let Some(w) = self.writer.take() {
536            w.shutdown();
537        }
538        self.state = State::Closed;
539        self.stop_capture();
540    }
541
542    /// Whether a call that has ended is worth dialing again: the connection
543    /// could not be made (refused, timed out), or the BBS hung up soon after
544    /// answering, saying it is busy. Not for failed logins, changed host
545    /// keys and the like.
546    pub fn should_redial(&self) -> bool {
547        if self.state != State::Closed {
548            return false;
549        }
550        match self.connected_at {
551            None => self.error.as_deref().is_some_and(|e| e.contains("connecting to")),
552            Some(t) => t.elapsed() < BUSY_WINDOW && says_busy(&self.term.screen),
553        }
554    }
555
556    /// Time since connecting (or since dialing, while still connecting).
557    pub fn elapsed(&self) -> Duration {
558        self.connected_at.unwrap_or(self.started).elapsed()
559    }
560
561    pub fn download_dir(&self) -> &Path {
562        &self.config.download_dir
563    }
564
565    pub fn capture_path(&self) -> Option<&Path> {
566        self.capture.as_ref().map(|c| c.path.as_path())
567    }
568
569    /// Start recording received data (after telnet decoding) to a file: all
570    /// of it, or for a `.txt` file only the text (see [`CaptureKind`]).
571    pub fn start_capture(&mut self, path: PathBuf) -> Result<()> {
572        if let Some(dir) = path.parent() {
573            std::fs::create_dir_all(dir)?;
574        }
575        let f = File::options()
576            .create(true)
577            .append(true)
578            .open(&path)
579            .with_context(|| format!("opening {}", path.display()))?;
580        let kind = CaptureKind::for_path(&path);
581        self.capture = Some(Capture { path, file: BufWriter::new(f), kind, text: TextState::Ground });
582        Ok(())
583    }
584
585    pub fn stop_capture(&mut self) {
586        if let Some(mut c) = self.capture.take() {
587            let _ = c.file.flush();
588        }
589    }
590}
591
592/// What a capture keeps.
593#[derive(Clone, Copy, Debug, PartialEq, Eq)]
594pub enum CaptureKind {
595    /// Everything the BBS sent, ANSI codes and all (`.ans`), to view later.
596    Raw,
597    /// Only the text, as UTF-8 (`.txt`): a log to read or search.
598    Text,
599}
600
601impl CaptureKind {
602    pub fn for_path(path: &Path) -> CaptureKind {
603        match path.extension().and_then(|e| e.to_str()) {
604            Some(e) if e.eq_ignore_ascii_case("txt") || e.eq_ignore_ascii_case("log") => CaptureKind::Text,
605            _ => CaptureKind::Raw,
606        }
607    }
608}
609
610#[derive(Clone, Copy, Debug, PartialEq, Eq)]
611enum TextState {
612    Ground,
613    Escape,
614    Csi,
615    /// An OSC or other string, up to BEL or ST.
616    String,
617}
618
619struct Capture {
620    path: PathBuf,
621    file: BufWriter<File>,
622    kind: CaptureKind,
623    text: TextState,
624}
625
626impl Capture {
627    fn write(&mut self, data: &[u8], encoding: crate::ansi::Encoding) {
628        if self.kind == CaptureKind::Raw {
629            let _ = self.file.write_all(data);
630            return;
631        }
632        let utf8 = encoding == crate::ansi::Encoding::Utf8;
633        let mut out = Vec::with_capacity(data.len());
634        for &b in data {
635            self.text = match (self.text, b) {
636                (_, 0x18 | 0x1A) => TextState::Ground,
637                (TextState::Ground, 0x1B) => TextState::Escape,
638                (TextState::Ground, b'\n' | b'\t') => {
639                    out.push(b);
640                    TextState::Ground
641                }
642                (TextState::Ground, 0x0C) => {
643                    out.extend_from_slice(b"\n\n");
644                    TextState::Ground
645                }
646                (TextState::Ground, 0x00..=0x1F | 0x7F) => TextState::Ground,
647                (TextState::Ground, _) if utf8 || b < 0x80 => {
648                    out.push(b);
649                    TextState::Ground
650                }
651                (TextState::Ground, _) => {
652                    let mut buf = [0; 4];
653                    out.extend_from_slice(crate::cp437::to_unicode(b).encode_utf8(&mut buf).as_bytes());
654                    TextState::Ground
655                }
656                (TextState::Escape, b'[') => TextState::Csi,
657                (TextState::Escape, b']' | b'P' | b'X' | b'^' | b'_') => TextState::String,
658                (TextState::Escape, 0x20..=0x2F) => TextState::Escape,
659                (TextState::Escape, _) => TextState::Ground,
660                (TextState::Csi, 0x40..=0x7E) => TextState::Ground,
661                (TextState::Csi, _) => TextState::Csi,
662                (TextState::String, 0x07) => TextState::Ground,
663                (TextState::String, 0x1B) => TextState::Escape,
664                (TextState::String, _) => TextState::String,
665            };
666        }
667        let _ = self.file.write_all(&out);
668    }
669}
670
671impl Drop for Session {
672    fn drop(&mut self) {
673        self.hang_up();
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::ansi::Encoding;
681
682    #[test]
683    fn text_captures_keep_only_the_words() {
684        let path = std::env::temp_dir().join(format!("badterm-capture-{}.txt", std::process::id()));
685        let _ = std::fs::remove_file(&path);
686        assert_eq!(CaptureKind::for_path(&path), CaptureKind::Text);
687        assert_eq!(CaptureKind::for_path(Path::new("x.ans")), CaptureKind::Raw);
688        let f = File::create(&path).unwrap();
689        let mut c =
690            Capture { path: path.clone(), file: BufWriter::new(f), kind: CaptureKind::Text, text: TextState::Ground };
691        // Colors, a title, CR LF and a CP437 box character, split mid-sequence.
692        c.write(b"\x1b[1;3", Encoding::Cp437);
693        c.write(b"1mHello\x1b]2;title\x07\r\n\xc4\xc4 bye\x07", Encoding::Cp437);
694        c.file.flush().unwrap();
695        assert_eq!(std::fs::read_to_string(&path).unwrap(), "Hello\n\u{2500}\u{2500} bye");
696        let _ = std::fs::remove_file(&path);
697    }
698}