├── cli ├── LICENSE-MIT ├── LICENSE-APACHE ├── Dockerfile ├── Cargo.toml ├── package.sh ├── rainbow.sh ├── CHANGELOG.md ├── tests │ ├── e2e.rs │ └── snapshots │ │ ├── print-with-failures.svg │ │ ├── help.svg │ │ ├── print.svg │ │ ├── test.svg │ │ └── test-fail.svg ├── src │ └── shell.rs └── README.md ├── lib ├── LICENSE-MIT ├── LICENSE-APACHE ├── tests │ ├── non-utf8.txt │ └── version_match.rs ├── src │ ├── term │ │ ├── mod.rs │ │ └── tests.rs │ ├── svg │ │ ├── font.rs │ │ ├── data.rs │ │ ├── common.handlebars │ │ └── default.svg.handlebars │ ├── shell │ │ └── standard.rs │ ├── test │ │ ├── tests.rs │ │ └── utils.rs │ ├── traits.rs │ └── utils.rs ├── Cargo.toml ├── README.md └── CHANGELOG.md ├── .gitignore ├── .dockerignore ├── examples ├── fonts │ ├── FiraMono-Bold.ttf │ ├── FiraMono-Regular.ttf │ ├── RobotoMono-VariableFont_wght.ttf │ └── RobotoMono-Italic-VariableFont_wght.ttf ├── config.toml ├── failure-pwsh.svg ├── failure-sh.svg ├── failure-bash-pty.svg ├── custom.html.handlebars ├── generate-snapshots.sh └── README.md ├── .clippy.toml ├── .github ├── workflows │ ├── scheduled.yml │ ├── build-reusable.yml │ ├── release.yml │ └── ci.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── pull_request_template.md ├── .editorconfig ├── e2e-tests └── rainbow │ ├── README.md │ ├── Cargo.toml │ ├── src │ ├── bin │ │ └── repl.rs │ └── main.rs │ └── repl.svg ├── deny.toml ├── LICENSE-MIT ├── Cargo.toml ├── CONTRIBUTING.md ├── README.md └── FAQ.md /cli/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | ../LICENSE-MIT -------------------------------------------------------------------------------- /lib/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | ../LICENSE-MIT -------------------------------------------------------------------------------- /cli/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | ../LICENSE-APACHE -------------------------------------------------------------------------------- /lib/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | ../LICENSE-APACHE -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust 2 | target 3 | 4 | # IDE 5 | .idea 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Do not send the `target` dirs to the Docker builder 2 | target 3 | -------------------------------------------------------------------------------- /lib/tests/non-utf8.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowli/term-transcript/HEAD/lib/tests/non-utf8.txt -------------------------------------------------------------------------------- /examples/fonts/FiraMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowli/term-transcript/HEAD/examples/fonts/FiraMono-Bold.ttf -------------------------------------------------------------------------------- /examples/fonts/FiraMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowli/term-transcript/HEAD/examples/fonts/FiraMono-Regular.ttf -------------------------------------------------------------------------------- /examples/fonts/RobotoMono-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowli/term-transcript/HEAD/examples/fonts/RobotoMono-VariableFont_wght.ttf -------------------------------------------------------------------------------- /examples/fonts/RobotoMono-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowli/term-transcript/HEAD/examples/fonts/RobotoMono-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /.clippy.toml: -------------------------------------------------------------------------------- 1 | # Minimum supported Rust version. Should be consistent with CI and mentions 2 | # in crate READMEs. 3 | msrv = "1.83" 4 | 5 | doc-valid-idents = ["PowerShell", ".."] 6 | -------------------------------------------------------------------------------- /.github/workflows/scheduled.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled checks 2 | 3 | on: 4 | schedule: 5 | - cron: "0 2 * * MON" 6 | 7 | jobs: 8 | build: 9 | uses: ./.github/workflows/build-reusable.yml 10 | -------------------------------------------------------------------------------- /lib/tests/version_match.rs: -------------------------------------------------------------------------------- 1 | use version_sync::{assert_html_root_url_updated, assert_markdown_deps_updated}; 2 | 3 | #[test] 4 | fn readme_is_in_sync() { 5 | assert_markdown_deps_updated!("README.md"); 6 | } 7 | 8 | #[test] 9 | fn html_root_url_is_in_sync() { 10 | assert_html_root_url_updated!("src/lib.rs"); 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.rs] 12 | indent_size = 4 13 | 14 | [*.{md,svg}] 15 | trim_trailing_whitespace = false 16 | 17 | [tests/non-utf8.txt] 18 | charset = latin1 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "03:00" 8 | groups: 9 | dev-dependencies: 10 | dependency-type: "development" 11 | minor-changes: 12 | update-types: 13 | - "minor" 14 | - "patch" 15 | open-pull-requests-limit: 10 16 | assignees: 17 | - slowli 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Use this template to request features 4 | title: '' 5 | labels: feat 6 | assignees: '' 7 | --- 8 | 9 | ## Feature request 10 | 11 | 12 | 13 | ### Why? 14 | 15 | 16 | 17 | ### Alternatives 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Use this template for reporting issues 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Bug report 10 | 11 | 12 | 13 | ### Steps to reproduce 14 | 15 | 16 | 17 | ### Expected behavior 18 | 19 | 20 | 21 | ### Environment 22 | 23 | 24 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What? 2 | 3 | 4 | 5 | 6 | 7 | ## Why? 8 | 9 | 10 | 12 | 13 | -------------------------------------------------------------------------------- /examples/config.toml: -------------------------------------------------------------------------------- 1 | # Custom template configuration. 2 | line_height = 1.3 3 | width = 900 4 | window_frame = true 5 | line_numbers = 'continuous' 6 | wrap = { hard_break_at = 100 } 7 | scroll = { max_height = 300, pixels_per_scroll = 18, interval = 1.5 } 8 | 9 | [palette.colors] 10 | black = '#3c3836' 11 | red = '#b85651' 12 | green = '#8f9a52' 13 | yellow = '#c18f41' 14 | blue = '#68948a' 15 | magenta = '#ab6c7d' 16 | cyan = '#72966c' 17 | white = '#a89984' 18 | 19 | [palette.intense_colors] 20 | black = '#5a524c' 21 | red = '#b85651' 22 | green = '#a9b665' 23 | yellow = '#d8a657' 24 | blue = '#7daea3' 25 | magenta = '#d3869b' 26 | cyan = '#89b482' 27 | white = '#ddc7a1' 28 | -------------------------------------------------------------------------------- /e2e-tests/rainbow/README.md: -------------------------------------------------------------------------------- 1 | # E2E Test for term-transcript 2 | 3 | This is a simple binary crate that prints colorful output to stdout: 4 | 5 | ![Example of output](../../examples/rainbow.svg) 6 | 7 | The crate also has a separate REPL binary that echoes provided input 8 | with colors / styles applied according to keywords: 9 | 10 | ![Example of REPL output](repl.svg) 11 | 12 | ## Why separate crate? 13 | 14 | Although Cargo builds example targets when testing, it does not enforce 15 | a particular build order and appends a suffix for the names of produced executables. 16 | This makes it quite difficult to use an executable example for E2E tests. 17 | Binary targets are much easier because they are guaranteed to be built before 18 | integration tests, and are named properly. 19 | -------------------------------------------------------------------------------- /e2e-tests/rainbow/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "term-transcript-rainbow" 3 | publish = false 4 | version.workspace = true 5 | authors.workspace = true 6 | edition.workspace = true 7 | license.workspace = true 8 | 9 | [[bin]] 10 | name = "rainbow" 11 | path = "src/main.rs" 12 | 13 | [[bin]] 14 | name = "rainbow-repl" 15 | path = "src/bin/repl.rs" 16 | 17 | [dependencies] 18 | anyhow.workspace = true 19 | termcolor.workspace = true 20 | 21 | [dependencies.term-transcript] 22 | path = "../../lib" 23 | features = ["tracing"] 24 | 25 | [dev-dependencies] 26 | handlebars.workspace = true 27 | pretty_assertions.workspace = true 28 | tempfile.workspace = true 29 | test-casing.workspace = true 30 | tracing.workspace = true 31 | tracing-subscriber = { workspace = true, features = ["env-filter"] } 32 | 33 | [features] 34 | portable-pty = ["term-transcript/portable-pty"] 35 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # `cargo-deny` configuration. 2 | 3 | [output] 4 | feature-depth = 1 5 | 6 | [advisories] 7 | db-urls = ["https://github.com/rustsec/advisory-db"] 8 | yanked = "deny" 9 | ignore = [] 10 | 11 | [licenses] 12 | allow = [ 13 | # Permissive open-source licenses 14 | "MIT", 15 | "Apache-2.0", 16 | "BSD-3-Clause", 17 | "Unicode-3.0", 18 | ] 19 | confidence-threshold = 0.8 20 | 21 | [bans] 22 | multiple-versions = "deny" 23 | wildcards = "deny" 24 | allow-wildcard-paths = true 25 | skip = [ 26 | # `bitflags` v1 is still used by many crates. Since it's largely a macro, 27 | # having multiple versions seems OK. 28 | { name = "bitflags", version = "^1" }, 29 | ] 30 | skip-tree = [ 31 | { name = "thiserror", version = "^1" }, 32 | ] 33 | 34 | [sources] 35 | unknown-registry = "deny" 36 | unknown-git = "deny" 37 | 38 | [sources.allow-org] 39 | # Temporarily allow non-published `font-tools` 40 | github = ["slowli"] 41 | -------------------------------------------------------------------------------- /cli/Dockerfile: -------------------------------------------------------------------------------- 1 | # Docker image for the `term-transcript` CLI executable. 2 | # See the CLI crate readme for the usage instructions. 3 | 4 | FROM clux/muslrust:stable AS builder 5 | ADD .. ./ 6 | ARG FEATURES=portable-pty,tracing 7 | RUN --mount=type=cache,id=cargo-registry,target=/root/.cargo/registry \ 8 | --mount=type=cache,id=artifacts,target=/volume/target \ 9 | cargo build -p term-transcript-cli --profile=executable \ 10 | --no-default-features --features=$FEATURES \ 11 | --target-dir /volume/target && \ 12 | # Move the resulting executable so it doesn't get unmounted together with the cache 13 | mv /volume/target/x86_64-unknown-linux-musl/executable/term-transcript /volume/term-transcript 14 | 15 | FROM alpine:3.17 16 | COPY --from=builder /volume/term-transcript /usr/local/bin 17 | # Add OpenBSD version of `nc` so that it supports Unix domain sockets 18 | # as a more secure communication channel with the host compared to TCP sockets. 19 | RUN apk add --no-cache netcat-openbsd 20 | ENTRYPOINT ["term-transcript"] 21 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright 2022-current Developers of term-transcript 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "term-transcript-cli" 3 | version.workspace = true 4 | authors.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | readme = "README.md" 9 | keywords = ["snapshot", "terminal", "SVG"] 10 | categories = ["command-line-utilities", "development-tools::testing", "visualization"] 11 | description = "CLI wrapper for term-transcript" 12 | 13 | [[bin]] 14 | name = "term-transcript" 15 | path = "src/main.rs" 16 | 17 | [dependencies] 18 | anyhow.workspace = true 19 | clap = { workspace = true, features = ["derive", "env", "wrap_help"] } 20 | handlebars.workspace = true 21 | humantime.workspace = true 22 | serde_json.workspace = true 23 | termcolor.workspace = true 24 | toml.workspace = true 25 | tracing = { workspace = true, optional = true } 26 | tracing-subscriber = { workspace = true, features = ["env-filter"], optional = true } 27 | 28 | term-transcript = { workspace = true, features = ["font-subset"] } 29 | 30 | [dev-dependencies] 31 | tempfile.workspace = true 32 | 33 | [features] 34 | default = [] 35 | # Enables capturing output via pseudo-terminal (PTY). 36 | portable-pty = ["term-transcript/portable-pty"] 37 | # Enables tracing for main operations. 38 | tracing = ["dep:tracing", "dep:tracing-subscriber", "term-transcript/tracing"] 39 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["lib", "cli", "e2e-tests/rainbow"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | version = "0.4.0" 7 | authors = ["Alex Ostrovski "] 8 | edition = "2021" 9 | rust-version = "1.83" 10 | license = "MIT OR Apache-2.0" 11 | repository = "https://github.com/slowli/term-transcript" 12 | 13 | [workspace.dependencies] 14 | # External dependencies 15 | anyhow = "1.0.100" 16 | assert_matches = "1.5.0" 17 | base64 = "0.22.1" 18 | bytecount = "0.6.9" 19 | clap = "4.5.50" 20 | doc-comment = "0.3.4" 21 | font-subset = { version = "0.1.0", git = "https://github.com/slowli/font-tools.git", rev = "9102262dbf5bf931b82644b72e1c2e5b887a2c69" } 22 | handlebars = "6.3.2" 23 | humantime = "2.3.0" 24 | os_pipe = "1.2.3" 25 | portable-pty = "0.9.0" 26 | pretty_assertions = "1.4.1" 27 | quick-xml = "0.38.3" 28 | serde = { version = "1.0", features = ["derive"] } 29 | serde_json = "1.0" 30 | tempfile = "3.23.0" 31 | termcolor = "1.4.1" 32 | test-casing = { version = "0.1.3", git = "https://github.com/slowli/test-casing.git", rev = "d143322a3e152436823c5616b0e56739380fe59d" } 33 | toml = "0.9.8" 34 | tracing = "0.1.41" 35 | tracing-capture = "0.1.0" 36 | tracing-subscriber = "0.3.20" 37 | unicode-width = "0.2" 38 | version-sync = "0.9.2" 39 | 40 | # Workspace dependencies 41 | term-transcript = { version = "=0.4.0", path = "lib" } 42 | 43 | # Profile for workspace executables 44 | [profile.executable] 45 | inherits = "release" 46 | strip = true 47 | codegen-units = 1 48 | lto = true 49 | -------------------------------------------------------------------------------- /cli/package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Script to create an archive with the release contents (the `term-transcript` executable 4 | # and the supporting docs). 5 | 6 | set -e 7 | 8 | VERSION=$1 9 | if [[ "$VERSION" == '' ]]; then 10 | echo "Error: release version is not specified" 11 | exit 1 12 | fi 13 | echo "Packaging term-transcript $VERSION for $TARGET..." 14 | 15 | CLI_DIR=$(dirname "$0") 16 | RELEASE_DIR="$CLI_DIR/release" 17 | ROOT_DIR="$CLI_DIR/.." 18 | EXECUTABLE="$ROOT_DIR/target/$TARGET/executable/term-transcript" 19 | 20 | if [[ "$OS" == 'windows-latest' ]]; then 21 | EXECUTABLE="$EXECUTABLE.exe" 22 | fi 23 | if [[ ! -x $EXECUTABLE ]]; then 24 | echo "Error: executable $EXECUTABLE does not exist" 25 | exit 1 26 | fi 27 | 28 | rm -rf "$RELEASE_DIR" && mkdir "$RELEASE_DIR" 29 | echo "Copying release files to $RELEASE_DIR..." 30 | cp "$EXECUTABLE" \ 31 | "$CLI_DIR/README.md" \ 32 | "$CLI_DIR/CHANGELOG.md" \ 33 | "$CLI_DIR/LICENSE-APACHE" \ 34 | "$CLI_DIR/LICENSE-MIT" \ 35 | "$RELEASE_DIR" 36 | 37 | cd "$RELEASE_DIR" 38 | echo "Creating release archive..." 39 | case $OS in 40 | ubuntu-latest | macos-latest) 41 | ARCHIVE="term-transcript-$VERSION-$TARGET.tar.gz" 42 | tar czf "$ARCHIVE" ./* 43 | ;; 44 | windows-latest) 45 | ARCHIVE="term-transcript-$VERSION-$TARGET.zip" 46 | 7z a "$ARCHIVE" ./* 47 | ;; 48 | *) 49 | echo "Unknown target: $TARGET" 50 | exit 1 51 | esac 52 | ls -l "$ARCHIVE" 53 | 54 | if [[ "$GITHUB_OUTPUT" != '' ]]; then 55 | echo "Outputting path to archive as GitHub step output: $RELEASE_DIR/$ARCHIVE" 56 | echo "archive=$RELEASE_DIR/$ARCHIVE" >> "$GITHUB_OUTPUT" 57 | fi 58 | -------------------------------------------------------------------------------- /lib/src/term/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fmt::Write as WriteStr}; 2 | 3 | use termcolor::NoColor; 4 | 5 | use crate::{ 6 | utils::{normalize_newlines, WriteAdapter}, 7 | TermError, 8 | }; 9 | 10 | mod parser; 11 | #[cfg(test)] 12 | mod tests; 13 | 14 | pub(crate) use self::parser::TermOutputParser; 15 | 16 | /// Marker trait for supported types of terminal output. 17 | pub trait TermOutput: Clone + Send + Sync + 'static {} 18 | 19 | /// Output captured from the terminal. 20 | #[derive(Debug, Clone)] 21 | pub struct Captured(String); 22 | 23 | impl AsRef for Captured { 24 | fn as_ref(&self) -> &str { 25 | &self.0 26 | } 27 | } 28 | 29 | impl From for Captured { 30 | fn from(raw: String) -> Self { 31 | // Normalize newlines to `\n`. 32 | Self(match normalize_newlines(&raw) { 33 | Cow::Owned(normalized) => normalized, 34 | Cow::Borrowed(_) => raw, 35 | }) 36 | } 37 | } 38 | 39 | impl Captured { 40 | fn write_as_plaintext(&self, output: &mut dyn WriteStr) -> Result<(), TermError> { 41 | let mut plaintext_writer = NoColor::new(WriteAdapter::new(output)); 42 | TermOutputParser::new(&mut plaintext_writer).parse(self.0.as_bytes()) 43 | } 44 | 45 | /// Converts this terminal output to plaintext. 46 | /// 47 | /// # Errors 48 | /// 49 | /// Returns an error if there was an issue processing output. 50 | pub fn to_plaintext(&self) -> Result { 51 | let mut output = String::with_capacity(self.0.len()); 52 | self.write_as_plaintext(&mut output)?; 53 | Ok(output) 54 | } 55 | } 56 | 57 | impl TermOutput for Captured {} 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `term-transcript` 2 | 3 | This project welcomes contribution from everyone, which can take form of suggestions / feature requests, bug reports, or pull requests. 4 | This document provides guidance how best to contribute. 5 | 6 | ## Bug reports and feature requests 7 | 8 | For bugs or when asking for help, please use the bug issue template and include enough details so that your observations 9 | can be reproduced. 10 | 11 | For feature requests, please use the feature request issue template and describe the intended use case(s) and motivation 12 | to go for them. If possible, include your ideas how to implement the feature, potential alternatives and disadvantages. 13 | 14 | ## Pull requests 15 | 16 | Please use the pull request template when submitting a PR. List the major goal(s) achieved by the PR 17 | and describe the motivation behind it. If applicable, like to the related issue(s). 18 | 19 | Optimally, you should check locally that the CI checks pass before submitting the PR. Checks included in the CI 20 | include: 21 | 22 | - Formatting using `cargo fmt --all` 23 | - Linting using `cargo clippy` 24 | - Linting the dependency graph using [`cargo deny`](https://crates.io/crates/cargo-deny) 25 | - Running the test suite using `cargo test` 26 | 27 | A complete list of checks can be viewed in [the CI workflow file](.github/workflows/ci.yml). The checks are run 28 | on the latest stable Rust version. 29 | 30 | ### MSRV checks 31 | 32 | A part of the CI assertions is the minimum supported Rust version (MSRV). If this check fails, consult the error messages. Depending on 33 | the error (e.g., whether it is caused by a newer language feature used in the PR code, or in a dependency), 34 | you might want to rework the PR, get rid of the offending dependency, or bump the MSRV; don't hesitate to consult the maintainers. 35 | 36 | ## Code of Conduct 37 | 38 | Be polite and respectful. 39 | -------------------------------------------------------------------------------- /cli/rainbow.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Standalone shell script to output various text styles 4 | 5 | BASE_COLORS="black red green yellow blue magenta cyan white" 6 | RGB_COLOR_NAMES="pink orange brown teal" 7 | RGB_COLOR_VALUES="255;187;221 255;170;68 159;64;16 16;136;159" 8 | 9 | index() { 10 | shift "$1" 11 | echo "$2" 12 | } 13 | 14 | base_colors_line() { 15 | start_code="$1" 16 | underline_oddity="$2" 17 | 18 | line="" 19 | for i in $(seq 0 7); do 20 | color=$((i + start_code)) 21 | decor="" 22 | if [ $((i % 2)) -eq "$underline_oddity" ]; then 23 | decor='\e[4m' # underline 24 | fi 25 | line=$line'\e['$color'm'$decor$(index "$i" $BASE_COLORS)'\e[0m ' 26 | done 27 | echo "$line" 28 | } 29 | 30 | ansi_colors_line() { 31 | line="" 32 | for i in $(seq 16 231); do 33 | fg_color="\e[37m" # white 34 | col=$(((i - 16) % 36)) 35 | if [ "$col" -gt 18 ]; then 36 | fg_color="\e[30m" # black 37 | fi 38 | line=$line'\e[38;5;'$i'm!\e[0m'$fg_color'\e[48;5;'$i'm?\e[0m' 39 | 40 | if [ "$col" -eq 35 ]; then 41 | echo "$line" 42 | line="" 43 | fi 44 | done 45 | } 46 | 47 | ansi_grayscale_line() { 48 | line="" 49 | for i in $(seq 232 255); do 50 | fg_color="\e[37m" # white 51 | if [ "$i" -ge 244 ]; then 52 | fg_color="\e[30m" # black 53 | fi 54 | line=$line'\e[38;5;'$i'm!\e[0m'$fg_color'\e[48;5;'$i'm?\e[0m' 55 | done 56 | echo "$line" 57 | } 58 | 59 | rgb_colors_line() { 60 | line="" 61 | for i in $(seq 0 3); do 62 | name=$(index "$i" $RGB_COLOR_NAMES) 63 | value=$(index "$i" $RGB_COLOR_VALUES) 64 | line=$line'\e[38;2;'$value'm'$name'\e[0m ' 65 | done 66 | echo "$line" 67 | } 68 | 69 | echo "Base colors:" 70 | base_colors_line 30 0 71 | base_colors_line 90 1 72 | echo "Base colors (bg):" 73 | base_colors_line 40 2 74 | base_colors_line 100 2 75 | 76 | if [ "$1" = "--short" ]; then 77 | exit 0 78 | fi 79 | 80 | echo "ANSI color palette:" 81 | ansi_colors_line 82 | echo "ANSI grayscale palette:" 83 | ansi_grayscale_line 84 | 85 | echo "24-bit colors:" 86 | rgb_colors_line 87 | -------------------------------------------------------------------------------- /.github/workflows/build-reusable.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | rust_version: 7 | type: string 8 | description: Rust version to use in the build 9 | required: false 10 | default: stable 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install Rust 20 | uses: dtolnay/rust-toolchain@master 21 | with: 22 | toolchain: ${{ inputs.rust_version }} 23 | components: rustfmt, clippy 24 | - name: Install cargo-deny 25 | uses: baptiste0928/cargo-install@v3 26 | with: 27 | crate: cargo-deny 28 | version: "^0.18.9" 29 | 30 | - name: Cache cargo build 31 | uses: actions/cache@v4 32 | with: 33 | path: target 34 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 35 | restore-keys: ${{ runner.os }}-cargo 36 | 37 | - name: Format 38 | run: cargo fmt --all -- --check 39 | - name: Clippy 40 | run: cargo clippy --workspace --all-features --all-targets -- -D warnings 41 | - name: Clippy (no features) 42 | run: cargo clippy -p term-transcript --no-default-features --lib 43 | - name: Clippy (features = svg) 44 | run: cargo clippy -p term-transcript --no-default-features --features svg --lib -- -D warnings 45 | - name: Clippy (features = test) 46 | run: cargo clippy -p term-transcript --no-default-features --features test --lib -- -D warnings 47 | 48 | - name: Check dependencies 49 | run: cargo deny --workspace --all-features check 50 | 51 | - name: Run tests 52 | run: cargo test --workspace --all-features --all-targets 53 | - name: Run doc tests 54 | run: cargo test --workspace --all-features --doc 55 | 56 | - name: Generate snapshots 57 | run: ./examples/generate-snapshots.sh 58 | - name: Test CLI tracing 59 | run: | 60 | RUST_LOG=term_transcript=debug \ 61 | cargo run -p term-transcript-cli --all-features -- \ 62 | exec 'echo Hello' |& grep DEBUG 63 | -------------------------------------------------------------------------------- /lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "term-transcript" 3 | version.workspace = true 4 | authors.workspace = true 5 | edition.workspace = true 6 | rust-version.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | readme = "README.md" 10 | keywords = ["snapshot", "terminal", "SVG"] 11 | categories = ["development-tools::testing", "visualization"] 12 | description = "Snapshotting and snapshot testing for CLI / REPL applications" 13 | 14 | [package.metadata.docs.rs] 15 | all-features = true 16 | # Set `docsrs` to enable unstable `doc(cfg(...))` attributes. 17 | rustdoc-args = ["--cfg", "docsrs"] 18 | 19 | [dependencies] 20 | # Public dependencies (present in the public API). 21 | quick-xml = { workspace = true, optional = true } 22 | handlebars = { workspace = true, optional = true } 23 | portable-pty = { workspace = true, optional = true } 24 | 25 | # Private dependencies (not exposed in the public API). 26 | base64.workspace = true 27 | bytecount.workspace = true 28 | # **NB.** Must be an optional dependency; we want to use `term-transcript` in the font-subset workspace. 29 | font-subset = { workspace = true, features = ["woff2"], optional = true } 30 | os_pipe.workspace = true 31 | serde = { workspace = true, optional = true } 32 | serde_json = { workspace = true, optional = true } 33 | tracing = { workspace = true, optional = true } 34 | pretty_assertions = {workspace = true, optional = true } 35 | termcolor.workspace = true 36 | unicode-width.workspace = true 37 | 38 | [dev-dependencies] 39 | anyhow.workspace = true 40 | assert_matches.workspace = true 41 | doc-comment.workspace = true 42 | test-casing.workspace = true 43 | toml.workspace = true 44 | tracing-capture.workspace = true 45 | tracing-subscriber = { workspace = true, features = ["env-filter"] } 46 | version-sync.workspace = true 47 | 48 | [features] 49 | default = ["pretty_assertions", "svg", "test"] 50 | # Rendering terminal transcripts into SVG snapshots 51 | svg = ["dep:handlebars", "dep:serde", "dep:serde_json"] 52 | # Enables subsetting and embedding OpenType fonts into snapshots 53 | font-subset = ["svg", "dep:font-subset"] 54 | # Allows parsing transcripts from SVG snapshots and testing them 55 | test = ["dep:quick-xml"] 56 | 57 | [[test]] 58 | name = "integration" 59 | path = "tests/integration.rs" 60 | required-features = ["tracing"] 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: [ "v*" ] 6 | workflow_dispatch: {} 7 | 8 | defaults: 9 | run: 10 | shell: bash 11 | 12 | jobs: 13 | release: 14 | permissions: 15 | contents: write 16 | strategy: 17 | matrix: 18 | include: 19 | - os: ubuntu-latest 20 | target: x86_64-unknown-linux-gnu 21 | - os: macos-latest 22 | target: x86_64-apple-darwin 23 | - os: macos-latest 24 | target: aarch64-apple-darwin 25 | - os: windows-latest 26 | target: x86_64-pc-windows-msvc 27 | 28 | runs-on: ${{ matrix.os }} 29 | name: Release ${{ matrix.target }} 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | 34 | - name: Determine release type 35 | id: release-type 36 | run: | 37 | if [[ ${{ github.ref }} =~ ^refs/tags/[0-9]+[.][0-9]+[.][0-9]+$ ]]; then 38 | echo 'type=release' >> "$GITHUB_OUTPUT" 39 | else 40 | echo 'type=prerelease' >> "$GITHUB_OUTPUT" 41 | fi 42 | 43 | - name: Install Rust 44 | uses: dtolnay/rust-toolchain@master 45 | with: 46 | toolchain: stable 47 | targets: ${{ matrix.target }} 48 | 49 | - name: Cache cargo build 50 | uses: actions/cache@v4 51 | with: 52 | path: target 53 | key: ${{ runner.os }}-release-cargo-${{ hashFiles('Cargo.lock') }} 54 | restore-keys: ${{ runner.os }}-release-cargo 55 | 56 | - name: Build CLI app 57 | run: | 58 | cargo build -p term-transcript-cli \ 59 | --profile=executable \ 60 | --target=${{ matrix.target }} \ 61 | --all-features \ 62 | --locked 63 | - name: Package archive 64 | id: package 65 | run: ./cli/package.sh ${REF#refs/*/} 66 | env: 67 | OS: ${{ matrix.os }} 68 | TARGET: ${{ matrix.target }} 69 | REF: ${{ github.ref }} 70 | - name: Publish archive 71 | uses: softprops/action-gh-release@v1 72 | if: github.event_name == 'push' 73 | with: 74 | draft: false 75 | files: ${{ steps.package.outputs.archive }} 76 | prerelease: ${{ steps.release-type.outputs.type == 'prerelease' }} 77 | - name: Attach archive to workflow 78 | uses: actions/upload-artifact@v4 79 | if: github.event_name == 'workflow_dispatch' 80 | with: 81 | name: term-transcript-${{ matrix.target }} 82 | path: ${{ steps.package.outputs.archive }} 83 | -------------------------------------------------------------------------------- /e2e-tests/rainbow/src/bin/repl.rs: -------------------------------------------------------------------------------- 1 | //! Simple REPL application that echoes the input with coloring / styles applied. 2 | 3 | use std::io::{self, BufRead}; 4 | 5 | use term_transcript::svg::RgbColor; 6 | use termcolor::{Ansi, Color, ColorSpec, WriteColor}; 7 | 8 | fn process_line(writer: &mut impl WriteColor, line: &str) -> io::Result<()> { 9 | let parts: Vec<_> = line.split_whitespace().collect(); 10 | let mut color_spec = ColorSpec::new(); 11 | for (i, &part) in parts.iter().enumerate() { 12 | match part { 13 | "bold" => { 14 | color_spec.set_bold(true); 15 | } 16 | "italic" => { 17 | color_spec.set_italic(true); 18 | } 19 | "underline" => { 20 | color_spec.set_underline(true); 21 | } 22 | "intense" => { 23 | color_spec.set_intense(true); 24 | } 25 | 26 | "black" => { 27 | color_spec.set_fg(Some(Color::Black)); 28 | } 29 | "blue" => { 30 | color_spec.set_fg(Some(Color::Blue)); 31 | } 32 | "green" => { 33 | color_spec.set_fg(Some(Color::Green)); 34 | } 35 | "red" => { 36 | color_spec.set_fg(Some(Color::Red)); 37 | } 38 | "cyan" => { 39 | color_spec.set_fg(Some(Color::Cyan)); 40 | } 41 | "magenta" => { 42 | color_spec.set_fg(Some(Color::Magenta)); 43 | } 44 | "yellow" => { 45 | color_spec.set_fg(Some(Color::Yellow)); 46 | } 47 | "white" => { 48 | color_spec.set_fg(Some(Color::White)); 49 | } 50 | 51 | color if color.starts_with('#') => { 52 | if let Ok(color) = color.parse::() { 53 | color_spec.set_fg(Some(Color::Rgb(color.0, color.1, color.2))); 54 | } 55 | } 56 | 57 | _ => { /* Do nothing. */ } 58 | } 59 | 60 | writer.set_color(&color_spec)?; 61 | write!(writer, "{part}")?; 62 | writer.reset()?; 63 | if i + 1 < parts.len() { 64 | write!(writer, " ")?; 65 | } 66 | } 67 | writeln!(writer) 68 | } 69 | 70 | fn main() -> anyhow::Result<()> { 71 | let mut writer = Ansi::new(io::stdout()); 72 | let stdin = io::stdin(); 73 | let mut lines = stdin.lock().lines(); 74 | while let Some(line) = lines.next().transpose()? { 75 | process_line(&mut writer, &line)?; 76 | } 77 | Ok(()) 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Capturing and Snapshot Testing for CLI / REPL Applications 2 | 3 | [![CI](https://github.com/slowli/term-transcript/actions/workflows/ci.yml/badge.svg)](https://github.com/slowli/term-transcript/actions/workflows/ci.yml) 4 | [![License: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%2FApache--2.0-blue)](https://github.com/slowli/term-transcript#license) 5 | 6 | `term-transcript` is a Rust library and a CLI app that allow to: 7 | 8 | - Create transcripts of interacting with a terminal, capturing both the output text 9 | and [ANSI-compatible color info][SGR]. 10 | - Save these transcripts in the [SVG] format, so that they can be easily embedded as images 11 | into HTML / Markdown documents. Rendering logic can be customized via [Handlebars] template engine; 12 | thus, other output formats besides SVG (e.g., HTML) are possible. 13 | - Parse transcripts from SVG. 14 | - Test that a parsed transcript actually corresponds to the terminal output (either as text 15 | or text + colors). 16 | 17 | The primary use case is easy to create and maintain end-to-end tests for CLI / REPL apps. 18 | Such tests can be embedded into a readme file. 19 | 20 | ## Usage 21 | 22 | `term-transcript` comes in two flavors: a [Rust library](lib), and a [CLI app](cli). 23 | The CLI app has slightly less functionality, but does not require Rust knowledge. 24 | See their docs and the [FAQ](FAQ.md) for usage guidelines and troubleshooting advice. 25 | 26 | ### Examples 27 | 28 | An SVG snapshot of [the `rainbow` example](e2e-tests/rainbow) 29 | produced by this crate: 30 | 31 | ![Snapshot of rainbow example](examples/rainbow.svg) 32 | 33 | A snapshot of the same example with the scrolling animation and window frame: 34 | 35 | ![Animated snapshot of rainbow example](examples/animated.svg) 36 | 37 | A snapshot of a similar example rendered to HTML using [a custom template](examples/custom.html.handlebars) 38 | is available [as a source file](examples/rainbow.html) and [in the rendered form][html-example]. 39 | 40 | See the [`examples` directory](examples) for more snapshot examples. 41 | 42 | ## Contributing 43 | 44 | All contributions are welcome! See [the contributing guide](CONTRIBUTING.md) to help 45 | you get involved. 46 | 47 | ## License 48 | 49 | All code is licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) 50 | or [MIT license](LICENSE-MIT) at your option. 51 | 52 | Unless you explicitly state otherwise, any contribution intentionally submitted 53 | for inclusion in `term-transcript` by you, as defined in the Apache-2.0 license, 54 | shall be dual licensed as above, without any additional terms or conditions. 55 | 56 | [SVG]: https://developer.mozilla.org/en-US/docs/Web/SVG 57 | [Handlebars]: https://handlebarsjs.com/ 58 | [SGR]: https://en.wikipedia.org/wiki/ANSI_escape_code#SGR 59 | [CSI]: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences 60 | [html-example]: https://slowli.github.io/term-transcript/examples/rainbow.html 61 | -------------------------------------------------------------------------------- /cli/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | The project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 5 | 6 | ## [Unreleased] 7 | 8 | ### Added 9 | 10 | - Support font embedding and subsetting with the help of `--embed-font`. 11 | 12 | ## 0.4.0-beta.1 - 2024-03-03 13 | 14 | ### Added 15 | 16 | - Allow specifying the snapshot width in pixels using `--width`. 17 | - Allow specifying at which char to hard-break lines using `--hard-wrap`. 18 | - Allow reading template configuration using `--config-path`. 19 | - Allow configuring line height using `--line-height`. 20 | 21 | ### Changed 22 | 23 | - `--scroll` now accepts an optional value that specifies max snapshot height in pixels. 24 | 25 | ## 0.3.0 - 2023-06-03 26 | 27 | *(No substantial changes compared to the [0.3.0-beta.2 release](#030-beta2---2023-04-29))* 28 | 29 | ## 0.3.0-beta.2 - 2023-04-29 30 | 31 | *(All changes are relative compared to [the 0.3.0-beta.1 release](#030-beta1---2023-01-19))* 32 | 33 | ### Added 34 | 35 | - Allow specifying the font family to be used in the generated SVG snapshots 36 | via the `--font` argument. 37 | - Allow specifying additional CSS instructions for the generated SVG snapshots 38 | using the `--styles` argument. 39 | As an example, this can be used to import fonts using `@import` or `@font-face`. 40 | - Support rendering pure SVG using `--pure-svg` option. See the library changelog and FAQ 41 | for more details. 42 | - Allow hiding all user inputs in a rendered transcript by specifying the `--no-inputs` flag. 43 | 44 | ## 0.3.0-beta.1 - 2023-01-19 45 | 46 | ### Added 47 | 48 | - Add ability to customize the rendering template using `--tpl ` option. 49 | Additionally, `--tpl -` outputs JSON data that would be fed to a template 50 | (could be useful if complex data processing is required). 51 | - Add the `--echoing` flag to specify whether the shell is echoing. 52 | - Support exit status capturing if using the default shell or another supported shell 53 | (`sh`, `bash`, `powershell` or `pwsh`). See the `term-transcript` crate docs 54 | for more details on exit statuses. 55 | - Print captured exit statuses in the `print` subcommand. 56 | - Allow redefining the initialization timeout with the help of the `--init-timeout` / `-I` option. 57 | - Proxy tracing from the `term-transcript` crate if the `tracing` crate feature is on. 58 | - Support line numbering with the help of the `--line-numbers` / `-n` option. 59 | - Add a Docker image for the CLI app 60 | on the [GitHub Container registry](https://github.com/slowli/term-transcript/pkgs/container/term-transcript). 61 | - Add prebuilt binaries for popular targets (x86_64 for Linux / macOS / Windows 62 | and aarch64 for macOS) available from [GitHub releases](https://github.com/slowli/term-transcript/releases). 63 | 64 | ### Changed 65 | 66 | - Change working directory to the working directory of the parent process 67 | for the `exec` subcommand. 68 | - Use [`humantime`](https://docs.rs/humantime/) for UX-friendly timeout values 69 | (`--io-timeout` / `-T` and `--init-timeout` / `-I` options). 70 | 71 | ## 0.2.0 - 2022-06-12 72 | 73 | *(All changes are relative compared to [the 0.2.0-beta.1 release](#020-beta1---2022-01-06))* 74 | 75 | ### Changed 76 | 77 | - Bump minimum supported Rust version and switch to 2021 Rust edition. 78 | 79 | ## 0.2.0-beta.1 - 2022-01-06 80 | 81 | ### Added 82 | 83 | - Add `print` command to parse the specified SVG snapshot and print it to the shell. 84 | 85 | ### Fixed 86 | 87 | - Remove obsolete dependencies. 88 | 89 | ## 0.1.0 - 2021-06-01 90 | 91 | The initial release of `term-transcript-cli`. 92 | -------------------------------------------------------------------------------- /lib/src/svg/font.rs: -------------------------------------------------------------------------------- 1 | //! Font-related functionality. 2 | 3 | use std::{collections::BTreeSet, fmt}; 4 | 5 | use base64::{prelude::BASE64_STANDARD, Engine}; 6 | use serde::{Serialize, Serializer}; 7 | 8 | use crate::BoxedError; 9 | 10 | /// Representation of a font that can be embedded into SVG via `@font-face` CSS with a data URL `src`. 11 | #[derive(Debug, Serialize)] 12 | pub struct EmbeddedFont { 13 | /// Family name of the font. 14 | pub family_name: String, 15 | /// Font metrics. 16 | pub metrics: FontMetrics, 17 | /// Font faces. Must have at least 1 entry. 18 | pub faces: Vec, 19 | } 20 | 21 | /// Representation of a single face of an [`EmbeddedFont`]. Corresponds to a single `@font-face` CSS rule. 22 | #[derive(Serialize)] 23 | pub struct EmbeddedFontFace { 24 | /// MIME type for the font, e.g. `font/woff2`. 25 | pub mime_type: String, 26 | /// Font data. Encoded in base64 when serialized. 27 | #[serde(serialize_with = "base64_encode")] 28 | pub base64_data: Vec, 29 | /// Determines the `font-weight` selector for the `@font-face` rule. 30 | pub is_bold: Option, 31 | /// Determines the `font-style` selector for the `@font-face` rule. 32 | pub is_italic: Option, 33 | } 34 | 35 | // Make `Debug` representation shorter. 36 | impl fmt::Debug for EmbeddedFontFace { 37 | fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 38 | formatter 39 | .debug_struct("EmbeddedFontFace") 40 | .field("mime_type", &self.mime_type) 41 | .field("data.len", &self.base64_data.len()) 42 | .field("is_bold", &self.is_bold) 43 | .field("is_italic", &self.is_italic) 44 | .finish() 45 | } 46 | } 47 | 48 | impl EmbeddedFontFace { 49 | /// Creates a face based on the provided WOFF2 font data. All selectors are set to `None`. 50 | pub fn woff2(data: Vec) -> Self { 51 | Self { 52 | mime_type: "font/woff2".to_owned(), 53 | base64_data: data, 54 | is_bold: None, 55 | is_italic: None, 56 | } 57 | } 58 | } 59 | 60 | fn base64_encode(data: &[u8], serializer: S) -> Result { 61 | let encoded = BASE64_STANDARD.encode(data); 62 | encoded.serialize(serializer) 63 | } 64 | 65 | /// Font metrics used in SVG layout. 66 | #[derive(Debug, Clone, Copy, Serialize)] 67 | pub struct FontMetrics { 68 | /// Font design units per em. Usually 1,000 or a power of 2 (e.g., 2,048). 69 | pub units_per_em: u16, 70 | /// Horizontal advance in font design units. 71 | pub advance_width: u16, 72 | /// Typographic ascent in font design units. Usually positive. 73 | pub ascent: i16, 74 | /// Typographic descent in font design units. Usually negative. 75 | pub descent: i16, 76 | /// `letter-spacing` adjustment for the bold font face in em units. 77 | pub bold_spacing: f64, 78 | /// `letter-spacing` adjustment for the italic font face in em units. Accounts for font advance width 79 | /// not matching between the regular and italic faces (e.g., in Roboto Mono), which can lead 80 | /// to misaligned terminal columns. 81 | pub italic_spacing: f64, 82 | } 83 | 84 | /// Produces an [`EmbeddedFont`] for SVG. 85 | pub trait FontEmbedder: 'static + fmt::Debug + Send + Sync { 86 | /// Errors produced by the embedder. 87 | type Error: Into; 88 | 89 | /// Performs embedding. This can involve subsetting the font based on the specified chars used in the transcript. 90 | /// 91 | /// # Errors 92 | /// 93 | /// May return errors if embedding / subsetting fails. 94 | fn embed_font(&self, used_chars: BTreeSet) -> Result; 95 | } 96 | 97 | #[derive(Debug)] 98 | pub(super) struct BoxedErrorEmbedder(pub(super) T); 99 | 100 | impl FontEmbedder for BoxedErrorEmbedder { 101 | type Error = BoxedError; 102 | 103 | fn embed_font(&self, used_chars: BTreeSet) -> Result { 104 | self.0.embed_font(used_chars).map_err(Into::into) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /cli/tests/e2e.rs: -------------------------------------------------------------------------------- 1 | #![cfg(unix)] 2 | 3 | use std::{ 4 | path::{Path, PathBuf}, 5 | time::Duration, 6 | }; 7 | 8 | use tempfile::{tempdir, TempDir}; 9 | use term_transcript::{ 10 | svg::{ScrollOptions, Template, TemplateOptions}, 11 | test::{MatchKind, TestConfig}, 12 | ShellOptions, StdShell, 13 | }; 14 | 15 | fn svg_snapshot(name: &str) -> PathBuf { 16 | let mut snapshot_path = Path::new("tests/snapshots").join(name); 17 | snapshot_path.set_extension("svg"); 18 | snapshot_path 19 | } 20 | 21 | // Executes commands in a temporary dir, with paths to the `term-transcript` binary and 22 | // the `rainbow.sh` example added to PATH. 23 | fn test_config() -> (TestConfig, TempDir) { 24 | let temp_dir = tempdir().expect("cannot create temporary directory"); 25 | let rainbow_dir = Path::new(env!("CARGO_MANIFEST_DIR")); 26 | 27 | let shell_options = ShellOptions::sh() 28 | .with_env("COLOR", "always") 29 | .with_current_dir(temp_dir.path()) 30 | .with_cargo_path() 31 | .with_additional_path(rainbow_dir) 32 | .with_io_timeout(Duration::from_secs(2)); 33 | let config = TestConfig::new(shell_options).with_match_kind(MatchKind::Precise); 34 | (config, temp_dir) 35 | } 36 | 37 | fn scrolled_template() -> Template { 38 | let template_options = TemplateOptions { 39 | window_frame: true, 40 | scroll: Some(ScrollOptions::default()), 41 | ..TemplateOptions::default() 42 | }; 43 | Template::new(template_options) 44 | } 45 | 46 | #[cfg(feature = "portable-pty")] 47 | #[test] 48 | fn help_example() { 49 | use term_transcript::PtyCommand; 50 | 51 | let shell_options = ShellOptions::new(PtyCommand::default()).with_cargo_path(); 52 | TestConfig::new(shell_options).test(svg_snapshot("help"), ["term-transcript --help"]); 53 | } 54 | 55 | #[test] 56 | fn testing_example() { 57 | let (config, _dir) = test_config(); 58 | config.with_template(scrolled_template()).test( 59 | svg_snapshot("test"), 60 | [ 61 | "term-transcript exec -I 300ms -T 100ms rainbow.sh > rainbow.svg\n\ 62 | # `-T` option defines the I/O timeout for the shell,\n\ 63 | # and `-I` specifies the additional initialization timeout", 64 | "term-transcript test -I 300ms -T 100ms -v rainbow.svg\n\ 65 | # `-v` switches on verbose output", 66 | ], 67 | ); 68 | } 69 | 70 | #[test] 71 | fn test_failure_example() { 72 | let (mut config, _dir) = test_config(); 73 | config.test( 74 | svg_snapshot("test-fail"), 75 | [ 76 | "term-transcript exec -I 300ms -T 100ms 'rainbow.sh --short' > bogus.svg && \\\n \ 77 | sed -i -E -e 's/(fg4|bg13)//g' bogus.svg\n\ 78 | # Mutate the captured output, removing some styles", 79 | "term-transcript test -I 300ms -T 100ms --precise bogus.svg\n\ 80 | # --precise / -p flag enables comparison by style", 81 | ], 82 | ); 83 | } 84 | 85 | #[test] 86 | fn print_example() { 87 | let (mut config, _dir) = test_config(); 88 | config.test( 89 | svg_snapshot("print"), 90 | [ 91 | "term-transcript exec -I 300ms -T 100ms 'rainbow.sh --short' > short.svg", 92 | "term-transcript print short.svg", 93 | ], 94 | ); 95 | } 96 | 97 | #[test] 98 | fn print_example_with_failures() { 99 | let (mut config, _dir) = test_config(); 100 | config.test( 101 | svg_snapshot("print-with-failures"), 102 | [ 103 | "term-transcript exec -I 300ms -T 100ms 'some-non-existing-command' \\\n \ 104 | '[ -x some-non-existing-file ]' > fail.svg", 105 | "term-transcript print fail.svg", 106 | ], 107 | ); 108 | } 109 | 110 | #[test] 111 | fn capture_example() { 112 | let (config, _dir) = test_config(); 113 | config.with_template(scrolled_template()).test( 114 | svg_snapshot("capture"), 115 | [ 116 | "rainbow.sh | term-transcript capture 'rainbow.sh' > captured.svg", 117 | "term-transcript print captured.svg", 118 | ], 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /cli/tests/snapshots/print-with-failures.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 67 | 68 | 69 | 70 |
71 |
$ term-transcript exec -I 300ms -T 100ms 'some-non-existing-command' \
72 |   '[ -x some-non-existing-file ]' > fail.svg
73 |
74 |
$ term-transcript print fail.svg
75 |
----------  Input #1 ----------
76 | $ some-non-existing-command
77 | Exit status: 127 (failure)
78 | 
79 | ---------- Output #1 ----------
80 | sh: 1: some-non-existing-command: not found
81 | 
82 | ----------  Input #2 ----------
83 | $ [ -x some-non-existing-file ]
84 | Exit status: 1 (failure)
85 | 
86 | ---------- Output #2 ----------
87 | 
88 |
89 |
90 |
91 |
92 | -------------------------------------------------------------------------------- /e2e-tests/rainbow/repl.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 56 | 57 | 58 | 59 |
60 |
$ yellow intense bold green cucumber
61 |
yellow intense bold green cucumber
62 |
$ neutral #fa4 underline #c0ffee
63 |
neutral #fa4 underline #c0ffee
64 |
$ #9f4010 (brown) italic
65 |
#9f4010 (brown) italic
66 |
67 |
68 |
69 |
70 | 71 | HTML embedding not supported. 72 | Consult term-transcript docs for details. 73 | 74 |
75 |
76 | -------------------------------------------------------------------------------- /examples/failure-pwsh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
82 |
$ ./non-existing-command
83 |
./non-existing-command: The term './non-existing-command' is not recognized as a
name of a cmdlet, function, script file, or executable program.
84 | Check the spelling of the name, or if a path was included, verify that the path
is correct and try again.
85 |
86 |
87 |
88 |
89 | -------------------------------------------------------------------------------- /cli/tests/snapshots/help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 59 | 60 | 61 | 62 |
63 |
$ term-transcript --help
64 |
CLI wrapper for term-transcript
65 | 
66 | Usage: term-transcript <COMMAND>
67 | 
68 | Commands:
69 |   capture  Captures output from stdin and renders it to SVG
70 |   exec     Executes one or more commands in a shell and renders the captured
71 |            output to SVG
72 |   test     Tests previously captured SVG snapshots
73 |   print    Prints a previously saved SVG file to stdout with the captured
74 |            coloring (unless the coloring of the output is switched off)
75 |   help     Print this message or the help of the given subcommand(s)
76 | 
77 | Options:
78 |   -h, --help     Print help
79 |   -V, --version  Print version
80 |
81 |
82 |
83 |
84 | 85 | HTML embedding not supported. 86 | Consult term-transcript docs for details. 87 | 88 |
89 |
90 | -------------------------------------------------------------------------------- /examples/failure-sh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
70 |
$ ./non-existing-command
71 |
sh: 1: ./non-existing-command: not found
72 |
$ [ -x non-existing-file ]
73 |
74 |
$ [ -x non-existing-file ] || echo "File is not there!"
75 |
File is not there!
76 |
77 |
78 |
79 |
80 | 81 | HTML embedding not supported. 82 | Consult term-transcript docs for details. 83 | 84 |
85 |
86 | -------------------------------------------------------------------------------- /cli/tests/snapshots/print.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 67 | 68 | 69 | 70 |
71 |
$ term-transcript exec -I 300ms -T 100ms 'rainbow.sh --short' > short.svg
72 |
73 |
$ term-transcript print short.svg
74 |
----------  Input #1 ----------
75 | $ rainbow.sh --short
76 | 
77 | ---------- Output #1 ----------
78 | Base colors:
79 | black red green yellow blue magenta cyan white 
80 | black red green yellow blue magenta cyan white 
81 | Base colors (bg):
82 | black red green yellow blue magenta cyan white 
83 | black red green yellow blue magenta cyan white 
84 |
85 |
86 |
87 |
88 | -------------------------------------------------------------------------------- /examples/failure-bash-pty.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
70 |
$ ls -l Cargo.lock
71 |
-rwxrwxrwx 1 alex alex 44148 Dec  5 11:07 Cargo.lock
72 |
$ grep -n serge Cargo.lock
73 |
74 |
$ grep -m 5 -n serde Cargo.lock
75 |
411: "serde",
76 | 412: "serde_json",
77 | 935:name = "serde"
78 | 940: "serde_core",
79 | 941: "serde_derive",
80 |
81 |
82 |
83 |
84 | 85 | HTML embedding not supported. 86 | Consult term-transcript docs for details. 87 | 88 |
89 |
90 | -------------------------------------------------------------------------------- /lib/src/shell/standard.rs: -------------------------------------------------------------------------------- 1 | //! Standard shell support. 2 | 3 | use std::{ 4 | ffi::OsStr, 5 | io, 6 | path::Path, 7 | process::{Child, ChildStdin, Command}, 8 | }; 9 | 10 | use super::ShellOptions; 11 | use crate::{ 12 | traits::{ConfigureCommand, Echoing, SpawnShell, SpawnedShell}, 13 | Captured, ExitStatus, 14 | }; 15 | 16 | #[derive(Debug, Clone, Copy)] 17 | enum StdShellType { 18 | /// `sh` shell. 19 | Sh, 20 | /// `bash` shell. 21 | Bash, 22 | /// PowerShell. 23 | PowerShell, 24 | } 25 | 26 | /// Shell interpreter that brings additional functionality for [`ShellOptions`]. 27 | #[derive(Debug)] 28 | pub struct StdShell { 29 | shell_type: StdShellType, 30 | command: Command, 31 | } 32 | 33 | impl ConfigureCommand for StdShell { 34 | fn current_dir(&mut self, dir: &Path) { 35 | self.command.current_dir(dir); 36 | } 37 | 38 | fn env(&mut self, name: &str, value: &OsStr) { 39 | self.command.env(name, value); 40 | } 41 | } 42 | 43 | #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", ret))] 44 | fn check_sh_exit_code(response: &Captured) -> Option { 45 | let response = response.to_plaintext().ok()?; 46 | response.trim().parse().ok().map(ExitStatus) 47 | } 48 | 49 | #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", ret))] 50 | fn check_ps_exit_code(response: &Captured) -> Option { 51 | let response = response.to_plaintext().ok()?; 52 | match response.trim() { 53 | "True" => Some(ExitStatus(0)), 54 | "False" => Some(ExitStatus(1)), 55 | _ => None, 56 | } 57 | } 58 | 59 | impl ShellOptions { 60 | /// Creates options for an `sh` shell. 61 | pub fn sh() -> Self { 62 | let this = Self::new(StdShell { 63 | shell_type: StdShellType::Sh, 64 | command: Command::new("sh"), 65 | }); 66 | this.with_status_check("echo $?", check_sh_exit_code) 67 | } 68 | 69 | /// Creates options for a Bash shell. 70 | pub fn bash() -> Self { 71 | let this = Self::new(StdShell { 72 | shell_type: StdShellType::Bash, 73 | command: Command::new("bash"), 74 | }); 75 | this.with_status_check("echo $?", check_sh_exit_code) 76 | } 77 | 78 | /// Creates options for PowerShell 6+ (the one with the `pwsh` executable). 79 | pub fn pwsh() -> Self { 80 | let mut command = Command::new("pwsh"); 81 | command.arg("-NoLogo").arg("-NoExit"); 82 | 83 | let command = StdShell { 84 | shell_type: StdShellType::PowerShell, 85 | command, 86 | }; 87 | Self::new(command) 88 | .with_init_command("function prompt { }") 89 | .with_status_check("echo $?", check_ps_exit_code) 90 | } 91 | 92 | /// Creates an alias for the binary at `path_to_bin`, which should be an absolute path. 93 | /// This allows to call the binary using this alias without complex preparations (such as 94 | /// installing it globally via `cargo install`), and is more flexible than 95 | /// [`Self::with_cargo_path()`]. 96 | /// 97 | /// In integration tests, you may use [`env!("CARGO_BIN_EXE_")`] to get a path 98 | /// to binary targets. 99 | /// 100 | /// # Limitations 101 | /// 102 | /// - For Bash and PowerShell, `name` must be a valid name of a function. For `sh`, 103 | /// `name` must be a valid name for the `alias` command. The `name` validity 104 | /// is **not** checked. 105 | /// 106 | /// [`env!("CARGO_BIN_EXE_")`]: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates 107 | #[must_use] 108 | pub fn with_alias(self, name: &str, path_to_bin: &str) -> Self { 109 | let alias_command = match self.command.shell_type { 110 | StdShellType::Sh => { 111 | format!("alias {name}=\"'{path_to_bin}'\"") 112 | } 113 | StdShellType::Bash => format!("{name}() {{ '{path_to_bin}' \"$@\"; }}"), 114 | StdShellType::PowerShell => format!("function {name} {{ & '{path_to_bin}' @Args }}"), 115 | }; 116 | 117 | self.with_init_command(alias_command) 118 | } 119 | } 120 | 121 | impl SpawnShell for StdShell { 122 | type ShellProcess = Echoing; 123 | type Reader = os_pipe::PipeReader; 124 | type Writer = ChildStdin; 125 | 126 | #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))] 127 | fn spawn_shell(&mut self) -> io::Result> { 128 | let SpawnedShell { 129 | shell, 130 | reader, 131 | writer, 132 | } = self.command.spawn_shell()?; 133 | 134 | let is_echoing = matches!(self.shell_type, StdShellType::PowerShell); 135 | Ok(SpawnedShell { 136 | shell: Echoing::new(shell, is_echoing), 137 | reader, 138 | writer, 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | This file provides some tips and troubleshooting advice for `term-transcript` 4 | in the FAQ format. 5 | 6 | ## Which template to use? 7 | 8 | The `term-transcript` library and the CLI app come with two main ways to render transcripts 9 | into SVG. 10 | 11 | ### HTML embedding 12 | 13 | Text can be embedded into SVG as an HTML fragment (i.e., using a ``). 14 | This is motivated by the fact that SVG isn't good at text layout, 15 | particularly for multiline text and/or text with background coloring. 16 | HTML, on the other hand, can lay out such text effortlessly. Thus, `term-transcript` 17 | avoids the need of layout logic by embedding pieces of HTML (essentially, `
`-formatted ``s)
 18 | into the generated SVGs.
 19 | 
 20 | HTML embedding is the default approach of the CLI app; it corresponds to the `new()` constructor in [`Template`].
 21 | It can lead to issues [with viewing](#html-embedding-not-supported-error) the rendered SVG as described below.
 22 | 
 23 | ### Pure SVG
 24 | 
 25 | As the second option, pure SVG can be generated with manual text layout logic
 26 | and a hack to deal with background coloring (lines of text with
 27 | appropriately colored `█` chars placed behind the content lines).
 28 | The resulting SVG is supported by more viewers than HTML embedding, 
 29 | but it may look incorrectly in certain corner cases.
 30 | For example, if the font family used in the template does not contain `█` or some chars
 31 | used in the transcript, the background coloring boxes for text may be mispositioned.
 32 | 
 33 | Pure SVG is generated by the CLI app if the `--pure-svg` flag is set.
 34 | In the library, it corresponds to the `pure_svg()` constructor in [`Template`].
 35 | 
 36 | ## HTML embedding not supported error
 37 | 
 38 | *(Applies to [the default template](#html-embedding) only; consider using the [pure SVG template](#pure-svg).)*
 39 | 
 40 | If the generated SVG file contains a single red line of text "HTML embedding not supported...",
 41 | it means that you view it using a program that does not support HTML embedding for SVG.
 42 | That is, the real transcript is still there, it is just not rendered properly by a particular viewer.
 43 | All modern web browsers support HTML embedding (since they support HTML rendering anyway),
 44 | but some other SVG viewers, such as [Inkscape], don't.
 45 | 
 46 | ## Transcripts & Content Security Policy
 47 | 
 48 | A potential reason for rendering errors when the transcript SVG is viewed from a browser
 49 | is [Content Security Policy (CSP)][CSP] set by the HTTP server.
 50 | If this is the case, the developer console will contain
 51 | an error mentioning the policy, e.g. "Refused to apply inline style because it violates
 52 | the following Content Security Policy...". To properly render a transcript, the CSP should contain
 53 | the `style-src 'unsafe-inline'` permission.
 54 | 
 55 | As an example, GitHub does not provide sufficient CSP permissions for the files attached to issues,
 56 | comments, etc. On the other hand, *committed* files are served with adequate permissions;
 57 | they can be linked to using a URL like `https://github.com/$user/$repo/raw/HEAD/path/to/snapshot.svg?sanitize=true`.
 58 | 
 59 | ## Customizing fonts
 60 | 
 61 | It is possible to customize the font used in the transcript using `font_family` and `additional_styles`
 62 | fields in [`TemplateOptions`] (when using the Rust library), or `--font` / `--styles` arguments
 63 | (when using the CLI app).
 64 | 
 65 | For example, the [Fira Mono](https://github.com/mozilla/Fira) font family can be included
 66 | by setting additional styles to the following value:
 67 | 
 68 |  ```css
 69 | @import url(https://code.cdn.mozilla.net/fonts/fira.css);
 70 | ```
 71 | 
 72 | It is possible to include `@font-face`s directly instead, which can theoretically
 73 | be used to embed the font family via [data URLs]:
 74 | 
 75 | ```css
 76 | @font-face {
 77 |   font-family: 'Fira Mono';
 78 |   src: local('Fira Mono'), url('data:font/woff;base64,...') format('woff');
 79 |   /* base64-encoded WOFF font snipped above */
 80 |   font-weight: 400;
 81 |   font-style: normal;
 82 | }
 83 | ```
 84 | 
 85 | Such embedding, however, typically leads to a huge file size overhead (hundreds of kilobytes)
 86 | unless the fonts are subsetted beforehand (minimized to contain only glyphs necessary
 87 | to render the transcript). Which is exactly the functionality provided by the [`FontEmbedder`] interface
 88 | and its [`FontSubsetter`] implementation (the latter via the `font-subset` feature). If using CLI,
 89 | you can embed fonts with the `--embed-font` flag.
 90 | 
 91 | Beware that if a font is included from an external source and the including SVG is hosted
 92 | on a website, it may be subject to CSP restrictions as described [above](#transcripts--content-security-policy).
 93 | 
 94 | [Inkscape]: https://inkscape.org/
 95 | [CSP]: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
 96 | [`Template`]: https://slowli.github.io/term-transcript/term_transcript/svg/struct.Template.html
 97 | [`TemplateOptions`]: https://slowli.github.io/term-transcript/term_transcript/svg/struct.TemplateOptions.html
 98 | [`FontEmbedder`]: https://slowli.github.io/term-transcript/term_transcript/svg/trait.FontEmbedder.html
 99 | [`FontSubsetter`]: https://slowli.github.io/term-transcript/term_transcript/svg/struct.FontSubsetter.html
100 | [data URLs]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs
101 | 


--------------------------------------------------------------------------------
/lib/src/test/tests.rs:
--------------------------------------------------------------------------------
  1 | use termcolor::NoColor;
  2 | use test_casing::test_casing;
  3 | 
  4 | use super::{color_diff::ColorSpan, *};
  5 | use crate::{
  6 |     svg::{Template, TemplateOptions},
  7 |     Captured, Interaction, Transcript, UserInput,
  8 | };
  9 | 
 10 | #[test_casing(2, [MatchKind::TextOnly, MatchKind::Precise])]
 11 | fn snapshot_testing(match_kind: MatchKind) -> anyhow::Result<()> {
 12 |     let mut test_config = TestConfig::new(ShellOptions::default()).with_match_kind(match_kind);
 13 |     let transcript = Transcript::from_inputs(
 14 |         &mut ShellOptions::default(),
 15 |         vec![UserInput::command("echo \"Hello, world!\"")],
 16 |     )?;
 17 | 
 18 |     let mut svg_buffer = vec![];
 19 |     Template::new(TemplateOptions::default()).render(&transcript, &mut svg_buffer)?;
 20 | 
 21 |     let parsed = Transcript::from_svg(svg_buffer.as_slice())?;
 22 |     test_config.test_transcript(&parsed);
 23 |     Ok(())
 24 | }
 25 | 
 26 | fn test_negative_snapshot_testing(
 27 |     out: &mut Vec,
 28 |     test_config: &mut TestConfig,
 29 | ) -> anyhow::Result<()> {
 30 |     let mut transcript = Transcript::from_inputs(
 31 |         &mut ShellOptions::default(),
 32 |         vec![UserInput::command("echo \"Hello, world!\"")],
 33 |     )?;
 34 |     transcript.add_interaction(UserInput::command("echo \"Sup?\""), "Nah");
 35 | 
 36 |     let mut svg_buffer = vec![];
 37 |     Template::new(TemplateOptions::default()).render(&transcript, &mut svg_buffer)?;
 38 | 
 39 |     let parsed = Transcript::from_svg(svg_buffer.as_slice())?;
 40 |     let (stats, _) = test_config.test_transcript_inner(&mut NoColor::new(out), &parsed)?;
 41 |     assert_eq!(stats.errors(MatchKind::TextOnly), 1);
 42 |     Ok(())
 43 | }
 44 | 
 45 | #[test]
 46 | fn negative_snapshot_testing_with_default_output() {
 47 |     let mut out = vec![];
 48 |     let mut test_config =
 49 |         TestConfig::new(ShellOptions::default()).with_color_choice(ColorChoice::Never);
 50 |     test_negative_snapshot_testing(&mut out, &mut test_config).unwrap();
 51 | 
 52 |     let out = String::from_utf8(out).unwrap();
 53 |     assert!(out.contains("[+] Input: echo \"Hello, world!\""), "{out}");
 54 |     assert_eq!(out.matches("Hello, world!").count(), 1, "{out}");
 55 |     // ^ output for successful interactions should not be included
 56 |     assert!(out.contains("[-] Input: echo \"Sup?\""), "{out}");
 57 |     assert!(out.contains("Nah"), "{out}");
 58 | }
 59 | 
 60 | #[test]
 61 | fn negative_snapshot_testing_with_verbose_output() {
 62 |     let mut out = vec![];
 63 |     let mut test_config = TestConfig::new(ShellOptions::default())
 64 |         .with_output(TestOutputConfig::Verbose)
 65 |         .with_color_choice(ColorChoice::Never);
 66 |     test_negative_snapshot_testing(&mut out, &mut test_config).unwrap();
 67 | 
 68 |     let out = String::from_utf8(out).unwrap();
 69 |     assert!(out.contains("[+] Input: echo \"Hello, world!\""), "{out}");
 70 |     assert_eq!(out.matches("Hello, world!").count(), 2, "{out}");
 71 |     // ^ output for successful interactions should be included
 72 |     assert!(out.contains("[-] Input: echo \"Sup?\""), "{out}");
 73 |     assert!(out.contains("Nah"), "{out}");
 74 | }
 75 | 
 76 | fn diff_snapshot_with_color(expected_capture: &str, actual_capture: &str) -> (TestStats, String) {
 77 |     let expected_capture = Captured::from(expected_capture.to_owned());
 78 |     let parsed = Transcript {
 79 |         interactions: vec![Interaction {
 80 |             input: UserInput::command("test"),
 81 |             output: Parsed {
 82 |                 plaintext: expected_capture.to_plaintext().unwrap(),
 83 |                 color_spans: ColorSpan::parse(expected_capture.as_ref()).unwrap(),
 84 |             },
 85 |             exit_status: None,
 86 |         }],
 87 |     };
 88 | 
 89 |     let mut reproduced = Transcript::new();
 90 |     reproduced.add_interaction(UserInput::command("test"), actual_capture);
 91 | 
 92 |     let mut out: Vec = vec![];
 93 |     let stats = compare_transcripts(
 94 |         &mut NoColor::new(&mut out),
 95 |         &parsed,
 96 |         &reproduced,
 97 |         MatchKind::Precise,
 98 |         false,
 99 |     )
100 |     .unwrap();
101 |     (stats, String::from_utf8(out).unwrap())
102 | }
103 | 
104 | #[test]
105 | fn snapshot_testing_with_color_diff() {
106 |     let (stats, out) = diff_snapshot_with_color(
107 |         "Apr 18 12:54 \u{1b}[0m\u{1b}[34m.\u{1b}[0m",
108 |         "Apr 18 12:54 \u{1b}[0m\u{1b}[34m.\u{1b}[0m",
109 |     );
110 | 
111 |     assert_eq!(stats.matches(), [Some(MatchKind::Precise)]);
112 |     assert!(out.contains("[+] Input: test"), "{out}");
113 | }
114 | 
115 | #[test]
116 | fn no_match_for_snapshot_testing_with_color_diff() {
117 |     let (stats, out) = diff_snapshot_with_color(
118 |         "Apr 18 12:54 \u{1b}[0m\u{1b}[33m.\u{1b}[0m",
119 |         "Apr 19 12:54 \u{1b}[0m\u{1b}[33m.\u{1b}[0m",
120 |     );
121 | 
122 |     assert_eq!(stats.matches(), [None]);
123 |     assert!(out.contains("[-] Input: test"), "{out}");
124 | }
125 | 
126 | #[test]
127 | fn text_match_for_snapshot_testing_with_color_diff() {
128 |     let (stats, out) = diff_snapshot_with_color(
129 |         "Apr 18 12:54 \u{1b}[0m\u{1b}[33m.\u{1b}[0m",
130 |         "Apr 18 12:54 \u{1b}[0m\u{1b}[34m.\u{1b}[0m",
131 |     );
132 | 
133 |     assert_eq!(stats.matches(), [Some(MatchKind::TextOnly)]);
134 |     assert!(out.contains("[#] Input: test"), "{out}");
135 |     assert!(out.contains("13..14 ----   yellow/(none)   ----     blue/(none)"));
136 | }
137 | 


--------------------------------------------------------------------------------
/examples/custom.html.handlebars:
--------------------------------------------------------------------------------
  1 | {{!
  2 |   Example of a custom Handlebars template for use with `term-transcript`.
  3 |   This template renders an HTML document with collapsible interaction sections.
  4 | }}
  5 | {{! Import misc helpers to the scope. }}
  6 | {{>_helpers}}
  7 | {{! CSS definitions: colors. }}
  8 | {{~#*inline "styles_colors"}}
  9 | :root {
 10 |   {{~#each palette.colors}}
 11 | 
 12 |   --{{@key}}: {{this}}; --i-{{@key}}: {{lookup ../palette.intense_colors @key}};
 13 |   {{~/each}}
 14 | 
 15 |   --hl-black: rgba(255, 255, 255, 0.1);
 16 | }
 17 | .fg0 { color: var(--black); } .bg0 { background: var(--black); }
 18 | .fg1 { color: var(--red); } .bg1 { background: var(--red); }
 19 | .fg2 { color: var(--green); } .bg2 { background: var(--green); }
 20 | .fg3 { color: var(--yellow); } .bg3 { background: var(--yellow); }
 21 | .fg4 { color: var(--blue); } .bg4 { background: var(--blue); }
 22 | .fg5 { color: var(--magenta); } .bg5 { background: var(--magenta); }
 23 | .fg6 { color: var(--cyan); } .bg6 { background: var(--cyan); }
 24 | .fg7 { color: var(--white); } .bg7 { background: var(--white); }
 25 | .fg8 { color: var(--i-black); } .bg8 { background: var(--i-black); }
 26 | .fg9 { color: var(--i-red); } .bg9 { background: var(--i-red); }
 27 | .fg10 { color: var(--i-green); } .bg10 { background: var(--i-green); }
 28 | .fg11 { color: var(--i-yellow); } .bg11 { background: var(--i-yellow); }
 29 | .fg12 { color: var(--i-blue); } .bg12 { background: var(--i-blue); }
 30 | .fg13 { color: var(--i-magenta); } .bg13 { background: var(--i-magenta); }
 31 | .fg14 { color: var(--i-cyan); } .bg14 { background: var(--i-cyan); }
 32 | .fg15 { color: var(--i-white); } .bg15 { background: var(--i-white); }
 33 | {{/inline~}}
 34 | 
 35 | {{! CSS definitions }}
 36 | {{~#*inline "styles"}}
 37 |     
 69 | {{/inline~}}
 70 | 
 71 | 
 72 | 
 73 |   
 74 |     
 75 |     
 76 |     
 77 | 
 78 |     {{~>styles}}
 79 |     
 80 |     Terminal transcript
 81 |   
 82 |   
 83 |     
84 |
85 |

Terminal Transcript

86 |

This example demonstrates using term-transcript with a custom template.

87 |

Templating allows changing the output format completely; in this case, it is changed to HTML instead of default SVG. The template source and docs can be found in the project repository.

88 |
89 |
90 |
91 |
92 | {{~#each interactions}} 93 |
94 |

95 | 98 |

99 |
100 |
101 |
102 |               {{~#each output as |line|~}}
103 |                 {{~#each line.spans as |span|~}}
104 |                   {{! Invoke the `html_span` helper used by the default template. }}
105 |                   {{{eval ">html_span" span=span html_suffix=""}}}
106 |                 {{~else~}}{{! The line may be empty }}
107 |                 {{~/each}}
108 |                 {{! Add a newline in order to make the text correctly copyable }}
109 | 
110 |               {{/each~}}
111 |               
112 |
113 |
114 |
115 | {{~/each}} 116 |
117 |
118 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /cli/tests/snapshots/test.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
77 |
$ term-transcript exec -I 300ms -T 100ms rainbow.sh > rainbow.svg
 78 | # `-T` option defines the I/O timeout for the shell,
 79 | # and `-I` specifies the additional initialization timeout
80 |
81 |
$ term-transcript test -I 300ms -T 100ms -v rainbow.svg
 82 | # `-v` switches on verbose output
83 |
Testing file rainbow.svg...
 84 |   [+] Input: rainbow.sh
 85 |     Base colors:
 86 |     black red green yellow blue magenta cyan white 
 87 |     black red green yellow blue magenta cyan white 
 88 |     Base colors (bg):
 89 |     black red green yellow blue magenta cyan white 
 90 |     black red green yellow blue magenta cyan white 
 91 |     ANSI color palette:
 92 |     !?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?
 93 |     !?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?
 94 |     !?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?
 95 |     !?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?
 96 |     !?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?
 97 |     !?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?
 98 |     ANSI grayscale palette:
 99 |     !?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?!?
100 |     24-bit colors:
101 |     pink orange brown teal 
102 | Totals: passed: 1, errors: 0, failures: 0
103 |
104 |
105 |
106 | 107 | 108 | 109 |
110 | -------------------------------------------------------------------------------- /cli/tests/snapshots/test-fail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 74 | 75 | 76 | 77 |
78 |
$ term-transcript exec -I 300ms -T 100ms 'rainbow.sh --short' > bogus.svg && \
 79 |   sed -i -E -e 's/(fg4|bg13)//g' bogus.svg
 80 | # Mutate the captured output, removing some styles
81 |
82 |
$ term-transcript test -I 300ms -T 100ms --precise bogus.svg
 83 | # --precise / -p flag enables comparison by style
84 |
Testing file bogus.svg...
 85 |   [#] Input: rainbow.sh --short
 86 | = Base colors:
 87 | > black red green yellow blue magenta cyan white 
 88 | >                        ^^^^
 89 | = black red green yellow blue magenta cyan white 
 90 | = Base colors (bg):
 91 | = black red green yellow blue magenta cyan white 
 92 | > black red green yellow blue magenta cyan white 
 93 | >                             ^^^^^^^
 94 | Positions      Expected style          Actual style     
 95 | ========== ====================== ======================
 96 |     36..40 --u-   (none)/(none)   --u-     blue/(none)  
 97 |   203..210 ----   (none)/(none)   ----   (none)/magenta*
 98 | Totals: passed: 0, errors: 1, failures: 0
99 |
100 |
101 |
102 |
103 | -------------------------------------------------------------------------------- /e2e-tests/rainbow/src/main.rs: -------------------------------------------------------------------------------- 1 | //! Simple executable that outputs colored output. Used for testing. 2 | 3 | use std::{ 4 | env, 5 | io::{self, Write}, 6 | }; 7 | 8 | use termcolor::{Ansi, Color, ColorSpec, WriteColor}; 9 | 10 | const BASE_COLORS: &[(&str, Color)] = &[ 11 | ("black", Color::Black), 12 | ("blue", Color::Blue), 13 | ("green", Color::Green), 14 | ("red", Color::Red), 15 | ("cyan", Color::Cyan), 16 | ("magenta", Color::Magenta), 17 | ("yellow", Color::Yellow), 18 | ]; 19 | 20 | const RGB_COLORS: &[(&str, Color)] = &[ 21 | ("pink", Color::Rgb(0xff, 0xbb, 0xdd)), 22 | ("orange", Color::Rgb(0xff, 0xaa, 0x44)), 23 | ("brown", Color::Rgb(0x9f, 0x40, 0x10)), 24 | ("teal", Color::Rgb(0x10, 0x88, 0x9f)), 25 | ]; 26 | 27 | fn write_base_styles(writer: &mut impl WriteColor, base: &ColorSpec, name: &str) -> io::Result<()> { 28 | writer.reset()?; 29 | write!(writer, "{name}: ")?; 30 | writer.set_color(base)?; 31 | write!(writer, "Regular")?; 32 | writer.reset()?; 33 | write!(writer, " ")?; 34 | 35 | writer.set_color(base.clone().set_bold(true))?; 36 | write!(writer, "Bold")?; 37 | writer.reset()?; 38 | write!(writer, " ")?; 39 | 40 | writer.set_color(base.clone().set_italic(true))?; 41 | write!(writer, "Italic")?; 42 | writer.reset()?; 43 | write!(writer, " ")?; 44 | 45 | writer.set_color(base.clone().set_bold(true).set_italic(true))?; 46 | write!(writer, "Bold+Italic")?; 47 | writer.reset()?; 48 | writeln!(writer) 49 | } 50 | 51 | fn write_base_colors( 52 | writer: &mut impl WriteColor, 53 | intense: bool, 54 | long_lines: bool, 55 | ) -> anyhow::Result<()> { 56 | for (i, &(name, color)) in BASE_COLORS.iter().enumerate() { 57 | let mut color_spec = ColorSpec::new(); 58 | color_spec.set_fg(Some(color)).set_intense(intense); 59 | if (i % 2 == 0) ^ intense { 60 | color_spec.set_underline(true); 61 | } 62 | writer.set_color(&color_spec)?; 63 | write!(writer, "{name}")?; 64 | writer.reset()?; 65 | write!(writer, " ")?; 66 | 67 | if long_lines { 68 | color_spec 69 | .set_underline(!color_spec.underline()) 70 | .set_italic(true); 71 | writer.set_color(&color_spec)?; 72 | write!(writer, "{name}/italic")?; 73 | writer.reset()?; 74 | write!(writer, " ")?; 75 | } 76 | } 77 | writeln!(writer)?; 78 | Ok(()) 79 | } 80 | 81 | fn write_base_colors_bg(writer: &mut impl WriteColor, intense: bool) -> anyhow::Result<()> { 82 | for &(name, color) in BASE_COLORS { 83 | let mut color_spec = ColorSpec::new(); 84 | color_spec 85 | .set_fg(Some(Color::White)) 86 | .set_bg(Some(color)) 87 | .set_intense(intense); 88 | writer.set_color(&color_spec)?; 89 | write!(writer, "{name}")?; 90 | writer.reset()?; 91 | write!(writer, " ")?; 92 | } 93 | writeln!(writer)?; 94 | Ok(()) 95 | } 96 | 97 | fn main() -> anyhow::Result<()> { 98 | let long_lines = env::args().any(|arg| arg == "--long-lines"); 99 | let short = env::args().any(|arg| arg == "--short"); 100 | let mut writer = Ansi::new(io::stdout()); 101 | 102 | if short { 103 | writeln!(writer, "Base styles:")?; 104 | write_base_styles(&mut writer, &ColorSpec::new(), " None")?; 105 | write_base_styles(&mut writer, ColorSpec::new().set_dimmed(true), " Dimmed")?; 106 | write_base_styles( 107 | &mut writer, 108 | ColorSpec::new().set_underline(true), 109 | "Underline", 110 | )?; 111 | write_base_styles( 112 | &mut writer, 113 | ColorSpec::new() 114 | .set_fg(Some(Color::Black)) 115 | .set_bg(Some(Color::White)), 116 | " With bg", 117 | )?; 118 | } 119 | 120 | writeln!(writer, "Base colors:")?; 121 | write_base_colors(&mut writer, false, long_lines)?; 122 | write_base_colors(&mut writer, true, long_lines)?; 123 | 124 | writeln!(writer, "Base colors (bg):")?; 125 | write_base_colors_bg(&mut writer, false)?; 126 | write_base_colors_bg(&mut writer, true)?; 127 | 128 | if short { 129 | return Ok(()); 130 | } 131 | 132 | writeln!(writer, "ANSI color palette:")?; 133 | for color_idx in 16_u8..232 { 134 | writer.set_color(ColorSpec::new().set_fg(Some(Color::Ansi256(color_idx))))?; 135 | write!(writer, "!")?; 136 | 137 | let col = (color_idx - 16) % 36; 138 | let fg = if col < 16 { Color::White } else { Color::Black }; 139 | writer.set_color( 140 | ColorSpec::new() 141 | .set_fg(Some(fg)) 142 | .set_bg(Some(Color::Ansi256(color_idx))), 143 | )?; 144 | write!(writer, "?")?; 145 | 146 | if col == 35 && !long_lines { 147 | writer.reset()?; 148 | writeln!(writer)?; 149 | } 150 | } 151 | 152 | if long_lines { 153 | writer.reset()?; 154 | writeln!(writer)?; 155 | } 156 | 157 | writeln!(writer, "ANSI grayscale palette:")?; 158 | for color_idx in 232_u8..=255 { 159 | writer.set_color(ColorSpec::new().set_fg(Some(Color::Ansi256(color_idx))))?; 160 | write!(writer, "!")?; 161 | 162 | let fg = if color_idx < 244 { 163 | Color::White 164 | } else { 165 | Color::Black 166 | }; 167 | writer.set_color( 168 | ColorSpec::new() 169 | .set_fg(Some(fg)) 170 | .set_bg(Some(Color::Ansi256(color_idx))) 171 | .set_bold(true), 172 | )?; 173 | write!(writer, "?")?; 174 | } 175 | writer.reset()?; 176 | writeln!(writer)?; 177 | 178 | writeln!(writer, "24-bit colors:")?; 179 | for &(name, color) in RGB_COLORS { 180 | writer.set_color(ColorSpec::new().set_fg(Some(color)))?; 181 | write!(writer, "{name}")?; 182 | writer.reset()?; 183 | write!(writer, " ")?; 184 | } 185 | writeln!(writer)?; 186 | 187 | Ok(()) 188 | } 189 | -------------------------------------------------------------------------------- /lib/README.md: -------------------------------------------------------------------------------- 1 | # Capturing and Snapshot Testing for CLI / REPL Applications 2 | 3 | [![CI](https://github.com/slowli/term-transcript/actions/workflows/ci.yml/badge.svg)](https://github.com/slowli/term-transcript/actions/workflows/ci.yml) 4 | [![License: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%2FApache--2.0-blue)](https://github.com/slowli/term-transcript#license) 5 | ![rust 1.83+ required](https://img.shields.io/badge/rust-1.83+-blue.svg?label=Required%20Rust) 6 | 7 | **Documentation:** [![Docs.rs](https://docs.rs/term-transcript/badge.svg)](https://docs.rs/term-transcript/) 8 | [![crate docs (master)](https://img.shields.io/badge/master-yellow.svg?label=docs)](https://slowli.github.io/term-transcript/term_transcript/) 9 | 10 | This crate allows to: 11 | 12 | - Create transcripts of interacting with a terminal, capturing both the output text 13 | and [ANSI-compatible color info][SGR]. 14 | - Save these transcripts in the [SVG] format, so that they can be easily embedded as images 15 | into HTML / Markdown documents. Rendering logic can be customized via [Handlebars] template engine; 16 | thus, other output formats besides SVG (e.g., HTML) are possible. 17 | See [crate docs][custom-templates] for an intro to custom templates. 18 | - Parse transcripts from SVG. 19 | - Test that a parsed transcript actually corresponds to the terminal output (either as text 20 | or text + colors). 21 | 22 | The primary use case is easy to create and maintain end-to-end tests for CLI / REPL apps. 23 | Such tests can be embedded into a readme file. 24 | 25 | ## Usage 26 | 27 | Add this to your `Crate.toml`: 28 | 29 | ```toml 30 | [dependencies] 31 | term-transcript = "0.4.0" 32 | ``` 33 | 34 | Example of usage: 35 | 36 | ```rust 37 | use term_transcript::{ 38 | svg::{Template, TemplateOptions}, ShellOptions, Transcript, UserInput, 39 | }; 40 | use std::str; 41 | 42 | let transcript = Transcript::from_inputs( 43 | &mut ShellOptions::default(), 44 | vec![UserInput::command(r#"echo "Hello world!""#)], 45 | )?; 46 | let mut writer = vec![]; 47 | // ^ Any `std::io::Write` implementation will do, such as a `File`. 48 | Template::new(TemplateOptions::default()).render(&transcript, &mut writer)?; 49 | println!("{}", str::from_utf8(&writer)?); 50 | Ok::<_, anyhow::Error>(()) 51 | ``` 52 | 53 | See more examples in the crate docs and the [FAQ](../FAQ.md) for some tips 54 | and troubleshooting advice. 55 | 56 | ### CLI app 57 | 58 | Most of the library functionality is packaged into [a CLI binary][term-transcript-cli], 59 | which allows using the library without Rust knowledge. See the binary docs 60 | for the installation and usage guides. 61 | 62 | ### Snapshot examples 63 | 64 | An SVG snapshot of [the `rainbow` example](../e2e-tests/rainbow) 65 | produced by this crate: 66 | 67 | ![Snapshot of rainbow example][rainbow-snapshot-link] 68 | 69 | A snapshot of the same example with the scrolling animation and window frame: 70 | 71 | ![Animated snapshot of rainbow example][animated-snapshot-link] 72 | 73 | ## Limitations 74 | 75 | - Terminal coloring only works with ANSI escape codes. (Since ANSI escape codes 76 | are supported even on Windows nowadays, this shouldn't be a significant problem.) 77 | - ANSI escape sequences other than [SGR] ones are either dropped (in case of [CSI] sequences), 78 | or lead to an error. 79 | - By default, the crate exposes APIs to perform capture via OS pipes. 80 | Since the terminal is not emulated in this case, programs dependent on [`isatty`] checks 81 | or getting term size can produce different output than if launched in an actual shell 82 | (no coloring, no line wrapping etc.). 83 | - It is possible to capture output from a pseudo-terminal (PTY) using the `portable-pty` 84 | crate feature. However, since most escape sequences are dropped, this is still not a good 85 | option to capture complex outputs (e.g., ones moving cursor). 86 | - PTY support for Windows is shaky. It requires a somewhat recent Windows version 87 | (Windows 10 from October 2018 or newer), and may work incorrectly even for the recent versions. 88 | 89 | ## Alternatives / similar tools 90 | 91 | - [`insta`](https://crates.io/crates/insta) is a generic snapshot testing library, which 92 | is amazing in general, but *kind of* too low-level for E2E CLI testing. 93 | - [`rexpect`](https://crates.io/crates/rexpect) allows testing CLI / REPL applications 94 | by scripting interactions with them in tests. It works in Unix only. 95 | - [`trybuild`](https://crates.io/crates/trybuild) snapshot-tests output 96 | of a particular program (the Rust compiler). 97 | - [`trycmd`](https://crates.io/crates/trycmd) snapshot-tests CLI apps using 98 | a text-based format. 99 | - Tools like [`termtosvg`](https://github.com/nbedos/termtosvg) and 100 | [Asciinema](https://asciinema.org/) allow recording terminal sessions and save them to SVG. 101 | The output of these tools is inherently *dynamic* (which, e.g., results in animated SVGs). 102 | This crate intentionally chooses a simpler static format, which makes snapshot testing easier. 103 | 104 | ## License 105 | 106 | Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) 107 | or [MIT license](LICENSE-MIT) at your option. 108 | 109 | Unless you explicitly state otherwise, any contribution intentionally submitted 110 | for inclusion in `term-transcript` by you, as defined in the Apache-2.0 license, 111 | shall be dual licensed as above, without any additional terms or conditions. 112 | 113 | [SVG]: https://developer.mozilla.org/en-US/docs/Web/SVG 114 | [Handlebars]: https://handlebarsjs.com/ 115 | [SGR]: https://en.wikipedia.org/wiki/ANSI_escape_code#SGR 116 | [CSI]: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences 117 | [`isatty`]: https://man7.org/linux/man-pages/man3/isatty.3.html 118 | [custom-templates]: https://slowli.github.io/term-transcript/term_transcript/svg/struct.Template.html#customization 119 | [term-transcript-cli]: https://crates.io/crates/term-transcript-cli 120 | [rainbow-snapshot-link]: https://github.com/slowli/term-transcript/raw/HEAD/examples/rainbow.svg?sanitize=true 121 | [animated-snapshot-link]: https://github.com/slowli/term-transcript/raw/HEAD/examples/animated.svg?sanitize=true 122 | -------------------------------------------------------------------------------- /lib/src/svg/data.rs: -------------------------------------------------------------------------------- 1 | //! Data provided to Handlebars templates. 2 | 3 | use std::{collections::HashMap, fmt}; 4 | 5 | use serde::Serialize; 6 | 7 | use super::write::StyledLine; 8 | use crate::{ 9 | svg::{EmbeddedFont, TemplateOptions}, 10 | UserInput, 11 | }; 12 | 13 | /// Root data structure sent to the Handlebars template. 14 | /// 15 | /// # Examples 16 | /// 17 | /// Here's example of JSON serialization of this type: 18 | /// 19 | /// ``` 20 | /// # use term_transcript::{svg::{TemplateOptions, NamedPalette}, Transcript, UserInput}; 21 | /// let mut transcript = Transcript::new(); 22 | /// let input = UserInput::command("rainbow"); 23 | /// transcript.add_interaction(input, "Hello, \u{1b}[32mworld\u{1b}[0m!"); 24 | /// let template_options = TemplateOptions { 25 | /// palette: NamedPalette::Dracula.into(), 26 | /// font_family: "Consolas, Menlo, monospace".to_owned(), 27 | /// ..TemplateOptions::default() 28 | /// }; 29 | /// let data = template_options.render_data(&transcript).unwrap(); 30 | /// 31 | /// let expected_json = serde_json::json!({ 32 | /// "creator": { 33 | /// "name": "term-transcript", 34 | /// "version": "0.4.0", 35 | /// "repo": "https://github.com/slowli/term-transcript", 36 | /// }, 37 | /// "width": 720, 38 | /// "line_height": null, 39 | /// "palette": { 40 | /// "colors": { 41 | /// "black": "#282936", 42 | /// "red": "#ea51b2", 43 | /// "green": "#ebff87", 44 | /// "yellow": "#00f769", 45 | /// "blue": "#62d6e8", 46 | /// "magenta": "#b45bcf", 47 | /// "cyan": "#a1efe4", 48 | /// "white": "#e9e9f4", 49 | /// }, 50 | /// "intense_colors": { 51 | /// "black": "#626483", 52 | /// "red": "#b45bcf", 53 | /// "green": "#3a3c4e", 54 | /// "yellow": "#4d4f68", 55 | /// "blue": "#62d6e8", 56 | /// "magenta": "#f1f2f8", 57 | /// "cyan": "#00f769", 58 | /// "white": "#f7f7fb", 59 | /// }, 60 | /// }, 61 | /// "font_family": "Consolas, Menlo, monospace", 62 | /// "window_frame": false, 63 | /// "wrap": { 64 | /// "hard_break_at": 80, 65 | /// }, 66 | /// "line_numbers": null, 67 | /// "has_failures": false, 68 | /// "interactions": [{ 69 | /// "input": { 70 | /// "text": "rainbow", 71 | /// "prompt": "$", 72 | /// "hidden": false, 73 | /// }, 74 | /// "output": [{ 75 | /// "spans": [ 76 | /// { "text": "Hello, " }, 77 | /// { "text": "world", "fg": 2 }, 78 | /// { "text": "!" }, 79 | /// ], 80 | /// }], 81 | /// "failure": false, 82 | /// "exit_status": null, 83 | /// }] 84 | /// }); 85 | /// assert_eq!(serde_json::to_value(data).unwrap(), expected_json); 86 | /// ``` 87 | #[derive(Debug, Serialize)] 88 | #[non_exhaustive] 89 | pub struct HandlebarsData<'r> { 90 | /// Information about the rendering software. 91 | pub creator: CreatorData, 92 | /// Template options used for rendering. These options are flattened into the parent 93 | /// during serialization. 94 | #[serde(flatten)] 95 | pub options: &'r TemplateOptions, 96 | /// Recorded terminal interactions. 97 | pub interactions: Vec>, 98 | /// Has any of terminal interactions failed? 99 | pub has_failures: bool, 100 | /// A font (usually subset) to be embedded into the generated transcript. 101 | #[serde(skip_serializing_if = "Option::is_none")] 102 | pub embedded_font: Option, 103 | } 104 | 105 | // 1. font-subset -> term-transcript -> font-subset-cli, .. 106 | // Problem: different workspaces / repos; meaning that font-subset-cli will depend on 2 `font-subset`s (????) 107 | // Patching the font-subset dep sort of works, unless term-transcript code needs to be modified 108 | // 109 | // 2. same, but font-subset is an optional dep in term-transcript, not used in font-subset-cli 110 | // (replaced with a local module) 111 | 112 | /// Information about software used for rendering (i.e., this crate). 113 | /// 114 | /// It can make sense to include this info as a comment in the rendered template 115 | /// for debugging purposes. 116 | #[derive(Debug, Serialize)] 117 | #[non_exhaustive] 118 | pub struct CreatorData { 119 | /// Name of software rendering the template. 120 | pub name: &'static str, 121 | /// Version of the rendering software. 122 | pub version: &'static str, 123 | /// Link to the git repository with the rendering software. 124 | pub repo: &'static str, 125 | } 126 | 127 | impl Default for CreatorData { 128 | fn default() -> Self { 129 | Self { 130 | name: env!("CARGO_PKG_NAME"), 131 | version: env!("CARGO_PKG_VERSION"), 132 | repo: env!("CARGO_PKG_REPOSITORY"), 133 | } 134 | } 135 | } 136 | 137 | /// Serializable version of [`Interaction`](crate::Interaction). 138 | #[derive(Serialize)] 139 | #[non_exhaustive] 140 | pub struct SerializedInteraction<'a> { 141 | /// User's input. 142 | pub input: &'a UserInput, 143 | /// Terminal output in the [HTML format](#html-output). 144 | pub(crate) output: Vec, 145 | /// Exit status of the latest executed program, or `None` if it cannot be determined. 146 | pub exit_status: Option, 147 | /// Was execution unsuccessful judging by the [`ExitStatus`](crate::ExitStatus)? 148 | pub failure: bool, 149 | } 150 | 151 | impl fmt::Debug for SerializedInteraction<'_> { 152 | fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 153 | formatter 154 | .debug_struct("SerializedInteraction") 155 | .field("input", &self.input) 156 | .field("output.line_count", &self.output.len()) 157 | .field("exit_status", &self.exit_status) 158 | .finish_non_exhaustive() 159 | } 160 | } 161 | 162 | #[derive(Debug, Serialize)] 163 | pub(super) struct CompleteHandlebarsData<'r> { 164 | #[serde(flatten)] 165 | pub inner: HandlebarsData<'r>, 166 | #[serde(rename = "const")] 167 | pub constants: &'r HashMap<&'static str, u32>, 168 | } 169 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: [ "v*" ] 7 | pull_request: 8 | branches: [ master ] 9 | 10 | env: 11 | # Minimum supported Rust version. 12 | msrv: 1.83.0 13 | # Nightly Rust necessary for building docs. 14 | nightly: nightly-2025-11-02 15 | 16 | jobs: 17 | build-msrv: 18 | strategy: 19 | matrix: 20 | include: 21 | - os: windows-latest 22 | features: "" 23 | - os: macos-latest 24 | features: "" 25 | - os: ubuntu-latest 26 | features: --all-features 27 | 28 | runs-on: ${{ matrix.os }} 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Install Rust 34 | uses: dtolnay/rust-toolchain@master 35 | with: 36 | toolchain: ${{ env.msrv }} 37 | 38 | - name: Cache cargo build 39 | uses: actions/cache@v4 40 | with: 41 | path: target 42 | key: ${{ runner.os }}${{ matrix.features }}-msrv-cargo-${{ hashFiles('**/Cargo.lock') }} 43 | restore-keys: ${{ runner.os }}${{ matrix.features }}-msrv-cargo 44 | 45 | - name: Run tests 46 | run: cargo test -p term-transcript ${{ matrix.features }} --all-targets 47 | - name: Run doc tests 48 | run: cargo test -p term-transcript ${{ matrix.features }} --doc 49 | 50 | build: 51 | uses: ./.github/workflows/build-reusable.yml 52 | 53 | build-docker: 54 | needs: 55 | - build 56 | - build-msrv 57 | permissions: 58 | contents: read 59 | packages: write 60 | runs-on: ubuntu-latest 61 | 62 | steps: 63 | - uses: actions/checkout@v4 64 | 65 | - name: Cache Docker build 66 | uses: actions/cache@v4 67 | with: 68 | path: target/docker 69 | key: ${{ runner.os }}-docker-buildkit-${{ hashFiles('Cargo.lock') }} 70 | restore-keys: ${{ runner.os }}-docker-buildkit 71 | 72 | - name: Install `socat` 73 | run: | 74 | sudo apt-get update && \ 75 | sudo apt-get install -y --no-install-suggests --no-install-recommends socat 76 | 77 | - name: Extract Docker metadata 78 | id: meta 79 | uses: docker/metadata-action@v5 80 | with: 81 | images: ghcr.io/${{ github.repository }} 82 | 83 | - name: Log in to Container registry 84 | uses: docker/login-action@v3 85 | with: 86 | registry: ghcr.io 87 | username: ${{ github.actor }} 88 | password: ${{ secrets.GITHUB_TOKEN }} 89 | 90 | - name: Set up Docker Buildx 91 | uses: docker/setup-buildx-action@v3 92 | - name: Identify Buildx container 93 | run: | 94 | CONTAINER_ID=$(docker ps --filter=ancestor=moby/buildkit:buildx-stable-1 --format='{{ .ID }}') 95 | echo "buildx_container=$CONTAINER_ID" | tee -a "$GITHUB_ENV" 96 | 97 | - name: Restore cache 98 | run: | 99 | if [[ -f target/docker/cache.db ]]; then 100 | docker cp target/docker/. "$buildx_container:/var/lib/buildkit" 101 | docker restart "$buildx_container" 102 | # Wait until the container is restarted 103 | sleep 5 104 | fi 105 | docker buildx du # Check the restored cache 106 | 107 | - name: Build image 108 | uses: docker/build-push-action@v5 109 | with: 110 | context: . 111 | file: cli/Dockerfile 112 | load: true 113 | tags: ${{ steps.meta.outputs.tags }} 114 | labels: ${{ steps.meta.outputs.labels }} 115 | 116 | # We want to only store cache volumes (type=exec.cachemount) since 117 | # their creation is computationally bound as opposed to other I/O-bound volume types. 118 | - name: Extract image cache 119 | run: | 120 | docker buildx prune --force --filter=type=regular 121 | docker buildx prune --force --filter=type=source.local 122 | rm -rf target/docker && mkdir -p target/docker 123 | docker cp "$buildx_container:/var/lib/buildkit/." target/docker 124 | du -ah -d 1 target/docker 125 | 126 | - name: Test image (--help) 127 | run: docker run --rm "$IMAGE_TAG" --help 128 | env: 129 | IMAGE_TAG: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} 130 | - name: Test image (print) 131 | run: | 132 | docker run -i --rm --env COLOR=always "$IMAGE_TAG" print - < examples/rainbow.svg 133 | env: 134 | IMAGE_TAG: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} 135 | - name: Test image (exec, nc host) 136 | run: | 137 | mkfifo /tmp/shell.fifo 138 | cat /tmp/shell.fifo | bash -i 2>&1 | nc -lU /tmp/shell.sock > /tmp/shell.fifo & 139 | docker run --rm -v /tmp/shell.sock:/tmp/shell.sock "$IMAGE_TAG" \ 140 | exec --shell nc --echoing --args=-U --args=/tmp/shell.sock 'ls -al' \ 141 | > test.svg 142 | docker run -i --rm --env COLOR=always "$IMAGE_TAG" print - < test.svg 143 | env: 144 | IMAGE_TAG: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} 145 | - name: Test image (exec, socat host) 146 | run: | 147 | rm -f /tmp/shell.sock 148 | socat UNIX-LISTEN:/tmp/shell.sock,fork EXEC:"bash -i",pty,setsid,ctty,stderr & 149 | docker run --rm -v /tmp/shell.sock:/tmp/shell.sock "$IMAGE_TAG" \ 150 | exec --shell nc --echoing --args=-U --args=/tmp/shell.sock 'ls -al' \ 151 | > test-pty.svg 152 | docker run -i --rm --env COLOR=always "$IMAGE_TAG" print - < test-pty.svg 153 | env: 154 | IMAGE_TAG: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} 155 | 156 | - name: Publish image 157 | if: github.event_name == 'push' 158 | run: docker push "$IMAGE_TAG" 159 | env: 160 | IMAGE_TAG: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} 161 | 162 | document: 163 | needs: 164 | - build 165 | - build-msrv 166 | if: github.event_name == 'push' && github.ref_type == 'branch' 167 | permissions: 168 | contents: write 169 | runs-on: ubuntu-latest 170 | 171 | steps: 172 | - uses: actions/checkout@v4 173 | 174 | - name: Install Rust 175 | uses: dtolnay/rust-toolchain@master 176 | with: 177 | toolchain: ${{ env.nightly }} 178 | 179 | - name: Cache cargo build 180 | uses: actions/cache@v4 181 | with: 182 | path: target 183 | key: ${{ runner.os }}-cargo-document-${{ hashFiles('Cargo.lock') }} 184 | restore-keys: ${{ runner.os }}-cargo-document 185 | 186 | - name: Build docs 187 | run: | 188 | cargo clean --doc && \ 189 | cargo rustdoc -p term-transcript --all-features -- --cfg docsrs 190 | 191 | - name: Copy examples 192 | run: | 193 | mkdir -p target/doc/examples && \ 194 | cp examples/rainbow.html target/doc/examples 195 | 196 | - name: Deploy 197 | uses: JamesIves/github-pages-deploy-action@v4 198 | with: 199 | branch: gh-pages 200 | folder: target/doc 201 | single-commit: true 202 | -------------------------------------------------------------------------------- /cli/src/shell.rs: -------------------------------------------------------------------------------- 1 | //! Shell-related command-line args. 2 | 3 | use std::{env, ffi::OsString, io, process::Command}; 4 | 5 | use clap::Args; 6 | use humantime::Duration; 7 | #[cfg(feature = "portable-pty")] 8 | use term_transcript::PtyCommand; 9 | use term_transcript::{traits::Echoing, Captured, ExitStatus, ShellOptions, Transcript, UserInput}; 10 | 11 | #[cfg(feature = "portable-pty")] 12 | mod pty { 13 | use std::str::FromStr; 14 | 15 | use anyhow::Context; 16 | 17 | #[cfg(feature = "portable-pty")] 18 | #[derive(Debug, Clone, Copy)] 19 | pub(super) struct PtySize { 20 | pub rows: u16, 21 | pub cols: u16, 22 | } 23 | 24 | impl FromStr for PtySize { 25 | type Err = anyhow::Error; 26 | 27 | fn from_str(s: &str) -> Result { 28 | let parts: Vec<_> = s.splitn(2, 'x').collect(); 29 | match parts.as_slice() { 30 | [rows_str, cols_str] => { 31 | let rows: u16 = rows_str 32 | .parse() 33 | .context("Cannot parse row count in PTY config")?; 34 | let cols: u16 = cols_str 35 | .parse() 36 | .context("Cannot parse column count in PTY config")?; 37 | Ok(Self { rows, cols }) 38 | } 39 | _ => Err(anyhow::anyhow!( 40 | "Invalid PTY config, expected a `{{rows}}x{{cols}}` string" 41 | )), 42 | } 43 | } 44 | } 45 | } 46 | 47 | #[cfg(feature = "portable-pty")] 48 | use self::pty::PtySize; 49 | 50 | #[derive(Debug, Clone, Copy)] 51 | enum ExitCodeCheck { 52 | Sh, 53 | PowerShell, 54 | } 55 | 56 | impl ExitCodeCheck { 57 | fn for_default_shell() -> Option { 58 | if cfg!(unix) { 59 | Some(Self::Sh) 60 | } else { 61 | None 62 | } 63 | } 64 | 65 | fn detect(shell_command: &OsString) -> Option { 66 | if shell_command == "sh" || shell_command == "bash" { 67 | Some(Self::Sh) 68 | } else if shell_command == "powershell" || shell_command == "pwsh" { 69 | Some(Self::PowerShell) 70 | } else { 71 | None 72 | } 73 | } 74 | 75 | fn check_exit_code(self, response: &Captured) -> Option { 76 | let response = response.to_plaintext().ok()?; 77 | match self { 78 | Self::Sh => response.trim().parse().ok().map(ExitStatus), 79 | Self::PowerShell => match response.trim() { 80 | "True" => Some(ExitStatus(0)), 81 | "False" => Some(ExitStatus(1)), 82 | _ => None, 83 | }, 84 | } 85 | } 86 | } 87 | 88 | #[derive(Debug, Args)] 89 | pub(crate) struct ShellArgs { 90 | /// Execute shell in a pseudo-terminal (PTY), rather than connecting to it via pipes. 91 | /// PTY size can be specified by providing row and column count in a string like 19x80. 92 | #[cfg(feature = "portable-pty")] 93 | #[arg(long)] 94 | pty: Option>, 95 | 96 | /// Shell command without args (they are supplied separately). If omitted, 97 | /// will be set to the default OS shell (`sh` for *NIX, `cmd` for Windows). 98 | #[arg(long, short = 's')] 99 | shell: Option, 100 | 101 | /// Sets the shell as echoing (i.e., one that echoes all inputs to the output). 102 | #[arg(long)] 103 | echoing: bool, 104 | 105 | /// Arguments to supply to the shell command. 106 | #[arg(name = "args", long, short = 'a')] 107 | shell_args: Vec, 108 | 109 | /// Timeout for I/O operations in milliseconds. 110 | #[arg(name = "io-timeout", long, short = 'T', default_value = "500ms")] 111 | io_timeout: Duration, 112 | 113 | /// Additional timeout waiting for the first output line after inputting a new command 114 | /// in milliseconds. 115 | #[arg(name = "init-timeout", long, short = 'I', default_value = "0ms")] 116 | init_timeout: Duration, 117 | } 118 | 119 | impl ShellArgs { 120 | pub fn into_std_options(self) -> ShellOptions> { 121 | let (options, exit_code_check) = if let Some(shell) = self.shell { 122 | let exit_code_check = ExitCodeCheck::detect(&shell); 123 | let mut command = Command::new(shell); 124 | command.args(self.shell_args); 125 | (ShellOptions::from(command), exit_code_check) 126 | } else { 127 | (ShellOptions::default(), ExitCodeCheck::for_default_shell()) 128 | }; 129 | 130 | let is_echoing = self.echoing || matches!(exit_code_check, Some(ExitCodeCheck::PowerShell)); 131 | let mut options = options.echoing(is_echoing); 132 | if let Ok(dir) = env::current_dir() { 133 | options = options.with_current_dir(dir); 134 | } 135 | if let Some(check) = exit_code_check { 136 | options = options.with_status_check("echo $?", move |code| check.check_exit_code(code)); 137 | } 138 | options 139 | .with_io_timeout(self.io_timeout.into()) 140 | .with_init_timeout(self.init_timeout.into()) 141 | } 142 | 143 | #[cfg(feature = "portable-pty")] 144 | fn into_pty_options(self, pty_size: Option) -> ShellOptions { 145 | let (mut command, exit_code_check) = if let Some(shell) = self.shell { 146 | let exit_code_check = ExitCodeCheck::detect(&shell); 147 | let mut command = PtyCommand::new(shell); 148 | for arg in self.shell_args { 149 | command.arg(arg); 150 | } 151 | (command, exit_code_check) 152 | } else { 153 | (PtyCommand::default(), ExitCodeCheck::for_default_shell()) 154 | }; 155 | 156 | if let Some(size) = pty_size { 157 | command.with_size(size.rows, size.cols); 158 | } 159 | 160 | let mut options = ShellOptions::new(command) 161 | .with_io_timeout(self.io_timeout.into()) 162 | .with_init_timeout(self.init_timeout.into()); 163 | if let Ok(dir) = env::current_dir() { 164 | options = options.with_current_dir(dir); 165 | } 166 | if let Some(check) = exit_code_check { 167 | options.with_status_check("echo $?", move |code| check.check_exit_code(code)) 168 | } else { 169 | options 170 | } 171 | } 172 | 173 | #[cfg(feature = "portable-pty")] 174 | pub fn create_transcript( 175 | self, 176 | inputs: impl IntoIterator, 177 | ) -> io::Result { 178 | if let Some(pty_size) = self.pty { 179 | let mut options = self.into_pty_options(pty_size); 180 | Transcript::from_inputs(&mut options, inputs) 181 | } else { 182 | let mut options = self.into_std_options(); 183 | Transcript::from_inputs(&mut options, inputs) 184 | } 185 | } 186 | 187 | #[cfg(not(feature = "portable-pty"))] 188 | pub fn create_transcript( 189 | self, 190 | inputs: impl IntoIterator, 191 | ) -> io::Result { 192 | let mut options = self.into_std_options(); 193 | Transcript::from_inputs(&mut options, inputs) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /examples/generate-snapshots.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Generates snapshots in this workspace. 4 | 5 | set -e 6 | 7 | # Extension for created snapshots. Especially important for the CLI test snapshot 8 | # (some details are manually added to it). 9 | EXTENSION=new.svg 10 | 11 | ROOT_DIR=$(dirname "$0") 12 | ROOT_DIR=$(realpath -L "$ROOT_DIR/..") 13 | TARGET_DIR="$ROOT_DIR/target/debug" 14 | 15 | FONT_DIR="$ROOT_DIR/examples/fonts" 16 | FONT_ROBOTO="$FONT_DIR/RobotoMono-VariableFont_wght.ttf" 17 | FONT_ROBOTO_ITALIC="$FONT_DIR/RobotoMono-Italic-VariableFont_wght.ttf" 18 | FONT_FIRA="$FONT_DIR/FiraMono-Regular.ttf" 19 | FONT_FIRA_BOLD="$FONT_DIR/FiraMono-Bold.ttf" 20 | 21 | # Common `term-transcript` CLI args 22 | TT_ARGS="-T 250ms" 23 | 24 | ( 25 | cd "$ROOT_DIR" 26 | cargo build -p term-transcript-rainbow 27 | cargo build -p term-transcript-cli --all-features 28 | ) 29 | 30 | if [[ ! -x "$TARGET_DIR/term-transcript" ]]; then 31 | echo "Executable term-transcript not found in expected location $TARGET_DIR" 32 | exit 1 33 | fi 34 | 35 | export PATH=$PATH:$TARGET_DIR 36 | 37 | echo "Creating rainbow snapshot..." 38 | term-transcript exec $TT_ARGS --palette gjm8 rainbow \ 39 | > "$ROOT_DIR/examples/rainbow.$EXTENSION" 40 | 41 | echo "Creating rainbow snapshot (pure SVG)..." 42 | term-transcript exec $TT_ARGS --pure-svg --palette gjm8 rainbow \ 43 | > "$ROOT_DIR/examples/rainbow-pure.$EXTENSION" 44 | 45 | echo "Creating animated rainbow snapshot..." 46 | term-transcript exec $TT_ARGS --palette powershell --scroll --pty --window \ 47 | --line-height=18px \ 48 | rainbow 'rainbow --long-lines' \ 49 | > "$ROOT_DIR/examples/animated.$EXTENSION" 50 | 51 | echo "Creating aliased rainbow snapshot..." 52 | rainbow | term-transcript capture 'colored-output' \ 53 | > "$ROOT_DIR/e2e-tests/rainbow/aliased.$EXTENSION" 54 | 55 | echo "Creating REPL snapshot..." 56 | term-transcript exec $TT_ARGS --shell rainbow-repl \ 57 | 'yellow intense bold green cucumber' \ 58 | 'neutral #fa4 underline #c0ffee' \ 59 | '#9f4010 (brown) italic' \ 60 | > "$ROOT_DIR/e2e-tests/rainbow/repl.$EXTENSION" 61 | 62 | echo "Creating wide rainbow snapshot..." 63 | term-transcript exec $TT_ARGS --palette gjm8 \ 64 | --hard-wrap=100 --width=900 'rainbow --long-lines' \ 65 | > "$ROOT_DIR/examples/rainbow-wide.$EXTENSION" 66 | 67 | echo "Creating small rainbow snapshot..." 68 | term-transcript exec $TT_ARGS --palette gjm8 \ 69 | --hard-wrap=50 --width=450 --scroll=180 rainbow \ 70 | > "$ROOT_DIR/examples/rainbow-small.$EXTENSION" 71 | 72 | echo "Creating snapshot with custom template..." 73 | term-transcript exec $TT_ARGS --palette xterm \ 74 | --tpl "$ROOT_DIR/examples/custom.html.handlebars" \ 75 | rainbow 'rainbow --short' \ 76 | > "$ROOT_DIR/examples/rainbow.new.html" 77 | 78 | echo "Creating snapshot with failure..." 79 | term-transcript exec $TT_ARGS --palette gjm8 --window \ 80 | './non-existing-command' '[ -x non-existing-file ]' '[ -x non-existing-file ] || echo "File is not there!"' \ 81 | > "$ROOT_DIR/examples/failure-sh.$EXTENSION" 82 | 83 | echo "Creating PTY snapshot with failure..." 84 | ( 85 | cd "$ROOT_DIR" 86 | term-transcript exec $TT_ARGS --palette gjm8 --pty --window --shell bash \ 87 | 'ls -l Cargo.lock' 'grep -n serge Cargo.lock' 'grep -m 5 -n serde Cargo.lock' \ 88 | > "$ROOT_DIR/examples/failure-bash-pty.$EXTENSION" 89 | ) 90 | 91 | echo "Creating snapshot with --line-numbers each-output" 92 | term-transcript exec $TT_ARGS --scroll --palette xterm --line-numbers each-output \ 93 | rainbow 'rainbow --short' \ 94 | > "$ROOT_DIR/examples/numbers-each-output.$EXTENSION" 95 | 96 | echo "Creating snapshot with no inputs, --line-numbers continuous" 97 | term-transcript exec $TT_ARGS --scroll --palette xterm \ 98 | --no-inputs --line-numbers continuous \ 99 | rainbow 'rainbow --short' \ 100 | > "$ROOT_DIR/examples/no-inputs-numbers.$EXTENSION" 101 | 102 | echo "Creating snapshot with no inputs, --line-numbers continuous (pure SVG)" 103 | term-transcript exec $TT_ARGS --scroll --palette xterm --pure-svg \ 104 | --no-inputs --line-numbers continuous \ 105 | rainbow 'rainbow --short' \ 106 | > "$ROOT_DIR/examples/no-inputs-numbers-pure.$EXTENSION" 107 | 108 | echo "Creating snapshot with --line-numbers continuous-outputs" 109 | term-transcript exec $TT_ARGS --scroll --palette powershell --line-numbers continuous-outputs \ 110 | --line-height=1.4 \ 111 | rainbow 'rainbow --short' \ 112 | > "$ROOT_DIR/examples/numbers-continuous-outputs.$EXTENSION" 113 | 114 | echo "Creating snapshot with --line-numbers continuous" 115 | term-transcript exec $TT_ARGS --scroll --palette gjm8 --line-numbers continuous \ 116 | rainbow 'rainbow --short' \ 117 | > "$ROOT_DIR/examples/numbers-continuous.$EXTENSION" 118 | 119 | echo "Creating snapshot with --line-numbers continuous (pure SVG)" 120 | term-transcript exec $TT_ARGS --pure-svg --scroll --palette gjm8 --line-numbers continuous \ 121 | rainbow 'rainbow --short' \ 122 | > "$ROOT_DIR/examples/numbers-continuous-pure.$EXTENSION" 123 | 124 | echo "Creating snapshot with --line-numbers and long lines" 125 | term-transcript exec $TT_ARGS --palette gjm8 --line-numbers continuous \ 126 | --line-height=18px \ 127 | 'rainbow --long-lines' \ 128 | > "$ROOT_DIR/examples/numbers-long.$EXTENSION" 129 | 130 | echo "Creating snapshot with --line-numbers and long lines (pure SVG)" 131 | term-transcript exec $TT_ARGS --pure-svg --palette gjm8 --line-numbers continuous \ 132 | --line-height=18px \ 133 | 'rainbow --long-lines' \ 134 | > "$ROOT_DIR/examples/numbers-long-pure.$EXTENSION" 135 | 136 | # Backup fonts are for the case if CSP prevents CSS / font loading from the CDN 137 | echo "Creating snapshot with Fira Mono font..." 138 | term-transcript exec $TT_ARGS --palette gjm8 --window \ 139 | --font 'Fira Mono, Consolas, Liberation Mono, Menlo' \ 140 | --styles '@import url(https://code.cdn.mozilla.net/fonts/fira.css);' rainbow \ 141 | > "$ROOT_DIR/examples/fira.$EXTENSION" 142 | term-transcript exec $TT_ARGS --pure-svg --palette gjm8 --window \ 143 | --font 'Fira Mono, Consolas, Liberation Mono, Menlo' \ 144 | --styles '@import url(https://code.cdn.mozilla.net/fonts/fira.css);' rainbow \ 145 | > "$ROOT_DIR/examples/fira-pure.$EXTENSION" 146 | 147 | echo "Creating snapshot with custom config..." 148 | term-transcript exec $TT_ARGS --config-path "$ROOT_DIR/examples/config.toml" \ 149 | 'rainbow --long-lines' \ 150 | > "$ROOT_DIR/examples/custom-config.$EXTENSION" 151 | 152 | echo "Creating snapshot with --embed-font (Roboto Mono, var weight)" 153 | term-transcript exec $TT_ARGS --palette gjm8 --line-numbers continuous \ 154 | --embed-font="$FONT_ROBOTO" \ 155 | 'rainbow --short' \ 156 | > "$ROOT_DIR/examples/embedded-font.$EXTENSION" 157 | 158 | echo "Creating snapshot with --embed-font (Roboto Mono, var weight + italic), --pure-svg" 159 | term-transcript exec $TT_ARGS --pure-svg --palette gjm8 --line-numbers continuous \ 160 | --line-height=1.4 \ 161 | --embed-font="$FONT_ROBOTO:$FONT_ROBOTO_ITALIC" \ 162 | 'rainbow --short' \ 163 | > "$ROOT_DIR/examples/embedded-font-pure.$EXTENSION" 164 | 165 | echo "Creating snapshot with --embed-font (Fira Mono, regular + bold), --pure-svg" 166 | term-transcript exec $TT_ARGS --pure-svg --palette gjm8 --line-numbers continuous \ 167 | --embed-font="$FONT_FIRA:$FONT_FIRA_BOLD" \ 168 | 'rainbow --short' \ 169 | > "$ROOT_DIR/examples/embedded-font-fira.$EXTENSION" 170 | -------------------------------------------------------------------------------- /lib/src/test/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{self, IsTerminal, Write}, 3 | str, 4 | }; 5 | 6 | use termcolor::{Ansi, ColorChoice, ColorSpec, NoColor, StandardStream, WriteColor}; 7 | 8 | #[cfg(test)] 9 | use self::tests::print_to_buffer; 10 | 11 | // Patch `print!` / `println!` macros for testing similarly to how they are patched in `std`. 12 | #[cfg(test)] 13 | macro_rules! print { 14 | ($($arg:tt)*) => (print_to_buffer(std::format_args!($($arg)*))); 15 | } 16 | #[cfg(test)] 17 | macro_rules! println { 18 | ($($arg:tt)*) => { 19 | print_to_buffer(std::format_args!($($arg)*)); 20 | print_to_buffer(std::format_args!("\n")); 21 | } 22 | } 23 | 24 | /// Writer that adds `padding` to each printed line. 25 | #[derive(Debug)] 26 | pub(super) struct IndentingWriter { 27 | inner: W, 28 | padding: &'static [u8], 29 | new_line: bool, 30 | } 31 | 32 | impl IndentingWriter { 33 | pub fn new(writer: W, padding: &'static [u8]) -> Self { 34 | Self { 35 | inner: writer, 36 | padding, 37 | new_line: true, 38 | } 39 | } 40 | } 41 | 42 | impl Write for IndentingWriter { 43 | fn write(&mut self, buf: &[u8]) -> io::Result { 44 | for (i, line) in buf.split(|&c| c == b'\n').enumerate() { 45 | if i > 0 { 46 | self.inner.write_all(b"\n")?; 47 | } 48 | if !line.is_empty() && (i > 0 || self.new_line) { 49 | self.inner.write_all(self.padding)?; 50 | } 51 | self.inner.write_all(line)?; 52 | } 53 | self.new_line = buf.ends_with(b"\n"); 54 | Ok(buf.len()) 55 | } 56 | 57 | fn flush(&mut self) -> io::Result<()> { 58 | self.inner.flush() 59 | } 60 | } 61 | 62 | /// `Write`r that uses `print!` / `println!` for output. 63 | /// 64 | /// # Why is this needed? 65 | /// 66 | /// This writer is used to output text within `TestConfig`. The primary use case of 67 | /// `TestConfig` is to be used within tests, and there the output is captured by default, 68 | /// which is implemented by effectively overriding the `std::print*` family of macros 69 | /// (see `std::io::_print()` for details). Using `termcolor::StandardStream` or another `Write`r 70 | /// connected to stdout will lead to `TestConfig` output not being captured, 71 | /// resulting in weird / incomprehensible test output. 72 | /// 73 | /// This issue is solved by using a writer that uses `std::print*` macros internally, 74 | /// instead of (implicitly) binding to `std::io::stdout()`. 75 | #[derive(Debug, Default)] 76 | pub(super) struct PrintlnWriter { 77 | line_buffer: Vec, 78 | } 79 | 80 | impl Write for PrintlnWriter { 81 | fn write(&mut self, buf: &[u8]) -> io::Result { 82 | for (i, line) in buf.split(|&c| c == b'\n').enumerate() { 83 | if i > 0 { 84 | // Output previously saved line and clear the line buffer. 85 | let str = str::from_utf8(&self.line_buffer) 86 | .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; 87 | println!("{str}"); 88 | self.line_buffer.clear(); 89 | } 90 | self.line_buffer.extend_from_slice(line); 91 | } 92 | Ok(buf.len()) 93 | } 94 | 95 | fn flush(&mut self) -> io::Result<()> { 96 | let str = str::from_utf8(&self.line_buffer) 97 | .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; 98 | print!("{str}"); 99 | self.line_buffer.clear(); 100 | Ok(()) 101 | } 102 | } 103 | 104 | /// `PrintlnWriter` extension with ANSI color support. 105 | pub(super) enum ColorPrintlnWriter { 106 | NoColor(NoColor), 107 | Ansi(Ansi), 108 | } 109 | 110 | impl ColorPrintlnWriter { 111 | pub fn new(color_choice: ColorChoice) -> Self { 112 | let is_ansi = match color_choice { 113 | ColorChoice::Never => false, 114 | ColorChoice::Always | ColorChoice::AlwaysAnsi => true, 115 | ColorChoice::Auto => { 116 | if io::stdout().is_terminal() { 117 | StandardStream::stdout(color_choice).supports_color() 118 | } else { 119 | false 120 | } 121 | } 122 | }; 123 | 124 | let inner = PrintlnWriter::default(); 125 | if is_ansi { 126 | Self::Ansi(Ansi::new(inner)) 127 | } else { 128 | Self::NoColor(NoColor::new(inner)) 129 | } 130 | } 131 | } 132 | 133 | impl Write for ColorPrintlnWriter { 134 | #[inline] 135 | fn write(&mut self, buf: &[u8]) -> io::Result { 136 | match self { 137 | Self::Ansi(ansi) => ansi.write(buf), 138 | Self::NoColor(no_color) => no_color.write(buf), 139 | } 140 | } 141 | 142 | #[inline] 143 | fn flush(&mut self) -> io::Result<()> { 144 | match self { 145 | Self::Ansi(ansi) => ansi.flush(), 146 | Self::NoColor(no_color) => no_color.flush(), 147 | } 148 | } 149 | } 150 | 151 | impl WriteColor for ColorPrintlnWriter { 152 | #[inline] 153 | fn supports_color(&self) -> bool { 154 | match self { 155 | Self::Ansi(ansi) => ansi.supports_color(), 156 | Self::NoColor(no_color) => no_color.supports_color(), 157 | } 158 | } 159 | 160 | #[inline] 161 | fn set_color(&mut self, spec: &ColorSpec) -> io::Result<()> { 162 | match self { 163 | Self::Ansi(ansi) => ansi.set_color(spec), 164 | Self::NoColor(no_color) => no_color.set_color(spec), 165 | } 166 | } 167 | 168 | #[inline] 169 | fn reset(&mut self) -> io::Result<()> { 170 | match self { 171 | Self::Ansi(ansi) => ansi.reset(), 172 | Self::NoColor(no_color) => no_color.reset(), 173 | } 174 | } 175 | } 176 | 177 | #[cfg(test)] 178 | mod tests { 179 | use std::{cell::RefCell, fmt, mem}; 180 | 181 | use super::*; 182 | 183 | thread_local! { 184 | static OUTPUT_CAPTURE: RefCell> = RefCell::default(); 185 | } 186 | 187 | pub fn print_to_buffer(args: fmt::Arguments<'_>) { 188 | OUTPUT_CAPTURE.with(|capture| { 189 | let mut lock = capture.borrow_mut(); 190 | lock.write_fmt(args).ok(); 191 | }); 192 | } 193 | 194 | #[test] 195 | fn indenting_writer_basics() -> io::Result<()> { 196 | let mut buffer = vec![]; 197 | let mut writer = IndentingWriter::new(&mut buffer, b" "); 198 | write!(writer, "Hello, ")?; 199 | writeln!(writer, "world!")?; 200 | writeln!(writer, "many\n lines!")?; 201 | 202 | assert_eq!(buffer, b" Hello, world!\n many\n lines!\n" as &[u8]); 203 | Ok(()) 204 | } 205 | 206 | #[test] 207 | fn println_writer_basics() -> io::Result<()> { 208 | let mut writer = PrintlnWriter::default(); 209 | write!(writer, "Hello, ")?; 210 | writeln!(writer, "world!")?; 211 | writeln!(writer, "many\n lines!")?; 212 | 213 | let captured = OUTPUT_CAPTURE.with(|capture| { 214 | let mut lock = capture.borrow_mut(); 215 | mem::take(&mut *lock) 216 | }); 217 | 218 | assert_eq!(captured, b"Hello, world!\nmany\n lines!\n"); 219 | Ok(()) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /lib/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | The project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 5 | 6 | ## [Unreleased] 7 | 8 | ### Added 9 | 10 | - Add location info for transcript parsing errors and make error types public. 11 | - Support parsing SVG transcripts generated with the pure SVG template. In particular, these transcripts 12 | are now supported in snapshot testing. The pure SVG template is slightly updated for parsing; 13 | thus, parsing won't work with transcripts produced with old `term-transcript` versions. 14 | - Support embedding fonts into the SVG template via `@font-face` CSS rules with a data URL. 15 | Provide font subsetting as an extension via the opt-in `font-subset` feature. 16 | - Allow configuring line height for both HTML-in-SVG and pure SVG templates. 17 | 18 | ### Changed 19 | 20 | - Update `quick-xml` dependency. 21 | - Bump minimum supported Rust version to 1.83. 22 | - Align the view box bottom during the last scroll animation frame, so that there's no overscroll. 23 | - Consistently trim the ending newline for captured outputs. 24 | - Change the hard break char to `»` so that it is covered by more fonts. Do not style the hard break as the surrounding text. 25 | - Change the output data provided to templates. Instead of pre-rendered HTML and SVG data, a template is now provided 26 | with an array of lines, each consisting of styled text spans. 27 | - Decrease the default line height to 1.2 (i.e., 16.8px); previously, it was 18px (i.e., ~1.29). 28 | - Change the way background works for HTML-in-SVG so that it always has the full line height. 29 | 30 | ### Removed 31 | 32 | - Remove the `Parsed::html()` getter as difficult to maintain given pure SVG parsing. 33 | 34 | ## 0.4.0 - 2025-06-01 35 | 36 | *(All changes are relative compared to the [0.4.0-beta.1 release](#040-beta1---2024-03-03))* 37 | 38 | ### Added 39 | 40 | - Allow transforming captured `Transcript`s. This is mostly useful for testing to filter out / replace 41 | variable / env-dependent output parts. Correspondingly, `TestConfig` allows customizing a transform 42 | using `with_transform()` method. 43 | 44 | ### Changed 45 | 46 | - Update `quick-xml` and `handlebars` dependencies. 47 | - Bump minimum supported Rust version to 1.74. 48 | 49 | ### Fixed 50 | 51 | - Fix rendering errors with standard templates with newer versions of `handlebars`. 52 | 53 | ## 0.4.0-beta.1 - 2024-03-03 54 | 55 | ### Changed 56 | 57 | - Allow configuring pixels per scroll using new `ScrollOptions.pixels_per_scroll` field. 58 | - Change some default values and set more default values during `TemplateOptions` deserialization. 59 | - Bump minimum supported Rust version to 1.70. 60 | - Update `handlebars` and `quick-xml` dependencies. 61 | 62 | ## 0.3.0 - 2023-06-03 63 | 64 | *(No substantial changes compared to the [0.3.0-beta.2 release](#030-beta2---2023-04-29))* 65 | 66 | ## 0.3.0-beta.2 - 2023-04-29 67 | 68 | *(All changes are relative compared to [the 0.3.0-beta.1 release](#030-beta1---2023-01-19))* 69 | 70 | ### Added 71 | 72 | - Add a pure SVG rendering option to `svg::Template`. Since rendered SVGs do not contain 73 | embedded HTML, they are supported by more SVG viewers / editors (e.g., Inkscape). 74 | On the downside, the rendered SVG may have mispositioned background text coloring 75 | in certain corner cases. 76 | - Allow specifying additional CSS instructions in `svg::TemplateOptions`. 77 | As an example, this can be used to import fonts using `@import` or `@font-face`. 78 | - Add a fallback error message to the default template if HTML-in-SVG embedding 79 | is not supported. 80 | - Add [FAQ](../FAQ.md) with some tips and troubleshooting advice. 81 | - Allow hiding `UserInput`s during transcript rendering by calling the `hide()` method. 82 | Hidden inputs are supported by the default and pure SVG templates. 83 | 84 | ### Changed 85 | 86 | - Update `portable-pty` and `quick-xml` dependencies. 87 | - Bump minimum supported Rust version to 1.66. 88 | 89 | ## 0.3.0-beta.1 - 2023-01-19 90 | 91 | ### Added 92 | 93 | - Support custom rendering templates via `Template::custom()`. 94 | This allows customizing rendering logic, including changing the output format 95 | entirely (e.g., to HTML). 96 | - Allow capturing exit statuses of commands executed in the shell. 97 | - Trace major operations using the [`tracing`](https://docs.rs/tracing/) facade. 98 | - Support line numbering for the default SVG template. 99 | 100 | ### Changed 101 | 102 | - Update `quick-xml` dependency. 103 | - Bump minimum supported Rust version to 1.61. 104 | - Replace a line mapper in `ShellOptions` to a more general line decoder that can handle 105 | non-UTF-8 encodings besides input filtering. 106 | - Improve configuring echoing in `ShellOptions`. 107 | - Use the initialization timeout from `ShellOptions` for each command, not only for 108 | the first command. This allows reducing the I/O timeout and thus performing operations faster. 109 | 110 | ## 0.2.0 - 2022-06-12 111 | 112 | *(All changes are relative compared to [the 0.2.0-beta.1 release](#020-beta1---2022-01-06))* 113 | 114 | ### Changed 115 | 116 | - Update `quick-xml` dependency. 117 | - Bump minimum supported Rust version to 1.57 and switch to 2021 Rust edition. 118 | 119 | ### Fixed 120 | 121 | - Properly handle non-ASCII input when parsing `RgbColor`. 122 | 123 | ### Removed 124 | 125 | - Remove `From<&&str>` implementation for `UserInput`. This implementation was previously used 126 | to make `Transcript::from_inputs()` and `TestConfig::test()` accept user inputs as `&[&str]`. 127 | In Rust 2021 edition, it is possible to use arrays (`[&str; _]`) instead. 128 | 129 | ## 0.2.0-beta.1 - 2022-01-06 130 | 131 | ### Added 132 | 133 | - Support interacting with shell using pseudo-terminal (PTY) via `portable-pty` 134 | crate. 135 | - Add `ShellOptions::with_env()` to set environment variables for the shell. 136 | - Make style / color comparisons more detailed and human-readable. 137 | - Allow specifying initialization timeout for `ShellOptions`. This timeout 138 | is added to the I/O timeout to wait for output for the first command. 139 | - Add `TestConfig::test()` to perform more high-level / fluent snapshot testing. 140 | - Allow adding generic paths to the `PATH` env var for the spawned shell 141 | via `ShellOptions::with_additional_path()`. 142 | 143 | ### Changed 144 | 145 | - Update `handlebars` and `pretty_assertions` dependencies. 146 | - Generalize `TermError::NonCsiSequence` variant to `UnrecognizedSequence`. 147 | - Make `TestConfig` modifiers take `self` by value for the sake of fluency. 148 | 149 | ### Fixed 150 | 151 | - Fix flaky PowerShell initialization that could lead to the init command 152 | being included into the captured output. 153 | - Fix parsing of `90..=97` and `100..=107` SGR params (i.e., intense foreground 154 | and background colors). 155 | - Enable parsing OSC escape sequences; they are now ignored instead of leading 156 | to a `TermError`. 157 | - Process carriage return `\r` in terminal output. (As a stopgap measure, the text 158 | before `\r` is not rendered.) 159 | - Fix rendering intense colors into HTML. Previously, intense color marker 160 | was dropped in certain cases. 161 | - Fix waiting for echoed initialization commands. 162 | - Add `height` attribute to top-level SVG to fix its rendering. 163 | - Remove an obsolete lifetime parameter from `svg::Template` and change `Template::render` 164 | to receive `self` by shared reference. 165 | - Fix `TestConfig` output not being captured during tests. 166 | 167 | ## 0.1.0 - 2021-06-01 168 | 169 | The initial release of `term-transcript`. 170 | -------------------------------------------------------------------------------- /lib/src/term/tests.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use termcolor::{Ansi, Color, ColorSpec, WriteColor}; 4 | 5 | use super::*; 6 | 7 | fn prepare_term_output() -> anyhow::Result { 8 | let mut writer = Ansi::new(vec![]); 9 | writer.set_color( 10 | ColorSpec::new() 11 | .set_fg(Some(Color::Cyan)) 12 | .set_underline(true), 13 | )?; 14 | write!(writer, "Hello")?; 15 | writer.reset()?; 16 | write!(writer, ", ")?; 17 | writer.set_color( 18 | ColorSpec::new() 19 | .set_fg(Some(Color::White)) 20 | .set_bg(Some(Color::Green)) 21 | .set_intense(true), 22 | )?; 23 | write!(writer, "world")?; 24 | writer.reset()?; 25 | write!(writer, "!")?; 26 | 27 | String::from_utf8(writer.into_inner()).map_err(From::from) 28 | } 29 | 30 | #[test] 31 | fn converting_captured_output_to_text() -> anyhow::Result<()> { 32 | let output = Captured(prepare_term_output()?); 33 | assert_eq!(output.to_plaintext()?, "Hello, world!"); 34 | Ok(()) 35 | } 36 | 37 | fn assert_eq_term_output(actual: &[u8], expected: &[u8]) { 38 | assert_eq!( 39 | String::from_utf8_lossy(actual), 40 | String::from_utf8_lossy(expected) 41 | ); 42 | } 43 | 44 | #[test] 45 | fn term_roundtrip_simple() -> anyhow::Result<()> { 46 | let mut writer = Ansi::new(vec![]); 47 | write!(writer, "Hello, ")?; 48 | writer.set_color(ColorSpec::new().set_bold(true).set_fg(Some(Color::Green)))?; 49 | write!(writer, "world")?; 50 | writer.reset()?; 51 | write!(writer, "!")?; 52 | 53 | let term_output = writer.into_inner(); 54 | 55 | let mut new_writer = Ansi::new(vec![]); 56 | TermOutputParser::new(&mut new_writer).parse(&term_output)?; 57 | let new_term_output = new_writer.into_inner(); 58 | assert_eq_term_output(&new_term_output, &term_output); 59 | Ok(()) 60 | } 61 | 62 | #[test] 63 | fn term_roundtrip_with_multiple_colors() -> anyhow::Result<()> { 64 | let mut writer = Ansi::new(vec![]); 65 | write!(writer, "He")?; 66 | writer.set_color( 67 | ColorSpec::new() 68 | .set_bg(Some(Color::White)) 69 | .set_fg(Some(Color::Black)), 70 | )?; 71 | write!(writer, "ll")?; 72 | writer.set_color( 73 | ColorSpec::new() 74 | .set_intense(true) 75 | .set_fg(Some(Color::Magenta)), 76 | )?; 77 | write!(writer, "o")?; 78 | writer.set_color( 79 | ColorSpec::new() 80 | .set_italic(true) 81 | .set_fg(Some(Color::Green)) 82 | .set_bg(Some(Color::Yellow)), 83 | )?; 84 | write!(writer, "world")?; 85 | writer.set_color( 86 | ColorSpec::new() 87 | .set_underline(true) 88 | .set_dimmed(true) 89 | .set_bg(Some(Color::Cyan)), 90 | )?; 91 | write!(writer, "!")?; 92 | 93 | let term_output = writer.into_inner(); 94 | 95 | let mut new_writer = Ansi::new(vec![]); 96 | TermOutputParser::new(&mut new_writer).parse(&term_output)?; 97 | let new_term_output = new_writer.into_inner(); 98 | assert_eq_term_output(&new_term_output, &term_output); 99 | Ok(()) 100 | } 101 | 102 | #[test] 103 | fn roundtrip_with_indexed_colors() -> anyhow::Result<()> { 104 | let mut writer = Ansi::new(vec![]); 105 | write!(writer, "H")?; 106 | writer.set_color(ColorSpec::new().set_fg(Some(Color::Ansi256(5))))?; 107 | write!(writer, "e")?; 108 | writer.set_color(ColorSpec::new().set_bg(Some(Color::Ansi256(11))))?; 109 | write!(writer, "l")?; 110 | writer.set_color(ColorSpec::new().set_fg(Some(Color::Ansi256(33))))?; 111 | write!(writer, "l")?; 112 | writer.set_color(ColorSpec::new().set_bg(Some(Color::Ansi256(250))))?; 113 | write!(writer, "o")?; 114 | 115 | let term_output = writer.into_inner(); 116 | 117 | let mut new_writer = Ansi::new(vec![]); 118 | TermOutputParser::new(&mut new_writer).parse(&term_output)?; 119 | let new_term_output = new_writer.into_inner(); 120 | assert_eq_term_output(&new_term_output, &term_output); 121 | Ok(()) 122 | } 123 | 124 | #[test] 125 | fn roundtrip_with_rgb_colors() -> anyhow::Result<()> { 126 | let mut writer = Ansi::new(vec![]); 127 | write!(writer, "H")?; 128 | writer.set_color(ColorSpec::new().set_fg(Some(Color::Rgb(16, 22, 35))))?; 129 | write!(writer, "e")?; 130 | writer.set_color(ColorSpec::new().set_bg(Some(Color::Rgb(255, 254, 253))))?; 131 | write!(writer, "l")?; 132 | writer.set_color(ColorSpec::new().set_fg(Some(Color::Rgb(0, 0, 0))))?; 133 | write!(writer, "l")?; 134 | writer.set_color(ColorSpec::new().set_bg(Some(Color::Rgb(0, 160, 128))))?; 135 | write!(writer, "o")?; 136 | 137 | let term_output = writer.into_inner(); 138 | 139 | let mut new_writer = Ansi::new(vec![]); 140 | TermOutputParser::new(&mut new_writer).parse(&term_output)?; 141 | let new_term_output = new_writer.into_inner(); 142 | assert_eq_term_output(&new_term_output, &term_output); 143 | Ok(()) 144 | } 145 | 146 | #[test] 147 | fn skipping_ocs_sequence_with_bell_terminator() -> anyhow::Result<()> { 148 | let term_output = "\u{1b}]0;C:\\WINDOWS\\system32\\cmd.EXE\u{7}echo foo"; 149 | 150 | let mut writer = Ansi::new(vec![]); 151 | TermOutputParser::new(&mut writer).parse(term_output.as_bytes())?; 152 | let rendered_output = writer.into_inner(); 153 | 154 | assert_eq!(String::from_utf8(rendered_output)?, "echo foo"); 155 | Ok(()) 156 | } 157 | 158 | #[test] 159 | fn skipping_ocs_sequence_with_st_terminator() -> anyhow::Result<()> { 160 | let term_output = "\u{1b}]0;C:\\WINDOWS\\system32\\cmd.EXE\u{1b}\\echo foo"; 161 | 162 | let mut writer = Ansi::new(vec![]); 163 | TermOutputParser::new(&mut writer).parse(term_output.as_bytes())?; 164 | let rendered_output = writer.into_inner(); 165 | 166 | assert_eq!(String::from_utf8(rendered_output)?, "echo foo"); 167 | Ok(()) 168 | } 169 | 170 | #[test] 171 | fn skipping_non_color_csi_sequence() -> anyhow::Result<()> { 172 | let term_output = "\u{1b}[49Xecho foo"; 173 | 174 | let mut writer = Ansi::new(vec![]); 175 | TermOutputParser::new(&mut writer).parse(term_output.as_bytes())?; 176 | let rendered_output = writer.into_inner(); 177 | 178 | assert_eq!(String::from_utf8(rendered_output)?, "echo foo"); 179 | Ok(()) 180 | } 181 | 182 | #[test] 183 | fn implicit_reset_sequence() -> anyhow::Result<()> { 184 | let term_output = "\u{1b}[34mblue\u{1b}[m"; 185 | 186 | let mut writer = Ansi::new(vec![]); 187 | TermOutputParser::new(&mut writer).parse(term_output.as_bytes())?; 188 | let rendered_output = writer.into_inner(); 189 | 190 | assert_eq!( 191 | String::from_utf8(rendered_output)?, 192 | "\u{1b}[0m\u{1b}[34mblue\u{1b}[0m" 193 | ); 194 | Ok(()) 195 | } 196 | 197 | #[test] 198 | fn intense_color() -> anyhow::Result<()> { 199 | let term_output = "\u{1b}[94mblue\u{1b}[m"; 200 | 201 | let mut writer = Ansi::new(vec![]); 202 | TermOutputParser::new(&mut writer).parse(term_output.as_bytes())?; 203 | let rendered_output = writer.into_inner(); 204 | 205 | assert_eq!( 206 | String::from_utf8(rendered_output)?, 207 | "\u{1b}[0m\u{1b}[38;5;12mblue\u{1b}[0m" 208 | ); 209 | Ok(()) 210 | } 211 | 212 | #[test] 213 | fn carriage_return_at_middle_of_line() -> anyhow::Result<()> { 214 | let term_output = "\u{1b}[32mgreen\u{1b}[m\r\u{1b}[34mblue\u{1b}[m"; 215 | 216 | let mut writer = Ansi::new(vec![]); 217 | TermOutputParser::new(&mut writer).parse(term_output.as_bytes())?; 218 | let rendered_output = writer.into_inner(); 219 | 220 | assert_eq!( 221 | String::from_utf8(rendered_output)?, 222 | "\u{1b}[0m\u{1b}[34mblue\u{1b}[0m" 223 | ); 224 | Ok(()) 225 | } 226 | -------------------------------------------------------------------------------- /lib/src/svg/common.handlebars: -------------------------------------------------------------------------------- 1 | {{! 2 | Computes content height based on line count in interactions. 3 | Expected hash inputs: `interactions`, `const`, `line_height`. 4 | }} 5 | {{~#*inline "compute_content_height"}} 6 | {{#scope lines=0 margins=0 displayed_interactions=0}} 7 | {{#each interactions}} 8 | {{#if (not input.hidden)}} 9 | {{lines set=(add (lines) (count_lines input.text))}} 10 | {{margins set=(add (margins) 1)}} 11 | {{displayed_interactions set=(add (displayed_interactions) 1)}} 12 | {{/if}} 13 | {{lines set=(add (lines) (len output))}} 14 | {{#if (ne 0 (len output))}} 15 | {{margins set=(add (margins) 1)}} 16 | {{/if}} 17 | {{/each}} 18 | {{#if (gt (margins) 0)}} 19 | {{! The last margin is not displayed. }} 20 | {{margins set=(sub (margins) 1)}} 21 | {{/if}} 22 | {{add (mul (lines) line_height) 23 | (mul (margins) const.BLOCK_MARGIN) 24 | (mul (displayed_interactions) (mul 2 const.USER_INPUT_PADDING)) }} 25 | {{/scope}} 26 | {{/inline~}} 27 | 28 | {{! 29 | Computes scroll animation parameters. 30 | Expected hash inputs: `content_height`, `const`, `scroll`, `width` 31 | }} 32 | {{~#*inline "compute_scroll_animation"}} 33 | {{#if (gte scroll.max_height content_height)}} 34 | {{! No need for scroll animation }} 35 | null 36 | {{else}} 37 | {{#scope 38 | steps=(div (sub content_height scroll.max_height) scroll.pixels_per_scroll round="up") 39 | y_step=0 40 | view_box="" 41 | scrollbar_y="" 42 | sep="" 43 | }} 44 | {{y_step set=(div (sub scroll.max_height const.SCROLLBAR_HEIGHT) (steps))}} 45 | {{#each (range 0 (add (steps) 1))}} 46 | {{#sep}}{{#if @first}}""{{else}}";"{{/if}}{{/sep}} 47 | {{#view_box}}"{{view_box}}{{sep}}0 {{min (mul ../scroll.pixels_per_scroll @index) (sub ../content_height ../scroll.max_height)}} {{../width}} {{../scroll.max_height}}"{{/view_box}} 48 | {{#scrollbar_y}}"{{scrollbar_y}}{{sep}}0 {{mul (y_step) @index round="nearest"}}"{{/scrollbar_y}} 49 | {{/each}} 50 | 51 | { 52 | "duration": {{mul scroll.interval (steps)}}, 53 | "view_box": "{{view_box}}", 54 | "scrollbar_x": {{sub width const.SCROLLBAR_RIGHT_OFFSET}}, 55 | "scrollbar_y": "{{scrollbar_y}}" 56 | } 57 | {{/scope}} 58 | {{/if}} 59 | {{/inline~}} 60 | 61 | {{! Common styles header, e.g. `@font-face`s }} 62 | {{~#*inline "common_styles_header"}} 63 | {{~#if additional_styles}} 64 | 65 | {{{additional_styles}}} 66 | {{~/if~}} 67 | {{~#with embedded_font~}} 68 | {{~#each faces}} 69 | @font-face { 70 | font-family: "{{../family_name}}"; 71 | src: url("data:{{mime_type}};base64,{{{base64_data}}}"); 72 | {{#if (ne is_bold null)}} 73 | font-weight: {{#if is_bold}}bold{{else}}normal{{/if}}; 74 | {{/if}} 75 | {{#if (ne is_italic null)}} 76 | font-style: {{#if is_italic}}italic{{else}}normal{{/if}}; 77 | {{/if}} 78 | } 79 | {{/each~}} 80 | {{~else~}} 81 | {{~/with~}} 82 | {{/inline~}} 83 | 84 | {{! Common font styling }} 85 | {{~#*inline "common_font_styles"}} 86 | .bold,.prompt { font-weight: bold;{{#with (ptr embedded_font "/metrics/bold_spacing") as |sp|}} letter-spacing: {{round sp digits=4}}em;{{else}}{{/with}} } 87 | .italic { font-style: italic;{{#with (ptr embedded_font "/metrics/italic_spacing") as |sp|}} letter-spacing: {{round sp digits=4}}em;{{else}}{{/with}} } 88 | .underline { text-decoration: underline; } 89 | {{/inline~}} 90 | 91 | {{! Terminal background }} 92 | {{~#*inline "background"}} 93 | 94 | {{~#if window_frame}} 95 | 96 | 97 | 98 | 99 | 100 | {{~/if}} 101 | 102 | {{/inline~}} 103 | 104 | {{! Scrollbar + its animation }} 105 | {{~#*inline "scrollbar"}} 106 | {{#with (scroll_animation)}} 107 | 108 | 109 | 110 | {{/with}} 111 | {{/inline~}} 112 | 113 | {{! Renders an HTML span }} 114 | {{~#*inline "html_span"}} 115 | {{~#if (gt (len span) 1)~}}{{! Are there any style properties in `span`? }} 116 | {{~#scope classes="" styles=""}} 117 | {{~#classes append=true}} 118 | {{~#if span.bold}} bold{{/if~}} 119 | {{~#if span.italic}} italic{{/if~}} 120 | {{~#if span.underline}} underline{{/if~}} 121 | {{~#if span.dimmed}} dimmed{{/if~}} 122 | {{~#if (eq (typeof span.fg) "number")}} fg{{span.fg}}{{/if~}} 123 | {{~#if (eq (typeof span.bg) "number")}} bg{{span.bg}}{{/if~}} 124 | {{/classes~}} 125 | {{~classes set=(trim (classes))~}} 126 | {{~#styles append=true}} 127 | {{~#if (eq (typeof span.fg) "string")}} color: {{span.fg}};{{/if~}} 128 | {{~#if (eq (typeof span.bg) "string")}} background: {{span.bg}};{{/if~}} 129 | {{/styles~}} 130 | {{~styles set=(trim (styles))~}} 131 | 132 | {{~/scope~}} 133 | {{~/if~}} 134 | {{{span.text}}} 135 | {{~#if (gt (len span) 1)~}} 136 |
137 | {{~/if~}} 138 | {{~/inline~}} 139 | 140 | {{! Renders an SVG foreground }} 141 | {{~#*inline "svg_tspan"}} 142 | {{~#if (gt (len span) 1)~}}{{! Are there any style properties in `span`? }} 143 | {{~#scope classes="" styles=""}} 144 | {{~#classes append=true}} 145 | {{~#if span.bold}} bold{{/if~}} 146 | {{~#if span.italic}} italic{{/if~}} 147 | {{~#if span.underline}} underline{{/if~}} 148 | {{~#if span.dimmed}} dimmed{{/if~}} 149 | {{~#if (eq (typeof span.fg) "number")}} fg{{span.fg}}{{/if~}} 150 | {{! Unlike with HTML s, we add the bg class in any case to assist with parsing }} 151 | {{~#if (ne (typeof span.bg) "null")}} bg{{span.bg}}{{/if~}} 152 | {{/classes~}} 153 | {{~classes set=(trim (classes))~}} 154 | {{~#styles append=true}} 155 | {{~#if (eq (typeof span.fg) "string")}}fill: {{span.fg}};{{/if~}} 156 | {{/styles~}} 157 | {{{span.text}}} 158 | {{~/scope~}} 159 | {{~else~}} 160 | {{{span.text}}} 161 | {{~/if~}} 162 | {{~/inline~}} 163 | 164 | {{! 165 | Scales font metrics from design units to ems to pixels. 166 | Expected hash inputs: `metrics`, `font_size` 167 | }} 168 | {{~#*inline "scale_font_metrics"}} 169 | { 170 | "advance_width": {{mul font_size (div metrics.advance_width metrics.units_per_em)}}, 171 | "ascent": {{mul font_size (div metrics.ascent metrics.units_per_em)}}, 172 | "descent": {{mul font_size (div metrics.descent metrics.units_per_em)}} 173 | } 174 | {{/inline~}} 175 | 176 | {{! Sets `line_height` based on font metrics (if supplied) or the user-provided value }} 177 | {{~#*inline "set_line_height"}} 178 | {{~#if (eq (line_height) null) ~}} 179 | {{#if embedded_font}} 180 | {{~#with (eval "scale_font_metrics" metrics=embedded_font.metrics font_size=const.FONT_SIZE) as |metrics|~}} 181 | {{~line_height set=(round (sub metrics.ascent metrics.descent) digits=1)~}} 182 | {{~/with~}} 183 | {{~else~}} 184 | {{~line_height set=16.8 ~}}{{! Set to a somewhat reasonable default (1.2 * font_size) }} 185 | {{~/if~}} 186 | {{~else~}} 187 | {{! Scale line height according to the font size }} 188 | {{~line_height set=(round (mul (line_height) const.FONT_SIZE) digits=1)~}} 189 | {{~/if~}} 190 | {{/inline~}} 191 | -------------------------------------------------------------------------------- /lib/src/traits.rs: -------------------------------------------------------------------------------- 1 | //! Traits for interaction with the terminal. 2 | 3 | use std::{ 4 | ffi::OsStr, 5 | io, 6 | path::Path, 7 | process::{Child, ChildStdin, Command, Stdio}, 8 | }; 9 | 10 | use crate::utils::is_recoverable_kill_error; 11 | 12 | /// Common denominator for types that can be used to configure commands for 13 | /// execution in the terminal. 14 | pub trait ConfigureCommand { 15 | /// Sets the current directory. 16 | fn current_dir(&mut self, dir: &Path); 17 | /// Sets an environment variable. 18 | fn env(&mut self, name: &str, value: &OsStr); 19 | } 20 | 21 | impl ConfigureCommand for Command { 22 | fn current_dir(&mut self, dir: &Path) { 23 | self.current_dir(dir); 24 | } 25 | 26 | fn env(&mut self, name: &str, value: &OsStr) { 27 | self.env(name, value); 28 | } 29 | } 30 | 31 | /// Encapsulates spawning and sending inputs / receiving outputs from the shell. 32 | /// 33 | /// The crate provides two principal implementations of this trait: 34 | /// 35 | /// - [`Command`] and [`StdShell`](crate::StdShell) communicate with the spawned process 36 | /// via OS pipes. Because stdin of the child process is not connected to a terminal / TTY, 37 | /// this can lead to the differences in output compared to launching the process in a terminal 38 | /// (no coloring, different formatting, etc.). On the other hand, this is the most widely 39 | /// supported option. 40 | /// - [`PtyCommand`](crate::PtyCommand) (available with the `portable-pty` crate feature) 41 | /// communicates with the child process via a pseudo-terminal (PTY). This makes the output 42 | /// closer to what it would like in the terminal, at the cost of lesser platform coverage 43 | /// (Unix + newer Windows distributions). 44 | /// 45 | /// External implementations are possible as well! E.g., for REPL applications written in Rust 46 | /// or packaged as a [WASI] module, it could be possible to write an implementation that "spawns" 47 | /// the application in the same process. 48 | /// 49 | /// [WASI]: https://wasi.dev/ 50 | pub trait SpawnShell: ConfigureCommand { 51 | /// Spawned shell process. 52 | type ShellProcess: ShellProcess; 53 | /// Reader of the shell output. 54 | type Reader: io::Read + 'static + Send; 55 | /// Writer to the shell input. 56 | type Writer: io::Write + 'static + Send; 57 | 58 | /// Spawns a shell process. 59 | /// 60 | /// # Errors 61 | /// 62 | /// Returns an error if the shell process cannot be spawned for whatever reason. 63 | fn spawn_shell(&mut self) -> io::Result>; 64 | } 65 | 66 | /// Representation of a shell process. 67 | pub trait ShellProcess { 68 | /// Checks if the process is alive. 69 | /// 70 | /// # Errors 71 | /// 72 | /// Returns an error if the process is not alive. Should include debug details if possible 73 | /// (e.g., the exit status of the process). 74 | fn check_is_alive(&mut self) -> io::Result<()>; 75 | 76 | /// Terminates the shell process. This can include killing it if necessary. 77 | /// 78 | /// # Errors 79 | /// 80 | /// Returns an error if the process cannot be killed. 81 | fn terminate(self) -> io::Result<()>; 82 | 83 | /// Returns `true` if the input commands are echoed back to the output. 84 | /// 85 | /// The default implementation returns `false`. 86 | fn is_echoing(&self) -> bool { 87 | false 88 | } 89 | } 90 | 91 | /// Wrapper for spawned shell and related I/O returned by [`SpawnShell::spawn_shell()`]. 92 | #[derive(Debug)] 93 | pub struct SpawnedShell { 94 | /// Shell process. 95 | pub shell: S::ShellProcess, 96 | /// Reader of shell output. 97 | pub reader: S::Reader, 98 | /// Writer to shell input. 99 | pub writer: S::Writer, 100 | } 101 | 102 | /// Uses pipes to communicate with the spawned process. This has a potential downside that 103 | /// the process output will differ from the case if the process was launched in the shell. 104 | /// See [`PtyCommand`] for an alternative that connects the spawned process to a pseudo-terminal 105 | /// (PTY). 106 | /// 107 | /// [`PtyCommand`]: crate::PtyCommand 108 | impl SpawnShell for Command { 109 | type ShellProcess = Child; 110 | type Reader = os_pipe::PipeReader; 111 | type Writer = ChildStdin; 112 | 113 | #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))] 114 | fn spawn_shell(&mut self) -> io::Result> { 115 | let (pipe_reader, pipe_writer) = os_pipe::pipe()?; 116 | #[cfg(feature = "tracing")] 117 | tracing::debug!("created OS pipe"); 118 | 119 | let mut shell = self 120 | .stdin(Stdio::piped()) 121 | .stdout(pipe_writer.try_clone()?) 122 | .stderr(pipe_writer) 123 | .spawn()?; 124 | #[cfg(feature = "tracing")] 125 | tracing::debug!("created child"); 126 | 127 | self.stdout(Stdio::null()).stderr(Stdio::null()); 128 | 129 | let stdin = shell.stdin.take().unwrap(); 130 | // ^-- `unwrap()` is safe due to configuration of the shell process. 131 | 132 | Ok(SpawnedShell { 133 | shell, 134 | reader: pipe_reader, 135 | writer: stdin, 136 | }) 137 | } 138 | } 139 | 140 | impl ShellProcess for Child { 141 | #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))] 142 | fn check_is_alive(&mut self) -> io::Result<()> { 143 | if let Some(exit_status) = self.try_wait()? { 144 | let message = format!("Shell process has prematurely exited: {exit_status}"); 145 | Err(io::Error::new(io::ErrorKind::BrokenPipe, message)) 146 | } else { 147 | Ok(()) 148 | } 149 | } 150 | 151 | #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))] 152 | fn terminate(mut self) -> io::Result<()> { 153 | if self.try_wait()?.is_none() { 154 | self.kill().or_else(|err| { 155 | if is_recoverable_kill_error(&err) { 156 | // The shell has already exited. We don't consider this an error. 157 | Ok(()) 158 | } else { 159 | Err(err) 160 | } 161 | })?; 162 | } 163 | Ok(()) 164 | } 165 | } 166 | 167 | /// Wrapper that allows configuring echoing of the shell process. 168 | /// 169 | /// A shell process is echoing if each line provided to the input is echoed to the output. 170 | #[derive(Debug, Clone)] 171 | pub struct Echoing { 172 | inner: S, 173 | is_echoing: bool, 174 | } 175 | 176 | impl Echoing { 177 | /// Wraps the provided `inner` type. 178 | pub fn new(inner: S, is_echoing: bool) -> Self { 179 | Self { inner, is_echoing } 180 | } 181 | } 182 | 183 | impl ConfigureCommand for Echoing { 184 | fn current_dir(&mut self, dir: &Path) { 185 | self.inner.current_dir(dir); 186 | } 187 | 188 | fn env(&mut self, name: &str, value: &OsStr) { 189 | self.inner.env(name, value); 190 | } 191 | } 192 | 193 | impl SpawnShell for Echoing { 194 | type ShellProcess = Echoing; 195 | type Reader = S::Reader; 196 | type Writer = S::Writer; 197 | 198 | fn spawn_shell(&mut self) -> io::Result> { 199 | let spawned = self.inner.spawn_shell()?; 200 | Ok(SpawnedShell { 201 | shell: Echoing { 202 | inner: spawned.shell, 203 | is_echoing: self.is_echoing, 204 | }, 205 | reader: spawned.reader, 206 | writer: spawned.writer, 207 | }) 208 | } 209 | } 210 | 211 | impl ShellProcess for Echoing { 212 | fn check_is_alive(&mut self) -> io::Result<()> { 213 | self.inner.check_is_alive() 214 | } 215 | 216 | fn terminate(self) -> io::Result<()> { 217 | self.inner.terminate() 218 | } 219 | 220 | fn is_echoing(&self) -> bool { 221 | self.is_echoing 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # term-transcript CLI 2 | 3 | [![CI](https://github.com/slowli/term-transcript/actions/workflows/ci.yml/badge.svg)](https://github.com/slowli/term-transcript/actions/workflows/ci.yml) 4 | [![License: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%2FApache--2.0-blue)](https://github.com/slowli/term-transcript#license) 5 | 6 | This crate provides command-line interface for [`term-transcript`]. It allows capturing 7 | terminal output to SVG and testing the captured snapshots. 8 | 9 | ## Installation 10 | 11 | Install with 12 | 13 | ```shell 14 | cargo install --locked term-transcript-cli 15 | # This will install `term-transcript` executable, which can be checked 16 | # as follows: 17 | term-transcript --help 18 | ``` 19 | 20 | Alternatively, you may use the app Docker image [as described below](#using-docker-image), 21 | or download a pre-built app binary for popular targets (x86_64 for Linux / macOS / Windows 22 | and AArch64 for macOS) 23 | from [GitHub Releases](https://github.com/slowli/term-transcript/releases). 24 | 25 | ### Minimum supported Rust version 26 | 27 | The crate supports the latest stable Rust version. It may support previous stable Rust versions, 28 | but this is not guaranteed. 29 | 30 | ### Crate feature: `portable-pty` 31 | 32 | Specify `--features portable-pty` in the installation command 33 | to enable the pseudo-terminal (PTY) support (note that PTY capturing still needs 34 | to be explicitly switched on when running `term-transcript` commands). 35 | Without this feature, console app output is captured via OS pipes, 36 | which means that programs dependent on [`isatty`] checks 37 | or getting term size can produce different output than if launched in an actual shell 38 | (no coloring, no line wrapping etc.). 39 | 40 | ### Crate feature: `tracing` 41 | 42 | Specify `--features tracing` in the installation command to enable tracing 43 | of the main performed operations. This could be useful for debugging purposes. 44 | Tracing is performed with the `term_transcript::*` targets, mostly on the `DEBUG` level. 45 | Tracing events are output to the stderr using [the standard subscriber][fmt-subscriber]; 46 | its filtering can be configured using the `RUST_LOG` env variable 47 | (e.g., `RUST_LOG=term_transcript=debug`). 48 | 49 | ## Usage 50 | 51 | - The `capture` subcommand captures output from stdin, renders it to SVG and 52 | outputs SVG to stdout. 53 | - The `exec` subcommand executes one or more commands in the shell, captures 54 | their outputs, renders to an SVG image and outputs it to stdout. 55 | - The `test` subcommand allows testing snapshots from the command line. 56 | - The `print` subcommand parses an SVG snapshot and outputs it to the command line. 57 | 58 | Launch the CLI app with the `--help` option for more details about arguments 59 | for each subcommand. See also the [FAQ] for some tips and troubleshooting advice. 60 | 61 | ### Using Docker image 62 | 63 | As a lower-cost alternative to the local installation, you may install and use the CLI app 64 | from the [GitHub Container registry](https://github.com/slowli/term-transcript/pkgs/container/term-transcript). 65 | To run the app in a Docker container, use a command like 66 | 67 | ```shell 68 | docker run -i --rm --env COLOR=always \ 69 | ghcr.io/slowli/term-transcript:master \ 70 | print - < examples/rainbow.svg 71 | ``` 72 | 73 | Here, the `COLOR` env variable sets the coloring preference for the output, 74 | and the `-` arg for the `print` subcommand instructs reading from stdin. 75 | 76 | Running `exec` and `test` subcommands from a Docker container is more tricky 77 | since normally this would require taking the entire environment for the executed commands 78 | into the container. In order to avoid this, you can establish a bidirectional channel 79 | with the host using [`nc`](https://linux.die.net/man/1/nc), which is pre-installed 80 | in the Docker image: 81 | 82 | ```shell 83 | docker run --rm -v /tmp/shell.sock:/tmp/shell.sock \ 84 | ghcr.io/slowli/term-transcript:master \ 85 | exec --shell nc --echoing --args=-U --args=/tmp/shell.sock 'ls -al' 86 | ``` 87 | 88 | Here, the complete shell command connects `nc` to the Unix domain socket 89 | at `/tmp/shell.sock`, which is mounted to the container using the `-v` option. 90 | 91 | On the host side, connecting the `bash` shell to the socket could look like this: 92 | 93 | ```shell 94 | mkfifo /tmp/shell.fifo 95 | cat /tmp/shell.fifo | bash -i 2>&1 | nc -lU /tmp/shell.sock > /tmp/shell.fifo & 96 | ``` 97 | 98 | Here, `/tmp/shell.fifo` is a FIFO pipe used to exchange data between `nc` and `bash`. 99 | The drawback of this approach is that the shell executable 100 | would not run in a (pseudo-)terminal and thus could look differently (no coloring etc.). 101 | To connect a shell in a pseudo-terminal, you can use [`socat`](http://www.dest-unreach.org/socat/doc/socat.html), 102 | changing the host command as follows: 103 | 104 | ```shell 105 | socat UNIX-LISTEN:/tmp/shell.sock,fork EXEC:"bash -i",pty,setsid,ctty,stderr & 106 | ``` 107 | 108 | TCP sockets can be used instead of Unix sockets, but are not recommended 109 | if Unix sockets are available since they are less secure. Indeed, care should be taken 110 | that the host "server" is not bound to a publicly accessible IP address, which 111 | would create a remote execution backdoor to the host system. As usual, caveats apply; 112 | e.g., one can spawn the shell in another Docker container connecting it and the `term-transcript` 113 | container in a single Docker network. In this case, TCP sockets are secure and arguably 114 | easier to use given Docker built-in DNS resolution machinery. 115 | 116 | ### Examples 117 | 118 | This example creates a snapshot of [the `rainbow` script][rainbow-script-link] and then tests it. 119 | 120 | ![Testing rainbow example][test-snapshot-link] 121 | 122 | The snapshot itself [is tested][test-link], too! It also shows 123 | that SVG output by the program is editable; in the snapshot, this is used to 124 | highlight command-line args and to change color of comments in the user inputs. 125 | 126 | The `test` command can compare colors as well: 127 | 128 | ![Testing color match][test-color-snapshot-link] 129 | 130 | Another snapshot created by capturing help output from a pseudo-terminal 131 | (the `--pty` flag): 132 | 133 | ![Output of `test-transcript --help`][help-snapshot-link] 134 | 135 | Using PTY enables coloring output by default and formatting dependent 136 | on the terminal size. 137 | 138 | See also [a shell script][generate-snapshots] used in the "parent" `term-transcript` 139 | crate to render examples; it uses all major commands and options of the CLI app. 140 | The snapshots generated by the script are showcased [in a dedicated file][examples-readme]. 141 | 142 | ## License 143 | 144 | Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) 145 | or [MIT license](LICENSE-MIT) at your option. 146 | 147 | Unless you explicitly state otherwise, any contribution intentionally submitted 148 | for inclusion in `term-transcript` by you, as defined in the Apache-2.0 license, 149 | shall be dual licensed as above, without any additional terms or conditions. 150 | 151 | [`term-transcript`]: https://crates.io/crates/term-transcript 152 | [fmt-subscriber]: https://docs.rs/tracing-subscriber/latest/tracing_subscriber/fmt/index.html 153 | [FAQ]: https://github.com/slowli/term-transcript/blob/HEAD/FAQ.md 154 | [rainbow-script-link]: https://github.com/slowli/term-transcript/blob/HEAD/cli/rainbow.sh 155 | [test-snapshot-link]: https://github.com/slowli/term-transcript/raw/HEAD/cli/tests/snapshots/test.svg?sanitize=true 156 | [test-color-snapshot-link]: https://github.com/slowli/term-transcript/raw/HEAD/cli/tests/snapshots/test-fail.svg?sanitize=true 157 | [test-link]: https://github.com/slowli/term-transcript/blob/HEAD/cli/tests/e2e.rs 158 | [help-snapshot-link]: https://github.com/slowli/term-transcript/raw/HEAD/cli/tests/snapshots/help.svg?sanitize=true 159 | [`isatty`]: https://man7.org/linux/man-pages/man3/isatty.3.html 160 | [generate-snapshots]: https://github.com/slowli/term-transcript/blob/HEAD/examples/generate-snapshots.sh 161 | [examples-readme]: https://github.com/slowli/term-transcript/blob/HEAD/examples/README.md 162 | -------------------------------------------------------------------------------- /lib/src/svg/default.svg.handlebars: -------------------------------------------------------------------------------- 1 | {{! Load common helpers }} 2 | {{>_helpers~}} 3 | 4 | {{! Root template }} 5 | {{~#*inline "root"}} 6 | 7 | 8 | 9 | 10 | {{>styles}} 11 | {{>background}} 12 | 13 | {{~>content}} 14 | {{~#if (scroll_animation)}} 15 | {{>scrollbar}} 16 | {{/if}} 17 | 18 | {{>unsupported_error}} 19 | 20 | 21 | {{/inline~}} 22 | 23 | {{! NB. The warning text should fit in one 80-char line to not potentially overflow the viewbox. }} 24 | {{~#*inline "unsupported_error"}} 25 | 26 | HTML embedding not supported. 27 | Consult term-transcript docs for details. 28 | 29 | {{/inline~}} 30 | 31 | {{! CSS definitions }} 32 | {{~#*inline "styles"}} 33 | 122 | {{/inline~}} 123 | 124 | {{~#*inline "content"}} 125 | 126 | {{~#if (scroll_animation)}} 127 | {{~#with (scroll_animation)}} 128 | 129 | 130 | {{~/with}} 131 | {{~/if}} 132 | 133 | 134 |
135 | {{~#each interactions}} 136 | 137 |
140 | {{~#if (and (eq ../line_numbers "continuous") (not input.hidden))}}{{>number_input_lines}}{{/if~}} 141 |
{{ input.prompt }} {{ input.text }}
142 |
143 | {{~#if ../line_numbers~}} 144 | {{~>number_output_lines~}} 145 | {{~#if (ne ../line_numbers "each_output")~}} 146 | {{~line_number set=(add (line_number) (len output))~}} 147 | {{~/if~}} 148 | {{~/if~}} 149 |
150 |               {{~#each output as |line|~}}
151 |                 
152 |                 {{~#each line.spans as |span|~}}
153 |                     {{{eval ">html_span" span=span}}}
154 |                 {{~else~}}{{! The line may be empty }}
155 |                 {{~/each~}}
156 |                 {{~#if line.br}}{{/if~}}
157 |                 {{! Add a newline in order to make the text correctly copyable }}
158 |                 {{~#if (not @last)}}
159 | 
160 |                 {{/if~}}
161 |                 
162 |               {{~else~}}{{! The output may be empty }}
163 |               {{~/each~}}
164 |               
165 | {{~/each}} 166 | 167 |
168 |
169 |
170 | {{/inline~}} 171 | 172 | {{~#*inline "number_input_lines"~}} 173 |
174 |     {{~#each (range 0 (count_lines input.text))~}}
175 |       {{add this (line_number)}}{{#if @last}}{{else}}
{{/if}} 176 | {{~/each~}} 177 |
178 | {{~line_number set=(add (line_number) (count_lines input.text))~}} 179 | {{~/inline~}} 180 | 181 | {{~#*inline "number_output_lines"}} 182 |
183 |   {{~#each (range 0 (len output))~}}
184 |     {{add this (line_number)}}{{#if @last}}{{else}}
{{/if}} 185 | {{~/each~}} 186 |
187 | {{~/inline~}} 188 | 189 | {{! Main logic }} 190 | {{#scope 191 | content_height=0 192 | scroll_animation=null 193 | screen_height=0 194 | height=0 195 | line_height=line_height 196 | line_number=1 197 | }} 198 | {{~>set_line_height ~}} 199 | {{~content_height set=(round 200 | (eval "compute_content_height" const=const line_height=(line_height) interactions=interactions) 201 | digits=1 202 | )~}} 203 | {{~#if scroll~}} 204 | {{scroll_animation set=(eval "compute_scroll_animation" 205 | const=const 206 | scroll=scroll 207 | width=width 208 | content_height=(content_height) 209 | )}} 210 | {{~/if~}} 211 | {{~#if (scroll_animation)~}} 212 | {{screen_height set=scroll.max_height}} 213 | {{~else~}} 214 | {{screen_height set=(content_height)}} 215 | {{~/if~}} 216 | {{~height set=(add (screen_height) (mul const.WINDOW_PADDING 2))~}} 217 | {{~#if window_frame~}} 218 | {{height set=(add (height) const.WINDOW_FRAME_HEIGHT)}} 219 | {{~/if~}} 220 | {{>root~}} {{! <-- All rendering happens here }} 221 | {{/scope}} 222 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Snapshot Showcase 2 | 3 | This file showcases snapshot examples generated by the [`term-transcript` CLI app](../cli). 4 | Consult the [generating script](generate-snapshots.sh) for details on preparing the environment. 5 | 6 | ## Basics 7 | 8 | ### Static snapshot 9 | 10 | ![Snapshot of rainbow example](rainbow.svg) 11 | 12 | Generating command: 13 | 14 | ```shell 15 | term-transcript exec -T 250ms --palette gjm8 rainbow 16 | ``` 17 | 18 | (`rainbow` is an executable for [end-to-end tests](../e2e-tests/rainbow).) 19 | 20 | ### Static snapshot (pure SVG) 21 | 22 | ![Snapshot of rainbow example](rainbow-pure.svg) 23 | 24 | Generating command: 25 | 26 | ```shell 27 | term-transcript exec -T 250ms --pure-svg --palette gjm8 rainbow 28 | ``` 29 | 30 | ### Animated snapshot 31 | 32 | ![Animated snapshot of rainbow example](animated.svg) 33 | 34 | Generating command: 35 | 36 | ```shell 37 | term-transcript exec -T 250ms --palette powershell --line-height=18px \ 38 | --pty --window --scroll rainbow 'rainbow --long-lines' 39 | ``` 40 | 41 | Note the `--pty` flag to use a pseudo-terminal for capture instead of default pipes, 42 | and an increased line height. 43 | 44 | ## Configuring console size 45 | 46 | ### Setting width 47 | 48 | ![Wide snapshot](rainbow-wide.svg) 49 | 50 | Use `--width` to control the pixel width of the console, and `--hard-wrap` to control 51 | at which char the console output is hard-wrapped to a new line. It usually makes sense 52 | to set these both params: `width ≈ hard_wrap * 9` (the exact coefficient depends on 53 | the font being used). 54 | 55 | Generating command: 56 | 57 | ```shell 58 | term-transcript exec -T 250ms --palette gjm8 \ 59 | --hard-wrap=100 --width=900 'rainbow --long-lines' 60 | ``` 61 | 62 | ### Setting scroll height 63 | 64 | ![Small snapshot](rainbow-small.svg) 65 | 66 | Use `--scroll=$height` to set the maximum pixel height of the snapshot. 67 | 68 | Generating command: 69 | 70 | ```shell 71 | term-transcript exec -T 250ms --palette gjm8 \ 72 | --hard-wrap=50 --width=450 --scroll=180 rainbow 73 | ``` 74 | 75 | ## Line numbering 76 | 77 | ### Separate numbering for each output 78 | 79 | ![Separate numbering for outputs](numbers-each-output.svg) 80 | 81 | Generating command: 82 | 83 | ```shell 84 | term-transcript exec -T 250ms --scroll --palette xterm \ 85 | --line-numbers each-output \ 86 | rainbow 'rainbow --short' 87 | ``` 88 | 89 | ### Continuous numbering for outputs 90 | 91 | ![Continuous numbering for outputs](numbers-continuous-outputs.svg) 92 | 93 | Generating command: 94 | 95 | ```shell 96 | term-transcript exec -T 250ms --scroll --palette powershell \ 97 | --line-numbers continuous-outputs \ 98 | --line-height=1.4 \ 99 | rainbow 'rainbow --short' 100 | ``` 101 | 102 | ### Continuous numbering for inputs and outputs 103 | 104 | ![Continuous numbering for inputs and outputs](numbers-continuous.svg) 105 | 106 | Generating command: 107 | 108 | ```shell 109 | term-transcript exec -T 250ms --scroll --palette gjm8 \ 110 | --line-numbers continuous \ 111 | rainbow 'rainbow --short' 112 | ``` 113 | 114 | Same snapshot generated using the pure SVG template (i.e., with the additional 115 | `--pure-svg` flag): 116 | 117 | ![Continuous numbering for inputs and outputs](numbers-continuous-pure.svg) 118 | 119 | ### Numbering with line breaks 120 | 121 | As the example below shows, what is numbered are *displayed* lines 122 | obtained after potential line breaking. 123 | 124 | ![Numbering with line breaks](numbers-long.svg) 125 | 126 | Generating command: 127 | 128 | ```shell 129 | term-transcript exec -T 250ms --palette gjm8 \ 130 | --line-numbers continuous \ 131 | --line-height 18px \ 132 | 'rainbow --long-lines' 133 | ``` 134 | 135 | Same snapshot generated using the pure SVG template (i.e., with the additional 136 | `--pure-svg` flag): 137 | 138 | ![Numbering with line breaks, pure SVG](numbers-long-pure.svg) 139 | 140 | ## Hiding user inputs 141 | 142 | Combined with line numbering and scrolling to test more features. 143 | 144 | ![Hidden user inputs](no-inputs-numbers.svg) 145 | 146 | Generating command: 147 | 148 | ```shell 149 | term-transcript exec -T 250ms --scroll --palette xterm \ 150 | --no-inputs --line-numbers continuous \ 151 | rainbow 'rainbow --short' 152 | ``` 153 | 154 | Same snapshot generated using the pure SVG template (i.e., with the additional 155 | `--pure-svg` flag): 156 | 157 | ![Hidden user inputs, pure SVG](no-inputs-numbers-pure.svg) 158 | 159 | ## Custom fonts 160 | 161 | Using `--styles` and `--font` options, it's possible to use a custom font in the snapshot. 162 | For example, the snapshot below uses [Fira Mono](https://github.com/mozilla/Fira): 163 | 164 | ![Snapshot with Fira Mono font](fira.svg) 165 | 166 | Note that the custom font will only be displayed when viewed in the browser 167 | if the [Content Security Policy][CSP] of the HTTP server hosting the SVG allows to do so. 168 | See the [FAQ](../FAQ.md#transcripts--content-security-policy) for more details. 169 | 170 | Generating command: 171 | 172 | ```shell 173 | term-transcript exec -T 250ms --palette gjm8 --window \ 174 | --font 'Fira Mono, Consolas, Liberation Mono, Menlo' \ 175 | --styles '@import url(https://code.cdn.mozilla.net/fonts/fira.css);' rainbow 176 | ``` 177 | 178 | The same snapshot rendered with pure SVG: 179 | 180 | ![Snapshot with Fira Mono font and pure SVG](fira-pure.svg) 181 | 182 | [CSP]: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP 183 | 184 | ### Embedding custom fonts 185 | 186 | Using `--embed-font`, it's possible to embed a font into the snapshot (rather than hot-linking it 187 | as with `--font`). The font is *subset* before embedding, meaning that only glyphs for chars 188 | used in the transcripts are retained; this means that the font overhead is not that significant (order of 10 kB). 189 | For example, the snapshot below embeds [Roboto Mono](https://fonts.google.com/specimen/Roboto+Mono): 190 | 191 | ![Snapshot with embedded Roboto Mono font](embedded-font.svg) 192 | 193 | Generating command: 194 | 195 | ```shell 196 | term-transcript exec -T 250ms --palette gjm8 \ 197 | --line-numbers continuous \ 198 | --embed-font="$ROBOTO_MONO_PATH" \ 199 | 'rainbow --short' 200 | ``` 201 | 202 | The embedded Roboto Mono font is [*variable*][variable-fonts] by font weight, meaning that it has the bold version 203 | (weight: 700) embedded as well. In contrast, the *italic* font face must be synthesized by the browser. 204 | It is possible to embed the italic font face as well by specifying 2 paths for `--embed-font`: 205 | 206 | ```shell 207 | term-transcript exec -T 250ms --palette gjm8 \ 208 | --line-numbers continuous \ 209 | --line-height=1.4 \ 210 | --embed-font="$ROBOTO_MONO_PATH:$ROBOTO_MONO_ITALIC_PATH" \ 211 | --pure-svg \ 212 | 'rainbow --short' 213 | ``` 214 | 215 | ![Snapshot with two embedded Roboto Mono fonts, pure SVG](embedded-font-pure.svg) 216 | 217 | Another example: [Fira Mono](https://fonts.google.com/specimen/Fira+Mono), which is a non-variable font. 218 | We embed its regular and **bold** faces (i.e., *italic* is synthesized): 219 | 220 | ```shell 221 | term-transcript exec -T 250ms --palette gjm8 \ 222 | --line-numbers continuous \ 223 | --embed-font="$FIRA_MONO_PATH:$FIRA_MONO_BOLD_PATH" \ 224 | --pure-svg \ 225 | 'rainbow --short' 226 | ``` 227 | 228 | ![Snapshot with embedded Fira Mono fonts, pure SVG](embedded-font-fira.svg) 229 | 230 | The same note regarding [content security policy][CSP] applies. 231 | 232 | [variable-fonts]: https://learn.microsoft.com/en-us/typography/opentype/spec/otvaroverview 233 | 234 | ## Configuration file 235 | 236 | `--config-path` option allows reading rendering options from a TOML file. This enables 237 | configuring low-level template details. The snapshot below uses a [configuration file](config.toml) 238 | to customize palette colors and scroll animation step / interval. 239 | 240 | ![Snapshot with config read from file](custom-config.svg) 241 | 242 | Generating command: 243 | 244 | ```shell 245 | term-transcript exec -T 250ms --config-path config.toml \ 246 | 'rainbow --long-lines' 247 | ``` 248 | 249 | ## Failed inputs 250 | 251 | Some shells may allow detecting whether an input resulted in a failure 252 | (e.g., *nix shells allow doing this by comparing the output of `echo $?` to 0, 253 | while in PowerShell `$?` can be compared to `True`). Such failures are captured 254 | and visually highlighted the default SVG template. 255 | 256 | ### Failures in `sh` 257 | 258 | ![Snapshot with failing `sh` commands](failure-sh.svg) 259 | 260 | Generating command: 261 | 262 | ```shell 263 | term-transcript exec -T 250ms --palette gjm8 --window \ 264 | './non-existing-command' \ 265 | '[ -x non-existing-file ]' \ 266 | '[ -x non-existing-file ] || echo "File is not there!"' 267 | ``` 268 | 269 | ### Failures in `bash` 270 | 271 | Captured using a pseudo-terminal, hence colorful `grep` output. 272 | 273 | ![Snapshot with failing `grep` in `bash`](failure-bash-pty.svg) 274 | 275 | Generating command: 276 | 277 | ```shell 278 | term-transcript exec -T 250ms --palette gjm8 \ 279 | --pty --window --shell bash \ 280 | 'ls -l Cargo.lock' \ 281 | 'grep -n serge Cargo.lock' \ 282 | 'grep -n serde Cargo.lock' 283 | ``` 284 | 285 | ### Failures in `pwsh` 286 | 287 | ![Snapshot with failing `pwsh` command](failure-pwsh.svg) 288 | 289 | ```shell 290 | term-transcript exec --window --palette gjm8 \ 291 | --shell pwsh './non-existing-command' 292 | ``` 293 | -------------------------------------------------------------------------------- /lib/src/utils.rs: -------------------------------------------------------------------------------- 1 | //! Misc utils. 2 | 3 | use std::{borrow::Cow, fmt::Write as WriteStr, io, str}; 4 | 5 | #[cfg(any(feature = "svg", feature = "test"))] 6 | pub(crate) use self::rgb_color::IndexOrRgb; 7 | #[cfg(any(feature = "svg", feature = "test"))] 8 | pub use self::rgb_color::RgbColor; 9 | #[cfg(feature = "svg")] 10 | pub use self::rgb_color::RgbColorParseError; 11 | 12 | /// Adapter for `dyn fmt::Write` that implements `io::Write`. 13 | pub(crate) struct WriteAdapter<'a> { 14 | inner: &'a mut dyn WriteStr, 15 | } 16 | 17 | impl<'a> WriteAdapter<'a> { 18 | pub fn new(output: &'a mut dyn WriteStr) -> Self { 19 | Self { inner: output } 20 | } 21 | } 22 | 23 | impl io::Write for WriteAdapter<'_> { 24 | fn write(&mut self, buf: &[u8]) -> io::Result { 25 | let segment = 26 | str::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; 27 | self.inner.write_str(segment).map_err(io::Error::other)?; 28 | Ok(buf.len()) 29 | } 30 | 31 | fn flush(&mut self) -> io::Result<()> { 32 | Ok(()) 33 | } 34 | } 35 | 36 | pub(crate) fn normalize_newlines(s: &str) -> Cow<'_, str> { 37 | if s.contains("\r\n") { 38 | Cow::Owned(s.replace("\r\n", "\n")) 39 | } else { 40 | Cow::Borrowed(s) 41 | } 42 | } 43 | 44 | #[cfg(not(windows))] 45 | pub(crate) fn is_recoverable_kill_error(err: &io::Error) -> bool { 46 | matches!(err.kind(), io::ErrorKind::InvalidInput) 47 | } 48 | 49 | // As per `TerminateProcess` docs (`TerminateProcess` is used by `Child::kill()`), 50 | // the call will result in ERROR_ACCESS_DENIED if the process has already terminated. 51 | // 52 | // https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-terminateprocess 53 | #[cfg(windows)] 54 | pub(crate) fn is_recoverable_kill_error(err: &io::Error) -> bool { 55 | matches!( 56 | err.kind(), 57 | io::ErrorKind::InvalidInput | io::ErrorKind::PermissionDenied 58 | ) 59 | } 60 | 61 | #[cfg(any(feature = "svg", feature = "test"))] 62 | mod rgb_color { 63 | use std::{error::Error as StdError, fmt, num::ParseIntError, str::FromStr}; 64 | 65 | /// RGB color with 8-bit channels. 66 | /// 67 | /// A color [can be parsed](FromStr) from a hex string like `#fed` or `#de382b`. 68 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 69 | pub struct RgbColor(pub u8, pub u8, pub u8); 70 | 71 | impl fmt::LowerHex for RgbColor { 72 | fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 73 | write!(formatter, "#{:02x}{:02x}{:02x}", self.0, self.1, self.2) 74 | } 75 | } 76 | 77 | /// Errors that can occur when [parsing](FromStr) an [`RgbColor`] from a string. 78 | #[derive(Debug)] 79 | #[non_exhaustive] 80 | pub enum RgbColorParseError { 81 | /// Color string contains non-ASCII chars. 82 | NotAscii, 83 | /// The color does not have a `#` prefix. 84 | NoHashPrefix, 85 | /// The color has incorrect string length (not 1 or 2 chars per color channel). 86 | /// The byte length of the string (including 1 char for the `#` prefix) 87 | /// is provided within this variant. 88 | IncorrectLen(usize), 89 | /// Error parsing color channel value. 90 | IncorrectDigit(ParseIntError), 91 | } 92 | 93 | impl fmt::Display for RgbColorParseError { 94 | fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 95 | match self { 96 | Self::NotAscii => formatter.write_str("color string contains non-ASCII chars"), 97 | Self::NoHashPrefix => formatter.write_str("missing '#' prefix"), 98 | Self::IncorrectLen(len) => write!( 99 | formatter, 100 | "unexpected byte length {len} of color string, expected 4 or 7" 101 | ), 102 | Self::IncorrectDigit(err) => write!(formatter, "error parsing hex digit: {err}"), 103 | } 104 | } 105 | } 106 | 107 | impl StdError for RgbColorParseError { 108 | fn source(&self) -> Option<&(dyn StdError + 'static)> { 109 | match self { 110 | Self::IncorrectDigit(err) => Some(err), 111 | _ => None, 112 | } 113 | } 114 | } 115 | 116 | impl FromStr for RgbColor { 117 | type Err = RgbColorParseError; 118 | 119 | fn from_str(s: &str) -> Result { 120 | if s.is_empty() || s.as_bytes()[0] != b'#' { 121 | Err(RgbColorParseError::NoHashPrefix) 122 | } else if s.len() == 4 { 123 | if !s.is_ascii() { 124 | return Err(RgbColorParseError::NotAscii); 125 | } 126 | 127 | let r = 128 | u8::from_str_radix(&s[1..2], 16).map_err(RgbColorParseError::IncorrectDigit)?; 129 | let g = 130 | u8::from_str_radix(&s[2..3], 16).map_err(RgbColorParseError::IncorrectDigit)?; 131 | let b = 132 | u8::from_str_radix(&s[3..], 16).map_err(RgbColorParseError::IncorrectDigit)?; 133 | Ok(Self(r * 17, g * 17, b * 17)) 134 | } else if s.len() == 7 { 135 | if !s.is_ascii() { 136 | return Err(RgbColorParseError::NotAscii); 137 | } 138 | 139 | let r = 140 | u8::from_str_radix(&s[1..3], 16).map_err(RgbColorParseError::IncorrectDigit)?; 141 | let g = 142 | u8::from_str_radix(&s[3..5], 16).map_err(RgbColorParseError::IncorrectDigit)?; 143 | let b = 144 | u8::from_str_radix(&s[5..], 16).map_err(RgbColorParseError::IncorrectDigit)?; 145 | Ok(Self(r, g, b)) 146 | } else { 147 | Err(RgbColorParseError::IncorrectLen(s.len())) 148 | } 149 | } 150 | } 151 | 152 | #[derive(Debug, Clone, Copy, PartialEq)] 153 | #[cfg_attr(feature = "svg", derive(serde::Serialize))] 154 | #[cfg_attr(feature = "svg", serde(untagged))] 155 | pub(crate) enum IndexOrRgb { 156 | Index(u8), 157 | Rgb(RgbColor), 158 | } 159 | 160 | impl IndexOrRgb { 161 | #[cfg(feature = "svg")] 162 | #[allow(clippy::match_wildcard_for_single_variants)] 163 | // ^-- `Color` is an old-school non-exhaustive enum 164 | pub(crate) fn new(color: termcolor::Color) -> std::io::Result { 165 | use termcolor::Color; 166 | 167 | Ok(match color { 168 | Color::Black => Self::index(0), 169 | Color::Red => Self::index(1), 170 | Color::Green => Self::index(2), 171 | Color::Yellow => Self::index(3), 172 | Color::Blue => Self::index(4), 173 | Color::Magenta => Self::index(5), 174 | Color::Cyan => Self::index(6), 175 | Color::White => Self::index(7), 176 | Color::Ansi256(idx) => Self::indexed_color(idx), 177 | Color::Rgb(r, g, b) => Self::Rgb(RgbColor(r, g, b)), 178 | _ => return Err(std::io::Error::other("Unsupported color")), 179 | }) 180 | } 181 | 182 | fn index(value: u8) -> Self { 183 | debug_assert!(value < 16); 184 | Self::Index(value) 185 | } 186 | 187 | pub(crate) fn indexed_color(index: u8) -> Self { 188 | match index { 189 | 0..=15 => Self::index(index), 190 | 191 | 16..=231 => { 192 | let index = index - 16; 193 | let r = Self::color_cube_color(index / 36); 194 | let g = Self::color_cube_color((index / 6) % 6); 195 | let b = Self::color_cube_color(index % 6); 196 | Self::Rgb(RgbColor(r, g, b)) 197 | } 198 | 199 | _ => { 200 | let gray = 10 * (index - 232) + 8; 201 | Self::Rgb(RgbColor(gray, gray, gray)) 202 | } 203 | } 204 | } 205 | 206 | fn color_cube_color(index: u8) -> u8 { 207 | match index { 208 | 0 => 0, 209 | 1 => 0x5f, 210 | 2 => 0x87, 211 | 3 => 0xaf, 212 | 4 => 0xd7, 213 | 5 => 0xff, 214 | _ => unreachable!(), 215 | } 216 | } 217 | } 218 | } 219 | 220 | #[cfg(all(test, any(feature = "svg", feature = "test")))] 221 | mod tests { 222 | use assert_matches::assert_matches; 223 | 224 | use super::*; 225 | 226 | #[test] 227 | fn parsing_color() { 228 | let RgbColor(r, g, b) = "#fed".parse().unwrap(); 229 | assert_eq!((r, g, b), (0xff, 0xee, 0xdd)); 230 | let RgbColor(r, g, b) = "#c0ffee".parse().unwrap(); 231 | assert_eq!((r, g, b), (0xc0, 0xff, 0xee)); 232 | } 233 | 234 | #[test] 235 | fn errors_parsing_color() { 236 | let err = "123".parse::().unwrap_err(); 237 | assert_matches!(err, RgbColorParseError::NoHashPrefix); 238 | let err = "#12".parse::().unwrap_err(); 239 | assert_matches!(err, RgbColorParseError::IncorrectLen(3)); 240 | let err = "#тэг".parse::().unwrap_err(); 241 | assert_matches!(err, RgbColorParseError::NotAscii); 242 | let err = "#coffee".parse::().unwrap_err(); 243 | assert_matches!(err, RgbColorParseError::IncorrectDigit(_)); 244 | } 245 | } 246 | --------------------------------------------------------------------------------