Skip to content

Commit

Permalink
Add BuildpackOutput (#721)
Browse files Browse the repository at this point in the history
This adds a new `build_output` module containing a `BuildOutput`
interface, whose goal is to provide a standard format for outputting
build information to the end user while a buildpack is running.

This implementation is an evolution of the approach prototyped in the
Ruby CNB:
https://github.com/heroku/buildpacks-ruby/tree/dda4ede413fc3fe4d6d2f2f63f039c7c1e5cc5fd/commons/src/output
  • Loading branch information
schneems authored Feb 14, 2024
1 parent 152b1a2 commit d806cbd
Show file tree
Hide file tree
Showing 10 changed files with 1,316 additions and 2 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `libherokubuildpack`:
- Added `buildpack_output` module. This will help buildpack authors provide consistent and delightful output to their buildpack users ([#721](https://github.com/heroku/libcnb.rs/pull/721))

## [0.18.0] - 2024-02-12

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ That's all we need! We can now move on to finally write some buildpack code!

### Writing the Buildpack

The buildpack we're writing will be very simple. We will just log a "Hello World" message during the build
The buildpack we're writing will be very simple. We will just output a "Hello World" message during the build
and set the default process type to a command that will also emit "Hello World" when the application image is run.
Examples of more complex buildpacks can be found in the [examples directory](https://github.com/heroku/libcnb.rs/tree/main/examples).

Expand Down
5 changes: 4 additions & 1 deletion libherokubuildpack/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ all-features = true
workspace = true

[features]
default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write"]
default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write", "buildpack_output"]
download = ["dep:ureq", "dep:thiserror"]
digest = ["dep:sha2"]
error = ["log", "dep:libcnb"]
Expand All @@ -27,6 +27,7 @@ tar = ["dep:tar", "dep:flate2"]
toml = ["dep:toml"]
fs = ["dep:pathdiff"]
command = ["write", "dep:crossbeam-utils"]
buildpack_output = []
write = []

[dependencies]
Expand All @@ -47,4 +48,6 @@ toml = { workspace = true, optional = true }
ureq = { version = "2.9.5", default-features = false, features = ["tls"], optional = true }

[dev-dependencies]
indoc = "2.0.4"
libcnb-test = { workspace = true }
tempfile = "3.10.0"
2 changes: 2 additions & 0 deletions libherokubuildpack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** -
Expand Down
115 changes: 115 additions & 0 deletions libherokubuildpack/src/buildpack_output/ansi_escape.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/// Wraps each line in an ANSI escape sequence while preserving prior ANSI escape sequences.
///
/// ## Why does this exist?
///
/// When buildpack output is streamed to the user, each line is prefixed with `remote: ` by Git.
/// Any colorization of text will apply to those prefixes which is not the desired behavior. This
/// function colors lines of text while ensuring that styles are disabled at the end of each line.
///
/// ## Supports recursive colorization
///
/// Strings that are previously colorized will not be overridden by this function. For example,
/// if a word is already colored yellow, that word will continue to be yellow.
pub(crate) fn wrap_ansi_escape_each_line(ansi: &ANSI, body: impl AsRef<str>) -> String {
let ansi_escape = ansi.to_str();
body.as_ref()
.split('\n')
// If sub contents are colorized it will contain SUBCOLOR ... RESET. After the reset,
// ensure we change back to the current color
.map(|line| line.replace(RESET, &format!("{RESET}{ansi_escape}"))) // Handles nested color
// Set the main color for each line and reset after so we don't colorize `remote:` by accident
.map(|line| format!("{ansi_escape}{line}{RESET}"))
// The above logic causes redundant colors and resets, clean them up
.map(|line| line.replace(&format!("{ansi_escape}{ansi_escape}"), ansi_escape)) // Reduce useless color
.map(|line| line.replace(&format!("{ansi_escape}{RESET}"), "")) // Empty lines or where the nested color is at the end of the line
.collect::<Vec<String>>()
.join("\n")
}

const RESET: &str = "\x1B[0m";
const RED: &str = "\x1B[0;31m";
const YELLOW: &str = "\x1B[0;33m";
const BOLD_CYAN: &str = "\x1B[1;36m";
const BOLD_PURPLE: &str = "\x1B[1;35m";

#[derive(Debug)]
#[allow(clippy::upper_case_acronyms)]
pub(crate) enum ANSI {
Red,
Yellow,
BoldCyan,
BoldPurple,
}

impl ANSI {
fn to_str(&self) -> &'static str {
match self {
ANSI::Red => RED,
ANSI::Yellow => YELLOW,
ANSI::BoldCyan => BOLD_CYAN,
ANSI::BoldPurple => BOLD_PURPLE,
}
}
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn empty_line() {
let actual = wrap_ansi_escape_each_line(&ANSI::Red, "\n");
let expected = String::from("\n");
assert_eq!(expected, actual);
}

#[test]
fn handles_nested_color_at_start() {
let start = wrap_ansi_escape_each_line(&ANSI::BoldCyan, "hello");
let out = wrap_ansi_escape_each_line(&ANSI::Red, format!("{start} world"));
let expected = format!("{RED}{BOLD_CYAN}hello{RESET}{RED} world{RESET}");

assert_eq!(expected, out);
}

#[test]
fn handles_nested_color_in_middle() {
let middle = wrap_ansi_escape_each_line(&ANSI::BoldCyan, "middle");
let out = wrap_ansi_escape_each_line(&ANSI::Red, format!("hello {middle} color"));
let expected = format!("{RED}hello {BOLD_CYAN}middle{RESET}{RED} color{RESET}");
assert_eq!(expected, out);
}

#[test]
fn handles_nested_color_at_end() {
let end = wrap_ansi_escape_each_line(&ANSI::BoldCyan, "world");
let out = wrap_ansi_escape_each_line(&ANSI::Red, format!("hello {end}"));
let expected = format!("{RED}hello {BOLD_CYAN}world{RESET}");

assert_eq!(expected, out);
}

#[test]
fn handles_double_nested_color() {
let inner = wrap_ansi_escape_each_line(&ANSI::BoldCyan, "inner");
let outer = wrap_ansi_escape_each_line(&ANSI::Red, format!("outer {inner}"));
let out = wrap_ansi_escape_each_line(&ANSI::Yellow, format!("hello {outer}"));
let expected = format!("{YELLOW}hello {RED}outer {BOLD_CYAN}inner{RESET}");

assert_eq!(expected, out);
}

#[test]
fn splits_newlines() {
let actual = wrap_ansi_escape_each_line(&ANSI::Red, "hello\nworld");
let expected = format!("{RED}hello{RESET}\n{RED}world{RESET}");

assert_eq!(expected, actual);
}

#[test]
fn simple_case() {
let actual = wrap_ansi_escape_each_line(&ANSI::Red, "hello world");
assert_eq!(format!("{RED}hello world{RESET}"), actual);
}
}
66 changes: 66 additions & 0 deletions libherokubuildpack/src/buildpack_output/duration_format.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use std::time::Duration;

pub(crate) fn human(duration: &Duration) -> String {
let hours = (duration.as_secs() / 3600) % 60;
let minutes = (duration.as_secs() / 60) % 60;
let seconds = duration.as_secs() % 60;
let milliseconds = duration.subsec_millis();
let tenths = milliseconds / 100;

if hours > 0 {
format!("{hours}h {minutes}m {seconds}s")
} else if minutes > 0 {
format!("{minutes}m {seconds}s")
} else if seconds > 0 || milliseconds >= 100 {
format!("{seconds}.{tenths}s")
} else {
String::from("< 0.1s")
}
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn test_display_duration() {
let duration = Duration::ZERO;
assert_eq!(human(&duration), "< 0.1s");

let duration = Duration::from_millis(99);
assert_eq!(human(&duration), "< 0.1s");

let duration = Duration::from_millis(100);
assert_eq!(human(&duration), "0.1s");

let duration = Duration::from_millis(210);
assert_eq!(human(&duration), "0.2s");

let duration = Duration::from_millis(1100);
assert_eq!(human(&duration), "1.1s");

let duration = Duration::from_millis(9100);
assert_eq!(human(&duration), "9.1s");

let duration = Duration::from_millis(10100);
assert_eq!(human(&duration), "10.1s");

let duration = Duration::from_millis(52100);
assert_eq!(human(&duration), "52.1s");

let duration = Duration::from_millis(60 * 1000);
assert_eq!(human(&duration), "1m 0s");

let duration = Duration::from_millis(60 * 1000 + 2000);
assert_eq!(human(&duration), "1m 2s");

let duration = Duration::from_millis(60 * 60 * 1000 - 1);
assert_eq!(human(&duration), "59m 59s");

let duration = Duration::from_millis(60 * 60 * 1000);
assert_eq!(human(&duration), "1h 0m 0s");

let duration = Duration::from_millis(75 * 60 * 1000 - 1);
assert_eq!(human(&duration), "1h 14m 59s");
}
}
Loading

0 comments on commit d806cbd

Please sign in to comment.