use std::time::Duration;
use crate::Screen;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error(
"timed out after {timeout:?} while waiting for {waiting_for}\n\
--- screen at timeout ---\n{screen}"
)]
Timeout {
waiting_for: String,
timeout: Duration,
screen: Screen,
},
#[error(
"terminal closed (EOF) while waiting for {waiting_for}\n\
--- final screen ---\n{screen}"
)]
Eof {
waiting_for: String,
screen: Screen,
},
#[error("failed to spawn `{command}`: {reason}")]
Spawn {
command: String,
reason: String,
},
#[error("PTY error: {0}")]
Pty(String),
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid terminal size: {0}")]
Size(String),
#[error(
"the terminal emulator failed and the screen stopped advancing: {detail}\n\
--- last screen before the failure ---\n{screen}"
)]
Emulator {
detail: String,
screen: Screen,
},
#[error("input not receivable: {0}")]
Input(String),
#[error("could not parse a saved screen: {0}")]
Parse(String),
#[error("failed to send {what}\n--- screen at the failed write ---\n{screen}")]
Write {
what: Box<str>,
screen: Screen,
},
}
impl Error {
#[must_use]
pub fn screen(&self) -> Option<&Screen> {
match self {
Error::Timeout { screen, .. }
| Error::Eof { screen, .. }
| Error::Emulator { screen, .. }
| Error::Write { screen, .. } => Some(screen),
_ => None,
}
}
pub(crate) fn recorded(self) -> Self {
if let Some(screen) = self.screen() {
artifact::write(screen);
}
self
}
}
pub(crate) mod artifact {
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::Screen;
pub(crate) const VAR: &str = "TERMLENS_ARTIFACT_DIR";
static COUNTER: AtomicUsize = AtomicUsize::new(0);
pub(crate) fn write(screen: &Screen) {
let Some(dir) = std::env::var_os(VAR).filter(|d| !d.is_empty()) else {
return;
};
let dir = PathBuf::from(dir);
let n = COUNTER.fetch_add(1, Ordering::Relaxed) + 1;
let thread = std::thread::current();
let test: String = thread
.name()
.unwrap_or("screen")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let (name, body) = render(screen, &format!("{test}-{n}"));
let path = dir.join(name);
if let Err(e) = std::fs::create_dir_all(&dir).and_then(|()| std::fs::write(&path, body)) {
eprintln!("termlens: could not write {} ({VAR}): {e}", path.display());
}
}
#[cfg(feature = "serde")]
fn render(screen: &Screen, stem: &str) -> (String, String) {
let json =
serde_json::to_string(screen).unwrap_or_else(|_| screen.with_styles().to_string());
(format!("{stem}.screen.json"), json)
}
#[cfg(not(feature = "serde"))]
fn render(screen: &Screen, stem: &str) -> (String, String) {
(
format!("{stem}.screen.txt"),
screen.with_styles().to_string(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::screen::{Cell, Style, TermState};
fn tiny_screen() -> Screen {
let mut cells = Vec::new();
for ch in ['o', 'k'] {
cells.push(Cell::new(ch.to_string(), Style::default(), false, false));
}
cells.push(Cell::new(String::new(), Style::default(), false, false));
Screen::from_parts(3, 1, 0, 2, true, cells, TermState::default())
}
#[test]
fn timeout_display_embeds_screen_dump() {
let err = Error::Timeout {
waiting_for: "text \"ready\"".into(),
timeout: Duration::from_millis(250),
screen: tiny_screen(),
};
let msg = err.to_string();
assert!(msg.contains("timed out after 250ms"), "{msg}");
assert!(msg.contains("--- screen at timeout ---"), "{msg}");
assert!(msg.contains("size: 3x1 cursor: 0,2"), "{msg}");
assert!(msg.contains("\nok"), "{msg}");
assert_eq!(err.screen().unwrap().size(), (3, 1));
}
}