diff --git a/CHANGELOG.md b/CHANGELOG.md index a2bd0385..583f0fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `libherokubuildpack`: + - Added `buildpack_output` module. This will help buildpack authors provide consistent and delightful output to their buildpack users ([#721](https://github.com/heroku/libcnb.rs/pull/721)) ## [0.18.0] - 2024-02-12 diff --git a/README.md b/README.md index 849e590b..997f1968 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ That's all we need! We can now move on to finally write some buildpack code! ### Writing the Buildpack -The buildpack we're writing will be very simple. We will just log a "Hello World" message during the build +The buildpack we're writing will be very simple. We will just output a "Hello World" message during the build and set the default process type to a command that will also emit "Hello World" when the application image is run. Examples of more complex buildpacks can be found in the [examples directory](https://github.com/heroku/libcnb.rs/tree/main/examples). diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index bc7f6a25..6056fb52 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -18,7 +18,7 @@ all-features = true workspace = true [features] -default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write"] +default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write", "buildpack_output"] download = ["dep:ureq", "dep:thiserror"] digest = ["dep:sha2"] error = ["log", "dep:libcnb"] @@ -27,6 +27,7 @@ tar = ["dep:tar", "dep:flate2"] toml = ["dep:toml"] fs = ["dep:pathdiff"] command = ["write", "dep:crossbeam-utils"] +buildpack_output = [] write = [] [dependencies] @@ -47,4 +48,6 @@ toml = { workspace = true, optional = true } ureq = { version = "2.9.5", default-features = false, features = ["tls"], optional = true } [dev-dependencies] +indoc = "2.0.4" +libcnb-test = { workspace = true } tempfile = "3.10.0" diff --git a/libherokubuildpack/README.md b/libherokubuildpack/README.md index b919e4db..633aab48 100644 --- a/libherokubuildpack/README.md +++ b/libherokubuildpack/README.md @@ -24,6 +24,8 @@ The feature names line up with the modules in this crate. All features are enabl Enables helpers to achieve consistent error logging. * **log** - Enables helpers for logging. +* **buildpack_output** - + Enables helpers for user-facing buildpack output. * **tar** - Enables helpers for working with tarballs. * **toml** - diff --git a/libherokubuildpack/src/buildpack_output/ansi_escape.rs b/libherokubuildpack/src/buildpack_output/ansi_escape.rs new file mode 100644 index 00000000..1bd7ea11 --- /dev/null +++ b/libherokubuildpack/src/buildpack_output/ansi_escape.rs @@ -0,0 +1,115 @@ +/// Wraps each line in an ANSI escape sequence while preserving prior ANSI escape sequences. +/// +/// ## Why does this exist? +/// +/// When buildpack output is streamed to the user, each line is prefixed with `remote: ` by Git. +/// Any colorization of text will apply to those prefixes which is not the desired behavior. This +/// function colors lines of text while ensuring that styles are disabled at the end of each line. +/// +/// ## Supports recursive colorization +/// +/// Strings that are previously colorized will not be overridden by this function. For example, +/// if a word is already colored yellow, that word will continue to be yellow. +pub(crate) fn wrap_ansi_escape_each_line(ansi: &ANSI, body: impl AsRef) -> String { + let ansi_escape = ansi.to_str(); + body.as_ref() + .split('\n') + // If sub contents are colorized it will contain SUBCOLOR ... RESET. After the reset, + // ensure we change back to the current color + .map(|line| line.replace(RESET, &format!("{RESET}{ansi_escape}"))) // Handles nested color + // Set the main color for each line and reset after so we don't colorize `remote:` by accident + .map(|line| format!("{ansi_escape}{line}{RESET}")) + // The above logic causes redundant colors and resets, clean them up + .map(|line| line.replace(&format!("{ansi_escape}{ansi_escape}"), ansi_escape)) // Reduce useless color + .map(|line| line.replace(&format!("{ansi_escape}{RESET}"), "")) // Empty lines or where the nested color is at the end of the line + .collect::>() + .join("\n") +} + +const RESET: &str = "\x1B[0m"; +const RED: &str = "\x1B[0;31m"; +const YELLOW: &str = "\x1B[0;33m"; +const BOLD_CYAN: &str = "\x1B[1;36m"; +const BOLD_PURPLE: &str = "\x1B[1;35m"; + +#[derive(Debug)] +#[allow(clippy::upper_case_acronyms)] +pub(crate) enum ANSI { + Red, + Yellow, + BoldCyan, + BoldPurple, +} + +impl ANSI { + fn to_str(&self) -> &'static str { + match self { + ANSI::Red => RED, + ANSI::Yellow => YELLOW, + ANSI::BoldCyan => BOLD_CYAN, + ANSI::BoldPurple => BOLD_PURPLE, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn empty_line() { + let actual = wrap_ansi_escape_each_line(&ANSI::Red, "\n"); + let expected = String::from("\n"); + assert_eq!(expected, actual); + } + + #[test] + fn handles_nested_color_at_start() { + let start = wrap_ansi_escape_each_line(&ANSI::BoldCyan, "hello"); + let out = wrap_ansi_escape_each_line(&ANSI::Red, format!("{start} world")); + let expected = format!("{RED}{BOLD_CYAN}hello{RESET}{RED} world{RESET}"); + + assert_eq!(expected, out); + } + + #[test] + fn handles_nested_color_in_middle() { + let middle = wrap_ansi_escape_each_line(&ANSI::BoldCyan, "middle"); + let out = wrap_ansi_escape_each_line(&ANSI::Red, format!("hello {middle} color")); + let expected = format!("{RED}hello {BOLD_CYAN}middle{RESET}{RED} color{RESET}"); + assert_eq!(expected, out); + } + + #[test] + fn handles_nested_color_at_end() { + let end = wrap_ansi_escape_each_line(&ANSI::BoldCyan, "world"); + let out = wrap_ansi_escape_each_line(&ANSI::Red, format!("hello {end}")); + let expected = format!("{RED}hello {BOLD_CYAN}world{RESET}"); + + assert_eq!(expected, out); + } + + #[test] + fn handles_double_nested_color() { + let inner = wrap_ansi_escape_each_line(&ANSI::BoldCyan, "inner"); + let outer = wrap_ansi_escape_each_line(&ANSI::Red, format!("outer {inner}")); + let out = wrap_ansi_escape_each_line(&ANSI::Yellow, format!("hello {outer}")); + let expected = format!("{YELLOW}hello {RED}outer {BOLD_CYAN}inner{RESET}"); + + assert_eq!(expected, out); + } + + #[test] + fn splits_newlines() { + let actual = wrap_ansi_escape_each_line(&ANSI::Red, "hello\nworld"); + let expected = format!("{RED}hello{RESET}\n{RED}world{RESET}"); + + assert_eq!(expected, actual); + } + + #[test] + fn simple_case() { + let actual = wrap_ansi_escape_each_line(&ANSI::Red, "hello world"); + assert_eq!(format!("{RED}hello world{RESET}"), actual); + } +} diff --git a/libherokubuildpack/src/buildpack_output/duration_format.rs b/libherokubuildpack/src/buildpack_output/duration_format.rs new file mode 100644 index 00000000..927e572e --- /dev/null +++ b/libherokubuildpack/src/buildpack_output/duration_format.rs @@ -0,0 +1,66 @@ +use std::time::Duration; + +pub(crate) fn human(duration: &Duration) -> String { + let hours = (duration.as_secs() / 3600) % 60; + let minutes = (duration.as_secs() / 60) % 60; + let seconds = duration.as_secs() % 60; + let milliseconds = duration.subsec_millis(); + let tenths = milliseconds / 100; + + if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else if seconds > 0 || milliseconds >= 100 { + format!("{seconds}.{tenths}s") + } else { + String::from("< 0.1s") + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_display_duration() { + let duration = Duration::ZERO; + assert_eq!(human(&duration), "< 0.1s"); + + let duration = Duration::from_millis(99); + assert_eq!(human(&duration), "< 0.1s"); + + let duration = Duration::from_millis(100); + assert_eq!(human(&duration), "0.1s"); + + let duration = Duration::from_millis(210); + assert_eq!(human(&duration), "0.2s"); + + let duration = Duration::from_millis(1100); + assert_eq!(human(&duration), "1.1s"); + + let duration = Duration::from_millis(9100); + assert_eq!(human(&duration), "9.1s"); + + let duration = Duration::from_millis(10100); + assert_eq!(human(&duration), "10.1s"); + + let duration = Duration::from_millis(52100); + assert_eq!(human(&duration), "52.1s"); + + let duration = Duration::from_millis(60 * 1000); + assert_eq!(human(&duration), "1m 0s"); + + let duration = Duration::from_millis(60 * 1000 + 2000); + assert_eq!(human(&duration), "1m 2s"); + + let duration = Duration::from_millis(60 * 60 * 1000 - 1); + assert_eq!(human(&duration), "59m 59s"); + + let duration = Duration::from_millis(60 * 60 * 1000); + assert_eq!(human(&duration), "1h 0m 0s"); + + let duration = Duration::from_millis(75 * 60 * 1000 - 1); + assert_eq!(human(&duration), "1h 14m 59s"); + } +} diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs new file mode 100644 index 00000000..9ea7ee6b --- /dev/null +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -0,0 +1,865 @@ +//! # Buildpack output +//! +//! Use [`BuildpackOutput`] to output structured text as a buildpack executes. The buildpack output +//! is intended to be read by the application user running your buildpack against their application. +//! +//! ```rust +//! use libherokubuildpack::buildpack_output::BuildpackOutput; +//! +//! let mut output = BuildpackOutput::new(std::io::stdout()) +//! .start("Example Buildpack") +//! .warning("No Gemfile.lock found"); +//! +//! output = output +//! .section("Ruby version") +//! .finish(); +//! +//! output.finish(); +//! ``` +//! +//! ## Colors +//! +//! In nature, colors and contrasts are used to emphasize differences and danger. [`BuildpackOutput`] +//! 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. +//! +//! ## Consistent indentation and newlines +//! +//! Help your users focus on what's happening, not on inconsistent formatting. The [`BuildpackOutput`] +//! 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. + +use crate::buildpack_output::ansi_escape::ANSI; +use crate::buildpack_output::util::{prefix_first_rest_lines, prefix_lines, ParagraphInspectWrite}; +use crate::write::line_mapped; +use std::fmt::Debug; +use std::io::Write; +use std::time::Instant; + +mod ansi_escape; +mod duration_format; +pub mod style; +mod util; + +/// Use [`BuildpackOutput`] to output structured text as a buildpack executes. The buildpack output +/// is intended to be read by the application user running your buildpack against their application. +/// +/// ```rust +/// use libherokubuildpack::buildpack_output::BuildpackOutput; +/// +/// let mut output = BuildpackOutput::new(std::io::stdout()) +/// .start("Example Buildpack") +/// .warning("No Gemfile.lock found"); +/// +/// output = output +/// .section("Ruby version") +/// .finish(); +/// +/// output.finish(); +/// ``` +#[allow(clippy::module_name_repetitions)] +#[derive(Debug)] +pub struct BuildpackOutput { + pub(crate) started: Option, + pub(crate) state: T, +} + +/// Various states for [`BuildpackOutput`] to contain. +/// +/// The [`BuildpackOutput`] struct acts as an output state machine. These structs +/// represent the various states. See struct documentation for more details. +pub mod state { + use crate::buildpack_output::util::ParagraphInspectWrite; + use crate::write::MappedWrite; + use std::time::Instant; + + /// An initialized buildpack output that has not announced its start. + /// + /// It is represented by the `state::NotStarted` type and is transitioned into a `state::Started` type. + /// + /// Example: + /// + /// ```rust + /// use libherokubuildpack::buildpack_output::{BuildpackOutput, state::{NotStarted, Started}}; + /// use std::io::Write; + /// + /// let mut not_started = BuildpackOutput::new(std::io::stdout()); + /// let output = start_buildpack(not_started); + /// + /// output.section("Ruby version").step("Installing Ruby").finish(); + /// + /// fn start_buildpack(mut output: BuildpackOutput>) -> BuildpackOutput> + /// where W: Write + Send + Sync + 'static { + /// output.start("Example Buildpack") + ///} + /// ``` + #[derive(Debug)] + pub struct NotStarted { + pub(crate) write: ParagraphInspectWrite, + } + + /// After the buildpack output has started, its top-level output will be represented by the + /// `state::Started` type and is transitioned into a `state::Section` to provide additional + /// details. + /// + /// Example: + /// + /// ```rust + /// use libherokubuildpack::buildpack_output::{BuildpackOutput, state::{Started, Section}}; + /// use std::io::Write; + /// + /// let mut output = BuildpackOutput::new(std::io::stdout()) + /// .start("Example Buildpack"); + /// + /// output = install_ruby(output).finish(); + /// + /// fn install_ruby(mut output: BuildpackOutput>) -> BuildpackOutput> + /// where W: Write + Send + Sync + 'static { + /// let out = output.section("Ruby version") + /// .step("Installing Ruby"); + /// // ... + /// out + ///} + /// ``` + #[derive(Debug)] + pub struct Started { + pub(crate) write: ParagraphInspectWrite, + } + + /// The `state::Section` is intended to provide additional details about the buildpack's + /// actions. When a section is finished, it transitions back to a `state::Started` type. + /// + /// A streaming type can be started from a `state::Section`, usually to run and stream a + /// `process::Command` to the end user. + /// + /// Example: + /// + /// ```rust + /// use libherokubuildpack::buildpack_output::{BuildpackOutput, state::{Started, Section}}; + /// use std::io::Write; + /// + /// let mut output = BuildpackOutput::new(std::io::stdout()) + /// .start("Example Buildpack") + /// .section("Ruby version"); + /// + /// install_ruby(output).finish(); + /// + /// fn install_ruby(mut output: BuildpackOutput>) -> BuildpackOutput> + /// where W: Write + Send + Sync + 'static { + /// let output = output.step("Installing Ruby"); + /// // ... + /// + /// output.finish() + ///} + /// ``` + #[derive(Debug)] + pub struct Section { + pub(crate) write: ParagraphInspectWrite, + } + + /// A this state is intended for streaming output from a process to the end user. It is + /// started from a `state::Section` and finished back to a `state::Section`. + /// + /// The `BuildpackOutput>` implements [`std::io::Write`], so you can stream + /// from anything that accepts a [`std::io::Write`]. + /// + /// ```rust + /// use libherokubuildpack::buildpack_output::{BuildpackOutput, state::{Started, Section}}; + /// use std::io::Write; + /// + /// let mut output = BuildpackOutput::new(std::io::stdout()) + /// .start("Example Buildpack") + /// .section("Ruby version"); + /// + /// install_ruby(output).finish(); + /// + /// fn install_ruby(mut output: BuildpackOutput>) -> BuildpackOutput> + /// where W: Write + Send + Sync + 'static { + /// let mut stream = output.step("Installing Ruby") + /// .start_stream("Streaming stuff"); + /// + /// write!(&mut stream, "...").unwrap(); + /// + /// stream.finish() + ///} + /// ``` + pub struct Stream { + pub(crate) started: Instant, + pub(crate) write: MappedWrite>, + } +} + +trait AnnounceSupportedState { + type Inner: Write; + + fn write_mut(&mut self) -> &mut ParagraphInspectWrite; +} + +impl AnnounceSupportedState for state::Section +where + W: Write, +{ + type Inner = W; + + fn write_mut(&mut self) -> &mut ParagraphInspectWrite { + &mut self.write + } +} + +impl AnnounceSupportedState for state::Started +where + W: Write, +{ + type Inner = W; + + fn write_mut(&mut self) -> &mut ParagraphInspectWrite { + &mut self.write + } +} + +#[allow(private_bounds)] +impl BuildpackOutput +where + S: AnnounceSupportedState, +{ + /// Emit an error and end the build output. + /// + /// When an unrecoverable situation is encountered, you can emit an error message to the user. + /// This associated function will consume the build output, so you may only emit one error per + /// build output. + /// + /// An error message should describe what went wrong and why the buildpack cannot continue. + /// It is best practice to include debugging information in the error message. For example, + /// if a file is missing, consider showing the user the contents of the directory where the + /// file was expected to be and the full path of the file. + /// + /// If you are confident about what action needs to be taken to fix the error, you should include + /// that in the error message. Do not write a generic suggestion like "try again later" unless + /// you are certain that the error is transient. + /// + /// If you detect something problematic but not bad enough to halt buildpack execution, consider + /// using a [`BuildpackOutput::warning`] instead. + pub fn error(mut self, s: impl AsRef) { + self.write_paragraph(&ANSI::Red, s); + } + + /// Emit a warning message to the end user. + /// + /// A warning should be used to emit a message to the end user about a potential problem. + /// + /// Multiple warnings can be emitted in sequence. The buildpack author should take care not to + /// overwhelm the end user with unnecessary warnings. + /// + /// When emitting a warning, describe the problem to the user, if possible, and tell them how + /// to fix it or where to look next. + /// + /// Warnings should often come with some disabling mechanism, if possible. If the user can turn + /// off the warning, that information should be included in the warning message. If you're + /// confident that the user should not be able to turn off a warning, consider using a + /// [`BuildpackOutput::error`] instead. + /// + /// Warnings will be output in a multi-line paragraph style. A warning can be emitted from any + /// state except for [`state::NotStarted`]. + #[must_use] + pub fn warning(mut self, s: impl AsRef) -> BuildpackOutput { + self.write_paragraph(&ANSI::Yellow, s); + self + } + + /// Emit an important message to the end user. + /// + /// When something significant happens but is not inherently negative, you can use an important + /// message. For example, if a buildpack detects that the operating system or architecture has + /// changed since the last build, it might not be a problem, but if something goes wrong, the + /// user should know about it. + /// + /// Important messages should be used sparingly and only for things the user should be aware of + /// but not necessarily act on. If the message is actionable, consider using a + /// [`BuildpackOutput::warning`] instead. + #[must_use] + pub fn important(mut self, s: impl AsRef) -> BuildpackOutput { + self.write_paragraph(&ANSI::BoldCyan, s); + self + } + + fn write_paragraph(&mut self, color: &ANSI, s: impl AsRef) { + let io = self.state.write_mut(); + let contents = s.as_ref().trim(); + + if !io.was_paragraph { + writeln_now(io, ""); + } + + writeln_now( + io, + ansi_escape::wrap_ansi_escape_each_line( + color, + prefix_lines(contents, |_, line| { + // Avoid adding trailing whitespace to the line, if there was none already. + // The `\n` case is required since `prefix_lines` uses `str::split_inclusive`, + // which preserves any trailing newline characters if present. + if line.is_empty() || line == "\n" { + String::from("!") + } else { + String::from("! ") + } + }), + ), + ); + writeln_now(io, ""); + } +} + +impl BuildpackOutput> +where + W: Write, +{ + /// Create a buildpack output struct, but do not announce the buildpack's start. + /// + /// See the [`BuildpackOutput::start`] method for more details. + #[must_use] + pub fn new(io: W) -> Self { + Self { + state: state::NotStarted { + write: ParagraphInspectWrite::new(io), + }, + started: None, + } + } + + /// Announce the start of the buildpack. + /// + /// The input should be the human-readable name of your buildpack. Most buildpack names include + /// the feature they provide. + /// + /// It is common to use a title case for the buildpack name and to include the word "Buildpack" at the end. + /// For example, `Ruby Buildpack`. Do not include a period at the end of the name. + /// + /// Avoid starting your buildpack with "Heroku" unless you work for Heroku. If you wish to express that your + /// buildpack is built to target only Heroku; you can include that in the description of the buildpack. + /// + /// This function will transition your buildpack output to [`state::Started`]. + #[must_use] + pub fn start(mut self, buildpack_name: impl AsRef) -> BuildpackOutput> { + writeln_now( + &mut self.state.write, + ansi_escape::wrap_ansi_escape_each_line( + &ANSI::BoldPurple, + format!("\n# {}\n", buildpack_name.as_ref().trim()), + ), + ); + + self.start_silent() + } + + /// Start a buildpack output without announcing the name. + #[must_use] + pub fn start_silent(self) -> BuildpackOutput> { + BuildpackOutput { + started: Some(Instant::now()), + state: state::Started { + write: self.state.write, + }, + } + } +} + +impl BuildpackOutput> +where + W: Write + Send + Sync + 'static, +{ + const PREFIX_FIRST: &'static str = "- "; + const PREFIX_REST: &'static str = " "; + + fn style(s: impl AsRef) -> String { + prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref().trim()) + } + + /// Begin a new section of the buildpack output. + /// + /// A section should be a noun, e.g., 'Ruby version'. Anything emitted within the section + /// should be in the context of this output. + /// + /// If the following steps can change based on input, consider grouping shared information + /// such as version numbers and sources in the section name e.g., + /// 'Ruby version ``3.1.3`` from ``Gemfile.lock``'. + /// + /// This function will transition your buildpack output to [`state::Section`]. + #[must_use] + pub fn section(mut self, s: impl AsRef) -> BuildpackOutput> { + writeln_now(&mut self.state.write, Self::style(s)); + + BuildpackOutput { + started: self.started, + state: state::Section { + write: self.state.write, + }, + } + } + + /// Announce that your buildpack has finished execution successfully. + pub fn finish(mut self) -> W { + if let Some(started) = &self.started { + let elapsed = duration_format::human(&started.elapsed()); + let details = style::details(format!("finished in {elapsed}")); + writeln_now( + &mut self.state.write, + Self::style(format!("Done {details}")), + ); + } else { + writeln_now(&mut self.state.write, Self::style("Done")); + } + + self.state.write.inner + } +} + +impl BuildpackOutput> +where + W: Write + Send + Sync + 'static, +{ + const PREFIX_FIRST: &'static str = " - "; + const PREFIX_REST: &'static str = " "; + const CMD_INDENT: &'static str = " "; + + fn style(s: impl AsRef) -> String { + prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref().trim()) + } + + /// Emit a step in the buildpack output within a section. + /// + /// A step should be a verb, i.e., 'Downloading'. Related verbs should be nested under a single section. + /// + /// Some example verbs to use: + /// + /// - Downloading + /// - Writing + /// - Using + /// - Reading + /// - Clearing + /// - Skipping + /// - Detecting + /// - Compiling + /// - etc. + /// + /// Steps should be short and stand-alone sentences within the context of the section header. + /// + /// In general, if the buildpack did something different between two builds, it should be + /// observable by the user through the buildpack output. For example, if a cache needs to be + /// cleared, emit that your buildpack is clearing it and why. + /// + /// Multiple steps are allowed within a section. This function returns to the same [`state::Section`]. + #[must_use] + pub fn step(mut self, s: impl AsRef) -> BuildpackOutput> { + writeln_now(&mut self.state.write, Self::style(s)); + self + } + + /// Stream output to the end user. + /// + /// The most common use case is to stream the output of a running `std::process::Command` to the + /// end user. Streaming lets the end user know that something is happening and provides them with + /// the output of the process. + /// + /// The result of this function is a `BuildpackOutput>` which implements [`std::io::Write`]. + /// + /// If you do not wish the end user to view the output of the process, consider using a `step` instead. + /// + /// This function will transition your buildpack output to [`state::Stream`]. + #[must_use] + pub fn start_stream(mut self, s: impl AsRef) -> BuildpackOutput> { + writeln_now(&mut self.state.write, Self::style(s)); + writeln_now(&mut self.state.write, ""); + + BuildpackOutput { + started: self.started, + state: state::Stream { + started: Instant::now(), + write: line_mapped(self.state.write, |mut line| { + // Avoid adding trailing whitespace to the line, if there was none already. + // The `[b'\n']` case is required since `line` includes the trailing newline byte. + if line.is_empty() || line == [b'\n'] { + line + } else { + let mut result: Vec = Self::CMD_INDENT.into(); + result.append(&mut line); + result + } + }), + }, + } + } + + /// Finish a section and transition back to [`state::Started`]. + pub fn finish(self) -> BuildpackOutput> { + BuildpackOutput { + started: self.started, + state: state::Started { + write: self.state.write, + }, + } + } +} + +impl BuildpackOutput> +where + W: Write + Send + Sync + 'static, +{ + /// Finalize a stream's output + /// + /// Once you're finished streaming to the output, calling this function + /// finalizes the stream's output and transitions back to a [`state::Section`]. + pub fn finish(self) -> BuildpackOutput> { + let duration = self.state.started.elapsed(); + + let mut output = BuildpackOutput { + started: self.started, + state: state::Section { + write: self.state.write.unwrap(), + }, + }; + + if !output.state.write_mut().was_paragraph { + writeln_now(&mut output.state.write, ""); + } + + output.step(format!( + "Done {}", + style::details(duration_format::human(&duration)) + )) + } +} + +impl Write for BuildpackOutput> +where + W: Write, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.state.write.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.state.write.flush() + } +} + +/// Internal helper, ensures that all contents are always flushed (never buffered). +fn writeln_now(destination: &mut D, msg: impl AsRef) { + 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::buildpack_output::util::LockedWriter; + use crate::command::CommandExt; + use indoc::formatdoc; + use libcnb_test::assert_contains; + use std::fs::File; + + #[test] + fn write_paragraph_empty_lines() { + let io = BuildpackOutput::new(Vec::new()) + .start("Example Buildpack\n\n") + .warning("\n\nhello\n\n\t\t\nworld\n\n") + .section("Version\n\n") + .step("Installing\n\n") + .finish() + .finish(); + + 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_escape_sequences(String::from_utf8_lossy(&io)) + ); + } + + #[test] + fn paragraph_color_codes() { + let tmpdir = tempfile::tempdir().unwrap(); + let path = tmpdir.path().join("output.txt"); + + BuildpackOutput::new(File::create(&path).unwrap()) + .start("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, std::fs::read_to_string(path).unwrap()); + } + + #[test] + fn test_important() { + let writer = Vec::new(); + let io = BuildpackOutput::new(writer) + .start("Heroku Ruby Buildpack") + .important("This is important") + .finish(); + + let expected = formatdoc! {" + + # Heroku Ruby Buildpack + + ! This is important + + - Done (finished in < 0.1s) + "}; + + assert_eq!( + expected, + strip_ansi_escape_sequences(String::from_utf8_lossy(&io)) + ); + } + + #[test] + fn test_error() { + let tmpdir = tempfile::tempdir().unwrap(); + let path = tmpdir.path().join("output.txt"); + + BuildpackOutput::new(File::create(&path).unwrap()) + .start("Heroku Ruby Buildpack") + .error("This is an error"); + + let expected = formatdoc! {" + + # Heroku Ruby Buildpack + + ! This is an error + + "}; + + assert_eq!( + expected, + strip_ansi_escape_sequences(std::fs::read_to_string(path).unwrap()) + ); + } + + #[test] + fn test_captures() { + let writer = Vec::new(); + let mut first_stream = BuildpackOutput::new(writer) + .start("Heroku Ruby Buildpack") + .section("Ruby version `3.1.3` from `Gemfile.lock`") + .finish() + .section("Hello world") + .start_stream("Streaming with no newlines"); + + writeln!(&mut first_stream, "stuff").unwrap(); + + let mut second_stream = first_stream + .finish() + .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.finish().finish().finish(); + + let tab_char = '\t'; + // TODO: See if there is a way to remove the additional newlines in the trailing newline case. + 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_escape_sequences(String::from_utf8_lossy(&io)) + ); + } + + #[test] + fn test_streaming_a_command() { + let writer = Vec::new(); + let mut stream = BuildpackOutput::new(writer) + .start("Streaming buildpack demo") + .section("Command streaming") + .start_stream("Streaming stuff"); + + let locked_writer = LockedWriter::new(stream); + + std::process::Command::new("echo") + .arg("hello world") + .output_and_write_streams(locked_writer.clone(), locked_writer.clone()) + .unwrap(); + + stream = locked_writer.unwrap(); + + let io = stream.finish().finish().finish(); + + let actual = strip_ansi_escape_sequences(String::from_utf8_lossy(&io)); + + assert_contains!(actual, " hello world\n"); + } + + #[test] + fn warning_after_buildpack() { + let writer = Vec::new(); + let io = BuildpackOutput::new(writer) + .start("RCT") + .warning("It's too crowded here\nI'm tired") + .section("Guest thoughts") + .step("The jumping fountains are great") + .step("The music is nice here") + .finish() + .finish(); + + 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_escape_sequences(String::from_utf8_lossy(&io)) + ); + } + + #[test] + fn warning_step_padding() { + let writer = Vec::new(); + let io = BuildpackOutput::new(writer) + .start("RCT") + .section("Guest thoughts") + .step("The scenery here is wonderful") + .warning("It's too crowded here\nI'm tired") + .step("The jumping fountains are great") + .step("The music is nice here") + .finish() + .finish(); + + 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_escape_sequences(String::from_utf8_lossy(&io)) + ); + } + + #[test] + fn double_warning_step_padding() { + let writer = Vec::new(); + let output = BuildpackOutput::new(writer) + .start("RCT") + .section("Guest thoughts") + .step("The scenery here is wonderful"); + + let io = output + .warning("It's too crowded here") + .warning("I'm tired") + .step("The jumping fountains are great") + .step("The music is nice here") + .finish() + .finish(); + + 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_escape_sequences(String::from_utf8_lossy(&io)) + ); + } + + fn strip_ansi_escape_sequences(contents: impl AsRef) -> String { + let mut result = String::new(); + let mut in_ansi_escape = false; + for char in contents.as_ref().chars() { + if in_ansi_escape { + if char == 'm' { + in_ansi_escape = false; + continue; + } + } else { + if char == '\x1B' { + in_ansi_escape = true; + continue; + } + + result.push(char); + } + } + + result + } +} diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs new file mode 100644 index 00000000..08deb109 --- /dev/null +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -0,0 +1,28 @@ +//! Helpers for formatting and colorizing your output. + +use crate::buildpack_output::ansi_escape::{self, ANSI}; + +/// Decorate a URL for the build output. +pub fn url(contents: impl AsRef) -> String { + ansi_escape::wrap_ansi_escape_each_line(&ANSI::BoldCyan, contents) +} + +/// Decorate the name of a command being run i.e. `bundle install`. +pub fn command(contents: impl AsRef) -> String { + value(ansi_escape::wrap_ansi_escape_each_line( + &ANSI::BoldCyan, + contents, + )) +} + +/// Decorate an important value i.e. `2.3.4`. +pub fn value(contents: impl AsRef) -> String { + let contents = ansi_escape::wrap_ansi_escape_each_line(&ANSI::Yellow, contents); + format!("`{contents}`") +} + +/// Decorate additional information at the end of a line. +pub fn details(contents: impl AsRef) -> String { + let contents = contents.as_ref(); + format!("({contents})") +} diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs new file mode 100644 index 00000000..ba8154d3 --- /dev/null +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -0,0 +1,229 @@ +use std::fmt::Debug; +use std::io::Write; + +#[cfg(test)] +use std::sync::{Arc, Mutex}; + +/// Applies a prefix to the first line and a different prefix to the rest of the lines. +/// +/// The primary use case is to align indentation with the prefix of the first line. Most often +/// for emitting indented bullet point lists. +/// +/// The first prefix is always applied, even when the contents are empty. This default was +/// chosen to ensure that a nested-bullet point will always follow a parent bullet point, +/// even if that parent has no text. +pub(crate) fn prefix_first_rest_lines( + first_prefix: &str, + rest_prefix: &str, + contents: &str, +) -> String { + prefix_lines(contents, move |index, _| { + if index == 0 { + String::from(first_prefix) + } else { + String::from(rest_prefix) + } + }) +} + +/// Prefixes each line of input. +/// +/// Each line of the provided string slice will be passed to the provided function along with +/// the index of the line. The function should return a string that will be prepended to the line. +/// +/// If an empty string is provided, a prefix will still be added to improve UX in cases +/// where the caller forgot to pass a non-empty string. +pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { + // `split_inclusive` yields `None` for the empty string, so we have to explicitly add the prefix. + if contents.is_empty() { + f(0, "") + } else { + contents + .split_inclusive('\n') + .enumerate() + .map(|(line_index, line)| { + let prefix = f(line_index, line); + prefix + line + }) + .collect() + } +} + +/// A trailing newline aware writer. +/// +/// A paragraph style block of text has an empty newline before and after the text. +/// When multiple paragraphs are emitted, it's important that they don't double up on empty +/// newlines or the output will look off. +/// +/// This writer seeks to solve that problem by preserving knowledge of prior newline writes and +/// exposing that information to the caller. +#[derive(Debug)] +pub(crate) struct ParagraphInspectWrite { + pub(crate) inner: W, + pub(crate) was_paragraph: bool, + pub(crate) newlines_since_last_char: usize, +} + +impl ParagraphInspectWrite { + pub(crate) fn new(io: W) -> Self { + Self { + inner: io, + newlines_since_last_char: 0, + was_paragraph: false, + } + } +} + +impl Write for ParagraphInspectWrite { + /// We need to track newlines across multiple writes to eliminate the double empty newline + /// problem described above. + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let trailing_newline_count = buf.iter().rev().take_while(|&&c| c == b'\n').count(); + // The buffer contains only newlines + if buf.len() == trailing_newline_count { + self.newlines_since_last_char += trailing_newline_count; + } else { + self.newlines_since_last_char = trailing_newline_count; + } + + self.was_paragraph = self.newlines_since_last_char > 1; + self.inner.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +#[cfg(test)] +#[derive(Debug)] +pub(crate) struct LockedWriter { + arc: Arc>, +} + +#[cfg(test)] +impl Clone for LockedWriter { + fn clone(&self) -> Self { + Self { + arc: self.arc.clone(), + } + } +} + +#[cfg(test)] +impl LockedWriter { + pub(crate) fn new(write: W) -> Self { + LockedWriter { + arc: Arc::new(Mutex::new(write)), + } + } + + pub(crate) fn unwrap(self) -> W { + let Ok(mutex) = Arc::try_unwrap(self.arc) else { + panic!("Expected buildpack author to not retain any IO streaming IO instances") + }; + + mutex + .into_inner() + .expect("Thread holding locked writer should not panic") + } +} + +#[cfg(test)] +impl Write for LockedWriter +where + W: Write + Send + Sync + 'static, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut io = self + .arc + .lock() + .expect("Thread holding locked writer should not panic"); + io.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + let mut io = self + .arc + .lock() + .expect("Thread holding locked writer should not panic"); + io.flush() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[allow(clippy::write_with_newline)] + fn test_paragraph_inspect_write() { + use std::io::Write; + + let buffer: Vec = vec![]; + let mut inspect_write = ParagraphInspectWrite::new(buffer); + assert!(!inspect_write.was_paragraph); + + write!(&mut inspect_write, "Hello World").unwrap(); + assert!(!inspect_write.was_paragraph); + + write!(&mut inspect_write, "").unwrap(); + assert!(!inspect_write.was_paragraph); + + write!(&mut inspect_write, "\n\nHello World!\n").unwrap(); + assert!(!inspect_write.was_paragraph); + + write!(&mut inspect_write, "Hello World!\n").unwrap(); + assert!(!inspect_write.was_paragraph); + + write!(&mut inspect_write, "Hello World!\n\n").unwrap(); + assert!(inspect_write.was_paragraph); + + write!(&mut inspect_write, "End.\n").unwrap(); + assert!(!inspect_write.was_paragraph); + + // Double end, on multiple writes + write!(&mut inspect_write, "End.\n").unwrap(); + write!(&mut inspect_write, "\n").unwrap(); + assert!(inspect_write.was_paragraph); + + write!(&mut inspect_write, "- The scenery here is wonderful\n").unwrap(); + write!(&mut inspect_write, "\n").unwrap(); + assert!(inspect_write.was_paragraph); + } + + #[test] + fn test_prefix_first_rest_lines() { + assert_eq!("- hello", &prefix_first_rest_lines("- ", " ", "hello")); + assert_eq!( + "- hello\n world", + &prefix_first_rest_lines("- ", " ", "hello\nworld") + ); + assert_eq!( + "- hello\n world\n", + &prefix_first_rest_lines("- ", " ", "hello\nworld\n") + ); + + assert_eq!("- ", &prefix_first_rest_lines("- ", " ", "")); + + assert_eq!( + "- hello\n \n world", + &prefix_first_rest_lines("- ", " ", "hello\n\nworld") + ); + } + + #[test] + fn test_prefix_lines() { + assert_eq!( + "- hello\n- world\n", + &prefix_lines("hello\nworld\n", |_, _| String::from("- ")) + ); + assert_eq!( + "0: hello\n1: world\n", + &prefix_lines("hello\nworld\n", |index, _| { format!("{index}: ") }) + ); + assert_eq!("- ", &prefix_lines("", |_, _| String::from("- "))); + assert_eq!("- \n", &prefix_lines("\n", |_, _| String::from("- "))); + assert_eq!("- \n- \n", &prefix_lines("\n\n", |_, _| String::from("- "))); + } +} diff --git a/libherokubuildpack/src/lib.rs b/libherokubuildpack/src/lib.rs index 60a9c376..c742a325 100644 --- a/libherokubuildpack/src/lib.rs +++ b/libherokubuildpack/src/lib.rs @@ -1,5 +1,7 @@ #![doc = include_str!("../README.md")] +#[cfg(feature = "buildpack_output")] +pub mod buildpack_output; #[cfg(feature = "command")] pub mod command; #[cfg(feature = "digest")]