From 86fba2fe54c78dc9bb03b245b53646bb266a8af4 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Mon, 6 Nov 2023 14:42:11 -0600 Subject: [PATCH 01/99] Port build output from Ruby build pack This is a port of https://github.com/heroku/buildpacks-ruby/tree/016ee969647afc6aa9565ff3a44594e308f83380/commons/src/output from the Ruby build pack. The goal is to provide a standard format for outputting build information to the end user while a buildpack is running. --- .github/workflows/ci.yml | 18 + CHANGELOG.md | 2 + libherokubuildpack/Cargo.toml | 14 +- .../examples/print_style_guide.rs | 228 ++++++ libherokubuildpack/src/lib.rs | 7 + .../src/output/background_timer.rs | 157 ++++ libherokubuildpack/src/output/build_log.rs | 710 ++++++++++++++++++ libherokubuildpack/src/output/interface.rs | 54 ++ libherokubuildpack/src/output/mod.rs | 7 + libherokubuildpack/src/output/section_log.rs | 129 ++++ libherokubuildpack/src/output/style.rs | 337 +++++++++ libherokubuildpack/src/output/util.rs | 186 +++++ libherokubuildpack/src/output/warn_later.rs | 347 +++++++++ 13 files changed, 2195 insertions(+), 1 deletion(-) create mode 100644 libherokubuildpack/examples/print_style_guide.rs create mode 100644 libherokubuildpack/src/output/background_timer.rs create mode 100644 libherokubuildpack/src/output/build_log.rs create mode 100644 libherokubuildpack/src/output/interface.rs create mode 100644 libherokubuildpack/src/output/mod.rs create mode 100644 libherokubuildpack/src/output/section_log.rs create mode 100644 libherokubuildpack/src/output/style.rs create mode 100644 libherokubuildpack/src/output/util.rs create mode 100644 libherokubuildpack/src/output/warn_later.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26b9e81b..812a97f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,3 +93,21 @@ jobs: # TODO: Switch this back to using the `alpine` tag once the stable Pack CLI release supports # image extensions (currently newer sample alpine images fail to build with stable Pack). run: pack build example-basics --builder cnbs/sample-builder@sha256:da5ff69191919f1ff30d5e28859affff8e39f23038137c7751e24a42e919c1ab --trust-builder --buildpack packaged/x86_64-unknown-linux-musl/debug/libcnb-examples_basics --path examples/ + + print-style-guide: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install musl-tools + run: sudo apt-get install musl-tools --no-install-recommends + - name: Update Rust toolchain + run: rustup update + - name: Install Rust linux-musl target + run: rustup target add x86_64-unknown-linux-musl + - name: Rust Cache + uses: Swatinem/rust-cache@v2.7.1 + - name: Install Pack CLI + uses: buildpacks/github-actions/setup-pack@v5.5.0 + - name: PRINT style guide + run: cargo run --example print_style_guide diff --git a/CHANGELOG.md b/CHANGELOG.md index ae20b9fd..7c9117fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `libherokubuildpack`: - `MappedWrite::unwrap` for getting the wrapped `Write` back out. ([#765](https://github.com/heroku/libcnb.rs/pull/765)) +- `libherokubuildpack`: + - Added build `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.17.0] - 2023-12-06 diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index 90e5a372..8d9f4854 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -17,8 +17,11 @@ all-features = true [lints] workspace = true +[[example]] +name = "print_style_guide" + [features] -default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write"] +default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write", "output"] download = ["dep:ureq", "dep:thiserror"] digest = ["dep:sha2"] error = ["log", "dep:libcnb"] @@ -27,6 +30,7 @@ tar = ["dep:tar", "dep:flate2"] toml = ["dep:toml"] fs = ["dep:pathdiff"] command = ["write", "dep:crossbeam-utils"] +output = ["dep:lazy_static", "dep:regex", "dep:const_format"] write = [] [dependencies] @@ -45,6 +49,14 @@ termcolor = { version = "1.4.0", optional = true } thiserror = { version = "1.0.50", optional = true } toml = { workspace = true, optional = true } ureq = { version = "2.9.1", default-features = false, features = ["tls"], optional = true } +lazy_static = { version = "1", optional = true } +regex = { version = "1", optional = true } +const_format = { version = "0.2", optional = true } [dev-dependencies] tempfile = "3.8.1" +libcnb-test = {workspace = true} +indoc = "2" +pretty_assertions = "1" +fun_run = "0.1.1" +ascii_table = { version = "4", features = ["color_codes"] } diff --git a/libherokubuildpack/examples/print_style_guide.rs b/libherokubuildpack/examples/print_style_guide.rs new file mode 100644 index 00000000..aa907f47 --- /dev/null +++ b/libherokubuildpack/examples/print_style_guide.rs @@ -0,0 +1,228 @@ +use ascii_table::AsciiTable; +use fun_run::CommandWithName; +use indoc::formatdoc; +use libherokubuildpack::output::style::{self, DEBUG_INFO, HELP}; +#[allow(clippy::wildcard_imports)] +use libherokubuildpack::output::{ + build_log::*, + section_log::{log_step, log_step_stream, log_step_timed}, +}; +use std::io::stdout; +use std::process::Command; + +// Avoid cargo-clippy warnings: "external crate X unused in `print_style_guide`" +use const_format as _; +use crossbeam_utils as _; +use flate2 as _; +use lazy_static as _; +use libcnb as _; +use libcnb_test as _; +use pathdiff as _; +use pretty_assertions as _; +use regex as _; +use sha2 as _; +use tar as _; +use tempfile as _; +use termcolor as _; +use thiserror as _; +use toml as _; +use ureq as _; + +#[allow(clippy::too_many_lines)] +fn main() { + println!( + "{}", + formatdoc! {" + + Living build output style guide + =============================== + "} + ); + + { + let mut log = BuildLog::new(stdout()).buildpack_name("Section logging features"); + log = log + .section("Section heading example") + .step("step example") + .step("step example two") + .end_section(); + + log = log + .section("Section and step description") + .step( + "A section should be a noun i.e. 'Ruby Version', consider this the section topic.", + ) + .step("A step should be a verb i.e. 'Downloading'") + .step("Related verbs should be nested under a single section") + .step( + formatdoc! {" + Steps can be multiple lines long + However they're best as short, factual, + descriptions of what the program is doing. + "} + .trim(), + ) + .step("Prefer a single line when possible") + .step("Sections and steps are sentence cased with no ending puncuation") + .step(&format!("{HELP} capitalize the first letter")) + .end_section(); + + let mut command = Command::new("bash"); + command.args(["-c", "ps aux | grep cargo"]); + + let mut stream = log.section("Timer steps") + .step("Long running code should execute with a timer printing to the UI, to indicate the progam did not hang.") + .step("Example:") + .step_timed("Background progress timer") + .finish_timed_step() + .step("Output can be streamed. Mostly from commands. Example:") + .step_timed_stream(&format!("Running {}", style::command(command.name()))); + + command + .stream_output(stream.io(), stream.io()) + .expect("Implement real error handling in real apps"); + log = stream.finish_timed_stream().end_section(); + drop(log); + } + + { + let mut log = BuildLog::new(stdout()).buildpack_name("Section log functions"); + log = log + .section("Logging inside a layer") + .step( + formatdoc! {" + Layer interfaces are neither mutable nor consuming i.e. + + ``` + fn create( + &self, + _context: &BuildContext, + layer_path: &Path, + ) -> Result, RubyBuildpackError> + ``` + + To allow logging within a layer you can use the `output::section_log` interface. + "} + .trim_end(), + ) + .step("This `section_log` inteface allows you to log without state") + .step("That means you're responsonsible creating a section before calling it") + .step("Here's an example") + .end_section(); + + let section_log = log.section("Example:"); + + log_step("log_step()"); + log_step_timed("log_step_timed()", || { + // do work here + }); + log_step_stream("log_step_stream()", |stream| { + Command::new("bash") + .args(["-c", "ps aux | grep cargo"]) + .stream_output(stream.io(), stream.io()) + .expect("Implement Error handling in real apps") + }); + log_step(formatdoc! {" + If you want to help make sure you're within a section then you can require your layer + takes a reference to `&'a dyn SectionLogger` + "}); + section_log.end_section(); + } + + { + #[allow(clippy::unwrap_used)] + let cmd_error = Command::new("iDoNotExist").named_output().err().unwrap(); + + let mut log = BuildLog::new(stdout()).buildpack_name("Error and warnings"); + log = log + .section("Debug information") + .step("Should go above errors in section/step format") + .end_section(); + + log = log + .section(DEBUG_INFO) + .step(&cmd_error.to_string()) + .end_section(); + + log.announce() + .warning(&formatdoc! {" + Warning: This is a warning header + + This is a warning body. Warnings are for when we know for a fact a problem exists + but it's not bad enough to abort the build. + "}) + .important(&formatdoc! {" + Important: This is important + + Important is for when there's critical information that needs to be read + however it may or may not be a problem. If we know for a fact that there's + a problem then use a warning instead. + + An example of something that is important but might not be a problem is + that an application owner upgraded to a new stack. + "}) + .error(&formatdoc! {" + Error: This is an error header + + This is the error body. Use an error for when the build cannot continue. + An error should include a header with a short description of why it cannot continue. + + The body should include what error state was observed, why that's a problem, and + what remediation steps an application owner using the buildpack to deploy can + take to solve the issue. + "}); + } + + { + let mut log = BuildLog::new(stdout()).buildpack_name("Formatting helpers"); + + log = log + .section("The style module") + .step(&formatdoc! {" + Formatting helpers can be used to enhance log output: + "}) + .end_section(); + + let mut table = AsciiTable::default(); + table.set_max_width(240); + table.column(0).set_header("Example"); + table.column(1).set_header("Code"); + table.column(2).set_header("When to use"); + + let data: Vec> = vec![ + vec![ + style::value("2.3.4"), + "style::value(\"2.3.f\")".to_string(), + "With versions, file names or other important values worth highlighting".to_string(), + ], + vec![ + style::url("https://www.schneems.com"), + "style::url(\"https://www.schneems.com\")".to_string(), + "With urls".to_string(), + ], + vec![ + style::command("bundle install"), + "style::command(command.name())".to_string(), + "With commands (alongside of `fun_run::CommandWithName`)".to_string(), + ], + vec![ + style::details("extra information"), + "style::details(\"extra information\")".to_string(), + "Add specific information at the end of a line i.e. 'Cache cleared (ruby version changed)'".to_string() + ], + vec![ + style::HELP.to_string(), + "style::HELP.to_string()".to_string(), + "A help prefix, use it in a step or section title".to_string() + ], + vec![ + style::DEBUG_INFO.to_string(), + "style::DEBUG_INFO.to_string()".to_string(), + "A debug prefix, use it in a step or section title".to_string() + ] + ]; + + table.print(data); + drop(log); + } +} diff --git a/libherokubuildpack/src/lib.rs b/libherokubuildpack/src/lib.rs index 60a9c376..2744ae21 100644 --- a/libherokubuildpack/src/lib.rs +++ b/libherokubuildpack/src/lib.rs @@ -12,9 +12,16 @@ pub mod error; pub mod fs; #[cfg(feature = "log")] pub mod log; +#[cfg(feature = "output")] +pub mod output; #[cfg(feature = "tar")] pub mod tar; #[cfg(feature = "toml")] pub mod toml; #[cfg(feature = "write")] pub mod write; + +#[cfg(test)] +use ascii_table as _; +#[cfg(test)] +use fun_run as _; diff --git a/libherokubuildpack/src/output/background_timer.rs b/libherokubuildpack/src/output/background_timer.rs new file mode 100644 index 00000000..f3c7f187 --- /dev/null +++ b/libherokubuildpack/src/output/background_timer.rs @@ -0,0 +1,157 @@ +use std::io::Write; +use std::sync::mpsc::Sender; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// This module is responsible for the logic involved in the printing to output while +/// other work is being performed. + +/// Prints a start, then a tick every second, and an end to the given `Write` value. +/// +/// Returns a struct that allows for manually stopping the timer or will automatically stop +/// the timer if the guard is dropped. This functionality allows for errors that trigger +/// an exit of the function to not accidentally have a timer printing in the background +/// forever. +pub(crate) fn start_timer( + arc_io: &Arc>, + tick_duration: Duration, + start: impl AsRef, + tick: impl AsRef, + end: impl AsRef, +) -> StopJoinGuard +where + // The 'static lifetime means as long as something holds a reference to it, nothing it references + // will go away. + // + // From https://users.rust-lang.org/t/why-does-thread-spawn-need-static-lifetime-for-generic-bounds/4541 + // + // [lifetimes] refer to the minimum possible lifetime of any borrowed references that the object contains. + T: Write + Send + Sync + 'static, +{ + let instant = Instant::now(); + let (sender, receiver) = mpsc::channel::<()>(); + let start = start.as_ref().to_string(); + let tick = tick.as_ref().to_string(); + let end = end.as_ref().to_string(); + + let arc_io = arc_io.clone(); + let handle = std::thread::spawn(move || { + let mut io = arc_io.lock().expect("Logging mutex poisoned"); + write!(&mut io, "{start}").expect("Internal error"); + io.flush().expect("Internal error"); + loop { + write!(&mut io, "{tick}").expect("Internal error"); + io.flush().expect("Internal error"); + + if receiver.recv_timeout(tick_duration).is_ok() { + write!(&mut io, "{end}").expect("Internal error"); + io.flush().expect("Internal error"); + break; + } + } + }); + + StopJoinGuard { + inner: Some(StopTimer { + handle: Some(handle), + sender: Some(sender), + instant, + }), + } +} + +/// Responsible for stopping a running timer thread +#[derive(Debug)] +pub(crate) struct StopTimer { + instant: Instant, + handle: Option>, + sender: Option>, +} + +impl StopTimer { + pub(crate) fn elapsed(&self) -> Duration { + self.instant.elapsed() + } +} + +pub(crate) trait StopJoin: std::fmt::Debug { + fn stop_join(self) -> Self; +} + +impl StopJoin for StopTimer { + fn stop_join(mut self) -> Self { + if let Some(inner) = self.sender.take() { + inner.send(()).expect("Internal error"); + } + + if let Some(inner) = self.handle.take() { + inner.join().expect("Internal error"); + } + + self + } +} + +// Guarantees that stop is called on the inner +#[derive(Debug)] +pub(crate) struct StopJoinGuard { + inner: Option, +} + +impl StopJoinGuard { + /// Since this consumes self and `stop_join` consumes + /// the inner, the option will never be empty unless + /// it was created with a None inner. + /// + /// Since inner is private we guarantee it's always Some + /// until this struct is consumed. + pub(crate) fn stop(mut self) -> T { + self.inner + .take() + .map(StopJoin::stop_join) + .expect("Internal error: Should never panic, codepath tested") + } +} + +impl Drop for StopJoinGuard { + fn drop(&mut self) { + if let Some(inner) = self.inner.take() { + inner.stop_join(); + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::output::util::ReadYourWrite; + use libcnb_test::assert_contains; + use std::thread::sleep; + + #[test] + fn does_stop_does_not_panic() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + let done = start_timer(&writer.arc_io(), Duration::from_millis(1), " .", ".", ". "); + + let _ = done.stop(); + + assert_contains!(String::from_utf8_lossy(&reader.lock().unwrap()), " ... "); + } + + #[test] + fn test_drop_stops_timer() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + let done = start_timer(&writer.arc_io(), Duration::from_millis(1), " .", ".", ". "); + + drop(done); + sleep(Duration::from_millis(2)); + + let before = String::from_utf8_lossy(&reader.lock().unwrap()).to_string(); + sleep(Duration::from_millis(100)); + let after = String::from_utf8_lossy(&reader.lock().unwrap()).to_string(); + assert_eq!(before, after); + } +} diff --git a/libherokubuildpack/src/output/build_log.rs b/libherokubuildpack/src/output/build_log.rs new file mode 100644 index 00000000..c7564aa1 --- /dev/null +++ b/libherokubuildpack/src/output/build_log.rs @@ -0,0 +1,710 @@ +use crate::output::background_timer::{start_timer, StopJoinGuard, StopTimer}; +#[allow(clippy::wildcard_imports)] +pub use crate::output::interface::*; +use crate::output::style; +use std::fmt::Debug; +use std::io::Write; +use std::marker::PhantomData; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// # Build output logging +/// +/// Use the `BuildLog` to output structured text as a buildpack is executing +/// +/// ``` +/// use libherokubuildpack::output::build_log::*; +/// +/// let mut logger = BuildLog::new(std::io::stdout()) +/// .buildpack_name("Heroku Ruby Buildpack"); +/// +/// logger = logger +/// .section("Ruby version") +/// .step_timed("Installing") +/// .finish_timed_step() +/// .end_section(); +/// +/// logger.finish_logging(); +/// ``` +/// +/// To log inside of a layer see `section_log`. +/// +/// For usage details run `cargo run --bin print_style_guide` + +#[allow(clippy::module_name_repetitions)] +#[derive(Debug)] +pub struct BuildLog { + pub(crate) io: W, + pub(crate) data: BuildData, + pub(crate) state: PhantomData, +} + +/// A bag of data passed throughout the lifecycle of a `BuildLog` +#[derive(Debug)] +pub(crate) struct BuildData { + pub(crate) started: Instant, +} + +impl Default for BuildData { + fn default() -> Self { + Self { + started: Instant::now(), + } + } +} + +/// Various states for `BuildLog` to contain +/// +/// The `BuildLog` struct acts as a logging state machine. These structs +/// are meant to represent those states +pub(crate) mod state { + #[derive(Debug)] + pub struct NotStarted; + + #[derive(Debug)] + pub struct Started; + + #[derive(Debug)] + pub struct InSection; +} + +impl BuildLog +where + W: Write + Debug, +{ + pub fn new(io: W) -> Self { + Self { + io, + state: PhantomData::, + data: BuildData::default(), + } + } +} + +impl Logger for BuildLog +where + W: Write + Send + Sync + Debug + 'static, +{ + fn buildpack_name(mut self, buildpack_name: &str) -> Box { + write_now( + &mut self.io, + format!("{}\n\n", style::header(buildpack_name)), + ); + + Box::new(BuildLog { + io: self.io, + data: self.data, + state: PhantomData::, + }) + } + + fn without_buildpack_name(self) -> Box { + Box::new(BuildLog { + io: self.io, + data: self.data, + state: PhantomData::, + }) + } +} + +impl StartedLogger for BuildLog +where + W: Write + Send + Sync + Debug + 'static, +{ + fn section(mut self: Box, s: &str) -> Box { + writeln_now(&mut self.io, style::section(s)); + + Box::new(BuildLog { + io: self.io, + data: self.data, + state: PhantomData::, + }) + } + + fn finish_logging(mut self: Box) { + let elapsed = style::time::human(&self.data.started.elapsed()); + let details = style::details(format!("finished in {elapsed}")); + + writeln_now(&mut self.io, style::section(format!("Done {details}"))); + } + + fn announce(self: Box) -> Box>> { + Box::new(AnnounceBuildLog { + io: self.io, + data: self.data, + state: PhantomData::, + leader: Some("\n".to_string()), + }) + } +} +impl SectionLogger for BuildLog +where + W: Write + Send + Sync + Debug + 'static, +{ + fn mut_step(&mut self, s: &str) { + writeln_now(&mut self.io, style::step(s)); + } + + fn step(mut self: Box, s: &str) -> Box { + self.mut_step(s); + + Box::new(BuildLog { + io: self.io, + state: PhantomData::, + data: self.data, + }) + } + + fn step_timed(self: Box, s: &str) -> Box { + let start = style::step(format!("{s}{}", style::background_timer_start())); + let tick = style::background_timer_tick(); + let end = style::background_timer_end(); + + let arc_io = Arc::new(Mutex::new(self.io)); + let background = start_timer(&arc_io, Duration::from_secs(1), start, tick, end); + + Box::new(FinishTimedStep { + arc_io, + background, + data: self.data, + }) + } + + fn step_timed_stream(mut self: Box, s: &str) -> Box { + self.mut_step(s); + + let started = Instant::now(); + let arc_io = Arc::new(Mutex::new(self.io)); + let mut stream = StreamTimed { + arc_io, + data: self.data, + started, + }; + stream.start(); + + Box::new(stream) + } + + fn end_section(self: Box) -> Box { + Box::new(BuildLog { + io: self.io, + data: self.data, + state: PhantomData::, + }) + } + + fn announce(self: Box) -> Box>> { + Box::new(AnnounceBuildLog { + io: self.io, + data: self.data, + state: PhantomData::, + leader: Some("\n".to_string()), + }) + } +} + +// Store internal state, print leading character exactly once on warning or important +#[derive(Debug)] +struct AnnounceBuildLog +where + W: Write + Send + Sync + Debug + 'static, +{ + io: W, + data: BuildData, + state: PhantomData, + leader: Option, +} + +impl AnnounceBuildLog +where + T: Debug, + W: Write + Send + Sync + Debug + 'static, +{ + fn log_warning_shared(&mut self, s: &str) { + if let Some(leader) = self.leader.take() { + write_now(&mut self.io, leader); + } + + writeln_now(&mut self.io, style::warning(s.trim())); + writeln_now(&mut self.io, ""); + } + + fn log_important_shared(&mut self, s: &str) { + if let Some(leader) = self.leader.take() { + write_now(&mut self.io, leader); + } + writeln_now(&mut self.io, style::important(s.trim())); + writeln_now(&mut self.io, ""); + } + + fn log_warn_later_shared(&mut self, s: &str) { + let mut formatted = style::warning(s.trim()); + formatted.push('\n'); + + match crate::output::warn_later::try_push(formatted) { + Ok(()) => {} + Err(error) => { + eprintln!("[Buildpack Warning]: Cannot use the delayed warning feature due to error: {error}"); + self.log_warning_shared(s); + } + }; + } +} + +impl ErrorLogger for AnnounceBuildLog +where + T: Debug, + W: Write + Send + Sync + Debug + 'static, +{ + fn error(mut self: Box, s: &str) { + if let Some(leader) = self.leader.take() { + write_now(&mut self.io, leader); + } + writeln_now(&mut self.io, style::error(s.trim())); + writeln_now(&mut self.io, ""); + } +} + +impl AnnounceLogger for AnnounceBuildLog +where + W: Write + Send + Sync + Debug + 'static, +{ + type ReturnTo = Box; + + fn warning(mut self: Box, s: &str) -> Box> { + self.log_warning_shared(s); + + self + } + + fn warn_later( + mut self: Box, + s: &str, + ) -> Box> { + self.log_warn_later_shared(s); + + self + } + + fn important( + mut self: Box, + s: &str, + ) -> Box> { + self.log_important_shared(s); + + self + } + + fn end_announce(self: Box) -> Box { + Box::new(BuildLog { + io: self.io, + data: self.data, + state: PhantomData::, + }) + } +} + +impl AnnounceLogger for AnnounceBuildLog +where + W: Write + Send + Sync + Debug + 'static, +{ + type ReturnTo = Box; + + fn warning(mut self: Box, s: &str) -> Box> { + self.log_warning_shared(s); + self + } + + fn warn_later( + mut self: Box, + s: &str, + ) -> Box> { + self.log_warn_later_shared(s); + self + } + + fn important( + mut self: Box, + s: &str, + ) -> Box> { + self.log_important_shared(s); + self + } + + fn end_announce(self: Box) -> Box { + Box::new(BuildLog { + io: self.io, + data: self.data, + state: PhantomData::, + }) + } +} + +/// Implements Box +/// +/// Ensures that the `W` can be passed across thread boundries +/// by wrapping in a mutex. +/// +/// It implements writing by unlocking and delegating to the internal writer. +/// Can be used for `Box::io()` +#[derive(Debug)] +struct LockedWriter { + arc: Arc>, +} + +impl Write for LockedWriter +where + W: Write + Send + Sync + Debug + 'static, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut io = self.arc.lock().expect("Logging mutex poisoned"); + io.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + let mut io = self.arc.lock().expect("Logging mutex poisoned"); + io.flush() + } +} + +/// Used to implement `Box` interface +/// +/// Mostly used for logging a running command +#[derive(Debug)] +struct StreamTimed { + data: BuildData, + arc_io: Arc>, + started: Instant, +} + +impl StreamTimed +where + W: Write + Send + Sync + Debug, +{ + fn start(&mut self) { + let mut guard = self.arc_io.lock().expect("Logging mutex posioned"); + let mut io = guard.by_ref(); + // Newline before stream + writeln_now(&mut io, ""); + } +} + +// Need a trait that is both write a debug +trait WriteDebug: Write + Debug {} +impl WriteDebug for T where T: Write + Debug {} + +/// Attempt to unwrap an io inside of an `Arc` if this fails because there is more +/// than a single reference don't panic, return the original IO instead. +/// +/// This prevents a runtime panic and allows us to continue logging +fn try_unwrap_arc_io(arc_io: Arc>) -> Box +where + W: Write + Send + Sync + Debug + 'static, +{ + match Arc::try_unwrap(arc_io) { + Ok(mutex) => Box::new(mutex.into_inner().expect("Logging mutex was poisioned")), + Err(original) => Box::new(LockedWriter { arc: original }), + } +} + +impl StreamLogger for StreamTimed +where + W: Write + Send + Sync + Debug + 'static, +{ + /// Yield boxed writer that can be used for formatting and streaming contents + /// back to the logger. + fn io(&mut self) -> Box { + Box::new(crate::write::line_mapped( + LockedWriter { + arc: self.arc_io.clone(), + }, + style::cmd_stream_format, + )) + } + + fn finish_timed_stream(self: Box) -> Box { + let duration = self.started.elapsed(); + let mut io = try_unwrap_arc_io(self.arc_io); + + // // Newline after stream + writeln_now(&mut io, ""); + + let mut section = BuildLog { + io, + data: self.data, + state: PhantomData::, + }; + + section.mut_step(&format!( + "Done {}", + style::details(style::time::human(&duration)) + )); + + Box::new(section) + } +} + +/// Implements `Box` +/// +/// Used to end a background inline timer i.e. Installing ...... (<0.1s) +#[derive(Debug)] +struct FinishTimedStep { + data: BuildData, + arc_io: Arc>, + background: StopJoinGuard, +} + +impl TimedStepLogger for FinishTimedStep +where + W: Write + Send + Sync + Debug + 'static, +{ + fn finish_timed_step(self: Box) -> Box { + // Must stop background writing thread before retrieving IO + let duration = self.background.stop().elapsed(); + let mut io = try_unwrap_arc_io(self.arc_io); + + writeln_now(&mut io, style::details(style::time::human(&duration))); + + Box::new(BuildLog { + io, + data: self.data, + state: PhantomData::, + }) + } +} + +/// Internal helper, ensures that all contents are always flushed (never buffered) +/// +/// This is especially important for writing individual characters to the same line +fn write_now(destination: &mut D, msg: impl AsRef) { + write!(destination, "{}", msg.as_ref()).expect("Logging error: UI writer closed"); + + destination + .flush() + .expect("Logging error: UI writer closed"); +} + +/// 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("Logging error: UI writer closed"); + + destination + .flush() + .expect("Logging error: UI writer closed"); +} + +#[cfg(test)] +mod test { + use super::*; + use crate::command::CommandExt; + use crate::output::style::{self, strip_control_codes}; + use crate::output::util::{strip_trailing_whitespace, ReadYourWrite}; + use crate::output::warn_later::WarnGuard; + use indoc::formatdoc; + use libcnb_test::assert_contains; + use pretty_assertions::assert_eq; + + #[test] + fn test_captures() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + + let mut stream = BuildLog::new(writer) + .buildpack_name("Heroku Ruby Buildpack") + .section("Ruby version `3.1.3` from `Gemfile.lock`") + .step_timed("Installing") + .finish_timed_step() + .end_section() + .section("Hello world") + .step_timed_stream("Streaming stuff"); + + let value = "stuff".to_string(); + writeln!(stream.io(), "{value}").unwrap(); + + stream.finish_timed_stream().end_section().finish_logging(); + + let expected = formatdoc! {" + + # Heroku Ruby Buildpack + + - Ruby version `3.1.3` from `Gemfile.lock` + - Installing ... (< 0.1s) + - Hello world + - Streaming stuff + + stuff + + - Done (< 0.1s) + - Done (finished in < 0.1s) + "}; + + assert_eq!( + expected, + strip_trailing_whitespace(style::strip_control_codes(reader.read_lossy().unwrap())) + ); + } + + #[test] + fn test_streaming_a_command() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + + let mut stream = BuildLog::new(writer) + .buildpack_name("Streaming buildpack demo") + .section("Command streaming") + .step_timed_stream("Streaming stuff"); + + std::process::Command::new("echo") + .arg("hello world") + .output_and_write_streams(stream.io(), stream.io()) + .unwrap(); + + stream.finish_timed_stream().end_section().finish_logging(); + + let actual = + strip_trailing_whitespace(style::strip_control_codes(reader.read_lossy().unwrap())); + + assert_contains!(actual, " hello world\n"); + } + + #[test] + fn warning_step_padding() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + + BuildLog::new(writer) + .buildpack_name("RCT") + .section("Guest thoughs") + .step("The scenery here is wonderful") + .announce() + .warning("It's too crowded here\nI'm tired") + .end_announce() + .step("The jumping fountains are great") + .step("The music is nice here") + .end_section() + .finish_logging(); + + let expected = formatdoc! {" + + # RCT + + - Guest thoughs + - 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_control_codes(reader.read_lossy().unwrap())); + } + + #[test] + fn double_warning_step_padding() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + + let logger = BuildLog::new(writer) + .buildpack_name("RCT") + .section("Guest thoughs") + .step("The scenery here is wonderful") + .announce(); + + logger + .warning("It's too crowded here") + .warning("I'm tired") + .end_announce() + .step("The jumping fountains are great") + .step("The music is nice here") + .end_section() + .finish_logging(); + + let expected = formatdoc! {" + + # RCT + + - Guest thoughs + - 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_control_codes(reader.read_lossy().unwrap())); + } + + #[test] + fn warn_later_doesnt_output_newline() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + + let warn_later = WarnGuard::new(writer.clone()); + BuildLog::new(writer) + .buildpack_name("Walkin' on the Sun") + .section("So don't delay, act now, supplies are running out") + .step("Allow if you're still alive, six to eight years to arrive") + .step("And if you follow, there may be a tomorrow") + .announce() + .warn_later("And all that glitters is gold") + .warn_later("Only shooting stars break the mold") + .end_announce() + .step("But if the offer's shunned") + .step("You might as well be walking on the Sun") + .end_section() + .finish_logging(); + + drop(warn_later); + + let expected = formatdoc! {" + + # Walkin' on the Sun + + - So don't delay, act now, supplies are running out + - Allow if you're still alive, six to eight years to arrive + - And if you follow, there may be a tomorrow + - But if the offer's shunned + - You might as well be walking on the Sun + - Done (finished in < 0.1s) + + ! And all that glitters is gold + + ! Only shooting stars break the mold + "}; + + assert_eq!(expected, strip_control_codes(reader.read_lossy().unwrap())); + } + + #[test] + fn announce_and_exit_makes_no_whitespace() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + + BuildLog::new(writer) + .buildpack_name("Quick and simple") + .section("Start") + .step("Step") + .announce() // <== Here + .end_announce() // <== Here + .end_section() + .finish_logging(); + + let expected = formatdoc! {" + + # Quick and simple + + - Start + - Step + - Done (finished in < 0.1s) + "}; + + assert_eq!(expected, strip_control_codes(reader.read_lossy().unwrap())); + } +} diff --git a/libherokubuildpack/src/output/interface.rs b/libherokubuildpack/src/output/interface.rs new file mode 100644 index 00000000..46dc615b --- /dev/null +++ b/libherokubuildpack/src/output/interface.rs @@ -0,0 +1,54 @@ +use std::fmt::Debug; +use std::io::Write; + +/// Consuming stateful logger interface +/// +/// The log pattern used by `BuildLog` is a consuming state machine that is designed to minimize +/// the amount of mistakes that can result in malformed build output. +/// +/// The interface isn't stable and may need to change. + +pub trait Logger: Debug { + fn buildpack_name(self, s: &str) -> Box; + fn without_buildpack_name(self) -> Box; +} + +pub trait StartedLogger: Debug { + fn section(self: Box, s: &str) -> Box; + fn finish_logging(self: Box); + + fn announce(self: Box) -> Box>>; +} + +pub trait SectionLogger: Debug { + fn step(self: Box, s: &str) -> Box; + fn mut_step(&mut self, s: &str); + fn step_timed(self: Box, s: &str) -> Box; + fn step_timed_stream(self: Box, s: &str) -> Box; + fn end_section(self: Box) -> Box; + + fn announce(self: Box) -> Box>>; +} + +pub trait AnnounceLogger: ErrorLogger + Debug { + type ReturnTo; + + fn warning(self: Box, s: &str) -> Box>; + fn warn_later(self: Box, s: &str) -> Box>; + fn important(self: Box, s: &str) -> Box>; + + fn end_announce(self: Box) -> Self::ReturnTo; +} + +pub trait TimedStepLogger: Debug { + fn finish_timed_step(self: Box) -> Box; +} + +pub trait StreamLogger: Debug { + fn io(&mut self) -> Box; + fn finish_timed_stream(self: Box) -> Box; +} + +pub trait ErrorLogger: Debug { + fn error(self: Box, s: &str); +} diff --git a/libherokubuildpack/src/output/mod.rs b/libherokubuildpack/src/output/mod.rs new file mode 100644 index 00000000..a862a9c9 --- /dev/null +++ b/libherokubuildpack/src/output/mod.rs @@ -0,0 +1,7 @@ +mod background_timer; +pub mod build_log; +pub mod interface; +pub mod section_log; +pub mod style; +mod util; +pub mod warn_later; diff --git a/libherokubuildpack/src/output/section_log.rs b/libherokubuildpack/src/output/section_log.rs new file mode 100644 index 00000000..c5781081 --- /dev/null +++ b/libherokubuildpack/src/output/section_log.rs @@ -0,0 +1,129 @@ +use crate::output::build_log::{state, BuildData, BuildLog}; +#[allow(clippy::wildcard_imports)] +pub use crate::output::interface::*; +use std::io::Stdout; +use std::marker::PhantomData; + +/// Write to the build output in a `Box` format with functions +/// +/// ## What +/// +/// Logging from within a layer can be difficult because calls to the layer interface are not +/// mutable nor consumable. Functions can be used at any time with no restrictions. The +/// only downside is that the buildpack author (you) is now responsible for: +/// +/// - Ensuring that `Box::section()` was called right before any of these +/// functions are called. +/// - Ensuring that you are not attempting to log while already logging i.e. calling `step()` within a +/// `step_timed()` call. +/// +/// For usage details run `cargo run --bin print_style_guide` +/// +/// ## Use +/// +/// The main use case is logging inside of a layer: +/// +/// ```no_run +/// use libherokubuildpack::output::section_log::log_step_timed; +/// +/// // fn create( +/// // &self, +/// // context: &libcnb::build::BuildContext, +/// // layer_path: &std::path::Path, +/// // ) -> Result< +/// // libcnb::layer::LayerResult, +/// // ::Error, +/// // > { +/// log_step_timed("Installing", || { +/// // Install logic here +/// todo!() +/// }) +/// // } +/// ``` + +/// Output a message as a single step, ideally a short message +/// +/// ``` +/// use libherokubuildpack::output::section_log::log_step; +/// +/// log_step("Clearing cache (ruby version changed)"); +/// ``` +pub fn log_step(s: impl AsRef) { + logger().step(s.as_ref()); +} + +/// Will print the input string followed by a background timer +/// that will emit to the UI until the passed in function ends +/// +/// ``` +/// use libherokubuildpack::output::section_log::log_step_timed; +/// +/// log_step_timed("Installing", || { +/// // Install logic here +/// }); +/// ``` +/// +/// Timing information will be output at the end of the step. +pub fn log_step_timed(s: impl AsRef, f: impl FnOnce() -> T) -> T { + let timer = logger().step_timed(s.as_ref()); + let out = f(); + timer.finish_timed_step(); + out +} + +/// Will print the input string and yield a `Box` that can be used to print +/// to the output. The main use case is running commands +/// +/// ```no_run +/// use fun_run::CommandWithName; +/// use libherokubuildpack::output::section_log::log_step_stream; +/// use libherokubuildpack::output::style; +/// +/// let mut cmd = std::process::Command::new("bundle"); +/// cmd.arg("install"); +/// +/// log_step_stream(format!("Running {}", style::command(cmd.name())), |stream| { +/// cmd.stream_output(stream.io(), stream.io()).unwrap() +/// }); +/// ``` +/// +/// Timing information will be output at the end of the step. +pub fn log_step_stream( + s: impl AsRef, + f: impl FnOnce(&mut Box) -> T, +) -> T { + let mut stream = logger().step_timed_stream(s.as_ref()); + let out = f(&mut stream); + stream.finish_timed_stream(); + out +} + +/// Print an error block to the output +pub fn log_error(s: impl AsRef) { + logger().announce().error(s.as_ref()); +} + +/// Print an warning block to the output +pub fn log_warning(s: impl AsRef) { + logger().announce().warning(s.as_ref()); +} + +/// Print an warning block to the output at a later time +pub fn log_warning_later(s: impl AsRef) { + logger().announce().warn_later(s.as_ref()); +} + +/// Print an important block to the output +pub fn log_important(s: impl AsRef) { + logger().announce().important(s.as_ref()); +} + +fn logger() -> Box { + Box::new(BuildLog:: { + io: std::io::stdout(), + // Be careful not to do anything that might access this state + // as it's ephemeral data (i.e. not passed in from the start of the build) + data: BuildData::default(), + state: PhantomData, + }) +} diff --git a/libherokubuildpack/src/output/style.rs b/libherokubuildpack/src/output/style.rs new file mode 100644 index 00000000..abe5c7a6 --- /dev/null +++ b/libherokubuildpack/src/output/style.rs @@ -0,0 +1,337 @@ +use crate::output::util::LinesWithEndings; +use const_format::formatcp; +use std::fmt::Write; + +/// Helpers for formatting and colorizing your output + +/// Decorated str for prefixing "Help:" +pub const HELP: &str = formatcp!("{IMPORTANT_COLOR}! HELP{RESET}"); + +/// Decorated str for prefixing "Debug info:" +pub const DEBUG_INFO: &str = formatcp!("{IMPORTANT_COLOR}Debug info{RESET}"); + +/// Decorate a URL for the build output +#[must_use] +pub fn url(contents: impl AsRef) -> String { + colorize(URL_COLOR, contents) +} + +/// Decorate the name of a command being run i.e. `bundle install` +#[must_use] +pub fn command(contents: impl AsRef) -> String { + value(colorize(COMMAND_COLOR, contents.as_ref())) +} + +/// Decorate an important value i.e. `2.3.4` +#[must_use] +pub fn value(contents: impl AsRef) -> String { + let contents = colorize(VALUE_COLOR, contents.as_ref()); + format!("`{contents}`") +} + +/// Decorate additional information at the end of a line +#[must_use] +pub fn details(contents: impl AsRef) -> String { + let contents = contents.as_ref(); + format!("({contents})") +} + +pub(crate) const RED: &str = "\x1B[0;31m"; +pub(crate) const YELLOW: &str = "\x1B[0;33m"; +pub(crate) const CYAN: &str = "\x1B[0;36m"; + +pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; +pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // magenta + +pub(crate) const DEFAULT_DIM: &str = "\x1B[2;1m"; // Default color but softer/less vibrant +pub(crate) const RESET: &str = "\x1B[0m"; + +#[cfg(test)] +pub(crate) const NOCOLOR: &str = "\x1B[1;39m"; // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 +pub(crate) const ALL_CODES: [&str; 7] = [ + RED, + YELLOW, + CYAN, + BOLD_CYAN, + BOLD_PURPLE, + DEFAULT_DIM, + RESET, +]; + +pub(crate) const HEROKU_COLOR: &str = BOLD_PURPLE; +pub(crate) const VALUE_COLOR: &str = YELLOW; +pub(crate) const COMMAND_COLOR: &str = BOLD_CYAN; +pub(crate) const URL_COLOR: &str = CYAN; +pub(crate) const IMPORTANT_COLOR: &str = CYAN; +pub(crate) const ERROR_COLOR: &str = RED; + +#[allow(dead_code)] +pub(crate) const WARNING_COLOR: &str = YELLOW; + +const SECTION_PREFIX: &str = "- "; +const STEP_PREFIX: &str = " - "; +const CMD_INDENT: &str = " "; + +/// Used with libherokubuildpack linemapped command output +/// +#[must_use] +pub(crate) fn cmd_stream_format(mut input: Vec) -> Vec { + let mut result: Vec = CMD_INDENT.into(); + result.append(&mut input); + result +} + +#[must_use] +pub(crate) fn background_timer_start() -> String { + colorize(DEFAULT_DIM, " .") +} + +#[must_use] +pub(crate) fn background_timer_tick() -> String { + colorize(DEFAULT_DIM, ".") +} + +#[must_use] +pub(crate) fn background_timer_end() -> String { + colorize(DEFAULT_DIM, ". ") +} + +#[must_use] +pub(crate) fn section(topic: impl AsRef) -> String { + prefix_indent(SECTION_PREFIX, topic) +} + +#[must_use] +pub(crate) fn step(contents: impl AsRef) -> String { + prefix_indent(STEP_PREFIX, contents) +} + +/// Used to decorate a buildpack +#[must_use] +pub(crate) fn header(contents: impl AsRef) -> String { + let contents = contents.as_ref(); + colorize(HEROKU_COLOR, format!("\n# {contents}")) +} + +// Prefix is expected to be a single line +// +// If contents is multi line then indent additional lines to align with the end of the prefix. +pub(crate) fn prefix_indent(prefix: impl AsRef, contents: impl AsRef) -> String { + let prefix = prefix.as_ref(); + let contents = contents.as_ref(); + let non_whitespace_re = regex::Regex::new("\\S").expect("Clippy"); + let clean_prefix = strip_control_codes(prefix); + + let indent_str = non_whitespace_re.replace_all(&clean_prefix, " "); // Preserve whitespace characters like tab and space, replace all characters with spaces + let lines = LinesWithEndings::from(contents).collect::>(); + + if let Some((first, rest)) = lines.split_first() { + format!( + "{prefix}{first}{}", + rest.iter().fold(String::new(), |mut output, line| { + let _ = write!(output, "{indent_str}{line}"); + output + }) + ) + } else { + prefix.to_string() + } +} + +#[must_use] +pub(crate) fn important(contents: impl AsRef) -> String { + colorize(IMPORTANT_COLOR, bangify(contents)) +} + +#[must_use] +pub(crate) fn warning(contents: impl AsRef) -> String { + colorize(WARNING_COLOR, bangify(contents)) +} + +#[must_use] +pub(crate) fn error(contents: impl AsRef) -> String { + colorize(ERROR_COLOR, bangify(contents)) +} + +/// Helper method that adds a bang i.e. `!` before strings +pub(crate) fn bangify(body: impl AsRef) -> String { + prepend_each_line("!", " ", body) +} + +// Ensures every line starts with `prepend` +pub(crate) fn prepend_each_line( + prepend: impl AsRef, + separator: impl AsRef, + contents: impl AsRef, +) -> String { + let body = contents.as_ref(); + let prepend = prepend.as_ref(); + let separator = separator.as_ref(); + + let lines = LinesWithEndings::from(body) + .map(|line| { + if line.trim().is_empty() { + format!("{prepend}{line}") + } else { + format!("{prepend}{separator}{line}") + } + }) + .collect::(); + lines +} + +/// Colorizes a body while preserving existing color/reset combinations and clearing before newlines +/// +/// Colors with newlines are a problem since the contents stream to git which prepends `remote:` before the `libcnb_test` +/// if we don't clear, then we will colorize output that isn't ours. +/// +/// Explicitly uncolored output is handled by treating `\x1b[1;39m` (NOCOLOR) as a distinct case from `\x1b[0m` +pub(crate) fn colorize(color: &str, body: impl AsRef) -> String { + 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}{color}"))) // Handles nested color + // Set the main color for each line and reset after so we don't colorize `remote:` by accident + .map(|line| format!("{color}{line}{RESET}")) + // The above logic causes redundant colors and resets, clean them up + .map(|line| line.replace(&format!("{RESET}{color}{RESET}"), RESET)) + .map(|line| line.replace(&format!("{color}{color}"), color)) // Reduce useless color + .collect::>() + .join("\n") +} + +pub(crate) fn strip_control_codes(contents: impl AsRef) -> String { + let mut contents = contents.as_ref().to_string(); + for code in ALL_CODES { + contents = contents.replace(code, ""); + } + contents +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_prefix_indent() { + assert_eq!("- hello", &prefix_indent("- ", "hello")); + assert_eq!("- hello\n world", &prefix_indent("- ", "hello\nworld")); + assert_eq!("- hello\n world\n", &prefix_indent("- ", "hello\nworld\n")); + let actual = prefix_indent(format!("- {RED}help:{RESET} "), "hello\nworld\n"); + assert_eq!( + &format!("- {RED}help:{RESET} hello\n world\n"), + &actual + ); + } + + #[test] + fn test_bangify() { + let actual = bangify("hello"); + assert_eq!("! hello", actual); + + let actual = bangify("\n"); + assert_eq!("!\n", actual); + } + + #[test] + fn handles_explicitly_removed_colors() { + let nested = colorize(NOCOLOR, "nested"); + + let out = colorize(RED, format!("hello {nested} color")); + let expected = format!("{RED}hello {NOCOLOR}nested{RESET}{RED} color{RESET}"); + + assert_eq!(expected, out); + } + + #[test] + fn handles_nested_colors() { + let nested = colorize(CYAN, "nested"); + + let out = colorize(RED, format!("hello {nested} color")); + let expected = format!("{RED}hello {CYAN}nested{RESET}{RED} color{RESET}"); + + assert_eq!(expected, out); + } + + #[test] + fn splits_newlines() { + let actual = colorize(RED, "hello\nworld"); + let expected = format!("{RED}hello{RESET}\n{RED}world{RESET}"); + + assert_eq!(expected, actual); + } + + #[test] + fn simple_case() { + let actual = colorize(RED, "hello world"); + assert_eq!(format!("{RED}hello world{RESET}"), actual); + } +} + +pub(crate) mod time { + use std::time::Duration; + + // Returns the part of a duration only in miliseconds + pub(crate) fn milliseconds(duration: &Duration) -> u32 { + duration.subsec_millis() + } + + pub(crate) fn seconds(duration: &Duration) -> u64 { + duration.as_secs() % 60 + } + + pub(crate) fn minutes(duration: &Duration) -> u64 { + (duration.as_secs() / 60) % 60 + } + + pub(crate) fn hours(duration: &Duration) -> u64 { + (duration.as_secs() / 3600) % 60 + } + + #[must_use] + pub(crate) fn human(duration: &Duration) -> String { + let hours = hours(duration); + let minutes = minutes(duration); + let seconds = seconds(duration); + let miliseconds = milliseconds(duration); + + if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else if seconds > 0 || miliseconds > 100 { + // 0.1 + format!("{seconds}.{miliseconds:0>3}s") + } else { + String::from("< 0.1s") + } + } + + #[cfg(test)] + mod test { + use super::*; + + #[test] + fn test_millis_and_seconds() { + let duration = Duration::from_millis(1024); + assert_eq!(24, milliseconds(&duration)); + assert_eq!(1, seconds(&duration)); + } + + #[test] + fn test_display_duration() { + let duration = Duration::from_millis(99); + assert_eq!("< 0.1s", human(&duration).as_str()); + + let duration = Duration::from_millis(1024); + assert_eq!("1.024s", human(&duration).as_str()); + + let duration = std::time::Duration::from_millis(60 * 1024); + assert_eq!("1m 1s", human(&duration).as_str()); + + let duration = std::time::Duration::from_millis(3600 * 1024); + assert_eq!("1h 1m 26s", human(&duration).as_str()); + } + } +} diff --git a/libherokubuildpack/src/output/util.rs b/libherokubuildpack/src/output/util.rs new file mode 100644 index 00000000..ee82a7b9 --- /dev/null +++ b/libherokubuildpack/src/output/util.rs @@ -0,0 +1,186 @@ +use lazy_static::lazy_static; +use std::fmt::Debug; +use std::io::Write; +use std::ops::Deref; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +lazy_static! { + static ref TRAILING_WHITESPACE_RE: regex::Regex = regex::Regex::new(r"\s+$").expect("clippy"); +} + +/// Threadsafe writer that can be read from +/// +/// Useful for testing +#[derive(Debug)] +pub(crate) struct ReadYourWrite +where + W: Write + AsRef<[u8]>, +{ + arc: Arc>, +} + +impl Clone for ReadYourWrite +where + W: Write + AsRef<[u8]> + Debug, +{ + fn clone(&self) -> Self { + Self { + arc: self.arc.clone(), + } + } +} + +impl Write for ReadYourWrite +where + W: Write + AsRef<[u8]> + Debug, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut writer = self.arc.lock().expect("Internal error"); + writer.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + let mut writer = self.arc.lock().expect("Internal error"); + writer.flush() + } +} + +impl ReadYourWrite +where + W: Write + AsRef<[u8]>, +{ + #[allow(dead_code)] + pub(crate) fn writer(writer: W) -> Self { + Self { + arc: Arc::new(Mutex::new(writer)), + } + } + + #[must_use] + #[allow(dead_code)] + pub(crate) fn reader(&self) -> Reader { + Reader { + arc: self.arc.clone(), + } + } + + #[must_use] + #[allow(dead_code)] + pub(crate) fn arc_io(&self) -> Arc> { + self.arc.clone() + } +} + +pub(crate) struct Reader +where + W: Write + AsRef<[u8]>, +{ + arc: Arc>, +} + +impl Reader +where + W: Write + AsRef<[u8]>, +{ + #[allow(dead_code)] + pub(crate) fn read_lossy(&self) -> Result>> { + let io = &self.arc.lock()?; + + Ok(String::from_utf8_lossy(io.as_ref()).to_string()) + } +} + +impl Deref for Reader +where + W: Write + AsRef<[u8]>, +{ + type Target = Arc>; + + fn deref(&self) -> &Self::Target { + &self.arc + } +} + +/// Iterator yielding every line in a string. The line includes newline character(s). +/// +/// +/// +/// The problem this solves is when iterating over lines of a string, the whitespace may be significant. +/// For example if you want to split a string and then get the original string back then calling +/// `lines().collect>().join("\n")` will never preserve trailing newlines. +/// +/// There's another option to `lines().fold(String::new(), |s, l| s + l + "\n")`, but that +/// always adds a trailing newline even if the original string doesn't have one. +pub(crate) struct LinesWithEndings<'a> { + input: &'a str, +} + +impl<'a> LinesWithEndings<'a> { + pub(crate) fn from(input: &'a str) -> LinesWithEndings<'a> { + LinesWithEndings { input } + } +} + +impl<'a> Iterator for LinesWithEndings<'a> { + type Item = &'a str; + + #[inline] + fn next(&mut self) -> Option<&'a str> { + if self.input.is_empty() { + return None; + } + let split = self.input.find('\n').map_or(self.input.len(), |i| i + 1); + + let (line, rest) = self.input.split_at(split); + self.input = rest; + Some(line) + } +} + +/// Removes trailing whitespace from lines +/// +/// Useful because most editors strip trailing whitespace (in test fixtures) +/// but commands emit newlines +/// with leading spaces. These can be sanatized by removing trailing whitespace. +#[allow(dead_code)] +pub(crate) fn strip_trailing_whitespace(s: impl AsRef) -> String { + LinesWithEndings::from(s.as_ref()) + .map(|line| { + // Remove empty indented lines + TRAILING_WHITESPACE_RE.replace(line, "\n").to_string() + }) + .collect::() +} + +#[cfg(test)] +mod test { + use super::*; + use std::fmt::Write; + + #[test] + fn test_trailing_whitespace() { + let actual = strip_trailing_whitespace("hello \n"); + assert_eq!("hello\n", &actual); + + let actual = strip_trailing_whitespace("hello\n \nworld\n"); + assert_eq!("hello\n\nworld\n", &actual); + } + + #[test] + fn test_lines_with_endings() { + let actual = LinesWithEndings::from("foo\nbar").fold(String::new(), |mut output, line| { + let _ = write!(output, "z{line}"); + output + }); + + assert_eq!("zfoo\nzbar", actual); + + let actual = + LinesWithEndings::from("foo\nbar\n").fold(String::new(), |mut output, line| { + let _ = write!(output, "z{line}"); + output + }); + + assert_eq!("zfoo\nzbar\n", actual); + } +} diff --git a/libherokubuildpack/src/output/warn_later.rs b/libherokubuildpack/src/output/warn_later.rs new file mode 100644 index 00000000..87395ac6 --- /dev/null +++ b/libherokubuildpack/src/output/warn_later.rs @@ -0,0 +1,347 @@ +use std::cell::RefCell; +use std::fmt::{Debug, Display}; +use std::io::Write; +use std::marker::PhantomData; +use std::rc::Rc; +use std::thread::ThreadId; + +pub type PhantomUnsync = PhantomData>; + +thread_local!(static WARN_LATER: RefCell>> = RefCell::new(None)); + +/// Queue a warning for later +/// +/// Build logs can be quite large and people don't always scroll back up to read every line. Delaying +/// a warning and emitting it right before the end of the build can increase the chances the app +/// developer will read it. +/// +/// ## Use - Setup a `WarnGuard` in your buildpack +/// +/// To ensure warnings are printed, even in the event of errors, you must create a `WarnGuard` +/// in your buildpack that will print any delayed warnings when dropped: +/// +/// ```no_run +/// // src/main.rs +/// use libherokubuildpack::output::warn_later::WarnGuard; +/// +/// // fn build(&self, context: BuildContext) -> libcnb::Result { +/// let warn_later = WarnGuard::new(std::io::stdout()); +/// // ... +/// +/// // Warnings will be emitted when the warn guard is dropped +/// drop(warn_later); +/// // } +/// ``` +/// +/// Alternatively you can manually print delayed warnings: +/// +/// ```no_run +/// use libherokubuildpack::output::warn_later::WarnGuard; +/// +/// // fn build(&self, context: BuildContext) -> libcnb::Result { +/// let warn_later = WarnGuard::new(std::io::stdout()); +/// // ... +/// +/// // Consumes the guard, prints and clears any delayed warnings. +/// warn_later.warn_now(); +/// // } +/// ``` +/// +/// ## Use - Issue a delayed warning +/// +/// Once a warn guard is in place you can queue a warning using `section_log::log_warning_later` or `build_log::*`: +/// +/// ``` +/// use libherokubuildpack::output::warn_later::WarnGuard; +/// use libherokubuildpack::output::build_log::*; +/// +/// // src/main.rs +/// let warn_later = WarnGuard::new(std::io::stdout()); +/// +/// BuildLog::new(std::io::stdout()) +/// .buildpack_name("Julius Caesar") +/// .announce() +/// .warn_later("Beware the ides of march"); +/// ``` +/// +/// ``` +/// use libherokubuildpack::output::warn_later::WarnGuard; +/// use libherokubuildpack::output::section_log::log_warning_later; +/// +/// // src/main.rs +/// let warn_later = WarnGuard::new(std::io::stdout()); +/// +/// // src/layers/greenday.rs +/// log_warning_later("WARNING: Live without warning"); +/// ``` + +/// Pushes a string to a thread local warning vec for to be emitted later +/// +/// # Errors +/// +/// If the internal `WARN_LATER` option is `None` this will emit a `WarnLaterError` because +/// the function call might not be visible to the application owner using the buildpack. +/// +/// This state can happen if no `WarnGuard` is created in the thread where the delayed warning +/// message is trying to be pushed. It can also happen if multiple `WarnGuard`-s are created in the +/// same thread and one of them "takes" the contents before the others go out of scope. +/// +/// For best practice create one and only one `WarnGuard` per thread at a time to avoid this error +/// state. +pub(crate) fn try_push(s: impl AsRef) -> Result<(), WarnLaterError> { + WARN_LATER.with(|cell| match &mut *cell.borrow_mut() { + Some(warnings) => { + warnings.push(s.as_ref().to_string()); + Ok(()) + } + None => Err(WarnLaterError::MissingGuardForThread( + std::thread::current().id(), + )), + }) +} + +/// Ensures a warning vec is present and pushes to it +/// +/// Should only ever be used within a `WarnGuard`. +/// +/// The state where the warnings are taken, but a warn guard is still present +/// can happen when more than one warn guard is created in the same thread +fn force_push(s: impl AsRef) { + WARN_LATER.with(|cell| { + let option = &mut *cell.borrow_mut(); + option + .get_or_insert(Vec::new()) + .push(s.as_ref().to_string()); + }); +} + +/// Removes all delayed warnings from current thread +/// +/// Should only execute from within a `WarnGuard` +fn take() -> Option> { + WARN_LATER.with(|cell| cell.replace(None)) +} + +#[allow(clippy::module_name_repetitions)] +#[derive(Debug)] +pub enum WarnLaterError { + MissingGuardForThread(ThreadId), +} + +impl Display for WarnLaterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WarnLaterError::MissingGuardForThread(id) => { + writeln!( + f, + "Cannot use warn_later unless a WarnGuard has been created\n and not yet dropped in the current thread: {id:?}", + ) + } + } + } +} + +/// Delayed Warning emitter +/// +/// To use the delayed warnings feature you'll need to first register a guard. +/// This guard will emit warnings when it goes out of scope or when you manually force it +/// to emit warnings. +/// +/// This struct allows delayed warnings to be emitted even in the even there's an error. +/// +/// See the `warn_later` module docs for usage instructions. +/// +/// The internal design of this features relies on state tied to the current thread. +/// As a result, this struct is not send or sync: +/// +/// ```compile_fail +/// // Fails to compile +/// # // Do not remove this test, it is the only thing that asserts this is not sync +/// use libherokubuildpack::output::warn_later::WarnGuard; +/// +/// fn is_sync(t: impl Sync) {} +/// +/// is_sync(WarnGuard::new(std::io::stdout())) +/// ``` +/// +/// ```compile_fail +/// // Fails to compile +/// # // Do not remove this test, it is the only thing that asserts this is not send +/// use libherokubuildpack::output::warn_later::WarnGuard; +/// +/// fn is_send(t: impl Send) {} +/// +/// is_send(WarnGuard::new(std::io::stdout())) +/// ``` +/// +/// If you are warning in multiple threads you can pass queued warnings from one thread to another. +/// +/// ```rust +/// use libherokubuildpack::output::warn_later::{WarnGuard, DelayedWarnings}; +/// +/// let main_guard = WarnGuard::new(std::io::stdout()); +/// +/// let (delayed_send, delayed_recv) = std::sync::mpsc::channel::(); +/// +/// std::thread::spawn(move || { +/// let sub_guard = WarnGuard::new(std::io::stdout()); +/// // ... +/// delayed_send +/// .send(sub_guard.consume_quiet()) +/// .unwrap(); +/// }) +/// .join(); +/// +/// main_guard +/// .extend_warnings(delayed_recv.recv().unwrap()); +/// ``` +#[derive(Debug)] +pub struct WarnGuard +where + W: Write + Debug, +{ + // Private inner to force public construction through `new()` which tracks creation state per thread. + io: W, + /// The use of WarnGuard is directly tied to the thread where it was created + /// This forces the struct to not be send or sync + /// + /// To move warn later data between threads, drain quietly, send the data to another + /// thread, and re-apply those warnings to a WarnGuard in the other thread. + unsync: PhantomUnsync, +} + +impl WarnGuard +where + W: Write + Debug, +{ + #[must_use] + #[allow(clippy::new_without_default)] + pub fn new(io: W) -> Self { + WARN_LATER.with(|cell| { + let maybe_warnings = &mut *cell.borrow_mut(); + if let Some(warnings) = maybe_warnings.take() { + let _ = maybe_warnings.insert(warnings); + eprintln!("[Buildpack warning]: Multiple `WarnGuard`-s in thread {id:?}, this may cause unexpected delayed warning output", id = std::thread::current().id()); + } else { + let _ = maybe_warnings.insert(Vec::new()); + } + }); + + Self { + io, + unsync: PhantomData, + } + } + + /// Use to move warnings from a different thread into this one + pub fn extend_warnings(&self, warnings: DelayedWarnings) { + for warning in warnings.inner { + force_push(warning.clone()); + } + } + + /// Use to move warnings out of the current thread without emitting to the UI. + pub fn consume_quiet(self) -> DelayedWarnings { + DelayedWarnings { + inner: take().unwrap_or_default(), + } + } + + /// Consumes self, prints and drains all existing delayed warnings + pub fn warn_now(self) { + drop(self); + } +} + +impl Drop for WarnGuard +where + W: Write + Debug, +{ + fn drop(&mut self) { + if let Some(warnings) = take() { + if !warnings.is_empty() { + for warning in &warnings { + writeln!(&mut self.io).expect("warn guard IO is writeable"); + write!(&mut self.io, "{warning}").expect("warn guard IO is writeable"); + } + } + } + } +} + +/// Holds warnings from a consumed `WarnGuard` +/// +/// The intended use of this struct is to pass warnings from one `WarnGuard` to another. +#[derive(Debug)] +pub struct DelayedWarnings { + // Private inner, must be constructed within a WarnGuard + inner: Vec, +} + +#[cfg(test)] +mod test { + use super::*; + use crate::output::util::ReadYourWrite; + use libcnb_test::assert_contains; + + #[test] + fn test_warn_guard_registers_itself() { + // Err when a guard is not yet created + assert!(try_push("lol").is_err()); + + // Don't err when a guard is created + let _warn_guard = WarnGuard::new(Vec::new()); + try_push("lol").unwrap(); + } + + #[test] + fn test_logging_a_warning() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + let warn_guard = WarnGuard::new(writer); + drop(warn_guard); + + assert_eq!(String::new(), reader.read_lossy().unwrap()); + + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + let warn_guard = WarnGuard::new(writer); + let message = + "Possessing knowledge and performing an action are two entirely different processes"; + + try_push(message).unwrap(); + drop(warn_guard); + + assert_contains!(reader.read_lossy().unwrap(), message); + + // Assert empty after calling drain + assert!(take().is_none()); + } + + #[test] + fn test_delayed_warnings_on_drop() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + let guard = WarnGuard::new(writer); + + let message = "You don't have to have a reason to be tired. You don't have to earn rest or comfort. You're allowed to just be."; + try_push(message).unwrap(); + drop(guard); + + assert_contains!(reader.read_lossy().unwrap(), message); + } + + #[test] + fn does_not_double_whitespace() { + let writer = ReadYourWrite::writer(Vec::new()); + let reader = writer.reader(); + let guard = WarnGuard::new(writer); + + let message = "Caution: This test is hot\n"; + try_push(message).unwrap(); + drop(guard); + + let expected = "\nCaution: This test is hot\n".to_string(); + assert_eq!(expected, reader.read_lossy().unwrap()); + } +} From a5843fcabcf8668122148c7be60264b9cbfb09c7 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Fri, 19 Jan 2024 09:04:45 -0600 Subject: [PATCH 02/99] [Stacked PR] Remove warn_later from output (#760) * Refactor background timer logic The background timer prints dots to the UI while the buildpack performs some expensive operation. This requires we use threading and Rust really wants to be sure that we're not going to have data races and that our data is secure. The original version of the code relied on wrapping our IO in an Arc> so that we don't "lose" it when we pass it to the background thread. This worked, but was overly complicated based on my limited understanding of working with lifetimes and threads. This version instead relies on the ability to retrieve a struct when we join the thread to get the IO object back out. This reduces complexity that the BuildLog interface needs to know about. # This is the commit message #2: Inject tick state The tick values for the printer was hardcoded. Passing in the values allows us to remove the `style` module dependency and makes the tests simpler. # This is the commit message #3: Rename modules and add docs The prior name `print_dots` isn't technically valid anymore as it could be printing something else. I also moved the guard struct into it's own module to make it a bit safer (ensure that Option is always Some at creation time). # This is the commit message #4: Clarify intention that option is Some in code The debug_assert adds a stronger signal that the option must be Some when it is being constructed. * [Stacked PR] Remove warn_later from output The `warn_later` functionality relies on global state in a way that we're not thrilled about putting into everyone's build packs by default. This removes the feature, plain and simple. --- libherokubuildpack/src/output/background.rs | 213 +++++++++++ .../src/output/background_timer.rs | 157 -------- libherokubuildpack/src/output/build_log.rs | 117 ++---- libherokubuildpack/src/output/interface.rs | 1 - libherokubuildpack/src/output/mod.rs | 3 +- libherokubuildpack/src/output/section_log.rs | 5 - libherokubuildpack/src/output/warn_later.rs | 347 ------------------ 7 files changed, 244 insertions(+), 599 deletions(-) create mode 100644 libherokubuildpack/src/output/background.rs delete mode 100644 libherokubuildpack/src/output/background_timer.rs delete mode 100644 libherokubuildpack/src/output/warn_later.rs diff --git a/libherokubuildpack/src/output/background.rs b/libherokubuildpack/src/output/background.rs new file mode 100644 index 00000000..f3769cb5 --- /dev/null +++ b/libherokubuildpack/src/output/background.rs @@ -0,0 +1,213 @@ +use std::fmt::Debug; +use std::io::Write; +use std::sync::mpsc::channel; +use std::time::Duration; + +/// This module is responsible for the logic involved in the printing to output while +/// other work is being performed. Such as printing dots while a download is being performed + +/// Print dots to the given buffer at the given interval +/// +/// Returns a struct that allows for manually stopping the timer or will automatically stop +/// the timer if the guard is dropped. This functionality allows for errors that trigger +/// an exit of the function to not accidentally have a timer printing in the background +/// forever. +#[must_use] +pub(crate) fn print_interval( + mut buffer: W, + interval: Duration, + start: String, + tick: String, + end: String, +) -> state::PrintGuard +where + W: Write + Send + Debug + 'static, +{ + let (sender, receiver) = channel::<()>(); + + let join_handle = std::thread::spawn(move || { + write!(buffer, "{start}").expect("Internal error"); + buffer.flush().expect("Internal error"); + + loop { + write!(buffer, "{tick}").expect("Internal error"); + buffer.flush().expect("Internal error"); + + if receiver.recv_timeout(interval).is_ok() { + break; + } + } + + write!(buffer, "{end}").expect("Internal error"); + buffer.flush().expect("Internal error"); + + buffer + }); + + state::PrintGuard::new(join_handle, sender) +} + +pub(crate) mod state { + use std::fmt::Debug; + use std::io::Write; + use std::sync::mpsc::Sender; + use std::thread::JoinHandle; + + /// Holds the reference to the background printer + /// + /// Ensures that the dot printer is stopped in the event of an error. By signaling + /// it and joining when this struct is dropped. + /// + /// Gives access to the original io/buffer struct passed to the background writer + /// when the thread is manually stopped. + /// + /// # Panics + /// + /// Updates to this code need to take care to not introduce a panic. See + /// documentation in `PrintGuard::stop` below for more details. + #[derive(Debug)] + pub(crate) struct PrintGuard + where + W: Write + Debug, + { + /// Holds the handle to the thread printing dots + /// + /// Structs that implement `Drop` must ensure a valid internal state at + /// all times due to E0509. The handle is wrapped in an option to allow the + /// inner value to be removed while preserving internal state. + join_handle: Option>, + + /// Holds the signaling method to tell the background printer + /// to stop emitting. + stop_signal: Sender<()>, + } + + impl Drop for PrintGuard + where + W: Write + Debug, + { + fn drop(&mut self) { + // A note on correctness. It might seem that it's enough to signal the thread to + // stop, that we don't also have to join and wait for it to finish, but that's not + // the case. The printer can still emit a value after it's told to stop. + // + // When that happens the output can appear in the middle of another output, such + // as an error message if a global writer is being used such as stdout. + // As a result we have to signal AND ensure the thread is stoped before + // continuing. + if let Some(join_handle) = self.join_handle.take() { + let _ = self.stop_signal.send(()); + let _ = join_handle.join(); + } + } + } + + impl PrintGuard + where + W: Write + Debug, + { + /// Preserve internal state by ensuring the `Option` is always populated + pub(crate) fn new(join_handle: JoinHandle, sender: Sender<()>) -> Self { + let guard = PrintGuard { + join_handle: Some(join_handle), + stop_signal: sender, + }; + debug_assert!(guard.join_handle.is_some()); + + guard + } + + /// The only thing a consumer can do is stop the dots and receive + /// the original buffer. + /// + /// # Panics + /// + /// This code can panic if it encounters an unexpedcted internal state. + /// If that happens it means there is an internal bug in this logic. + /// To avoid a panic, developers modifying this file must obey the following + /// rules: + /// + /// - Always consume the struct when accessing the Option value outside of Drop + /// - Never construct this struct with a `None` option value. + /// + /// This `Option` wrapping is needed to support a implementing Drop to + /// ensure the printing is stopped. When a struct implements drop it cannot + /// remove it's internal state due to E0509: + /// + /// + /// + /// The workaround is to never allow invalid internal state by replacing the + /// inner value with a `None` when removing it. We don't want to expose this + /// implementation detail to the user, so instead we accept the panic, ensure + /// the code is exercised under test, and exhaustively document why this panic + /// exists and how developers working with this code can maintain safety. + #[allow(clippy::panic_in_result_fn)] + pub(crate) fn stop(mut self) -> std::thread::Result { + // Ignore if the channel is closed, likely means the thread died which + // we want in this case. + match self.join_handle.take() { + Some(join_handle) => { + let _ = self.stop_signal.send(()); + join_handle.join() + } + None => panic!("Internal error: Dot print internal state should never be None"), + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use std::fs::{File, OpenOptions}; + use tempfile::NamedTempFile; + + #[test] + fn does_stop_does_not_panic() { + let mut buffer: Vec = vec![]; + write!(buffer, "before").unwrap(); + + let dot = print_interval( + buffer, + Duration::from_millis(1), + String::from(" ."), + String::from("."), + String::from(". "), + ); + let mut writer = dot.stop().unwrap(); + + write!(writer, "after").unwrap(); + writer.flush().unwrap(); + + assert_eq!("before ... after", String::from_utf8_lossy(&writer)); + } + + #[test] + fn test_drop_stops_timer() { + let tempfile = NamedTempFile::new().unwrap(); + let mut log = File::create(tempfile.path()).unwrap(); + write!(log, "before").unwrap(); + + let dot = print_interval( + log, + Duration::from_millis(1), + String::from(" ."), + String::from("."), + String::from(". "), + ); + drop(dot); + + let mut log = OpenOptions::new() + .append(true) + .write(true) + .open(tempfile.path()) + .unwrap(); + write!(log, "after").unwrap(); + log.flush().unwrap(); + + assert_eq!( + String::from("before ... after"), + std::fs::read_to_string(tempfile.path()).unwrap() + ); + } +} diff --git a/libherokubuildpack/src/output/background_timer.rs b/libherokubuildpack/src/output/background_timer.rs deleted file mode 100644 index f3c7f187..00000000 --- a/libherokubuildpack/src/output/background_timer.rs +++ /dev/null @@ -1,157 +0,0 @@ -use std::io::Write; -use std::sync::mpsc::Sender; -use std::sync::{mpsc, Arc, Mutex}; -use std::thread::JoinHandle; -use std::time::{Duration, Instant}; - -/// This module is responsible for the logic involved in the printing to output while -/// other work is being performed. - -/// Prints a start, then a tick every second, and an end to the given `Write` value. -/// -/// Returns a struct that allows for manually stopping the timer or will automatically stop -/// the timer if the guard is dropped. This functionality allows for errors that trigger -/// an exit of the function to not accidentally have a timer printing in the background -/// forever. -pub(crate) fn start_timer( - arc_io: &Arc>, - tick_duration: Duration, - start: impl AsRef, - tick: impl AsRef, - end: impl AsRef, -) -> StopJoinGuard -where - // The 'static lifetime means as long as something holds a reference to it, nothing it references - // will go away. - // - // From https://users.rust-lang.org/t/why-does-thread-spawn-need-static-lifetime-for-generic-bounds/4541 - // - // [lifetimes] refer to the minimum possible lifetime of any borrowed references that the object contains. - T: Write + Send + Sync + 'static, -{ - let instant = Instant::now(); - let (sender, receiver) = mpsc::channel::<()>(); - let start = start.as_ref().to_string(); - let tick = tick.as_ref().to_string(); - let end = end.as_ref().to_string(); - - let arc_io = arc_io.clone(); - let handle = std::thread::spawn(move || { - let mut io = arc_io.lock().expect("Logging mutex poisoned"); - write!(&mut io, "{start}").expect("Internal error"); - io.flush().expect("Internal error"); - loop { - write!(&mut io, "{tick}").expect("Internal error"); - io.flush().expect("Internal error"); - - if receiver.recv_timeout(tick_duration).is_ok() { - write!(&mut io, "{end}").expect("Internal error"); - io.flush().expect("Internal error"); - break; - } - } - }); - - StopJoinGuard { - inner: Some(StopTimer { - handle: Some(handle), - sender: Some(sender), - instant, - }), - } -} - -/// Responsible for stopping a running timer thread -#[derive(Debug)] -pub(crate) struct StopTimer { - instant: Instant, - handle: Option>, - sender: Option>, -} - -impl StopTimer { - pub(crate) fn elapsed(&self) -> Duration { - self.instant.elapsed() - } -} - -pub(crate) trait StopJoin: std::fmt::Debug { - fn stop_join(self) -> Self; -} - -impl StopJoin for StopTimer { - fn stop_join(mut self) -> Self { - if let Some(inner) = self.sender.take() { - inner.send(()).expect("Internal error"); - } - - if let Some(inner) = self.handle.take() { - inner.join().expect("Internal error"); - } - - self - } -} - -// Guarantees that stop is called on the inner -#[derive(Debug)] -pub(crate) struct StopJoinGuard { - inner: Option, -} - -impl StopJoinGuard { - /// Since this consumes self and `stop_join` consumes - /// the inner, the option will never be empty unless - /// it was created with a None inner. - /// - /// Since inner is private we guarantee it's always Some - /// until this struct is consumed. - pub(crate) fn stop(mut self) -> T { - self.inner - .take() - .map(StopJoin::stop_join) - .expect("Internal error: Should never panic, codepath tested") - } -} - -impl Drop for StopJoinGuard { - fn drop(&mut self) { - if let Some(inner) = self.inner.take() { - inner.stop_join(); - } - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::output::util::ReadYourWrite; - use libcnb_test::assert_contains; - use std::thread::sleep; - - #[test] - fn does_stop_does_not_panic() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - let done = start_timer(&writer.arc_io(), Duration::from_millis(1), " .", ".", ". "); - - let _ = done.stop(); - - assert_contains!(String::from_utf8_lossy(&reader.lock().unwrap()), " ... "); - } - - #[test] - fn test_drop_stops_timer() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - let done = start_timer(&writer.arc_io(), Duration::from_millis(1), " .", ".", ". "); - - drop(done); - sleep(Duration::from_millis(2)); - - let before = String::from_utf8_lossy(&reader.lock().unwrap()).to_string(); - sleep(Duration::from_millis(100)); - let after = String::from_utf8_lossy(&reader.lock().unwrap()).to_string(); - assert_eq!(before, after); - } -} diff --git a/libherokubuildpack/src/output/build_log.rs b/libherokubuildpack/src/output/build_log.rs index c7564aa1..966c2de4 100644 --- a/libherokubuildpack/src/output/build_log.rs +++ b/libherokubuildpack/src/output/build_log.rs @@ -1,4 +1,4 @@ -use crate::output::background_timer::{start_timer, StopJoinGuard, StopTimer}; +use crate::output::background::{print_interval, state::PrintGuard}; #[allow(clippy::wildcard_imports)] pub use crate::output::interface::*; use crate::output::style; @@ -156,17 +156,23 @@ where } fn step_timed(self: Box, s: &str) -> Box { - let start = style::step(format!("{s}{}", style::background_timer_start())); - let tick = style::background_timer_tick(); - let end = style::background_timer_end(); + let mut io = self.io; + let data = self.data; + let timer = Instant::now(); - let arc_io = Arc::new(Mutex::new(self.io)); - let background = start_timer(&arc_io, Duration::from_secs(1), start, tick, end); + write_now(&mut io, style::step(s)); + let dot_printer = print_interval( + io, + Duration::from_secs(1), + style::background_timer_start(), + style::background_timer_tick(), + style::background_timer_end(), + ); Box::new(FinishTimedStep { - arc_io, - background, - data: self.data, + data, + timer, + dot_printer, }) } @@ -236,19 +242,6 @@ where writeln_now(&mut self.io, style::important(s.trim())); writeln_now(&mut self.io, ""); } - - fn log_warn_later_shared(&mut self, s: &str) { - let mut formatted = style::warning(s.trim()); - formatted.push('\n'); - - match crate::output::warn_later::try_push(formatted) { - Ok(()) => {} - Err(error) => { - eprintln!("[Buildpack Warning]: Cannot use the delayed warning feature due to error: {error}"); - self.log_warning_shared(s); - } - }; - } } impl ErrorLogger for AnnounceBuildLog @@ -277,15 +270,6 @@ where self } - fn warn_later( - mut self: Box, - s: &str, - ) -> Box> { - self.log_warn_later_shared(s); - - self - } - fn important( mut self: Box, s: &str, @@ -315,14 +299,6 @@ where self } - fn warn_later( - mut self: Box, - s: &str, - ) -> Box> { - self.log_warn_later_shared(s); - self - } - fn important( mut self: Box, s: &str, @@ -448,10 +424,13 @@ where /// /// Used to end a background inline timer i.e. Installing ...... (<0.1s) #[derive(Debug)] -struct FinishTimedStep { +struct FinishTimedStep +where + W: Write + Debug, +{ data: BuildData, - arc_io: Arc>, - background: StopJoinGuard, + timer: Instant, + dot_printer: PrintGuard, } impl TimedStepLogger for FinishTimedStep @@ -460,14 +439,20 @@ where { fn finish_timed_step(self: Box) -> Box { // Must stop background writing thread before retrieving IO - let duration = self.background.stop().elapsed(); - let mut io = try_unwrap_arc_io(self.arc_io); + let data = self.data; + let timer = self.timer; + + let mut io = match self.dot_printer.stop() { + Ok(io) => io, + Err(e) => std::panic::resume_unwind(e), + }; + let duration = timer.elapsed(); writeln_now(&mut io, style::details(style::time::human(&duration))); Box::new(BuildLog { io, - data: self.data, + data, state: PhantomData::, }) } @@ -499,7 +484,6 @@ mod test { use crate::command::CommandExt; use crate::output::style::{self, strip_control_codes}; use crate::output::util::{strip_trailing_whitespace, ReadYourWrite}; - use crate::output::warn_later::WarnGuard; use indoc::formatdoc; use libcnb_test::assert_contains; use pretty_assertions::assert_eq; @@ -641,47 +625,6 @@ mod test { assert_eq!(expected, strip_control_codes(reader.read_lossy().unwrap())); } - #[test] - fn warn_later_doesnt_output_newline() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - - let warn_later = WarnGuard::new(writer.clone()); - BuildLog::new(writer) - .buildpack_name("Walkin' on the Sun") - .section("So don't delay, act now, supplies are running out") - .step("Allow if you're still alive, six to eight years to arrive") - .step("And if you follow, there may be a tomorrow") - .announce() - .warn_later("And all that glitters is gold") - .warn_later("Only shooting stars break the mold") - .end_announce() - .step("But if the offer's shunned") - .step("You might as well be walking on the Sun") - .end_section() - .finish_logging(); - - drop(warn_later); - - let expected = formatdoc! {" - - # Walkin' on the Sun - - - So don't delay, act now, supplies are running out - - Allow if you're still alive, six to eight years to arrive - - And if you follow, there may be a tomorrow - - But if the offer's shunned - - You might as well be walking on the Sun - - Done (finished in < 0.1s) - - ! And all that glitters is gold - - ! Only shooting stars break the mold - "}; - - assert_eq!(expected, strip_control_codes(reader.read_lossy().unwrap())); - } - #[test] fn announce_and_exit_makes_no_whitespace() { let writer = ReadYourWrite::writer(Vec::new()); diff --git a/libherokubuildpack/src/output/interface.rs b/libherokubuildpack/src/output/interface.rs index 46dc615b..52aa1da5 100644 --- a/libherokubuildpack/src/output/interface.rs +++ b/libherokubuildpack/src/output/interface.rs @@ -34,7 +34,6 @@ pub trait AnnounceLogger: ErrorLogger + Debug { type ReturnTo; fn warning(self: Box, s: &str) -> Box>; - fn warn_later(self: Box, s: &str) -> Box>; fn important(self: Box, s: &str) -> Box>; fn end_announce(self: Box) -> Self::ReturnTo; diff --git a/libherokubuildpack/src/output/mod.rs b/libherokubuildpack/src/output/mod.rs index a862a9c9..b3b211ef 100644 --- a/libherokubuildpack/src/output/mod.rs +++ b/libherokubuildpack/src/output/mod.rs @@ -1,7 +1,6 @@ -mod background_timer; +pub(crate) mod background; pub mod build_log; pub mod interface; pub mod section_log; pub mod style; mod util; -pub mod warn_later; diff --git a/libherokubuildpack/src/output/section_log.rs b/libherokubuildpack/src/output/section_log.rs index c5781081..ac1d4e30 100644 --- a/libherokubuildpack/src/output/section_log.rs +++ b/libherokubuildpack/src/output/section_log.rs @@ -108,11 +108,6 @@ pub fn log_warning(s: impl AsRef) { logger().announce().warning(s.as_ref()); } -/// Print an warning block to the output at a later time -pub fn log_warning_later(s: impl AsRef) { - logger().announce().warn_later(s.as_ref()); -} - /// Print an important block to the output pub fn log_important(s: impl AsRef) { logger().announce().important(s.as_ref()); diff --git a/libherokubuildpack/src/output/warn_later.rs b/libherokubuildpack/src/output/warn_later.rs deleted file mode 100644 index 87395ac6..00000000 --- a/libherokubuildpack/src/output/warn_later.rs +++ /dev/null @@ -1,347 +0,0 @@ -use std::cell::RefCell; -use std::fmt::{Debug, Display}; -use std::io::Write; -use std::marker::PhantomData; -use std::rc::Rc; -use std::thread::ThreadId; - -pub type PhantomUnsync = PhantomData>; - -thread_local!(static WARN_LATER: RefCell>> = RefCell::new(None)); - -/// Queue a warning for later -/// -/// Build logs can be quite large and people don't always scroll back up to read every line. Delaying -/// a warning and emitting it right before the end of the build can increase the chances the app -/// developer will read it. -/// -/// ## Use - Setup a `WarnGuard` in your buildpack -/// -/// To ensure warnings are printed, even in the event of errors, you must create a `WarnGuard` -/// in your buildpack that will print any delayed warnings when dropped: -/// -/// ```no_run -/// // src/main.rs -/// use libherokubuildpack::output::warn_later::WarnGuard; -/// -/// // fn build(&self, context: BuildContext) -> libcnb::Result { -/// let warn_later = WarnGuard::new(std::io::stdout()); -/// // ... -/// -/// // Warnings will be emitted when the warn guard is dropped -/// drop(warn_later); -/// // } -/// ``` -/// -/// Alternatively you can manually print delayed warnings: -/// -/// ```no_run -/// use libherokubuildpack::output::warn_later::WarnGuard; -/// -/// // fn build(&self, context: BuildContext) -> libcnb::Result { -/// let warn_later = WarnGuard::new(std::io::stdout()); -/// // ... -/// -/// // Consumes the guard, prints and clears any delayed warnings. -/// warn_later.warn_now(); -/// // } -/// ``` -/// -/// ## Use - Issue a delayed warning -/// -/// Once a warn guard is in place you can queue a warning using `section_log::log_warning_later` or `build_log::*`: -/// -/// ``` -/// use libherokubuildpack::output::warn_later::WarnGuard; -/// use libherokubuildpack::output::build_log::*; -/// -/// // src/main.rs -/// let warn_later = WarnGuard::new(std::io::stdout()); -/// -/// BuildLog::new(std::io::stdout()) -/// .buildpack_name("Julius Caesar") -/// .announce() -/// .warn_later("Beware the ides of march"); -/// ``` -/// -/// ``` -/// use libherokubuildpack::output::warn_later::WarnGuard; -/// use libherokubuildpack::output::section_log::log_warning_later; -/// -/// // src/main.rs -/// let warn_later = WarnGuard::new(std::io::stdout()); -/// -/// // src/layers/greenday.rs -/// log_warning_later("WARNING: Live without warning"); -/// ``` - -/// Pushes a string to a thread local warning vec for to be emitted later -/// -/// # Errors -/// -/// If the internal `WARN_LATER` option is `None` this will emit a `WarnLaterError` because -/// the function call might not be visible to the application owner using the buildpack. -/// -/// This state can happen if no `WarnGuard` is created in the thread where the delayed warning -/// message is trying to be pushed. It can also happen if multiple `WarnGuard`-s are created in the -/// same thread and one of them "takes" the contents before the others go out of scope. -/// -/// For best practice create one and only one `WarnGuard` per thread at a time to avoid this error -/// state. -pub(crate) fn try_push(s: impl AsRef) -> Result<(), WarnLaterError> { - WARN_LATER.with(|cell| match &mut *cell.borrow_mut() { - Some(warnings) => { - warnings.push(s.as_ref().to_string()); - Ok(()) - } - None => Err(WarnLaterError::MissingGuardForThread( - std::thread::current().id(), - )), - }) -} - -/// Ensures a warning vec is present and pushes to it -/// -/// Should only ever be used within a `WarnGuard`. -/// -/// The state where the warnings are taken, but a warn guard is still present -/// can happen when more than one warn guard is created in the same thread -fn force_push(s: impl AsRef) { - WARN_LATER.with(|cell| { - let option = &mut *cell.borrow_mut(); - option - .get_or_insert(Vec::new()) - .push(s.as_ref().to_string()); - }); -} - -/// Removes all delayed warnings from current thread -/// -/// Should only execute from within a `WarnGuard` -fn take() -> Option> { - WARN_LATER.with(|cell| cell.replace(None)) -} - -#[allow(clippy::module_name_repetitions)] -#[derive(Debug)] -pub enum WarnLaterError { - MissingGuardForThread(ThreadId), -} - -impl Display for WarnLaterError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - WarnLaterError::MissingGuardForThread(id) => { - writeln!( - f, - "Cannot use warn_later unless a WarnGuard has been created\n and not yet dropped in the current thread: {id:?}", - ) - } - } - } -} - -/// Delayed Warning emitter -/// -/// To use the delayed warnings feature you'll need to first register a guard. -/// This guard will emit warnings when it goes out of scope or when you manually force it -/// to emit warnings. -/// -/// This struct allows delayed warnings to be emitted even in the even there's an error. -/// -/// See the `warn_later` module docs for usage instructions. -/// -/// The internal design of this features relies on state tied to the current thread. -/// As a result, this struct is not send or sync: -/// -/// ```compile_fail -/// // Fails to compile -/// # // Do not remove this test, it is the only thing that asserts this is not sync -/// use libherokubuildpack::output::warn_later::WarnGuard; -/// -/// fn is_sync(t: impl Sync) {} -/// -/// is_sync(WarnGuard::new(std::io::stdout())) -/// ``` -/// -/// ```compile_fail -/// // Fails to compile -/// # // Do not remove this test, it is the only thing that asserts this is not send -/// use libherokubuildpack::output::warn_later::WarnGuard; -/// -/// fn is_send(t: impl Send) {} -/// -/// is_send(WarnGuard::new(std::io::stdout())) -/// ``` -/// -/// If you are warning in multiple threads you can pass queued warnings from one thread to another. -/// -/// ```rust -/// use libherokubuildpack::output::warn_later::{WarnGuard, DelayedWarnings}; -/// -/// let main_guard = WarnGuard::new(std::io::stdout()); -/// -/// let (delayed_send, delayed_recv) = std::sync::mpsc::channel::(); -/// -/// std::thread::spawn(move || { -/// let sub_guard = WarnGuard::new(std::io::stdout()); -/// // ... -/// delayed_send -/// .send(sub_guard.consume_quiet()) -/// .unwrap(); -/// }) -/// .join(); -/// -/// main_guard -/// .extend_warnings(delayed_recv.recv().unwrap()); -/// ``` -#[derive(Debug)] -pub struct WarnGuard -where - W: Write + Debug, -{ - // Private inner to force public construction through `new()` which tracks creation state per thread. - io: W, - /// The use of WarnGuard is directly tied to the thread where it was created - /// This forces the struct to not be send or sync - /// - /// To move warn later data between threads, drain quietly, send the data to another - /// thread, and re-apply those warnings to a WarnGuard in the other thread. - unsync: PhantomUnsync, -} - -impl WarnGuard -where - W: Write + Debug, -{ - #[must_use] - #[allow(clippy::new_without_default)] - pub fn new(io: W) -> Self { - WARN_LATER.with(|cell| { - let maybe_warnings = &mut *cell.borrow_mut(); - if let Some(warnings) = maybe_warnings.take() { - let _ = maybe_warnings.insert(warnings); - eprintln!("[Buildpack warning]: Multiple `WarnGuard`-s in thread {id:?}, this may cause unexpected delayed warning output", id = std::thread::current().id()); - } else { - let _ = maybe_warnings.insert(Vec::new()); - } - }); - - Self { - io, - unsync: PhantomData, - } - } - - /// Use to move warnings from a different thread into this one - pub fn extend_warnings(&self, warnings: DelayedWarnings) { - for warning in warnings.inner { - force_push(warning.clone()); - } - } - - /// Use to move warnings out of the current thread without emitting to the UI. - pub fn consume_quiet(self) -> DelayedWarnings { - DelayedWarnings { - inner: take().unwrap_or_default(), - } - } - - /// Consumes self, prints and drains all existing delayed warnings - pub fn warn_now(self) { - drop(self); - } -} - -impl Drop for WarnGuard -where - W: Write + Debug, -{ - fn drop(&mut self) { - if let Some(warnings) = take() { - if !warnings.is_empty() { - for warning in &warnings { - writeln!(&mut self.io).expect("warn guard IO is writeable"); - write!(&mut self.io, "{warning}").expect("warn guard IO is writeable"); - } - } - } - } -} - -/// Holds warnings from a consumed `WarnGuard` -/// -/// The intended use of this struct is to pass warnings from one `WarnGuard` to another. -#[derive(Debug)] -pub struct DelayedWarnings { - // Private inner, must be constructed within a WarnGuard - inner: Vec, -} - -#[cfg(test)] -mod test { - use super::*; - use crate::output::util::ReadYourWrite; - use libcnb_test::assert_contains; - - #[test] - fn test_warn_guard_registers_itself() { - // Err when a guard is not yet created - assert!(try_push("lol").is_err()); - - // Don't err when a guard is created - let _warn_guard = WarnGuard::new(Vec::new()); - try_push("lol").unwrap(); - } - - #[test] - fn test_logging_a_warning() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - let warn_guard = WarnGuard::new(writer); - drop(warn_guard); - - assert_eq!(String::new(), reader.read_lossy().unwrap()); - - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - let warn_guard = WarnGuard::new(writer); - let message = - "Possessing knowledge and performing an action are two entirely different processes"; - - try_push(message).unwrap(); - drop(warn_guard); - - assert_contains!(reader.read_lossy().unwrap(), message); - - // Assert empty after calling drain - assert!(take().is_none()); - } - - #[test] - fn test_delayed_warnings_on_drop() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - let guard = WarnGuard::new(writer); - - let message = "You don't have to have a reason to be tired. You don't have to earn rest or comfort. You're allowed to just be."; - try_push(message).unwrap(); - drop(guard); - - assert_contains!(reader.read_lossy().unwrap(), message); - } - - #[test] - fn does_not_double_whitespace() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - let guard = WarnGuard::new(writer); - - let message = "Caution: This test is hot\n"; - try_push(message).unwrap(); - drop(guard); - - let expected = "\nCaution: This test is hot\n".to_string(); - assert_eq!(expected, reader.read_lossy().unwrap()); - } -} From d4b83f99b736840a4ae09b13a21bad84b6975ae1 Mon Sep 17 00:00:00 2001 From: Schneems Date: Wed, 24 Jan 2024 15:40:27 -0600 Subject: [PATCH 03/99] Removes the boxed trait state machine pattern and replaces it with struct returns. Boxed traits proved to be difficult to work with. On the user side: the buildpack user mostly interacts with associated methods on `BuildLog` they cannot be called unless the traits that implement them are in scope so the user was forced to have a `*` import. On the implementation side, boxed traits are limited. For example, there was no way to have an associated function that accepts a `Fn` or `FnOnce` to call it and return itself. The boxed trait state machine pattern was a useful exercise in refining the shape of the API, but it is no longer bringing joy. With that, we wish it farewell. --- .../examples/print_style_guide.rs | 12 +- libherokubuildpack/src/output/build_log.rs | 237 ++++++++---------- libherokubuildpack/src/output/interface.rs | 53 ---- libherokubuildpack/src/output/mod.rs | 1 - libherokubuildpack/src/output/section_log.rs | 97 ++++--- 5 files changed, 158 insertions(+), 242 deletions(-) delete mode 100644 libherokubuildpack/src/output/interface.rs diff --git a/libherokubuildpack/examples/print_style_guide.rs b/libherokubuildpack/examples/print_style_guide.rs index aa907f47..8a18d43e 100644 --- a/libherokubuildpack/examples/print_style_guide.rs +++ b/libherokubuildpack/examples/print_style_guide.rs @@ -4,7 +4,7 @@ use indoc::formatdoc; use libherokubuildpack::output::style::{self, DEBUG_INFO, HELP}; #[allow(clippy::wildcard_imports)] use libherokubuildpack::output::{ - build_log::*, + build_log::BuildLog, section_log::{log_step, log_step_stream, log_step_timed}, }; use std::io::stdout; @@ -81,8 +81,7 @@ fn main() { command .stream_output(stream.io(), stream.io()) .expect("Implement real error handling in real apps"); - log = stream.finish_timed_stream().end_section(); - drop(log); + stream.finish_timed_stream().end_section(); } { @@ -174,10 +173,8 @@ fn main() { } { - let mut log = BuildLog::new(stdout()).buildpack_name("Formatting helpers"); - - log = log - .section("The style module") + let log = BuildLog::new(stdout()).buildpack_name("Formatting helpers"); + log.section("The style module") .step(&formatdoc! {" Formatting helpers can be used to enhance log output: "}) @@ -223,6 +220,5 @@ fn main() { ]; table.print(data); - drop(log); } } diff --git a/libherokubuildpack/src/output/build_log.rs b/libherokubuildpack/src/output/build_log.rs index 966c2de4..a2dfd5b4 100644 --- a/libherokubuildpack/src/output/build_log.rs +++ b/libherokubuildpack/src/output/build_log.rs @@ -1,6 +1,26 @@ +//! # Build output logging +//! +//! Use the `BuildLog` to output structured text as a buildpack is executing +//! +//! ``` +//! use libherokubuildpack::output::build_log::BuildLog; +//! +//! let mut logger = BuildLog::new(std::io::stdout()) +//! .buildpack_name("Heroku Ruby Buildpack"); +//! +//! logger = logger +//! .section("Ruby version") +//! .step_timed("Installing") +//! .finish_timed_step() +//! .end_section(); +//! +//! logger.finish_logging(); +//! ``` +//! +//! To log inside of a layer see `section_log`. +//! +//! For usage details run `cargo run --bin print_style_guide` use crate::output::background::{print_interval, state::PrintGuard}; -#[allow(clippy::wildcard_imports)] -pub use crate::output::interface::*; use crate::output::style; use std::fmt::Debug; use std::io::Write; @@ -8,29 +28,7 @@ use std::marker::PhantomData; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -/// # Build output logging -/// -/// Use the `BuildLog` to output structured text as a buildpack is executing -/// -/// ``` -/// use libherokubuildpack::output::build_log::*; -/// -/// let mut logger = BuildLog::new(std::io::stdout()) -/// .buildpack_name("Heroku Ruby Buildpack"); -/// -/// logger = logger -/// .section("Ruby version") -/// .step_timed("Installing") -/// .finish_timed_step() -/// .end_section(); -/// -/// logger.finish_logging(); -/// ``` -/// -/// To log inside of a layer see `section_log`. -/// -/// For usage details run `cargo run --bin print_style_guide` - +/// See the module docs for example usage #[allow(clippy::module_name_repetitions)] #[derive(Debug)] pub struct BuildLog { @@ -79,83 +77,76 @@ where data: BuildData::default(), } } -} -impl Logger for BuildLog -where - W: Write + Send + Sync + Debug + 'static, -{ - fn buildpack_name(mut self, buildpack_name: &str) -> Box { + pub fn buildpack_name(mut self, buildpack_name: &str) -> BuildLog { write_now( &mut self.io, format!("{}\n\n", style::header(buildpack_name)), ); - Box::new(BuildLog { + BuildLog { io: self.io, data: self.data, state: PhantomData::, - }) + } } - fn without_buildpack_name(self) -> Box { - Box::new(BuildLog { + pub fn without_buildpack_name(self) -> BuildLog { + BuildLog { io: self.io, data: self.data, state: PhantomData::, - }) + } } } -impl StartedLogger for BuildLog +impl BuildLog where W: Write + Send + Sync + Debug + 'static, { - fn section(mut self: Box, s: &str) -> Box { + pub fn section(mut self, s: &str) -> BuildLog { writeln_now(&mut self.io, style::section(s)); - Box::new(BuildLog { + BuildLog { io: self.io, data: self.data, state: PhantomData::, - }) + } } - fn finish_logging(mut self: Box) { + pub fn finish_logging(mut self) { let elapsed = style::time::human(&self.data.started.elapsed()); let details = style::details(format!("finished in {elapsed}")); writeln_now(&mut self.io, style::section(format!("Done {details}"))); } - fn announce(self: Box) -> Box>> { - Box::new(AnnounceBuildLog { + pub fn announce(self) -> AnnounceLog { + AnnounceLog { io: self.io, data: self.data, state: PhantomData::, leader: Some("\n".to_string()), - }) + } } } -impl SectionLogger for BuildLog + +impl BuildLog where W: Write + Send + Sync + Debug + 'static, { - fn mut_step(&mut self, s: &str) { + pub fn mut_step(&mut self, s: &str) { writeln_now(&mut self.io, style::step(s)); } - fn step(mut self: Box, s: &str) -> Box { + #[must_use] + pub fn step(mut self, s: &str) -> BuildLog { self.mut_step(s); - Box::new(BuildLog { - io: self.io, - state: PhantomData::, - data: self.data, - }) + self } - fn step_timed(self: Box, s: &str) -> Box { + pub fn step_timed(self, s: &str) -> BackgroundLog { let mut io = self.io; let data = self.data; let timer = Instant::now(); @@ -169,49 +160,49 @@ where style::background_timer_end(), ); - Box::new(FinishTimedStep { + BackgroundLog { data, timer, dot_printer, - }) + } } - fn step_timed_stream(mut self: Box, s: &str) -> Box { + pub fn step_timed_stream(mut self, s: &str) -> StreamLog { self.mut_step(s); let started = Instant::now(); let arc_io = Arc::new(Mutex::new(self.io)); - let mut stream = StreamTimed { + let mut stream = StreamLog { arc_io, data: self.data, started, }; stream.start(); - Box::new(stream) + stream } - fn end_section(self: Box) -> Box { - Box::new(BuildLog { + pub fn end_section(self) -> BuildLog { + BuildLog { io: self.io, data: self.data, state: PhantomData::, - }) + } } - fn announce(self: Box) -> Box>> { - Box::new(AnnounceBuildLog { + pub fn announce(self) -> AnnounceLog { + AnnounceLog { io: self.io, data: self.data, state: PhantomData::, leader: Some("\n".to_string()), - }) + } } } // Store internal state, print leading character exactly once on warning or important #[derive(Debug)] -struct AnnounceBuildLog +pub struct AnnounceLog where W: Write + Send + Sync + Debug + 'static, { @@ -221,7 +212,7 @@ where leader: Option, } -impl AnnounceBuildLog +impl AnnounceLog where T: Debug, W: Write + Send + Sync + Debug + 'static, @@ -244,12 +235,12 @@ where } } -impl ErrorLogger for AnnounceBuildLog +impl AnnounceLog where T: Debug, W: Write + Send + Sync + Debug + 'static, { - fn error(mut self: Box, s: &str) { + pub fn error(mut self, s: &str) { if let Some(leader) = self.leader.take() { write_now(&mut self.io, leader); } @@ -258,61 +249,56 @@ where } } -impl AnnounceLogger for AnnounceBuildLog +impl AnnounceLog where W: Write + Send + Sync + Debug + 'static, { - type ReturnTo = Box; - - fn warning(mut self: Box, s: &str) -> Box> { + #[must_use] + pub fn warning(mut self, s: &str) -> AnnounceLog { self.log_warning_shared(s); self } - fn important( - mut self: Box, - s: &str, - ) -> Box> { + #[must_use] + pub fn important(mut self, s: &str) -> AnnounceLog { self.log_important_shared(s); self } - fn end_announce(self: Box) -> Box { - Box::new(BuildLog { + pub fn end_announce(self) -> BuildLog { + BuildLog { io: self.io, data: self.data, state: PhantomData::, - }) + } } } -impl AnnounceLogger for AnnounceBuildLog +impl AnnounceLog where W: Write + Send + Sync + Debug + 'static, { - type ReturnTo = Box; - - fn warning(mut self: Box, s: &str) -> Box> { + #[must_use] + pub fn warning(mut self, s: &str) -> AnnounceLog { self.log_warning_shared(s); self } - fn important( - mut self: Box, - s: &str, - ) -> Box> { + #[must_use] + pub fn important(mut self, s: &str) -> AnnounceLog { self.log_important_shared(s); self } - fn end_announce(self: Box) -> Box { - Box::new(BuildLog { + #[must_use] + pub fn end_announce(self) -> BuildLog { + BuildLog { io: self.io, data: self.data, state: PhantomData::, - }) + } } } @@ -322,7 +308,7 @@ where /// by wrapping in a mutex. /// /// It implements writing by unlocking and delegating to the internal writer. -/// Can be used for `Box::io()` +/// Can be used for streaming stdout and stderr to the same writer. #[derive(Debug)] struct LockedWriter { arc: Arc>, @@ -343,53 +329,30 @@ where } } -/// Used to implement `Box` interface +/// Stream output to the user /// /// Mostly used for logging a running command #[derive(Debug)] -struct StreamTimed { +pub struct StreamLog { data: BuildData, arc_io: Arc>, started: Instant, } -impl StreamTimed +impl StreamLog where - W: Write + Send + Sync + Debug, + W: Write + Send + Sync + Debug + 'static, { fn start(&mut self) { let mut guard = self.arc_io.lock().expect("Logging mutex posioned"); let mut io = guard.by_ref(); - // Newline before stream + // Newline before stream https://github.com/heroku/libcnb.rs/issues/582 writeln_now(&mut io, ""); } -} - -// Need a trait that is both write a debug -trait WriteDebug: Write + Debug {} -impl WriteDebug for T where T: Write + Debug {} -/// Attempt to unwrap an io inside of an `Arc` if this fails because there is more -/// than a single reference don't panic, return the original IO instead. -/// -/// This prevents a runtime panic and allows us to continue logging -fn try_unwrap_arc_io(arc_io: Arc>) -> Box -where - W: Write + Send + Sync + Debug + 'static, -{ - match Arc::try_unwrap(arc_io) { - Ok(mutex) => Box::new(mutex.into_inner().expect("Logging mutex was poisioned")), - Err(original) => Box::new(LockedWriter { arc: original }), - } -} - -impl StreamLogger for StreamTimed -where - W: Write + Send + Sync + Debug + 'static, -{ /// Yield boxed writer that can be used for formatting and streaming contents /// back to the logger. - fn io(&mut self) -> Box { + pub fn io(&mut self) -> Box { Box::new(crate::write::line_mapped( LockedWriter { arc: self.arc_io.clone(), @@ -398,9 +361,24 @@ where )) } - fn finish_timed_stream(self: Box) -> Box { + /// # Panics + /// + /// Ensure that the return of any calls to the `io` function + /// are not retained before calling this function. + /// + /// This struct yields a `Box` which is effectively an + /// `Arc` to allow using the same writer for streaming stdout and stderr. + /// + /// If any of those boxed writers are retained then the `W` cannot + /// be reclaimed and returned. This will cause a panic. + #[must_use] + pub fn finish_timed_stream(self) -> BuildLog { let duration = self.started.elapsed(); - let mut io = try_unwrap_arc_io(self.arc_io); + + let mut io = Arc::try_unwrap(self.arc_io) + .expect("Expected buildpack author to not retain any IO streaming IO instances") + .into_inner() + .expect("Logging mutex was poisioned"); // // Newline after stream writeln_now(&mut io, ""); @@ -416,15 +394,15 @@ where style::details(style::time::human(&duration)) )); - Box::new(section) + section } } -/// Implements `Box` +/// Logs to the user while work is being performed in the background /// /// Used to end a background inline timer i.e. Installing ...... (<0.1s) #[derive(Debug)] -struct FinishTimedStep +pub struct BackgroundLog where W: Write + Debug, { @@ -433,11 +411,12 @@ where dot_printer: PrintGuard, } -impl TimedStepLogger for FinishTimedStep +impl BackgroundLog where W: Write + Send + Sync + Debug + 'static, { - fn finish_timed_step(self: Box) -> Box { + #[must_use] + pub fn finish_timed_step(self) -> BuildLog { // Must stop background writing thread before retrieving IO let data = self.data; let timer = self.timer; @@ -450,11 +429,11 @@ where writeln_now(&mut io, style::details(style::time::human(&duration))); - Box::new(BuildLog { + BuildLog { io, data, state: PhantomData::, - }) + } } } diff --git a/libherokubuildpack/src/output/interface.rs b/libherokubuildpack/src/output/interface.rs deleted file mode 100644 index 52aa1da5..00000000 --- a/libherokubuildpack/src/output/interface.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::fmt::Debug; -use std::io::Write; - -/// Consuming stateful logger interface -/// -/// The log pattern used by `BuildLog` is a consuming state machine that is designed to minimize -/// the amount of mistakes that can result in malformed build output. -/// -/// The interface isn't stable and may need to change. - -pub trait Logger: Debug { - fn buildpack_name(self, s: &str) -> Box; - fn without_buildpack_name(self) -> Box; -} - -pub trait StartedLogger: Debug { - fn section(self: Box, s: &str) -> Box; - fn finish_logging(self: Box); - - fn announce(self: Box) -> Box>>; -} - -pub trait SectionLogger: Debug { - fn step(self: Box, s: &str) -> Box; - fn mut_step(&mut self, s: &str); - fn step_timed(self: Box, s: &str) -> Box; - fn step_timed_stream(self: Box, s: &str) -> Box; - fn end_section(self: Box) -> Box; - - fn announce(self: Box) -> Box>>; -} - -pub trait AnnounceLogger: ErrorLogger + Debug { - type ReturnTo; - - fn warning(self: Box, s: &str) -> Box>; - fn important(self: Box, s: &str) -> Box>; - - fn end_announce(self: Box) -> Self::ReturnTo; -} - -pub trait TimedStepLogger: Debug { - fn finish_timed_step(self: Box) -> Box; -} - -pub trait StreamLogger: Debug { - fn io(&mut self) -> Box; - fn finish_timed_stream(self: Box) -> Box; -} - -pub trait ErrorLogger: Debug { - fn error(self: Box, s: &str); -} diff --git a/libherokubuildpack/src/output/mod.rs b/libherokubuildpack/src/output/mod.rs index b3b211ef..ed883333 100644 --- a/libherokubuildpack/src/output/mod.rs +++ b/libherokubuildpack/src/output/mod.rs @@ -1,6 +1,5 @@ pub(crate) mod background; pub mod build_log; -pub mod interface; pub mod section_log; pub mod style; mod util; diff --git a/libherokubuildpack/src/output/section_log.rs b/libherokubuildpack/src/output/section_log.rs index ac1d4e30..40ab31e6 100644 --- a/libherokubuildpack/src/output/section_log.rs +++ b/libherokubuildpack/src/output/section_log.rs @@ -1,46 +1,44 @@ +//! Write to the build output in a `Box` format with functions +//! +//! ## What +//! +//! Logging from within a layer can be difficult because calls to the layer interface are not +//! mutable nor consumable. Functions can be used at any time with no restrictions. The +//! only downside is that the buildpack author (you) is now responsible for: +//! +//! - Ensuring that `Box::section()` was called right before any of these +//! functions are called. +//! - Ensuring that you are not attempting to log while already logging i.e. calling `step()` within a +//! `step_timed()` call. +//! +//! For usage details run `cargo run --bin print_style_guide` +//! +//! ## Use +//! +//! The main use case is logging inside of a layer: +//! +//! ```no_run +//! use libherokubuildpack::output::section_log::log_step_timed; +//! +//! // fn create( +//! // &self, +//! // context: &libcnb::build::BuildContext, +//! // layer_path: &std::path::Path, +//! // ) -> Result< +//! // libcnb::layer::LayerResult, +//! // ::Error, +//! // > { +//! log_step_timed("Installing", || { +//! // Install logic here +//! todo!() +//! }) +//! // } +//! ``` +use crate::output::build_log::StreamLog; use crate::output::build_log::{state, BuildData, BuildLog}; -#[allow(clippy::wildcard_imports)] -pub use crate::output::interface::*; use std::io::Stdout; use std::marker::PhantomData; -/// Write to the build output in a `Box` format with functions -/// -/// ## What -/// -/// Logging from within a layer can be difficult because calls to the layer interface are not -/// mutable nor consumable. Functions can be used at any time with no restrictions. The -/// only downside is that the buildpack author (you) is now responsible for: -/// -/// - Ensuring that `Box::section()` was called right before any of these -/// functions are called. -/// - Ensuring that you are not attempting to log while already logging i.e. calling `step()` within a -/// `step_timed()` call. -/// -/// For usage details run `cargo run --bin print_style_guide` -/// -/// ## Use -/// -/// The main use case is logging inside of a layer: -/// -/// ```no_run -/// use libherokubuildpack::output::section_log::log_step_timed; -/// -/// // fn create( -/// // &self, -/// // context: &libcnb::build::BuildContext, -/// // layer_path: &std::path::Path, -/// // ) -> Result< -/// // libcnb::layer::LayerResult, -/// // ::Error, -/// // > { -/// log_step_timed("Installing", || { -/// // Install logic here -/// todo!() -/// }) -/// // } -/// ``` - /// Output a message as a single step, ideally a short message /// /// ``` @@ -49,7 +47,7 @@ use std::marker::PhantomData; /// log_step("Clearing cache (ruby version changed)"); /// ``` pub fn log_step(s: impl AsRef) { - logger().step(s.as_ref()); + let _ = logger().step(s.as_ref()); } /// Will print the input string followed by a background timer @@ -67,7 +65,7 @@ pub fn log_step(s: impl AsRef) { pub fn log_step_timed(s: impl AsRef, f: impl FnOnce() -> T) -> T { let timer = logger().step_timed(s.as_ref()); let out = f(); - timer.finish_timed_step(); + let _ = timer.finish_timed_step(); out } @@ -88,13 +86,10 @@ pub fn log_step_timed(s: impl AsRef, f: impl FnOnce() -> T) -> T { /// ``` /// /// Timing information will be output at the end of the step. -pub fn log_step_stream( - s: impl AsRef, - f: impl FnOnce(&mut Box) -> T, -) -> T { +pub fn log_step_stream(s: impl AsRef, f: impl FnOnce(&mut StreamLog) -> T) -> T { let mut stream = logger().step_timed_stream(s.as_ref()); let out = f(&mut stream); - stream.finish_timed_stream(); + let _ = stream.finish_timed_stream(); out } @@ -105,20 +100,20 @@ pub fn log_error(s: impl AsRef) { /// Print an warning block to the output pub fn log_warning(s: impl AsRef) { - logger().announce().warning(s.as_ref()); + let _ = logger().announce().warning(s.as_ref()); } /// Print an important block to the output pub fn log_important(s: impl AsRef) { - logger().announce().important(s.as_ref()); + let _ = logger().announce().important(s.as_ref()); } -fn logger() -> Box { - Box::new(BuildLog:: { +fn logger() -> BuildLog { + BuildLog:: { io: std::io::stdout(), // Be careful not to do anything that might access this state // as it's ephemeral data (i.e. not passed in from the start of the build) data: BuildData::default(), state: PhantomData, - }) + } } From fafddee08ad81c3e3c4ba47a4c177ec4c949d6c1 Mon Sep 17 00:00:00 2001 From: Schneems Date: Wed, 24 Jan 2024 16:48:52 -0600 Subject: [PATCH 04/99] Remove ReadYourWrite utility struct The ReadYourWrite struct aided in testing as it implemented `Write` and produced a way to read the contents that were written. This struct was used for testing purposes and Manuel identified that it would be preferable to have a way to retrieve the Write IO struct directly. Previously this would have meant introducing a public interface to the build log trait as all type information was hidden behind a boxed trait return. As the trait implementation is removed we can now add a testing function that exposes the writer and can remove this single purpose class. Fun fact: Prior to introducing the ReadYourWrite struct, earlier versions of the code used a file to pass into the BuildLog as a `Write` that could later be read since the filename was not consumed and could be read even once the `BuildLog` and associated file handle are not accessible. --- libherokubuildpack/src/output/build_log.rs | 65 +++++++------- libherokubuildpack/src/output/util.rs | 98 ---------------------- 2 files changed, 33 insertions(+), 130 deletions(-) diff --git a/libherokubuildpack/src/output/build_log.rs b/libherokubuildpack/src/output/build_log.rs index a2dfd5b4..417742ef 100644 --- a/libherokubuildpack/src/output/build_log.rs +++ b/libherokubuildpack/src/output/build_log.rs @@ -114,11 +114,17 @@ where } } - pub fn finish_logging(mut self) { + #[must_use] + fn finish_logging_writer(mut self) -> W { let elapsed = style::time::human(&self.data.started.elapsed()); let details = style::details(format!("finished in {elapsed}")); writeln_now(&mut self.io, style::section(format!("Done {details}"))); + self.io + } + + pub fn finish_logging(self) { + let _ = self.finish_logging_writer(); } pub fn announce(self) -> AnnounceLog { @@ -461,17 +467,15 @@ fn writeln_now(destination: &mut D, msg: impl AsRef) { mod test { use super::*; use crate::command::CommandExt; - use crate::output::style::{self, strip_control_codes}; - use crate::output::util::{strip_trailing_whitespace, ReadYourWrite}; + use crate::output::style::strip_control_codes; + use crate::output::util::strip_trailing_whitespace; use indoc::formatdoc; use libcnb_test::assert_contains; use pretty_assertions::assert_eq; #[test] fn test_captures() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - + let writer = Vec::new(); let mut stream = BuildLog::new(writer) .buildpack_name("Heroku Ruby Buildpack") .section("Ruby version `3.1.3` from `Gemfile.lock`") @@ -484,7 +488,10 @@ mod test { let value = "stuff".to_string(); writeln!(stream.io(), "{value}").unwrap(); - stream.finish_timed_stream().end_section().finish_logging(); + let io = stream + .finish_timed_stream() + .end_section() + .finish_logging_writer(); let expected = formatdoc! {" @@ -503,15 +510,13 @@ mod test { assert_eq!( expected, - strip_trailing_whitespace(style::strip_control_codes(reader.read_lossy().unwrap())) + strip_trailing_whitespace(strip_control_codes(String::from_utf8_lossy(&io))) ); } #[test] fn test_streaming_a_command() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - + let writer = Vec::new(); let mut stream = BuildLog::new(writer) .buildpack_name("Streaming buildpack demo") .section("Command streaming") @@ -522,20 +527,20 @@ mod test { .output_and_write_streams(stream.io(), stream.io()) .unwrap(); - stream.finish_timed_stream().end_section().finish_logging(); + let io = stream + .finish_timed_stream() + .end_section() + .finish_logging_writer(); - let actual = - strip_trailing_whitespace(style::strip_control_codes(reader.read_lossy().unwrap())); + let actual = strip_trailing_whitespace(strip_control_codes(String::from_utf8_lossy(&io))); assert_contains!(actual, " hello world\n"); } #[test] fn warning_step_padding() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - - BuildLog::new(writer) + let writer = Vec::new(); + let io = BuildLog::new(writer) .buildpack_name("RCT") .section("Guest thoughs") .step("The scenery here is wonderful") @@ -545,7 +550,7 @@ mod test { .step("The jumping fountains are great") .step("The music is nice here") .end_section() - .finish_logging(); + .finish_logging_writer(); let expected = formatdoc! {" @@ -562,28 +567,26 @@ mod test { - Done (finished in < 0.1s) "}; - assert_eq!(expected, strip_control_codes(reader.read_lossy().unwrap())); + assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); } #[test] fn double_warning_step_padding() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - + let writer = Vec::new(); let logger = BuildLog::new(writer) .buildpack_name("RCT") .section("Guest thoughs") .step("The scenery here is wonderful") .announce(); - logger + let io = logger .warning("It's too crowded here") .warning("I'm tired") .end_announce() .step("The jumping fountains are great") .step("The music is nice here") .end_section() - .finish_logging(); + .finish_logging_writer(); let expected = formatdoc! {" @@ -601,22 +604,20 @@ mod test { - Done (finished in < 0.1s) "}; - assert_eq!(expected, strip_control_codes(reader.read_lossy().unwrap())); + assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); } #[test] fn announce_and_exit_makes_no_whitespace() { - let writer = ReadYourWrite::writer(Vec::new()); - let reader = writer.reader(); - - BuildLog::new(writer) + let writer = Vec::new(); + let io = BuildLog::new(writer) .buildpack_name("Quick and simple") .section("Start") .step("Step") .announce() // <== Here .end_announce() // <== Here .end_section() - .finish_logging(); + .finish_logging_writer(); let expected = formatdoc! {" @@ -627,6 +628,6 @@ mod test { - Done (finished in < 0.1s) "}; - assert_eq!(expected, strip_control_codes(reader.read_lossy().unwrap())); + assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); } } diff --git a/libherokubuildpack/src/output/util.rs b/libherokubuildpack/src/output/util.rs index ee82a7b9..ff4bf5ee 100644 --- a/libherokubuildpack/src/output/util.rs +++ b/libherokubuildpack/src/output/util.rs @@ -1,106 +1,8 @@ use lazy_static::lazy_static; -use std::fmt::Debug; -use std::io::Write; -use std::ops::Deref; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; - lazy_static! { static ref TRAILING_WHITESPACE_RE: regex::Regex = regex::Regex::new(r"\s+$").expect("clippy"); } -/// Threadsafe writer that can be read from -/// -/// Useful for testing -#[derive(Debug)] -pub(crate) struct ReadYourWrite -where - W: Write + AsRef<[u8]>, -{ - arc: Arc>, -} - -impl Clone for ReadYourWrite -where - W: Write + AsRef<[u8]> + Debug, -{ - fn clone(&self) -> Self { - Self { - arc: self.arc.clone(), - } - } -} - -impl Write for ReadYourWrite -where - W: Write + AsRef<[u8]> + Debug, -{ - fn write(&mut self, buf: &[u8]) -> std::io::Result { - let mut writer = self.arc.lock().expect("Internal error"); - writer.write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - let mut writer = self.arc.lock().expect("Internal error"); - writer.flush() - } -} - -impl ReadYourWrite -where - W: Write + AsRef<[u8]>, -{ - #[allow(dead_code)] - pub(crate) fn writer(writer: W) -> Self { - Self { - arc: Arc::new(Mutex::new(writer)), - } - } - - #[must_use] - #[allow(dead_code)] - pub(crate) fn reader(&self) -> Reader { - Reader { - arc: self.arc.clone(), - } - } - - #[must_use] - #[allow(dead_code)] - pub(crate) fn arc_io(&self) -> Arc> { - self.arc.clone() - } -} - -pub(crate) struct Reader -where - W: Write + AsRef<[u8]>, -{ - arc: Arc>, -} - -impl Reader -where - W: Write + AsRef<[u8]>, -{ - #[allow(dead_code)] - pub(crate) fn read_lossy(&self) -> Result>> { - let io = &self.arc.lock()?; - - Ok(String::from_utf8_lossy(io.as_ref()).to_string()) - } -} - -impl Deref for Reader -where - W: Write + AsRef<[u8]>, -{ - type Target = Arc>; - - fn deref(&self) -> &Self::Target { - &self.arc - } -} - /// Iterator yielding every line in a string. The line includes newline character(s). /// /// From 8127b19dfb9a04d3812d195578c563e5702f988a Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Fri, 26 Jan 2024 12:19:41 +0100 Subject: [PATCH 05/99] Fix typos --- libherokubuildpack/src/output/background.rs | 4 ++-- libherokubuildpack/src/output/build_log.rs | 2 +- libherokubuildpack/src/output/style.rs | 2 +- libherokubuildpack/src/output/util.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libherokubuildpack/src/output/background.rs b/libherokubuildpack/src/output/background.rs index f3769cb5..cb333f1e 100644 --- a/libherokubuildpack/src/output/background.rs +++ b/libherokubuildpack/src/output/background.rs @@ -93,7 +93,7 @@ pub(crate) mod state { // // When that happens the output can appear in the middle of another output, such // as an error message if a global writer is being used such as stdout. - // As a result we have to signal AND ensure the thread is stoped before + // As a result we have to signal AND ensure the thread is stopped before // continuing. if let Some(join_handle) = self.join_handle.take() { let _ = self.stop_signal.send(()); @@ -122,7 +122,7 @@ pub(crate) mod state { /// /// # Panics /// - /// This code can panic if it encounters an unexpedcted internal state. + /// This code can panic if it encounters an unexpected internal state. /// If that happens it means there is an internal bug in this logic. /// To avoid a panic, developers modifying this file must obey the following /// rules: diff --git a/libherokubuildpack/src/output/build_log.rs b/libherokubuildpack/src/output/build_log.rs index 417742ef..fa78e912 100644 --- a/libherokubuildpack/src/output/build_log.rs +++ b/libherokubuildpack/src/output/build_log.rs @@ -310,7 +310,7 @@ where /// Implements Box /// -/// Ensures that the `W` can be passed across thread boundries +/// Ensures that the `W` can be passed across thread boundaries /// by wrapping in a mutex. /// /// It implements writing by unlocking and delegating to the internal writer. diff --git a/libherokubuildpack/src/output/style.rs b/libherokubuildpack/src/output/style.rs index abe5c7a6..a5339c04 100644 --- a/libherokubuildpack/src/output/style.rs +++ b/libherokubuildpack/src/output/style.rs @@ -272,7 +272,7 @@ mod test { pub(crate) mod time { use std::time::Duration; - // Returns the part of a duration only in miliseconds + // Returns the part of a duration only in milliseconds pub(crate) fn milliseconds(duration: &Duration) -> u32 { duration.subsec_millis() } diff --git a/libherokubuildpack/src/output/util.rs b/libherokubuildpack/src/output/util.rs index ff4bf5ee..808663c7 100644 --- a/libherokubuildpack/src/output/util.rs +++ b/libherokubuildpack/src/output/util.rs @@ -43,7 +43,7 @@ impl<'a> Iterator for LinesWithEndings<'a> { /// /// Useful because most editors strip trailing whitespace (in test fixtures) /// but commands emit newlines -/// with leading spaces. These can be sanatized by removing trailing whitespace. +/// with leading spaces. These can be sanitized by removing trailing whitespace. #[allow(dead_code)] pub(crate) fn strip_trailing_whitespace(s: impl AsRef) -> String { LinesWithEndings::from(s.as_ref()) From 314742f4bb2ae3661005a618a2d56fc1d681c9f1 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Fri, 26 Jan 2024 19:18:11 +0100 Subject: [PATCH 06/99] Draft: Slimmer build output PR experiment (#761) * cull * Add changes from pairing --- .github/workflows/ci.yml | 18 -- libherokubuildpack/Cargo.toml | 3 - .../examples/print_style_guide.rs | 224 ------------------ libherokubuildpack/src/output/background.rs | 213 ----------------- libherokubuildpack/src/output/build_log.rs | 212 +++++------------ libherokubuildpack/src/output/mod.rs | 1 - libherokubuildpack/src/output/section_log.rs | 27 +-- libherokubuildpack/src/output/style.rs | 19 +- 8 files changed, 65 insertions(+), 652 deletions(-) delete mode 100644 libherokubuildpack/examples/print_style_guide.rs delete mode 100644 libherokubuildpack/src/output/background.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 812a97f6..26b9e81b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,21 +93,3 @@ jobs: # TODO: Switch this back to using the `alpine` tag once the stable Pack CLI release supports # image extensions (currently newer sample alpine images fail to build with stable Pack). run: pack build example-basics --builder cnbs/sample-builder@sha256:da5ff69191919f1ff30d5e28859affff8e39f23038137c7751e24a42e919c1ab --trust-builder --buildpack packaged/x86_64-unknown-linux-musl/debug/libcnb-examples_basics --path examples/ - - print-style-guide: - runs-on: ubuntu-22.04 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install musl-tools - run: sudo apt-get install musl-tools --no-install-recommends - - name: Update Rust toolchain - run: rustup update - - name: Install Rust linux-musl target - run: rustup target add x86_64-unknown-linux-musl - - name: Rust Cache - uses: Swatinem/rust-cache@v2.7.1 - - name: Install Pack CLI - uses: buildpacks/github-actions/setup-pack@v5.5.0 - - name: PRINT style guide - run: cargo run --example print_style_guide diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index 8d9f4854..e4f3fd4e 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -17,9 +17,6 @@ all-features = true [lints] workspace = true -[[example]] -name = "print_style_guide" - [features] default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write", "output"] download = ["dep:ureq", "dep:thiserror"] diff --git a/libherokubuildpack/examples/print_style_guide.rs b/libherokubuildpack/examples/print_style_guide.rs deleted file mode 100644 index 8a18d43e..00000000 --- a/libherokubuildpack/examples/print_style_guide.rs +++ /dev/null @@ -1,224 +0,0 @@ -use ascii_table::AsciiTable; -use fun_run::CommandWithName; -use indoc::formatdoc; -use libherokubuildpack::output::style::{self, DEBUG_INFO, HELP}; -#[allow(clippy::wildcard_imports)] -use libherokubuildpack::output::{ - build_log::BuildLog, - section_log::{log_step, log_step_stream, log_step_timed}, -}; -use std::io::stdout; -use std::process::Command; - -// Avoid cargo-clippy warnings: "external crate X unused in `print_style_guide`" -use const_format as _; -use crossbeam_utils as _; -use flate2 as _; -use lazy_static as _; -use libcnb as _; -use libcnb_test as _; -use pathdiff as _; -use pretty_assertions as _; -use regex as _; -use sha2 as _; -use tar as _; -use tempfile as _; -use termcolor as _; -use thiserror as _; -use toml as _; -use ureq as _; - -#[allow(clippy::too_many_lines)] -fn main() { - println!( - "{}", - formatdoc! {" - - Living build output style guide - =============================== - "} - ); - - { - let mut log = BuildLog::new(stdout()).buildpack_name("Section logging features"); - log = log - .section("Section heading example") - .step("step example") - .step("step example two") - .end_section(); - - log = log - .section("Section and step description") - .step( - "A section should be a noun i.e. 'Ruby Version', consider this the section topic.", - ) - .step("A step should be a verb i.e. 'Downloading'") - .step("Related verbs should be nested under a single section") - .step( - formatdoc! {" - Steps can be multiple lines long - However they're best as short, factual, - descriptions of what the program is doing. - "} - .trim(), - ) - .step("Prefer a single line when possible") - .step("Sections and steps are sentence cased with no ending puncuation") - .step(&format!("{HELP} capitalize the first letter")) - .end_section(); - - let mut command = Command::new("bash"); - command.args(["-c", "ps aux | grep cargo"]); - - let mut stream = log.section("Timer steps") - .step("Long running code should execute with a timer printing to the UI, to indicate the progam did not hang.") - .step("Example:") - .step_timed("Background progress timer") - .finish_timed_step() - .step("Output can be streamed. Mostly from commands. Example:") - .step_timed_stream(&format!("Running {}", style::command(command.name()))); - - command - .stream_output(stream.io(), stream.io()) - .expect("Implement real error handling in real apps"); - stream.finish_timed_stream().end_section(); - } - - { - let mut log = BuildLog::new(stdout()).buildpack_name("Section log functions"); - log = log - .section("Logging inside a layer") - .step( - formatdoc! {" - Layer interfaces are neither mutable nor consuming i.e. - - ``` - fn create( - &self, - _context: &BuildContext, - layer_path: &Path, - ) -> Result, RubyBuildpackError> - ``` - - To allow logging within a layer you can use the `output::section_log` interface. - "} - .trim_end(), - ) - .step("This `section_log` inteface allows you to log without state") - .step("That means you're responsonsible creating a section before calling it") - .step("Here's an example") - .end_section(); - - let section_log = log.section("Example:"); - - log_step("log_step()"); - log_step_timed("log_step_timed()", || { - // do work here - }); - log_step_stream("log_step_stream()", |stream| { - Command::new("bash") - .args(["-c", "ps aux | grep cargo"]) - .stream_output(stream.io(), stream.io()) - .expect("Implement Error handling in real apps") - }); - log_step(formatdoc! {" - If you want to help make sure you're within a section then you can require your layer - takes a reference to `&'a dyn SectionLogger` - "}); - section_log.end_section(); - } - - { - #[allow(clippy::unwrap_used)] - let cmd_error = Command::new("iDoNotExist").named_output().err().unwrap(); - - let mut log = BuildLog::new(stdout()).buildpack_name("Error and warnings"); - log = log - .section("Debug information") - .step("Should go above errors in section/step format") - .end_section(); - - log = log - .section(DEBUG_INFO) - .step(&cmd_error.to_string()) - .end_section(); - - log.announce() - .warning(&formatdoc! {" - Warning: This is a warning header - - This is a warning body. Warnings are for when we know for a fact a problem exists - but it's not bad enough to abort the build. - "}) - .important(&formatdoc! {" - Important: This is important - - Important is for when there's critical information that needs to be read - however it may or may not be a problem. If we know for a fact that there's - a problem then use a warning instead. - - An example of something that is important but might not be a problem is - that an application owner upgraded to a new stack. - "}) - .error(&formatdoc! {" - Error: This is an error header - - This is the error body. Use an error for when the build cannot continue. - An error should include a header with a short description of why it cannot continue. - - The body should include what error state was observed, why that's a problem, and - what remediation steps an application owner using the buildpack to deploy can - take to solve the issue. - "}); - } - - { - let log = BuildLog::new(stdout()).buildpack_name("Formatting helpers"); - log.section("The style module") - .step(&formatdoc! {" - Formatting helpers can be used to enhance log output: - "}) - .end_section(); - - let mut table = AsciiTable::default(); - table.set_max_width(240); - table.column(0).set_header("Example"); - table.column(1).set_header("Code"); - table.column(2).set_header("When to use"); - - let data: Vec> = vec![ - vec![ - style::value("2.3.4"), - "style::value(\"2.3.f\")".to_string(), - "With versions, file names or other important values worth highlighting".to_string(), - ], - vec![ - style::url("https://www.schneems.com"), - "style::url(\"https://www.schneems.com\")".to_string(), - "With urls".to_string(), - ], - vec![ - style::command("bundle install"), - "style::command(command.name())".to_string(), - "With commands (alongside of `fun_run::CommandWithName`)".to_string(), - ], - vec![ - style::details("extra information"), - "style::details(\"extra information\")".to_string(), - "Add specific information at the end of a line i.e. 'Cache cleared (ruby version changed)'".to_string() - ], - vec![ - style::HELP.to_string(), - "style::HELP.to_string()".to_string(), - "A help prefix, use it in a step or section title".to_string() - ], - vec![ - style::DEBUG_INFO.to_string(), - "style::DEBUG_INFO.to_string()".to_string(), - "A debug prefix, use it in a step or section title".to_string() - ] - ]; - - table.print(data); - } -} diff --git a/libherokubuildpack/src/output/background.rs b/libherokubuildpack/src/output/background.rs deleted file mode 100644 index cb333f1e..00000000 --- a/libherokubuildpack/src/output/background.rs +++ /dev/null @@ -1,213 +0,0 @@ -use std::fmt::Debug; -use std::io::Write; -use std::sync::mpsc::channel; -use std::time::Duration; - -/// This module is responsible for the logic involved in the printing to output while -/// other work is being performed. Such as printing dots while a download is being performed - -/// Print dots to the given buffer at the given interval -/// -/// Returns a struct that allows for manually stopping the timer or will automatically stop -/// the timer if the guard is dropped. This functionality allows for errors that trigger -/// an exit of the function to not accidentally have a timer printing in the background -/// forever. -#[must_use] -pub(crate) fn print_interval( - mut buffer: W, - interval: Duration, - start: String, - tick: String, - end: String, -) -> state::PrintGuard -where - W: Write + Send + Debug + 'static, -{ - let (sender, receiver) = channel::<()>(); - - let join_handle = std::thread::spawn(move || { - write!(buffer, "{start}").expect("Internal error"); - buffer.flush().expect("Internal error"); - - loop { - write!(buffer, "{tick}").expect("Internal error"); - buffer.flush().expect("Internal error"); - - if receiver.recv_timeout(interval).is_ok() { - break; - } - } - - write!(buffer, "{end}").expect("Internal error"); - buffer.flush().expect("Internal error"); - - buffer - }); - - state::PrintGuard::new(join_handle, sender) -} - -pub(crate) mod state { - use std::fmt::Debug; - use std::io::Write; - use std::sync::mpsc::Sender; - use std::thread::JoinHandle; - - /// Holds the reference to the background printer - /// - /// Ensures that the dot printer is stopped in the event of an error. By signaling - /// it and joining when this struct is dropped. - /// - /// Gives access to the original io/buffer struct passed to the background writer - /// when the thread is manually stopped. - /// - /// # Panics - /// - /// Updates to this code need to take care to not introduce a panic. See - /// documentation in `PrintGuard::stop` below for more details. - #[derive(Debug)] - pub(crate) struct PrintGuard - where - W: Write + Debug, - { - /// Holds the handle to the thread printing dots - /// - /// Structs that implement `Drop` must ensure a valid internal state at - /// all times due to E0509. The handle is wrapped in an option to allow the - /// inner value to be removed while preserving internal state. - join_handle: Option>, - - /// Holds the signaling method to tell the background printer - /// to stop emitting. - stop_signal: Sender<()>, - } - - impl Drop for PrintGuard - where - W: Write + Debug, - { - fn drop(&mut self) { - // A note on correctness. It might seem that it's enough to signal the thread to - // stop, that we don't also have to join and wait for it to finish, but that's not - // the case. The printer can still emit a value after it's told to stop. - // - // When that happens the output can appear in the middle of another output, such - // as an error message if a global writer is being used such as stdout. - // As a result we have to signal AND ensure the thread is stopped before - // continuing. - if let Some(join_handle) = self.join_handle.take() { - let _ = self.stop_signal.send(()); - let _ = join_handle.join(); - } - } - } - - impl PrintGuard - where - W: Write + Debug, - { - /// Preserve internal state by ensuring the `Option` is always populated - pub(crate) fn new(join_handle: JoinHandle, sender: Sender<()>) -> Self { - let guard = PrintGuard { - join_handle: Some(join_handle), - stop_signal: sender, - }; - debug_assert!(guard.join_handle.is_some()); - - guard - } - - /// The only thing a consumer can do is stop the dots and receive - /// the original buffer. - /// - /// # Panics - /// - /// This code can panic if it encounters an unexpected internal state. - /// If that happens it means there is an internal bug in this logic. - /// To avoid a panic, developers modifying this file must obey the following - /// rules: - /// - /// - Always consume the struct when accessing the Option value outside of Drop - /// - Never construct this struct with a `None` option value. - /// - /// This `Option` wrapping is needed to support a implementing Drop to - /// ensure the printing is stopped. When a struct implements drop it cannot - /// remove it's internal state due to E0509: - /// - /// - /// - /// The workaround is to never allow invalid internal state by replacing the - /// inner value with a `None` when removing it. We don't want to expose this - /// implementation detail to the user, so instead we accept the panic, ensure - /// the code is exercised under test, and exhaustively document why this panic - /// exists and how developers working with this code can maintain safety. - #[allow(clippy::panic_in_result_fn)] - pub(crate) fn stop(mut self) -> std::thread::Result { - // Ignore if the channel is closed, likely means the thread died which - // we want in this case. - match self.join_handle.take() { - Some(join_handle) => { - let _ = self.stop_signal.send(()); - join_handle.join() - } - None => panic!("Internal error: Dot print internal state should never be None"), - } - } - } -} - -#[cfg(test)] -mod test { - use super::*; - use std::fs::{File, OpenOptions}; - use tempfile::NamedTempFile; - - #[test] - fn does_stop_does_not_panic() { - let mut buffer: Vec = vec![]; - write!(buffer, "before").unwrap(); - - let dot = print_interval( - buffer, - Duration::from_millis(1), - String::from(" ."), - String::from("."), - String::from(". "), - ); - let mut writer = dot.stop().unwrap(); - - write!(writer, "after").unwrap(); - writer.flush().unwrap(); - - assert_eq!("before ... after", String::from_utf8_lossy(&writer)); - } - - #[test] - fn test_drop_stops_timer() { - let tempfile = NamedTempFile::new().unwrap(); - let mut log = File::create(tempfile.path()).unwrap(); - write!(log, "before").unwrap(); - - let dot = print_interval( - log, - Duration::from_millis(1), - String::from(" ."), - String::from("."), - String::from(". "), - ); - drop(dot); - - let mut log = OpenOptions::new() - .append(true) - .write(true) - .open(tempfile.path()) - .unwrap(); - write!(log, "after").unwrap(); - log.flush().unwrap(); - - assert_eq!( - String::from("before ... after"), - std::fs::read_to_string(tempfile.path()).unwrap() - ); - } -} diff --git a/libherokubuildpack/src/output/build_log.rs b/libherokubuildpack/src/output/build_log.rs index fa78e912..fb44dd41 100644 --- a/libherokubuildpack/src/output/build_log.rs +++ b/libherokubuildpack/src/output/build_log.rs @@ -3,15 +3,13 @@ //! Use the `BuildLog` to output structured text as a buildpack is executing //! //! ``` -//! use libherokubuildpack::output::build_log::BuildLog; +//! use libherokubuildpack::output::build_log::BuildpackOutput; //! -//! let mut logger = BuildLog::new(std::io::stdout()) -//! .buildpack_name("Heroku Ruby Buildpack"); +//! let mut logger = BuildpackOutput::new(std::io::stdout()) +//! .start("Heroku Ruby Buildpack"); //! //! logger = logger //! .section("Ruby version") -//! .step_timed("Installing") -//! .finish_timed_step() //! .end_section(); //! //! logger.finish_logging(); @@ -20,21 +18,19 @@ //! To log inside of a layer see `section_log`. //! //! For usage details run `cargo run --bin print_style_guide` -use crate::output::background::{print_interval, state::PrintGuard}; use crate::output::style; use std::fmt::Debug; use std::io::Write; -use std::marker::PhantomData; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Instant; /// See the module docs for example usage #[allow(clippy::module_name_repetitions)] #[derive(Debug)] -pub struct BuildLog { +pub struct BuildpackOutput { pub(crate) io: W, pub(crate) data: BuildData, - pub(crate) state: PhantomData, + pub(crate) state: T, } /// A bag of data passed throughout the lifecycle of a `BuildLog` @@ -51,7 +47,7 @@ impl Default for BuildData { } } -/// Various states for `BuildLog` to contain +/// Various states for `BuildOutput` to contain /// /// The `BuildLog` struct acts as a logging state machine. These structs /// are meant to represent those states @@ -66,56 +62,51 @@ pub(crate) mod state { pub struct InSection; } -impl BuildLog +impl BuildpackOutput where W: Write + Debug, { pub fn new(io: W) -> Self { Self { io, - state: PhantomData::, + state: state::NotStarted, data: BuildData::default(), } } - pub fn buildpack_name(mut self, buildpack_name: &str) -> BuildLog { + pub fn start(mut self, buildpack_name: &str) -> BuildpackOutput { write_now( &mut self.io, format!("{}\n\n", style::header(buildpack_name)), ); - BuildLog { - io: self.io, - data: self.data, - state: PhantomData::, - } + self.start_silent() } - pub fn without_buildpack_name(self) -> BuildLog { - BuildLog { + pub fn start_silent(self) -> BuildpackOutput { + BuildpackOutput { io: self.io, data: self.data, - state: PhantomData::, + state: state::Started, } } } -impl BuildLog +impl BuildpackOutput where W: Write + Send + Sync + Debug + 'static, { - pub fn section(mut self, s: &str) -> BuildLog { + pub fn section(mut self, s: &str) -> BuildpackOutput { writeln_now(&mut self.io, style::section(s)); - BuildLog { + BuildpackOutput { io: self.io, data: self.data, - state: PhantomData::, + state: state::InSection, } } - #[must_use] - fn finish_logging_writer(mut self) -> W { + fn finish(mut self) -> W { let elapsed = style::time::human(&self.data.started.elapsed()); let details = style::details(format!("finished in {elapsed}")); @@ -123,21 +114,17 @@ where self.io } - pub fn finish_logging(self) { - let _ = self.finish_logging_writer(); - } - pub fn announce(self) -> AnnounceLog { AnnounceLog { io: self.io, data: self.data, - state: PhantomData::, + state: state::Started, leader: Some("\n".to_string()), } } } -impl BuildLog +impl BuildpackOutput where W: Write + Send + Sync + Debug + 'static, { @@ -146,33 +133,12 @@ where } #[must_use] - pub fn step(mut self, s: &str) -> BuildLog { + pub fn step(mut self, s: &str) -> BuildpackOutput { self.mut_step(s); self } - pub fn step_timed(self, s: &str) -> BackgroundLog { - let mut io = self.io; - let data = self.data; - let timer = Instant::now(); - - write_now(&mut io, style::step(s)); - let dot_printer = print_interval( - io, - Duration::from_secs(1), - style::background_timer_start(), - style::background_timer_tick(), - style::background_timer_end(), - ); - - BackgroundLog { - data, - timer, - dot_printer, - } - } - pub fn step_timed_stream(mut self, s: &str) -> StreamLog { self.mut_step(s); @@ -188,11 +154,11 @@ where stream } - pub fn end_section(self) -> BuildLog { - BuildLog { + pub fn end_section(self) -> BuildpackOutput { + BuildpackOutput { io: self.io, data: self.data, - state: PhantomData::, + state: state::Started, } } @@ -200,7 +166,7 @@ where AnnounceLog { io: self.io, data: self.data, - state: PhantomData::, + state: state::InSection, leader: Some("\n".to_string()), } } @@ -214,7 +180,7 @@ where { io: W, data: BuildData, - state: PhantomData, + state: T, leader: Option, } @@ -223,7 +189,7 @@ where T: Debug, W: Write + Send + Sync + Debug + 'static, { - fn log_warning_shared(&mut self, s: &str) { + fn log_warning(&mut self, s: &str) { if let Some(leader) = self.leader.take() { write_now(&mut self.io, leader); } @@ -232,20 +198,14 @@ where writeln_now(&mut self.io, ""); } - fn log_important_shared(&mut self, s: &str) { + fn log_important(&mut self, s: &str) { if let Some(leader) = self.leader.take() { write_now(&mut self.io, leader); } writeln_now(&mut self.io, style::important(s.trim())); writeln_now(&mut self.io, ""); } -} -impl AnnounceLog -where - T: Debug, - W: Write + Send + Sync + Debug + 'static, -{ pub fn error(mut self, s: &str) { if let Some(leader) = self.leader.take() { write_now(&mut self.io, leader); @@ -261,23 +221,23 @@ where { #[must_use] pub fn warning(mut self, s: &str) -> AnnounceLog { - self.log_warning_shared(s); + self.log_warning(s); self } #[must_use] pub fn important(mut self, s: &str) -> AnnounceLog { - self.log_important_shared(s); + self.log_important(s); self } - pub fn end_announce(self) -> BuildLog { - BuildLog { + pub fn end_announce(self) -> BuildpackOutput { + BuildpackOutput { io: self.io, data: self.data, - state: PhantomData::, + state: state::InSection, } } } @@ -288,33 +248,27 @@ where { #[must_use] pub fn warning(mut self, s: &str) -> AnnounceLog { - self.log_warning_shared(s); + self.log_warning(s); self } #[must_use] pub fn important(mut self, s: &str) -> AnnounceLog { - self.log_important_shared(s); + self.log_important(s); self } #[must_use] - pub fn end_announce(self) -> BuildLog { - BuildLog { + pub fn end_announce(self) -> BuildpackOutput { + BuildpackOutput { io: self.io, data: self.data, - state: PhantomData::, + state: state::Started, } } } -/// Implements Box -/// -/// Ensures that the `W` can be passed across thread boundaries -/// by wrapping in a mutex. -/// -/// It implements writing by unlocking and delegating to the internal writer. -/// Can be used for streaming stdout and stderr to the same writer. +// TODO: Decide if we need documentation for this #[derive(Debug)] struct LockedWriter { arc: Arc>, @@ -350,7 +304,7 @@ where W: Write + Send + Sync + Debug + 'static, { fn start(&mut self) { - let mut guard = self.arc_io.lock().expect("Logging mutex posioned"); + let mut guard = self.arc_io.lock().expect("Logging mutex poisoned"); let mut io = guard.by_ref(); // Newline before stream https://github.com/heroku/libcnb.rs/issues/582 writeln_now(&mut io, ""); @@ -378,7 +332,7 @@ where /// If any of those boxed writers are retained then the `W` cannot /// be reclaimed and returned. This will cause a panic. #[must_use] - pub fn finish_timed_stream(self) -> BuildLog { + pub fn finish_timed_stream(self) -> BuildpackOutput { let duration = self.started.elapsed(); let mut io = Arc::try_unwrap(self.arc_io) @@ -389,10 +343,10 @@ where // // Newline after stream writeln_now(&mut io, ""); - let mut section = BuildLog { + let mut section = BuildpackOutput { io, data: self.data, - state: PhantomData::, + state: state::InSection, }; section.mut_step(&format!( @@ -404,45 +358,6 @@ where } } -/// Logs to the user while work is being performed in the background -/// -/// Used to end a background inline timer i.e. Installing ...... (<0.1s) -#[derive(Debug)] -pub struct BackgroundLog -where - W: Write + Debug, -{ - data: BuildData, - timer: Instant, - dot_printer: PrintGuard, -} - -impl BackgroundLog -where - W: Write + Send + Sync + Debug + 'static, -{ - #[must_use] - pub fn finish_timed_step(self) -> BuildLog { - // Must stop background writing thread before retrieving IO - let data = self.data; - let timer = self.timer; - - let mut io = match self.dot_printer.stop() { - Ok(io) => io, - Err(e) => std::panic::resume_unwind(e), - }; - let duration = timer.elapsed(); - - writeln_now(&mut io, style::details(style::time::human(&duration))); - - BuildLog { - io, - data, - state: PhantomData::, - } - } -} - /// Internal helper, ensures that all contents are always flushed (never buffered) /// /// This is especially important for writing individual characters to the same line @@ -476,11 +391,9 @@ mod test { #[test] fn test_captures() { let writer = Vec::new(); - let mut stream = BuildLog::new(writer) - .buildpack_name("Heroku Ruby Buildpack") + let mut stream = BuildpackOutput::new(writer) + .start("Heroku Ruby Buildpack") .section("Ruby version `3.1.3` from `Gemfile.lock`") - .step_timed("Installing") - .finish_timed_step() .end_section() .section("Hello world") .step_timed_stream("Streaming stuff"); @@ -488,17 +401,13 @@ mod test { let value = "stuff".to_string(); writeln!(stream.io(), "{value}").unwrap(); - let io = stream - .finish_timed_stream() - .end_section() - .finish_logging_writer(); + let io = stream.finish_timed_stream().end_section().finish(); let expected = formatdoc! {" # Heroku Ruby Buildpack - Ruby version `3.1.3` from `Gemfile.lock` - - Installing ... (< 0.1s) - Hello world - Streaming stuff @@ -517,8 +426,8 @@ mod test { #[test] fn test_streaming_a_command() { let writer = Vec::new(); - let mut stream = BuildLog::new(writer) - .buildpack_name("Streaming buildpack demo") + let mut stream = BuildpackOutput::new(writer) + .start("Streaming buildpack demo") .section("Command streaming") .step_timed_stream("Streaming stuff"); @@ -527,10 +436,7 @@ mod test { .output_and_write_streams(stream.io(), stream.io()) .unwrap(); - let io = stream - .finish_timed_stream() - .end_section() - .finish_logging_writer(); + let io = stream.finish_timed_stream().end_section().finish(); let actual = strip_trailing_whitespace(strip_control_codes(String::from_utf8_lossy(&io))); @@ -540,8 +446,8 @@ mod test { #[test] fn warning_step_padding() { let writer = Vec::new(); - let io = BuildLog::new(writer) - .buildpack_name("RCT") + let io = BuildpackOutput::new(writer) + .start("RCT") .section("Guest thoughs") .step("The scenery here is wonderful") .announce() @@ -550,7 +456,7 @@ mod test { .step("The jumping fountains are great") .step("The music is nice here") .end_section() - .finish_logging_writer(); + .finish(); let expected = formatdoc! {" @@ -573,8 +479,8 @@ mod test { #[test] fn double_warning_step_padding() { let writer = Vec::new(); - let logger = BuildLog::new(writer) - .buildpack_name("RCT") + let logger = BuildpackOutput::new(writer) + .start("RCT") .section("Guest thoughs") .step("The scenery here is wonderful") .announce(); @@ -586,7 +492,7 @@ mod test { .step("The jumping fountains are great") .step("The music is nice here") .end_section() - .finish_logging_writer(); + .finish(); let expected = formatdoc! {" @@ -610,14 +516,14 @@ mod test { #[test] fn announce_and_exit_makes_no_whitespace() { let writer = Vec::new(); - let io = BuildLog::new(writer) - .buildpack_name("Quick and simple") + let io = BuildpackOutput::new(writer) + .start("Quick and simple") .section("Start") .step("Step") .announce() // <== Here .end_announce() // <== Here .end_section() - .finish_logging_writer(); + .finish(); let expected = formatdoc! {" diff --git a/libherokubuildpack/src/output/mod.rs b/libherokubuildpack/src/output/mod.rs index ed883333..b7dbd856 100644 --- a/libherokubuildpack/src/output/mod.rs +++ b/libherokubuildpack/src/output/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod background; pub mod build_log; pub mod section_log; pub mod style; diff --git a/libherokubuildpack/src/output/section_log.rs b/libherokubuildpack/src/output/section_log.rs index 40ab31e6..5905221b 100644 --- a/libherokubuildpack/src/output/section_log.rs +++ b/libherokubuildpack/src/output/section_log.rs @@ -35,7 +35,7 @@ //! // } //! ``` use crate::output::build_log::StreamLog; -use crate::output::build_log::{state, BuildData, BuildLog}; +use crate::output::build_log::{state, BuildData, BuildpackOutput}; use std::io::Stdout; use std::marker::PhantomData; @@ -50,25 +50,6 @@ pub fn log_step(s: impl AsRef) { let _ = logger().step(s.as_ref()); } -/// Will print the input string followed by a background timer -/// that will emit to the UI until the passed in function ends -/// -/// ``` -/// use libherokubuildpack::output::section_log::log_step_timed; -/// -/// log_step_timed("Installing", || { -/// // Install logic here -/// }); -/// ``` -/// -/// Timing information will be output at the end of the step. -pub fn log_step_timed(s: impl AsRef, f: impl FnOnce() -> T) -> T { - let timer = logger().step_timed(s.as_ref()); - let out = f(); - let _ = timer.finish_timed_step(); - out -} - /// Will print the input string and yield a `Box` that can be used to print /// to the output. The main use case is running commands /// @@ -108,12 +89,12 @@ pub fn log_important(s: impl AsRef) { let _ = logger().announce().important(s.as_ref()); } -fn logger() -> BuildLog { - BuildLog:: { +fn logger() -> BuildpackOutput { + BuildpackOutput:: { io: std::io::stdout(), // Be careful not to do anything that might access this state // as it's ephemeral data (i.e. not passed in from the start of the build) data: BuildData::default(), - state: PhantomData, + state: state::InSection, } } diff --git a/libherokubuildpack/src/output/style.rs b/libherokubuildpack/src/output/style.rs index a5339c04..e5d41fee 100644 --- a/libherokubuildpack/src/output/style.rs +++ b/libherokubuildpack/src/output/style.rs @@ -81,21 +81,6 @@ pub(crate) fn cmd_stream_format(mut input: Vec) -> Vec { result } -#[must_use] -pub(crate) fn background_timer_start() -> String { - colorize(DEFAULT_DIM, " .") -} - -#[must_use] -pub(crate) fn background_timer_tick() -> String { - colorize(DEFAULT_DIM, ".") -} - -#[must_use] -pub(crate) fn background_timer_end() -> String { - colorize(DEFAULT_DIM, ". ") -} - #[must_use] pub(crate) fn section(topic: impl AsRef) -> String { prefix_indent(SECTION_PREFIX, topic) @@ -327,10 +312,10 @@ pub(crate) mod time { let duration = Duration::from_millis(1024); assert_eq!("1.024s", human(&duration).as_str()); - let duration = std::time::Duration::from_millis(60 * 1024); + let duration = Duration::from_millis(60 * 1024); assert_eq!("1m 1s", human(&duration).as_str()); - let duration = std::time::Duration::from_millis(3600 * 1024); + let duration = Duration::from_millis(3600 * 1024); assert_eq!("1h 1m 26s", human(&duration).as_str()); } } From 70ac8d0fc9cb689efd543a550af22ee8ca277b35 Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 26 Jan 2024 12:24:08 -0600 Subject: [PATCH 07/99] Fix tests, address clippy lints --- libherokubuildpack/src/output/build_log.rs | 30 ++++++++++---------- libherokubuildpack/src/output/section_log.rs | 19 ++----------- 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/libherokubuildpack/src/output/build_log.rs b/libherokubuildpack/src/output/build_log.rs index fb44dd41..d492d01a 100644 --- a/libherokubuildpack/src/output/build_log.rs +++ b/libherokubuildpack/src/output/build_log.rs @@ -5,14 +5,14 @@ //! ``` //! use libherokubuildpack::output::build_log::BuildpackOutput; //! -//! let mut logger = BuildpackOutput::new(std::io::stdout()) +//! let mut output = BuildpackOutput::new(std::io::stdout()) //! .start("Heroku Ruby Buildpack"); //! -//! logger = logger +//! output = output //! .section("Ruby version") //! .end_section(); //! -//! logger.finish_logging(); +//! output.finish(); //! ``` //! //! To log inside of a layer see `section_log`. @@ -30,7 +30,7 @@ use std::time::Instant; pub struct BuildpackOutput { pub(crate) io: W, pub(crate) data: BuildData, - pub(crate) state: T, + pub(crate) _state: T, } /// A bag of data passed throughout the lifecycle of a `BuildLog` @@ -69,7 +69,7 @@ where pub fn new(io: W) -> Self { Self { io, - state: state::NotStarted, + _state: state::NotStarted, data: BuildData::default(), } } @@ -87,7 +87,7 @@ where BuildpackOutput { io: self.io, data: self.data, - state: state::Started, + _state: state::Started, } } } @@ -102,11 +102,11 @@ where BuildpackOutput { io: self.io, data: self.data, - state: state::InSection, + _state: state::InSection, } } - fn finish(mut self) -> W { + pub fn finish(mut self) -> W { let elapsed = style::time::human(&self.data.started.elapsed()); let details = style::details(format!("finished in {elapsed}")); @@ -118,7 +118,7 @@ where AnnounceLog { io: self.io, data: self.data, - state: state::Started, + _state: state::Started, leader: Some("\n".to_string()), } } @@ -158,7 +158,7 @@ where BuildpackOutput { io: self.io, data: self.data, - state: state::Started, + _state: state::Started, } } @@ -166,7 +166,7 @@ where AnnounceLog { io: self.io, data: self.data, - state: state::InSection, + _state: state::InSection, leader: Some("\n".to_string()), } } @@ -180,7 +180,7 @@ where { io: W, data: BuildData, - state: T, + _state: T, leader: Option, } @@ -237,7 +237,7 @@ where BuildpackOutput { io: self.io, data: self.data, - state: state::InSection, + _state: state::InSection, } } } @@ -263,7 +263,7 @@ where BuildpackOutput { io: self.io, data: self.data, - state: state::Started, + _state: state::Started, } } } @@ -346,7 +346,7 @@ where let mut section = BuildpackOutput { io, data: self.data, - state: state::InSection, + _state: state::InSection, }; section.mut_step(&format!( diff --git a/libherokubuildpack/src/output/section_log.rs b/libherokubuildpack/src/output/section_log.rs index 5905221b..c0b1f8e2 100644 --- a/libherokubuildpack/src/output/section_log.rs +++ b/libherokubuildpack/src/output/section_log.rs @@ -18,26 +18,13 @@ //! The main use case is logging inside of a layer: //! //! ```no_run -//! use libherokubuildpack::output::section_log::log_step_timed; +//! use libherokubuildpack::output::section_log::log_step; //! -//! // fn create( -//! // &self, -//! // context: &libcnb::build::BuildContext, -//! // layer_path: &std::path::Path, -//! // ) -> Result< -//! // libcnb::layer::LayerResult, -//! // ::Error, -//! // > { -//! log_step_timed("Installing", || { -//! // Install logic here -//! todo!() -//! }) -//! // } +//! log_step("Clearing the cache") //! ``` use crate::output::build_log::StreamLog; use crate::output::build_log::{state, BuildData, BuildpackOutput}; use std::io::Stdout; -use std::marker::PhantomData; /// Output a message as a single step, ideally a short message /// @@ -95,6 +82,6 @@ fn logger() -> BuildpackOutput { // Be careful not to do anything that might access this state // as it's ephemeral data (i.e. not passed in from the start of the build) data: BuildData::default(), - state: state::InSection, + _state: state::InSection, } } From 80c28c33612cc74b6a34ac71bdd67354966d0b5d Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 26 Jan 2024 12:56:06 -0600 Subject: [PATCH 08/99] Address `strip_trailing_whitespace` comments - Annotate that it's only used in tests - Rename to make it clearer that it operates on all lines - Refactor to not rely on regex --- libherokubuildpack/Cargo.toml | 4 +-- libherokubuildpack/src/output/build_log.rs | 6 ++-- libherokubuildpack/src/output/style.rs | 16 +++++++-- libherokubuildpack/src/output/util.rs | 39 +++++++++++----------- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index e4f3fd4e..cb6246a1 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -27,7 +27,7 @@ tar = ["dep:tar", "dep:flate2"] toml = ["dep:toml"] fs = ["dep:pathdiff"] command = ["write", "dep:crossbeam-utils"] -output = ["dep:lazy_static", "dep:regex", "dep:const_format"] +output = ["dep:const_format"] write = [] [dependencies] @@ -46,8 +46,6 @@ termcolor = { version = "1.4.0", optional = true } thiserror = { version = "1.0.50", optional = true } toml = { workspace = true, optional = true } ureq = { version = "2.9.1", default-features = false, features = ["tls"], optional = true } -lazy_static = { version = "1", optional = true } -regex = { version = "1", optional = true } const_format = { version = "0.2", optional = true } [dev-dependencies] diff --git a/libherokubuildpack/src/output/build_log.rs b/libherokubuildpack/src/output/build_log.rs index d492d01a..376c856b 100644 --- a/libherokubuildpack/src/output/build_log.rs +++ b/libherokubuildpack/src/output/build_log.rs @@ -383,7 +383,7 @@ mod test { use super::*; use crate::command::CommandExt; use crate::output::style::strip_control_codes; - use crate::output::util::strip_trailing_whitespace; + use crate::output::util::test_helpers::trim_end_lines; use indoc::formatdoc; use libcnb_test::assert_contains; use pretty_assertions::assert_eq; @@ -419,7 +419,7 @@ mod test { assert_eq!( expected, - strip_trailing_whitespace(strip_control_codes(String::from_utf8_lossy(&io))) + trim_end_lines(strip_control_codes(String::from_utf8_lossy(&io))) ); } @@ -438,7 +438,7 @@ mod test { let io = stream.finish_timed_stream().end_section().finish(); - let actual = strip_trailing_whitespace(strip_control_codes(String::from_utf8_lossy(&io))); + let actual = trim_end_lines(strip_control_codes(String::from_utf8_lossy(&io))); assert_contains!(actual, " hello world\n"); } diff --git a/libherokubuildpack/src/output/style.rs b/libherokubuildpack/src/output/style.rs index e5d41fee..d1143813 100644 --- a/libherokubuildpack/src/output/style.rs +++ b/libherokubuildpack/src/output/style.rs @@ -98,16 +98,28 @@ pub(crate) fn header(contents: impl AsRef) -> String { colorize(HEROKU_COLOR, format!("\n# {contents}")) } +pub(crate) fn replace_chars_preserve_whitespace(input: &str, replacement: &str) -> String { + input + .chars() + .map(|c| { + if c.is_whitespace() { + c.to_string() + } else { + replacement.to_string() + } + }) + .collect() +} + // Prefix is expected to be a single line // // If contents is multi line then indent additional lines to align with the end of the prefix. pub(crate) fn prefix_indent(prefix: impl AsRef, contents: impl AsRef) -> String { let prefix = prefix.as_ref(); let contents = contents.as_ref(); - let non_whitespace_re = regex::Regex::new("\\S").expect("Clippy"); let clean_prefix = strip_control_codes(prefix); - let indent_str = non_whitespace_re.replace_all(&clean_prefix, " "); // Preserve whitespace characters like tab and space, replace all characters with spaces + let indent_str = replace_chars_preserve_whitespace(&clean_prefix, " "); let lines = LinesWithEndings::from(contents).collect::>(); if let Some((first, rest)) = lines.split_first() { diff --git a/libherokubuildpack/src/output/util.rs b/libherokubuildpack/src/output/util.rs index 808663c7..b7921266 100644 --- a/libherokubuildpack/src/output/util.rs +++ b/libherokubuildpack/src/output/util.rs @@ -1,8 +1,3 @@ -use lazy_static::lazy_static; -lazy_static! { - static ref TRAILING_WHITESPACE_RE: regex::Regex = regex::Regex::new(r"\s+$").expect("clippy"); -} - /// Iterator yielding every line in a string. The line includes newline character(s). /// /// @@ -13,6 +8,7 @@ lazy_static! { /// /// There's another option to `lines().fold(String::new(), |s, l| s + l + "\n")`, but that /// always adds a trailing newline even if the original string doesn't have one. +/// pub(crate) struct LinesWithEndings<'a> { input: &'a str, } @@ -39,19 +35,22 @@ impl<'a> Iterator for LinesWithEndings<'a> { } } -/// Removes trailing whitespace from lines -/// -/// Useful because most editors strip trailing whitespace (in test fixtures) -/// but commands emit newlines -/// with leading spaces. These can be sanitized by removing trailing whitespace. -#[allow(dead_code)] -pub(crate) fn strip_trailing_whitespace(s: impl AsRef) -> String { - LinesWithEndings::from(s.as_ref()) - .map(|line| { - // Remove empty indented lines - TRAILING_WHITESPACE_RE.replace(line, "\n").to_string() +#[cfg(test)] +pub(crate) mod test_helpers { + use super::*; + use std::fmt::Write; + + /// Removes trailing whitespace from lines + /// + /// Useful because most editors strip trailing whitespace (in test fixtures) + /// but commands emit newlines + /// with leading spaces. These can be sanitized by removing trailing whitespace. + pub(crate) fn trim_end_lines(s: impl AsRef) -> String { + LinesWithEndings::from(s.as_ref()).fold(String::new(), |mut output, line| { + let _ = writeln!(output, "{}", line.trim_end()); + output }) - .collect::() + } } #[cfg(test)] @@ -60,11 +59,11 @@ mod test { use std::fmt::Write; #[test] - fn test_trailing_whitespace() { - let actual = strip_trailing_whitespace("hello \n"); + fn test_trim_end_lines() { + let actual = test_helpers::trim_end_lines("hello \n"); assert_eq!("hello\n", &actual); - let actual = strip_trailing_whitespace("hello\n \nworld\n"); + let actual = test_helpers::trim_end_lines("hello\n \nworld\n"); assert_eq!("hello\n\nworld\n", &actual); } From b55e2c71b1804b6eeb432589942a0b501d21e355 Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 26 Jan 2024 13:22:10 -0600 Subject: [PATCH 09/99] Remove ascii_table dependency This was used for the style guide printing. --- libherokubuildpack/Cargo.toml | 1 - libherokubuildpack/src/lib.rs | 3 --- 2 files changed, 4 deletions(-) diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index cb6246a1..e8c3458c 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -54,4 +54,3 @@ libcnb-test = {workspace = true} indoc = "2" pretty_assertions = "1" fun_run = "0.1.1" -ascii_table = { version = "4", features = ["color_codes"] } diff --git a/libherokubuildpack/src/lib.rs b/libherokubuildpack/src/lib.rs index 2744ae21..20c78ec1 100644 --- a/libherokubuildpack/src/lib.rs +++ b/libherokubuildpack/src/lib.rs @@ -20,8 +20,5 @@ pub mod tar; pub mod toml; #[cfg(feature = "write")] pub mod write; - -#[cfg(test)] -use ascii_table as _; #[cfg(test)] use fun_run as _; From 15c828eaa4dd0563a7043842468f9d9c19c619d1 Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 26 Jan 2024 13:24:52 -0600 Subject: [PATCH 10/99] Rename `build_log` module to `BuildpackOutput` --- .../src/output/{build_log.rs => buildpack_output.rs} | 2 +- libherokubuildpack/src/output/mod.rs | 3 +-- libherokubuildpack/src/output/section_log.rs | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) rename libherokubuildpack/src/output/{build_log.rs => buildpack_output.rs} (99%) diff --git a/libherokubuildpack/src/output/build_log.rs b/libherokubuildpack/src/output/buildpack_output.rs similarity index 99% rename from libherokubuildpack/src/output/build_log.rs rename to libherokubuildpack/src/output/buildpack_output.rs index 376c856b..e705a870 100644 --- a/libherokubuildpack/src/output/build_log.rs +++ b/libherokubuildpack/src/output/buildpack_output.rs @@ -3,7 +3,7 @@ //! Use the `BuildLog` to output structured text as a buildpack is executing //! //! ``` -//! use libherokubuildpack::output::build_log::BuildpackOutput; +//! use libherokubuildpack::output::buildpack_output::BuildpackOutput; //! //! let mut output = BuildpackOutput::new(std::io::stdout()) //! .start("Heroku Ruby Buildpack"); diff --git a/libherokubuildpack/src/output/mod.rs b/libherokubuildpack/src/output/mod.rs index b7dbd856..c175f985 100644 --- a/libherokubuildpack/src/output/mod.rs +++ b/libherokubuildpack/src/output/mod.rs @@ -1,4 +1,3 @@ -pub mod build_log; -pub mod section_log; +pub mod buildpack_output; pub mod style; mod util; diff --git a/libherokubuildpack/src/output/section_log.rs b/libherokubuildpack/src/output/section_log.rs index c0b1f8e2..a83ae406 100644 --- a/libherokubuildpack/src/output/section_log.rs +++ b/libherokubuildpack/src/output/section_log.rs @@ -22,8 +22,8 @@ //! //! log_step("Clearing the cache") //! ``` -use crate::output::build_log::StreamLog; -use crate::output::build_log::{state, BuildData, BuildpackOutput}; +use crate::output::buildpack_output::StreamLog; +use crate::output::buildpack_output::{state, BuildData, BuildpackOutput}; use std::io::Stdout; /// Output a message as a single step, ideally a short message From 4e2887f413ae7f5afb4c1d530bb5cd29cf303c70 Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 26 Jan 2024 13:28:16 -0600 Subject: [PATCH 11/99] Rename `InSection` to `Section` --- .../src/output/buildpack_output.rs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/libherokubuildpack/src/output/buildpack_output.rs b/libherokubuildpack/src/output/buildpack_output.rs index e705a870..96658a0a 100644 --- a/libherokubuildpack/src/output/buildpack_output.rs +++ b/libherokubuildpack/src/output/buildpack_output.rs @@ -59,7 +59,7 @@ pub(crate) mod state { pub struct Started; #[derive(Debug)] - pub struct InSection; + pub struct Section; } impl BuildpackOutput @@ -96,13 +96,13 @@ impl BuildpackOutput where W: Write + Send + Sync + Debug + 'static, { - pub fn section(mut self, s: &str) -> BuildpackOutput { + pub fn section(mut self, s: &str) -> BuildpackOutput { writeln_now(&mut self.io, style::section(s)); BuildpackOutput { io: self.io, data: self.data, - _state: state::InSection, + _state: state::Section, } } @@ -124,7 +124,7 @@ where } } -impl BuildpackOutput +impl BuildpackOutput where W: Write + Send + Sync + Debug + 'static, { @@ -133,7 +133,7 @@ where } #[must_use] - pub fn step(mut self, s: &str) -> BuildpackOutput { + pub fn step(mut self, s: &str) -> BuildpackOutput { self.mut_step(s); self @@ -162,11 +162,11 @@ where } } - pub fn announce(self) -> AnnounceLog { + pub fn announce(self) -> AnnounceLog { AnnounceLog { io: self.io, data: self.data, - _state: state::InSection, + _state: state::Section, leader: Some("\n".to_string()), } } @@ -215,29 +215,29 @@ where } } -impl AnnounceLog +impl AnnounceLog where W: Write + Send + Sync + Debug + 'static, { #[must_use] - pub fn warning(mut self, s: &str) -> AnnounceLog { + pub fn warning(mut self, s: &str) -> AnnounceLog { self.log_warning(s); self } #[must_use] - pub fn important(mut self, s: &str) -> AnnounceLog { + pub fn important(mut self, s: &str) -> AnnounceLog { self.log_important(s); self } - pub fn end_announce(self) -> BuildpackOutput { + pub fn end_announce(self) -> BuildpackOutput { BuildpackOutput { io: self.io, data: self.data, - _state: state::InSection, + _state: state::Section, } } } @@ -332,7 +332,7 @@ where /// If any of those boxed writers are retained then the `W` cannot /// be reclaimed and returned. This will cause a panic. #[must_use] - pub fn finish_timed_stream(self) -> BuildpackOutput { + pub fn finish_timed_stream(self) -> BuildpackOutput { let duration = self.started.elapsed(); let mut io = Arc::try_unwrap(self.arc_io) @@ -346,7 +346,7 @@ where let mut section = BuildpackOutput { io, data: self.data, - _state: state::InSection, + _state: state::Section, }; section.mut_step(&format!( From 4e6f3d31ce95458388561a8290dc03f7ab0410b2 Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 26 Jan 2024 14:50:46 -0600 Subject: [PATCH 12/99] Fix spelling in test --- libherokubuildpack/src/output/buildpack_output.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libherokubuildpack/src/output/buildpack_output.rs b/libherokubuildpack/src/output/buildpack_output.rs index 96658a0a..14ee297a 100644 --- a/libherokubuildpack/src/output/buildpack_output.rs +++ b/libherokubuildpack/src/output/buildpack_output.rs @@ -481,7 +481,7 @@ mod test { let writer = Vec::new(); let logger = BuildpackOutput::new(writer) .start("RCT") - .section("Guest thoughs") + .section("Guest thoughts") .step("The scenery here is wonderful") .announce(); @@ -498,7 +498,7 @@ mod test { # RCT - - Guest thoughs + - Guest thoughts - The scenery here is wonderful ! It's too crowded here From d677ee54a57d93c0f33a7c2a9ab0bf7aa77f2932 Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 26 Jan 2024 15:10:49 -0600 Subject: [PATCH 13/99] s/log/output for buildpack_output Uses consistent naming so that we distinguish between the general concept of a "log" which is not usually visible to the end user and the "output" which is the primary interface with our end user. Leaves the `section_log` code and renames it `inline_output` --- README.md | 2 +- .../src/output/buildpack_output.rs | 86 +++++++++--------- .../src/output/inline_output.rs | 84 ++++++++++++++++++ libherokubuildpack/src/output/mod.rs | 1 + libherokubuildpack/src/output/section_log.rs | 87 ------------------- 5 files changed, 126 insertions(+), 134 deletions(-) create mode 100644 libherokubuildpack/src/output/inline_output.rs delete mode 100644 libherokubuildpack/src/output/section_log.rs diff --git a/README.md b/README.md index 67e788b5..098bda5a 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,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/src/output/buildpack_output.rs b/libherokubuildpack/src/output/buildpack_output.rs index 14ee297a..987dcb1b 100644 --- a/libherokubuildpack/src/output/buildpack_output.rs +++ b/libherokubuildpack/src/output/buildpack_output.rs @@ -1,6 +1,6 @@ -//! # Build output logging +//! # Buildpack output //! -//! Use the `BuildLog` to output structured text as a buildpack is executing +//! Use the [`BuildpackOutput`] to output structured text as a buildpack is executing //! //! ``` //! use libherokubuildpack::output::buildpack_output::BuildpackOutput; @@ -15,9 +15,7 @@ //! output.finish(); //! ``` //! -//! To log inside of a layer see `section_log`. -//! -//! For usage details run `cargo run --bin print_style_guide` +//! To output inside of a layer see [`inline_output`]. use crate::output::style; use std::fmt::Debug; use std::io::Write; @@ -33,7 +31,7 @@ pub struct BuildpackOutput { pub(crate) _state: T, } -/// A bag of data passed throughout the lifecycle of a `BuildLog` +/// A bag of data passed throughout the lifecycle of a [`BuildpackOutput`] #[derive(Debug)] pub(crate) struct BuildData { pub(crate) started: Instant, @@ -47,9 +45,9 @@ impl Default for BuildData { } } -/// Various states for `BuildOutput` to contain +/// Various states for [`BuildpackOutput`] to contain /// -/// The `BuildLog` struct acts as a logging state machine. These structs +/// The [`BuildpackOutput`] struct acts as an output state machine. These structs /// are meant to represent those states pub(crate) mod state { #[derive(Debug)] @@ -114,8 +112,8 @@ where self.io } - pub fn announce(self) -> AnnounceLog { - AnnounceLog { + pub fn announce(self) -> Announce { + Announce { io: self.io, data: self.data, _state: state::Started, @@ -139,12 +137,12 @@ where self } - pub fn step_timed_stream(mut self, s: &str) -> StreamLog { + pub fn step_timed_stream(mut self, s: &str) -> Stream { self.mut_step(s); let started = Instant::now(); let arc_io = Arc::new(Mutex::new(self.io)); - let mut stream = StreamLog { + let mut stream = Stream { arc_io, data: self.data, started, @@ -162,8 +160,8 @@ where } } - pub fn announce(self) -> AnnounceLog { - AnnounceLog { + pub fn announce(self) -> Announce { + Announce { io: self.io, data: self.data, _state: state::Section, @@ -174,7 +172,7 @@ where // Store internal state, print leading character exactly once on warning or important #[derive(Debug)] -pub struct AnnounceLog +pub struct Announce where W: Write + Send + Sync + Debug + 'static, { @@ -184,12 +182,12 @@ where leader: Option, } -impl AnnounceLog +impl Announce where T: Debug, W: Write + Send + Sync + Debug + 'static, { - fn log_warning(&mut self, s: &str) { + fn shared_warning(&mut self, s: &str) { if let Some(leader) = self.leader.take() { write_now(&mut self.io, leader); } @@ -198,7 +196,7 @@ where writeln_now(&mut self.io, ""); } - fn log_important(&mut self, s: &str) { + fn shared_important(&mut self, s: &str) { if let Some(leader) = self.leader.take() { write_now(&mut self.io, leader); } @@ -215,20 +213,20 @@ where } } -impl AnnounceLog +impl Announce where W: Write + Send + Sync + Debug + 'static, { #[must_use] - pub fn warning(mut self, s: &str) -> AnnounceLog { - self.log_warning(s); + pub fn warning(mut self, s: &str) -> Announce { + self.shared_warning(s); self } #[must_use] - pub fn important(mut self, s: &str) -> AnnounceLog { - self.log_important(s); + pub fn important(mut self, s: &str) -> Announce { + self.shared_important(s); self } @@ -242,19 +240,19 @@ where } } -impl AnnounceLog +impl Announce where W: Write + Send + Sync + Debug + 'static, { #[must_use] - pub fn warning(mut self, s: &str) -> AnnounceLog { - self.log_warning(s); + pub fn warning(mut self, s: &str) -> Announce { + self.shared_warning(s); self } #[must_use] - pub fn important(mut self, s: &str) -> AnnounceLog { - self.log_important(s); + pub fn important(mut self, s: &str) -> Announce { + self.shared_important(s); self } @@ -279,39 +277,39 @@ where W: Write + Send + Sync + Debug + 'static, { fn write(&mut self, buf: &[u8]) -> std::io::Result { - let mut io = self.arc.lock().expect("Logging mutex poisoned"); + let mut io = self.arc.lock().expect("Output mutex poisoned"); io.write(buf) } fn flush(&mut self) -> std::io::Result<()> { - let mut io = self.arc.lock().expect("Logging mutex poisoned"); + let mut io = self.arc.lock().expect("Output mutex poisoned"); io.flush() } } /// Stream output to the user /// -/// Mostly used for logging a running command +/// Mostly used for ouputting a running command #[derive(Debug)] -pub struct StreamLog { +pub struct Stream { data: BuildData, arc_io: Arc>, started: Instant, } -impl StreamLog +impl Stream where W: Write + Send + Sync + Debug + 'static, { fn start(&mut self) { - let mut guard = self.arc_io.lock().expect("Logging mutex poisoned"); + let mut guard = self.arc_io.lock().expect("Output mutex poisoned"); let mut io = guard.by_ref(); // Newline before stream https://github.com/heroku/libcnb.rs/issues/582 writeln_now(&mut io, ""); } /// Yield boxed writer that can be used for formatting and streaming contents - /// back to the logger. + /// back to the output. pub fn io(&mut self) -> Box { Box::new(crate::write::line_mapped( LockedWriter { @@ -338,7 +336,7 @@ where let mut io = Arc::try_unwrap(self.arc_io) .expect("Expected buildpack author to not retain any IO streaming IO instances") .into_inner() - .expect("Logging mutex was poisioned"); + .expect("Output mutex was poisioned"); // // Newline after stream writeln_now(&mut io, ""); @@ -362,20 +360,16 @@ where /// /// This is especially important for writing individual characters to the same line fn write_now(destination: &mut D, msg: impl AsRef) { - write!(destination, "{}", msg.as_ref()).expect("Logging error: UI writer closed"); + write!(destination, "{}", msg.as_ref()).expect("Output error: UI writer closed"); - destination - .flush() - .expect("Logging error: UI writer closed"); + destination.flush().expect("Output error: UI writer closed"); } /// 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("Logging error: UI writer closed"); + writeln!(destination, "{}", msg.as_ref()).expect("Output error: UI writer closed"); - destination - .flush() - .expect("Logging error: UI writer closed"); + destination.flush().expect("Output error: UI writer closed"); } #[cfg(test)] @@ -479,13 +473,13 @@ mod test { #[test] fn double_warning_step_padding() { let writer = Vec::new(); - let logger = BuildpackOutput::new(writer) + let output = BuildpackOutput::new(writer) .start("RCT") .section("Guest thoughts") .step("The scenery here is wonderful") .announce(); - let io = logger + let io = output .warning("It's too crowded here") .warning("I'm tired") .end_announce() diff --git a/libherokubuildpack/src/output/inline_output.rs b/libherokubuildpack/src/output/inline_output.rs new file mode 100644 index 00000000..b2f12e67 --- /dev/null +++ b/libherokubuildpack/src/output/inline_output.rs @@ -0,0 +1,84 @@ +//! Write to the build output in a [`BuildpackOutput`] format with functions +//! +//! ## What +//! +//! Outputting from within a layer can be difficult because calls to the layer interface are not +//! mutable nor consumable. Functions can be used at any time with no restrictions. The +//! only downside is that the buildpack author (you) is now responsible for: +//! +//! - Ensuring that [`BuildpackOutput::section`] funciton was called right before any of these +//! functions are called. +//! - Ensuring that you are not attempting to output while already streaming i.e. calling [`step`] within +//! a [`step_stream`] call. +//! +//! ## Use +//! +//! The main use case is writing output in a layer: +//! +//! ```no_run +//! use libherokubuildpack::output::inline_output; +//! +//! inline_output::step("Clearing the cache") +//! ``` +use crate::output::buildpack_output::{state, BuildData, BuildpackOutput, Stream}; +use std::io::Stdout; + +/// Output a message as a single step, ideally a short message +/// +/// ``` +/// use libherokubuildpack::output::inline_output; +/// +/// inline_output::step("Clearing cache (ruby version changed)"); +/// ``` +pub fn step(s: impl AsRef) { + let _ = build_buildpack_output().step(s.as_ref()); +} + +/// Will print the input string and yield a [`Stream`] that can be used to print +/// to the output. The main use case is running commands +/// +/// ```no_run +/// use fun_run::CommandWithName; +/// use libherokubuildpack::output::inline_output; +/// use libherokubuildpack::output::style; +/// +/// let mut cmd = std::process::Command::new("bundle"); +/// cmd.arg("install"); +/// +/// inline_output::step_stream(format!("Running {}", style::command(cmd.name())), |stream| { +/// cmd.stream_output(stream.io(), stream.io()).unwrap() +/// }); +/// ``` +/// +/// Timing information will be output at the end of the step. +pub fn step_stream(s: impl AsRef, f: impl FnOnce(&mut Stream) -> T) -> T { + let mut stream = build_buildpack_output().step_timed_stream(s.as_ref()); + let out = f(&mut stream); + let _ = stream.finish_timed_stream(); + out +} + +/// Print an error block to the output +pub fn error(s: impl AsRef) { + build_buildpack_output().announce().error(s.as_ref()); +} + +/// Print an warning block to the output +pub fn warning(s: impl AsRef) { + let _ = build_buildpack_output().announce().warning(s.as_ref()); +} + +/// Print an important block to the output +pub fn important(s: impl AsRef) { + let _ = build_buildpack_output().announce().important(s.as_ref()); +} + +fn build_buildpack_output() -> BuildpackOutput { + BuildpackOutput:: { + io: std::io::stdout(), + // Be careful not to do anything that might access this state + // as it's ephemeral data (i.e. not passed in from the start of the build) + data: BuildData::default(), + _state: state::Section, + } +} diff --git a/libherokubuildpack/src/output/mod.rs b/libherokubuildpack/src/output/mod.rs index c175f985..4ed0c4af 100644 --- a/libherokubuildpack/src/output/mod.rs +++ b/libherokubuildpack/src/output/mod.rs @@ -1,3 +1,4 @@ pub mod buildpack_output; +pub mod inline_output; pub mod style; mod util; diff --git a/libherokubuildpack/src/output/section_log.rs b/libherokubuildpack/src/output/section_log.rs deleted file mode 100644 index a83ae406..00000000 --- a/libherokubuildpack/src/output/section_log.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Write to the build output in a `Box` format with functions -//! -//! ## What -//! -//! Logging from within a layer can be difficult because calls to the layer interface are not -//! mutable nor consumable. Functions can be used at any time with no restrictions. The -//! only downside is that the buildpack author (you) is now responsible for: -//! -//! - Ensuring that `Box::section()` was called right before any of these -//! functions are called. -//! - Ensuring that you are not attempting to log while already logging i.e. calling `step()` within a -//! `step_timed()` call. -//! -//! For usage details run `cargo run --bin print_style_guide` -//! -//! ## Use -//! -//! The main use case is logging inside of a layer: -//! -//! ```no_run -//! use libherokubuildpack::output::section_log::log_step; -//! -//! log_step("Clearing the cache") -//! ``` -use crate::output::buildpack_output::StreamLog; -use crate::output::buildpack_output::{state, BuildData, BuildpackOutput}; -use std::io::Stdout; - -/// Output a message as a single step, ideally a short message -/// -/// ``` -/// use libherokubuildpack::output::section_log::log_step; -/// -/// log_step("Clearing cache (ruby version changed)"); -/// ``` -pub fn log_step(s: impl AsRef) { - let _ = logger().step(s.as_ref()); -} - -/// Will print the input string and yield a `Box` that can be used to print -/// to the output. The main use case is running commands -/// -/// ```no_run -/// use fun_run::CommandWithName; -/// use libherokubuildpack::output::section_log::log_step_stream; -/// use libherokubuildpack::output::style; -/// -/// let mut cmd = std::process::Command::new("bundle"); -/// cmd.arg("install"); -/// -/// log_step_stream(format!("Running {}", style::command(cmd.name())), |stream| { -/// cmd.stream_output(stream.io(), stream.io()).unwrap() -/// }); -/// ``` -/// -/// Timing information will be output at the end of the step. -pub fn log_step_stream(s: impl AsRef, f: impl FnOnce(&mut StreamLog) -> T) -> T { - let mut stream = logger().step_timed_stream(s.as_ref()); - let out = f(&mut stream); - let _ = stream.finish_timed_stream(); - out -} - -/// Print an error block to the output -pub fn log_error(s: impl AsRef) { - logger().announce().error(s.as_ref()); -} - -/// Print an warning block to the output -pub fn log_warning(s: impl AsRef) { - let _ = logger().announce().warning(s.as_ref()); -} - -/// Print an important block to the output -pub fn log_important(s: impl AsRef) { - let _ = logger().announce().important(s.as_ref()); -} - -fn logger() -> BuildpackOutput { - BuildpackOutput:: { - io: std::io::stdout(), - // Be careful not to do anything that might access this state - // as it's ephemeral data (i.e. not passed in from the start of the build) - data: BuildData::default(), - _state: state::InSection, - } -} From b9f6e2fdf8fcf0bf9d61480899912afce869e7bc Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 26 Jan 2024 17:22:12 -0600 Subject: [PATCH 14/99] Replace announce struct with additional state It was identified that calling `announce().warning("").end_announce` is "weird" which I agree with. It exists due to a desire to tame newlines when toggling between multiple warnings/errors and "normal" output, more below. The prior implementation was shaped by the limitations of boxed traits. With that out of the way, this implementation swaps `struct Announce` for `BuildpackOutput>` where T is the "return type". Imagine the states going something like this: ``` Section -> Announce
-> Section or Section -> Announce
-> Announce
-> Section or Started -> Announce -> Started or Started -> Announce -> Announce -> Started ``` The `Announce` state is entered any time a `warning()` or `important()` is called. It is exited when a function from SectionMarker or StartedMarker is called that will output to the UI. This works, because those functions always return a `BuildpackOutput
` or `BuildpackOutput` and effectively act as an exit of the Announce state. The downside to this approach is that any methods that do not consume, must be implemented separately, like `mut_step()`. --- .../src/output/buildpack_output.rs | 259 +++++++----------- .../src/output/inline_output.rs | 8 +- 2 files changed, 109 insertions(+), 158 deletions(-) diff --git a/libherokubuildpack/src/output/buildpack_output.rs b/libherokubuildpack/src/output/buildpack_output.rs index 987dcb1b..1a98a58c 100644 --- a/libherokubuildpack/src/output/buildpack_output.rs +++ b/libherokubuildpack/src/output/buildpack_output.rs @@ -15,7 +15,6 @@ //! output.finish(); //! ``` //! -//! To output inside of a layer see [`inline_output`]. use crate::output::style; use std::fmt::Debug; use std::io::Write; @@ -28,7 +27,7 @@ use std::time::Instant; pub struct BuildpackOutput { pub(crate) io: W, pub(crate) data: BuildData, - pub(crate) _state: T, + pub(crate) state: T, } /// A bag of data passed throughout the lifecycle of a [`BuildpackOutput`] @@ -58,6 +57,89 @@ pub(crate) mod state { #[derive(Debug)] pub struct Section; + + #[derive(Debug)] + pub struct Announce(pub T); +} + +#[doc(hidden)] +pub trait StartedMarker {} +impl StartedMarker for state::Started {} +impl StartedMarker for state::Announce where S: StartedMarker + IntoAnnounceMarker {} + +#[doc(hidden)] +pub trait SectionMarker {} +impl SectionMarker for state::Section {} +impl SectionMarker for state::Announce where S: SectionMarker + IntoAnnounceMarker {} + +#[doc(hidden)] +pub trait IntoAnnounceMarker {} +impl IntoAnnounceMarker for state::Section {} +impl IntoAnnounceMarker for state::Started {} + +impl BuildpackOutput, W> +where + W: Write + Send + Sync + Debug + 'static, +{ + #[must_use] + pub fn warning(mut self, s: &str) -> BuildpackOutput, W> { + writeln_now(&mut self.io, style::warning(s.trim())); + writeln_now(&mut self.io, ""); + + self + } + + #[must_use] + pub fn important(mut self, s: &str) -> BuildpackOutput, W> { + writeln_now(&mut self.io, style::important(s.trim())); + writeln_now(&mut self.io, ""); + + self + } + + pub fn error(mut self, s: &str) { + writeln_now(&mut self.io, style::error(s.trim())); + writeln_now(&mut self.io, ""); + } +} + +impl BuildpackOutput +where + S: IntoAnnounceMarker, + W: Write + Send + Sync + Debug + 'static, +{ + #[must_use] + pub fn warning(mut self, s: &str) -> BuildpackOutput, W> { + writeln_now(&mut self.io, ""); + + let announce = BuildpackOutput { + io: self.io, + data: self.data, + state: state::Announce(self.state), + }; + announce.warning(s) + } + + #[must_use] + pub fn important(mut self, s: &str) -> BuildpackOutput, W> { + writeln_now(&mut self.io, ""); + + let announce = BuildpackOutput { + io: self.io, + data: self.data, + state: state::Announce(self.state), + }; + announce.important(s) + } + + pub fn error(self, s: &str) { + let announce = BuildpackOutput { + io: self.io, + data: self.data, + state: state::Announce(self.state), + }; + announce.error(s); + } } impl BuildpackOutput @@ -67,7 +149,7 @@ where pub fn new(io: W) -> Self { Self { io, - _state: state::NotStarted, + state: state::NotStarted, data: BuildData::default(), } } @@ -85,13 +167,14 @@ where BuildpackOutput { io: self.io, data: self.data, - _state: state::Started, + state: state::Started, } } } -impl BuildpackOutput +impl BuildpackOutput where + S: StartedMarker, W: Write + Send + Sync + Debug + 'static, { pub fn section(mut self, s: &str) -> BuildpackOutput { @@ -100,7 +183,7 @@ where BuildpackOutput { io: self.io, data: self.data, - _state: state::Section, + state: state::Section, } } @@ -111,15 +194,6 @@ where writeln_now(&mut self.io, style::section(format!("Done {details}"))); self.io } - - pub fn announce(self) -> Announce { - Announce { - io: self.io, - data: self.data, - _state: state::Started, - leader: Some("\n".to_string()), - } - } } impl BuildpackOutput @@ -129,16 +203,26 @@ where pub fn mut_step(&mut self, s: &str) { writeln_now(&mut self.io, style::step(s)); } +} +impl BuildpackOutput +where + S: SectionMarker, + W: Write + Send + Sync + Debug + 'static, +{ #[must_use] pub fn step(mut self, s: &str) -> BuildpackOutput { - self.mut_step(s); + writeln_now(&mut self.io, style::step(s)); - self + BuildpackOutput { + io: self.io, + data: self.data, + state: state::Section, + } } pub fn step_timed_stream(mut self, s: &str) -> Stream { - self.mut_step(s); + writeln_now(&mut self.io, style::step(s)); let started = Instant::now(); let arc_io = Arc::new(Mutex::new(self.io)); @@ -156,112 +240,7 @@ where BuildpackOutput { io: self.io, data: self.data, - _state: state::Started, - } - } - - pub fn announce(self) -> Announce { - Announce { - io: self.io, - data: self.data, - _state: state::Section, - leader: Some("\n".to_string()), - } - } -} - -// Store internal state, print leading character exactly once on warning or important -#[derive(Debug)] -pub struct Announce -where - W: Write + Send + Sync + Debug + 'static, -{ - io: W, - data: BuildData, - _state: T, - leader: Option, -} - -impl Announce -where - T: Debug, - W: Write + Send + Sync + Debug + 'static, -{ - fn shared_warning(&mut self, s: &str) { - if let Some(leader) = self.leader.take() { - write_now(&mut self.io, leader); - } - - writeln_now(&mut self.io, style::warning(s.trim())); - writeln_now(&mut self.io, ""); - } - - fn shared_important(&mut self, s: &str) { - if let Some(leader) = self.leader.take() { - write_now(&mut self.io, leader); - } - writeln_now(&mut self.io, style::important(s.trim())); - writeln_now(&mut self.io, ""); - } - - pub fn error(mut self, s: &str) { - if let Some(leader) = self.leader.take() { - write_now(&mut self.io, leader); - } - writeln_now(&mut self.io, style::error(s.trim())); - writeln_now(&mut self.io, ""); - } -} - -impl Announce -where - W: Write + Send + Sync + Debug + 'static, -{ - #[must_use] - pub fn warning(mut self, s: &str) -> Announce { - self.shared_warning(s); - - self - } - - #[must_use] - pub fn important(mut self, s: &str) -> Announce { - self.shared_important(s); - - self - } - - pub fn end_announce(self) -> BuildpackOutput { - BuildpackOutput { - io: self.io, - data: self.data, - _state: state::Section, - } - } -} - -impl Announce -where - W: Write + Send + Sync + Debug + 'static, -{ - #[must_use] - pub fn warning(mut self, s: &str) -> Announce { - self.shared_warning(s); - self - } - - #[must_use] - pub fn important(mut self, s: &str) -> Announce { - self.shared_important(s); - self - } - - #[must_use] - pub fn end_announce(self) -> BuildpackOutput { - BuildpackOutput { - io: self.io, - data: self.data, - _state: state::Started, + state: state::Started, } } } @@ -344,7 +323,7 @@ where let mut section = BuildpackOutput { io, data: self.data, - _state: state::Section, + state: state::Section, }; section.mut_step(&format!( @@ -444,9 +423,7 @@ mod test { .start("RCT") .section("Guest thoughs") .step("The scenery here is wonderful") - .announce() .warning("It's too crowded here\nI'm tired") - .end_announce() .step("The jumping fountains are great") .step("The music is nice here") .end_section() @@ -476,13 +453,11 @@ mod test { let output = BuildpackOutput::new(writer) .start("RCT") .section("Guest thoughts") - .step("The scenery here is wonderful") - .announce(); + .step("The scenery here is wonderful"); let io = output .warning("It's too crowded here") .warning("I'm tired") - .end_announce() .step("The jumping fountains are great") .step("The music is nice here") .end_section() @@ -506,28 +481,4 @@ mod test { assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); } - - #[test] - fn announce_and_exit_makes_no_whitespace() { - let writer = Vec::new(); - let io = BuildpackOutput::new(writer) - .start("Quick and simple") - .section("Start") - .step("Step") - .announce() // <== Here - .end_announce() // <== Here - .end_section() - .finish(); - - let expected = formatdoc! {" - - # Quick and simple - - - Start - - Step - - Done (finished in < 0.1s) - "}; - - assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); - } } diff --git a/libherokubuildpack/src/output/inline_output.rs b/libherokubuildpack/src/output/inline_output.rs index b2f12e67..67234558 100644 --- a/libherokubuildpack/src/output/inline_output.rs +++ b/libherokubuildpack/src/output/inline_output.rs @@ -60,17 +60,17 @@ pub fn step_stream(s: impl AsRef, f: impl FnOnce(&mut Stream) -> /// Print an error block to the output pub fn error(s: impl AsRef) { - build_buildpack_output().announce().error(s.as_ref()); + build_buildpack_output().error(s.as_ref()); } /// Print an warning block to the output pub fn warning(s: impl AsRef) { - let _ = build_buildpack_output().announce().warning(s.as_ref()); + let _ = build_buildpack_output().warning(s.as_ref()); } /// Print an important block to the output pub fn important(s: impl AsRef) { - let _ = build_buildpack_output().announce().important(s.as_ref()); + let _ = build_buildpack_output().important(s.as_ref()); } fn build_buildpack_output() -> BuildpackOutput { @@ -79,6 +79,6 @@ fn build_buildpack_output() -> BuildpackOutput { // Be careful not to do anything that might access this state // as it's ephemeral data (i.e. not passed in from the start of the build) data: BuildData::default(), - _state: state::Section, + state: state::Section, } } From 153cd539ace3c8db5ce978c8f00f5e3d9995c464 Mon Sep 17 00:00:00 2001 From: Schneems Date: Fri, 26 Jan 2024 19:20:28 -0600 Subject: [PATCH 15/99] Hide docs for public but internal structures --- libherokubuildpack/src/output/buildpack_output.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libherokubuildpack/src/output/buildpack_output.rs b/libherokubuildpack/src/output/buildpack_output.rs index 1a98a58c..94ff3333 100644 --- a/libherokubuildpack/src/output/buildpack_output.rs +++ b/libherokubuildpack/src/output/buildpack_output.rs @@ -32,6 +32,7 @@ pub struct BuildpackOutput { /// A bag of data passed throughout the lifecycle of a [`BuildpackOutput`] #[derive(Debug)] +#[doc(hidden)] pub(crate) struct BuildData { pub(crate) started: Instant, } @@ -48,6 +49,7 @@ impl Default for BuildData { /// /// The [`BuildpackOutput`] struct acts as an output state machine. These structs /// are meant to represent those states +#[doc(hidden)] pub(crate) mod state { #[derive(Debug)] pub struct NotStarted; @@ -247,6 +249,7 @@ where // TODO: Decide if we need documentation for this #[derive(Debug)] +#[doc(hidden)] struct LockedWriter { arc: Arc>, } @@ -270,6 +273,7 @@ where /// /// Mostly used for ouputting a running command #[derive(Debug)] +#[doc(hidden)] pub struct Stream { data: BuildData, arc_io: Arc>, From f4c210c74075d10a64da96c9a198003f04aa9129 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Jan 2024 14:59:42 +0100 Subject: [PATCH 16/99] Remove BuildData It only contained one value and relied on `Default`. Unstarted buildpack output will still have a started value that would be the time the struct was initialized which isn't correct. This was also fixed by making that value optional. --- .../src/output/buildpack_output.rs | 52 +++++++------------ .../src/output/inline_output.rs | 5 +- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/libherokubuildpack/src/output/buildpack_output.rs b/libherokubuildpack/src/output/buildpack_output.rs index 94ff3333..76b00c5e 100644 --- a/libherokubuildpack/src/output/buildpack_output.rs +++ b/libherokubuildpack/src/output/buildpack_output.rs @@ -26,25 +26,10 @@ use std::time::Instant; #[derive(Debug)] pub struct BuildpackOutput { pub(crate) io: W, - pub(crate) data: BuildData, + pub(crate) started: Option, pub(crate) state: T, } -/// A bag of data passed throughout the lifecycle of a [`BuildpackOutput`] -#[derive(Debug)] -#[doc(hidden)] -pub(crate) struct BuildData { - pub(crate) started: Instant, -} - -impl Default for BuildData { - fn default() -> Self { - Self { - started: Instant::now(), - } - } -} - /// Various states for [`BuildpackOutput`] to contain /// /// The [`BuildpackOutput`] struct acts as an output state machine. These structs @@ -116,7 +101,7 @@ where let announce = BuildpackOutput { io: self.io, - data: self.data, + started: self.started, state: state::Announce(self.state), }; announce.warning(s) @@ -128,7 +113,7 @@ where let announce = BuildpackOutput { io: self.io, - data: self.data, + started: self.started, state: state::Announce(self.state), }; announce.important(s) @@ -137,7 +122,7 @@ where pub fn error(self, s: &str) { let announce = BuildpackOutput { io: self.io, - data: self.data, + started: self.started, state: state::Announce(self.state), }; announce.error(s); @@ -152,7 +137,7 @@ where Self { io, state: state::NotStarted, - data: BuildData::default(), + started: None, } } @@ -168,7 +153,7 @@ where pub fn start_silent(self) -> BuildpackOutput { BuildpackOutput { io: self.io, - data: self.data, + started: Some(Instant::now()), state: state::Started, } } @@ -184,16 +169,20 @@ where BuildpackOutput { io: self.io, - data: self.data, + started: self.started, state: state::Section, } } pub fn finish(mut self) -> W { - let elapsed = style::time::human(&self.data.started.elapsed()); - let details = style::details(format!("finished in {elapsed}")); + if let Some(started) = &self.started { + let elapsed = style::time::human(&started.elapsed()); + let details = style::details(format!("finished in {elapsed}")); + writeln_now(&mut self.io, style::section(format!("Done {details}"))); + } else { + writeln_now(&mut self.io, style::section("Done")); + } - writeln_now(&mut self.io, style::section(format!("Done {details}"))); self.io } } @@ -218,7 +207,7 @@ where BuildpackOutput { io: self.io, - data: self.data, + started: self.started, state: state::Section, } } @@ -226,12 +215,11 @@ where pub fn step_timed_stream(mut self, s: &str) -> Stream { writeln_now(&mut self.io, style::step(s)); - let started = Instant::now(); let arc_io = Arc::new(Mutex::new(self.io)); let mut stream = Stream { arc_io, - data: self.data, - started, + started: Instant::now(), + buildpack_output_started: self.started, }; stream.start(); @@ -241,7 +229,7 @@ where pub fn end_section(self) -> BuildpackOutput { BuildpackOutput { io: self.io, - data: self.data, + started: self.started, state: state::Started, } } @@ -275,7 +263,7 @@ where #[derive(Debug)] #[doc(hidden)] pub struct Stream { - data: BuildData, + buildpack_output_started: Option, arc_io: Arc>, started: Instant, } @@ -326,7 +314,7 @@ where let mut section = BuildpackOutput { io, - data: self.data, + started: self.buildpack_output_started, state: state::Section, }; diff --git a/libherokubuildpack/src/output/inline_output.rs b/libherokubuildpack/src/output/inline_output.rs index 67234558..d865efce 100644 --- a/libherokubuildpack/src/output/inline_output.rs +++ b/libherokubuildpack/src/output/inline_output.rs @@ -20,8 +20,9 @@ //! //! inline_output::step("Clearing the cache") //! ``` -use crate::output::buildpack_output::{state, BuildData, BuildpackOutput, Stream}; +use crate::output::buildpack_output::{state, BuildpackOutput, Stream}; use std::io::Stdout; +use std::time::Instant; /// Output a message as a single step, ideally a short message /// @@ -78,7 +79,7 @@ fn build_buildpack_output() -> BuildpackOutput { io: std::io::stdout(), // Be careful not to do anything that might access this state // as it's ephemeral data (i.e. not passed in from the start of the build) - data: BuildData::default(), + started: Some(Instant::now()), state: state::Section, } } From 9070ac2410d696a232342699b77ddcc14088b7e7 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Jan 2024 15:55:37 +0100 Subject: [PATCH 17/99] Rustdoc touchups --- .../src/output/buildpack_output.rs | 20 +++++++------- .../src/output/inline_output.rs | 14 +++++----- libherokubuildpack/src/output/style.rs | 27 +++++++++---------- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/libherokubuildpack/src/output/buildpack_output.rs b/libherokubuildpack/src/output/buildpack_output.rs index 76b00c5e..d8b23038 100644 --- a/libherokubuildpack/src/output/buildpack_output.rs +++ b/libherokubuildpack/src/output/buildpack_output.rs @@ -1,6 +1,6 @@ //! # Buildpack output //! -//! Use the [`BuildpackOutput`] to output structured text as a buildpack is executing +//! Use the [`BuildpackOutput`] to output structured text as a buildpack is executing. //! //! ``` //! use libherokubuildpack::output::buildpack_output::BuildpackOutput; @@ -21,7 +21,7 @@ use std::io::Write; use std::sync::{Arc, Mutex}; use std::time::Instant; -/// See the module docs for example usage +/// See the module docs for example usage. #[allow(clippy::module_name_repetitions)] #[derive(Debug)] pub struct BuildpackOutput { @@ -30,10 +30,10 @@ pub struct BuildpackOutput { pub(crate) state: T, } -/// Various states for [`BuildpackOutput`] to contain +/// Various states for [`BuildpackOutput`] to contain. /// /// The [`BuildpackOutput`] struct acts as an output state machine. These structs -/// are meant to represent those states +/// are meant to represent those states. #[doc(hidden)] pub(crate) mod state { #[derive(Debug)] @@ -257,9 +257,9 @@ where } } -/// Stream output to the user +/// Stream output to the user. /// -/// Mostly used for ouputting a running command +/// Mostly used for outputting a running command. #[derive(Debug)] #[doc(hidden)] pub struct Stream { @@ -309,7 +309,7 @@ where .into_inner() .expect("Output mutex was poisioned"); - // // Newline after stream + // Newline after stream writeln_now(&mut io, ""); let mut section = BuildpackOutput { @@ -327,16 +327,16 @@ where } } -/// Internal helper, ensures that all contents are always flushed (never buffered) +/// Internal helper, ensures that all contents are always flushed (never buffered). /// -/// This is especially important for writing individual characters to the same line +/// This is especially important for writing individual characters to the same line. fn write_now(destination: &mut D, msg: impl AsRef) { write!(destination, "{}", msg.as_ref()).expect("Output error: UI writer closed"); destination.flush().expect("Output error: UI writer closed"); } -/// Internal helper, ensures that all contents are always flushed (never buffered) +/// 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"); diff --git a/libherokubuildpack/src/output/inline_output.rs b/libherokubuildpack/src/output/inline_output.rs index d865efce..04e0f53c 100644 --- a/libherokubuildpack/src/output/inline_output.rs +++ b/libherokubuildpack/src/output/inline_output.rs @@ -1,4 +1,4 @@ -//! Write to the build output in a [`BuildpackOutput`] format with functions +//! Write to the build output in a [`BuildpackOutput`] format with functions. //! //! ## What //! @@ -6,7 +6,7 @@ //! mutable nor consumable. Functions can be used at any time with no restrictions. The //! only downside is that the buildpack author (you) is now responsible for: //! -//! - Ensuring that [`BuildpackOutput::section`] funciton was called right before any of these +//! - Ensuring that [`BuildpackOutput::section`] function was called right before any of these //! functions are called. //! - Ensuring that you are not attempting to output while already streaming i.e. calling [`step`] within //! a [`step_stream`] call. @@ -24,7 +24,7 @@ use crate::output::buildpack_output::{state, BuildpackOutput, Stream}; use std::io::Stdout; use std::time::Instant; -/// Output a message as a single step, ideally a short message +/// Output a message as a single step, ideally a short message. /// /// ``` /// use libherokubuildpack::output::inline_output; @@ -36,7 +36,7 @@ pub fn step(s: impl AsRef) { } /// Will print the input string and yield a [`Stream`] that can be used to print -/// to the output. The main use case is running commands +/// to the output. The main use case is running commands. /// /// ```no_run /// use fun_run::CommandWithName; @@ -59,17 +59,17 @@ pub fn step_stream(s: impl AsRef, f: impl FnOnce(&mut Stream) -> out } -/// Print an error block to the output +/// Print an error block to the output. pub fn error(s: impl AsRef) { build_buildpack_output().error(s.as_ref()); } -/// Print an warning block to the output +/// Print an warning block to the output. pub fn warning(s: impl AsRef) { let _ = build_buildpack_output().warning(s.as_ref()); } -/// Print an important block to the output +/// Print an important block to the output. pub fn important(s: impl AsRef) { let _ = build_buildpack_output().important(s.as_ref()); } diff --git a/libherokubuildpack/src/output/style.rs b/libherokubuildpack/src/output/style.rs index d1143813..aaede471 100644 --- a/libherokubuildpack/src/output/style.rs +++ b/libherokubuildpack/src/output/style.rs @@ -2,34 +2,34 @@ use crate::output::util::LinesWithEndings; use const_format::formatcp; use std::fmt::Write; -/// Helpers for formatting and colorizing your output +/// Helpers for formatting and colorizing your output. -/// Decorated str for prefixing "Help:" +/// Decorated str for prefixing "Help:". pub const HELP: &str = formatcp!("{IMPORTANT_COLOR}! HELP{RESET}"); -/// Decorated str for prefixing "Debug info:" +/// Decorated str for prefixing "Debug info:". pub const DEBUG_INFO: &str = formatcp!("{IMPORTANT_COLOR}Debug info{RESET}"); -/// Decorate a URL for the build output +/// Decorate a URL for the build output. #[must_use] pub fn url(contents: impl AsRef) -> String { colorize(URL_COLOR, contents) } -/// Decorate the name of a command being run i.e. `bundle install` +/// Decorate the name of a command being run i.e. `bundle install`. #[must_use] pub fn command(contents: impl AsRef) -> String { value(colorize(COMMAND_COLOR, contents.as_ref())) } -/// Decorate an important value i.e. `2.3.4` +/// Decorate an important value i.e. `2.3.4`. #[must_use] pub fn value(contents: impl AsRef) -> String { let contents = colorize(VALUE_COLOR, contents.as_ref()); format!("`{contents}`") } -/// Decorate additional information at the end of a line +/// Decorate additional information at the end of a line. #[must_use] pub fn details(contents: impl AsRef) -> String { let contents = contents.as_ref(); @@ -41,7 +41,7 @@ pub(crate) const YELLOW: &str = "\x1B[0;33m"; pub(crate) const CYAN: &str = "\x1B[0;36m"; pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; -pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // magenta +pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // Magenta pub(crate) const DEFAULT_DIM: &str = "\x1B[2;1m"; // Default color but softer/less vibrant pub(crate) const RESET: &str = "\x1B[0m"; @@ -72,8 +72,7 @@ const SECTION_PREFIX: &str = "- "; const STEP_PREFIX: &str = " - "; const CMD_INDENT: &str = " "; -/// Used with libherokubuildpack linemapped command output -/// +/// Used with libherokubuildpack line-mapped command output. #[must_use] pub(crate) fn cmd_stream_format(mut input: Vec) -> Vec { let mut result: Vec = CMD_INDENT.into(); @@ -91,7 +90,7 @@ pub(crate) fn step(contents: impl AsRef) -> String { prefix_indent(STEP_PREFIX, contents) } -/// Used to decorate a buildpack +/// Used to decorate a buildpack. #[must_use] pub(crate) fn header(contents: impl AsRef) -> String { let contents = contents.as_ref(); @@ -150,7 +149,7 @@ pub(crate) fn error(contents: impl AsRef) -> String { colorize(ERROR_COLOR, bangify(contents)) } -/// Helper method that adds a bang i.e. `!` before strings +/// Helper method that adds a bang i.e. `!` before strings. pub(crate) fn bangify(body: impl AsRef) -> String { prepend_each_line("!", " ", body) } @@ -177,12 +176,12 @@ pub(crate) fn prepend_each_line( lines } -/// Colorizes a body while preserving existing color/reset combinations and clearing before newlines +/// Colorizes a body while preserving existing color/reset combinations and clearing before newlines. /// /// Colors with newlines are a problem since the contents stream to git which prepends `remote:` before the `libcnb_test` /// if we don't clear, then we will colorize output that isn't ours. /// -/// Explicitly uncolored output is handled by treating `\x1b[1;39m` (NOCOLOR) as a distinct case from `\x1b[0m` +/// Explicitly uncolored output is handled by treating `\x1b[1;39m` (NOCOLOR) as a distinct case from `\x1b[0m`. pub(crate) fn colorize(color: &str, body: impl AsRef) -> String { body.as_ref() .split('\n') From 83a195a69cfa1f37d4a0f48e784d76a92648b810 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Jan 2024 15:58:21 +0100 Subject: [PATCH 18/99] Rename NOCOLOR to NO_COLOR --- libherokubuildpack/src/output/style.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libherokubuildpack/src/output/style.rs b/libherokubuildpack/src/output/style.rs index aaede471..3d7e4e9d 100644 --- a/libherokubuildpack/src/output/style.rs +++ b/libherokubuildpack/src/output/style.rs @@ -47,7 +47,7 @@ pub(crate) const DEFAULT_DIM: &str = "\x1B[2;1m"; // Default color but softer/le pub(crate) const RESET: &str = "\x1B[0m"; #[cfg(test)] -pub(crate) const NOCOLOR: &str = "\x1B[1;39m"; // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 +pub(crate) const NO_COLOR: &str = "\x1B[1;39m"; // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 pub(crate) const ALL_CODES: [&str; 7] = [ RED, YELLOW, @@ -181,7 +181,7 @@ pub(crate) fn prepend_each_line( /// Colors with newlines are a problem since the contents stream to git which prepends `remote:` before the `libcnb_test` /// if we don't clear, then we will colorize output that isn't ours. /// -/// Explicitly uncolored output is handled by treating `\x1b[1;39m` (NOCOLOR) as a distinct case from `\x1b[0m`. +/// Explicitly uncolored output is handled by treating `\x1b[1;39m` (`NO_COLOR`) as a distinct case from `\x1b[0m`. pub(crate) fn colorize(color: &str, body: impl AsRef) -> String { body.as_ref() .split('\n') @@ -232,10 +232,10 @@ mod test { #[test] fn handles_explicitly_removed_colors() { - let nested = colorize(NOCOLOR, "nested"); + let nested = colorize(NO_COLOR, "nested"); let out = colorize(RED, format!("hello {nested} color")); - let expected = format!("{RED}hello {NOCOLOR}nested{RESET}{RED} color{RESET}"); + let expected = format!("{RED}hello {NO_COLOR}nested{RESET}{RED} color{RESET}"); assert_eq!(expected, out); } From a497bdb8446cd7c65e3e7d9b4e0b392e1155d4a2 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Jan 2024 16:00:13 +0100 Subject: [PATCH 19/99] Fix "miliseconds" typo --- libherokubuildpack/src/output/style.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libherokubuildpack/src/output/style.rs b/libherokubuildpack/src/output/style.rs index 3d7e4e9d..c2af2e1f 100644 --- a/libherokubuildpack/src/output/style.rs +++ b/libherokubuildpack/src/output/style.rs @@ -290,15 +290,15 @@ pub(crate) mod time { let hours = hours(duration); let minutes = minutes(duration); let seconds = seconds(duration); - let miliseconds = milliseconds(duration); + let milliseconds = milliseconds(duration); if hours > 0 { format!("{hours}h {minutes}m {seconds}s") } else if minutes > 0 { format!("{minutes}m {seconds}s") - } else if seconds > 0 || miliseconds > 100 { + } else if seconds > 0 || milliseconds > 100 { // 0.1 - format!("{seconds}.{miliseconds:0>3}s") + format!("{seconds}.{milliseconds:0>3}s") } else { String::from("< 0.1s") } From 537df683dbefa9b0f1f88a3e0dc9dd8e366df5d1 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Jan 2024 16:42:43 +0100 Subject: [PATCH 20/99] Remove output module, rename output feature --- libherokubuildpack/Cargo.toml | 4 ++-- .../src/{output => buildpack_output}/inline_output.rs | 10 +++++----- .../buildpack_output.rs => buildpack_output/mod.rs} | 11 +++++++---- .../src/{output => buildpack_output}/style.rs | 2 +- .../src/{output => buildpack_output}/util.rs | 0 libherokubuildpack/src/lib.rs | 5 +++-- libherokubuildpack/src/output/mod.rs | 4 ---- 7 files changed, 18 insertions(+), 18 deletions(-) rename libherokubuildpack/src/{output => buildpack_output}/inline_output.rs (89%) rename libherokubuildpack/src/{output/buildpack_output.rs => buildpack_output/mod.rs} (98%) rename libherokubuildpack/src/{output => buildpack_output}/style.rs (99%) rename libherokubuildpack/src/{output => buildpack_output}/util.rs (100%) delete mode 100644 libherokubuildpack/src/output/mod.rs diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index e8c3458c..6b1fd531 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", "output"] +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,7 +27,7 @@ tar = ["dep:tar", "dep:flate2"] toml = ["dep:toml"] fs = ["dep:pathdiff"] command = ["write", "dep:crossbeam-utils"] -output = ["dep:const_format"] +buildpack_output = ["dep:const_format"] write = [] [dependencies] diff --git a/libherokubuildpack/src/output/inline_output.rs b/libherokubuildpack/src/buildpack_output/inline_output.rs similarity index 89% rename from libherokubuildpack/src/output/inline_output.rs rename to libherokubuildpack/src/buildpack_output/inline_output.rs index 04e0f53c..0ab8cf15 100644 --- a/libherokubuildpack/src/output/inline_output.rs +++ b/libherokubuildpack/src/buildpack_output/inline_output.rs @@ -16,18 +16,18 @@ //! The main use case is writing output in a layer: //! //! ```no_run -//! use libherokubuildpack::output::inline_output; +//! use libherokubuildpack::buildpack_output::inline_output; //! //! inline_output::step("Clearing the cache") //! ``` -use crate::output::buildpack_output::{state, BuildpackOutput, Stream}; +use crate::buildpack_output::{state, BuildpackOutput, Stream}; use std::io::Stdout; use std::time::Instant; /// Output a message as a single step, ideally a short message. /// /// ``` -/// use libherokubuildpack::output::inline_output; +/// use libherokubuildpack::buildpack_output::inline_output; /// /// inline_output::step("Clearing cache (ruby version changed)"); /// ``` @@ -40,8 +40,8 @@ pub fn step(s: impl AsRef) { /// /// ```no_run /// use fun_run::CommandWithName; -/// use libherokubuildpack::output::inline_output; -/// use libherokubuildpack::output::style; +/// use libherokubuildpack::buildpack_output::inline_output; +/// use libherokubuildpack::buildpack_output::style; /// /// let mut cmd = std::process::Command::new("bundle"); /// cmd.arg("install"); diff --git a/libherokubuildpack/src/output/buildpack_output.rs b/libherokubuildpack/src/buildpack_output/mod.rs similarity index 98% rename from libherokubuildpack/src/output/buildpack_output.rs rename to libherokubuildpack/src/buildpack_output/mod.rs index d8b23038..16260a3d 100644 --- a/libherokubuildpack/src/output/buildpack_output.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -3,7 +3,7 @@ //! Use the [`BuildpackOutput`] to output structured text as a buildpack is executing. //! //! ``` -//! use libherokubuildpack::output::buildpack_output::BuildpackOutput; +//! use libherokubuildpack::buildpack_output::BuildpackOutput; //! //! let mut output = BuildpackOutput::new(std::io::stdout()) //! .start("Heroku Ruby Buildpack"); @@ -15,12 +15,15 @@ //! output.finish(); //! ``` //! -use crate::output::style; use std::fmt::Debug; use std::io::Write; use std::sync::{Arc, Mutex}; use std::time::Instant; +pub mod inline_output; +pub mod style; +mod util; + /// See the module docs for example usage. #[allow(clippy::module_name_repetitions)] #[derive(Debug)] @@ -346,9 +349,9 @@ fn writeln_now(destination: &mut D, msg: impl AsRef) { #[cfg(test)] mod test { use super::*; + use crate::buildpack_output::style::strip_control_codes; + use crate::buildpack_output::util::test_helpers::trim_end_lines; use crate::command::CommandExt; - use crate::output::style::strip_control_codes; - use crate::output::util::test_helpers::trim_end_lines; use indoc::formatdoc; use libcnb_test::assert_contains; use pretty_assertions::assert_eq; diff --git a/libherokubuildpack/src/output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs similarity index 99% rename from libherokubuildpack/src/output/style.rs rename to libherokubuildpack/src/buildpack_output/style.rs index c2af2e1f..87965985 100644 --- a/libherokubuildpack/src/output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -1,4 +1,4 @@ -use crate::output::util::LinesWithEndings; +use crate::buildpack_output::util::LinesWithEndings; use const_format::formatcp; use std::fmt::Write; diff --git a/libherokubuildpack/src/output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs similarity index 100% rename from libherokubuildpack/src/output/util.rs rename to libherokubuildpack/src/buildpack_output/util.rs diff --git a/libherokubuildpack/src/lib.rs b/libherokubuildpack/src/lib.rs index 20c78ec1..83a8b77e 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")] @@ -12,13 +14,12 @@ pub mod error; pub mod fs; #[cfg(feature = "log")] pub mod log; -#[cfg(feature = "output")] -pub mod output; #[cfg(feature = "tar")] pub mod tar; #[cfg(feature = "toml")] pub mod toml; #[cfg(feature = "write")] pub mod write; + #[cfg(test)] use fun_run as _; diff --git a/libherokubuildpack/src/output/mod.rs b/libherokubuildpack/src/output/mod.rs deleted file mode 100644 index 4ed0c4af..00000000 --- a/libherokubuildpack/src/output/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod buildpack_output; -pub mod inline_output; -pub mod style; -mod util; From c8793a7725adeded117fe30a0269f2c32fdca9d4 Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 29 Jan 2024 10:00:15 -0600 Subject: [PATCH 21/99] Add failing test and fix test spelling --- .../src/buildpack_output/mod.rs | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 16260a3d..f3c3895e 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -411,12 +411,40 @@ mod test { 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") + .end_section() + .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_control_codes(String::from_utf8_lossy(&io))); + } + #[test] fn warning_step_padding() { let writer = Vec::new(); let io = BuildpackOutput::new(writer) .start("RCT") - .section("Guest thoughs") + .section("Guest thoughts") .step("The scenery here is wonderful") .warning("It's too crowded here\nI'm tired") .step("The jumping fountains are great") @@ -428,7 +456,7 @@ mod test { # RCT - - Guest thoughs + - Guest thoughts - The scenery here is wonderful ! It's too crowded here From 8ba3248df31d65f9cbf497214239ac5305cfcb30 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Jan 2024 17:52:42 +0100 Subject: [PATCH 22/99] Add ParagraphInspectWrite --- .../src/buildpack_output/inline_output.rs | 4 +- .../src/buildpack_output/mod.rs | 11 ++-- .../src/buildpack_output/util.rs | 58 +++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/inline_output.rs b/libherokubuildpack/src/buildpack_output/inline_output.rs index 0ab8cf15..4272bfd1 100644 --- a/libherokubuildpack/src/buildpack_output/inline_output.rs +++ b/libherokubuildpack/src/buildpack_output/inline_output.rs @@ -20,7 +20,7 @@ //! //! inline_output::step("Clearing the cache") //! ``` -use crate::buildpack_output::{state, BuildpackOutput, Stream}; +use crate::buildpack_output::{state, BuildpackOutput, ParagraphInspectWrite, Stream}; use std::io::Stdout; use std::time::Instant; @@ -76,7 +76,7 @@ pub fn important(s: impl AsRef) { fn build_buildpack_output() -> BuildpackOutput { BuildpackOutput:: { - io: std::io::stdout(), + io: ParagraphInspectWrite::new(std::io::stdout()), // Be careful not to do anything that might access this state // as it's ephemeral data (i.e. not passed in from the start of the build) started: Some(Instant::now()), diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index f3c3895e..827b0fe9 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -15,6 +15,7 @@ //! output.finish(); //! ``` //! +use crate::buildpack_output::util::ParagraphInspectWrite; use std::fmt::Debug; use std::io::Write; use std::sync::{Arc, Mutex}; @@ -28,7 +29,7 @@ mod util; #[allow(clippy::module_name_repetitions)] #[derive(Debug)] pub struct BuildpackOutput { - pub(crate) io: W, + pub(crate) io: ParagraphInspectWrite, pub(crate) started: Option, pub(crate) state: T, } @@ -138,7 +139,7 @@ where { pub fn new(io: W) -> Self { Self { - io, + io: ParagraphInspectWrite::new(io), state: state::NotStarted, started: None, } @@ -186,7 +187,7 @@ where writeln_now(&mut self.io, style::section("Done")); } - self.io + self.io.inner } } @@ -265,9 +266,9 @@ where /// Mostly used for outputting a running command. #[derive(Debug)] #[doc(hidden)] -pub struct Stream { +pub struct Stream { buildpack_output_started: Option, - arc_io: Arc>, + arc_io: Arc>>, started: Instant, } diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index b7921266..33b3aa66 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -1,3 +1,6 @@ +use std::fmt::Debug; +use std::io::Write; + /// Iterator yielding every line in a string. The line includes newline character(s). /// /// @@ -35,6 +38,41 @@ impl<'a> Iterator for LinesWithEndings<'a> { } } +#[derive(Debug)] +pub(crate) struct ParagraphInspectWrite { + pub(crate) inner: W, + pub(crate) was_paragraph: bool, +} + +impl ParagraphInspectWrite +where + W: Debug, +{ + pub(crate) fn new(io: W) -> Self { + Self { + inner: io, + was_paragraph: false, + } + } +} + +impl Write for ParagraphInspectWrite { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // Only modify `was_paragraph` if we write anything + if !buf.is_empty() { + // TODO: This will not work with Windows line endings + self.was_paragraph = + buf.len() >= 2 && buf[buf.len() - 2] == b'\n' && buf[buf.len() - 1] == b'\n'; + } + + self.inner.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + #[cfg(test)] pub(crate) mod test_helpers { use super::*; @@ -84,4 +122,24 @@ mod test { assert_eq!("zfoo\nzbar\n", actual); } + + #[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!\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); + } } From ff79b3073eea224449786dc09080384f73de97a5 Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 29 Jan 2024 14:03:04 -0600 Subject: [PATCH 23/99] Replace state::Announce with ParagraphInspectWrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We identified that there were gaps in the stateful implementation of `state::Announce`. It also leaked to the user and was in general not nice. To fix this, we've decided to inspect the values being printed to our writer using ParagraphInspectWrite. When we write two newlines at the end of a write call then we know the last thing emitted was a "paragraph". We expose this information via `was_paragraph` associated function, and that allows us to simplify the code dramatically. ## Identified edge case in ParagraphInspectWrite The first stub of ParagraphInspectWrite worked if an output emits two newlines directly like "Hello\n\n" but does not catch two writes like "Hello\n", "\n", which happens frequently as I use writeln! to add empty newlines to visually differentiate between only single lines written. I added a test and fixed this issue by storing the number of newlines written since a non-newline character had been encountered. ## Original problem definition that this is solving When we emit a line or paragraph we cannot know what the next output will be. If two warnings are emitted in a row, or a warning and an error, etc. then we do not want to emit two empty newlines. It sounds like a nit, but it looks bad. We shouldn't do it. Here are cases of the correct behavior: - Warning after a step: - 🎉 Last output was a single empty newline, print a single newline - 🎉 Print the warning - 🎉 Print an empty newline - Warning after another warning: - 🔇 Last output was a double newline, therefore we do not need to output any additional newlines. - 🎉 Print the warning - 🎉 Print an empty newline - Warning after starting the buildpack: - 🔇 Header ends in an empty newline, don't print a leader newline - 🎉 Print the warning - 🎉 Print an empty newline --- .../src/buildpack_output/inline_output.rs | 2 +- .../src/buildpack_output/mod.rs | 98 +++++-------------- .../src/buildpack_output/util.rs | 22 ++++- 3 files changed, 43 insertions(+), 79 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/inline_output.rs b/libherokubuildpack/src/buildpack_output/inline_output.rs index 4272bfd1..5ae32164 100644 --- a/libherokubuildpack/src/buildpack_output/inline_output.rs +++ b/libherokubuildpack/src/buildpack_output/inline_output.rs @@ -80,6 +80,6 @@ fn build_buildpack_output() -> BuildpackOutput { // Be careful not to do anything that might access this state // as it's ephemeral data (i.e. not passed in from the start of the build) started: Some(Instant::now()), - state: state::Section, + _state: state::Section, } } diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 827b0fe9..8a0f216b 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -31,7 +31,7 @@ mod util; pub struct BuildpackOutput { pub(crate) io: ParagraphInspectWrite, pub(crate) started: Option, - pub(crate) state: T, + pub(crate) _state: T, } /// Various states for [`BuildpackOutput`] to contain. @@ -48,32 +48,23 @@ pub(crate) mod state { #[derive(Debug)] pub struct Section; - - #[derive(Debug)] - pub struct Announce(pub T); } #[doc(hidden)] -pub trait StartedMarker {} -impl StartedMarker for state::Started {} -impl StartedMarker for state::Announce where S: StartedMarker + IntoAnnounceMarker {} - -#[doc(hidden)] -pub trait SectionMarker {} -impl SectionMarker for state::Section {} -impl SectionMarker for state::Announce where S: SectionMarker + IntoAnnounceMarker {} - -#[doc(hidden)] -pub trait IntoAnnounceMarker {} -impl IntoAnnounceMarker for state::Section {} -impl IntoAnnounceMarker for state::Started {} +pub trait WarnInfoError {} +impl WarnInfoError for state::Section {} +impl WarnInfoError for state::Started {} -impl BuildpackOutput, W> +impl BuildpackOutput where + S: WarnInfoError, W: Write + Send + Sync + Debug + 'static, { #[must_use] - pub fn warning(mut self, s: &str) -> BuildpackOutput, W> { + pub fn warning(mut self, s: &str) -> BuildpackOutput { + if !self.io.was_paragraph { + writeln_now(&mut self.io, ""); + } writeln_now(&mut self.io, style::warning(s.trim())); writeln_now(&mut self.io, ""); @@ -81,7 +72,10 @@ where } #[must_use] - pub fn important(mut self, s: &str) -> BuildpackOutput, W> { + pub fn important(mut self, s: &str) -> BuildpackOutput { + if !self.io.was_paragraph { + writeln_now(&mut self.io, ""); + } writeln_now(&mut self.io, style::important(s.trim())); writeln_now(&mut self.io, ""); @@ -89,50 +83,14 @@ where } pub fn error(mut self, s: &str) { + if !self.io.was_paragraph { + writeln_now(&mut self.io, ""); + } writeln_now(&mut self.io, style::error(s.trim())); writeln_now(&mut self.io, ""); } } -impl BuildpackOutput -where - S: IntoAnnounceMarker, - W: Write + Send + Sync + Debug + 'static, -{ - #[must_use] - pub fn warning(mut self, s: &str) -> BuildpackOutput, W> { - writeln_now(&mut self.io, ""); - - let announce = BuildpackOutput { - io: self.io, - started: self.started, - state: state::Announce(self.state), - }; - announce.warning(s) - } - - #[must_use] - pub fn important(mut self, s: &str) -> BuildpackOutput, W> { - writeln_now(&mut self.io, ""); - - let announce = BuildpackOutput { - io: self.io, - started: self.started, - state: state::Announce(self.state), - }; - announce.important(s) - } - - pub fn error(self, s: &str) { - let announce = BuildpackOutput { - io: self.io, - started: self.started, - state: state::Announce(self.state), - }; - announce.error(s); - } -} - impl BuildpackOutput where W: Write + Debug, @@ -140,7 +98,7 @@ where pub fn new(io: W) -> Self { Self { io: ParagraphInspectWrite::new(io), - state: state::NotStarted, + _state: state::NotStarted, started: None, } } @@ -158,23 +116,23 @@ where BuildpackOutput { io: self.io, started: Some(Instant::now()), - state: state::Started, + _state: state::Started, } } } -impl BuildpackOutput +impl BuildpackOutput where - S: StartedMarker, W: Write + Send + Sync + Debug + 'static, { + #[must_use] pub fn section(mut self, s: &str) -> BuildpackOutput { writeln_now(&mut self.io, style::section(s)); BuildpackOutput { io: self.io, started: self.started, - state: state::Section, + _state: state::Section, } } @@ -198,13 +156,7 @@ where pub fn mut_step(&mut self, s: &str) { writeln_now(&mut self.io, style::step(s)); } -} -impl BuildpackOutput -where - S: SectionMarker, - W: Write + Send + Sync + Debug + 'static, -{ #[must_use] pub fn step(mut self, s: &str) -> BuildpackOutput { writeln_now(&mut self.io, style::step(s)); @@ -212,7 +164,7 @@ where BuildpackOutput { io: self.io, started: self.started, - state: state::Section, + _state: state::Section, } } @@ -234,7 +186,7 @@ where BuildpackOutput { io: self.io, started: self.started, - state: state::Started, + _state: state::Started, } } } @@ -319,7 +271,7 @@ where let mut section = BuildpackOutput { io, started: self.buildpack_output_started, - state: state::Section, + _state: state::Section, }; section.mut_step(&format!( diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 33b3aa66..cc123c1e 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -42,6 +42,7 @@ impl<'a> Iterator for LinesWithEndings<'a> { pub(crate) struct ParagraphInspectWrite { pub(crate) inner: W, pub(crate) was_paragraph: bool, + pub(crate) newlines_since_last_char: usize, } impl ParagraphInspectWrite @@ -51,6 +52,7 @@ where pub(crate) fn new(io: W) -> Self { Self { inner: io, + newlines_since_last_char: 0, was_paragraph: false, } } @@ -58,13 +60,14 @@ where impl Write for ParagraphInspectWrite { fn write(&mut self, buf: &[u8]) -> std::io::Result { - // Only modify `was_paragraph` if we write anything - if !buf.is_empty() { - // TODO: This will not work with Windows line endings - self.was_paragraph = - buf.len() >= 2 && buf[buf.len() - 2] == b'\n' && buf[buf.len() - 1] == b'\n'; + let newline_count = buf.iter().rev().take_while(|&&c| c == b'\n').count(); + if buf.len() == newline_count { + self.newlines_since_last_char += newline_count; + } else { + self.newlines_since_last_char = newline_count; } + self.was_paragraph = self.newlines_since_last_char > 1; self.inner.write(buf) } @@ -141,5 +144,14 @@ mod test { 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); } } From d63cb525a69f6d438ea5ade3c62ba9dc7cca45a2 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 30 Jan 2024 09:57:59 +0100 Subject: [PATCH 24/99] Fix typo --- libherokubuildpack/src/buildpack_output/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 8a0f216b..b80da0c3 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -263,7 +263,7 @@ where let mut io = Arc::try_unwrap(self.arc_io) .expect("Expected buildpack author to not retain any IO streaming IO instances") .into_inner() - .expect("Output mutex was poisioned"); + .expect("Output mutex was poisoned"); // Newline after stream writeln_now(&mut io, ""); From 53db8f8e9da3643de4dd0958b69870c226d6eaec Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 30 Jan 2024 10:00:54 +0100 Subject: [PATCH 25/99] Remove infectious Debug trait bounds --- .../src/buildpack_output/mod.rs | 25 ++++++++++--------- .../src/buildpack_output/util.rs | 9 +++---- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index b80da0c3..386f2874 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -28,7 +28,7 @@ mod util; /// See the module docs for example usage. #[allow(clippy::module_name_repetitions)] #[derive(Debug)] -pub struct BuildpackOutput { +pub struct BuildpackOutput { pub(crate) io: ParagraphInspectWrite, pub(crate) started: Option, pub(crate) _state: T, @@ -58,7 +58,7 @@ impl WarnInfoError for state::Started {} impl BuildpackOutput where S: WarnInfoError, - W: Write + Send + Sync + Debug + 'static, + W: Write + Send + Sync + 'static, { #[must_use] pub fn warning(mut self, s: &str) -> BuildpackOutput { @@ -93,7 +93,7 @@ where impl BuildpackOutput where - W: Write + Debug, + W: Write, { pub fn new(io: W) -> Self { Self { @@ -123,7 +123,7 @@ where impl BuildpackOutput where - W: Write + Send + Sync + Debug + 'static, + W: Write + Send + Sync + 'static, { #[must_use] pub fn section(mut self, s: &str) -> BuildpackOutput { @@ -151,7 +151,7 @@ where impl BuildpackOutput where - W: Write + Send + Sync + Debug + 'static, + W: Write + Send + Sync + 'static, { pub fn mut_step(&mut self, s: &str) { writeln_now(&mut self.io, style::step(s)); @@ -200,7 +200,7 @@ struct LockedWriter { impl Write for LockedWriter where - W: Write + Send + Sync + Debug + 'static, + W: Write + Send + Sync + 'static, { fn write(&mut self, buf: &[u8]) -> std::io::Result { let mut io = self.arc.lock().expect("Output mutex poisoned"); @@ -218,7 +218,7 @@ where /// Mostly used for outputting a running command. #[derive(Debug)] #[doc(hidden)] -pub struct Stream { +pub struct Stream { buildpack_output_started: Option, arc_io: Arc>>, started: Instant, @@ -226,7 +226,7 @@ pub struct Stream { impl Stream where - W: Write + Send + Sync + Debug + 'static, + W: Write + Send + Sync + 'static, { fn start(&mut self) { let mut guard = self.arc_io.lock().expect("Output mutex poisoned"); @@ -260,10 +260,11 @@ where pub fn finish_timed_stream(self) -> BuildpackOutput { let duration = self.started.elapsed(); - let mut io = Arc::try_unwrap(self.arc_io) - .expect("Expected buildpack author to not retain any IO streaming IO instances") - .into_inner() - .expect("Output mutex was poisoned"); + let Ok(mutex) = Arc::try_unwrap(self.arc_io) else { + panic!("Expected buildpack author to not retain any IO streaming IO instances") + }; + + let mut io = mutex.into_inner().expect("Output mutex was poisoned"); // Newline after stream writeln_now(&mut io, ""); diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index cc123c1e..dd13a224 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -39,16 +39,13 @@ impl<'a> Iterator for LinesWithEndings<'a> { } #[derive(Debug)] -pub(crate) struct ParagraphInspectWrite { +pub(crate) struct ParagraphInspectWrite { pub(crate) inner: W, pub(crate) was_paragraph: bool, pub(crate) newlines_since_last_char: usize, } -impl ParagraphInspectWrite -where - W: Debug, -{ +impl ParagraphInspectWrite { pub(crate) fn new(io: W) -> Self { Self { inner: io, @@ -58,7 +55,7 @@ where } } -impl Write for ParagraphInspectWrite { +impl Write for ParagraphInspectWrite { fn write(&mut self, buf: &[u8]) -> std::io::Result { let newline_count = buf.iter().rev().take_while(|&&c| c == b'\n').count(); if buf.len() == newline_count { From fc36f23923d4714615d789f02959c900764ca100 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 30 Jan 2024 11:42:23 +0100 Subject: [PATCH 26/99] Replace `Stream` with `BuildpackOutput` state --- .../src/buildpack_output/inline_output.rs | 30 +- .../src/buildpack_output/mod.rs | 283 +++++++++--------- .../src/buildpack_output/util.rs | 45 +++ 3 files changed, 199 insertions(+), 159 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/inline_output.rs b/libherokubuildpack/src/buildpack_output/inline_output.rs index 5ae32164..f2851734 100644 --- a/libherokubuildpack/src/buildpack_output/inline_output.rs +++ b/libherokubuildpack/src/buildpack_output/inline_output.rs @@ -20,7 +20,8 @@ //! //! inline_output::step("Clearing the cache") //! ``` -use crate::buildpack_output::{state, BuildpackOutput, ParagraphInspectWrite, Stream}; +use crate::buildpack_output::state::TimedStream; +use crate::buildpack_output::{state, BuildpackOutput, ParagraphInspectWrite}; use std::io::Stdout; use std::time::Instant; @@ -38,21 +39,11 @@ pub fn step(s: impl AsRef) { /// Will print the input string and yield a [`Stream`] that can be used to print /// to the output. The main use case is running commands. /// -/// ```no_run -/// use fun_run::CommandWithName; -/// use libherokubuildpack::buildpack_output::inline_output; -/// use libherokubuildpack::buildpack_output::style; -/// -/// let mut cmd = std::process::Command::new("bundle"); -/// cmd.arg("install"); -/// -/// inline_output::step_stream(format!("Running {}", style::command(cmd.name())), |stream| { -/// cmd.stream_output(stream.io(), stream.io()).unwrap() -/// }); -/// ``` -/// /// Timing information will be output at the end of the step. -pub fn step_stream(s: impl AsRef, f: impl FnOnce(&mut Stream) -> T) -> T { +pub fn step_stream( + s: impl AsRef, + f: impl FnOnce(&mut BuildpackOutput>) -> T, +) -> T { let mut stream = build_buildpack_output().step_timed_stream(s.as_ref()); let out = f(&mut stream); let _ = stream.finish_timed_stream(); @@ -74,12 +65,13 @@ pub fn important(s: impl AsRef) { let _ = build_buildpack_output().important(s.as_ref()); } -fn build_buildpack_output() -> BuildpackOutput { - BuildpackOutput:: { - io: ParagraphInspectWrite::new(std::io::stdout()), +fn build_buildpack_output() -> BuildpackOutput> { + BuildpackOutput::> { // Be careful not to do anything that might access this state // as it's ephemeral data (i.e. not passed in from the start of the build) started: Some(Instant::now()), - _state: state::Section, + state: state::Section { + write: ParagraphInspectWrite::new(std::io::stdout()), + }, } } diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 386f2874..627dea8c 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -15,10 +15,11 @@ //! output.finish(); //! ``` //! +use crate::buildpack_output::style::cmd_stream_format; use crate::buildpack_output::util::ParagraphInspectWrite; +use crate::write::line_mapped; use std::fmt::Debug; use std::io::Write; -use std::sync::{Arc, Mutex}; use std::time::Instant; pub mod inline_output; @@ -28,10 +29,9 @@ mod util; /// See the module docs for example usage. #[allow(clippy::module_name_repetitions)] #[derive(Debug)] -pub struct BuildpackOutput { - pub(crate) io: ParagraphInspectWrite, +pub struct BuildpackOutput { pub(crate) started: Option, - pub(crate) _state: T, + pub(crate) state: T, } /// Various states for [`BuildpackOutput`] to contain. @@ -40,99 +40,146 @@ pub struct BuildpackOutput { /// are meant to represent those states. #[doc(hidden)] pub(crate) mod state { + use crate::buildpack_output::util::ParagraphInspectWrite; + use crate::write::MappedWrite; + use std::time::Instant; + #[derive(Debug)] - pub struct NotStarted; + pub struct NotStarted { + pub(crate) write: ParagraphInspectWrite, + } #[derive(Debug)] - pub struct Started; + pub struct Started { + pub(crate) write: ParagraphInspectWrite, + } #[derive(Debug)] - pub struct Section; + pub struct Section { + pub(crate) write: ParagraphInspectWrite, + } + + pub struct TimedStream { + pub(crate) started: Instant, + pub(crate) write: MappedWrite>, + } } #[doc(hidden)] -pub trait WarnInfoError {} -impl WarnInfoError for state::Section {} -impl WarnInfoError for state::Started {} +pub trait AnnounceSupportedState { + type Inner: Write; + + fn write_mut(&mut self) -> &mut ParagraphInspectWrite; +} -impl BuildpackOutput +impl AnnounceSupportedState for state::Section where - S: WarnInfoError, - W: Write + Send + Sync + 'static, + 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 + } +} + +impl BuildpackOutput +where + S: AnnounceSupportedState, { #[must_use] - pub fn warning(mut self, s: &str) -> BuildpackOutput { - if !self.io.was_paragraph { - writeln_now(&mut self.io, ""); + pub fn warning(mut self, s: &str) -> BuildpackOutput { + let io = self.state.write_mut(); + + if !io.was_paragraph { + writeln_now(io, ""); } - writeln_now(&mut self.io, style::warning(s.trim())); - writeln_now(&mut self.io, ""); + writeln_now(io, style::warning(s.trim())); + writeln_now(io, ""); self } #[must_use] - pub fn important(mut self, s: &str) -> BuildpackOutput { - if !self.io.was_paragraph { - writeln_now(&mut self.io, ""); + pub fn important(mut self, s: &str) -> BuildpackOutput { + let io = self.state.write_mut(); + + if !io.was_paragraph { + writeln_now(io, ""); } - writeln_now(&mut self.io, style::important(s.trim())); - writeln_now(&mut self.io, ""); + writeln_now(io, style::important(s.trim())); + writeln_now(io, ""); self } pub fn error(mut self, s: &str) { - if !self.io.was_paragraph { - writeln_now(&mut self.io, ""); + let io = self.state.write_mut(); + + if !io.was_paragraph { + writeln_now(io, ""); } - writeln_now(&mut self.io, style::error(s.trim())); - writeln_now(&mut self.io, ""); + writeln_now(io, style::error(s.trim())); + writeln_now(io, ""); } } -impl BuildpackOutput +impl BuildpackOutput> where W: Write, { pub fn new(io: W) -> Self { Self { - io: ParagraphInspectWrite::new(io), - _state: state::NotStarted, + state: state::NotStarted { + write: ParagraphInspectWrite::new(io), + }, started: None, } } - pub fn start(mut self, buildpack_name: &str) -> BuildpackOutput { + pub fn start(mut self, buildpack_name: &str) -> BuildpackOutput> { write_now( - &mut self.io, + &mut self.state.write, format!("{}\n\n", style::header(buildpack_name)), ); self.start_silent() } - pub fn start_silent(self) -> BuildpackOutput { + pub fn start_silent(self) -> BuildpackOutput> { BuildpackOutput { - io: self.io, started: Some(Instant::now()), - _state: state::Started, + state: state::Started { + write: self.state.write, + }, } } } -impl BuildpackOutput +impl BuildpackOutput> where W: Write + Send + Sync + 'static, { #[must_use] - pub fn section(mut self, s: &str) -> BuildpackOutput { - writeln_now(&mut self.io, style::section(s)); + pub fn section(mut self, s: &str) -> BuildpackOutput> { + writeln_now(&mut self.state.write, style::section(s)); BuildpackOutput { - io: self.io, started: self.started, - _state: state::Section, + state: state::Section { + write: self.state.write, + }, } } @@ -140,139 +187,77 @@ where if let Some(started) = &self.started { let elapsed = style::time::human(&started.elapsed()); let details = style::details(format!("finished in {elapsed}")); - writeln_now(&mut self.io, style::section(format!("Done {details}"))); + writeln_now( + &mut self.state.write, + style::section(format!("Done {details}")), + ); } else { - writeln_now(&mut self.io, style::section("Done")); + writeln_now(&mut self.state.write, style::section("Done")); } - self.io.inner + self.state.write.inner } } -impl BuildpackOutput +impl BuildpackOutput> where W: Write + Send + Sync + 'static, { pub fn mut_step(&mut self, s: &str) { - writeln_now(&mut self.io, style::step(s)); + writeln_now(&mut self.state.write, style::step(s)); } #[must_use] - pub fn step(mut self, s: &str) -> BuildpackOutput { - writeln_now(&mut self.io, style::step(s)); + pub fn step(mut self, s: &str) -> BuildpackOutput> { + writeln_now(&mut self.state.write, style::step(s)); BuildpackOutput { - io: self.io, started: self.started, - _state: state::Section, + state: state::Section { + write: self.state.write, + }, } } - pub fn step_timed_stream(mut self, s: &str) -> Stream { - writeln_now(&mut self.io, style::step(s)); + pub fn step_timed_stream(mut self, s: &str) -> BuildpackOutput> { + writeln_now(&mut self.state.write, style::step(s)); - let arc_io = Arc::new(Mutex::new(self.io)); - let mut stream = Stream { - arc_io, - started: Instant::now(), - buildpack_output_started: self.started, - }; - stream.start(); - - stream - } + // Newline before stream https://github.com/heroku/libcnb.rs/issues/582 + writeln_now(&mut self.state.write, ""); - pub fn end_section(self) -> BuildpackOutput { BuildpackOutput { - io: self.io, started: self.started, - _state: state::Started, + state: state::TimedStream { + started: Instant::now(), + write: line_mapped(self.state.write, cmd_stream_format), + }, } } -} - -// TODO: Decide if we need documentation for this -#[derive(Debug)] -#[doc(hidden)] -struct LockedWriter { - arc: Arc>, -} - -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("Output mutex poisoned"); - io.write(buf) - } - fn flush(&mut self) -> std::io::Result<()> { - let mut io = self.arc.lock().expect("Output mutex poisoned"); - io.flush() + pub fn end_section(self) -> BuildpackOutput> { + BuildpackOutput { + started: self.started, + state: state::Started { + write: self.state.write, + }, + } } } -/// Stream output to the user. -/// -/// Mostly used for outputting a running command. -#[derive(Debug)] -#[doc(hidden)] -pub struct Stream { - buildpack_output_started: Option, - arc_io: Arc>>, - started: Instant, -} - -impl Stream +impl BuildpackOutput> where W: Write + Send + Sync + 'static, { - fn start(&mut self) { - let mut guard = self.arc_io.lock().expect("Output mutex poisoned"); - let mut io = guard.by_ref(); - // Newline before stream https://github.com/heroku/libcnb.rs/issues/582 - writeln_now(&mut io, ""); - } - - /// Yield boxed writer that can be used for formatting and streaming contents - /// back to the output. - pub fn io(&mut self) -> Box { - Box::new(crate::write::line_mapped( - LockedWriter { - arc: self.arc_io.clone(), - }, - style::cmd_stream_format, - )) - } - - /// # Panics - /// - /// Ensure that the return of any calls to the `io` function - /// are not retained before calling this function. - /// - /// This struct yields a `Box` which is effectively an - /// `Arc` to allow using the same writer for streaming stdout and stderr. - /// - /// If any of those boxed writers are retained then the `W` cannot - /// be reclaimed and returned. This will cause a panic. - #[must_use] - pub fn finish_timed_stream(self) -> BuildpackOutput { - let duration = self.started.elapsed(); - - let Ok(mutex) = Arc::try_unwrap(self.arc_io) else { - panic!("Expected buildpack author to not retain any IO streaming IO instances") - }; + pub fn finish_timed_stream(mut self) -> BuildpackOutput> { + let duration = self.state.started.elapsed(); - let mut io = mutex.into_inner().expect("Output mutex was poisoned"); - - // Newline after stream - writeln_now(&mut io, ""); + writeln_now(&mut self.state.write, ""); let mut section = BuildpackOutput { - io, - started: self.buildpack_output_started, - _state: state::Section, + started: self.started, + state: state::Section { + write: self.state.write.unwrap(), + }, }; section.mut_step(&format!( @@ -284,6 +269,19 @@ where } } +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). /// /// This is especially important for writing individual characters to the same line. @@ -305,6 +303,7 @@ mod test { use super::*; use crate::buildpack_output::style::strip_control_codes; use crate::buildpack_output::util::test_helpers::trim_end_lines; + use crate::buildpack_output::util::LockedWriter; use crate::command::CommandExt; use indoc::formatdoc; use libcnb_test::assert_contains; @@ -321,7 +320,7 @@ mod test { .step_timed_stream("Streaming stuff"); let value = "stuff".to_string(); - writeln!(stream.io(), "{value}").unwrap(); + writeln!(&mut stream, "{value}").unwrap(); let io = stream.finish_timed_stream().end_section().finish(); @@ -353,11 +352,15 @@ mod test { .section("Command streaming") .step_timed_stream("Streaming stuff"); + let locked_writer = LockedWriter::new(stream); + std::process::Command::new("echo") .arg("hello world") - .output_and_write_streams(stream.io(), stream.io()) + .output_and_write_streams(locked_writer.clone(), locked_writer.clone()) .unwrap(); + stream = locked_writer.unwrap(); + let io = stream.finish_timed_stream().end_section().finish(); let actual = trim_end_lines(strip_control_codes(String::from_utf8_lossy(&io))); diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index dd13a224..6bfdebd1 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -1,5 +1,6 @@ use std::fmt::Debug; use std::io::Write; +use std::sync::{Arc, Mutex}; /// Iterator yielding every line in a string. The line includes newline character(s). /// @@ -38,6 +39,50 @@ impl<'a> Iterator for LinesWithEndings<'a> { } } +#[derive(Debug)] +pub(crate) struct LockedWriter { + arc: Arc>, +} + +impl Clone for LockedWriter { + fn clone(&self) -> Self { + Self { + arc: self.arc.clone(), + } + } +} + +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("Output mutex was poisoned") + } +} + +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("Output mutex poisoned"); + io.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + let mut io = self.arc.lock().expect("Output mutex poisoned"); + io.flush() + } +} + #[derive(Debug)] pub(crate) struct ParagraphInspectWrite { pub(crate) inner: W, From 174fc02af40d1819837a9afe595955a21605364d Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 30 Jan 2024 17:09:38 +0100 Subject: [PATCH 27/99] Remove inline_output module --- .../src/buildpack_output/inline_output.rs | 77 ------------------- .../src/buildpack_output/mod.rs | 1 - 2 files changed, 78 deletions(-) delete mode 100644 libherokubuildpack/src/buildpack_output/inline_output.rs diff --git a/libherokubuildpack/src/buildpack_output/inline_output.rs b/libherokubuildpack/src/buildpack_output/inline_output.rs deleted file mode 100644 index f2851734..00000000 --- a/libherokubuildpack/src/buildpack_output/inline_output.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Write to the build output in a [`BuildpackOutput`] format with functions. -//! -//! ## What -//! -//! Outputting from within a layer can be difficult because calls to the layer interface are not -//! mutable nor consumable. Functions can be used at any time with no restrictions. The -//! only downside is that the buildpack author (you) is now responsible for: -//! -//! - Ensuring that [`BuildpackOutput::section`] function was called right before any of these -//! functions are called. -//! - Ensuring that you are not attempting to output while already streaming i.e. calling [`step`] within -//! a [`step_stream`] call. -//! -//! ## Use -//! -//! The main use case is writing output in a layer: -//! -//! ```no_run -//! use libherokubuildpack::buildpack_output::inline_output; -//! -//! inline_output::step("Clearing the cache") -//! ``` -use crate::buildpack_output::state::TimedStream; -use crate::buildpack_output::{state, BuildpackOutput, ParagraphInspectWrite}; -use std::io::Stdout; -use std::time::Instant; - -/// Output a message as a single step, ideally a short message. -/// -/// ``` -/// use libherokubuildpack::buildpack_output::inline_output; -/// -/// inline_output::step("Clearing cache (ruby version changed)"); -/// ``` -pub fn step(s: impl AsRef) { - let _ = build_buildpack_output().step(s.as_ref()); -} - -/// Will print the input string and yield a [`Stream`] that can be used to print -/// to the output. The main use case is running commands. -/// -/// Timing information will be output at the end of the step. -pub fn step_stream( - s: impl AsRef, - f: impl FnOnce(&mut BuildpackOutput>) -> T, -) -> T { - let mut stream = build_buildpack_output().step_timed_stream(s.as_ref()); - let out = f(&mut stream); - let _ = stream.finish_timed_stream(); - out -} - -/// Print an error block to the output. -pub fn error(s: impl AsRef) { - build_buildpack_output().error(s.as_ref()); -} - -/// Print an warning block to the output. -pub fn warning(s: impl AsRef) { - let _ = build_buildpack_output().warning(s.as_ref()); -} - -/// Print an important block to the output. -pub fn important(s: impl AsRef) { - let _ = build_buildpack_output().important(s.as_ref()); -} - -fn build_buildpack_output() -> BuildpackOutput> { - BuildpackOutput::> { - // Be careful not to do anything that might access this state - // as it's ephemeral data (i.e. not passed in from the start of the build) - started: Some(Instant::now()), - state: state::Section { - write: ParagraphInspectWrite::new(std::io::stdout()), - }, - } -} diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 627dea8c..1c61769d 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -22,7 +22,6 @@ use std::fmt::Debug; use std::io::Write; use std::time::Instant; -pub mod inline_output; pub mod style; mod util; From 81f63d090d827ab3ce4da9b44189a80be19533aa Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 30 Jan 2024 17:21:30 +0100 Subject: [PATCH 28/99] Remove trim_end_lines --- .../src/buildpack_output/mod.rs | 8 ++---- .../src/buildpack_output/style.rs | 11 +++++--- .../src/buildpack_output/util.rs | 27 ------------------- 3 files changed, 10 insertions(+), 36 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 1c61769d..66f7bd65 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -301,7 +301,6 @@ fn writeln_now(destination: &mut D, msg: impl AsRef) { mod test { use super::*; use crate::buildpack_output::style::strip_control_codes; - use crate::buildpack_output::util::test_helpers::trim_end_lines; use crate::buildpack_output::util::LockedWriter; use crate::command::CommandExt; use indoc::formatdoc; @@ -337,10 +336,7 @@ mod test { - Done (finished in < 0.1s) "}; - assert_eq!( - expected, - trim_end_lines(strip_control_codes(String::from_utf8_lossy(&io))) - ); + assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); } #[test] @@ -362,7 +358,7 @@ mod test { let io = stream.finish_timed_stream().end_section().finish(); - let actual = trim_end_lines(strip_control_codes(String::from_utf8_lossy(&io))); + let actual = strip_control_codes(String::from_utf8_lossy(&io)); assert_contains!(actual, " hello world\n"); } diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 87965985..8534caea 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -75,9 +75,14 @@ const CMD_INDENT: &str = " "; /// Used with libherokubuildpack line-mapped command output. #[must_use] pub(crate) fn cmd_stream_format(mut input: Vec) -> Vec { - let mut result: Vec = CMD_INDENT.into(); - result.append(&mut input); - result + let s = String::from_utf8_lossy(&input); + if !s.trim().is_empty() { + let mut result: Vec = CMD_INDENT.into(); + result.append(&mut input); + result + } else { + input + } } #[must_use] diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 6bfdebd1..6df39344 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -118,38 +118,11 @@ impl Write for ParagraphInspectWrite { } } -#[cfg(test)] -pub(crate) mod test_helpers { - use super::*; - use std::fmt::Write; - - /// Removes trailing whitespace from lines - /// - /// Useful because most editors strip trailing whitespace (in test fixtures) - /// but commands emit newlines - /// with leading spaces. These can be sanitized by removing trailing whitespace. - pub(crate) fn trim_end_lines(s: impl AsRef) -> String { - LinesWithEndings::from(s.as_ref()).fold(String::new(), |mut output, line| { - let _ = writeln!(output, "{}", line.trim_end()); - output - }) - } -} - #[cfg(test)] mod test { use super::*; use std::fmt::Write; - #[test] - fn test_trim_end_lines() { - let actual = test_helpers::trim_end_lines("hello \n"); - assert_eq!("hello\n", &actual); - - let actual = test_helpers::trim_end_lines("hello\n \nworld\n"); - assert_eq!("hello\n\nworld\n", &actual); - } - #[test] fn test_lines_with_endings() { let actual = LinesWithEndings::from("foo\nbar").fold(String::new(), |mut output, line| { From f23b8880af5901b98636a65023d3f8832274cc48 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 30 Jan 2024 18:35:26 +0100 Subject: [PATCH 29/99] Refactor prefix_indent --- .../src/buildpack_output/style.rs | 78 +++++++++---------- .../src/buildpack_output/util.rs | 38 ++++----- 2 files changed, 49 insertions(+), 67 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 8534caea..698e4d42 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -1,4 +1,4 @@ -use crate::buildpack_output::util::LinesWithEndings; +use crate::buildpack_output::util::LineIterator; use const_format::formatcp; use std::fmt::Write; @@ -68,8 +68,12 @@ pub(crate) const ERROR_COLOR: &str = RED; #[allow(dead_code)] pub(crate) const WARNING_COLOR: &str = YELLOW; -const SECTION_PREFIX: &str = "- "; -const STEP_PREFIX: &str = " - "; +const SECTION_PREFIX_FIRST: &str = "- "; +const SECTION_PREFIX_REST: &str = " "; + +const STEP_PREFIX_FIRST: &str = " - "; +const STEP_PREFIX_REST: &str = " "; + const CMD_INDENT: &str = " "; /// Used with libherokubuildpack line-mapped command output. @@ -87,12 +91,12 @@ pub(crate) fn cmd_stream_format(mut input: Vec) -> Vec { #[must_use] pub(crate) fn section(topic: impl AsRef) -> String { - prefix_indent(SECTION_PREFIX, topic) + prefix_lines(SECTION_PREFIX_FIRST, SECTION_PREFIX_REST, topic.as_ref()) } #[must_use] pub(crate) fn step(contents: impl AsRef) -> String { - prefix_indent(STEP_PREFIX, contents) + prefix_lines(STEP_PREFIX_FIRST, STEP_PREFIX_REST, contents.as_ref()) } /// Used to decorate a buildpack. @@ -102,40 +106,25 @@ pub(crate) fn header(contents: impl AsRef) -> String { colorize(HEROKU_COLOR, format!("\n# {contents}")) } -pub(crate) fn replace_chars_preserve_whitespace(input: &str, replacement: &str) -> String { - input - .chars() - .map(|c| { - if c.is_whitespace() { - c.to_string() +pub(crate) fn prefix_lines(first_prefix: &str, rest_prefix: &str, contents: &str) -> String { + let lines = LineIterator::from(contents).enumerate().fold( + String::new(), + |mut acc, (line_index, line)| { + let prefix = if line_index == 0 { + first_prefix } else { - replacement.to_string() - } - }) - .collect() -} + rest_prefix + }; -// Prefix is expected to be a single line -// -// If contents is multi line then indent additional lines to align with the end of the prefix. -pub(crate) fn prefix_indent(prefix: impl AsRef, contents: impl AsRef) -> String { - let prefix = prefix.as_ref(); - let contents = contents.as_ref(); - let clean_prefix = strip_control_codes(prefix); - - let indent_str = replace_chars_preserve_whitespace(&clean_prefix, " "); - let lines = LinesWithEndings::from(contents).collect::>(); - - if let Some((first, rest)) = lines.split_first() { - format!( - "{prefix}{first}{}", - rest.iter().fold(String::new(), |mut output, line| { - let _ = write!(output, "{indent_str}{line}"); - output - }) - ) + let _ = write!(acc, "{prefix}{line}"); + acc + }, + ); + + if lines.is_empty() { + String::from(first_prefix) } else { - prefix.to_string() + lines } } @@ -169,7 +158,7 @@ pub(crate) fn prepend_each_line( let prepend = prepend.as_ref(); let separator = separator.as_ref(); - let lines = LinesWithEndings::from(body) + let lines = LineIterator::from(body) .map(|line| { if line.trim().is_empty() { format!("{prepend}{line}") @@ -216,14 +205,17 @@ mod test { #[test] fn test_prefix_indent() { - assert_eq!("- hello", &prefix_indent("- ", "hello")); - assert_eq!("- hello\n world", &prefix_indent("- ", "hello\nworld")); - assert_eq!("- hello\n world\n", &prefix_indent("- ", "hello\nworld\n")); - let actual = prefix_indent(format!("- {RED}help:{RESET} "), "hello\nworld\n"); + assert_eq!("- hello", &prefix_lines("- ", " ", "hello")); assert_eq!( - &format!("- {RED}help:{RESET} hello\n world\n"), - &actual + "- hello\n world", + &prefix_lines("- ", " ", "hello\nworld") ); + assert_eq!( + "- hello\n world\n", + &prefix_lines("- ", " ", "hello\nworld\n") + ); + + assert_eq!("- ", &prefix_lines("- ", " ", "")); } #[test] diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 6df39344..12cbb0a0 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -2,28 +2,18 @@ use std::fmt::Debug; use std::io::Write; use std::sync::{Arc, Mutex}; -/// Iterator yielding every line in a string. The line includes newline character(s). -/// -/// -/// -/// The problem this solves is when iterating over lines of a string, the whitespace may be significant. -/// For example if you want to split a string and then get the original string back then calling -/// `lines().collect>().join("\n")` will never preserve trailing newlines. -/// -/// There's another option to `lines().fold(String::new(), |s, l| s + l + "\n")`, but that -/// always adds a trailing newline even if the original string doesn't have one. -/// -pub(crate) struct LinesWithEndings<'a> { +/// Iterator yielding every line in a string. Every line includes existing newline character(s). +pub(crate) struct LineIterator<'a> { input: &'a str, } -impl<'a> LinesWithEndings<'a> { - pub(crate) fn from(input: &'a str) -> LinesWithEndings<'a> { - LinesWithEndings { input } +impl<'a> LineIterator<'a> { + pub(crate) fn from(input: &'a str) -> LineIterator<'a> { + LineIterator { input } } } -impl<'a> Iterator for LinesWithEndings<'a> { +impl<'a> Iterator for LineIterator<'a> { type Item = &'a str; #[inline] @@ -31,9 +21,10 @@ impl<'a> Iterator for LinesWithEndings<'a> { if self.input.is_empty() { return None; } - let split = self.input.find('\n').map_or(self.input.len(), |i| i + 1); - let (line, rest) = self.input.split_at(split); + let newline_index = self.input.find('\n').map_or(self.input.len(), |i| i + 1); + + let (line, rest) = self.input.split_at(newline_index); self.input = rest; Some(line) } @@ -125,18 +116,17 @@ mod test { #[test] fn test_lines_with_endings() { - let actual = LinesWithEndings::from("foo\nbar").fold(String::new(), |mut output, line| { + let actual = LineIterator::from("foo\nbar").fold(String::new(), |mut output, line| { let _ = write!(output, "z{line}"); output }); assert_eq!("zfoo\nzbar", actual); - let actual = - LinesWithEndings::from("foo\nbar\n").fold(String::new(), |mut output, line| { - let _ = write!(output, "z{line}"); - output - }); + let actual = LineIterator::from("foo\nbar\n").fold(String::new(), |mut output, line| { + let _ = write!(output, "z{line}"); + output + }); assert_eq!("zfoo\nzbar\n", actual); } From 870ca33d868b42fbc64fc390e1e3c1453c84f3e2 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 30 Jan 2024 19:05:43 +0100 Subject: [PATCH 30/99] Refactor style --- .../src/buildpack_output/mod.rs | 26 +++--- .../src/buildpack_output/style.rs | 88 +++++++------------ 2 files changed, 40 insertions(+), 74 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 66f7bd65..e8a51290 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -15,7 +15,9 @@ //! output.finish(); //! ``` //! -use crate::buildpack_output::style::cmd_stream_format; +use crate::buildpack_output::style::{ + bangify, cmd_stream_format, colorize, ERROR_COLOR, HEROKU_COLOR, IMPORTANT_COLOR, WARNING_COLOR, +}; use crate::buildpack_output::util::ParagraphInspectWrite; use crate::write::line_mapped; use std::fmt::Debug; @@ -104,8 +106,8 @@ where if !io.was_paragraph { writeln_now(io, ""); } - writeln_now(io, style::warning(s.trim())); - writeln_now(io, ""); + writeln_now(io, colorize(WARNING_COLOR, bangify(s.trim()))); + writeln_now(io, "\n"); self } @@ -117,7 +119,7 @@ where if !io.was_paragraph { writeln_now(io, ""); } - writeln_now(io, style::important(s.trim())); + writeln_now(io, colorize(IMPORTANT_COLOR, bangify(s.trim()))); writeln_now(io, ""); self @@ -129,7 +131,8 @@ where if !io.was_paragraph { writeln_now(io, ""); } - writeln_now(io, style::error(s.trim())); + + writeln_now(io, colorize(ERROR_COLOR, bangify(s.trim()))); writeln_now(io, ""); } } @@ -148,9 +151,9 @@ where } pub fn start(mut self, buildpack_name: &str) -> BuildpackOutput> { - write_now( + writeln_now( &mut self.state.write, - format!("{}\n\n", style::header(buildpack_name)), + colorize(HEROKU_COLOR, format!("\n# {buildpack_name}\n")), ); self.start_silent() @@ -281,15 +284,6 @@ where } } -/// Internal helper, ensures that all contents are always flushed (never buffered). -/// -/// This is especially important for writing individual characters to the same line. -fn write_now(destination: &mut D, msg: impl AsRef) { - write!(destination, "{}", msg.as_ref()).expect("Output error: UI writer closed"); - - destination.flush().expect("Output error: UI writer closed"); -} - /// 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"); diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 698e4d42..c45e7a79 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -64,8 +64,6 @@ pub(crate) const COMMAND_COLOR: &str = BOLD_CYAN; pub(crate) const URL_COLOR: &str = CYAN; pub(crate) const IMPORTANT_COLOR: &str = CYAN; pub(crate) const ERROR_COLOR: &str = RED; - -#[allow(dead_code)] pub(crate) const WARNING_COLOR: &str = YELLOW; const SECTION_PREFIX_FIRST: &str = "- "; @@ -91,83 +89,57 @@ pub(crate) fn cmd_stream_format(mut input: Vec) -> Vec { #[must_use] pub(crate) fn section(topic: impl AsRef) -> String { - prefix_lines(SECTION_PREFIX_FIRST, SECTION_PREFIX_REST, topic.as_ref()) + prefix_first_rest_lines(SECTION_PREFIX_FIRST, SECTION_PREFIX_REST, topic.as_ref()) } #[must_use] pub(crate) fn step(contents: impl AsRef) -> String { - prefix_lines(STEP_PREFIX_FIRST, STEP_PREFIX_REST, contents.as_ref()) -} - -/// Used to decorate a buildpack. -#[must_use] -pub(crate) fn header(contents: impl AsRef) -> String { - let contents = contents.as_ref(); - colorize(HEROKU_COLOR, format!("\n# {contents}")) + prefix_first_rest_lines(STEP_PREFIX_FIRST, STEP_PREFIX_REST, contents.as_ref()) } -pub(crate) fn prefix_lines(first_prefix: &str, rest_prefix: &str, contents: &str) -> String { +pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { let lines = LineIterator::from(contents).enumerate().fold( String::new(), |mut acc, (line_index, line)| { - let prefix = if line_index == 0 { - first_prefix - } else { - rest_prefix - }; - + let prefix = f(line_index, line); let _ = write!(acc, "{prefix}{line}"); acc }, ); if lines.is_empty() { - String::from(first_prefix) + f(0, "") } else { lines } } -#[must_use] -pub(crate) fn important(contents: impl AsRef) -> String { - colorize(IMPORTANT_COLOR, bangify(contents)) -} - -#[must_use] -pub(crate) fn warning(contents: impl AsRef) -> String { - colorize(WARNING_COLOR, bangify(contents)) -} +pub(crate) fn prefix_first_rest_lines( + first_prefix: &str, + rest_prefix: &str, + contents: &str, +) -> String { + let first_prefix = String::from(first_prefix); + let rest_prefix = String::from(rest_prefix); -#[must_use] -pub(crate) fn error(contents: impl AsRef) -> String { - colorize(ERROR_COLOR, bangify(contents)) + prefix_lines(contents, move |index, _| { + if index == 0 { + first_prefix.clone() + } else { + rest_prefix.clone() + } + }) } /// Helper method that adds a bang i.e. `!` before strings. pub(crate) fn bangify(body: impl AsRef) -> String { - prepend_each_line("!", " ", body) -} - -// Ensures every line starts with `prepend` -pub(crate) fn prepend_each_line( - prepend: impl AsRef, - separator: impl AsRef, - contents: impl AsRef, -) -> String { - let body = contents.as_ref(); - let prepend = prepend.as_ref(); - let separator = separator.as_ref(); - - let lines = LineIterator::from(body) - .map(|line| { - if line.trim().is_empty() { - format!("{prepend}{line}") - } else { - format!("{prepend}{separator}{line}") - } - }) - .collect::(); - lines + prefix_lines(body.as_ref(), |_, line| { + if line.trim().is_empty() { + String::from("!") + } else { + String::from("! ") + } + }) } /// Colorizes a body while preserving existing color/reset combinations and clearing before newlines. @@ -205,17 +177,17 @@ mod test { #[test] fn test_prefix_indent() { - assert_eq!("- hello", &prefix_lines("- ", " ", "hello")); + assert_eq!("- hello", &prefix_first_rest_lines("- ", " ", "hello")); assert_eq!( "- hello\n world", - &prefix_lines("- ", " ", "hello\nworld") + &prefix_first_rest_lines("- ", " ", "hello\nworld") ); assert_eq!( "- hello\n world\n", - &prefix_lines("- ", " ", "hello\nworld\n") + &prefix_first_rest_lines("- ", " ", "hello\nworld\n") ); - assert_eq!("- ", &prefix_lines("- ", " ", "")); + assert_eq!("- ", &prefix_first_rest_lines("- ", " ", "")); } #[test] From af7494ce2bdff3e8d92ca515511c014b217b7ea2 Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 30 Jan 2024 13:25:32 -0600 Subject: [PATCH 31/99] Fix warning and double warning tests Warnings had an extra newline by accident. This was fixed by changing "\n" to "" --- libherokubuildpack/src/buildpack_output/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index e8a51290..64c0aef9 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -107,7 +107,7 @@ where writeln_now(io, ""); } writeln_now(io, colorize(WARNING_COLOR, bangify(s.trim()))); - writeln_now(io, "\n"); + writeln_now(io, ""); self } From b3d498d77ab3ba348f3099a042fff776df061f07 Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 30 Jan 2024 13:26:00 -0600 Subject: [PATCH 32/99] Fix header followed by warning tests Header followed by a warning was returning an accidental extra newline. This is caused due to the way that we're inspecting writes via `ParagraphInspectWrite`, it looks at the end of the string, counting newlines. However if there is a control (color) sequence or reset sequence it this will trigger it to also stop looking. In the future we should make this more robust by accounting for any non-visible character, but in the short term I'm able to eliminate such results from being generated in the first place in the `colorize` function. --- libherokubuildpack/src/buildpack_output/style.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index c45e7a79..0124b8dd 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -159,6 +159,7 @@ pub(crate) fn colorize(color: &str, body: impl AsRef) -> String { // The above logic causes redundant colors and resets, clean them up .map(|line| line.replace(&format!("{RESET}{color}{RESET}"), RESET)) .map(|line| line.replace(&format!("{color}{color}"), color)) // Reduce useless color + .map(|line| line.replace(&format!("{color}{RESET}"), "")) // Do not colorize empty lines .collect::>() .join("\n") } From 367504c8f5878d40903373fd40c34218c9dc6bca Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 30 Jan 2024 13:55:48 -0600 Subject: [PATCH 33/99] Reduce clippy warnings --- libherokubuildpack/src/buildpack_output/style.rs | 9 ++++++--- libherokubuildpack/src/buildpack_output/util.rs | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 0124b8dd..1a76efa9 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -43,11 +43,13 @@ pub(crate) const CYAN: &str = "\x1B[0;36m"; pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // Magenta +#[cfg(test)] pub(crate) const DEFAULT_DIM: &str = "\x1B[2;1m"; // Default color but softer/less vibrant pub(crate) const RESET: &str = "\x1B[0m"; #[cfg(test)] pub(crate) const NO_COLOR: &str = "\x1B[1;39m"; // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 +#[cfg(test)] pub(crate) const ALL_CODES: [&str; 7] = [ RED, YELLOW, @@ -78,12 +80,12 @@ const CMD_INDENT: &str = " "; #[must_use] pub(crate) fn cmd_stream_format(mut input: Vec) -> Vec { let s = String::from_utf8_lossy(&input); - if !s.trim().is_empty() { + if s.trim().is_empty() { + input + } else { let mut result: Vec = CMD_INDENT.into(); result.append(&mut input); result - } else { - input } } @@ -164,6 +166,7 @@ pub(crate) fn colorize(color: &str, body: impl AsRef) -> String { .join("\n") } +#[cfg(test)] pub(crate) fn strip_control_codes(contents: impl AsRef) -> String { let mut contents = contents.as_ref().to_string(); for code in ALL_CODES { diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 12cbb0a0..a2cf9f1f 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -44,12 +44,14 @@ impl Clone for LockedWriter { } impl LockedWriter { + #[cfg(test)] pub(crate) fn new(write: W) -> Self { LockedWriter { arc: Arc::new(Mutex::new(write)), } } + #[cfg(test)] 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") From 08478b8b54a04880bb0d8146a4553eb6f1266941 Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 30 Jan 2024 13:58:03 -0600 Subject: [PATCH 34/99] Refactor warn/important/error internals The code pattern is repeated with only the color varying. I extracted it into a reusable private function. --- .../src/buildpack_output/mod.rs | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 64c0aef9..b984caf7 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -101,38 +101,27 @@ where { #[must_use] pub fn warning(mut self, s: &str) -> BuildpackOutput { - let io = self.state.write_mut(); - - if !io.was_paragraph { - writeln_now(io, ""); - } - writeln_now(io, colorize(WARNING_COLOR, bangify(s.trim()))); - writeln_now(io, ""); - + self.write_paragraph(WARNING_COLOR, s); self } #[must_use] pub fn important(mut self, s: &str) -> BuildpackOutput { - let io = self.state.write_mut(); - - if !io.was_paragraph { - writeln_now(io, ""); - } - writeln_now(io, colorize(IMPORTANT_COLOR, bangify(s.trim()))); - writeln_now(io, ""); - + self.write_paragraph(IMPORTANT_COLOR, s); self } pub fn error(mut self, s: &str) { + self.write_paragraph(ERROR_COLOR, s); + } + + fn write_paragraph(&mut self, color: &str, s: &str) { let io = self.state.write_mut(); if !io.was_paragraph { writeln_now(io, ""); } - - writeln_now(io, colorize(ERROR_COLOR, bangify(s.trim()))); + writeln_now(io, colorize(color, bangify(s.trim()))); writeln_now(io, ""); } } From dd83998d02ba583b6270f68e11e9c52795d6addb Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 30 Jan 2024 14:34:08 -0600 Subject: [PATCH 35/99] Adjust visibility due to clippy We do not intend for anyone to directly use or implement the trait `AnnounceSupportedState`, it is now `pub(crate)`. On the flip side, developers are expected to use items in the `state` module, so I made that `pub`. Finally, clippy is giving us a warning: ``` 99 | / impl BuildpackOutput 100 | | where 101 | | S: AnnounceSupportedState, | |______________________________^ implementation `buildpack_output::BuildpackOutput` is reachable at visibility `pub` | ``` Which is what we want (I think). We want `write_mut` to be exercised indirectly through `warning`, `important`, and `error`. I ignored this warning with `allow(private_bounds)`. I added a call to `warning()` in the doc test to ensure that it can still be called without the `AnnounceSupportedState` trait in bounds. --- libherokubuildpack/src/buildpack_output/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index b984caf7..fa82a2f7 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -6,7 +6,8 @@ //! use libherokubuildpack::buildpack_output::BuildpackOutput; //! //! let mut output = BuildpackOutput::new(std::io::stdout()) -//! .start("Heroku Ruby Buildpack"); +//! .start("Heroku Ruby Buildpack") +//! .warning("No Gemfile.lock found"); //! //! output = output //! .section("Ruby version") @@ -40,7 +41,7 @@ pub struct BuildpackOutput { /// The [`BuildpackOutput`] struct acts as an output state machine. These structs /// are meant to represent those states. #[doc(hidden)] -pub(crate) mod state { +pub mod state { use crate::buildpack_output::util::ParagraphInspectWrite; use crate::write::MappedWrite; use std::time::Instant; @@ -67,7 +68,7 @@ pub(crate) mod state { } #[doc(hidden)] -pub trait AnnounceSupportedState { +trait AnnounceSupportedState { type Inner: Write; fn write_mut(&mut self) -> &mut ParagraphInspectWrite; @@ -95,6 +96,7 @@ where } } +#[allow(private_bounds)] impl BuildpackOutput where S: AnnounceSupportedState, From 644c5ae6d3aadac1ec40a85af3ed7e477679a142 Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 30 Jan 2024 14:53:07 -0600 Subject: [PATCH 36/99] Change &str to impl AsRef for flexability --- .../src/buildpack_output/mod.rs | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index fa82a2f7..93f2c1f4 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -102,28 +102,28 @@ where S: AnnounceSupportedState, { #[must_use] - pub fn warning(mut self, s: &str) -> BuildpackOutput { + pub fn warning(mut self, s: impl AsRef) -> BuildpackOutput { self.write_paragraph(WARNING_COLOR, s); self } #[must_use] - pub fn important(mut self, s: &str) -> BuildpackOutput { + pub fn important(mut self, s: impl AsRef) -> BuildpackOutput { self.write_paragraph(IMPORTANT_COLOR, s); self } - pub fn error(mut self, s: &str) { + pub fn error(mut self, s: impl AsRef) { self.write_paragraph(ERROR_COLOR, s); } - fn write_paragraph(&mut self, color: &str, s: &str) { + fn write_paragraph(&mut self, color: &str, s: impl AsRef) { let io = self.state.write_mut(); if !io.was_paragraph { writeln_now(io, ""); } - writeln_now(io, colorize(color, bangify(s.trim()))); + writeln_now(io, colorize(color, bangify(s.as_ref().trim()))); writeln_now(io, ""); } } @@ -141,10 +141,10 @@ where } } - pub fn start(mut self, buildpack_name: &str) -> BuildpackOutput> { + pub fn start(mut self, buildpack_name: impl AsRef) -> BuildpackOutput> { writeln_now( &mut self.state.write, - colorize(HEROKU_COLOR, format!("\n# {buildpack_name}\n")), + colorize(HEROKU_COLOR, format!("\n# {}\n", buildpack_name.as_ref())), ); self.start_silent() @@ -165,7 +165,7 @@ where W: Write + Send + Sync + 'static, { #[must_use] - pub fn section(mut self, s: &str) -> BuildpackOutput> { + pub fn section(mut self, s: impl AsRef) -> BuildpackOutput> { writeln_now(&mut self.state.write, style::section(s)); BuildpackOutput { @@ -196,12 +196,12 @@ impl BuildpackOutput> where W: Write + Send + Sync + 'static, { - pub fn mut_step(&mut self, s: &str) { + pub fn mut_step(&mut self, s: impl AsRef) { writeln_now(&mut self.state.write, style::step(s)); } #[must_use] - pub fn step(mut self, s: &str) -> BuildpackOutput> { + pub fn step(mut self, s: impl AsRef) -> BuildpackOutput> { writeln_now(&mut self.state.write, style::step(s)); BuildpackOutput { @@ -212,7 +212,10 @@ where } } - pub fn step_timed_stream(mut self, s: &str) -> BuildpackOutput> { + pub fn step_timed_stream( + mut self, + s: impl AsRef, + ) -> BuildpackOutput> { writeln_now(&mut self.state.write, style::step(s)); // Newline before stream https://github.com/heroku/libcnb.rs/issues/582 From f36c4b355593969427d1b544c8b31ad1947dfd74 Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 30 Jan 2024 15:03:39 -0600 Subject: [PATCH 37/99] Move style logic closer to the end use Make `trim().empty()` clearer that we're checking for whitespace (and avoid an allocation). --- .../src/buildpack_output/mod.rs | 39 +++++++++++++++---- .../src/buildpack_output/style.rs | 33 +--------------- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 93f2c1f4..6bf749e3 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -17,7 +17,7 @@ //! ``` //! use crate::buildpack_output::style::{ - bangify, cmd_stream_format, colorize, ERROR_COLOR, HEROKU_COLOR, IMPORTANT_COLOR, WARNING_COLOR, + bangify, colorize, ERROR_COLOR, HEROKU_COLOR, IMPORTANT_COLOR, WARNING_COLOR, }; use crate::buildpack_output::util::ParagraphInspectWrite; use crate::write::line_mapped; @@ -164,9 +164,16 @@ impl BuildpackOutput> where W: Write + Send + Sync + 'static, { + const PREFIX_FIRST: &'static str = "- "; + const PREFIX_REST: &'static str = " "; + + fn style(s: impl AsRef) -> String { + style::prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) + } + #[must_use] pub fn section(mut self, s: impl AsRef) -> BuildpackOutput> { - writeln_now(&mut self.state.write, style::section(s)); + writeln_now(&mut self.state.write, Self::style(s)); BuildpackOutput { started: self.started, @@ -182,10 +189,10 @@ where let details = style::details(format!("finished in {elapsed}")); writeln_now( &mut self.state.write, - style::section(format!("Done {details}")), + Self::style(format!("Done {details}")), ); } else { - writeln_now(&mut self.state.write, style::section("Done")); + writeln_now(&mut self.state.write, Self::style("Done")); } self.state.write.inner @@ -196,13 +203,21 @@ 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 { + style::prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) + } + pub fn mut_step(&mut self, s: impl AsRef) { - writeln_now(&mut self.state.write, style::step(s)); + writeln_now(&mut self.state.write, Self::style(s)); } #[must_use] pub fn step(mut self, s: impl AsRef) -> BuildpackOutput> { - writeln_now(&mut self.state.write, style::step(s)); + writeln_now(&mut self.state.write, Self::style(s)); BuildpackOutput { started: self.started, @@ -216,7 +231,7 @@ where mut self, s: impl AsRef, ) -> BuildpackOutput> { - writeln_now(&mut self.state.write, style::step(s)); + writeln_now(&mut self.state.write, Self::style(s)); // Newline before stream https://github.com/heroku/libcnb.rs/issues/582 writeln_now(&mut self.state.write, ""); @@ -225,7 +240,15 @@ where started: self.started, state: state::TimedStream { started: Instant::now(), - write: line_mapped(self.state.write, cmd_stream_format), + write: line_mapped(self.state.write, |mut input| { + if input.iter().all(u8::is_ascii_whitespace) { + input + } else { + let mut result: Vec = Self::CMD_INDENT.into(); + result.append(&mut input); + result + } + }), }, } } diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 1a76efa9..9a7adb4a 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -68,37 +68,6 @@ pub(crate) const IMPORTANT_COLOR: &str = CYAN; pub(crate) const ERROR_COLOR: &str = RED; pub(crate) const WARNING_COLOR: &str = YELLOW; -const SECTION_PREFIX_FIRST: &str = "- "; -const SECTION_PREFIX_REST: &str = " "; - -const STEP_PREFIX_FIRST: &str = " - "; -const STEP_PREFIX_REST: &str = " "; - -const CMD_INDENT: &str = " "; - -/// Used with libherokubuildpack line-mapped command output. -#[must_use] -pub(crate) fn cmd_stream_format(mut input: Vec) -> Vec { - let s = String::from_utf8_lossy(&input); - if s.trim().is_empty() { - input - } else { - let mut result: Vec = CMD_INDENT.into(); - result.append(&mut input); - result - } -} - -#[must_use] -pub(crate) fn section(topic: impl AsRef) -> String { - prefix_first_rest_lines(SECTION_PREFIX_FIRST, SECTION_PREFIX_REST, topic.as_ref()) -} - -#[must_use] -pub(crate) fn step(contents: impl AsRef) -> String { - prefix_first_rest_lines(STEP_PREFIX_FIRST, STEP_PREFIX_REST, contents.as_ref()) -} - pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { let lines = LineIterator::from(contents).enumerate().fold( String::new(), @@ -136,7 +105,7 @@ pub(crate) fn prefix_first_rest_lines( /// Helper method that adds a bang i.e. `!` before strings. pub(crate) fn bangify(body: impl AsRef) -> String { prefix_lines(body.as_ref(), |_, line| { - if line.trim().is_empty() { + if line.chars().all(char::is_whitespace) { String::from("!") } else { String::from("! ") From b5b29e0683a7f150db80193d48446fda94bd19bc Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 30 Jan 2024 15:22:42 -0600 Subject: [PATCH 38/99] Rename TimedStream to Stream The state is streaming, the fact we're timing it is a property. --- .../src/buildpack_output/mod.rs | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 6bf749e3..52610af9 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -61,7 +61,7 @@ pub mod state { pub(crate) write: ParagraphInspectWrite, } - pub struct TimedStream { + pub struct Stream { pub(crate) started: Instant, pub(crate) write: MappedWrite>, } @@ -227,18 +227,13 @@ where } } - pub fn step_timed_stream( - mut self, - s: impl AsRef, - ) -> BuildpackOutput> { + pub fn start_stream(mut self, s: impl AsRef) -> BuildpackOutput> { writeln_now(&mut self.state.write, Self::style(s)); - - // Newline before stream https://github.com/heroku/libcnb.rs/issues/582 writeln_now(&mut self.state.write, ""); BuildpackOutput { started: self.started, - state: state::TimedStream { + state: state::Stream { started: Instant::now(), write: line_mapped(self.state.write, |mut input| { if input.iter().all(u8::is_ascii_whitespace) { @@ -263,11 +258,11 @@ where } } -impl BuildpackOutput> +impl BuildpackOutput> where W: Write + Send + Sync + 'static, { - pub fn finish_timed_stream(mut self) -> BuildpackOutput> { + pub fn finish_stream(mut self) -> BuildpackOutput> { let duration = self.state.started.elapsed(); writeln_now(&mut self.state.write, ""); @@ -288,7 +283,7 @@ where } } -impl Write for BuildpackOutput> +impl Write for BuildpackOutput> where W: Write, { @@ -326,12 +321,12 @@ mod test { .section("Ruby version `3.1.3` from `Gemfile.lock`") .end_section() .section("Hello world") - .step_timed_stream("Streaming stuff"); + .start_stream("Streaming stuff"); let value = "stuff".to_string(); writeln!(&mut stream, "{value}").unwrap(); - let io = stream.finish_timed_stream().end_section().finish(); + let io = stream.finish_stream().end_section().finish(); let expected = formatdoc! {" @@ -356,7 +351,7 @@ mod test { let mut stream = BuildpackOutput::new(writer) .start("Streaming buildpack demo") .section("Command streaming") - .step_timed_stream("Streaming stuff"); + .start_stream("Streaming stuff"); let locked_writer = LockedWriter::new(stream); @@ -367,7 +362,7 @@ mod test { stream = locked_writer.unwrap(); - let io = stream.finish_timed_stream().end_section().finish(); + let io = stream.finish_stream().end_section().finish(); let actual = strip_control_codes(String::from_utf8_lossy(&io)); From 2f7a13e2799e36b2f6a430d3a39337e6227e3906 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 09:08:43 +0100 Subject: [PATCH 39/99] Remove pretty_assertions --- libherokubuildpack/Cargo.toml | 1 - libherokubuildpack/src/buildpack_output/mod.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index 6b1fd531..a9019139 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -52,5 +52,4 @@ const_format = { version = "0.2", optional = true } tempfile = "3.8.1" libcnb-test = {workspace = true} indoc = "2" -pretty_assertions = "1" fun_run = "0.1.1" diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 52610af9..e29a8b15 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -311,7 +311,6 @@ mod test { use crate::command::CommandExt; use indoc::formatdoc; use libcnb_test::assert_contains; - use pretty_assertions::assert_eq; #[test] fn test_captures() { From 910c6195237051d59d93347b3918ed4ae3c4e63f Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 09:09:25 +0100 Subject: [PATCH 40/99] Remove fun_run --- libherokubuildpack/Cargo.toml | 1 - libherokubuildpack/src/lib.rs | 3 --- 2 files changed, 4 deletions(-) diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index a9019139..e6cd8961 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -52,4 +52,3 @@ const_format = { version = "0.2", optional = true } tempfile = "3.8.1" libcnb-test = {workspace = true} indoc = "2" -fun_run = "0.1.1" diff --git a/libherokubuildpack/src/lib.rs b/libherokubuildpack/src/lib.rs index 83a8b77e..c742a325 100644 --- a/libherokubuildpack/src/lib.rs +++ b/libherokubuildpack/src/lib.rs @@ -20,6 +20,3 @@ pub mod tar; pub mod toml; #[cfg(feature = "write")] pub mod write; - -#[cfg(test)] -use fun_run as _; From 6f5c6d65483c776222f866d70ce2590bca8090b8 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 09:14:44 +0100 Subject: [PATCH 41/99] Move Duration formatting into dedicated file --- .../src/buildpack_output/duration_format.rs | 64 ++++++++++++++++++ .../src/buildpack_output/mod.rs | 5 +- .../src/buildpack_output/style.rs | 67 ------------------- 3 files changed, 67 insertions(+), 69 deletions(-) create mode 100644 libherokubuildpack/src/buildpack_output/duration_format.rs diff --git a/libherokubuildpack/src/buildpack_output/duration_format.rs b/libherokubuildpack/src/buildpack_output/duration_format.rs new file mode 100644 index 00000000..2cf9a56e --- /dev/null +++ b/libherokubuildpack/src/buildpack_output/duration_format.rs @@ -0,0 +1,64 @@ +use std::time::Duration; + +// Returns the part of a duration only in milliseconds +pub(crate) fn milliseconds(duration: &Duration) -> u32 { + duration.subsec_millis() +} + +pub(crate) fn seconds(duration: &Duration) -> u64 { + duration.as_secs() % 60 +} + +pub(crate) fn minutes(duration: &Duration) -> u64 { + (duration.as_secs() / 60) % 60 +} + +pub(crate) fn hours(duration: &Duration) -> u64 { + (duration.as_secs() / 3600) % 60 +} + +#[must_use] +pub(crate) fn human(duration: &Duration) -> String { + let hours = hours(duration); + let minutes = minutes(duration); + let seconds = seconds(duration); + let milliseconds = milliseconds(duration); + + 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 { + // 0.1 + format!("{seconds}.{milliseconds:0>3}s") + } else { + String::from("< 0.1s") + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_millis_and_seconds() { + let duration = Duration::from_millis(1024); + assert_eq!(24, milliseconds(&duration)); + assert_eq!(1, seconds(&duration)); + } + + #[test] + fn test_display_duration() { + let duration = Duration::from_millis(99); + assert_eq!("< 0.1s", human(&duration).as_str()); + + let duration = Duration::from_millis(1024); + assert_eq!("1.024s", human(&duration).as_str()); + + let duration = Duration::from_millis(60 * 1024); + assert_eq!("1m 1s", human(&duration).as_str()); + + let duration = Duration::from_millis(3600 * 1024); + assert_eq!("1h 1m 26s", human(&duration).as_str()); + } +} diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index e29a8b15..ff863b58 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -25,6 +25,7 @@ use std::fmt::Debug; use std::io::Write; use std::time::Instant; +mod duration_format; pub mod style; mod util; @@ -185,7 +186,7 @@ where pub fn finish(mut self) -> W { if let Some(started) = &self.started { - let elapsed = style::time::human(&started.elapsed()); + let elapsed = duration_format::human(&started.elapsed()); let details = style::details(format!("finished in {elapsed}")); writeln_now( &mut self.state.write, @@ -276,7 +277,7 @@ where section.mut_step(&format!( "Done {}", - style::details(style::time::human(&duration)) + style::details(duration_format::human(&duration)) )); section diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 9a7adb4a..3f8d3748 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -206,70 +206,3 @@ mod test { assert_eq!(format!("{RED}hello world{RESET}"), actual); } } - -pub(crate) mod time { - use std::time::Duration; - - // Returns the part of a duration only in milliseconds - pub(crate) fn milliseconds(duration: &Duration) -> u32 { - duration.subsec_millis() - } - - pub(crate) fn seconds(duration: &Duration) -> u64 { - duration.as_secs() % 60 - } - - pub(crate) fn minutes(duration: &Duration) -> u64 { - (duration.as_secs() / 60) % 60 - } - - pub(crate) fn hours(duration: &Duration) -> u64 { - (duration.as_secs() / 3600) % 60 - } - - #[must_use] - pub(crate) fn human(duration: &Duration) -> String { - let hours = hours(duration); - let minutes = minutes(duration); - let seconds = seconds(duration); - let milliseconds = milliseconds(duration); - - 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 { - // 0.1 - format!("{seconds}.{milliseconds:0>3}s") - } else { - String::from("< 0.1s") - } - } - - #[cfg(test)] - mod test { - use super::*; - - #[test] - fn test_millis_and_seconds() { - let duration = Duration::from_millis(1024); - assert_eq!(24, milliseconds(&duration)); - assert_eq!(1, seconds(&duration)); - } - - #[test] - fn test_display_duration() { - let duration = Duration::from_millis(99); - assert_eq!("< 0.1s", human(&duration).as_str()); - - let duration = Duration::from_millis(1024); - assert_eq!("1.024s", human(&duration).as_str()); - - let duration = Duration::from_millis(60 * 1024); - assert_eq!("1m 1s", human(&duration).as_str()); - - let duration = Duration::from_millis(3600 * 1024); - assert_eq!("1h 1m 26s", human(&duration).as_str()); - } - } -} From e5daaf47e25f9d905e1c3ac6379be7e5dd6c755f Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 09:19:25 +0100 Subject: [PATCH 42/99] Clean up duration_format.rs --- .../src/buildpack_output/duration_format.rs | 33 +++---------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/duration_format.rs b/libherokubuildpack/src/buildpack_output/duration_format.rs index 2cf9a56e..d43665c2 100644 --- a/libherokubuildpack/src/buildpack_output/duration_format.rs +++ b/libherokubuildpack/src/buildpack_output/duration_format.rs @@ -1,35 +1,17 @@ use std::time::Duration; -// Returns the part of a duration only in milliseconds -pub(crate) fn milliseconds(duration: &Duration) -> u32 { - duration.subsec_millis() -} - -pub(crate) fn seconds(duration: &Duration) -> u64 { - duration.as_secs() % 60 -} - -pub(crate) fn minutes(duration: &Duration) -> u64 { - (duration.as_secs() / 60) % 60 -} - -pub(crate) fn hours(duration: &Duration) -> u64 { - (duration.as_secs() / 3600) % 60 -} - #[must_use] pub(crate) fn human(duration: &Duration) -> String { - let hours = hours(duration); - let minutes = minutes(duration); - let seconds = seconds(duration); - let milliseconds = milliseconds(duration); + 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(); 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 { - // 0.1 format!("{seconds}.{milliseconds:0>3}s") } else { String::from("< 0.1s") @@ -40,13 +22,6 @@ pub(crate) fn human(duration: &Duration) -> String { mod test { use super::*; - #[test] - fn test_millis_and_seconds() { - let duration = Duration::from_millis(1024); - assert_eq!(24, milliseconds(&duration)); - assert_eq!(1, seconds(&duration)); - } - #[test] fn test_display_duration() { let duration = Duration::from_millis(99); From 83dacfc8004b7c0ba938ed50aab1262f8583b437 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 09:21:47 +0100 Subject: [PATCH 43/99] Remove unused pub constants --- libherokubuildpack/src/buildpack_output/style.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 3f8d3748..3f6e2828 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -4,12 +4,6 @@ use std::fmt::Write; /// Helpers for formatting and colorizing your output. -/// Decorated str for prefixing "Help:". -pub const HELP: &str = formatcp!("{IMPORTANT_COLOR}! HELP{RESET}"); - -/// Decorated str for prefixing "Debug info:". -pub const DEBUG_INFO: &str = formatcp!("{IMPORTANT_COLOR}Debug info{RESET}"); - /// Decorate a URL for the build output. #[must_use] pub fn url(contents: impl AsRef) -> String { From 362462756cc64d3a97c54ddb69adc28694b54951 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 09:27:00 +0100 Subject: [PATCH 44/99] Move constants to constants.rs --- .../src/buildpack_output/constants.rs | 31 +++++++++++++++++ .../src/buildpack_output/mod.rs | 6 ++-- .../src/buildpack_output/style.rs | 34 +------------------ 3 files changed, 36 insertions(+), 35 deletions(-) create mode 100644 libherokubuildpack/src/buildpack_output/constants.rs diff --git a/libherokubuildpack/src/buildpack_output/constants.rs b/libherokubuildpack/src/buildpack_output/constants.rs new file mode 100644 index 00000000..5c65a840 --- /dev/null +++ b/libherokubuildpack/src/buildpack_output/constants.rs @@ -0,0 +1,31 @@ +pub(crate) const RED: &str = "\x1B[0;31m"; +pub(crate) const YELLOW: &str = "\x1B[0;33m"; +pub(crate) const CYAN: &str = "\x1B[0;36m"; + +pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; +pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // Magenta + +#[cfg(test)] +pub(crate) const DEFAULT_DIM: &str = "\x1B[2;1m"; // Default color but softer/less vibrant +pub(crate) const RESET: &str = "\x1B[0m"; + +#[cfg(test)] +pub(crate) const NO_COLOR: &str = "\x1B[1;39m"; // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 +#[cfg(test)] +pub(crate) const ALL_CODES: [&str; 7] = [ + RED, + YELLOW, + CYAN, + BOLD_CYAN, + BOLD_PURPLE, + DEFAULT_DIM, + RESET, +]; + +pub(crate) const HEROKU_COLOR: &str = BOLD_PURPLE; +pub(crate) const VALUE_COLOR: &str = YELLOW; +pub(crate) const COMMAND_COLOR: &str = BOLD_CYAN; +pub(crate) const URL_COLOR: &str = CYAN; +pub(crate) const IMPORTANT_COLOR: &str = CYAN; +pub(crate) const ERROR_COLOR: &str = RED; +pub(crate) const WARNING_COLOR: &str = YELLOW; diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index ff863b58..5b51bb8f 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -16,15 +16,17 @@ //! output.finish(); //! ``` //! -use crate::buildpack_output::style::{ - bangify, colorize, ERROR_COLOR, HEROKU_COLOR, IMPORTANT_COLOR, WARNING_COLOR, +use crate::buildpack_output::constants::{ + ERROR_COLOR, HEROKU_COLOR, IMPORTANT_COLOR, WARNING_COLOR, }; +use crate::buildpack_output::style::{bangify, colorize}; use crate::buildpack_output::util::ParagraphInspectWrite; use crate::write::line_mapped; use std::fmt::Debug; use std::io::Write; use std::time::Instant; +mod constants; mod duration_format; pub mod style; mod util; diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 3f6e2828..2b2b592c 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -1,5 +1,5 @@ +use crate::buildpack_output::constants::*; use crate::buildpack_output::util::LineIterator; -use const_format::formatcp; use std::fmt::Write; /// Helpers for formatting and colorizing your output. @@ -30,38 +30,6 @@ pub fn details(contents: impl AsRef) -> String { format!("({contents})") } -pub(crate) const RED: &str = "\x1B[0;31m"; -pub(crate) const YELLOW: &str = "\x1B[0;33m"; -pub(crate) const CYAN: &str = "\x1B[0;36m"; - -pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; -pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // Magenta - -#[cfg(test)] -pub(crate) const DEFAULT_DIM: &str = "\x1B[2;1m"; // Default color but softer/less vibrant -pub(crate) const RESET: &str = "\x1B[0m"; - -#[cfg(test)] -pub(crate) const NO_COLOR: &str = "\x1B[1;39m"; // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 -#[cfg(test)] -pub(crate) const ALL_CODES: [&str; 7] = [ - RED, - YELLOW, - CYAN, - BOLD_CYAN, - BOLD_PURPLE, - DEFAULT_DIM, - RESET, -]; - -pub(crate) const HEROKU_COLOR: &str = BOLD_PURPLE; -pub(crate) const VALUE_COLOR: &str = YELLOW; -pub(crate) const COMMAND_COLOR: &str = BOLD_CYAN; -pub(crate) const URL_COLOR: &str = CYAN; -pub(crate) const IMPORTANT_COLOR: &str = CYAN; -pub(crate) const ERROR_COLOR: &str = RED; -pub(crate) const WARNING_COLOR: &str = YELLOW; - pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { let lines = LineIterator::from(contents).enumerate().fold( String::new(), From bba601d8aa00fb71649b7c960c368baf7a8325f5 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 09:52:47 +0100 Subject: [PATCH 45/99] Move strip_control_codes closer to single usage --- libherokubuildpack/src/buildpack_output/mod.rs | 10 +++++++++- libherokubuildpack/src/buildpack_output/style.rs | 9 --------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 5b51bb8f..d9ce65bb 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -309,7 +309,7 @@ fn writeln_now(destination: &mut D, msg: impl AsRef) { #[cfg(test)] mod test { use super::*; - use crate::buildpack_output::style::strip_control_codes; + use crate::buildpack_output::constants::ALL_CODES; use crate::buildpack_output::util::LockedWriter; use crate::command::CommandExt; use indoc::formatdoc; @@ -464,4 +464,12 @@ mod test { assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); } + + fn strip_control_codes(contents: impl AsRef) -> String { + let mut contents = contents.as_ref().to_string(); + for code in ALL_CODES { + contents = contents.replace(code, ""); + } + contents + } } diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 2b2b592c..bb670fd5 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -97,15 +97,6 @@ pub(crate) fn colorize(color: &str, body: impl AsRef) -> String { .join("\n") } -#[cfg(test)] -pub(crate) fn strip_control_codes(contents: impl AsRef) -> String { - let mut contents = contents.as_ref().to_string(); - for code in ALL_CODES { - contents = contents.replace(code, ""); - } - contents -} - #[cfg(test)] mod test { use super::*; From 549e73adcd2dbcccbe237f0fe6b3d67a178aabfd Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 09:58:43 +0100 Subject: [PATCH 46/99] Remove single-use renamed constants These constants were just alternative names for other constants (which are good to have, regardless of useage count). --- .../src/buildpack_output/constants.rs | 8 -------- .../src/buildpack_output/mod.rs | 16 +++++++-------- .../src/buildpack_output/style.rs | 20 +++++++++---------- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/constants.rs b/libherokubuildpack/src/buildpack_output/constants.rs index 5c65a840..ad3cba67 100644 --- a/libherokubuildpack/src/buildpack_output/constants.rs +++ b/libherokubuildpack/src/buildpack_output/constants.rs @@ -21,11 +21,3 @@ pub(crate) const ALL_CODES: [&str; 7] = [ DEFAULT_DIM, RESET, ]; - -pub(crate) const HEROKU_COLOR: &str = BOLD_PURPLE; -pub(crate) const VALUE_COLOR: &str = YELLOW; -pub(crate) const COMMAND_COLOR: &str = BOLD_CYAN; -pub(crate) const URL_COLOR: &str = CYAN; -pub(crate) const IMPORTANT_COLOR: &str = CYAN; -pub(crate) const ERROR_COLOR: &str = RED; -pub(crate) const WARNING_COLOR: &str = YELLOW; diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index d9ce65bb..108ce662 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -16,10 +16,8 @@ //! output.finish(); //! ``` //! -use crate::buildpack_output::constants::{ - ERROR_COLOR, HEROKU_COLOR, IMPORTANT_COLOR, WARNING_COLOR, -}; -use crate::buildpack_output::style::{bangify, colorize}; +use crate::buildpack_output::constants::{BOLD_PURPLE, CYAN, RED, YELLOW}; +use crate::buildpack_output::style::{bangify, colorize_multiline}; use crate::buildpack_output::util::ParagraphInspectWrite; use crate::write::line_mapped; use std::fmt::Debug; @@ -106,18 +104,18 @@ where { #[must_use] pub fn warning(mut self, s: impl AsRef) -> BuildpackOutput { - self.write_paragraph(WARNING_COLOR, s); + self.write_paragraph(YELLOW, s); self } #[must_use] pub fn important(mut self, s: impl AsRef) -> BuildpackOutput { - self.write_paragraph(IMPORTANT_COLOR, s); + self.write_paragraph(CYAN, s); self } pub fn error(mut self, s: impl AsRef) { - self.write_paragraph(ERROR_COLOR, s); + self.write_paragraph(RED, s); } fn write_paragraph(&mut self, color: &str, s: impl AsRef) { @@ -126,7 +124,7 @@ where if !io.was_paragraph { writeln_now(io, ""); } - writeln_now(io, colorize(color, bangify(s.as_ref().trim()))); + writeln_now(io, colorize_multiline(color, bangify(s.as_ref().trim()))); writeln_now(io, ""); } } @@ -147,7 +145,7 @@ where pub fn start(mut self, buildpack_name: impl AsRef) -> BuildpackOutput> { writeln_now( &mut self.state.write, - colorize(HEROKU_COLOR, format!("\n# {}\n", buildpack_name.as_ref())), + colorize_multiline(BOLD_PURPLE, format!("\n# {}\n", buildpack_name.as_ref())), ); self.start_silent() diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index bb670fd5..dfae8fb6 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -7,19 +7,19 @@ use std::fmt::Write; /// Decorate a URL for the build output. #[must_use] pub fn url(contents: impl AsRef) -> String { - colorize(URL_COLOR, contents) + colorize_multiline(CYAN, contents) } /// Decorate the name of a command being run i.e. `bundle install`. #[must_use] pub fn command(contents: impl AsRef) -> String { - value(colorize(COMMAND_COLOR, contents.as_ref())) + value(colorize_multiline(BOLD_CYAN, contents.as_ref())) } /// Decorate an important value i.e. `2.3.4`. #[must_use] pub fn value(contents: impl AsRef) -> String { - let contents = colorize(VALUE_COLOR, contents.as_ref()); + let contents = colorize_multiline(YELLOW, contents.as_ref()); format!("`{contents}`") } @@ -81,7 +81,7 @@ pub(crate) fn bangify(body: impl AsRef) -> String { /// if we don't clear, then we will colorize output that isn't ours. /// /// Explicitly uncolored output is handled by treating `\x1b[1;39m` (`NO_COLOR`) as a distinct case from `\x1b[0m`. -pub(crate) fn colorize(color: &str, body: impl AsRef) -> String { +pub(crate) fn colorize_multiline(color: &str, body: impl AsRef) -> String { body.as_ref() .split('\n') // If sub contents are colorized it will contain SUBCOLOR ... RESET. After the reset, @@ -127,9 +127,9 @@ mod test { #[test] fn handles_explicitly_removed_colors() { - let nested = colorize(NO_COLOR, "nested"); + let nested = colorize_multiline(NO_COLOR, "nested"); - let out = colorize(RED, format!("hello {nested} color")); + let out = colorize_multiline(RED, format!("hello {nested} color")); let expected = format!("{RED}hello {NO_COLOR}nested{RESET}{RED} color{RESET}"); assert_eq!(expected, out); @@ -137,9 +137,9 @@ mod test { #[test] fn handles_nested_colors() { - let nested = colorize(CYAN, "nested"); + let nested = colorize_multiline(CYAN, "nested"); - let out = colorize(RED, format!("hello {nested} color")); + let out = colorize_multiline(RED, format!("hello {nested} color")); let expected = format!("{RED}hello {CYAN}nested{RESET}{RED} color{RESET}"); assert_eq!(expected, out); @@ -147,7 +147,7 @@ mod test { #[test] fn splits_newlines() { - let actual = colorize(RED, "hello\nworld"); + let actual = colorize_multiline(RED, "hello\nworld"); let expected = format!("{RED}hello{RESET}\n{RED}world{RESET}"); assert_eq!(expected, actual); @@ -155,7 +155,7 @@ mod test { #[test] fn simple_case() { - let actual = colorize(RED, "hello world"); + let actual = colorize_multiline(RED, "hello world"); assert_eq!(format!("{RED}hello world{RESET}"), actual); } } From 7efe225a0c0c3fe30afa47f8f5bd125d2263522d Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 10:12:46 +0100 Subject: [PATCH 47/99] Refactor and rename strip_control_codes --- .../src/buildpack_output/constants.rs | 12 ----- .../src/buildpack_output/mod.rs | 47 ++++++++++++++----- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/constants.rs b/libherokubuildpack/src/buildpack_output/constants.rs index ad3cba67..3777fd9e 100644 --- a/libherokubuildpack/src/buildpack_output/constants.rs +++ b/libherokubuildpack/src/buildpack_output/constants.rs @@ -5,19 +5,7 @@ pub(crate) const CYAN: &str = "\x1B[0;36m"; pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // Magenta -#[cfg(test)] -pub(crate) const DEFAULT_DIM: &str = "\x1B[2;1m"; // Default color but softer/less vibrant pub(crate) const RESET: &str = "\x1B[0m"; #[cfg(test)] pub(crate) const NO_COLOR: &str = "\x1B[1;39m"; // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 -#[cfg(test)] -pub(crate) const ALL_CODES: [&str; 7] = [ - RED, - YELLOW, - CYAN, - BOLD_CYAN, - BOLD_PURPLE, - DEFAULT_DIM, - RESET, -]; diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 108ce662..f96153bc 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -307,7 +307,6 @@ fn writeln_now(destination: &mut D, msg: impl AsRef) { #[cfg(test)] mod test { use super::*; - use crate::buildpack_output::constants::ALL_CODES; use crate::buildpack_output::util::LockedWriter; use crate::command::CommandExt; use indoc::formatdoc; @@ -342,7 +341,10 @@ mod test { - Done (finished in < 0.1s) "}; - assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); + assert_eq!( + expected, + strip_ansi_escape_sequences(String::from_utf8_lossy(&io)) + ); } #[test] @@ -364,7 +366,7 @@ mod test { let io = stream.finish_stream().end_section().finish(); - let actual = strip_control_codes(String::from_utf8_lossy(&io)); + let actual = strip_ansi_escape_sequences(String::from_utf8_lossy(&io)); assert_contains!(actual, " hello world\n"); } @@ -394,7 +396,10 @@ mod test { - Done (finished in < 0.1s) "}; - assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); + assert_eq!( + expected, + strip_ansi_escape_sequences(String::from_utf8_lossy(&io)) + ); } #[test] @@ -425,7 +430,10 @@ mod test { - Done (finished in < 0.1s) "}; - assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); + assert_eq!( + expected, + strip_ansi_escape_sequences(String::from_utf8_lossy(&io)) + ); } #[test] @@ -460,14 +468,31 @@ mod test { - Done (finished in < 0.1s) "}; - assert_eq!(expected, strip_control_codes(String::from_utf8_lossy(&io))); + assert_eq!( + expected, + strip_ansi_escape_sequences(String::from_utf8_lossy(&io)) + ); } - fn strip_control_codes(contents: impl AsRef) -> String { - let mut contents = contents.as_ref().to_string(); - for code in ALL_CODES { - contents = contents.replace(code, ""); + 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); + } } - contents + + result } } From a534371783b379ecb2680f5d805966f350fdc2f7 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 10:29:19 +0100 Subject: [PATCH 48/99] Move ANSI related code to separate module --- .../src/buildpack_output/ansi_escape.rs | 71 +++++++++++++++++++ .../src/buildpack_output/constants.rs | 11 --- .../src/buildpack_output/mod.rs | 16 +++-- .../src/buildpack_output/style.rs | 58 +-------------- 4 files changed, 83 insertions(+), 73 deletions(-) create mode 100644 libherokubuildpack/src/buildpack_output/ansi_escape.rs delete mode 100644 libherokubuildpack/src/buildpack_output/constants.rs diff --git a/libherokubuildpack/src/buildpack_output/ansi_escape.rs b/libherokubuildpack/src/buildpack_output/ansi_escape.rs new file mode 100644 index 00000000..9bb3754a --- /dev/null +++ b/libherokubuildpack/src/buildpack_output/ansi_escape.rs @@ -0,0 +1,71 @@ +/// Colorizes a body while preserving existing color/reset combinations and clearing before newlines. +/// +/// Colors with newlines are a problem since the contents stream to git which prepends `remote:` before the `libcnb_test` +/// if we don't clear, then we will colorize output that isn't ours. +/// +/// Explicitly uncolored output is handled by treating `\x1b[1;39m` (`NO_COLOR`) as a distinct case from `\x1b[0m`. +pub(crate) fn colorize_multiline(color: &str, body: impl AsRef) -> String { + 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}{color}"))) // Handles nested color + // Set the main color for each line and reset after so we don't colorize `remote:` by accident + .map(|line| format!("{color}{line}{RESET}")) + // The above logic causes redundant colors and resets, clean them up + .map(|line| line.replace(&format!("{RESET}{color}{RESET}"), RESET)) + .map(|line| line.replace(&format!("{color}{color}"), color)) // Reduce useless color + .map(|line| line.replace(&format!("{color}{RESET}"), "")) // Do not colorize empty lines + .collect::>() + .join("\n") +} + +pub(crate) const RED: &str = "\x1B[0;31m"; +pub(crate) const YELLOW: &str = "\x1B[0;33m"; +pub(crate) const CYAN: &str = "\x1B[0;36m"; + +pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; +pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // Magenta + +pub(crate) const RESET: &str = "\x1B[0m"; + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn handles_explicitly_removed_colors() { + // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 + const NO_COLOR: &str = "\x1B[1;39m"; + let nested = colorize_multiline(NO_COLOR, "nested"); + + let out = colorize_multiline(RED, format!("hello {nested} color")); + let expected = format!("{RED}hello {NO_COLOR}nested{RESET}{RED} color{RESET}"); + + assert_eq!(expected, out); + } + + #[test] + fn handles_nested_colors() { + let nested = colorize_multiline(CYAN, "nested"); + + let out = colorize_multiline(RED, format!("hello {nested} color")); + let expected = format!("{RED}hello {CYAN}nested{RESET}{RED} color{RESET}"); + + assert_eq!(expected, out); + } + + #[test] + fn splits_newlines() { + let actual = colorize_multiline(RED, "hello\nworld"); + let expected = format!("{RED}hello{RESET}\n{RED}world{RESET}"); + + assert_eq!(expected, actual); + } + + #[test] + fn simple_case() { + let actual = colorize_multiline(RED, "hello world"); + assert_eq!(format!("{RED}hello world{RESET}"), actual); + } +} diff --git a/libherokubuildpack/src/buildpack_output/constants.rs b/libherokubuildpack/src/buildpack_output/constants.rs deleted file mode 100644 index 3777fd9e..00000000 --- a/libherokubuildpack/src/buildpack_output/constants.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub(crate) const RED: &str = "\x1B[0;31m"; -pub(crate) const YELLOW: &str = "\x1B[0;33m"; -pub(crate) const CYAN: &str = "\x1B[0;36m"; - -pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; -pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // Magenta - -pub(crate) const RESET: &str = "\x1B[0m"; - -#[cfg(test)] -pub(crate) const NO_COLOR: &str = "\x1B[1;39m"; // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index f96153bc..4f8b43db 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -16,15 +16,15 @@ //! output.finish(); //! ``` //! -use crate::buildpack_output::constants::{BOLD_PURPLE, CYAN, RED, YELLOW}; -use crate::buildpack_output::style::{bangify, colorize_multiline}; +use crate::buildpack_output::ansi_escape::{BOLD_PURPLE, CYAN, RED, YELLOW}; +use crate::buildpack_output::style::bangify; use crate::buildpack_output::util::ParagraphInspectWrite; use crate::write::line_mapped; use std::fmt::Debug; use std::io::Write; use std::time::Instant; -mod constants; +mod ansi_escape; mod duration_format; pub mod style; mod util; @@ -124,7 +124,10 @@ where if !io.was_paragraph { writeln_now(io, ""); } - writeln_now(io, colorize_multiline(color, bangify(s.as_ref().trim()))); + writeln_now( + io, + ansi_escape::colorize_multiline(color, bangify(s.as_ref().trim())), + ); writeln_now(io, ""); } } @@ -145,7 +148,10 @@ where pub fn start(mut self, buildpack_name: impl AsRef) -> BuildpackOutput> { writeln_now( &mut self.state.write, - colorize_multiline(BOLD_PURPLE, format!("\n# {}\n", buildpack_name.as_ref())), + ansi_escape::colorize_multiline( + BOLD_PURPLE, + format!("\n# {}\n", buildpack_name.as_ref()), + ), ); self.start_silent() diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index dfae8fb6..bea303ca 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -1,4 +1,4 @@ -use crate::buildpack_output::constants::*; +use crate::buildpack_output::ansi_escape::*; use crate::buildpack_output::util::LineIterator; use std::fmt::Write; @@ -75,28 +75,6 @@ pub(crate) fn bangify(body: impl AsRef) -> String { }) } -/// Colorizes a body while preserving existing color/reset combinations and clearing before newlines. -/// -/// Colors with newlines are a problem since the contents stream to git which prepends `remote:` before the `libcnb_test` -/// if we don't clear, then we will colorize output that isn't ours. -/// -/// Explicitly uncolored output is handled by treating `\x1b[1;39m` (`NO_COLOR`) as a distinct case from `\x1b[0m`. -pub(crate) fn colorize_multiline(color: &str, body: impl AsRef) -> String { - 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}{color}"))) // Handles nested color - // Set the main color for each line and reset after so we don't colorize `remote:` by accident - .map(|line| format!("{color}{line}{RESET}")) - // The above logic causes redundant colors and resets, clean them up - .map(|line| line.replace(&format!("{RESET}{color}{RESET}"), RESET)) - .map(|line| line.replace(&format!("{color}{color}"), color)) // Reduce useless color - .map(|line| line.replace(&format!("{color}{RESET}"), "")) // Do not colorize empty lines - .collect::>() - .join("\n") -} - #[cfg(test)] mod test { use super::*; @@ -124,38 +102,4 @@ mod test { let actual = bangify("\n"); assert_eq!("!\n", actual); } - - #[test] - fn handles_explicitly_removed_colors() { - let nested = colorize_multiline(NO_COLOR, "nested"); - - let out = colorize_multiline(RED, format!("hello {nested} color")); - let expected = format!("{RED}hello {NO_COLOR}nested{RESET}{RED} color{RESET}"); - - assert_eq!(expected, out); - } - - #[test] - fn handles_nested_colors() { - let nested = colorize_multiline(CYAN, "nested"); - - let out = colorize_multiline(RED, format!("hello {nested} color")); - let expected = format!("{RED}hello {CYAN}nested{RESET}{RED} color{RESET}"); - - assert_eq!(expected, out); - } - - #[test] - fn splits_newlines() { - let actual = colorize_multiline(RED, "hello\nworld"); - let expected = format!("{RED}hello{RESET}\n{RED}world{RESET}"); - - assert_eq!(expected, actual); - } - - #[test] - fn simple_case() { - let actual = colorize_multiline(RED, "hello world"); - assert_eq!(format!("{RED}hello world{RESET}"), actual); - } } From 6b1cea99b9937a35a15bb1bb2d4da80aed803723 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 10:38:51 +0100 Subject: [PATCH 49/99] Move prefix functions to util module --- .../src/buildpack_output/mod.rs | 19 +++-- .../src/buildpack_output/style.rs | 76 ------------------- .../src/buildpack_output/util.rs | 51 +++++++++++++ 3 files changed, 65 insertions(+), 81 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 4f8b43db..6960b194 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -17,8 +17,7 @@ //! ``` //! use crate::buildpack_output::ansi_escape::{BOLD_PURPLE, CYAN, RED, YELLOW}; -use crate::buildpack_output::style::bangify; -use crate::buildpack_output::util::ParagraphInspectWrite; +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; @@ -124,9 +123,19 @@ where if !io.was_paragraph { writeln_now(io, ""); } + writeln_now( io, - ansi_escape::colorize_multiline(color, bangify(s.as_ref().trim())), + ansi_escape::colorize_multiline( + color, + prefix_lines(s.as_ref(), |_, line| { + if line.chars().all(char::is_whitespace) { + String::from("!") + } else { + String::from("! ") + } + }), + ), ); writeln_now(io, ""); } @@ -175,7 +184,7 @@ where const PREFIX_REST: &'static str = " "; fn style(s: impl AsRef) -> String { - style::prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) + prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) } #[must_use] @@ -215,7 +224,7 @@ where const CMD_INDENT: &'static str = " "; fn style(s: impl AsRef) -> String { - style::prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) + prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) } pub fn mut_step(&mut self, s: impl AsRef) { diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index bea303ca..ba10643c 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -1,6 +1,4 @@ use crate::buildpack_output::ansi_escape::*; -use crate::buildpack_output::util::LineIterator; -use std::fmt::Write; /// Helpers for formatting and colorizing your output. @@ -29,77 +27,3 @@ pub fn details(contents: impl AsRef) -> String { let contents = contents.as_ref(); format!("({contents})") } - -pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { - let lines = LineIterator::from(contents).enumerate().fold( - String::new(), - |mut acc, (line_index, line)| { - let prefix = f(line_index, line); - let _ = write!(acc, "{prefix}{line}"); - acc - }, - ); - - if lines.is_empty() { - f(0, "") - } else { - lines - } -} - -pub(crate) fn prefix_first_rest_lines( - first_prefix: &str, - rest_prefix: &str, - contents: &str, -) -> String { - let first_prefix = String::from(first_prefix); - let rest_prefix = String::from(rest_prefix); - - prefix_lines(contents, move |index, _| { - if index == 0 { - first_prefix.clone() - } else { - rest_prefix.clone() - } - }) -} - -/// Helper method that adds a bang i.e. `!` before strings. -pub(crate) fn bangify(body: impl AsRef) -> String { - prefix_lines(body.as_ref(), |_, line| { - if line.chars().all(char::is_whitespace) { - String::from("!") - } else { - String::from("! ") - } - }) -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_prefix_indent() { - 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("- ", " ", "")); - } - - #[test] - fn test_bangify() { - let actual = bangify("hello"); - assert_eq!("! hello", actual); - - let actual = bangify("\n"); - assert_eq!("!\n", actual); - } -} diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index a2cf9f1f..27c0ad25 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -30,6 +30,42 @@ impl<'a> Iterator for LineIterator<'a> { } } +pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { + use std::fmt::Write; + + let lines = LineIterator::from(contents).enumerate().fold( + String::new(), + |mut acc, (line_index, line)| { + let prefix = f(line_index, line); + let _ = write!(acc, "{prefix}{line}"); + acc + }, + ); + + if lines.is_empty() { + f(0, "") + } else { + lines + } +} + +pub(crate) fn prefix_first_rest_lines( + first_prefix: &str, + rest_prefix: &str, + contents: &str, +) -> String { + let first_prefix = String::from(first_prefix); + let rest_prefix = String::from(rest_prefix); + + prefix_lines(contents, move |index, _| { + if index == 0 { + first_prefix.clone() + } else { + rest_prefix.clone() + } + }) +} + #[derive(Debug)] pub(crate) struct LockedWriter { arc: Arc>, @@ -161,4 +197,19 @@ mod test { 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("- ", " ", "")); + } } From 3e3cc80efdd158593ed607de44bc02af3c4a8c9a Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 10:40:38 +0100 Subject: [PATCH 50/99] Remove wildcard import --- libherokubuildpack/src/buildpack_output/style.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index ba10643c..6185ba00 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -1,23 +1,26 @@ -use crate::buildpack_output::ansi_escape::*; +use crate::buildpack_output::ansi_escape; /// Helpers for formatting and colorizing your output. /// Decorate a URL for the build output. #[must_use] pub fn url(contents: impl AsRef) -> String { - colorize_multiline(CYAN, contents) + ansi_escape::colorize_multiline(ansi_escape::CYAN, contents) } /// Decorate the name of a command being run i.e. `bundle install`. #[must_use] pub fn command(contents: impl AsRef) -> String { - value(colorize_multiline(BOLD_CYAN, contents.as_ref())) + value(ansi_escape::colorize_multiline( + ansi_escape::BOLD_CYAN, + contents.as_ref(), + )) } /// Decorate an important value i.e. `2.3.4`. #[must_use] pub fn value(contents: impl AsRef) -> String { - let contents = colorize_multiline(YELLOW, contents.as_ref()); + let contents = ansi_escape::colorize_multiline(ansi_escape::YELLOW, contents.as_ref()); format!("`{contents}`") } From 089964f50e975a5bd7a5ec34dbe2688b29282279 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 10:41:29 +0100 Subject: [PATCH 51/99] Remove const_format --- libherokubuildpack/Cargo.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index e6cd8961..c57a6bed 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -27,7 +27,7 @@ tar = ["dep:tar", "dep:flate2"] toml = ["dep:toml"] fs = ["dep:pathdiff"] command = ["write", "dep:crossbeam-utils"] -buildpack_output = ["dep:const_format"] +buildpack_output = [] write = [] [dependencies] @@ -46,7 +46,6 @@ termcolor = { version = "1.4.0", optional = true } thiserror = { version = "1.0.50", optional = true } toml = { workspace = true, optional = true } ureq = { version = "2.9.1", default-features = false, features = ["tls"], optional = true } -const_format = { version = "0.2", optional = true } [dev-dependencies] tempfile = "3.8.1" From 2d4fc796e01c8248d874644777d6076a040272cc Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 10:57:43 +0100 Subject: [PATCH 52/99] Remove unnecessary must_use attributes --- libherokubuildpack/src/buildpack_output/duration_format.rs | 1 - libherokubuildpack/src/buildpack_output/style.rs | 4 ---- 2 files changed, 5 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/duration_format.rs b/libherokubuildpack/src/buildpack_output/duration_format.rs index d43665c2..89f5c721 100644 --- a/libherokubuildpack/src/buildpack_output/duration_format.rs +++ b/libherokubuildpack/src/buildpack_output/duration_format.rs @@ -1,6 +1,5 @@ use std::time::Duration; -#[must_use] pub(crate) fn human(duration: &Duration) -> String { let hours = (duration.as_secs() / 3600) % 60; let minutes = (duration.as_secs() / 60) % 60; diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 6185ba00..1777c80a 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -3,13 +3,11 @@ use crate::buildpack_output::ansi_escape; /// Helpers for formatting and colorizing your output. /// Decorate a URL for the build output. -#[must_use] pub fn url(contents: impl AsRef) -> String { ansi_escape::colorize_multiline(ansi_escape::CYAN, contents) } /// Decorate the name of a command being run i.e. `bundle install`. -#[must_use] pub fn command(contents: impl AsRef) -> String { value(ansi_escape::colorize_multiline( ansi_escape::BOLD_CYAN, @@ -18,14 +16,12 @@ pub fn command(contents: impl AsRef) -> String { } /// Decorate an important value i.e. `2.3.4`. -#[must_use] pub fn value(contents: impl AsRef) -> String { let contents = ansi_escape::colorize_multiline(ansi_escape::YELLOW, contents.as_ref()); format!("`{contents}`") } /// Decorate additional information at the end of a line. -#[must_use] pub fn details(contents: impl AsRef) -> String { let contents = contents.as_ref(); format!("({contents})") From b3ecc860602b98fa8eb6a44768a47bdf43bdb2b2 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 10:59:34 +0100 Subject: [PATCH 53/99] Simplify BuildpackOutput>::step --- libherokubuildpack/src/buildpack_output/mod.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 6960b194..11dd903a 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -234,13 +234,7 @@ where #[must_use] pub fn step(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, - }, - } + self } pub fn start_stream(mut self, s: impl AsRef) -> BuildpackOutput> { From 17922c30e4e95169008262aa0299483d4b80f7b7 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 11:02:32 +0100 Subject: [PATCH 54/99] Remove BuildpackOutput>::step_mut --- libherokubuildpack/src/buildpack_output/mod.rs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 11dd903a..2bbcbf8e 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -227,10 +227,6 @@ where prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) } - pub fn mut_step(&mut self, s: impl AsRef) { - writeln_now(&mut self.state.write, Self::style(s)); - } - #[must_use] pub fn step(mut self, s: impl AsRef) -> BuildpackOutput> { writeln_now(&mut self.state.write, Self::style(s)); @@ -277,19 +273,16 @@ where writeln_now(&mut self.state.write, ""); - let mut section = BuildpackOutput { + BuildpackOutput { started: self.started, state: state::Section { write: self.state.write.unwrap(), }, - }; - - section.mut_step(&format!( + } + .step(format!( "Done {}", style::details(duration_format::human(&duration)) - )); - - section + )) } } From 3e029e7be180caffa40f228ca8b53f6f70fda09f Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 11:35:50 +0100 Subject: [PATCH 55/99] Rename colorize_multiline to inject_default_ansi_escape, add docs --- .../src/buildpack_output/ansi_escape.rs | 43 +++++++++++-------- .../src/buildpack_output/mod.rs | 4 +- .../src/buildpack_output/style.rs | 7 +-- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/ansi_escape.rs b/libherokubuildpack/src/buildpack_output/ansi_escape.rs index 9bb3754a..ac436482 100644 --- a/libherokubuildpack/src/buildpack_output/ansi_escape.rs +++ b/libherokubuildpack/src/buildpack_output/ansi_escape.rs @@ -1,25 +1,34 @@ -/// Colorizes a body while preserving existing color/reset combinations and clearing before newlines. +/// Smartly injects an ANSI escape sequence as the default into the given string. /// -/// Colors with newlines are a problem since the contents stream to git which prepends `remote:` before the `libcnb_test` -/// if we don't clear, then we will colorize output that isn't ours. +/// All sub sequences of the given string that are not preceded by an ANSI escape sequence other than reset will use +/// the given ANSI escape sequence as the default. /// -/// Explicitly uncolored output is handled by treating `\x1b[1;39m` (`NO_COLOR`) as a distinct case from `\x1b[0m`. -pub(crate) fn colorize_multiline(color: &str, body: impl AsRef) -> String { +/// The given string is allowed to already contain ANSI sequences which will not be overridden by this function. For +/// example, this function can be used to color all text red, but if a word is already colored yellow, that word will +/// continue to be yellow. +/// +/// The given ANSI escape sequence will in injected into each line of the given string separately, followed by a reset +/// at the end of each line. This ensure that any downstream consumers of the resulting string can process it +/// line-by-line without losing context. One example is the `remote: ` prefix that Git adds when streaming output from +/// a buildpack. +pub(crate) fn inject_default_ansi_escape(ansi_escape: &str, body: impl AsRef) -> String { 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}{color}"))) // Handles nested 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!("{color}{line}{RESET}")) + .map(|line| format!("{ansi_escape}{line}{RESET}")) // The above logic causes redundant colors and resets, clean them up - .map(|line| line.replace(&format!("{RESET}{color}{RESET}"), RESET)) - .map(|line| line.replace(&format!("{color}{color}"), color)) // Reduce useless color - .map(|line| line.replace(&format!("{color}{RESET}"), "")) // Do not colorize empty lines + .map(|line| line.replace(&format!("{RESET}{ansi_escape}{RESET}"), RESET)) + .map(|line| line.replace(&format!("{ansi_escape}{ansi_escape}"), ansi_escape)) // Reduce useless color + .map(|line| line.replace(&format!("{ansi_escape}{RESET}"), "")) // Do not colorize empty lines .collect::>() .join("\n") } +pub(crate) const RESET: &str = "\x1B[0m"; + pub(crate) const RED: &str = "\x1B[0;31m"; pub(crate) const YELLOW: &str = "\x1B[0;33m"; pub(crate) const CYAN: &str = "\x1B[0;36m"; @@ -27,8 +36,6 @@ pub(crate) const CYAN: &str = "\x1B[0;36m"; pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // Magenta -pub(crate) const RESET: &str = "\x1B[0m"; - #[cfg(test)] mod test { use super::*; @@ -37,9 +44,9 @@ mod test { fn handles_explicitly_removed_colors() { // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 const NO_COLOR: &str = "\x1B[1;39m"; - let nested = colorize_multiline(NO_COLOR, "nested"); + let nested = inject_default_ansi_escape(NO_COLOR, "nested"); - let out = colorize_multiline(RED, format!("hello {nested} color")); + let out = inject_default_ansi_escape(RED, format!("hello {nested} color")); let expected = format!("{RED}hello {NO_COLOR}nested{RESET}{RED} color{RESET}"); assert_eq!(expected, out); @@ -47,9 +54,9 @@ mod test { #[test] fn handles_nested_colors() { - let nested = colorize_multiline(CYAN, "nested"); + let nested = inject_default_ansi_escape(CYAN, "nested"); - let out = colorize_multiline(RED, format!("hello {nested} color")); + let out = inject_default_ansi_escape(RED, format!("hello {nested} color")); let expected = format!("{RED}hello {CYAN}nested{RESET}{RED} color{RESET}"); assert_eq!(expected, out); @@ -57,7 +64,7 @@ mod test { #[test] fn splits_newlines() { - let actual = colorize_multiline(RED, "hello\nworld"); + let actual = inject_default_ansi_escape(RED, "hello\nworld"); let expected = format!("{RED}hello{RESET}\n{RED}world{RESET}"); assert_eq!(expected, actual); @@ -65,7 +72,7 @@ mod test { #[test] fn simple_case() { - let actual = colorize_multiline(RED, "hello world"); + let actual = inject_default_ansi_escape(RED, "hello world"); assert_eq!(format!("{RED}hello world{RESET}"), actual); } } diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 2bbcbf8e..f436a6f1 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -126,7 +126,7 @@ where writeln_now( io, - ansi_escape::colorize_multiline( + ansi_escape::inject_default_ansi_escape( color, prefix_lines(s.as_ref(), |_, line| { if line.chars().all(char::is_whitespace) { @@ -157,7 +157,7 @@ where pub fn start(mut self, buildpack_name: impl AsRef) -> BuildpackOutput> { writeln_now( &mut self.state.write, - ansi_escape::colorize_multiline( + ansi_escape::inject_default_ansi_escape( BOLD_PURPLE, format!("\n# {}\n", buildpack_name.as_ref()), ), diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 1777c80a..3e196856 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -4,12 +4,12 @@ use crate::buildpack_output::ansi_escape; /// Decorate a URL for the build output. pub fn url(contents: impl AsRef) -> String { - ansi_escape::colorize_multiline(ansi_escape::CYAN, contents) + ansi_escape::inject_default_ansi_escape(ansi_escape::CYAN, contents) } /// Decorate the name of a command being run i.e. `bundle install`. pub fn command(contents: impl AsRef) -> String { - value(ansi_escape::colorize_multiline( + value(ansi_escape::inject_default_ansi_escape( ansi_escape::BOLD_CYAN, contents.as_ref(), )) @@ -17,7 +17,8 @@ pub fn command(contents: impl AsRef) -> String { /// Decorate an important value i.e. `2.3.4`. pub fn value(contents: impl AsRef) -> String { - let contents = ansi_escape::colorize_multiline(ansi_escape::YELLOW, contents.as_ref()); + let contents = + ansi_escape::inject_default_ansi_escape(ansi_escape::YELLOW, contents.as_ref()); format!("`{contents}`") } From 075c46870a1666c8ce82990713aeec58cc98fd12 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 11:41:06 +0100 Subject: [PATCH 56/99] Fix module docs for buildpack_output::style --- libherokubuildpack/src/buildpack_output/style.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 3e196856..41281687 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -1,6 +1,6 @@ -use crate::buildpack_output::ansi_escape; +//! Helpers for formatting and colorizing your output. -/// Helpers for formatting and colorizing your output. +use crate::buildpack_output::ansi_escape; /// Decorate a URL for the build output. pub fn url(contents: impl AsRef) -> String { @@ -17,8 +17,7 @@ pub fn command(contents: impl AsRef) -> String { /// Decorate an important value i.e. `2.3.4`. pub fn value(contents: impl AsRef) -> String { - let contents = - ansi_escape::inject_default_ansi_escape(ansi_escape::YELLOW, contents.as_ref()); + let contents = ansi_escape::inject_default_ansi_escape(ansi_escape::YELLOW, contents.as_ref()); format!("`{contents}`") } From 2f8b8457b4f826b55b0fa65ea98cbe0280914564 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 11:42:45 +0100 Subject: [PATCH 57/99] Rename all state finishing functions to "finish" --- libherokubuildpack/src/buildpack_output/mod.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index f436a6f1..97d58005 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -11,7 +11,7 @@ //! //! output = output //! .section("Ruby version") -//! .end_section(); +//! .finish(); //! //! output.finish(); //! ``` @@ -254,7 +254,7 @@ where } } - pub fn end_section(self) -> BuildpackOutput> { + pub fn finish(self) -> BuildpackOutput> { BuildpackOutput { started: self.started, state: state::Started { @@ -268,7 +268,7 @@ impl BuildpackOutput> where W: Write + Send + Sync + 'static, { - pub fn finish_stream(mut self) -> BuildpackOutput> { + pub fn finish(mut self) -> BuildpackOutput> { let duration = self.state.started.elapsed(); writeln_now(&mut self.state.write, ""); @@ -320,14 +320,14 @@ mod test { let mut stream = BuildpackOutput::new(writer) .start("Heroku Ruby Buildpack") .section("Ruby version `3.1.3` from `Gemfile.lock`") - .end_section() + .finish() .section("Hello world") .start_stream("Streaming stuff"); let value = "stuff".to_string(); writeln!(&mut stream, "{value}").unwrap(); - let io = stream.finish_stream().end_section().finish(); + let io = stream.finish().finish().finish(); let expected = formatdoc! {" @@ -366,7 +366,7 @@ mod test { stream = locked_writer.unwrap(); - let io = stream.finish_stream().end_section().finish(); + let io = stream.finish().finish().finish(); let actual = strip_ansi_escape_sequences(String::from_utf8_lossy(&io)); @@ -382,7 +382,7 @@ mod test { .section("Guest thoughts") .step("The jumping fountains are great") .step("The music is nice here") - .end_section() + .finish() .finish(); let expected = formatdoc! {" @@ -414,7 +414,7 @@ mod test { .warning("It's too crowded here\nI'm tired") .step("The jumping fountains are great") .step("The music is nice here") - .end_section() + .finish() .finish(); let expected = formatdoc! {" @@ -451,7 +451,7 @@ mod test { .warning("I'm tired") .step("The jumping fountains are great") .step("The music is nice here") - .end_section() + .finish() .finish(); let expected = formatdoc! {" From 390f330af144fb883052391f7244008ec048f9d4 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 31 Jan 2024 11:45:10 +0100 Subject: [PATCH 58/99] Update README --- libherokubuildpack/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libherokubuildpack/README.md b/libherokubuildpack/README.md index 3954e651..93aa08f2 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** - From b4e877c7be4ca2165729b22f91fb38a518882743 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Mon, 5 Feb 2024 16:23:30 -0600 Subject: [PATCH 59/99] Apply suggestions from code review Co-authored-by: Ed Morley <501702+edmorley@users.noreply.github.com> --- CHANGELOG.md | 1 - libherokubuildpack/Cargo.toml | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9117fd..b4f913e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `libherokubuildpack`: - `MappedWrite::unwrap` for getting the wrapped `Write` back out. ([#765](https://github.com/heroku/libcnb.rs/pull/765)) -- `libherokubuildpack`: - Added build `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.17.0] - 2023-12-06 diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index c57a6bed..29a4d5a1 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -48,6 +48,6 @@ toml = { workspace = true, optional = true } ureq = { version = "2.9.1", default-features = false, features = ["tls"], optional = true } [dev-dependencies] +indoc = "2.0.4" +libcnb-test = { workspace = true } tempfile = "3.8.1" -libcnb-test = {workspace = true} -indoc = "2" From 1c27a744eb3d7e7ba3c63774e70c570f5f960158 Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 5 Feb 2024 16:21:59 -0600 Subject: [PATCH 60/99] Update CHANGELOG.md to correct module name https://github.com/heroku/libcnb.rs/pull/721#discussion_r1474525156 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f913e0..af612393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `libherokubuildpack`: - `MappedWrite::unwrap` for getting the wrapped `Write` back out. ([#765](https://github.com/heroku/libcnb.rs/pull/765)) - - Added build `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)) + - Added build `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.17.0] - 2023-12-06 From 26a9b09f45849fcc0fdf0dfbedf91a60e89d0a27 Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 5 Feb 2024 16:33:44 -0600 Subject: [PATCH 61/99] Follow rust style guides for .expect() https://doc.rust-lang.org/std/error/index.html#common-message-styles --- libherokubuildpack/src/buildpack_output/util.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 27c0ad25..e1330c7d 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -93,7 +93,9 @@ impl LockedWriter { panic!("Expected buildpack author to not retain any IO streaming IO instances") }; - mutex.into_inner().expect("Output mutex was poisoned") + mutex + .into_inner() + .expect("Thread holding locked writer should not panic") } } @@ -102,12 +104,18 @@ where W: Write + Send + Sync + 'static, { fn write(&mut self, buf: &[u8]) -> std::io::Result { - let mut io = self.arc.lock().expect("Output mutex poisoned"); + 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("Output mutex poisoned"); + let mut io = self + .arc + .lock() + .expect("Thread holding locked writer should not panic"); io.flush() } } From e77275833365b357a08bcb3feb09a329ce4d1d2d Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 5 Feb 2024 17:34:40 -0600 Subject: [PATCH 62/99] Add doctests for `build_output::state` https://github.com/heroku/libcnb.rs/pull/721/files#r1476192478 --- .../src/buildpack_output/mod.rs | 97 ++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 97d58005..d9c43dc1 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -39,28 +39,121 @@ pub struct BuildpackOutput { /// Various states for [`BuildpackOutput`] to contain. /// /// The [`BuildpackOutput`] struct acts as an output state machine. These structs -/// are meant to represent those states. -#[doc(hidden)] +/// 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 it's start. Is represented by the + /// `state::NotStarted` type. This 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("Heroku Ruby Buildpack") + ///} + /// ``` #[derive(Debug)] pub struct NotStarted { pub(crate) write: ParagraphInspectWrite, } + /// After buildpack output has been started, it's top level output will be represented by the + /// `state::Started` type. This 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("Heroku Ruby 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 for providing addiitonal 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("Heroku Ruby 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 `state::Stream` 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 to 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("Heroku Ruby 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>, From f31f660c1c19b817dc65f73d39575e7fc2e5413c Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Mon, 5 Feb 2024 17:43:37 -0600 Subject: [PATCH 63/99] Apply suggestions from code review Co-authored-by: Ed Morley <501702+edmorley@users.noreply.github.com> --- libherokubuildpack/src/buildpack_output/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index d9c43dc1..bfe3779b 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -1,6 +1,6 @@ //! # Buildpack output //! -//! Use the [`BuildpackOutput`] to output structured text as a buildpack is executing. +//! Use [`BuildpackOutput`] to output structured text as a buildpack is executing. //! //! ``` //! use libherokubuildpack::buildpack_output::BuildpackOutput; @@ -160,7 +160,6 @@ pub mod state { } } -#[doc(hidden)] trait AnnounceSupportedState { type Inner: Write; From a3571b683cd0dcd5137d57096db1a9bbc4c18aa7 Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 6 Feb 2024 16:44:07 -0600 Subject: [PATCH 64/99] Add BuildpackOutput docs --- .../src/buildpack_output/README.md | 30 ++++ .../src/buildpack_output/mod.rs | 154 ++++++++++++++---- 2 files changed, 148 insertions(+), 36 deletions(-) create mode 100644 libherokubuildpack/src/buildpack_output/README.md diff --git a/libherokubuildpack/src/buildpack_output/README.md b/libherokubuildpack/src/buildpack_output/README.md new file mode 100644 index 00000000..28e0ea20 --- /dev/null +++ b/libherokubuildpack/src/buildpack_output/README.md @@ -0,0 +1,30 @@ +# 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. + +## See it in action + +Beyond reading about the features, you can see the build output in action (TODO: style guide link). Run it locally by cloning this repo and executing (TODO: style guide command). The text of the style guide has helpful tips, dos and don'ts, and suggestions for helping your buildpack stand out in a good way. diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index bfe3779b..77c560ba 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -1,21 +1,5 @@ -//! # Buildpack output -//! -//! Use [`BuildpackOutput`] to output structured text as a buildpack is executing. -//! -//! ``` -//! use libherokubuildpack::buildpack_output::BuildpackOutput; -//! -//! let mut output = BuildpackOutput::new(std::io::stdout()) -//! .start("Heroku Ruby Buildpack") -//! .warning("No Gemfile.lock found"); -//! -//! output = output -//! .section("Ruby version") -//! .finish(); -//! -//! output.finish(); -//! ``` -//! +#![doc = include_str!("./README.md")] + use crate::buildpack_output::ansi_escape::{BOLD_PURPLE, CYAN, RED, YELLOW}; use crate::buildpack_output::util::{prefix_first_rest_lines, prefix_lines, ParagraphInspectWrite}; use crate::write::line_mapped; @@ -28,7 +12,7 @@ mod duration_format; pub mod style; mod util; -/// See the module docs for example usage. +#[doc = include_str!("./README.md")] #[allow(clippy::module_name_repetitions)] #[derive(Debug)] pub struct BuildpackOutput { @@ -45,8 +29,9 @@ pub mod state { use crate::write::MappedWrite; use std::time::Instant; - /// An initialized buildpack output that has not announced it's start. Is represented by the - /// `state::NotStarted` type. This is transitioned into a `state::Started` type. + /// 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: /// @@ -61,7 +46,7 @@ pub mod state { /// /// fn start_buildpack(mut output: BuildpackOutput>) -> BuildpackOutput> /// where W: Write + Send + Sync + 'static { - /// output.start("Heroku Ruby Buildpack") + /// output.start("Example Buildpack") ///} /// ``` #[derive(Debug)] @@ -69,8 +54,8 @@ pub mod state { pub(crate) write: ParagraphInspectWrite, } - /// After buildpack output has been started, it's top level output will be represented by the - /// `state::Started` type. This is transitioned into a `state::Section` to provide additional + /// 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: @@ -80,7 +65,7 @@ pub mod state { /// use std::io::Write; /// /// let mut output = BuildpackOutput::new(std::io::stdout()) - /// .start("Heroku Ruby Buildpack"); + /// .start("Example Buildpack"); /// /// output = install_ruby(output).finish(); /// @@ -97,8 +82,8 @@ pub mod state { pub(crate) write: ParagraphInspectWrite, } - /// The `state::Section` is intended for providing addiitonal details about the buildpack's - /// actions. When a section is finished it transitions back to a `state::Started` type. + /// The `state::Section` is intended to provide addiitonal 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. @@ -110,7 +95,7 @@ pub mod state { /// use std::io::Write; /// /// let mut output = BuildpackOutput::new(std::io::stdout()) - /// .start("Heroku Ruby Buildpack") + /// .start("Example Buildpack") /// .section("Ruby version"); /// /// install_ruby(output).finish(); @@ -128,18 +113,17 @@ pub mod state { pub(crate) write: ParagraphInspectWrite, } - /// A `state::Stream` is intended for streaming output from a process to the end user. It is + /// 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 to anything that accepts a `std::io::Write`. + /// 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("Heroku Ruby Buildpack") + /// .start("Example Buildpack") /// .section("Ruby version"); /// /// install_ruby(output).finish(); @@ -193,22 +177,58 @@ 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(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; use an [`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(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(CYAN, s); self } - pub fn error(mut self, s: impl AsRef) { - self.write_paragraph(RED, s); - } - fn write_paragraph(&mut self, color: &str, s: impl AsRef) { let io = self.state.write_mut(); @@ -237,6 +257,9 @@ 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. pub fn new(io: W) -> Self { Self { state: state::NotStarted { @@ -246,6 +269,18 @@ where } } + /// 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`]. pub fn start(mut self, buildpack_name: impl AsRef) -> BuildpackOutput> { writeln_now( &mut self.state.write, @@ -258,6 +293,7 @@ where self.start_silent() } + /// Start a buildpack output without announcing the name pub fn start_silent(self) -> BuildpackOutput> { BuildpackOutput { started: Some(Instant::now()), @@ -279,6 +315,14 @@ where prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) } + /// 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)); @@ -291,6 +335,7 @@ where } } + /// 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()); @@ -319,12 +364,44 @@ where prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) } + /// 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`]. 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, ""); @@ -346,6 +423,7 @@ where } } + /// Finish a section and transition back to [`state::Started`]. pub fn finish(self) -> BuildpackOutput> { BuildpackOutput { started: self.started, @@ -360,6 +438,10 @@ 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(mut self) -> BuildpackOutput> { let duration = self.state.started.elapsed(); From 0d8629b44b72ba16a2176471d6a65f4878a96101 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Wed, 7 Feb 2024 17:16:42 -0600 Subject: [PATCH 65/99] Apply suggestions from code review Co-authored-by: Ed Morley <501702+edmorley@users.noreply.github.com> --- .../src/buildpack_output/ansi_escape.rs | 3 +- .../src/buildpack_output/duration_format.rs | 32 +++++++++++++++---- .../src/buildpack_output/style.rs | 4 +-- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/ansi_escape.rs b/libherokubuildpack/src/buildpack_output/ansi_escape.rs index ac436482..91033aa0 100644 --- a/libherokubuildpack/src/buildpack_output/ansi_escape.rs +++ b/libherokubuildpack/src/buildpack_output/ansi_escape.rs @@ -20,9 +20,8 @@ pub(crate) fn inject_default_ansi_escape(ansi_escape: &str, body: impl AsRef>() .join("\n") } diff --git a/libherokubuildpack/src/buildpack_output/duration_format.rs b/libherokubuildpack/src/buildpack_output/duration_format.rs index 89f5c721..49f6e2b9 100644 --- a/libherokubuildpack/src/buildpack_output/duration_format.rs +++ b/libherokubuildpack/src/buildpack_output/duration_format.rs @@ -23,16 +23,34 @@ mod test { #[test] fn test_display_duration() { + let duration = Duration::ZERO; + assert_eq!(human(&duration), "< 0.1s"); + let duration = Duration::from_millis(99); - assert_eq!("< 0.1s", human(&duration).as_str()); + assert_eq!(human(&duration), "< 0.1s"); + + let duration = Duration::from_millis(100); + assert_eq!(human(&duration), "0.100s"); + + let duration = Duration::from_millis(999); + assert_eq!(human(&duration), "0.999s"); + + let duration = Duration::from_millis(1000); + assert_eq!(human(&duration), "1.000s"); + + let duration = Duration::from_millis(60 * 1000 - 1); + assert_eq!(human(&duration), "59.999s"); + + let duration = Duration::from_millis(60 * 1000); + assert_eq!(human(&duration), "1m 0s"); - let duration = Duration::from_millis(1024); - assert_eq!("1.024s", human(&duration).as_str()); + let duration = Duration::from_millis(60 * 60 * 1000 - 1); + assert_eq!(human(&duration), "59m 59s"); - let duration = Duration::from_millis(60 * 1024); - assert_eq!("1m 1s", human(&duration).as_str()); + let duration = Duration::from_millis(60 * 60 * 1000); + assert_eq!(human(&duration), "1h 0m 0s"); - let duration = Duration::from_millis(3600 * 1024); - assert_eq!("1h 1m 26s", human(&duration).as_str()); + let duration = Duration::from_millis(75 * 60 * 1000 - 1); + assert_eq!(human(&duration), "1h 14m 59s"); } } diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 41281687..88880346 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -11,13 +11,13 @@ pub fn url(contents: impl AsRef) -> String { pub fn command(contents: impl AsRef) -> String { value(ansi_escape::inject_default_ansi_escape( ansi_escape::BOLD_CYAN, - contents.as_ref(), + contents, )) } /// Decorate an important value i.e. `2.3.4`. pub fn value(contents: impl AsRef) -> String { - let contents = ansi_escape::inject_default_ansi_escape(ansi_escape::YELLOW, contents.as_ref()); + let contents = ansi_escape::inject_default_ansi_escape(ansi_escape::YELLOW, contents); format!("`{contents}`") } From 7fd64081bd273ecba5e2108eef3977481fee151f Mon Sep 17 00:00:00 2001 From: Schneems Date: Wed, 7 Feb 2024 17:38:23 -0600 Subject: [PATCH 66/99] Update date formatting Normalize on 0.1s for precision https://github.com/heroku/libcnb.rs/pull/721/files#r1479978287 --- .../src/buildpack_output/duration_format.rs | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/duration_format.rs b/libherokubuildpack/src/buildpack_output/duration_format.rs index 49f6e2b9..ba0b842c 100644 --- a/libherokubuildpack/src/buildpack_output/duration_format.rs +++ b/libherokubuildpack/src/buildpack_output/duration_format.rs @@ -5,13 +5,14 @@ pub(crate) fn human(duration: &Duration) -> String { 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}.{milliseconds:0>3}s") + } else if seconds > 0 || milliseconds >= 100 { + format!("{seconds}.{tenths}s") } else { String::from("< 0.1s") } @@ -23,27 +24,33 @@ mod test { #[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.100s"); + assert_eq!(human(&duration), "0.1s"); + + let duration = Duration::from_millis(210); + assert_eq!(human(&duration), "0.2s"); - let duration = Duration::from_millis(999); - assert_eq!(human(&duration), "0.999s"); + let duration = Duration::from_millis(1100); + assert_eq!(human(&duration), "1.1s"); - let duration = Duration::from_millis(1000); - assert_eq!(human(&duration), "1.000s"); + let duration = Duration::from_millis(9100); + assert_eq!(human(&duration), "9.1s"); - let duration = Duration::from_millis(60 * 1000 - 1); - assert_eq!(human(&duration), "59.999s"); + 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"); From 5fdedb50cef1632159512503147587153709729c Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 8 Feb 2024 09:35:27 -0600 Subject: [PATCH 67/99] Add tests for nested ansi cases --- .../src/buildpack_output/ansi_escape.rs | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/ansi_escape.rs b/libherokubuildpack/src/buildpack_output/ansi_escape.rs index 91033aa0..a9b0d4e5 100644 --- a/libherokubuildpack/src/buildpack_output/ansi_escape.rs +++ b/libherokubuildpack/src/buildpack_output/ansi_escape.rs @@ -40,23 +40,44 @@ mod test { use super::*; #[test] - fn handles_explicitly_removed_colors() { - // Differentiate between color clear and explicit no color https://github.com/heroku/buildpacks-ruby/pull/155#discussion_r1260029915 - const NO_COLOR: &str = "\x1B[1;39m"; - let nested = inject_default_ansi_escape(NO_COLOR, "nested"); + fn empty_line() { + let actual = inject_default_ansi_escape(RED, "\n"); + let expected = String::from("\n"); + assert_eq!(expected, actual); + } - let out = inject_default_ansi_escape(RED, format!("hello {nested} color")); - let expected = format!("{RED}hello {NO_COLOR}nested{RESET}{RED} color{RESET}"); + #[test] + fn handles_nested_color_at_start() { + let start = inject_default_ansi_escape(CYAN, "hello"); + let out = inject_default_ansi_escape(RED, format!("{start} world")); + let expected = format!("{RED}{CYAN}hello{RESET}{RED} world{RESET}"); assert_eq!(expected, out); } #[test] - fn handles_nested_colors() { - let nested = inject_default_ansi_escape(CYAN, "nested"); + fn handles_nested_color_in_middle() { + let middle = inject_default_ansi_escape(CYAN, "middle"); + let out = inject_default_ansi_escape(RED, format!("hello {middle} color")); + let expected = format!("{RED}hello {CYAN}middle{RESET}{RED} color{RESET}"); + assert_eq!(expected, out); + } - let out = inject_default_ansi_escape(RED, format!("hello {nested} color")); - let expected = format!("{RED}hello {CYAN}nested{RESET}{RED} color{RESET}"); + #[test] + fn handles_nested_color_at_end() { + let end = inject_default_ansi_escape(CYAN, "world"); + let out = inject_default_ansi_escape(RED, format!("hello {end}")); + let expected = format!("{RED}hello {CYAN}world{RESET}"); + + assert_eq!(expected, out); + } + + #[test] + fn handles_double_nested_color() { + let inner = inject_default_ansi_escape(CYAN, "inner"); + let outer = inject_default_ansi_escape(RED, format!("outer {inner}")); + let out = inject_default_ansi_escape(YELLOW, format!("hello {outer}")); + let expected = format!("{YELLOW}hello {RED}outer {CYAN}inner{RESET}"); assert_eq!(expected, out); } From 71b3af20fbcf62c0bda249bef794d72553ff24a4 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 8 Feb 2024 09:47:45 -0600 Subject: [PATCH 68/99] Remove unnecessary iterator Ed pointed out that split inclusive accomplishes the same end goal as LineIterator https://doc.rust-lang.org/std/str/struct.SplitInclusive.html --- .../src/buildpack_output/util.rs | 48 +------------------ 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index e1330c7d..6ef0edce 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -2,38 +2,10 @@ use std::fmt::Debug; use std::io::Write; use std::sync::{Arc, Mutex}; -/// Iterator yielding every line in a string. Every line includes existing newline character(s). -pub(crate) struct LineIterator<'a> { - input: &'a str, -} - -impl<'a> LineIterator<'a> { - pub(crate) fn from(input: &'a str) -> LineIterator<'a> { - LineIterator { input } - } -} - -impl<'a> Iterator for LineIterator<'a> { - type Item = &'a str; - - #[inline] - fn next(&mut self) -> Option<&'a str> { - if self.input.is_empty() { - return None; - } - - let newline_index = self.input.find('\n').map_or(self.input.len(), |i| i + 1); - - let (line, rest) = self.input.split_at(newline_index); - self.input = rest; - Some(line) - } -} - pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { use std::fmt::Write; - let lines = LineIterator::from(contents).enumerate().fold( + let lines = contents.split_inclusive('\n').enumerate().fold( String::new(), |mut acc, (line_index, line)| { let prefix = f(line_index, line); @@ -158,24 +130,6 @@ impl Write for ParagraphInspectWrite { #[cfg(test)] mod test { use super::*; - use std::fmt::Write; - - #[test] - fn test_lines_with_endings() { - let actual = LineIterator::from("foo\nbar").fold(String::new(), |mut output, line| { - let _ = write!(output, "z{line}"); - output - }); - - assert_eq!("zfoo\nzbar", actual); - - let actual = LineIterator::from("foo\nbar\n").fold(String::new(), |mut output, line| { - let _ = write!(output, "z{line}"); - output - }); - - assert_eq!("zfoo\nzbar\n", actual); - } #[test] #[allow(clippy::write_with_newline)] From b98c6a41d89af19c6619ab5d97c2abea1baba4f5 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 8 Feb 2024 10:08:44 -0600 Subject: [PATCH 69/99] Make LockedWriter #[cfg(test)] move to bottom --- .../src/buildpack_output/util.rs | 76 ++++++++++--------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 6ef0edce..656c6802 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -1,5 +1,7 @@ use std::fmt::Debug; use std::io::Write; + +#[cfg(test)] use std::sync::{Arc, Mutex}; pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { @@ -38,11 +40,48 @@ pub(crate) fn prefix_first_rest_lines( }) } +#[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 { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let newline_count = buf.iter().rev().take_while(|&&c| c == b'\n').count(); + if buf.len() == newline_count { + self.newlines_since_last_char += newline_count; + } else { + self.newlines_since_last_char = 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 { @@ -51,6 +90,7 @@ impl Clone for LockedWriter { } } +#[cfg(test)] impl LockedWriter { #[cfg(test)] pub(crate) fn new(write: W) -> Self { @@ -71,6 +111,7 @@ impl LockedWriter { } } +#[cfg(test)] impl Write for LockedWriter where W: Write + Send + Sync + 'static, @@ -92,41 +133,6 @@ where } } -#[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 { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - let newline_count = buf.iter().rev().take_while(|&&c| c == b'\n').count(); - if buf.len() == newline_count { - self.newlines_since_last_char += newline_count; - } else { - self.newlines_since_last_char = 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)] mod test { use super::*; From ad088781363f21777d0bc75f47fc8d82c3b809c4 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 8 Feb 2024 10:10:49 -0600 Subject: [PATCH 70/99] Re-order functions --- .../src/buildpack_output/util.rs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 656c6802..b6a2adbf 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -4,6 +4,23 @@ use std::io::Write; #[cfg(test)] use std::sync::{Arc, Mutex}; +pub(crate) fn prefix_first_rest_lines( + first_prefix: &str, + rest_prefix: &str, + contents: &str, +) -> String { + let first_prefix = String::from(first_prefix); + let rest_prefix = String::from(rest_prefix); + + prefix_lines(contents, move |index, _| { + if index == 0 { + first_prefix.clone() + } else { + rest_prefix.clone() + } + }) +} + pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { use std::fmt::Write; @@ -23,23 +40,6 @@ pub(crate) fn prefix_lines String>(contents: &str, f: F) - } } -pub(crate) fn prefix_first_rest_lines( - first_prefix: &str, - rest_prefix: &str, - contents: &str, -) -> String { - let first_prefix = String::from(first_prefix); - let rest_prefix = String::from(rest_prefix); - - prefix_lines(contents, move |index, _| { - if index == 0 { - first_prefix.clone() - } else { - rest_prefix.clone() - } - }) -} - #[derive(Debug)] pub(crate) struct ParagraphInspectWrite { pub(crate) inner: W, From 220fa166d8e7d42883ac27061226a630a840b5af Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 8 Feb 2024 11:05:39 -0600 Subject: [PATCH 71/99] Update ANSI color docs --- .../src/buildpack_output/ansi_escape.rs | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/ansi_escape.rs b/libherokubuildpack/src/buildpack_output/ansi_escape.rs index a9b0d4e5..08d86280 100644 --- a/libherokubuildpack/src/buildpack_output/ansi_escape.rs +++ b/libherokubuildpack/src/buildpack_output/ansi_escape.rs @@ -1,17 +1,15 @@ -/// Smartly injects an ANSI escape sequence as the default into the given string. +/// Wraps each line in an ANSI escape sequence while preserving prior ANSI escape sequences. /// -/// All sub sequences of the given string that are not preceded by an ANSI escape sequence other than reset will use -/// the given ANSI escape sequence as the default. +/// ## Why does this exist? /// -/// The given string is allowed to already contain ANSI sequences which will not be overridden by this function. For -/// example, this function can be used to color all text red, but if a word is already colored yellow, that word will -/// continue to be yellow. +/// 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. /// -/// The given ANSI escape sequence will in injected into each line of the given string separately, followed by a reset -/// at the end of each line. This ensure that any downstream consumers of the resulting string can process it -/// line-by-line without losing context. One example is the `remote: ` prefix that Git adds when streaming output from -/// a buildpack. -pub(crate) fn inject_default_ansi_escape(ansi_escape: &str, body: impl AsRef) -> String { +/// ## 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. body.as_ref() .split('\n') // If sub contents are colorized it will contain SUBCOLOR ... RESET. After the reset, From 63c08c82386e7777db5d636a947d47bd7007a025 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 8 Feb 2024 13:53:03 -0600 Subject: [PATCH 72/99] Refactor inject_default_ansi_escape to take enum I was about to write docs about how this was an internal function was only known to work with escape codes we expect, then I realized that would mean my method signature was lying because it says it takes an &str. To make it not lie, I changed it to an enum which we control, and that let me delete the clarifying doc. --- .../src/buildpack_output/ansi_escape.rs | 63 ++++++++++++------- .../src/buildpack_output/mod.rs | 12 ++-- .../src/buildpack_output/style.rs | 8 +-- 3 files changed, 51 insertions(+), 32 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/ansi_escape.rs b/libherokubuildpack/src/buildpack_output/ansi_escape.rs index 08d86280..79abc27d 100644 --- a/libherokubuildpack/src/buildpack_output/ansi_escape.rs +++ b/libherokubuildpack/src/buildpack_output/ansi_escape.rs @@ -10,6 +10,8 @@ /// /// 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 inject_default_ansi_escape(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, @@ -24,14 +26,31 @@ .join("\n") } -pub(crate) const RESET: &str = "\x1B[0m"; +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"; -pub(crate) const RED: &str = "\x1B[0;31m"; -pub(crate) const YELLOW: &str = "\x1B[0;33m"; -pub(crate) const CYAN: &str = "\x1B[0;36m"; +#[derive(Debug)] +#[allow(clippy::upper_case_acronyms)] +pub(crate) enum ANSI { + Red, + Yellow, + BoldCyan, + BoldPurple, +} -pub(crate) const BOLD_CYAN: &str = "\x1B[1;36m"; -pub(crate) const BOLD_PURPLE: &str = "\x1B[1;35m"; // Magenta +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 { @@ -39,50 +58,50 @@ mod test { #[test] fn empty_line() { - let actual = inject_default_ansi_escape(RED, "\n"); + let actual = inject_default_ansi_escape(&ANSI::Red, "\n"); let expected = String::from("\n"); assert_eq!(expected, actual); } #[test] fn handles_nested_color_at_start() { - let start = inject_default_ansi_escape(CYAN, "hello"); - let out = inject_default_ansi_escape(RED, format!("{start} world")); - let expected = format!("{RED}{CYAN}hello{RESET}{RED} world{RESET}"); + let start = inject_default_ansi_escape(&ANSI::BoldCyan, "hello"); + let out = inject_default_ansi_escape(&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 = inject_default_ansi_escape(CYAN, "middle"); - let out = inject_default_ansi_escape(RED, format!("hello {middle} color")); - let expected = format!("{RED}hello {CYAN}middle{RESET}{RED} color{RESET}"); + let middle = inject_default_ansi_escape(&ANSI::BoldCyan, "middle"); + let out = inject_default_ansi_escape(&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 = inject_default_ansi_escape(CYAN, "world"); - let out = inject_default_ansi_escape(RED, format!("hello {end}")); - let expected = format!("{RED}hello {CYAN}world{RESET}"); + let end = inject_default_ansi_escape(&ANSI::BoldCyan, "world"); + let out = inject_default_ansi_escape(&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 = inject_default_ansi_escape(CYAN, "inner"); - let outer = inject_default_ansi_escape(RED, format!("outer {inner}")); - let out = inject_default_ansi_escape(YELLOW, format!("hello {outer}")); - let expected = format!("{YELLOW}hello {RED}outer {CYAN}inner{RESET}"); + let inner = inject_default_ansi_escape(&ANSI::BoldCyan, "inner"); + let outer = inject_default_ansi_escape(&ANSI::Red, format!("outer {inner}")); + let out = inject_default_ansi_escape(&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 = inject_default_ansi_escape(RED, "hello\nworld"); + let actual = inject_default_ansi_escape(&ANSI::Red, "hello\nworld"); let expected = format!("{RED}hello{RESET}\n{RED}world{RESET}"); assert_eq!(expected, actual); @@ -90,7 +109,7 @@ mod test { #[test] fn simple_case() { - let actual = inject_default_ansi_escape(RED, "hello world"); + let actual = inject_default_ansi_escape(&ANSI::Red, "hello world"); assert_eq!(format!("{RED}hello world{RESET}"), actual); } } diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 77c560ba..fb8d2679 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -1,6 +1,6 @@ #![doc = include_str!("./README.md")] -use crate::buildpack_output::ansi_escape::{BOLD_PURPLE, CYAN, RED, YELLOW}; +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; @@ -192,7 +192,7 @@ where /// /// 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(RED, s); + self.write_paragraph(&ANSI::Red, s); } /// Emit a warning message to the end user. @@ -211,7 +211,7 @@ where /// 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(YELLOW, s); + self.write_paragraph(&ANSI::Yellow, s); self } @@ -225,11 +225,11 @@ where /// 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(CYAN, s); + self.write_paragraph(&ANSI::BoldCyan, s); self } - fn write_paragraph(&mut self, color: &str, s: impl AsRef) { + fn write_paragraph(&mut self, color: &ANSI, s: impl AsRef) { let io = self.state.write_mut(); if !io.was_paragraph { @@ -285,7 +285,7 @@ where writeln_now( &mut self.state.write, ansi_escape::inject_default_ansi_escape( - BOLD_PURPLE, + &ANSI::BoldPurple, format!("\n# {}\n", buildpack_name.as_ref()), ), ); diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 88880346..1fc18b8a 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -1,23 +1,23 @@ //! Helpers for formatting and colorizing your output. -use crate::buildpack_output::ansi_escape; +use crate::buildpack_output::ansi_escape::{self, ANSI}; /// Decorate a URL for the build output. pub fn url(contents: impl AsRef) -> String { - ansi_escape::inject_default_ansi_escape(ansi_escape::CYAN, contents) + ansi_escape::inject_default_ansi_escape(&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::inject_default_ansi_escape( - ansi_escape::BOLD_CYAN, + &ANSI::BoldCyan, contents, )) } /// Decorate an important value i.e. `2.3.4`. pub fn value(contents: impl AsRef) -> String { - let contents = ansi_escape::inject_default_ansi_escape(ansi_escape::YELLOW, contents); + let contents = ansi_escape::inject_default_ansi_escape(&ANSI::Yellow, contents); format!("`{contents}`") } From bd7f2cd07567adf91d6d4bd2d3a2fa4996373510 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 8 Feb 2024 13:56:19 -0600 Subject: [PATCH 73/99] Rename function Manuel renamed the function, there was confusion about the word "default" and what exactly this does. Fundamentally this function applies an ansi escape sequence to every (non-empty) line, so let's put that on the tin. --- .../src/buildpack_output/ansi_escape.rs | 26 +++++++++---------- .../src/buildpack_output/mod.rs | 4 +-- .../src/buildpack_output/style.rs | 6 ++--- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/ansi_escape.rs b/libherokubuildpack/src/buildpack_output/ansi_escape.rs index 79abc27d..60bc5e98 100644 --- a/libherokubuildpack/src/buildpack_output/ansi_escape.rs +++ b/libherokubuildpack/src/buildpack_output/ansi_escape.rs @@ -10,7 +10,7 @@ /// /// 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 inject_default_ansi_escape(ansi: &ANSI, body: impl AsRef) -> String { +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') @@ -58,15 +58,15 @@ mod test { #[test] fn empty_line() { - let actual = inject_default_ansi_escape(&ANSI::Red, "\n"); + 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 = inject_default_ansi_escape(&ANSI::BoldCyan, "hello"); - let out = inject_default_ansi_escape(&ANSI::Red, format!("{start} world")); + 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); @@ -74,16 +74,16 @@ mod test { #[test] fn handles_nested_color_in_middle() { - let middle = inject_default_ansi_escape(&ANSI::BoldCyan, "middle"); - let out = inject_default_ansi_escape(&ANSI::Red, format!("hello {middle} color")); + 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 = inject_default_ansi_escape(&ANSI::BoldCyan, "world"); - let out = inject_default_ansi_escape(&ANSI::Red, format!("hello {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); @@ -91,9 +91,9 @@ mod test { #[test] fn handles_double_nested_color() { - let inner = inject_default_ansi_escape(&ANSI::BoldCyan, "inner"); - let outer = inject_default_ansi_escape(&ANSI::Red, format!("outer {inner}")); - let out = inject_default_ansi_escape(&ANSI::Yellow, format!("hello {outer}")); + 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); @@ -101,7 +101,7 @@ mod test { #[test] fn splits_newlines() { - let actual = inject_default_ansi_escape(&ANSI::Red, "hello\nworld"); + 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); @@ -109,7 +109,7 @@ mod test { #[test] fn simple_case() { - let actual = inject_default_ansi_escape(&ANSI::Red, "hello world"); + 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/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index fb8d2679..87d991e9 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -238,7 +238,7 @@ where writeln_now( io, - ansi_escape::inject_default_ansi_escape( + ansi_escape::wrap_ansi_escape_each_line( color, prefix_lines(s.as_ref(), |_, line| { if line.chars().all(char::is_whitespace) { @@ -284,7 +284,7 @@ where pub fn start(mut self, buildpack_name: impl AsRef) -> BuildpackOutput> { writeln_now( &mut self.state.write, - ansi_escape::inject_default_ansi_escape( + ansi_escape::wrap_ansi_escape_each_line( &ANSI::BoldPurple, format!("\n# {}\n", buildpack_name.as_ref()), ), diff --git a/libherokubuildpack/src/buildpack_output/style.rs b/libherokubuildpack/src/buildpack_output/style.rs index 1fc18b8a..08deb109 100644 --- a/libherokubuildpack/src/buildpack_output/style.rs +++ b/libherokubuildpack/src/buildpack_output/style.rs @@ -4,12 +4,12 @@ use crate::buildpack_output::ansi_escape::{self, ANSI}; /// Decorate a URL for the build output. pub fn url(contents: impl AsRef) -> String { - ansi_escape::inject_default_ansi_escape(&ANSI::BoldCyan, contents) + 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::inject_default_ansi_escape( + value(ansi_escape::wrap_ansi_escape_each_line( &ANSI::BoldCyan, contents, )) @@ -17,7 +17,7 @@ pub fn command(contents: impl AsRef) -> String { /// Decorate an important value i.e. `2.3.4`. pub fn value(contents: impl AsRef) -> String { - let contents = ansi_escape::inject_default_ansi_escape(&ANSI::Yellow, contents); + let contents = ansi_escape::wrap_ansi_escape_each_line(&ANSI::Yellow, contents); format!("`{contents}`") } From 699304675396b339cc0acdc55dfb5c3c5e6eb630 Mon Sep 17 00:00:00 2001 From: Schneems Date: Thu, 8 Feb 2024 22:11:12 -0500 Subject: [PATCH 74/99] Move module docs from file to the code --- .../src/buildpack_output/README.md | 30 ------------ .../src/buildpack_output/mod.rs | 49 ++++++++++++++++++- 2 files changed, 47 insertions(+), 32 deletions(-) delete mode 100644 libherokubuildpack/src/buildpack_output/README.md diff --git a/libherokubuildpack/src/buildpack_output/README.md b/libherokubuildpack/src/buildpack_output/README.md deleted file mode 100644 index 28e0ea20..00000000 --- a/libherokubuildpack/src/buildpack_output/README.md +++ /dev/null @@ -1,30 +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. - -## See it in action - -Beyond reading about the features, you can see the build output in action (TODO: style guide link). Run it locally by cloning this repo and executing (TODO: style guide command). The text of the style guide has helpful tips, dos and don'ts, and suggestions for helping your buildpack stand out in a good way. diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 87d991e9..d1b3f9ba 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -1,4 +1,33 @@ -#![doc = include_str!("./README.md")] +//! # 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. +//! +//! ## See it in action +//! +//! Beyond reading about the features, you can see the build output in action (TODO: style guide link). Run it locally by cloning this repo and executing (TODO: style guide command). The text of the style guide has helpful tips, dos and don'ts, and suggestions for helping your buildpack stand out in a good way. use crate::buildpack_output::ansi_escape::ANSI; use crate::buildpack_output::util::{prefix_first_rest_lines, prefix_lines, ParagraphInspectWrite}; @@ -12,7 +41,23 @@ mod duration_format; pub mod style; mod util; -#[doc = include_str!("./README.md")] +/// # 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(); +/// ``` #[allow(clippy::module_name_repetitions)] #[derive(Debug)] pub struct BuildpackOutput { From f620ce37b7c7dbcf245ecf113eccca6bcde68ed8 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Sun, 11 Feb 2024 08:25:51 -0500 Subject: [PATCH 75/99] Apply suggestions from code review Co-authored-by: Ed Morley <501702+edmorley@users.noreply.github.com> --- .../src/buildpack_output/duration_format.rs | 2 ++ .../src/buildpack_output/mod.rs | 16 ++++------ .../src/buildpack_output/util.rs | 29 +++++++------------ 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/duration_format.rs b/libherokubuildpack/src/buildpack_output/duration_format.rs index ba0b842c..fd04093b 100644 --- a/libherokubuildpack/src/buildpack_output/duration_format.rs +++ b/libherokubuildpack/src/buildpack_output/duration_format.rs @@ -24,6 +24,8 @@ mod test { #[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"); diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index d1b3f9ba..8fd7e30d 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -24,10 +24,6 @@ //! ## 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. -//! -//! ## See it in action -//! -//! Beyond reading about the features, you can see the build output in action (TODO: style guide link). Run it locally by cloning this repo and executing (TODO: style guide command). The text of the style guide has helpful tips, dos and don'ts, and suggestions for helping your buildpack stand out in a good way. use crate::buildpack_output::ansi_escape::ANSI; use crate::buildpack_output::util::{prefix_first_rest_lines, prefix_lines, ParagraphInspectWrite}; @@ -41,8 +37,6 @@ mod duration_format; pub mod style; mod util; -/// # 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 @@ -127,7 +121,7 @@ pub mod state { pub(crate) write: ParagraphInspectWrite, } - /// The `state::Section` is intended to provide addiitonal details about the buildpack's + /// 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 @@ -222,7 +216,7 @@ impl BuildpackOutput where S: AnnounceSupportedState, { - /// Emit an error and end the build output + /// 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. @@ -286,7 +280,7 @@ where ansi_escape::wrap_ansi_escape_each_line( color, prefix_lines(s.as_ref(), |_, line| { - if line.chars().all(char::is_whitespace) { + if line.is_empty() { String::from("!") } else { String::from("! ") @@ -338,7 +332,7 @@ where self.start_silent() } - /// Start a buildpack output without announcing the name + /// Start a buildpack output without announcing the name. pub fn start_silent(self) -> BuildpackOutput> { BuildpackOutput { started: Some(Instant::now()), @@ -437,7 +431,7 @@ where self } - /// Stream output to the end user + /// 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. diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index b6a2adbf..28b923e4 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -9,14 +9,11 @@ pub(crate) fn prefix_first_rest_lines( rest_prefix: &str, contents: &str, ) -> String { - let first_prefix = String::from(first_prefix); - let rest_prefix = String::from(rest_prefix); - prefix_lines(contents, move |index, _| { if index == 0 { - first_prefix.clone() + String::from(first_prefix) } else { - rest_prefix.clone() + String::from(rest_prefix) } }) } @@ -24,19 +21,17 @@ pub(crate) fn prefix_first_rest_lines( pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { use std::fmt::Write; - let lines = contents.split_inclusive('\n').enumerate().fold( - String::new(), - |mut acc, (line_index, line)| { - let prefix = f(line_index, line); - let _ = write!(acc, "{prefix}{line}"); - acc - }, - ); - - if lines.is_empty() { + if contents.is_empty() { f(0, "") } else { - lines + contents.split_inclusive('\n').enumerate().fold( + String::new(), + |mut acc, (line_index, line)| { + let prefix = f(line_index, line); + let _ = write!(acc, "{prefix}{line}"); + acc + }, + ) } } @@ -92,14 +87,12 @@ impl Clone for LockedWriter { #[cfg(test)] impl LockedWriter { - #[cfg(test)] pub(crate) fn new(write: W) -> Self { LockedWriter { arc: Arc::new(Mutex::new(write)), } } - #[cfg(test)] 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") From 59930334dde796f339d25e5361e2a443b2af39ef Mon Sep 17 00:00:00 2001 From: Schneems Date: Sun, 11 Feb 2024 08:54:20 -0500 Subject: [PATCH 76/99] Add warning language Distinguish that non-disable able warnings are permissible, that they don't have to always be upgraded to errors. --- libherokubuildpack/src/buildpack_output/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 8fd7e30d..410ac967 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -245,7 +245,7 @@ where /// /// 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; use an [`BuildpackOutput::error`] instead + /// 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] From 5b8896932c4f05d862d4f648ccbaefd9d247a453 Mon Sep 17 00:00:00 2001 From: Schneems Date: Sun, 11 Feb 2024 08:56:18 -0500 Subject: [PATCH 77/99] Specify various streaming states must be used --- libherokubuildpack/src/buildpack_output/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 410ac967..25352ece 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -299,6 +299,7 @@ where /// 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 { @@ -320,6 +321,7 @@ where /// 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, @@ -333,6 +335,7 @@ where } /// Start a buildpack output without announcing the name. + #[must_use] pub fn start_silent(self) -> BuildpackOutput> { BuildpackOutput { started: Some(Instant::now()), @@ -441,6 +444,7 @@ where /// 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, ""); From 912e53f329baa95d2d0b0e48388b4cedc8f878d3 Mon Sep 17 00:00:00 2001 From: Schneems Date: Sun, 11 Feb 2024 09:49:42 -0500 Subject: [PATCH 78/99] Test important and error buildpack output --- .../src/buildpack_output/mod.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 25352ece..d01c93c2 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -530,6 +530,53 @@ mod test { use crate::command::CommandExt; use indoc::formatdoc; use libcnb_test::assert_contains; + use std::fs::File; + + #[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() { From 3240465e99d63dee9035a0c8bbc1963dcbd2c0de Mon Sep 17 00:00:00 2001 From: Schneems Date: Sun, 11 Feb 2024 10:29:32 -0500 Subject: [PATCH 79/99] Test color codes for warning/important/etc. --- .../src/buildpack_output/mod.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index d01c93c2..284003ce 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -532,6 +532,32 @@ mod test { use libcnb_test::assert_contains; use std::fs::File; + #[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(); From 6abd930f1f134f6fe737e1ff81738e68b3ff76eb Mon Sep 17 00:00:00 2001 From: Schneems Date: Sun, 11 Feb 2024 10:31:52 -0500 Subject: [PATCH 80/99] Remove confusing fmt::Write usage This previous code was a result of a clippy suggestion. Ed has a different way to re-write it that doesn't trigger clippy, let's use it. --- libherokubuildpack/src/buildpack_output/util.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 28b923e4..1b89c7ee 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -19,19 +19,17 @@ pub(crate) fn prefix_first_rest_lines( } pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { - use std::fmt::Write; - if contents.is_empty() { f(0, "") } else { - contents.split_inclusive('\n').enumerate().fold( - String::new(), - |mut acc, (line_index, line)| { + contents + .split_inclusive('\n') + .enumerate() + .map(|(line_index, line)| { let prefix = f(line_index, line); - let _ = write!(acc, "{prefix}{line}"); - acc - }, - ) + prefix + line + }) + .collect() } } From 167cdc7192da62786e29f3472fe0211880b38ceb Mon Sep 17 00:00:00 2001 From: Schneems Date: Sun, 11 Feb 2024 10:44:29 -0500 Subject: [PATCH 81/99] Add docs and tests for ParagraphInspectWrite --- .../src/buildpack_output/util.rs | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 1b89c7ee..83c70736 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -33,6 +33,14 @@ pub(crate) fn prefix_lines String>(contents: &str, f: F) - } } +/// 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, @@ -51,12 +59,15 @@ impl ParagraphInspectWrite { } 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 newline_count = buf.iter().rev().take_while(|&&c| c == b'\n').count(); - if buf.len() == newline_count { - self.newlines_since_last_char += newline_count; + 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 = newline_count; + self.newlines_since_last_char = trailing_newline_count; } self.was_paragraph = self.newlines_since_last_char > 1; @@ -135,7 +146,15 @@ mod test { 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(); From ddb6bbfeebe9b4e6475c91b8e18989380ade614b Mon Sep 17 00:00:00 2001 From: Schneems Date: Sun, 11 Feb 2024 10:49:43 -0500 Subject: [PATCH 82/99] Assert prefix_first_rest_lines preserves inner newlines --- libherokubuildpack/src/buildpack_output/util.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 83c70736..44406567 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -189,5 +189,10 @@ mod test { ); assert_eq!("- ", &prefix_first_rest_lines("- ", " ", "")); + + assert_eq!( + "- hello\n \n world", + &prefix_first_rest_lines("- ", " ", "hello\n\nworld") + ); } } From 8be7f9069f71c1ff9505ed7b3f3d64569201cc0c Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 12 Feb 2024 11:58:40 -0600 Subject: [PATCH 83/99] Add test for empty line case Funny enough, the original logic to not append a space when the line is empty was added because my editor has "trim trailing whitespace" and it wasn't possible for me to make a heredoc test case with a trailing space (without changing my editor settings which I really didn't want to do). In adding the test I'm now needing to revert https://github.com/heroku/libcnb.rs/commit/f620ce37b7c7dbcf245ecf113eccca6bcde68ed8#diff-811be8ca19faa4272e51de5beb3f1f9b088cfeeb65055d7a185dced3d071b5beL289 as we are asserting against that logic again. It's good to have in there for future testing and consistency. It makes me wonder somewhat if that constraint (not trailing whitespace except for newlines) would be better enforced somewhere else, say in `prefix_lines` or friends. But I don't feel it's worth it to make that check everywhere if this is the only place it's needed. We can always do that later. --- .../src/buildpack_output/mod.rs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 284003ce..18c0a435 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -280,7 +280,7 @@ where ansi_escape::wrap_ansi_escape_each_line( color, prefix_lines(s.as_ref(), |_, line| { - if line.is_empty() { + if line.chars().all(char::is_whitespace) { String::from("!") } else { String::from("! ") @@ -532,6 +532,30 @@ mod test { use libcnb_test::assert_contains; use std::fs::File; + #[test] + fn write_paragraph_empty_lines() { + let io = BuildpackOutput::new(Vec::new()) + .start("Example Buildpack") + .warning("hello\n\nworld") + .finish(); + + let expected = formatdoc! {" + + # Example Buildpack + + ! hello + ! + ! world + + - 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(); From acf3f77cbdcb92f6ed697f976abaed10ad2ca156 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Mon, 12 Feb 2024 12:59:41 -0500 Subject: [PATCH 84/99] Apply suggestions from code review Co-authored-by: Ed Morley <501702+edmorley@users.noreply.github.com> --- libherokubuildpack/src/buildpack_output/duration_format.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/libherokubuildpack/src/buildpack_output/duration_format.rs b/libherokubuildpack/src/buildpack_output/duration_format.rs index fd04093b..927e572e 100644 --- a/libherokubuildpack/src/buildpack_output/duration_format.rs +++ b/libherokubuildpack/src/buildpack_output/duration_format.rs @@ -26,6 +26,7 @@ mod 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"); From ab5d9a5579b3d9b1a7b54da80f27c9134f0d9b3b Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 12 Feb 2024 15:52:28 -0600 Subject: [PATCH 85/99] Document, test, and refactor prefix functions There was a request that I document why the `contents.is_empty()` logic is present. I think it makes more logical sense to encode that information in the code, so I refactored to use a peekable iterator instead. Anyone who does not want to apply a prefix to an empty slice can do so via their custom function passed to `prefix_lines`. I added docs for the `prefix_first_rest_lines` to call out the edge case of calling with an empty slice. --- .../src/buildpack_output/util.rs | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 44406567..83251ef5 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -4,6 +4,14 @@ 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, @@ -18,12 +26,19 @@ pub(crate) fn prefix_first_rest_lines( }) } +/// 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, the function will be called with a zero index and an empty slice. pub(crate) fn prefix_lines String>(contents: &str, f: F) -> String { - if contents.is_empty() { + let mut lines = contents.split_inclusive('\n').peekable(); + + if lines.peek().is_none() { f(0, "") } else { - contents - .split_inclusive('\n') + lines .enumerate() .map(|(line_index, line)| { let prefix = f(line_index, line); @@ -195,4 +210,19 @@ mod test { &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("- "))); + } } From f3f6e39876c33fcd461f3ce1abd7ac03c1a76688 Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 12 Feb 2024 16:47:23 -0600 Subject: [PATCH 86/99] Remove `log` and `error` features The build_output supersedes this logging functionality. Removing this allows us to get rid of a barely used dependency https://github.com/BurntSushi/termcolor/blob/71f0921f1eeceda85487098588a1602979d52493/src/lib.rs#L2572. --- libherokubuildpack/Cargo.toml | 5 +-- libherokubuildpack/src/error.rs | 69 ------------------------------ libherokubuildpack/src/lib.rs | 4 -- libherokubuildpack/src/log.rs | 74 --------------------------------- 4 files changed, 1 insertion(+), 151 deletions(-) delete mode 100644 libherokubuildpack/src/error.rs delete mode 100644 libherokubuildpack/src/log.rs diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index 29a4d5a1..35958bf7 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -18,11 +18,9 @@ all-features = true workspace = true [features] -default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write", "buildpack_output"] +default = ["command", "download", "digest", "tar", "toml", "fs", "write", "buildpack_output"] download = ["dep:ureq", "dep:thiserror"] digest = ["dep:sha2"] -error = ["log", "dep:libcnb"] -log = ["dep:termcolor"] tar = ["dep:tar", "dep:flate2"] toml = ["dep:toml"] fs = ["dep:pathdiff"] @@ -42,7 +40,6 @@ libcnb = { workspace = true, optional = true } pathdiff = { version = "0.2.1", optional = true } sha2 = { version = "0.10.8", optional = true } tar = { version = "0.4.40", default-features = false, optional = true } -termcolor = { version = "1.4.0", optional = true } thiserror = { version = "1.0.50", optional = true } toml = { workspace = true, optional = true } ureq = { version = "2.9.1", default-features = false, features = ["tls"], optional = true } diff --git a/libherokubuildpack/src/error.rs b/libherokubuildpack/src/error.rs deleted file mode 100644 index 70efbff0..00000000 --- a/libherokubuildpack/src/error.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crate::log::log_error; -use std::fmt::Debug; - -/// Handles a given [`libcnb::Error`] in a consistent style. -/// -/// This function is intended to be used inside [`libcnb::Buildpack::on_error`]. -/// -/// It outputs generic libcnb errors in a consistent style using the [logging functions](log_error) from this -/// crate. Buildpack specific errors are handled by the passed custom handler. -/// -/// # Example: -/// ``` -/// use libcnb::build::{BuildContext, BuildResult}; -/// use libcnb::Buildpack; -/// use libcnb::detect::{DetectContext, DetectResult}; -/// use libcnb::generic::{GenericMetadata, GenericPlatform}; -/// use libherokubuildpack::log::log_error; -/// use libherokubuildpack::error::on_error; -/// -/// #[derive(Debug)] -/// enum FooBuildpackError { -/// CannotExecuteFooBuildTool(std::io::Error), -/// InvalidFooDescriptorToml -/// } -/// -/// fn on_foo_buildpack_error(e: FooBuildpackError) { -/// match e { -/// FooBuildpackError::InvalidFooDescriptorToml => { -/// log_error("Invalid foo.toml", "Your app's foo.toml is invalid!"); -/// } -/// FooBuildpackError::CannotExecuteFooBuildTool(inner) => { -/// log_error("Couldn't execute foo build tool", format!("Cause: {}", &inner)); -/// } -/// } -/// } -/// -/// struct FooBuildpack; -/// -/// impl Buildpack for FooBuildpack { -/// type Platform = GenericPlatform; -/// type Metadata = GenericMetadata; -/// type Error = FooBuildpackError; -/// -/// // Omitted detect and build implementations... -/// # fn detect(&self, context: DetectContext) -> libcnb::Result { -/// # unimplemented!() -/// # } -/// # -/// # fn build(&self, context: BuildContext) -> libcnb::Result { -/// # unimplemented!() -/// # } -/// -/// fn on_error(&self, error: libcnb::Error) { -/// on_error(on_foo_buildpack_error, error) -/// } -/// } -/// ``` -pub fn on_error(f: F, error: libcnb::Error) -where - E: Debug, - F: Fn(E), -{ - match error { - libcnb::Error::BuildpackError(buildpack_error) => f(buildpack_error), - libcnb_error => { - log_error("Internal Buildpack Error", libcnb_error.to_string()); - } - } -} diff --git a/libherokubuildpack/src/lib.rs b/libherokubuildpack/src/lib.rs index c742a325..27ffb187 100644 --- a/libherokubuildpack/src/lib.rs +++ b/libherokubuildpack/src/lib.rs @@ -8,12 +8,8 @@ pub mod command; pub mod digest; #[cfg(feature = "download")] pub mod download; -#[cfg(feature = "error")] -pub mod error; #[cfg(feature = "fs")] pub mod fs; -#[cfg(feature = "log")] -pub mod log; #[cfg(feature = "tar")] pub mod tar; #[cfg(feature = "toml")] diff --git a/libherokubuildpack/src/log.rs b/libherokubuildpack/src/log.rs deleted file mode 100644 index 9e270e27..00000000 --- a/libherokubuildpack/src/log.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::io::Write; -use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; - -/// # Panics -/// -/// Will panic if there was a problem setting the color settings, or all bytes could -/// not be written due to either I/O errors or EOF being reached. -// TODO: Replace `.unwrap()` usages with `.expect()` to give a clearer error message: -// https://github.com/heroku/libcnb.rs/issues/712 -#[allow(clippy::unwrap_used)] -pub fn log_error(header: impl AsRef, body: impl AsRef) { - let mut stream = StandardStream::stderr(ColorChoice::Always); - stream - .set_color(ColorSpec::new().set_fg(Some(Color::Red)).set_bold(true)) - .unwrap(); - writeln!(&mut stream, "\n[Error: {}]", header.as_ref()).unwrap(); - stream.reset().unwrap(); - - stream - .set_color(ColorSpec::new().set_fg(Some(Color::Red))) - .unwrap(); - writeln!(&mut stream, "{}", body.as_ref()).unwrap(); - stream.flush().unwrap(); -} - -/// # Panics -/// -/// Will panic if there was a problem setting the color settings, or all bytes could -/// not be written due to either I/O errors or EOF being reached. -// TODO: Replace `.unwrap()` usages with `.expect()` to give a clearer error message: -// https://github.com/heroku/libcnb.rs/issues/712 -#[allow(clippy::unwrap_used)] -pub fn log_warning(header: impl AsRef, body: impl AsRef) { - let mut stream = StandardStream::stderr(ColorChoice::Always); - stream - .set_color(ColorSpec::new().set_fg(Some(Color::Yellow)).set_bold(true)) - .unwrap(); - writeln!(&mut stream, "\n[Warning: {}]", header.as_ref()).unwrap(); - stream.reset().unwrap(); - - stream - .set_color(ColorSpec::new().set_fg(Some(Color::Yellow))) - .unwrap(); - writeln!(&mut stream, "{}", body.as_ref()).unwrap(); - stream.flush().unwrap(); -} - -/// # Panics -/// -/// Will panic if there was a problem setting the color settings, or all bytes could -/// not be written due to either I/O errors or EOF being reached. -// TODO: Replace `.unwrap()` usages with `.expect()` to give a clearer error message: -// https://github.com/heroku/libcnb.rs/issues/712 -#[allow(clippy::unwrap_used)] -pub fn log_header(title: impl AsRef) { - let mut stream = StandardStream::stdout(ColorChoice::Always); - stream - .set_color(ColorSpec::new().set_fg(Some(Color::Magenta)).set_bold(true)) - .unwrap(); - writeln!(&mut stream, "\n[{}]", title.as_ref()).unwrap(); - stream.reset().unwrap(); - stream.flush().unwrap(); -} - -/// # Panics -/// -/// Will panic if all bytes could not be written due to I/O errors or EOF being reached. -// TODO: Replace `.unwrap()` usages with `.expect()` to give a clearer error message: -// https://github.com/heroku/libcnb.rs/issues/712 -#[allow(clippy::unwrap_used)] -pub fn log_info(message: impl AsRef) { - println!("{}", message.as_ref()); - std::io::stdout().flush().unwrap(); -} From a76c4907fb76a54cb6b0f2d2cbdd05dfd31eb8c7 Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 12 Feb 2024 16:54:03 -0600 Subject: [PATCH 87/99] Update changelog --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46bad406..c750c804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `libherokubuildpack`: + - Removed `error` and `log` modules in favor of `buildpack_output`. ([#721](https://github.com/heroku/libcnb.rs/pull/721)) + +### Added + +- `libherokubuildpack`: + - Added build `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 @@ -25,7 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `libherokubuildpack`: - `MappedWrite::unwrap` for getting the wrapped `Write` back out. ([#765](https://github.com/heroku/libcnb.rs/pull/765)) - - Added build `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)) ### Removed From 55868b31b381029641dda5bf73d6d58cd0c5fef6 Mon Sep 17 00:00:00 2001 From: Schneems Date: Mon, 12 Feb 2024 21:52:50 -0600 Subject: [PATCH 88/99] Revert removing error, only remove log This preserves the existing error interface but it now prints using the buildpack output instead of the old `log` module. --- CHANGELOG.md | 2 +- libherokubuildpack/Cargo.toml | 2 +- libherokubuildpack/src/error.rs | 72 +++++++++++++++++++++++++++++++++ libherokubuildpack/src/lib.rs | 2 + 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 libherokubuildpack/src/error.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c750c804..79e2bbce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - `libherokubuildpack`: - - Removed `error` and `log` modules in favor of `buildpack_output`. ([#721](https://github.com/heroku/libcnb.rs/pull/721)) + - Removed the `log` module in favor of `buildpack_output`. ([#721](https://github.com/heroku/libcnb.rs/pull/721)) ### Added diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index c5d7ef78..4ed4c4d2 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -21,6 +21,7 @@ workspace = true default = ["command", "download", "digest", "tar", "toml", "fs", "write", "buildpack_output"] download = ["dep:ureq", "dep:thiserror"] digest = ["dep:sha2"] +error = ["buildpack_output", "dep:libcnb"] tar = ["dep:tar", "dep:flate2"] toml = ["dep:toml"] fs = ["dep:pathdiff"] @@ -40,7 +41,6 @@ libcnb = { workspace = true, optional = true } pathdiff = { version = "0.2.1", optional = true } sha2 = { version = "0.10.8", optional = true } tar = { version = "0.4.40", default-features = false, optional = true } -termcolor = { version = "1.4.1", optional = true } thiserror = { version = "1.0.57", optional = true } toml = { workspace = true, optional = true } ureq = { version = "2.9.5", default-features = false, features = ["tls"], optional = true } diff --git a/libherokubuildpack/src/error.rs b/libherokubuildpack/src/error.rs new file mode 100644 index 00000000..ee61f362 --- /dev/null +++ b/libherokubuildpack/src/error.rs @@ -0,0 +1,72 @@ +use crate::buildpack_output::BuildpackOutput; +use std::fmt::Debug; + +/// Handles a given [`libcnb::Error`] in a consistent style. +/// +/// This function is intended to be used inside [`libcnb::Buildpack::on_error`]. +/// +/// It outputs generic libcnb errors in a consistent style using [`BuildpackOutput`] from this +/// crate. Buildpack specific errors are handled by the passed custom handler. +/// +/// # Example: +/// ``` +/// use libcnb::build::{BuildContext, BuildResult}; +/// use libcnb::Buildpack; +/// use libcnb::detect::{DetectContext, DetectResult}; +/// use libcnb::generic::{GenericMetadata, GenericPlatform}; +/// use libherokubuildpack::buildpack_output::BuildpackOutput; +/// use libherokubuildpack::error::on_error; +/// +/// #[derive(Debug)] +/// enum FooBuildpackError { +/// CannotExecuteFooBuildTool(std::io::Error), +/// InvalidFooDescriptorToml +/// } +/// +/// fn on_foo_buildpack_error(e: FooBuildpackError) { +/// let output = BuildpackOutput::new(std::io::stdout()).start_silent(); +/// match e { +/// FooBuildpackError::InvalidFooDescriptorToml => { +/// output.error("Invalid foo.toml\n\nYour app's foo.toml is invalid!"); +/// } +/// FooBuildpackError::CannotExecuteFooBuildTool(inner) => { +/// output.error(format!("Couldn't execute foo build tool\n\nYour app's foo.toml is invalid!\n\nCause: {}", &inner)); +/// } +/// } +/// } +/// +/// struct FooBuildpack; +/// +/// impl Buildpack for FooBuildpack { +/// type Platform = GenericPlatform; +/// type Metadata = GenericMetadata; +/// type Error = FooBuildpackError; +/// +/// // Omitted detect and build implementations... +/// # fn detect(&self, context: DetectContext) -> libcnb::Result { +/// # unimplemented!() +/// # } +/// # +/// # fn build(&self, context: BuildContext) -> libcnb::Result { +/// # unimplemented!() +/// # } +/// +/// fn on_error(&self, error: libcnb::Error) { +/// on_error(on_foo_buildpack_error, error) +/// } +/// } +/// ``` +pub fn on_error(f: F, error: libcnb::Error) +where + E: Debug, + F: Fn(E), +{ + match error { + libcnb::Error::BuildpackError(buildpack_error) => f(buildpack_error), + libcnb_error => { + BuildpackOutput::new(std::io::stdout()) + .start_silent() + .error(format!("Internal Buildpack Error\n\n{libcnb_error}")); + } + } +} diff --git a/libherokubuildpack/src/lib.rs b/libherokubuildpack/src/lib.rs index 27ffb187..c4b23dcd 100644 --- a/libherokubuildpack/src/lib.rs +++ b/libherokubuildpack/src/lib.rs @@ -8,6 +8,8 @@ pub mod command; pub mod digest; #[cfg(feature = "download")] pub mod download; +#[cfg(feature = "error")] +pub mod error; #[cfg(feature = "fs")] pub mod fs; #[cfg(feature = "tar")] From 1da805e40c848d3359305a5da1c8cdc3eeb04148 Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Tue, 13 Feb 2024 11:30:07 +0000 Subject: [PATCH 89/99] Revert removing `log` and changes to `on_error` We still want the `log` feature to exist until all buildpacks have transitioned over to `build_output`, since: 1. It will ease the transition for repositories where there are multiple buildpacks, since each buildpack can be migrated to `build_output` individually, rather than needing to do all of them at once. 2. It means any buildpacks that cannot switch to `build_output` right away, can still stay up to date with other libcnb changes. The change to `on_error` has also been reverted, since: 1. It's a change that's standalone from introducing the `build_output` module (ie: lets first introduce a new thing, then afterwards handle migrating things to use it) 2. It's otherwise yet another thing we need to review in this already very-long lived PR, and it would be best to avoid further delays. (I can see at least one issue with the change that would need fixing, and I haven't even reviewed it fully yet.) --- CHANGELOG.md | 5 --- libherokubuildpack/Cargo.toml | 6 ++- libherokubuildpack/src/error.rs | 15 +++---- libherokubuildpack/src/lib.rs | 2 + libherokubuildpack/src/log.rs | 74 +++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 libherokubuildpack/src/log.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e2bbce..bd8e5950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- `libherokubuildpack`: - - Removed the `log` module in favor of `buildpack_output`. ([#721](https://github.com/heroku/libcnb.rs/pull/721)) - ### Added - `libherokubuildpack`: diff --git a/libherokubuildpack/Cargo.toml b/libherokubuildpack/Cargo.toml index 4ed4c4d2..6056fb52 100644 --- a/libherokubuildpack/Cargo.toml +++ b/libherokubuildpack/Cargo.toml @@ -18,10 +18,11 @@ all-features = true workspace = true [features] -default = ["command", "download", "digest", "tar", "toml", "fs", "write", "buildpack_output"] +default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write", "buildpack_output"] download = ["dep:ureq", "dep:thiserror"] digest = ["dep:sha2"] -error = ["buildpack_output", "dep:libcnb"] +error = ["log", "dep:libcnb"] +log = ["dep:termcolor"] tar = ["dep:tar", "dep:flate2"] toml = ["dep:toml"] fs = ["dep:pathdiff"] @@ -41,6 +42,7 @@ libcnb = { workspace = true, optional = true } pathdiff = { version = "0.2.1", optional = true } sha2 = { version = "0.10.8", optional = true } tar = { version = "0.4.40", default-features = false, optional = true } +termcolor = { version = "1.4.1", optional = true } thiserror = { version = "1.0.57", optional = true } toml = { workspace = true, optional = true } ureq = { version = "2.9.5", default-features = false, features = ["tls"], optional = true } diff --git a/libherokubuildpack/src/error.rs b/libherokubuildpack/src/error.rs index ee61f362..70efbff0 100644 --- a/libherokubuildpack/src/error.rs +++ b/libherokubuildpack/src/error.rs @@ -1,11 +1,11 @@ -use crate::buildpack_output::BuildpackOutput; +use crate::log::log_error; use std::fmt::Debug; /// Handles a given [`libcnb::Error`] in a consistent style. /// /// This function is intended to be used inside [`libcnb::Buildpack::on_error`]. /// -/// It outputs generic libcnb errors in a consistent style using [`BuildpackOutput`] from this +/// It outputs generic libcnb errors in a consistent style using the [logging functions](log_error) from this /// crate. Buildpack specific errors are handled by the passed custom handler. /// /// # Example: @@ -14,7 +14,7 @@ use std::fmt::Debug; /// use libcnb::Buildpack; /// use libcnb::detect::{DetectContext, DetectResult}; /// use libcnb::generic::{GenericMetadata, GenericPlatform}; -/// use libherokubuildpack::buildpack_output::BuildpackOutput; +/// use libherokubuildpack::log::log_error; /// use libherokubuildpack::error::on_error; /// /// #[derive(Debug)] @@ -24,13 +24,12 @@ use std::fmt::Debug; /// } /// /// fn on_foo_buildpack_error(e: FooBuildpackError) { -/// let output = BuildpackOutput::new(std::io::stdout()).start_silent(); /// match e { /// FooBuildpackError::InvalidFooDescriptorToml => { -/// output.error("Invalid foo.toml\n\nYour app's foo.toml is invalid!"); +/// log_error("Invalid foo.toml", "Your app's foo.toml is invalid!"); /// } /// FooBuildpackError::CannotExecuteFooBuildTool(inner) => { -/// output.error(format!("Couldn't execute foo build tool\n\nYour app's foo.toml is invalid!\n\nCause: {}", &inner)); +/// log_error("Couldn't execute foo build tool", format!("Cause: {}", &inner)); /// } /// } /// } @@ -64,9 +63,7 @@ where match error { libcnb::Error::BuildpackError(buildpack_error) => f(buildpack_error), libcnb_error => { - BuildpackOutput::new(std::io::stdout()) - .start_silent() - .error(format!("Internal Buildpack Error\n\n{libcnb_error}")); + log_error("Internal Buildpack Error", libcnb_error.to_string()); } } } diff --git a/libherokubuildpack/src/lib.rs b/libherokubuildpack/src/lib.rs index c4b23dcd..c742a325 100644 --- a/libherokubuildpack/src/lib.rs +++ b/libherokubuildpack/src/lib.rs @@ -12,6 +12,8 @@ pub mod download; pub mod error; #[cfg(feature = "fs")] pub mod fs; +#[cfg(feature = "log")] +pub mod log; #[cfg(feature = "tar")] pub mod tar; #[cfg(feature = "toml")] diff --git a/libherokubuildpack/src/log.rs b/libherokubuildpack/src/log.rs new file mode 100644 index 00000000..9e270e27 --- /dev/null +++ b/libherokubuildpack/src/log.rs @@ -0,0 +1,74 @@ +use std::io::Write; +use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; + +/// # Panics +/// +/// Will panic if there was a problem setting the color settings, or all bytes could +/// not be written due to either I/O errors or EOF being reached. +// TODO: Replace `.unwrap()` usages with `.expect()` to give a clearer error message: +// https://github.com/heroku/libcnb.rs/issues/712 +#[allow(clippy::unwrap_used)] +pub fn log_error(header: impl AsRef, body: impl AsRef) { + let mut stream = StandardStream::stderr(ColorChoice::Always); + stream + .set_color(ColorSpec::new().set_fg(Some(Color::Red)).set_bold(true)) + .unwrap(); + writeln!(&mut stream, "\n[Error: {}]", header.as_ref()).unwrap(); + stream.reset().unwrap(); + + stream + .set_color(ColorSpec::new().set_fg(Some(Color::Red))) + .unwrap(); + writeln!(&mut stream, "{}", body.as_ref()).unwrap(); + stream.flush().unwrap(); +} + +/// # Panics +/// +/// Will panic if there was a problem setting the color settings, or all bytes could +/// not be written due to either I/O errors or EOF being reached. +// TODO: Replace `.unwrap()` usages with `.expect()` to give a clearer error message: +// https://github.com/heroku/libcnb.rs/issues/712 +#[allow(clippy::unwrap_used)] +pub fn log_warning(header: impl AsRef, body: impl AsRef) { + let mut stream = StandardStream::stderr(ColorChoice::Always); + stream + .set_color(ColorSpec::new().set_fg(Some(Color::Yellow)).set_bold(true)) + .unwrap(); + writeln!(&mut stream, "\n[Warning: {}]", header.as_ref()).unwrap(); + stream.reset().unwrap(); + + stream + .set_color(ColorSpec::new().set_fg(Some(Color::Yellow))) + .unwrap(); + writeln!(&mut stream, "{}", body.as_ref()).unwrap(); + stream.flush().unwrap(); +} + +/// # Panics +/// +/// Will panic if there was a problem setting the color settings, or all bytes could +/// not be written due to either I/O errors or EOF being reached. +// TODO: Replace `.unwrap()` usages with `.expect()` to give a clearer error message: +// https://github.com/heroku/libcnb.rs/issues/712 +#[allow(clippy::unwrap_used)] +pub fn log_header(title: impl AsRef) { + let mut stream = StandardStream::stdout(ColorChoice::Always); + stream + .set_color(ColorSpec::new().set_fg(Some(Color::Magenta)).set_bold(true)) + .unwrap(); + writeln!(&mut stream, "\n[{}]", title.as_ref()).unwrap(); + stream.reset().unwrap(); + stream.flush().unwrap(); +} + +/// # Panics +/// +/// Will panic if all bytes could not be written due to I/O errors or EOF being reached. +// TODO: Replace `.unwrap()` usages with `.expect()` to give a clearer error message: +// https://github.com/heroku/libcnb.rs/issues/712 +#[allow(clippy::unwrap_used)] +pub fn log_info(message: impl AsRef) { + println!("{}", message.as_ref()); + std::io::stdout().flush().unwrap(); +} From 16c90e65db853225f0f7f613485718f8bad34ca3 Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Tue, 13 Feb 2024 11:41:51 +0000 Subject: [PATCH 90/99] Remove stray README newline This was introduced when the changelog was updated as part of merging `main` into this branch in: https://github.com/heroku/libcnb.rs/pull/721/commits/a76c4907fb76a54cb6b0f2d2cbdd05dfd31eb8c7 --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8e5950..7e49da25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `libherokubuildpack`: - Added build `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 ### Changed From 7d8fa16e84febe240ad2dfa4eb9587e5ee5666b0 Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:03:29 +0000 Subject: [PATCH 91/99] Revert the implementation change in ab5d9a5579b3d9b1a7b54da80f27c9134f0d9b3b Since: - both myself and Manuel find the previous implementation clearer - the rustdocs say "If an empty string is provided ...", rather than "if there are no lines after splitting ...", so the previous implementation makes even more sense in that light - the previous implementation was what was code reviewed already --- libherokubuildpack/src/buildpack_output/util.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index 83251ef5..e28c9d4f 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -31,14 +31,15 @@ pub(crate) fn prefix_first_rest_lines( /// 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, the function will be called with a zero index and an empty slice. +/// 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 { - let mut lines = contents.split_inclusive('\n').peekable(); - - if lines.peek().is_none() { + // `split_inclusive` yields `None` for the empty string, so we have to explicitly add the prefix. + if contents.is_empty() { f(0, "") } else { - lines + contents + .split_inclusive('\n') .enumerate() .map(|(line_index, line)| { let prefix = f(line_index, line); From 7185440348110c8f7c659733b3d17873e5e82e0a Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:06:18 +0000 Subject: [PATCH 92/99] Remove stray word from CHANGELOG I think this might have been a leftover from: https://github.com/heroku/libcnb.rs/pull/721/commits/1c27a744eb3d7e7ba3c63774e70c570f5f960158 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e49da25..583f0fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `libherokubuildpack`: - - Added build `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)) + - 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 From 5e20c4154d31fe55891419f1c479996af177c90c Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:57:56 +0000 Subject: [PATCH 93/99] Fix `write_paragraph`'s handling of prefixes for empty lines Previously `write_paragraph` would use the incorrect prefix when the line wasn't empty but had trailing whitespace in the input string. ie for this input: `foo\n\t\t\nbar` Previously the output would be: `! foo\n!\t\t\n! bar` And now it will be: `! foo\n! \t\t\n! bar` I've adjusted the tests to test this case too. I've also added a comment to the implementation to make it clearer why the prefix is being adjusted at all. --- libherokubuildpack/src/buildpack_output/mod.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 18c0a435..cdb02a8a 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -280,7 +280,10 @@ where ansi_escape::wrap_ansi_escape_each_line( color, prefix_lines(s.as_ref(), |_, line| { - if line.chars().all(char::is_whitespace) { + // 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("! ") @@ -536,15 +539,17 @@ mod test { fn write_paragraph_empty_lines() { let io = BuildpackOutput::new(Vec::new()) .start("Example Buildpack") - .warning("hello\n\nworld") + .warning("hello\n\n\t\t\nworld") .finish(); + let tab_char = '\t'; let expected = formatdoc! {" # Example Buildpack ! hello ! + ! {tab_char}{tab_char} ! world - Done (finished in < 0.1s) From 3ad2008e41dd1d4584142a8692f3aef677d2db96 Mon Sep 17 00:00:00 2001 From: Schneems Date: Tue, 13 Feb 2024 16:58:38 -0600 Subject: [PATCH 94/99] Sanitize stray newlines at point of origin Mentioned in the comments: A user can add a stray newline to an input and mess up the formatting. The original implementation guarded against this at the point of origination. The basic concept is that user input isn't to be trusted, and at the point that they've given it to us, we can sanitize it for consistency https://github.com/heroku/buildpacks-ruby/blob/dda4ede413fc3fe4d6d2f2f63f039c7c1e5cc5fd/commons/src/output/build_log.rs#L224. With that concept in mind, the BuildOutput needs to own the consistency of the data it passes to other modules and functions. The build output concept largely takes ownership over newlines between outputs so I feel comfortable trimming these. I picked shared entry points to trim before any writeln_now calls. I extended the test to check for trailing newlines in additional locations. --- libherokubuildpack/src/buildpack_output/mod.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index cdb02a8a..03b18741 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -270,6 +270,7 @@ where 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, ""); @@ -279,7 +280,7 @@ where io, ansi_escape::wrap_ansi_escape_each_line( color, - prefix_lines(s.as_ref(), |_, line| { + 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. @@ -330,7 +331,7 @@ where &mut self.state.write, ansi_escape::wrap_ansi_escape_each_line( &ANSI::BoldPurple, - format!("\n# {}\n", buildpack_name.as_ref()), + format!("\n# {}\n", buildpack_name.as_ref().trim()), ), ); @@ -357,7 +358,7 @@ where const PREFIX_REST: &'static str = " "; fn style(s: impl AsRef) -> String { - prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) + prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref().trim()) } /// Begin a new section of the buildpack output. @@ -406,7 +407,7 @@ where const CMD_INDENT: &'static str = " "; fn style(s: impl AsRef) -> String { - prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref()) + prefix_first_rest_lines(Self::PREFIX_FIRST, Self::PREFIX_REST, s.as_ref().trim()) } /// Emit a step in the buildpack output within a section. @@ -538,8 +539,11 @@ mod test { #[test] fn write_paragraph_empty_lines() { let io = BuildpackOutput::new(Vec::new()) - .start("Example Buildpack") + .start("Example Buildpack\n\n") .warning("hello\n\n\t\t\nworld") + .section("Version\n\n") + .step("Installing\n\n") + .finish() .finish(); let tab_char = '\t'; @@ -552,6 +556,8 @@ mod test { ! {tab_char}{tab_char} ! world + - Version + - Installing - Done (finished in < 0.1s) "}; From aa19940d2b9c52eaad70468909eebf55c9b3e9ed Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Wed, 14 Feb 2024 14:39:33 +0000 Subject: [PATCH 95/99] Update testcase to cover trailing newlines in another case The fix in 3ad2008e41dd1d4584142a8692f3aef677d2db96 fixed trailing newlines in a few scenarios, and updated the tests to cover most of them, but there was one more case not tested, which is resolved now. --- libherokubuildpack/src/buildpack_output/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 03b18741..83e1bccf 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -540,7 +540,7 @@ mod test { fn write_paragraph_empty_lines() { let io = BuildpackOutput::new(Vec::new()) .start("Example Buildpack\n\n") - .warning("hello\n\n\t\t\nworld") + .warning("\n\nhello\n\n\t\t\nworld\n\n") .section("Version\n\n") .step("Installing\n\n") .finish() From 67e67aac889902924ad80d6a63b8b40fcd245940 Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:08:24 +0000 Subject: [PATCH 96/99] Add a test for streaming with blank lines and a trailing newline Previously two cases were not tested: 1. The "don't add the prefix if the line is blank" case, which is why the `if input.iter().all(u8::is_ascii_whitespace)` logic exists. 2. The output when the streamed command output includes a trailing newline. Whilst the output for (2) currently includes a redundant newline, IMO it makes sense to have a test demonstrating the issue in the meantime. (I've also added a TODO.) --- .../src/buildpack_output/mod.rs | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 83e1bccf..6c45ee59 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -642,28 +642,43 @@ mod test { #[test] fn test_captures() { let writer = Vec::new(); - let mut stream = BuildpackOutput::new(writer) + 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 stuff"); + .start_stream("Streaming with no newlines"); - let value = "stuff".to_string(); - writeln!(&mut stream, "{value}").unwrap(); + writeln!(&mut first_stream, "stuff").unwrap(); - let io = stream.finish().finish().finish(); + let mut second_stream = first_stream + .finish() + .start_stream("Streaming with blank lines and a trailing newline"); + + writeln!(&mut second_stream, "foo\nbar\n\nbaz\n").unwrap(); + + let io = second_stream.finish().finish().finish(); + // 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 stuff + - Streaming with no newlines stuff + - Done (< 0.1s) + - Streaming with blank lines and a trailing newline + + foo + bar + + baz + + - Done (< 0.1s) - Done (finished in < 0.1s) "}; From d423789eee05fb9e108455ee64c0a403a7f25fdb Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:39:53 +0000 Subject: [PATCH 97/99] Fix the `line_mapped` closure used in `start_stream` This makes the "don't add the indent if the line is blank" conditional: (a) correct in the "trailing whitespace isn't just a newline" case (b) equivalent to the `prefix_lines` closure used in `write_paragraph` ie: This is the `start_stream` version of this change: https://github.com/heroku/libcnb.rs/pull/721/commits/5e20c4154d31fe55891419f1c479996af177c90c I've also renamed `input` to `line` to make the closure easier to understand. --- libherokubuildpack/src/buildpack_output/mod.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 6c45ee59..5386f4ed 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -457,12 +457,14 @@ where started: self.started, state: state::Stream { started: Instant::now(), - write: line_mapped(self.state.write, |mut input| { - if input.iter().all(u8::is_ascii_whitespace) { - input + 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 input); + result.append(&mut line); result } }), @@ -655,10 +657,11 @@ mod test { .finish() .start_stream("Streaming with blank lines and a trailing newline"); - writeln!(&mut second_stream, "foo\nbar\n\nbaz\n").unwrap(); + 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! {" @@ -676,6 +679,7 @@ mod test { foo bar + {tab_char} baz From 29f1a4ed9daf11ac6bfeaa5176da7e8ead479fe2 Mon Sep 17 00:00:00 2001 From: Ed Morley <501702+edmorley@users.noreply.github.com> Date: Wed, 14 Feb 2024 16:17:04 +0000 Subject: [PATCH 98/99] Make line wrapping of rustdocs more consistent --- .../src/buildpack_output/ansi_escape.rs | 10 +-- .../src/buildpack_output/mod.rs | 83 ++++++++++++------- .../src/buildpack_output/util.rs | 16 ++-- 3 files changed, 64 insertions(+), 45 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/ansi_escape.rs b/libherokubuildpack/src/buildpack_output/ansi_escape.rs index 60bc5e98..1bd7ea11 100644 --- a/libherokubuildpack/src/buildpack_output/ansi_escape.rs +++ b/libherokubuildpack/src/buildpack_output/ansi_escape.rs @@ -2,14 +2,14 @@ /// /// ## 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. +/// 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. +/// 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() diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 5386f4ed..8ad885e8 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -1,6 +1,7 @@ //! # 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. +//! 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; @@ -18,12 +19,16 @@ //! //! ## 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. -//! +//! 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. +//! 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}; @@ -37,7 +42,8 @@ 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. +/// 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; @@ -70,7 +76,7 @@ pub mod state { /// 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. + /// It is represented by the `state::NotStarted` type and is transitioned into a `state::Started` type. /// /// Example: /// @@ -155,7 +161,8 @@ pub mod state { /// 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`]. + /// 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}}; @@ -219,17 +226,20 @@ where /// 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. + /// 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 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 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. + /// 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); } @@ -238,16 +248,19 @@ where /// /// 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. + /// 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. + /// 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 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`]. + /// 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); @@ -256,12 +269,14 @@ where /// 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. + /// 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. + /// 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); @@ -363,10 +378,12 @@ where /// 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. + /// 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``'. + /// 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] @@ -428,8 +445,9 @@ where /// /// 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. + /// 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] @@ -440,8 +458,9 @@ where /// 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 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`]. /// diff --git a/libherokubuildpack/src/buildpack_output/util.rs b/libherokubuildpack/src/buildpack_output/util.rs index e28c9d4f..ba8154d3 100644 --- a/libherokubuildpack/src/buildpack_output/util.rs +++ b/libherokubuildpack/src/buildpack_output/util.rs @@ -4,14 +4,14 @@ 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 +/// 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. +/// 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, @@ -26,7 +26,7 @@ pub(crate) fn prefix_first_rest_lines( }) } -/// Prefixes each line of input +/// 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. @@ -49,11 +49,11 @@ pub(crate) fn prefix_lines String>(contents: &str, f: F) - } } -/// A trailing newline aware writer +/// 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. +/// 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. From e177d786f81190826fc222cbdd40a7e0bcd157fe Mon Sep 17 00:00:00 2001 From: Schneems Date: Wed, 14 Feb 2024 15:10:34 -0600 Subject: [PATCH 99/99] Fix double newline after streaming Check if the streaming command emitted two newlines before adding an extra newline for padding. This fixes the case where an extra newline was being added when it wasn't needed. --- libherokubuildpack/src/buildpack_output/mod.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/libherokubuildpack/src/buildpack_output/mod.rs b/libherokubuildpack/src/buildpack_output/mod.rs index 8ad885e8..9ea7ee6b 100644 --- a/libherokubuildpack/src/buildpack_output/mod.rs +++ b/libherokubuildpack/src/buildpack_output/mod.rs @@ -510,18 +510,21 @@ where /// /// 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(mut self) -> BuildpackOutput> { + pub fn finish(self) -> BuildpackOutput> { let duration = self.state.started.elapsed(); - writeln_now(&mut self.state.write, ""); - - BuildpackOutput { + 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, ""); } - .step(format!( + + output.step(format!( "Done {}", style::details(duration_format::human(&duration)) )) @@ -701,7 +704,6 @@ mod test { {tab_char} baz - - Done (< 0.1s) - Done (finished in < 0.1s) "};