diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b543df2..5e431f86 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] +### Changed + +- `libherokubuildpack`: + - Removed `buildpack_output` module. This functionality from ([#721](https://github.com/heroku/libcnb.rs/pull/721)) was experimental. The API was not stable and it is being removed. A similar API is available at [bullet_stream](https://crates.io/crates/bullet_stream). ([#852](https://github.com/heroku/libcnb.rs/pull/852) ## [0.25.0] - 2024-10-23 diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index 0acc7601..95bcceb2 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -18,7 +18,7 @@ all-features = true workspace = true [features] -default = ["command", "download", "digest", "error", "inventory", "log", "inventory-semver", "inventory-sha2", "tar", "toml", "fs", "write", "buildpack_output"] +default = ["command", "download", "digest", "error", "inventory", "log", "inventory-semver", "inventory-sha2", "tar", "toml", "fs", "write"] download = ["dep:ureq", "dep:thiserror"] digest = ["dep:sha2"] error = ["log", "dep:libcnb"] @@ -30,7 +30,6 @@ tar = ["dep:tar", "dep:flate2"] toml = ["dep:toml"] fs = ["dep:pathdiff"] command = ["write", "dep:crossbeam-utils"] -buildpack_output = [] write = [] [dependencies] @@ -54,7 +53,5 @@ toml = { workspace = true, optional = true } ureq = { version = "2.10.1", default-features = false, features = ["tls"], optional = true } [dev-dependencies] -indoc = "2.0.5" -libcnb-test = { workspace = true } serde_test = "1.0.177" tempfile = "3.13.0" diff --git a/libherokubuildpack/README.md b/libherokubuildpack/README.md index 0bb94908..4961d5ad 100644 --- a/libherokubuildpack/README.md +++ b/libherokubuildpack/README.md @@ -4,8 +4,8 @@ Common utilities for buildpacks written with [libcnb.rs](https://github.com/hero only used for official Heroku buildpacks. It was moved into the libcnb.rs repository as an incubator for utilities that might find their way into libcnb.rs proper. -This crate is optional and not required to write buildpacks with libcnb.rs. It provides helpers that buildpack authors -commonly need. Examples are digest generation, filesystem utilities, HTTP download helpers and tarball extraction. +This crate is optional and not required to write buildpacks with libcnb.rs. It provides helpers that buildpack authors +commonly need. Examples are digest generation, filesystem utilities, HTTP download helpers and tarball extraction. ## Crate Features @@ -30,8 +30,6 @@ The feature names line up with the modules in this crate. All features are enabl Enables inventory helpers to work with `sha2::Sha256` and `sha2::Sha512`. * `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 deleted file mode 100644 index 1bd7ea11..00000000 --- a/libherokubuildpack/src/buildpack_output/ansi_escape.rs +++ /dev/null @@ -1,115 +0,0 @@ -/// 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 deleted file mode 100644 index 927e572e..00000000 --- a/libherokubuildpack/src/buildpack_output/duration_format.rs +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index b61d8b40..00000000 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ /dev/null @@ -1,865 +0,0 @@ -//! # 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, - } - - /// 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() - ///} - /// ``` - #[derive(Debug)] - 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'; - 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 deleted file mode 100644 index 08deb109..00000000 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! 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 deleted file mode 100644 index 8a147846..00000000 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ /dev/null @@ -1,229 +0,0 @@ -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::new(); - 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 0596a3c6..0e23e153 100644 --- a/libherokubuildpack/src/lib.rs +++ b/libherokubuildpack/src/lib.rs @@ -1,7 +1,5 @@ #![doc = include_str!("../README.md")] -#[cfg(feature = "buildpack_output")] -pub mod buildpack_output; #[cfg(feature = "command")] pub mod command; #[cfg(feature = "digest")]