#![doc = include_str!("../README.md")]
use crate::util::ParagraphInspectWrite;
use crate::write::line_mapped;
use global::GlobalWriter;
use std::fmt::Debug;
use std::io::Write;
use std::time::Instant;
use style::CMD_INDENT;
use util::TrailingParagraph;
pub use ansi_escape::strip_ansi;
#[cfg(feature = "fun_run")]
pub use fun_run;
mod ansi_escape;
mod background_printer;
mod duration_format;
mod util;
mod write;
pub mod global;
pub mod style;
pub struct GlobalTimer {
pub(crate) started: Instant,
pub(crate) guard: background_printer::PrintGuard<GlobalWriter>,
}
impl GlobalTimer {
pub fn cancel(self, why_details: impl AsRef<str>) {
let mut io = match self.guard.stop() {
Ok(io) => io,
Err(e) => std::panic::resume_unwind(e),
};
writeln_now(&mut io, style::details(why_details));
}
pub fn done(self) {
let duration = self.started.elapsed();
let mut io = match self.guard.stop() {
Ok(io) => io,
Err(e) => std::panic::resume_unwind(e),
};
writeln_now(&mut io, style::details(duration_format::human(&duration)));
}
}
#[allow(clippy::module_name_repetitions)]
#[derive(Debug)]
pub struct Print<T> {
pub(crate) started: Option<Instant>,
pub(crate) state: T,
}
#[deprecated(
since = "0.2.0",
note = "bullet_stream::Output conflicts with std::io::Output, prefer Print"
)]
pub type Output<T> = Print<T>;
pub mod state {
use crate::background_printer::PrintGuard;
use crate::util::ParagraphInspectWrite;
use crate::write::MappedWrite;
use std::time::Instant;
#[derive(Debug)]
pub struct Header<W> {
pub(crate) write: ParagraphInspectWrite<W>,
}
#[derive(Debug)]
pub struct Bullet<W> {
pub(crate) write: ParagraphInspectWrite<W>,
}
#[derive(Debug)]
pub struct SubBullet<W> {
pub(crate) write: ParagraphInspectWrite<W>,
}
#[derive(Debug)]
pub struct Stream<W: std::io::Write> {
pub(crate) started: Instant,
pub(crate) write: MappedWrite<ParagraphInspectWrite<W>>,
}
#[derive(Debug)]
pub struct Background<W: std::io::Write + Send + 'static> {
pub(crate) started: Instant,
pub(crate) write: PrintGuard<ParagraphInspectWrite<W>>,
}
}
impl Print<state::Header<GlobalWriter>> {
pub fn global() -> Print<state::Header<GlobalWriter>> {
Print {
state: state::Header {
write: ParagraphInspectWrite {
inner: GlobalWriter,
was_paragraph: GlobalWriter.trailing_paragraph(),
newlines_since_last_char: GlobalWriter.trailing_newline_count(),
},
},
started: None,
}
}
}
impl<W> Print<state::Header<W>>
where
W: Write + Send + Sync + 'static,
{
#[must_use]
pub fn new(io: W) -> Self {
Self {
state: state::Header {
write: ParagraphInspectWrite::new(io),
},
started: None,
}
}
#[must_use]
pub fn h1(mut self, s: impl AsRef<str>) -> Print<state::Bullet<W>> {
write::h1(&mut self.state.write, s);
self.without_header()
}
#[must_use]
pub fn h2(mut self, s: impl AsRef<str>) -> Print<state::Bullet<W>> {
write::h2(&mut self.state.write, s);
self.without_header()
}
#[must_use]
pub fn h3(mut self, s: impl AsRef<str>) -> Print<state::Bullet<W>> {
write::h3(&mut self.state.write, s);
self.without_header()
}
#[must_use]
pub fn without_header(self) -> Print<state::Bullet<W>> {
Print {
started: Some(Instant::now()),
state: state::Bullet {
write: self.state.write,
},
}
}
}
impl<W> Print<state::Bullet<W>>
where
W: Write + Send + Sync + 'static,
{
#[must_use]
pub fn bullet(mut self, s: impl AsRef<str>) -> Print<state::SubBullet<W>> {
write::bullet(&mut self.state.write, s);
Print {
started: self.started,
state: state::SubBullet {
write: self.state.write,
},
}
}
#[must_use]
pub fn h2(mut self, s: impl AsRef<str>) -> Print<state::Bullet<W>> {
write::h2(&mut self.state.write, s);
self
}
#[must_use]
pub fn h3(mut self, s: impl AsRef<str>) -> Print<state::Bullet<W>> {
write::h3(&mut self.state.write, s);
self
}
#[doc = include_str!("docs/stateful_error.md")]
pub fn error(mut self, s: impl AsRef<str>) -> W {
write::error(&mut self.state.write, s);
self.state.write.inner
}
#[must_use]
#[doc = include_str!("docs/stateful_warning.md")]
pub fn warning(mut self, s: impl AsRef<str>) -> Self {
write::warning(&mut self.state.write, s);
self
}
#[must_use]
#[doc = include_str!("docs/stateful_important.md")]
pub fn important(mut self, s: impl AsRef<str>) -> Self {
write::important(&mut self.state.write, s);
self
}
pub fn done(mut self) -> W {
write::all_done(&mut self.state.write, &self.started);
self.state.write.inner
}
}
impl<W> Print<state::Background<W>>
where
W: Write + Send + Sync + 'static,
{
pub fn cancel(self, why_details: impl AsRef<str>) -> Print<state::SubBullet<W>> {
let mut io = match self.state.write.stop() {
Ok(io) => io,
Err(e) => std::panic::resume_unwind(e),
};
writeln_now(&mut io, style::details(why_details));
Print {
started: self.started,
state: state::SubBullet { write: io },
}
}
pub fn done(self) -> Print<state::SubBullet<W>> {
let duration = self.state.started.elapsed();
let mut io = match self.state.write.stop() {
Ok(io) => io,
Err(e) => std::panic::resume_unwind(e),
};
writeln_now(&mut io, style::details(duration_format::human(&duration)));
Print {
started: self.started,
state: state::SubBullet { write: io },
}
}
}
impl<W> Print<state::SubBullet<W>>
where
W: Write + Send + Sync + 'static,
{
#[must_use]
pub fn sub_bullet(mut self, s: impl AsRef<str>) -> Print<state::SubBullet<W>> {
write::sub_bullet(&mut self.state.write, s);
self
}
#[must_use]
pub fn start_stream(mut self, s: impl AsRef<str>) -> Print<state::Stream<W>> {
write::sub_bullet(&mut self.state.write, s);
writeln_now(&mut self.state.write, "");
Print {
started: self.started,
state: state::Stream {
started: Instant::now(),
write: line_mapped(self.state.write, |mut line| {
if line.is_empty() || line == [b'\n'] {
line
} else {
let mut result: Vec<u8> = CMD_INDENT.into();
result.append(&mut line);
result
}
}),
},
}
}
#[allow(clippy::missing_panics_doc)]
#[must_use]
#[allow(unused_mut)]
pub fn start_timer(mut self, s: impl AsRef<str>) -> Print<state::Background<W>> {
write::sub_start_timer(self.state.write, Instant::now(), s)
}
#[cfg(feature = "fun_run")]
#[allow(unused_mut)]
pub fn time_cmd(
&mut self,
mut command: impl fun_run::CommandWithName,
) -> Result<fun_run::NamedOutput, fun_run::CmdError> {
util::mpsc_stream_to_output(
|sender| {
let start = Instant::now();
let background =
write::sub_start_print_interval(sender, style::running_command(command.name()));
let output = command.named_output();
writeln_now(
&mut background.stop().expect("constructed with valid state"),
style::details(duration_format::human(&start.elapsed())),
);
output
},
move |recv| {
for message in recv {
self.state.write.write_all(&message).expect("Writeable");
}
},
)
}
#[allow(clippy::missing_panics_doc)]
pub fn stream_with<F, T>(&mut self, s: impl AsRef<str>, f: F) -> T
where
F: FnMut(Box<dyn Write + Send + Sync>, Box<dyn Write + Send + Sync>) -> T,
T: 'static,
{
write::sub_stream_with(&mut self.state.write, s, f)
}
#[cfg(feature = "fun_run")]
pub fn stream_cmd(
&mut self,
command: impl fun_run::CommandWithName,
) -> Result<fun_run::NamedOutput, fun_run::CmdError> {
write::sub_stream_cmd(&mut self.state.write, command)
}
#[doc = include_str!("docs/stateful_error.md")]
pub fn error(mut self, s: impl AsRef<str>) -> W {
write::error(&mut self.state.write, s);
self.state.write.inner
}
#[must_use]
#[doc = include_str!("docs/stateful_warning.md")]
pub fn warning(mut self, s: impl AsRef<str>) -> Self {
write::warning(&mut self.state.write, s);
self
}
#[must_use]
#[doc = include_str!("docs/stateful_important.md")]
pub fn important(mut self, s: impl AsRef<str>) -> Self {
write::important(&mut self.state.write, s);
self
}
#[must_use]
pub fn done(self) -> Print<state::Bullet<W>> {
Print {
started: self.started,
state: state::Bullet {
write: self.state.write,
},
}
}
}
impl<W> Print<state::Stream<W>>
where
W: Write + Send + Sync + 'static,
{
#[must_use]
pub fn done(self) -> Print<state::SubBullet<W>> {
let duration = self.state.started.elapsed();
let mut output = Print {
started: self.started,
state: state::SubBullet {
write: self.state.write.unwrap(),
},
};
if !output.state.write.was_paragraph {
writeln_now(&mut output.state.write, "");
}
output.sub_bullet(format!(
"Done {}",
style::details(duration_format::human(&duration))
))
}
}
impl<W> Write for Print<state::Stream<W>>
where
W: Write,
{
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.state.write.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.state.write.flush()
}
}
fn writeln_now<D: Write>(destination: &mut D, msg: impl AsRef<str>) {
writeln!(destination, "{}", msg.as_ref()).expect("Output error: UI writer closed");
destination.flush().expect("Output error: UI writer closed");
}
#[cfg(test)]
mod test {
use super::*;
use crate::util::LockedWriter;
use ansi_escape::strip_ansi;
use fun_run::CommandWithName;
use indoc::formatdoc;
use libcnb_test::assert_contains;
use pretty_assertions::assert_eq;
use std::{fs::File, process::Command};
#[test]
fn double_h2_h2_newlines() {
let writer = Vec::new();
let output = Print::new(writer)
.h2("Header 2")
.h2("Header 2")
.h3("Header 3");
let io = output.done();
let expected = formatdoc! {"
## Header 2
## Header 2
### Header 3
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)))
}
#[test]
fn double_h1_h2_newlines() {
let writer = Vec::new();
let output = Print::new(writer).h1("Header 1").h2("Header 2");
let io = output.done();
let expected = formatdoc! {"
# Header 1
## Header 2
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)))
}
#[test]
fn h3_first() {
let writer = Vec::new();
let output = Print::new(writer).h3("Header 3");
let io = output.done();
let expected = formatdoc! {"
### Header 3
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)))
}
#[test]
fn stream_with() {
let writer = Vec::new();
let mut output = Print::new(writer)
.h2("Example Buildpack")
.bullet("Streaming");
let mut cmd = std::process::Command::new("echo");
cmd.arg("hello world");
let _result = output.stream_with(
format!("Running {}", style::command(cmd.name())),
|stdout, stderr| cmd.stream_output(stdout, stderr),
);
let io = output.done().done();
let expected = formatdoc! {"
## Example Buildpack
- Streaming
- Running `echo \"hello world\"`
hello world
- Done (< 0.1s)
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
}
#[test]
fn background_timer() {
let io = Print::new(Vec::new())
.without_header()
.bullet("Background")
.start_timer("Installing")
.done()
.done()
.done();
let expected = formatdoc! {"
- Background
- Installing ... (< 0.1s)
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
let expected = formatdoc! {"
- Background
- Installing\u{1b}[2;1m .\u{1b}[0m\u{1b}[2;1m.\u{1b}[0m\u{1b}[2;1m. \u{1b}[0m(< 0.1s)
- Done (finished in < 0.1s)
"};
assert_eq!(expected, String::from_utf8_lossy(&io));
}
#[test]
fn background_timer_dropped() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("output.txt");
let timer = Print::new(File::create(&path).unwrap())
.without_header()
.bullet("Background")
.start_timer("Installing");
drop(timer);
let expected = formatdoc! {"
- Background
- Installing ... (Error)
"};
assert_eq!(expected, strip_ansi(std::fs::read_to_string(path).unwrap()));
}
#[test]
fn write_paragraph_empty_lines() {
let io = Print::new(Vec::new())
.h1("Example Buildpack\n\n")
.warning("\n\nhello\n\n\t\t\nworld\n\n")
.bullet("Version\n\n")
.sub_bullet("Installing\n\n")
.done()
.done();
let tab_char = '\t';
let expected = formatdoc! {"
# Example Buildpack
! hello
!
! {tab_char}{tab_char}
! world
- Version
- Installing
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
}
#[test]
fn paragraph_color_codes() {
let io = Print::new(Vec::new())
.h1("Buildpack Header is Bold Purple")
.important("Important is bold cyan")
.warning("Warnings are yellow")
.error("Errors are red");
let expected = formatdoc! {"
\u{1b}[1;35m# Buildpack Header is Bold Purple\u{1b}[0m
\u{1b}[1;36m! Important is bold cyan\u{1b}[0m
\u{1b}[0;33m! Warnings are yellow\u{1b}[0m
\u{1b}[0;31m! Errors are red\u{1b}[0m
"};
assert_eq!(expected, String::from_utf8_lossy(&io));
}
#[test]
fn test_important() {
let writer = Vec::new();
let io = Print::new(writer)
.h1("Heroku Ruby Buildpack")
.important("This is important")
.done();
let expected = formatdoc! {"
# Heroku Ruby Buildpack
! This is important
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
}
#[test]
fn test_error() {
let io = Print::new(Vec::new())
.h1("Heroku Ruby Buildpack")
.error("This is an error");
let expected = formatdoc! {"
# Heroku Ruby Buildpack
! This is an error
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
}
#[test]
fn test_captures() {
let writer = Vec::new();
let mut first_stream = Print::new(writer)
.h1("Heroku Ruby Buildpack")
.bullet("Ruby version `3.1.3` from `Gemfile.lock`")
.done()
.bullet("Hello world")
.start_stream("Streaming with no newlines");
writeln!(&mut first_stream, "stuff").unwrap();
let mut second_stream = first_stream
.done()
.start_stream("Streaming with blank lines and a trailing newline");
writeln!(&mut second_stream, "foo\nbar\n\n\t\nbaz\n").unwrap();
let io = second_stream.done().done().done();
let tab_char = '\t';
let expected = formatdoc! {"
# Heroku Ruby Buildpack
- Ruby version `3.1.3` from `Gemfile.lock`
- Hello world
- Streaming with no newlines
stuff
- Done (< 0.1s)
- Streaming with blank lines and a trailing newline
foo
bar
{tab_char}
baz
- Done (< 0.1s)
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
}
#[test]
fn test_streaming_a_command() {
let writer = Vec::new();
let mut stream = Print::new(writer)
.h1("Streaming buildpack demo")
.bullet("Command streaming")
.start_stream("Streaming stuff");
let locked_writer = LockedWriter::new(stream);
std::process::Command::new("echo")
.arg("hello world")
.stream_output(locked_writer.clone(), locked_writer.clone())
.unwrap();
stream = locked_writer.unwrap();
let io = stream.done().done().done();
let actual = strip_ansi(String::from_utf8_lossy(&io));
assert_contains!(actual, " hello world\n");
}
#[test]
fn warning_after_buildpack() {
let writer = Vec::new();
let io = Print::new(writer)
.h1("RCT")
.warning("It's too crowded here\nI'm tired")
.bullet("Guest thoughts")
.sub_bullet("The jumping fountains are great")
.sub_bullet("The music is nice here")
.done()
.done();
let expected = formatdoc! {"
# RCT
! It's too crowded here
! I'm tired
- Guest thoughts
- The jumping fountains are great
- The music is nice here
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
}
#[test]
fn warning_step_padding() {
let writer = Vec::new();
let io = Print::new(writer)
.h1("RCT")
.bullet("Guest thoughts")
.sub_bullet("The scenery here is wonderful")
.warning("It's too crowded here\nI'm tired")
.sub_bullet("The jumping fountains are great")
.sub_bullet("The music is nice here")
.done()
.done();
let expected = formatdoc! {"
# RCT
- Guest thoughts
- The scenery here is wonderful
! It's too crowded here
! I'm tired
- The jumping fountains are great
- The music is nice here
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
}
#[test]
fn global_preserves_newline() {
let output = global::with_locked_writer(Vec::new(), || {
Print::global()
.h1("Genuine Joes")
.bullet("Dodge")
.sub_bullet("A ball")
.error("A wrench");
Print::global()
.without_header()
.error("It's a bold strategy, Cotton.\nLet's see if it pays off for 'em.");
});
let expected = formatdoc! {"
# Genuine Joes
- Dodge
- A ball
! A wrench
! It's a bold strategy, Cotton.
! Let's see if it pays off for 'em.
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&output)));
}
#[test]
fn double_warning_step_padding() {
let writer = Vec::new();
let output = Print::new(writer)
.h1("RCT")
.bullet("Guest thoughts")
.sub_bullet("The scenery here is wonderful");
let io = output
.warning("It's too crowded here")
.warning("I'm tired")
.sub_bullet("The jumping fountains are great")
.sub_bullet("The music is nice here")
.done()
.done();
let expected = formatdoc! {"
# RCT
- Guest thoughts
- The scenery here is wonderful
! It's too crowded here
! I'm tired
- The jumping fountains are great
- The music is nice here
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
}
#[test]
fn test_cmd() {
let writer = Vec::new();
let mut bullet = Print::new(writer)
.h2("You must obey the dance commander")
.bullet("Giving out the order for fun");
bullet
.stream_cmd(
Command::new("bash")
.arg("-c")
.arg("echo it would be awesome"),
)
.unwrap();
bullet
.time_cmd(Command::new("bash").arg("-c").arg("echo if we could dance"))
.unwrap();
let io = bullet.done().done();
let expected = formatdoc! {"
## You must obey the dance commander
- Giving out the order for fun
- Running `bash -c \"echo it would be awesome\"`
it would be awesome
- Done (< 0.1s)
- Running `bash -c \"echo if we could dance\"` ... (< 0.1s)
- Done (finished in < 0.1s)
"};
assert_eq!(expected, strip_ansi(String::from_utf8_lossy(&io)));
}
}