Repository: asciinema/asciinema (language: rust)

Issue:
[Feature Request] --append should work if file is empty of doesn't exist
Hi all,

It would be really helpful to be able to have asciinema to automatically create a file in a given chmod, if it doesnt exist, so that --append would work, if the file doesnt exist.

Idea would be to run asciinema on startup, or everytime terminal is open, and append the initial file.

Today, if the file doesnt exist, and the command has --append, then it wont work...
So ideally, it would be nice to be able to use --append, and if file doesnt exist, asciinema would create it...

Note: same applies if file exists but is empty, today it doesn't work, but ideally it should work..



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8ebe8257..fcd3f6051 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # asciinema changelog
 
+## 3.0.0 (wip)
+
+* Full rewrite in Rust
+* rec: `--append` can be used with `--raw` now
+* rec: use of `--append` and `--overwrite` together returns error now
+* rec: fixed saving of custom rec command in asciicast header
+* Improved error message when non-UTF-8 locale is detected
+
 ## 2.4.0 (2023-10-23)
 
 * When recording without file arg we now ask whether to save, upload or discard the recording (#576)
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 000000000..ddfb84979
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,405 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "anstream"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab91ebe16eb252986481c5b62f6098f3b698a45e34b5b98200cf20dd2484a44"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628"
+dependencies = [
+ "anstyle",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
+
+[[package]]
+name = "asciinema"
+version = "3.0.0-alpha.1"
+dependencies = [
+ "anyhow",
+ "clap",
+ "mio",
+ "nix",
+ "serde",
+ "serde_json",
+ "signal-hook",
+ "signal-hook-mio",
+ "termion",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clap"
+version = "4.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac495e00dcec98c83465d5ad66c5c4fabd652fd6686e7c6269b117e729a6f17b"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77ed9a32a62e6ca27175d00d29d05ca32e396ea1eb5fb01d8256b669cec7663"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "itoa"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
+
+[[package]]
+name = "libc"
+version = "0.2.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b"
+
+[[package]]
+name = "log"
+version = "0.4.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
+
+[[package]]
+name = "mio"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "nix"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
+dependencies = [
+ "bitflags 2.4.1",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "numtoa"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_termios"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f"
+dependencies = [
+ "redox_syscall",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
+
+[[package]]
+name = "serde"
+version = "1.0.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "signal-hook"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "syn"
+version = "2.0.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "termion"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90"
+dependencies = [
+ "libc",
+ "numtoa",
+ "redox_syscall",
+ "redox_termios",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 000000000..b652b470b
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "asciinema"
+version = "3.0.0-alpha.1"
+edition = "2021"
+authors = ["Marcin Kulik <m@ku1ik.com>"]
+homepage = "https://asciinema.org"
+repository = "https://github.com/asciinema/asciinema"
+description = "Terminal session recorder"
+license-file = "LICENSE"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = "1.0.75"
+nix = { version = "0.27", features = [ "fs", "term", "process", "signal" ] }
+mio = { version ="0.8", features = ["os-poll", "os-ext"] }
+termion = "2.0.1"
+serde = { version = "1.0.189", features = ["derive"] }
+serde_json = "1.0.107"
+clap = { version = "4.4.7", features = ["derive"] }
+signal-hook-mio = { version = "0.2.3", features = ["support-v0_8"] }
+signal-hook = "0.3.17"
diff --git a/doc/asciicast-v2.md b/doc/asciicast-v2.md
index 8f1b60ab4..ae9ab4098 100644
--- a/doc/asciicast-v2.md
+++ b/doc/asciicast-v2.md
@@ -72,10 +72,7 @@ Map of captured environment variables. Object (String -> String).
 Example env:
 
 ```json
-"env": {
-  "SHELL": "/bin/bash",
-  "TERM": "xterm-256color"
-}
+"env": { "SHELL": "/bin/bash", "TERM": "xterm-256color" }
 ```
 
 > Official asciinema recorder captures only `SHELL` and `TERM` by default. All
diff --git a/src/format.rs b/src/format.rs
new file mode 100644
index 000000000..4c4fddf4e
--- /dev/null
+++ b/src/format.rs
@@ -0,0 +1,20 @@
+pub mod asciicast;
+pub mod raw;
+use std::{collections::HashMap, io};
+
+pub trait Writer {
+    fn header(&mut self, header: &Header) -> io::Result<()>;
+    fn output(&mut self, time: f64, data: &[u8]) -> io::Result<()>;
+    fn input(&mut self, time: f64, data: &[u8]) -> io::Result<()>;
+    fn resize(&mut self, time: f64, size: (u16, u16)) -> io::Result<()>;
+}
+
+pub struct Header {
+    pub cols: u16,
+    pub rows: u16,
+    pub timestamp: u64,
+    pub idle_time_limit: Option<f32>,
+    pub command: Option<String>,
+    pub title: Option<String>,
+    pub env: HashMap<String, String>,
+}
diff --git a/src/format/asciicast.rs b/src/format/asciicast.rs
new file mode 100644
index 000000000..d688cf748
--- /dev/null
+++ b/src/format/asciicast.rs
@@ -0,0 +1,396 @@
+use anyhow::bail;
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::fmt::{self, Display};
+use std::fs;
+use std::io::BufRead;
+use std::io::{self, Write};
+use std::path::Path;
+
+pub struct Writer<W: Write> {
+    writer: W,
+    time_offset: f64,
+}
+
+#[derive(Deserialize)]
+pub struct Header {
+    width: u16,
+    height: u16,
+    timestamp: u64,
+    idle_time_limit: Option<f32>,
+    command: Option<String>,
+    title: Option<String>,
+    env: HashMap<String, String>,
+}
+
+pub struct Event {
+    pub time: f64,
+    pub code: EventCode,
+    pub data: String,
+}
+
+#[derive(PartialEq, Eq, Debug)]
+pub enum EventCode {
+    Output,
+    Input,
+    Resize,
+    Marker,
+    Other(char),
+}
+
+impl<W> Writer<W>
+where
+    W: Write,
+{
+    pub fn new(writer: W, time_offset: f64) -> Self {
+        Self {
+            writer,
+            time_offset,
+        }
+    }
+
+    pub fn write_header(&mut self, header: &Header) -> io::Result<()> {
+        writeln!(self.writer, "{}", serde_json::to_string(&header)?)
+    }
+
+    pub fn write_event(&mut self, mut event: Event) -> io::Result<()> {
+        event.time += self.time_offset;
+
+        writeln!(self.writer, "{}", serde_json::to_string(&event)?)
+    }
+}
+
+impl<W> super::Writer for Writer<W>
+where
+    W: Write,
+{
+    fn header(&mut self, header: &super::Header) -> io::Result<()> {
+        self.write_header(&header.into())
+    }
+
+    fn output(&mut self, time: f64, data: &[u8]) -> io::Result<()> {
+        self.write_event(Event::output(time, data))
+    }
+
+    fn input(&mut self, time: f64, data: &[u8]) -> io::Result<()> {
+        self.write_event(Event::input(time, data))
+    }
+
+    fn resize(&mut self, time: f64, size: (u16, u16)) -> io::Result<()> {
+        self.write_event(Event::resize(time, size))
+    }
+}
+
+pub fn open<R: BufRead>(
+    reader: R,
+) -> anyhow::Result<(super::Header, impl Iterator<Item = anyhow::Result<Event>>)> {
+    let mut lines = reader.lines();
+    let first_line = lines.next().ok_or(anyhow::anyhow!("empty"))??;
+    let header: Header = serde_json::from_str(&first_line)?;
+    let header: super::Header = (&header).into();
+
+    let events = lines
+        .filter(|l| l.as_ref().map_or(true, |l| !l.is_empty()))
+        .enumerate()
+        .map(|(i, l)| l.map(|l| parse_event(l, i + 2))?);
+
+    Ok((header, events))
+}
+
+fn parse_event(line: String, i: usize) -> anyhow::Result<Event> {
+    use EventCode::*;
+
+    let value: serde_json::Value = serde_json::from_str(&line)?;
+
+    let time = value[0]
+        .as_f64()
+        .ok_or(anyhow::anyhow!("line {}: invalid event time", i))?;
+
+    let code = match value[1].as_str() {
+        Some("o") => Output,
+        Some("i") => Input,
+        Some("r") => Resize,
+        Some("m") => Marker,
+        Some(s) if !s.is_empty() => Other(s.chars().next().unwrap()),
+        Some(_) => bail!("line {}: missing event code", i),
+        None => bail!("line {}: event code must be a string", i),
+    };
+
+    let data = match value[2].as_str() {
+        Some(data) => data.to_owned(),
+        None => bail!("line {}: event data must be a string", i),
+    };
+
+    Ok(Event { time, code, data })
+}
+
+pub fn get_duration<S: AsRef<Path>>(path: S) -> anyhow::Result<f64> {
+    let file = fs::File::open(path)?;
+    let reader = io::BufReader::new(file);
+    let (_header, events) = open(reader)?;
+    let time = events.last().map_or(Ok(0.0), |e| e.map(|e| e.time))?;
+
+    Ok(time)
+}
+
+impl Event {
+    pub fn output(time: f64, data: &[u8]) -> Self {
+        Event {
+            time,
+            code: EventCode::Output,
+            data: String::from_utf8_lossy(data).to_string(),
+        }
+    }
+
+    pub fn input(time: f64, data: &[u8]) -> Self {
+        Event {
+            time,
+            code: EventCode::Input,
+            data: String::from_utf8_lossy(data).to_string(),
+        }
+    }
+
+    pub fn resize(time: f64, size: (u16, u16)) -> Self {
+        Event {
+            time,
+            code: EventCode::Resize,
+            data: format!("{}x{}", size.0, size.1),
+        }
+    }
+}
+
+impl Display for EventCode {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result {
+        use EventCode::*;
+
+        match self {
+            Output => f.write_str("o"),
+            Input => f.write_str("i"),
+            Resize => f.write_str("r"),
+            Marker => f.write_str("m"),
+            Other(t) => f.write_str(&t.to_string()),
+        }
+    }
+}
+
+impl serde::Serialize for Header {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeMap;
+
+        let mut len = 4;
+
+        if self.idle_time_limit.is_some() {
+            len += 1;
+        }
+
+        if self.command.is_some() {
+            len += 1;
+        }
+
+        if self.title.is_some() {
+            len += 1;
+        }
+
+        if !self.env.is_empty() {
+            len += 1;
+        }
+
+        let mut map = serializer.serialize_map(Some(len))?;
+        map.serialize_entry("version", &2)?;
+        map.serialize_entry("width", &self.width)?;
+        map.serialize_entry("height", &self.height)?;
+        map.serialize_entry("timestamp", &self.timestamp)?;
+
+        if let Some(limit) = self.idle_time_limit {
+            map.serialize_entry("idle_time_limit", &limit)?;
+        }
+
+        if let Some(command) = &self.command {
+            map.serialize_entry("command", &command)?;
+        }
+
+        if let Some(title) = &self.title {
+            map.serialize_entry("title", &title)?;
+        }
+
+        if !self.env.is_empty() {
+            map.serialize_entry("env", &self.env)?;
+        }
+
+        map.end()
+    }
+}
+
+impl serde::Serialize for Event {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeTuple;
+        let mut tup = serializer.serialize_tuple(3)?;
+        tup.serialize_element(&self.time)?;
+        tup.serialize_element(&self.code.to_string())?;
+        tup.serialize_element(&self.data)?;
+        tup.end()
+    }
+}
+
+impl From<&Header> for super::Header {
+    fn from(header: &Header) -> Self {
+        Self {
+            cols: header.width,
+            rows: header.height,
+            timestamp: header.timestamp,
+            idle_time_limit: header.idle_time_limit,
+            command: header.command.clone(),
+            title: header.title.clone(),
+            env: header.env.clone(),
+        }
+    }
+}
+
+impl From<&super::Header> for Header {
+    fn from(header: &super::Header) -> Self {
+        Self {
+            width: header.cols,
+            height: header.rows,
+            timestamp: header.timestamp,
+            idle_time_limit: header.idle_time_limit,
+            command: header.command.clone(),
+            title: header.title.clone(),
+            env: header.env.clone(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{Event, EventCode, Header, Writer};
+    use std::collections::HashMap;
+    use std::fs::File;
+    use std::io;
+
+    #[test]
+    fn open() {
+        let file = File::open("tests/demo.cast").unwrap();
+        let (header, events) = super::open(io::BufReader::new(file)).unwrap();
+
+        let events = events
+            .take(7)
+            .collect::<anyhow::Result<Vec<Event>>>()
+            .unwrap();
+
+        assert_eq!((header.cols, header.rows), (75, 18));
+
+        assert_eq!(events[1].time, 0.100989);
+        assert_eq!(events[1].code, EventCode::Output);
+        assert_eq!(events[1].data, "\u{1b}[?2004h");
+
+        assert_eq!(events[5].time, 1.511526);
+        assert_eq!(events[5].code, EventCode::Input);
+        assert_eq!(events[5].data, "v");
+
+        assert_eq!(events[6].time, 1.511937);
+        assert_eq!(events[6].code, EventCode::Output);
+        assert_eq!(events[6].data, "v");
+    }
+
+    #[test]
+    fn writer() {
+        let mut data = Vec::new();
+
+        let cursor = io::Cursor::new(&mut data);
+        let mut fw = Writer::new(cursor, 0.0);
+
+        let header = Header {
+            width: 80,
+            height: 24,
+            timestamp: 1,
+            idle_time_limit: None,
+            command: None,
+            title: None,
+            env: Default::default(),
+        };
+
+        fw.write_header(&header).unwrap();
+        fw.write_event(Event::output(1.0, "hello\r\n".as_bytes()))
+            .unwrap();
+
+        let data_len = data.len() as u64;
+        let mut cursor = io::Cursor::new(&mut data);
+        cursor.set_position(data_len);
+        let mut fw = Writer::new(cursor, 1.0);
+
+        fw.write_event(Event::output(1.0, "world".as_bytes()))
+            .unwrap();
+        fw.write_event(Event::input(2.0, " ".as_bytes())).unwrap();
+        fw.write_event(Event::resize(3.0, (100, 40))).unwrap();
+
+        let lines = parse(data);
+
+        assert_eq!(lines[0]["version"], 2);
+        assert_eq!(lines[0]["width"], 80);
+        assert_eq!(lines[0]["height"], 24);
+        assert_eq!(lines[0]["timestamp"], 1);
+        assert_eq!(lines[1][0], 1.0);
+        assert_eq!(lines[1][1], "o");
+        assert_eq!(lines[1][2], "hello\r\n");
+        assert_eq!(lines[2][0], 2.0);
+        assert_eq!(lines[2][1], "o");
+        assert_eq!(lines[2][2], "world");
+        assert_eq!(lines[3][0], 3.0);
+        assert_eq!(lines[3][1], "i");
+        assert_eq!(lines[3][2], " ");
+        assert_eq!(lines[4][0], 4.0);
+        assert_eq!(lines[4][1], "r");
+        assert_eq!(lines[4][2], "100x40");
+    }
+
+    #[test]
+    fn write_header() {
+        let mut data = Vec::new();
+        let mut fw = Writer::new(io::Cursor::new(&mut data), 0.0);
+
+        let mut env = HashMap::new();
+        env.insert("SHELL".to_owned(), "/usr/bin/fish".to_owned());
+        env.insert("TERM".to_owned(), "xterm256-color".to_owned());
+
+        let header = Header {
+            width: 80,
+            height: 24,
+            timestamp: 1,
+            idle_time_limit: Some(1.5),
+            command: Some("/bin/bash".to_owned()),
+            title: Some("Demo".to_owned()),
+            env,
+        };
+
+        fw.write_header(&header).unwrap();
+
+        let lines = parse(data);
+
+        assert_eq!(lines[0]["version"], 2);
+        assert_eq!(lines[0]["width"], 80);
+        assert_eq!(lines[0]["height"], 24);
+        assert_eq!(lines[0]["timestamp"], 1);
+        assert_eq!(lines[0]["idle_time_limit"], 1.5);
+        assert_eq!(lines[0]["command"], "/bin/bash");
+        assert_eq!(lines[0]["title"], "Demo");
+        assert_eq!(lines[0]["env"].as_object().unwrap().len(), 2);
+        assert_eq!(lines[0]["env"]["SHELL"], "/usr/bin/fish");
+        assert_eq!(lines[0]["env"]["TERM"], "xterm256-color");
+    }
+
+    fn parse(json: Vec<u8>) -> Vec<serde_json::Value> {
+        String::from_utf8(json)
+            .unwrap()
+            .split('\n')
+            .filter(|s| !s.is_empty())
+            .map(serde_json::from_str::<serde_json::Value>)
+            .collect::<serde_json::Result<Vec<_>>>()
+            .unwrap()
+    }
+}
diff --git a/src/format/raw.rs b/src/format/raw.rs
new file mode 100644
index 000000000..345fcb20f
--- /dev/null
+++ b/src/format/raw.rs
@@ -0,0 +1,29 @@
+use std::io::{self, Write};
+
+pub struct Writer<W> {
+    writer: W,
+}
+
+impl<W> Writer<W> {
+    pub fn new(writer: W) -> Self {
+        Writer { writer }
+    }
+}
+
+impl<W: Write> super::Writer for Writer<W> {
+    fn header(&mut self, header: &super::Header) -> io::Result<()> {
+        write!(self.writer, "\x1b[8;{};{}t", header.rows, header.cols)
+    }
+
+    fn output(&mut self, _time: f64, data: &[u8]) -> io::Result<()> {
+        self.writer.write_all(data)
+    }
+
+    fn input(&mut self, _time: f64, _data: &[u8]) -> io::Result<()> {
+        Ok(())
+    }
+
+    fn resize(&mut self, _time: f64, _size: (u16, u16)) -> io::Result<()> {
+        Ok(())
+    }
+}
diff --git a/src/locale.rs b/src/locale.rs
new file mode 100644
index 000000000..e14537558
--- /dev/null
+++ b/src/locale.rs
@@ -0,0 +1,42 @@
+use nix::libc::{self, CODESET, LC_ALL};
+use std::env;
+use std::ffi::CStr;
+
+pub fn check_utf8_locale() -> anyhow::Result<()> {
+    initialize_from_env();
+
+    let encoding = get_encoding();
+
+    if ["US-ASCII", "UTF-8"].contains(&encoding.as_str()) {
+        Ok(())
+    } else {
+        let env = env::var("LC_ALL")
+            .map(|v| format!("LC_ALL={}", v))
+            .or(env::var("LC_CTYPE").map(|v| format!("LC_CTYPE={}", v)))
+            .or(env::var("LANG").map(|v| format!("LANG={}", v)))
+            .unwrap_or("".to_string());
+
+        Err(anyhow::anyhow!("asciinema requires ASCII or UTF-8 character encoding. The environment ({}) specifies the character set \"{}\". Check the output of `locale` command.", env, encoding))
+    }
+}
+
+pub fn initialize_from_env() {
+    unsafe {
+        libc::setlocale(LC_ALL, b"\0".as_ptr() as *const libc::c_char);
+    };
+}
+
+fn get_encoding() -> String {
+    let codeset = unsafe { CStr::from_ptr(libc::nl_langinfo(CODESET)) };
+
+    let mut encoding = codeset
+        .to_str()
+        .expect("Locale codeset name is not a valid UTF-8 string")
+        .to_owned();
+
+    if encoding == "ANSI_X3.4-1968" {
+        encoding = "US-ASCII".to_owned();
+    }
+
+    encoding
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 000000000..a8ce79d3a
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,235 @@
+mod format;
+mod locale;
+mod pty;
+mod recorder;
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+use format::{asciicast, raw};
+use std::collections::{HashMap, HashSet};
+use std::env;
+use std::ffi::{CString, OsString};
+use std::fs;
+use std::io;
+use std::os::unix::ffi::OsStringExt;
+use std::path::Path;
+
+#[derive(Debug, Parser)]
+#[clap(author, version, about)]
+#[command(name = "asciinema")]
+struct Cli {
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(Debug, Subcommand)]
+enum Commands {
+    /// Record terminal session
+    #[command(name = "rec")]
+    Record {
+        filename: String,
+
+        /// Enable input recording
+        #[arg(long)]
+        stdin: bool,
+
+        /// Append to existing asciicast file
+        #[arg(long)]
+        append: bool,
+
+        /// Save raw output only
+        #[arg(long)]
+        raw: bool,
+
+        /// Overwrite target file if it already exists
+        #[arg(long, conflicts_with = "append")]
+        overwrite: bool,
+
+        /// Command to record [default: $SHELL]
+        #[arg(short, long)]
+        command: Option<String>,
+
+        /// List of env vars to save
+        #[arg(short, long, default_value_t = String::from("SHELL,TERM"))]
+        env: String,
+
+        /// Title of the recording
+        #[arg(short, long)]
+        title: Option<String>,
+
+        /// Limit idle time to given number of seconds
+        #[arg(short, long, value_name = "SECS")]
+        idle_time_limit: Option<f32>,
+
+        /// Override terminal width (columns) for recorded command
+        #[arg(long)]
+        cols: Option<u16>,
+
+        /// Override terminal height (rows) for recorded command
+        #[arg(long)]
+        rows: Option<u16>,
+
+        /// Quiet mode - suppress all notices/warnings
+        #[arg(short, long)]
+        quiet: bool,
+    },
+
+    /// Play terminal session
+    Play {
+        filename: String,
+
+        /// Limit idle time to given number of seconds
+        #[arg(short, long, value_name = "SECS")]
+        idle_time_limit: Option<f64>,
+
+        /// Set playback speed
+        #[arg(short, long)]
+        speed: Option<f64>,
+
+        /// Loop loop loop loop
+        #[arg(short, long, name = "loop")]
+        loop_: bool,
+
+        /// Automatically pause on markers
+        #[arg(short = 'm', long)]
+        pause_on_markers: bool,
+    },
+
+    /// Print full output of terminal sessions
+    Cat {
+        #[arg(required = true)]
+        filename: Vec<String>,
+    },
+
+    /// Upload recording to asciinema.org
+    Upload {
+        /// Filename/path of asciicast to upload
+        filename: String,
+    },
+
+    /// Link this system to asciinema.org account
+    Auth,
+}
+
+fn main() -> Result<()> {
+    let cli = Cli::parse();
+
+    match cli.command {
+        Commands::Record {
+            filename,
+            stdin,
+            mut append,
+            raw,
+            mut overwrite,
+            command,
+            env,
+            title,
+            idle_time_limit,
+            cols,
+            rows,
+            quiet,
+        } => {
+            locale::check_utf8_locale()?;
+
+            let path = Path::new(&filename);
+
+            if path.exists() {
+                let metadata = fs::metadata(path)?;
+
+                if metadata.len() == 0 {
+                    overwrite = true;
+                    append = false;
+                }
+                // TODO if !append && !overwrite - error message
+            } else {
+                append = false;
+            }
+
+            let mut opts = fs::OpenOptions::new();
+
+            opts.write(true)
+                .append(append)
+                .create(overwrite)
+                .create_new(!overwrite && !append)
+                .truncate(overwrite);
+
+            let writer: Box<dyn format::Writer + Send> = if raw {
+                let file = opts.open(&filename)?;
+
+                Box::new(raw::Writer::new(file))
+            } else {
+                let time_offset = if append {
+                    asciicast::get_duration(&filename)?
+                } else {
+                    0.0
+                };
+
+                let file = io::LineWriter::new(opts.open(&filename)?);
+                let writer = asciicast::Writer::new(file, time_offset);
+
+                Box::new(writer)
+            };
+
+            let mut recorder = recorder::Recorder::new(
+                writer,
+                append,
+                stdin,
+                idle_time_limit,
+                command.clone(),
+                title,
+                capture_env(&env),
+            );
+
+            let exec_args = build_exec_args(command);
+            let exec_env = build_exec_env();
+
+            pty::exec(&exec_args, &exec_env, (cols, rows), &mut recorder)?;
+        }
+
+        Commands::Play {
+            filename,
+            idle_time_limit,
+            speed,
+            loop_,
+            pause_on_markers,
+        } => todo!(),
+
+        Commands::Cat { filename } => todo!(),
+
+        Commands::Upload { filename } => todo!(),
+
+        Commands::Auth => todo!(),
+    }
+
+    Ok(())
+}
+
+fn capture_env(vars: &str) -> HashMap<String, String> {
+    let vars = vars.split(',').collect::<HashSet<_>>();
+
+    env::vars()
+        .filter(|(k, _v)| vars.contains(&k.as_str()))
+        .collect::<HashMap<_, _>>()
+}
+
+fn build_exec_args(command: Option<String>) -> Vec<String> {
+    let command = command
+        .or(env::var("SHELL").ok())
+        .unwrap_or("/bin/sh".to_owned());
+
+    vec!["/bin/sh".to_owned(), "-c".to_owned(), command]
+}
+
+fn build_exec_env() -> Vec<CString> {
+    env::vars_os()
+        .map(format_env_var)
+        .chain(std::iter::once(CString::new("ASCIINEMA_REC=1").unwrap()))
+        .collect()
+}
+
+fn format_env_var((key, value): (OsString, OsString)) -> CString {
+    let mut key_value = key.into_vec();
+    key_value.push(b'=');
+    key_value.extend(value.into_vec());
+
+    CString::new(key_value).unwrap()
+}
diff --git a/src/pty.rs b/src/pty.rs
new file mode 100644
index 000000000..a04ab756b
--- /dev/null
+++ b/src/pty.rs
@@ -0,0 +1,380 @@
+use anyhow::bail;
+use mio::unix::SourceFd;
+use nix::{fcntl, libc, pty, sys::signal, sys::wait, unistd, unistd::ForkResult};
+use signal_hook::consts::signal::*;
+use signal_hook_mio::v0_8::Signals;
+use std::ffi::{CString, NulError};
+use std::fs;
+use std::io::{self, Read, Write};
+use std::ops::Deref;
+use std::os::fd::RawFd;
+use std::os::unix::io::{AsRawFd, FromRawFd};
+use termion::raw::IntoRawMode;
+
+pub trait Recorder {
+    fn start(&mut self, size: (u16, u16)) -> io::Result<()>;
+    fn output(&mut self, data: &[u8]);
+    fn input(&mut self, data: &[u8]);
+    fn resize(&mut self, size: (u16, u16));
+}
+
+pub fn exec<S: AsRef<str>, R: Recorder>(
+    args: &[S],
+    env: &[CString],
+    winsize_override: (Option<u16>, Option<u16>),
+    recorder: &mut R,
+) -> anyhow::Result<i32> {
+    let tty = open_tty()?;
+    let winsize = get_tty_size(tty.as_raw_fd(), winsize_override);
+    recorder.start((winsize.ws_col, winsize.ws_row))?;
+    let result = unsafe { pty::forkpty(Some(&winsize), None) }?;
+
+    match result.fork_result {
+        ForkResult::Parent { child } => handle_parent(
+            result.master.as_raw_fd(),
+            tty,
+            child,
+            winsize_override,
+            recorder,
+        ),
+
+        ForkResult::Child => {
+            handle_child(args, env)?;
+            unreachable!();
+        }
+    }
+}
+
+fn handle_parent<R: Recorder>(
+    master_fd: RawFd,
+    tty: fs::File,
+    child: unistd::Pid,
+    winsize_override: (Option<u16>, Option<u16>),
+    recorder: &mut R,
+) -> anyhow::Result<i32> {
+    let copy_result = copy(master_fd, tty, child, winsize_override, recorder);
+    let wait_result = wait::waitpid(child, None);
+    copy_result?;
+
+    match wait_result {
+        Ok(wait::WaitStatus::Exited(_pid, status)) => Ok(status),
+        Ok(wait::WaitStatus::Signaled(_pid, signal, ..)) => Ok(128 + signal as i32),
+        Ok(_) => Ok(1),
+        Err(e) => Err(anyhow::anyhow!(e)),
+    }
+}
+
+const MASTER: mio::Token = mio::Token(0);
+const TTY: mio::Token = mio::Token(1);
+const SIGNAL: mio::Token = mio::Token(2);
+const BUF_SIZE: usize = 128 * 1024;
+
+fn copy<R: Recorder>(
+    master_fd: RawFd,
+    tty: fs::File,
+    child: unistd::Pid,
+    winsize_override: (Option<u16>, Option<u16>),
+    recorder: &mut R,
+) -> anyhow::Result<()> {
+    let mut master = unsafe { fs::File::from_raw_fd(master_fd) };
+    let mut poll = mio::Poll::new()?;
+    let mut events = mio::Events::with_capacity(128);
+    let mut master_source = SourceFd(&master_fd);
+    let mut tty = tty.into_raw_mode()?;
+    let tty_fd = tty.as_raw_fd();
+    let mut tty_source = SourceFd(&tty_fd);
+    let mut signals = Signals::new([SIGWINCH, SIGINT, SIGTERM, SIGQUIT, SIGHUP])?;
+    let mut buf = [0u8; BUF_SIZE];
+    let mut input: Vec<u8> = Vec::with_capacity(BUF_SIZE);
+    let mut output: Vec<u8> = Vec::with_capacity(BUF_SIZE);
+    let mut flush = false;
+
+    set_non_blocking(&master_fd)?;
+    set_non_blocking(&tty_fd)?;
+
+    poll.registry()
+        .register(&mut master_source, MASTER, mio::Interest::READABLE)?;
+
+    poll.registry()
+        .register(&mut tty_source, TTY, mio::Interest::READABLE)?;
+
+    poll.registry()
+        .register(&mut signals, SIGNAL, mio::Interest::READABLE)?;
+
+    loop {
+        if let Err(e) = poll.poll(&mut events, None) {
+            if e.kind() == io::ErrorKind::Interrupted {
+                continue;
+            } else {
+                bail!(e);
+            }
+        }
+
+        for event in events.iter() {
+            match event.token() {
+                MASTER => {
+                    if event.is_readable() {
+                        let offset = output.len();
+                        let read = read_all(&mut master, &mut buf, &mut output)?;
+
+                        if read > 0 {
+                            recorder.output(&output[offset..]);
+
+                            poll.registry().reregister(
+                                &mut tty_source,
+                                TTY,
+                                mio::Interest::READABLE | mio::Interest::WRITABLE,
+                            )?;
+                        }
+                    }
+
+                    if event.is_writable() {
+                        let left = write_all(&mut master, &mut input)?;
+
+                        if left == 0 {
+                            poll.registry().reregister(
+                                &mut master_source,
+                                MASTER,
+                                mio::Interest::READABLE,
+                            )?;
+                        }
+                    }
+
+                    if event.is_read_closed() {
+                        poll.registry().deregister(&mut master_source)?;
+
+                        if !output.is_empty() {
+                            flush = true;
+                        } else {
+                            return Ok(());
+                        }
+                    }
+                }
+
+                TTY => {
+                    if event.is_writable() {
+                        let left = write_all(&mut tty, &mut output)?;
+
+                        if left == 0 {
+                            if flush {
+                                return Ok(());
+                            } else {
+                                poll.registry().reregister(
+                                    &mut tty_source,
+                                    TTY,
+                                    mio::Interest::READABLE,
+                                )?;
+                            }
+                        }
+                    }
+
+                    if event.is_readable() {
+                        let offset = input.len();
+                        let read = read_all(&mut tty.deref(), &mut buf, &mut input)?;
+
+                        if read > 0 {
+                            recorder.input(&input[offset..]);
+
+                            poll.registry().reregister(
+                                &mut master_source,
+                                MASTER,
+                                mio::Interest::READABLE | mio::Interest::WRITABLE,
+                            )?;
+                        }
+                    }
+
+                    if event.is_read_closed() {
+                        poll.registry().deregister(&mut tty_source).unwrap();
+                        return Ok(());
+                    }
+                }
+
+                SIGNAL => {
+                    for signal in signals.pending() {
+                        match signal {
+                            SIGWINCH => {
+                                let winsize = get_tty_size(tty_fd, winsize_override);
+                                set_pty_size(master_fd, &winsize);
+                                recorder.resize((winsize.ws_col, winsize.ws_row));
+                            }
+
+                            SIGINT => (),
+
+                            SIGTERM | SIGQUIT | SIGHUP => {
+                                unsafe { libc::kill(child.as_raw(), SIGTERM) };
+                                return Ok(());
+                            }
+
+                            _ => (),
+                        }
+                    }
+                }
+
+                _ => (),
+            }
+        }
+    }
+}
+
+fn handle_child<S: AsRef<str>>(args: &[S], env: &[CString]) -> anyhow::Result<()> {
+    use signal::{SigHandler, Signal};
+
+    let args = args
+        .iter()
+        .map(|s| CString::new(s.as_ref()))
+        .collect::<Result<Vec<CString>, NulError>>()?;
+
+    unsafe { signal::signal(Signal::SIGPIPE, SigHandler::SigDfl) }?;
+    unistd::execvpe(&args[0], &args, env)?;
+    unsafe { libc::_exit(1) }
+}
+
+fn open_tty() -> io::Result<fs::File> {
+    fs::OpenOptions::new()
+        .read(true)
+        .write(true)
+        .open("/dev/tty")
+}
+
+fn get_tty_size(tty_fd: i32, winsize_override: (Option<u16>, Option<u16>)) -> pty::Winsize {
+    let mut winsize = pty::Winsize {
+        ws_row: 24,
+        ws_col: 80,
+        ws_xpixel: 0,
+        ws_ypixel: 0,
+    };
+
+    unsafe { libc::ioctl(tty_fd, libc::TIOCGWINSZ, &mut winsize) };
+
+    if let Some(cols) = winsize_override.0 {
+        winsize.ws_col = cols;
+    }
+
+    if let Some(rows) = winsize_override.1 {
+        winsize.ws_row = rows;
+    }
+
+    winsize
+}
+
+fn set_pty_size(pty_fd: i32, winsize: &pty::Winsize) {
+    unsafe { libc::ioctl(pty_fd, libc::TIOCSWINSZ, winsize) };
+}
+
+fn set_non_blocking(fd: &RawFd) -> Result<(), io::Error> {
+    use fcntl::{fcntl, FcntlArg::*, OFlag};
+
+    let flags = fcntl(*fd, F_GETFL)?;
+    let mut oflags = OFlag::from_bits_truncate(flags);
+    oflags |= OFlag::O_NONBLOCK;
+    fcntl(*fd, F_SETFL(oflags))?;
+
+    Ok(())
+}
+
+fn read_all<R: Read>(source: &mut R, buf: &mut [u8], out: &mut Vec<u8>) -> io::Result<usize> {
+    let mut read = 0;
+
+    loop {
+        match source.read(buf) {
+            Ok(0) => (),
+
+            Ok(n) => {
+                out.extend_from_slice(&buf[0..n]);
+                read += n;
+            }
+
+            Err(_) => {
+                break;
+            }
+        }
+    }
+
+    Ok(read)
+}
+
+fn write_all<W: Write>(sink: &mut W, data: &mut Vec<u8>) -> io::Result<usize> {
+    let mut buf: &[u8] = data.as_ref();
+
+    loop {
+        match sink.write(buf) {
+            Ok(0) => (),
+
+            Ok(n) => {
+                buf = &buf[n..];
+
+                if buf.is_empty() {
+                    break;
+                }
+            }
+
+            Err(_) => {
+                break;
+            }
+        }
+    }
+
+    let left = buf.len();
+
+    if left == 0 {
+        data.clear();
+    } else {
+        let rot = data.len() - left;
+        data.rotate_left(rot);
+        data.truncate(left);
+    }
+
+    Ok(left)
+}
+
+#[cfg(test)]
+mod tests {
+    #[derive(Default)]
+    struct TestRecorder {
+        size: Option<(u16, u16)>,
+        output: Vec<Vec<u8>>,
+    }
+
+    impl super::Recorder for TestRecorder {
+        fn start(&mut self, size: (u16, u16)) -> std::io::Result<()> {
+            self.size = Some(size);
+            Ok(())
+        }
+
+        fn output(&mut self, data: &[u8]) {
+            self.output.push(data.into());
+        }
+
+        fn input(&mut self, _data: &[u8]) {}
+        fn resize(&mut self, _size: (u16, u16)) {}
+    }
+
+    impl TestRecorder {
+        fn output(&self) -> Vec<String> {
+            self.output
+                .iter()
+                .map(|x| String::from_utf8_lossy(x).to_string())
+                .collect::<Vec<_>>()
+        }
+    }
+
+    #[test]
+    fn exec() {
+        let mut recorder = TestRecorder::default();
+
+        let code = r#"
+import sys;
+import time;
+sys.stdout.write('foo');
+sys.stdout.flush();
+time.sleep(0.01);
+sys.stdout.write('bar');
+"#;
+
+        let result = super::exec(&["python3", "-c", code], &[], (None, None), &mut recorder);
+
+        assert!(result.is_ok());
+        assert!(recorder.size.is_some());
+        assert_eq!(recorder.output(), vec!["foo", "bar"]);
+    }
+}
diff --git a/src/recorder.rs b/src/recorder.rs
new file mode 100644
index 000000000..baf278609
--- /dev/null
+++ b/src/recorder.rs
@@ -0,0 +1,136 @@
+use crate::format;
+use crate::pty;
+use std::collections::HashMap;
+use std::io;
+use std::sync::mpsc;
+use std::thread;
+use std::time::{Instant, SystemTime, UNIX_EPOCH};
+
+pub struct Recorder {
+    writer: Option<Box<dyn format::Writer + Send>>,
+    start_time: Instant,
+    append: bool,
+    record_input: bool,
+    idle_time_limit: Option<f32>,
+    command: Option<String>,
+    title: Option<String>,
+    env: HashMap<String, String>,
+    sender: mpsc::Sender<Message>,
+    receiver: Option<mpsc::Receiver<Message>>,
+    handle: Option<JoinHandle>,
+}
+
+enum Message {
+    Output(f64, Vec<u8>),
+    Input(f64, Vec<u8>),
+    Resize(f64, (u16, u16)),
+}
+
+struct JoinHandle(Option<thread::JoinHandle<()>>);
+
+impl Recorder {
+    pub fn new(
+        writer: Box<dyn format::Writer + Send>,
+        append: bool,
+        record_input: bool,
+        idle_time_limit: Option<f32>,
+        command: Option<String>,
+        title: Option<String>,
+        env: HashMap<String, String>,
+    ) -> Self {
+        let (sender, receiver) = mpsc::channel();
+
+        Recorder {
+            writer: Some(writer),
+            start_time: Instant::now(),
+            append,
+            record_input,
+            idle_time_limit,
+            command,
+            title,
+            env,
+            sender,
+            receiver: Some(receiver),
+            handle: None,
+        }
+    }
+
+    fn elapsed_time(&self) -> f64 {
+        self.start_time.elapsed().as_secs_f64()
+    }
+}
+
+impl pty::Recorder for Recorder {
+    fn start(&mut self, size: (u16, u16)) -> io::Result<()> {
+        let timestamp = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_secs();
+
+        let mut writer = self.writer.take().unwrap();
+        let receiver = self.receiver.take().unwrap();
+
+        if !self.append {
+            let header = format::Header {
+                cols: size.0,
+                rows: size.1,
+                timestamp,
+                idle_time_limit: self.idle_time_limit,
+                command: self.command.clone(),
+                title: self.title.clone(),
+                env: self.env.clone(),
+            };
+
+            writer.header(&header)?;
+        }
+
+        let handle = thread::spawn(move || {
+            for msg in receiver {
+                match msg {
+                    Message::Output(time, data) => {
+                        let _ = writer.output(time, &data);
+                    }
+
+                    Message::Input(time, data) => {
+                        let _ = writer.input(time, &data);
+                    }
+
+                    Message::Resize(time, size) => {
+                        let _ = writer.resize(time, size);
+                    }
+                }
+            }
+        });
+
+        self.handle = Some(JoinHandle(Some(handle)));
+        self.start_time = Instant::now();
+
+        Ok(())
+    }
+
+    fn output(&mut self, data: &[u8]) {
+        let msg = Message::Output(self.elapsed_time(), data.into());
+        let _ = self.sender.send(msg);
+        // TODO use notifier for error reporting
+    }
+
+    fn input(&mut self, data: &[u8]) {
+        if self.record_input {
+            let msg = Message::Input(self.elapsed_time(), data.into());
+            let _ = self.sender.send(msg);
+            // TODO use notifier for error reporting
+        }
+    }
+
+    fn resize(&mut self, size: (u16, u16)) {
+        let msg = Message::Resize(self.elapsed_time(), size);
+        let _ = self.sender.send(msg);
+        // TODO use notifier for error reporting
+    }
+}
+
+impl Drop for JoinHandle {
+    fn drop(&mut self) {
+        self.0.take().unwrap().join().expect("Thread panicked");
+    }
+}


Test patch: