Crates.io | bullet_stream |
lib.rs | bullet_stream |
version | 0.3.0 |
source | src |
created_at | 2024-06-03 20:32:41.779147 |
updated_at | 2024-10-14 15:37:37.548759 |
description | Bulletproof printing for bullet point text |
homepage | |
repository | https://github.com/schneems/bullet_stream |
max_upload_size | |
id | 1260497 |
size | 104,105 |
Bulletproof printing for bullet point text
An opinionated logger aimed at streaming text output (of scripts or buildpacks) to users. The format is loosely based on markdown headers and bullet points, hence the name.
This work started as a shared output format for Heroku's Cloud Native Buildpack (CNB) efforts, which are written in Rust. You can learn more about Heroku's Cloud Native Buildpacks here.
Add bullet_stream
to your project:
$ cargo add bullet_stream
Now use [Print
] to output structured text as a script/buildpack executes. The output
is intended to be read by the end user.
use bullet_stream::Print;
let mut output = Print::new(std::io::stdout())
.h2("Example Buildpack")
.warning("No Gemfile.lock found");
output = output
.bullet("Ruby version")
.done();
output.done();
To view the output format and read a living style guide, you can run:
$ git clone https://github.com/schneems/bullet_stream
$ cd bullet_stream
$ cargo run --example style_guide
In nature, colors and contrasts are used to emphasize differences and danger. [Print
]
utilizes common ANSI escape characters to highlight what's important and deemphasize what's not.
The output experience is designed from the ground up to be streamed to a user's terminal correctly.
Help your users focus on what's happening rather than on inconsistent formatting. The [Print
] is a consuming, stateful design. That means you can use Rust's powerful type system to ensure
only the output you expect, in the style you want, is emitted to the screen. See the documentation
in the [state
] module for more information.
The project has some unique requirements that might not be obvious at first glance:
remote >
. The library provides tooling for alternative append-only "spinners" that denote the passage of time without requiring a screen redraw.remote >
are not accidentally colorized.bundle install
) are "unowned". Bullet stream uses leader characters and color to denote "owned" output, while unowned output carries no markers and is generally indented.The library design relies on a consuming struct design to guarantee output consistency. That means that you'll end up needing to assign the bullet_stream
result just about every time you use it, for example:
use bullet_stream::Print;
let mut log = Print::new(std::io::stderr()).h1("Building Ruby");
log = {
let mut bullet = log.bullet("Doing things");
// ..
bullet.done()
};
log = {
let mut bullet = log.bullet("Noun");
// ...
bullet = bullet.sub_bullet("Verb");
// ...
bullet = bullet.sub_bullet("Another verb");
// ...
let timer = bullet.start_timer("Printing dots in the background");
// ...
bullet = timer.done();
bullet.done()
};
log.done();
Bullet stream works with anything that is Write + Send + Sync + 'static,
but most people will use std::io::Stdout
or std::io::Stderr
. If you know a specific type you want to output to, you can simplify your method definitions.
For example:
use bullet_stream::{
state::{Bullet, SubBullet},
Print,
};
use std::path::{Path, PathBuf};
use std::io::Stdout;
fn install_ruby(
mut output: Print<Bullet<Stdout>>,
path: &Path,
) -> Result<Print<SubBullet<Stdout>>, std::io::Error>
{
todo!();
}
If that's still too much typing for you, you can simplify more with type aliases:
use bullet_stream::{Print, state};
use std::io::Stdout;
use std::path::Path;
pub(crate) type Header = Print<state::Header<Stdout>>;
pub(crate) type Bullet = Print<state::Bullet<Stdout>>;
pub(crate) type SubBullet = Print<state::SubBullet<Stdout>>;
fn install_ruby(
mut output: Bullet,
path: &Path,
) -> Result<SubBullet, std::io::Error>
{
todo!();
}
Any state you send to a function must be retrieved. There are examples in:
state::Header
]state::Bullet
]state::SubBullet
]state::Stream
]state::Background
]In general, we recommend pushing business logic down into functions. Rather than threading the logging state throughout every possible function, rely on functions to bubble up information to log. For example, this code reads version information from a file and logs it, while the logic function install_ruby_version
does not need direct access to print any output:
// Example of bubbling up information to the logger
// β
πΈβ
use bullet_stream::{Print, style};
/// Smaller signature
fn install_ruby_version(version: impl AsRef<str>) -> Result<(), std::io::Error> {
// ...
Ok(())
}
let mut output = Print::new(std::io::stdout()).h2("Example Buildpack");
// Bubble up data
let version = std::fs::read_to_string(std::path::Path::new("/dev/null"))
.unwrap()
.trim()
.to_owned();
// Output data
let timer = output.bullet(format!("Ruby version {}", style::value(&version)))
.start_timer("Installing");
// Call logic
install_ruby_version(&version).unwrap();
output = timer.done()
.done();
Here's the same general code, but using a function that accepts a print struct as it's input and then returns it via a tuple when it's done:
// Example of logging by passing state into a function, requires a large signature
// βπΎβ
use bullet_stream::{
state::{Bullet, SubBullet},
Print, style
};
use std::io::Stdout;
use std::path::Path;
/// Large function signature, it works but might not always be needed
fn install_ruby(
mut output: Print<Bullet<Stdout>>,
path: &Path,
) -> Result<(Print<SubBullet<Stdout>>, String), std::io::Error>
{
let version = std::fs::read_to_string(path)?
.trim()
.to_owned();
let timer = output.bullet(format!("Ruby version {}", style::value(&version)))
.start_timer("Installing");
// ...
Ok((timer.done(), version))
}
let mut output = Print::new(std::io::stdout()).h2("Example Buildpack");
let (bullet, version) = install_ruby(output, &Path::new("/dev/null"))
.unwrap();
output = bullet.done();
In the above example, the install_ruby
function both performs logic and logs information, resulting in a very large function signature. Both styles achieve the same outcome, so it's ultimately your preference. It's not bad if you want to pass your output around to functions, but it is cumbersome.
For some operations like streaming the output of a std::process::Command
Status: Experimental/WIP; if you've got a better suggestion, let us know.
Because the logger is stateful, consuming logging from within an async or parallel execution context is tricky. We recommend using the same pattern as above to bubble up information that can be logged between synchronization points in the program.
For example, here's some hand-rolled output from code that uses async:
## Distribution Info
- Name: ubuntu
- Version: 22.04
- Codename: jammy
- Architecture: amd64
## Creating package index
[GET] http://archive.ubuntu.com/ubuntu/dists/jammy-updates/InRelease
[CACHED] http://archive.ubuntu.com/ubuntu/dists/jammy/InRelease
[GET] http://archive.ubuntu.com/ubuntu/dists/jammy-security/InRelease
[CACHED] http://archive.ubuntu.com/ubuntu/dists/jammy/universe/binary-amd64/by-hash/SHA256/9939f6554c5cbea6607e3886634d7e393d8b0364ae0a43c2549d7191840c66c1
[CACHED] http://archive.ubuntu.com/ubuntu/dists/jammy/main/binary-amd64/by-hash/SHA256/712ee19b50fa5a5963b82b8dd00438f59ef1f088db8e3e042f4306d2b7c89c69
[GET] http://archive.ubuntu.com/ubuntu/dists/jammy-updates/main/binary-amd64/by-hash/SHA256/9be23783bb2295aedcb02760ffaa8980c58573d0318ec67f2f409b8f3d2f27bb
[GET] http://archive.ubuntu.com/ubuntu/dists/jammy-updates/universe/binary-amd64/by-hash/SHA256/c6a66ee7fb32ca0f0662b0b1b2a2f58ab18a10b749a8dcc61a9fc7d0fde17754
[CACHED] http://archive.ubuntu.com/ubuntu/dists/jammy-security/universe/binary-amd64/by-hash/SHA256/86e543e7b5cccc2537a4f6451f7f9c0cd459803cf4403beca71a459848dd9a0f
[GET] http://archive.ubuntu.com/ubuntu/dists/jammy-security/main/binary-amd64/by-hash/SHA256/9943ee3b3104b37d0ee219fae65f261d0c61c96bb3978fe92e6573c9dcd88862
In this example, the get and cached lines are logged within an async context. Here's an example of a refactor that could use the bullet stream library:
# Heroku Debian Packages Buildpack (v0.0.1)
- Package index sources
- `http://archive.ubuntu.com/ubuntu/dists/jammy/InRelease`
- `http://archive.ubuntu.com/ubuntu/dists/jammy-updates/InRelease`
- `http://archive.ubuntu.com/ubuntu/dists/jammy-security/InRelease`
- Downloading ...................................... (Done 35s)
- Downloaded indexes
- `http://archive.ubuntu.com/ubuntu/dists/jammy/universe/binary-amd64/by-hash/SHA256/9939f6554c5cbea6607e3886634d7e393d8b0364ae0a43c2549d7191840c66c1`
- `http://archive.ubuntu.com/ubuntu/dists/jammy/main/binary-amd64/by-hash/SHA256/712ee19b50fa5a5963b82b8dd00438f59ef1f088db8e3e042f4306d2b7c89c69`
- `http://archive.ubuntu.com/ubuntu/dists/jammy-updates/main/binary-amd64/by-hash/SHA256/9be23783bb2295aedcb02760ffaa8980c58573d0318ec67f2f409b8f3d2f27bb`
- `http://archive.ubuntu.com/ubuntu/dists/jammy-updates/universe/binary-amd64/by-hash/SHA256/c6a66ee7fb32ca0f0662b0b1b2a2f58ab18a10b749a8dcc61a9fc7d0fde17754`
- `http://archive.ubuntu.com/ubuntu/dists/jammy-security/universe/binary-amd64/by-hash/SHA256/86e543e7b5cccc2537a4f6451f7f9c0cd459803cf4403beca71a459848dd9a0f`
- `http://archive.ubuntu.com/ubuntu/dists/jammy-security/main/binary-amd64/by-hash/SHA2569943ee3b3104b37d0ee219fae65f261d0c61c96bb3978fe92e6573c9dcd88862`
- Processing ..... (Done 4s)
In this example, the output states what it's going to do by listing the package source locations. After it downloads them, there's a synchronization point before it has enough information to output which archives were downloaded and their SHAs and begin processing them (again asynchronously).
Alternatively, you could wrap a SubBullet
state struct in an Arc and try passing it around, or use bullet_stream
for top-level printing. Printing inside an async context could happen via println
.