diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8f8bc2..b4fbe9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,3 +58,32 @@ jobs: - name: Run integration tests # Runs only tests annotated with the `ignore` attribute (which in this repo, are the integration tests). run: cargo test --locked -- --ignored + + pack-getting-started-output: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + - 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.5.1 + - name: Install Pack CLI + uses: buildpacks/github-actions/setup-pack@v5.2.0 + - name: Pull builder image + run: | + docker pull "heroku/builder:22" + docker pull "heroku/heroku:22-cnb" + - name: Clone ruby getting started guide + run: mkdir tmp; git clone https://github.com/heroku/ruby-getting-started tmp/ruby-getting-started + - name: Install libcnb-cargo for `cargo libcnb package` command + run: cargo install libcnb-cargo + - name: Compile ruby buildpack + run: cargo libcnb package + - name: Getting started guide output + run: | # Use `script -e -c` to pretend to be a TTY for pack terminal color support + script -e -c "pack build my-image --builder heroku/builder:22 --buildpack heroku/nodejs-engine --buildpack target/buildpack/debug/heroku_ruby --path tmp/ruby-getting-started --pull-policy never" diff --git a/Cargo.lock b/Cargo.lock index 4a7f407..733521f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,6 +232,7 @@ dependencies = [ "fs-err", "fs_extra", "glob", + "indoc", "lazy_static", "libcnb", "libherokubuildpack", @@ -574,10 +575,10 @@ dependencies = [ "commons", "flate2", "fs-err", + "glob", "indoc", "libcnb", "libcnb-test", - "libherokubuildpack", "rand", "regex", "serde", @@ -1255,18 +1256,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.166" +version = "1.0.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d01b7404f9d441d3ad40e6a636a7782c377d2abdbe4fa2440e2edcc2f4f10db8" +checksum = "7daf513456463b42aa1d94cff7e0c24d682b429f020b9afa4f5ba5c40a22b237" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.166" +version = "1.0.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd83d6dde2b6b2d466e14d9d1acce8816dedee94f735eac6395808b3483c6d6" +checksum = "b69b106b68bc8054f0e974e70d19984040f8a5cf9215ca82626ea4853f82c4b9" dependencies = [ "proc-macro2", "quote", @@ -1420,18 +1421,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.41" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c16a64ba9387ef3fdae4f9c1a7f07a0997fce91985c0336f1ddc1822b3b37802" +checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.41" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d14928354b01c4d6a4f0e549069adef399a284e7995c7ccca94e8a07a5346c59" +checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f" dependencies = [ "proc-macro2", "quote", diff --git a/README.md b/README.md index e34b762..07105b3 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Make sure it doesn't say `/usr/bin/ruby` or another system ruby location As a oneliner: ``` -$(set -pipefail; cd buildpacks/ruby && cargo libcnb package; cd -) && +cargo libcnb package && docker rmi my-image --force && pack build my-image --buildpack target/buildpack/debug/heroku_ruby --path buildpacks/ruby/tests/fixtures/default_ruby --verbose && docker run -it --rm --entrypoint='/cnb/lifecycle/launcher' my-image 'which bundle' diff --git a/buildpacks/ruby/CHANGELOG.md b/buildpacks/ruby/CHANGELOG.md index 4621c21..29eab03 100644 --- a/buildpacks/ruby/CHANGELOG.md +++ b/buildpacks/ruby/CHANGELOG.md @@ -2,7 +2,9 @@ ## [Unreleased] -- Commons: EnvCommand removed, replaced with fun_run (https://github.com/heroku/buildpacks-ruby/pull/139) +- Commons: Introduce `build_output` module (https://github.com/heroku/buildpacks-ruby/pull/155) +- Commons: Remove `gem_list`, `rake_status`, `rake_task_detect` modules (https://github.com/heroku/buildpacks-ruby/pull/155) +- Commons: `EnvCommand` removed, replaced with `fun_run` (https://github.com/heroku/buildpacks-ruby/pull/139) ## [2.0.0] 2023/31/01 diff --git a/buildpacks/ruby/Cargo.toml b/buildpacks/ruby/Cargo.toml index 23bfefd..5d70502 100644 --- a/buildpacks/ruby/Cargo.toml +++ b/buildpacks/ruby/Cargo.toml @@ -12,7 +12,6 @@ flate2 = "1" fs-err = "2" indoc = "2" libcnb = "0.13" -libherokubuildpack = "0.13" rand = "0.8" regex = "1" serde = "1" @@ -21,6 +20,7 @@ tempfile = "3" thiserror = "1" ureq = "2" url = "2" +glob = "0.3" [dev-dependencies] libcnb-test = "0.13" diff --git a/commons/src/gem_list.rs b/buildpacks/ruby/src/gem_list.rs similarity index 84% rename from commons/src/gem_list.rs rename to buildpacks/ruby/src/gem_list.rs index bf379a9..47b33b1 100644 --- a/commons/src/gem_list.rs +++ b/buildpacks/ruby/src/gem_list.rs @@ -1,5 +1,6 @@ -use crate::fun_run::{self, CmdMapExt}; -use crate::gem_version::GemVersion; +use crate::build_output::{RunCommand, Section}; +use commons::fun_run::{self, CmdMapExt}; +use commons::gem_version::GemVersion; use core::str::FromStr; use regex::Regex; use std::collections::HashMap; @@ -63,17 +64,13 @@ impl GemList { /// Errors if the command `bundle list` is unsuccessful. pub fn from_bundle_list, K: AsRef, V: AsRef>( envs: T, + build_output: &Section, ) -> Result { let output = Command::new("bundle") .arg("list") .env_clear() .envs(envs) - .cmd_map(|cmd| { - let name = fun_run::display(cmd); - cmd.output() - .map_err(|error| fun_run::on_system_error(name.clone(), error)) - .and_then(|output| fun_run::nonzero_captured(name.clone(), output)) - }) + .cmd_map(|cmd| build_output.run(RunCommand::inline_progress(cmd))) .map_err(ListError::BundleListShellCommandError)?; let stdout = String::from_utf8_lossy(&output.stdout); @@ -81,14 +78,9 @@ impl GemList { } #[must_use] - pub fn has(&self, str: &str) -> bool { + pub(crate) fn has(&self, str: &str) -> bool { self.gems.get(&str.trim().to_lowercase()).is_some() } - - #[must_use] - pub fn version_for(&self, str: &str) -> Option<&GemVersion> { - self.gems.get(&str.trim().to_lowercase()) - } } impl FromStr for GemList { @@ -155,12 +147,6 @@ Use `bundle info` to print more detailed information about a gem assert!(gem_list.has("railties")); assert!(!gem_list.has("foo")); - assert_eq!( - gem_list.version_for("railties").unwrap(), - &GemVersion::from_str("6.1.4.1").unwrap() - ); - assert_eq!(gem_list.version_for("foo"), None); - assert_eq!(gem_list.gems.len(), 14); } } diff --git a/buildpacks/ruby/src/layers/bundle_download_layer.rs b/buildpacks/ruby/src/layers/bundle_download_layer.rs index 2359640..14d45ba 100644 --- a/buildpacks/ruby/src/layers/bundle_download_layer.rs +++ b/buildpacks/ruby/src/layers/bundle_download_layer.rs @@ -1,3 +1,4 @@ +use crate::build_output::{RunCommand, Section}; use crate::RubyBuildpack; use crate::RubyBuildpackError; use commons::fun_run::{self, CmdMapExt}; @@ -7,8 +8,6 @@ use libcnb::data::layer_content_metadata::LayerTypes; use libcnb::layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder}; use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope}; use libcnb::Env; -use libherokubuildpack::command::CommandExt; -use libherokubuildpack::log as user; use serde::{Deserialize, Serialize}; use std::path::Path; use std::process::Command; @@ -27,6 +26,7 @@ pub(crate) struct BundleDownloadLayerMetadata { pub(crate) struct BundleDownloadLayer { pub env: Env, pub version: ResolvedBundlerVersion, + pub build_output: Section, } impl Layer for BundleDownloadLayer { @@ -45,8 +45,6 @@ impl Layer for BundleDownloadLayer { _context: &BuildContext, layer_path: &Path, ) -> Result, RubyBuildpackError> { - user::log_info(format!("Installing bundler {}", self.version)); - let bin_dir = layer_path.join("bin"); let gem_path = layer_path; @@ -54,29 +52,29 @@ impl Layer for BundleDownloadLayer { .args([ "install", "bundler", - "--force", - "--no-document", // Don't install ri or rdoc which takes extra time - "--env-shebang", // Start the `bundle` executable with `#! /usr/bin/env ruby` - "--version", // Specify exact version to install + "--version", // Specify exact version to install &self.version.to_string(), - "--install-dir", // Directory where bundler's contents will live - &layer_path.to_string_lossy(), - "--bindir", // Directory where `bundle` executable lives - &bin_dir.to_string_lossy(), ]) .env_clear() .envs(&self.env) .cmd_map(|cmd| { + // Format `gem install --version ` without other content for display let name = fun_run::display(cmd); - - user::log_info(format!("Running $ {name}")); - - cmd.output_and_write_streams(std::io::stdout(), std::io::stderr()) + // Arguments we don't need in the output + cmd.args([ + "--install-dir", // Directory where bundler's contents will live + &layer_path.to_string_lossy(), + "--bindir", // Directory where `bundle` executable lives + &bin_dir.to_string_lossy(), + "--force", + "--no-document", // Don't install ri or rdoc documentation, which takes extra time + "--env-shebang", // Start the `bundle` executable with `#! /usr/bin/env ruby` + ]); + self.build_output + .run(RunCommand::inline_progress(cmd).with_name(name)) .map_err(|error| { - fun_run::annotate_which_problem(error, cmd, self.env.get("PATH").cloned()) + fun_run::map_which_problem(error, cmd, self.env.get("PATH").cloned()) }) - .map_err(|error| fun_run::on_system_error(name.clone(), error)) - .and_then(|output| fun_run::nonzero_streamed(name.clone(), output)) }) .map_err(RubyBuildpackError::GemInstallBundlerCommandError)?; @@ -113,14 +111,14 @@ impl Layer for BundleDownloadLayer { version: self.version.clone(), }; match cache_state(old.clone(), now) { - State::NothingChanged(version) => { - user::log_info(format!("Using bundler {version} from cache")); + State::NothingChanged(_version) => { + self.build_output.say("Using cached version"); Ok(ExistingLayerStrategy::Keep) } - State::BundlerVersionChanged(old, now) => { - user::log_info(format!("Bundler version changed from {old} to {now}")); - user::log_info("Clearing bundler from cache"); + State::BundlerVersionChanged(_old, _now) => { + self.build_output + .say_with_details("Clearing cache", "bundler version changed"); Ok(ExistingLayerStrategy::Recreate) } diff --git a/buildpacks/ruby/src/layers/bundle_install_layer.rs b/buildpacks/ruby/src/layers/bundle_install_layer.rs index d4d9c10..60de1eb 100644 --- a/buildpacks/ruby/src/layers/bundle_install_layer.rs +++ b/buildpacks/ruby/src/layers/bundle_install_layer.rs @@ -1,4 +1,7 @@ -use crate::{BundleWithout, RubyBuildpack, RubyBuildpackError}; +use crate::{ + build_output::{self, RunCommand, Section}, + BundleWithout, RubyBuildpack, RubyBuildpackError, +}; use commons::{ display::SentenceList, fun_run::{self, CmdError, CmdMapExt}, @@ -12,7 +15,6 @@ use libcnb::{ layer_env::{LayerEnv, ModificationBehavior, Scope}, Env, }; -use libherokubuildpack::{command::CommandExt, log as user}; use serde::{Deserialize, Serialize}; use std::{path::Path, process::Command}; @@ -31,6 +33,7 @@ pub(crate) struct BundleInstallLayer { pub env: Env, pub without: BundleWithout, pub ruby_version: ResolvedRubyVersion, + pub build_output: Section, } #[derive(Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] @@ -90,40 +93,6 @@ impl BundleInstallLayer { Ok(out) } - - #[allow(clippy::unnecessary_wraps)] - fn existing_cache_strategy( - old: BundleInstallLayerMetadata, - now: BundleInstallLayerMetadata, - ) -> Result { - match cache_state(old, now) { - Changed::Nothing => { - user::log_info("Using gems cache from last deploy"); - - Ok(CacheStrategy::KeepAndRun) - } - Changed::Stack(old, now) => { - user::log_info(format!( - "Clearing gems cache, Stack changed from {old} to {now}" - )); - - Ok(CacheStrategy::ClearAndRun) - } - Changed::RubyVersion(old, now) => { - user::log_info(format!( - "Clearing gems cache, ruby version changed from {old} to {now}" - )); - - Ok(CacheStrategy::ClearAndRun) - } - } - } -} - -#[derive(Debug)] -enum CacheStrategy { - ClearAndRun, - KeepAndRun, } #[derive(Debug)] @@ -182,19 +151,22 @@ impl Layer for BundleInstallLayer { match update_state(&layer_data.content_metadata.metadata, &metadata) { UpdateState::Run(reason) => { - user::log_info(format!("Running 'bundle install', {reason}")); + self.build_output.say(reason); - bundle_install(&env).map_err(RubyBuildpackError::BundleInstallCommandError)?; + bundle_install(&env, &self.build_output) + .map_err(RubyBuildpackError::BundleInstallCommandError)?; } UpdateState::Skip(checked) => { let checked = SentenceList::new(&checked).join_str("or"); - user::log_info(format!( - "Skipping 'bundle install', no changes found in {checked}" - )); - user::log_info("Help: To skip digest change detection and force running"); - user::log_info(format!( - " 'bundle install' set {HEROKU_SKIP_BUNDLE_DIGEST}=1" - )); + let bundle_install = build_output::fmt::value("bundle install"); + let env_var = build_output::fmt::value(format!("{HEROKU_SKIP_BUNDLE_DIGEST}=1")); + + self.build_output.say_with_details( + format!("Skipping {bundle_install}"), + format!("no changes found in {checked}"), + ); + self.build_output + .help(format!("To force run {bundle_install} set {env_var}")); } } @@ -211,8 +183,8 @@ impl Layer for BundleInstallLayer { let layer_env = self.build_layer_env(context, layer_path)?; let env = layer_env.apply(Scope::Build, &self.env); - user::log_info("Running 'bundle install'"); - bundle_install(&env).map_err(RubyBuildpackError::BundleInstallCommandError)?; + bundle_install(&env, &self.build_output) + .map_err(RubyBuildpackError::BundleInstallCommandError)?; LayerResultBuilder::new(metadata).env(layer_env).build() } @@ -232,9 +204,27 @@ impl Layer for BundleInstallLayer { let old = &layer_data.content_metadata.metadata; let now = self.build_metadata(context, &layer_data.path)?; - match Self::existing_cache_strategy(old.clone(), now)? { - CacheStrategy::ClearAndRun => Ok(ExistingLayerStrategy::Recreate), - CacheStrategy::KeepAndRun => Ok(ExistingLayerStrategy::Update), + let clear_and_run = Ok(ExistingLayerStrategy::Recreate); + let keep_and_run = Ok(ExistingLayerStrategy::Update); + + match cache_state(old.clone(), now) { + Changed::Nothing => { + self.build_output.say("Loading cache"); + + keep_and_run + } + Changed::Stack(_old, _now) => { + self.build_output + .say_with_details("Clearing cache", "stack changed"); + + clear_and_run + } + Changed::RubyVersion(_old, _now) => { + self.build_output + .say_with_details("Clearing cache", "ruby version changed"); + + clear_and_run + } } } } @@ -338,7 +328,7 @@ fn layer_env(layer_path: &Path, app_dir: &Path, without_default: &BundleWithout) /// /// When the 'bundle install' command fails this function returns an error. /// -fn bundle_install(env: &Env) -> Result<(), CmdError> { +fn bundle_install(env: &Env, section: &Section) -> Result<(), CmdError> { let display_with_env = |cmd: &'_ mut Command| { fun_run::display_with_env_keys( cmd, @@ -361,13 +351,11 @@ fn bundle_install(env: &Env) -> Result<(), CmdError> { .envs(env) .cmd_map(|cmd| { let name = display_with_env(cmd); - let path_env = env.get("PATH"); - user::log_info(format!("Running $ {name}")); + let path_env = env.get("PATH").cloned(); - cmd.output_and_write_streams(std::io::stdout(), std::io::stderr()) - .map_err(|error| fun_run::annotate_which_problem(error, cmd, path_env.cloned())) - .map_err(|error| fun_run::on_system_error(name.clone(), error)) - .and_then(|output| fun_run::nonzero_streamed(name.clone(), output)) + section + .run(RunCommand::stream(cmd).with_name(name)) + .map_err(|error| fun_run::map_which_problem(error, cmd, path_env)) })?; Ok(()) diff --git a/buildpacks/ruby/src/layers/ruby_install_layer.rs b/buildpacks/ruby/src/layers/ruby_install_layer.rs index c590718..c954c83 100644 --- a/buildpacks/ruby/src/layers/ruby_install_layer.rs +++ b/buildpacks/ruby/src/layers/ruby_install_layer.rs @@ -1,11 +1,10 @@ -use crate::{RubyBuildpack, RubyBuildpackError}; +use crate::{build_output, RubyBuildpack, RubyBuildpackError}; use commons::gemfile_lock::ResolvedRubyVersion; use flate2::read::GzDecoder; use libcnb::build::BuildContext; use libcnb::data::buildpack::StackId; use libcnb::data::layer_content_metadata::LayerTypes; use libcnb::layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder}; -use libherokubuildpack::log as user; use serde::{Deserialize, Serialize}; use std::io; use std::path::Path; @@ -29,6 +28,7 @@ use url::Url; #[derive(PartialEq, Eq)] pub(crate) struct RubyInstallLayer { pub version: ResolvedRubyVersion, + pub build_output: build_output::Section, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -54,7 +54,7 @@ impl Layer for RubyInstallLayer { context: &BuildContext, layer_path: &Path, ) -> Result, RubyBuildpackError> { - user::log_info(format!("Installing ruby {}", &self.version)); + let mut timer = self.build_output.say_with_inline_timer("Installing"); let tmp_ruby_tgz = NamedTempFile::new() .map_err(RubyInstallError::CouldNotCreateDestinationFile) @@ -68,6 +68,8 @@ impl Layer for RubyInstallLayer { untar(tmp_ruby_tgz.path(), layer_path).map_err(RubyBuildpackError::RubyInstallError)?; + timer.done(); + LayerResultBuilder::new(RubyInstallLayerMetadata { stack: context.stack_id.clone(), version: self.version.clone(), @@ -87,20 +89,20 @@ impl Layer for RubyInstallLayer { }; match cache_state(old.clone(), now) { - Changed::Nothing(version) => { - user::log_info(format!("Using Ruby {version} from cache")); + Changed::Nothing(_version) => { + self.build_output.say("Using cached version"); Ok(ExistingLayerStrategy::Keep) } - Changed::Stack(old, now) => { - user::log_info(format!("Stack changed from {old} to {now}",)); - user::log_info("Clearing ruby from cache"); + Changed::Stack(_old, _now) => { + self.build_output + .say_with_details("Clearing cache", "stack changed"); Ok(ExistingLayerStrategy::Recreate) } - Changed::RubyVersion(old, now) => { - user::log_info(format!("Ruby version changed from {old} to {now}",)); - user::log_info("Clearing ruby from cache"); + Changed::RubyVersion(_old, _now) => { + self.build_output + .say_with_details("Clearing cache", "ruby version changed"); Ok(ExistingLayerStrategy::Recreate) } diff --git a/buildpacks/ruby/src/main.rs b/buildpacks/ruby/src/main.rs index 1eba916..084197e 100644 --- a/buildpacks/ruby/src/main.rs +++ b/buildpacks/ruby/src/main.rs @@ -2,12 +2,13 @@ #![warn(clippy::pedantic)] #![allow(clippy::module_name_repetitions)] use crate::layers::{RubyInstallError, RubyInstallLayer}; +use crate::rake_task_detect::RakeError; +use commons::build_output; use commons::cache::CacheError; use commons::fun_run::CmdError; -use commons::gem_list::GemList; use commons::gemfile_lock::GemfileLock; -use commons::rake_task_detect::RakeError; use core::str::FromStr; +use layers::{BundleDownloadLayer, BundleInstallLayer}; use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder}; use libcnb::data::build_plan::BuildPlanBuilder; use libcnb::data::launch::LaunchBuilder; @@ -17,11 +18,12 @@ use libcnb::generic::{GenericMetadata, GenericPlatform}; use libcnb::layer_env::Scope; use libcnb::Platform; use libcnb::{buildpack_main, Buildpack}; -use libherokubuildpack::log as user; use regex::Regex; -use std::fmt::Display; +mod gem_list; mod layers; +mod rake_status; +mod rake_task_detect; mod steps; mod user_errors; @@ -62,84 +64,95 @@ impl Buildpack for RubyBuildpack { } fn build(&self, context: BuildContext) -> libcnb::Result { - let total = std::time::Instant::now(); - let section = header("Heroku Ruby buildpack"); - user::log_info("Running Heroku Ruby buildpack"); - section.done_quiet(); + let build_duration = build_output::buildpack_name("Heroku Ruby Buildpack"); - let section = header("Setting environment"); - user::log_info("Setting default environment values"); // ## Set default environment let (mut env, store) = crate::steps::default_env(&context, &context.platform.env().clone())?; - section.done(); // Gather static information about project - let section = header("Detecting versions"); let lockfile_contents = fs_err::read_to_string(context.app_dir.join("Gemfile.lock")) .map_err(RubyBuildpackError::MissingGemfileLock)?; let gemfile_lock = GemfileLock::from_str(&lockfile_contents).expect("Infallible"); let bundler_version = gemfile_lock.resolve_bundler("2.4.5"); let ruby_version = gemfile_lock.resolve_ruby("3.1.3"); - user::log_info(format!("Detected ruby: {ruby_version}")); - user::log_info(format!("Detected bundler: {bundler_version}")); - section.done(); // ## Install executable ruby version - let section = header("Installing Ruby"); - let ruby_layer = context // - .handle_layer( - layer_name!("ruby"), - RubyInstallLayer { - version: ruby_version.clone(), - }, - )?; - env = ruby_layer.env.apply(Scope::Build, &env); - section.done(); + + env = { + let section = build_output::section(format!( + "Ruby version {} from {}", + build_output::fmt::value(ruby_version.to_string()), + build_output::fmt::value(gemfile_lock.ruby_source()) + )); + let ruby_layer = context // + .handle_layer( + layer_name!("ruby"), + RubyInstallLayer { + build_output: section, + version: ruby_version.clone(), + }, + )?; + ruby_layer.env.apply(Scope::Build, &env) + }; // ## Setup bundler - let section = header("Installing Bundler"); - env = crate::steps::bundler_download(bundler_version, &context, &env)?; - section.done(); + env = { + let section = build_output::section(format!( + "Bundler version {} from {}", + build_output::fmt::value(bundler_version.to_string()), + build_output::fmt::value(gemfile_lock.bundler_source()) + )); + let download_bundler_layer = context.handle_layer( + layer_name!("bundler"), + BundleDownloadLayer { + env: env.clone(), + version: bundler_version, + build_output: section, + }, + )?; + download_bundler_layer.env.apply(Scope::Build, &env) + }; // ## Bundle install - let section = header("Installing dependencies"); - env = crate::steps::bundle_install( - &context, - BundleWithout(String::from("development:test")), - ruby_version, - &env, - )?; - section.done(); + env = { + let section = build_output::section("Bundle install"); + + let bundle_install_layer = context.handle_layer( + layer_name!("gems"), + BundleInstallLayer { + env: env.clone(), + without: BundleWithout::new("development:test"), + ruby_version, + build_output: section, + }, + )?; + bundle_install_layer.env.apply(Scope::Build, &env) + }; // ## Detect gems - let section = header("Detecting gems"); - user::log_info("Detecting gems via `bundle list`"); - let gem_list = - GemList::from_bundle_list(&env).map_err(RubyBuildpackError::GemListGetError)?; - section.done(); + let (gem_list, default_process) = { + let section = build_output::section("Setting default processes(es)"); + section.say("Detecting gems"); + + let gem_list = gem_list::GemList::from_bundle_list(&env, §ion) + .map_err(RubyBuildpackError::GemListGetError)?; + let default_process = steps::get_default_process(§ion, &context, &gem_list); - let section = header("Setting default process(es)"); - let default_process = steps::get_default_process(&context, &gem_list); - section.done(); + (gem_list, default_process) + }; // ## Assets install - let section = header("Rake task detection"); - let rake_detect = crate::steps::detect_rake_tasks(&gem_list, &context, &env)?; - section.done(); - - if let Some(rake_detect) = rake_detect { - let section = header("Rake asset installation"); - crate::steps::rake_assets_install(&context, &env, &rake_detect)?; - section.done(); - } - let duration = total.elapsed(); - user::log_header("Heroku Ruby buildpack finished"); - user::log_info(format!( - "Finished ({} total elapsed time)\n", - DisplayDuration::new(&duration) - )); + { + let section = build_output::section("Rake assets install"); + let rake_detect = crate::steps::detect_rake_tasks(§ion, &gem_list, &context, &env)?; + + if let Some(rake_detect) = rake_detect { + crate::steps::rake_assets_install(§ion, &context, &env, &rake_detect)?; + } + }; + build_duration.done_timed(); if let Some(default_process) = default_process { BuildResultBuilder::new() @@ -164,7 +177,7 @@ fn needs_java(gemfile_lock: &str) -> bool { #[derive(Debug)] pub(crate) enum RubyBuildpackError { RakeDetectError(RakeError), - GemListGetError(commons::gem_list::ListError), + GemListGetError(gem_list::ListError), RubyInstallError(RubyInstallError), MissingGemfileLock(std::io::Error), InAppDirCacheError(CacheError), @@ -182,85 +195,13 @@ impl From for libcnb::Error { buildpack_main!(RubyBuildpack); -/// Use for logging a duration -#[derive(Debug)] -struct LogSectionWithTime { - start: std::time::Instant, -} - -impl LogSectionWithTime { - fn done(&self) { - let diff = &self.start.elapsed(); - let duration = DisplayDuration::new(diff); - - user::log_info(format!("Done ({duration})")); - } - - #[allow(clippy::unused_self)] - fn done_quiet(&self) {} -} - -/// Prints out a header and ensures a done section is printed -/// -/// Returns a `LogSectionWithTime` that must be used. That -/// will print out the elapsed time. -#[must_use] -fn header(message: &str) -> LogSectionWithTime { - user::log_header(message); - - let start = std::time::Instant::now(); - - LogSectionWithTime { start } -} - -#[derive(Debug)] -struct DisplayDuration<'a> { - duration: &'a std::time::Duration, -} - -impl DisplayDuration<'_> { - fn new(duration: &std::time::Duration) -> DisplayDuration { - DisplayDuration { duration } - } - - fn milliseconds(&self) -> u32 { - self.duration.subsec_millis() - } - - fn seconds(&self) -> u64 { - self.duration.as_secs() % 60 - } - - fn minutes(&self) -> u64 { - (self.duration.as_secs() / 60) % 60 - } - - fn hours(&self) -> u64 { - (self.duration.as_secs() / 3600) % 60 - } -} - -impl Display for DisplayDuration<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let hours = self.hours(); - let minutes = self.minutes(); - let seconds = self.seconds(); - let miliseconds = self.milliseconds(); - - if self.hours() > 0 { - f.write_fmt(format_args!("{hours}h {minutes}m {seconds}s")) - } else if self.minutes() > 0 { - f.write_fmt(format_args!("{minutes}m {seconds}s")) - } else { - f.write_fmt(format_args!("{seconds}.{miliseconds:0>3}s")) - } - } -} - #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq, Eq)] pub(crate) struct BundleWithout(String); impl BundleWithout { + fn new(without: impl AsRef) -> Self { + Self(String::from(without.as_ref())) + } fn as_str(&self) -> &str { &self.0 } @@ -270,18 +211,6 @@ impl BundleWithout { mod test { use super::*; - #[test] - fn test_display_duration() { - let diff = std::time::Duration::from_millis(1024); - assert_eq!("1.024s", format!("{}", DisplayDuration::new(&diff))); - - let diff = std::time::Duration::from_millis(60 * 1024); - assert_eq!("1m 1s", format!("{}", DisplayDuration::new(&diff))); - - let diff = std::time::Duration::from_millis(3600 * 1024); - assert_eq!("1h 1m 26s", format!("{}", DisplayDuration::new(&diff))); - } - #[test] fn test_needs_java() { let gemfile_lock = r#""#; diff --git a/commons/src/rake_status.rs b/buildpacks/ruby/src/rake_status.rs similarity index 100% rename from commons/src/rake_status.rs rename to buildpacks/ruby/src/rake_status.rs diff --git a/commons/src/rake_task_detect.rs b/buildpacks/ruby/src/rake_task_detect.rs similarity index 83% rename from commons/src/rake_task_detect.rs rename to buildpacks/ruby/src/rake_task_detect.rs index 6ef8491..43f170a 100644 --- a/commons/src/rake_task_detect.rs +++ b/buildpacks/ruby/src/rake_task_detect.rs @@ -1,8 +1,10 @@ -use crate::fun_run::{self, CmdMapExt}; +use commons::fun_run::{self, CmdError, CmdMapExt}; use core::str::FromStr; use regex::Regex; use std::{ffi::OsStr, process::Command}; +use crate::build_output::{RunCommand, Section}; + /// Run `rake -P` and parse output to show what rake tasks an application has /// /// ```rust,no_run @@ -31,6 +33,7 @@ impl RakeDetect { /// /// Will return `Err` if `bundle exec rake -p` command cannot be invoked by the operating system. pub fn from_rake_command, K: AsRef, V: AsRef>( + section: &Section, envs: T, error_on_failure: bool, ) -> Result { @@ -39,16 +42,17 @@ impl RakeDetect { .env_clear() .envs(envs) .cmd_map(|cmd| { - let name = fun_run::display(cmd); - cmd.output() - .map_err(|error| fun_run::on_system_error(name.clone(), error)) - .and_then(|output| { - if error_on_failure { - fun_run::nonzero_captured(name.clone(), output) - } else { - Ok(output) + section.run(RunCommand::silent(cmd)).or_else(|error| { + if error_on_failure { + Err(error) + } else { + match error { + CmdError::SystemError(_, _) => Err(error), + CmdError::NonZeroExitNotStreamed(_, output) + | CmdError::NonZeroExitAlreadyStreamed(_, output) => Ok(output), } - }) + } + }) }) .map_err(RakeError::DashpCommandError) .and_then(|output| RakeDetect::from_str(&String::from_utf8_lossy(&output.stdout))) diff --git a/buildpacks/ruby/src/steps.rs b/buildpacks/ruby/src/steps.rs index b59c012..6de8129 100644 --- a/buildpacks/ruby/src/steps.rs +++ b/buildpacks/ruby/src/steps.rs @@ -1,12 +1,8 @@ -mod bundle_install; -mod bundler_download; mod default_env; mod detect_rake_tasks; mod get_default_process; mod rake_assets_install; -pub(crate) use self::bundle_install::bundle_install; -pub(crate) use self::bundler_download::bundler_download; pub(crate) use self::default_env::default_env; pub(crate) use self::detect_rake_tasks::detect_rake_tasks; pub(crate) use self::get_default_process::get_default_process; diff --git a/buildpacks/ruby/src/steps/bundle_install.rs b/buildpacks/ruby/src/steps/bundle_install.rs deleted file mode 100644 index dadfa60..0000000 --- a/buildpacks/ruby/src/steps/bundle_install.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::{layers::BundleInstallLayer, BundleWithout, RubyBuildpack, RubyBuildpackError}; -use commons::gemfile_lock::ResolvedRubyVersion; -use libcnb::{build::BuildContext, data::layer_name, layer_env::Scope, Env}; - -pub(crate) fn bundle_install( - context: &BuildContext, - without: BundleWithout, - ruby_version: ResolvedRubyVersion, - env: &Env, -) -> libcnb::Result { - // Gems will be installed here, sets BUNDLE_PATH env var - let bundle_install_layer = context.handle_layer( - layer_name!("gems"), - BundleInstallLayer { - env: env.clone(), - without, - ruby_version, - }, - )?; - let env = bundle_install_layer.env.apply(Scope::Build, env); - - Ok(env) -} diff --git a/buildpacks/ruby/src/steps/bundler_download.rs b/buildpacks/ruby/src/steps/bundler_download.rs deleted file mode 100644 index 1c33e68..0000000 --- a/buildpacks/ruby/src/steps/bundler_download.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::layers::BundleDownloadLayer; -use crate::{RubyBuildpack, RubyBuildpackError}; -use commons::gemfile_lock::ResolvedBundlerVersion; -use libcnb::Env; -use libcnb::{build::BuildContext, data::layer_name, layer_env::Scope}; - -/// Primary interface for `bundle install` -pub(crate) fn bundler_download( - bundler_version: ResolvedBundlerVersion, - context: &BuildContext, - env: &Env, -) -> libcnb::Result { - let mut env = env.clone(); - - // Download the specified bundler version - let download_bundler_layer = context.handle_layer( - layer_name!("bundler"), - BundleDownloadLayer { - version: bundler_version, - env: env.clone(), - }, - )?; - env = download_bundler_layer.env.apply(Scope::Build, &env); - - Ok(env) -} diff --git a/buildpacks/ruby/src/steps/detect_rake_tasks.rs b/buildpacks/ruby/src/steps/detect_rake_tasks.rs index 387dfa4..c9ad726 100644 --- a/buildpacks/ruby/src/steps/detect_rake_tasks.rs +++ b/buildpacks/ruby/src/steps/detect_rake_tasks.rs @@ -1,54 +1,67 @@ +use crate::build_output::{self, Section}; +use crate::gem_list::GemList; +use crate::rake_status::{check_rake_ready, RakeStatus}; +use crate::rake_task_detect::RakeDetect; use crate::RubyBuildpack; use crate::RubyBuildpackError; -use commons::gem_list::GemList; -use commons::rake_status::{check_rake_ready, RakeStatus}; -use commons::rake_task_detect::RakeDetect; use libcnb::build::BuildContext; use libcnb::Env; -use libherokubuildpack::log as user; pub(crate) fn detect_rake_tasks( + section: &Section, gem_list: &GemList, context: &BuildContext, env: &Env, ) -> Result, RubyBuildpackError> { + let rake = build_output::fmt::value("rake"); + let gemfile = build_output::fmt::value("Gemfile"); + let rakefile = build_output::fmt::value("Rakefile"); + match check_rake_ready( &context.app_dir, gem_list, [".sprockets-manifest-*.json", "manifest-*.json"], ) { RakeStatus::MissingRakeGem => { - user::log_info("Cannot run rake tasks, no rake gem in Gemfile"); - user::log_info("Add `gem 'rake'` to your Gemfile to enable"); + section.say_with_details( + "Cannot run rake tasks", + format!("no {rake} gem in {gemfile}"), + ); + + let gem = build_output::fmt::value("gem 'rake'"); + section.help(format!("Add {gem} to your {gemfile} to enable")); Ok(None) } RakeStatus::MissingRakefile => { - user::log_info("Cannot run rake tasks, no Rakefile"); - user::log_info("Add a `Rakefile` to your project to enable"); + section.say_with_details("Cannot run rake tasks", format!("no {rakefile}")); + section.help(format!("Add {rakefile} to your project to enable")); Ok(None) } RakeStatus::SkipManifestFound(paths) => { - user::log_info("Skipping rake tasks. Manifest file(s) found"); - user::log_info(format!( - "To enable, delete files: {}", - paths - .iter() - .map(|path| path.to_string_lossy()) - .collect::>() - .join(", ") - )); + let files = paths + .iter() + .map(|path| build_output::fmt::value(path.to_string_lossy())) + .collect::>() + .join(", "); + + section.say_with_details( + "Skipping rake tasks", + format!("Manifest files found {files}"), + ); + section.help("Delete files to enable running rake tasks"); Ok(None) } RakeStatus::Ready(path) => { - let path = path.display(); - user::log_info("Rake gem found"); - user::log_info(format!("Rakefile found at {path}")); + let path = build_output::fmt::value(path.to_string_lossy()); + section.say_with_details( + "Rake detected", + format!("{rake} gem found, {rakefile} found at {path}"), + ); - user::log_info("Detecting rake tasks via `rake -P`"); - let rake_detect = RakeDetect::from_rake_command(env, true) + let rake_detect = RakeDetect::from_rake_command(section, env, true) .map_err(RubyBuildpackError::RakeDetectError)?; Ok(Some(rake_detect)) diff --git a/buildpacks/ruby/src/steps/get_default_process.rs b/buildpacks/ruby/src/steps/get_default_process.rs index 1561021..4fa0265 100644 --- a/buildpacks/ruby/src/steps/get_default_process.rs +++ b/buildpacks/ruby/src/steps/get_default_process.rs @@ -1,41 +1,45 @@ -use commons::gem_list::GemList; +use crate::build_output::{self, Section}; +use crate::gem_list::GemList; +use crate::RubyBuildpack; use libcnb::build::BuildContext; use libcnb::data::launch::Process; use libcnb::data::launch::ProcessBuilder; use libcnb::data::process_type; -use libherokubuildpack::log as user; use std::path::Path; -use crate::RubyBuildpack; - pub(crate) fn get_default_process( + section: &Section, context: &BuildContext, gem_list: &GemList, ) -> Option { + let config_ru = build_output::fmt::value("config.ru"); + let rails = build_output::fmt::value("rails"); + let rack = build_output::fmt::value("rack"); + let railties = build_output::fmt::value("railties"); match detect_web(gem_list, &context.app_dir) { WebProcess::Rails => { - user::log_info("Detected railties gem"); - user::log_info("Setting default web process (rails)"); + section.say(format!("Detected rails app ({rails} gem)")); Some(default_rails()) } WebProcess::RackWithConfigRU => { - user::log_info("Detected rack gem"); - user::log_info("Found `config.ru` file at root of application"); - user::log_info("Setting default web process (rackup)"); + section.say(format!( + "Detected rack app ({rack} gem and {config_ru} at root of application)" + )); Some(default_rack()) } WebProcess::RackMissingConfigRu => { - user::log_info("Detected rack gem"); - user::log_info("Missing `config.ru` file at root of application"); - user::log_info("Skipping default web process"); + section.say(format!( + "Skipping default web process (detected {rack} gem but missing {config_ru} file" + )); None } WebProcess::Missing => { - user::log_info("No web gems found (railties, rack)"); - user::log_info("Skipping default web process"); + section.say(format!( + "Skipping default web process (no web gems detected: {rails}, {railties}, {rack}" + )); None } diff --git a/buildpacks/ruby/src/steps/rake_assets_install.rs b/buildpacks/ruby/src/steps/rake_assets_install.rs index d89d559..128ce3c 100644 --- a/buildpacks/ruby/src/steps/rake_assets_install.rs +++ b/buildpacks/ruby/src/steps/rake_assets_install.rs @@ -1,36 +1,49 @@ +use crate::build_output::{self, RunCommand, Section}; +use crate::rake_task_detect::RakeDetect; use crate::RubyBuildpack; use crate::RubyBuildpackError; use commons::cache::{mib, AppCacheCollection, CacheConfig, KeepPath}; use commons::fun_run::{self, CmdError, CmdMapExt}; -use commons::rake_task_detect::RakeDetect; use libcnb::build::BuildContext; use libcnb::Env; -use libherokubuildpack::command::CommandExt; -use libherokubuildpack::log as user; use std::process::Command; pub(crate) fn rake_assets_install( + section: &Section, context: &BuildContext, env: &Env, rake_detect: &RakeDetect, ) -> Result<(), RubyBuildpackError> { let cases = asset_cases(rake_detect); + let rake_assets_precompile = build_output::fmt::value("rake assets:precompile"); + let rake_assets_clean = build_output::fmt::value("rake assets:clean"); + let rake_detect_cmd = build_output::fmt::value("bundle exec rake -P"); + match cases { AssetCases::None => { - user::log_info("Skipping 'rake assets:precompile', task not found"); - user::log_info("Help: Ensure `bundle exec rake -P` includes this task"); + section.say_with_details( + format!("Skipping {rake_assets_precompile}"), + format!("task not found via {rake_detect_cmd}"), + ); + section.help("Enable compiling assets by ensuring that task is present when running the detect command locally"); } AssetCases::PrecompileOnly => { - user::log_info("Running 'rake assets:precompile', task found"); - user::log_info("Skipping 'rake assets:clean', task not found"); - user::log_info("Help: Ensure `bundle exec rake -P` includes this task"); + section.say_with_details( + "Compiling assets without cache", + format!("clean task not found via {rake_detect_cmd}"), + ); + section.help(format!("Enable caching by ensuring {rake_assets_clean} is present when running the detect command locally")); - run_rake_assets_precompile(env) + run_rake_assets_precompile(section, env) .map_err(RubyBuildpackError::RakeAssetsPrecompileFailed)?; } AssetCases::PrecompileAndClean => { - user::log_info("Running 'rake assets:precompile', task found"); - user::log_info("Running 'rake assets:clean', task found"); + section.say_with_details( + "Compiling assets with cache", + format!( + "detected {rake_assets_precompile} and {rake_assets_clean} via {rake_detect_cmd}" + ), + ); let cache_config = [ CacheConfig { @@ -45,11 +58,13 @@ pub(crate) fn rake_assets_install( }, ]; - let cache = - AppCacheCollection::new_and_load(context, cache_config, |log| user::log_info(log)) - .map_err(RubyBuildpackError::InAppDirCacheError)?; + let cache = { + let section = section.clone(); + AppCacheCollection::new_and_load(context, cache_config, move |log| section.say(log)) + .map_err(RubyBuildpackError::InAppDirCacheError)? + }; - run_rake_assets_precompile_with_clean(env) + run_rake_assets_precompile_with_clean(section, env) .map_err(RubyBuildpackError::RakeAssetsPrecompileFailed)?; cache @@ -61,27 +76,22 @@ pub(crate) fn rake_assets_install( Ok(()) } -fn run_rake_assets_precompile(env: &Env) -> Result<(), CmdError> { +fn run_rake_assets_precompile(section: &Section, env: &Env) -> Result<(), CmdError> { Command::new("bundle") .args(["exec", "rake", "assets:precompile", "--trace"]) .env_clear() .envs(env) .cmd_map(|cmd| { - let name = fun_run::display(cmd); - user::log_info(format!("Running $ {name}")); - - cmd.output_and_write_streams(std::io::stdout(), std::io::stderr()) - .map_err(|error| { - fun_run::annotate_which_problem(error, cmd, env.get("PATH").cloned()) - }) - .map_err(|error| fun_run::on_system_error(name.clone(), error)) - .and_then(|output| fun_run::nonzero_streamed(name.clone(), output)) + let path_env = env.get("PATH").cloned(); + section + .run(RunCommand::stream(cmd)) + .map_err(|error| fun_run::map_which_problem(error, cmd, path_env)) })?; Ok(()) } -fn run_rake_assets_precompile_with_clean(env: &Env) -> Result<(), CmdError> { +fn run_rake_assets_precompile_with_clean(section: &Section, env: &Env) -> Result<(), CmdError> { Command::new("bundle") .args([ "exec", @@ -93,15 +103,10 @@ fn run_rake_assets_precompile_with_clean(env: &Env) -> Result<(), CmdError> { .env_clear() .envs(env) .cmd_map(|cmd| { - let name = fun_run::display(cmd); - user::log_info(format!("Running $ {name}")); - - cmd.output_and_write_streams(std::io::stdout(), std::io::stderr()) - .map_err(|error| { - fun_run::annotate_which_problem(error, cmd, env.get("PATH").cloned()) - }) - .map_err(|error| fun_run::on_system_error(name.clone(), error)) - .and_then(|output| fun_run::nonzero_streamed(name.clone(), output)) + let path_env = env.get("PATH").cloned(); + section + .run(RunCommand::stream(cmd)) + .map_err(|error| fun_run::map_which_problem(error, cmd, path_env)) })?; Ok(()) diff --git a/buildpacks/ruby/src/user_errors.rs b/buildpacks/ruby/src/user_errors.rs index 7329944..0af7e17 100644 --- a/buildpacks/ruby/src/user_errors.rs +++ b/buildpacks/ruby/src/user_errors.rs @@ -1,12 +1,11 @@ use indoc::formatdoc; -use libherokubuildpack::log as user; -use crate::RubyBuildpackError; +use crate::{build_output::fmt::ErrorInfo, RubyBuildpackError}; pub(crate) fn on_error(err: libcnb::Error) { match cause(err) { Cause::OurError(error) => log_our_error(error), - Cause::FrameworkError(error) => user::log_error( + Cause::FrameworkError(error) => ErrorInfo::header_body_details( "heroku/buildpack-ruby internal buildpack error", formatdoc! {" An unexpected internal error was reported by the framework used @@ -15,52 +14,43 @@ pub(crate) fn on_error(err: libcnb::Error) { If the issue persists, consider opening an issue on the GitHub repository. If you are unable to deploy to Heroku as a result of this issue, consider opening a ticket for additional support. - - Details: - - {error} "}, - ), + error, + ) + .print(), }; } fn log_our_error(error: RubyBuildpackError) { match error { - RubyBuildpackError::RakeDetectError(error) => user::log_error( + RubyBuildpackError::RakeDetectError(error) => ErrorInfo::header_body_details( "Error detecting rake tasks", formatdoc! {" The Ruby buildpack uses rake task information from your application to guide build logic. Without this information, the Ruby buildpack cannot continue. - - Details: - - {error} - "}, - ), - RubyBuildpackError::GemListGetError(error) => user::log_error( + error, + ) + .print(), + RubyBuildpackError::GemListGetError(error) => ErrorInfo::header_body_details( "Error detecting dependencies", formatdoc! {" The Ruby buildpack uses dependency information from your application to guide build logic. Without this information, the Ruby buildpack cannot continue. - - Details: - - {error} "}, - ), - RubyBuildpackError::RubyInstallError(error) => user::log_error( + error, + ) + .print(), + RubyBuildpackError::RubyInstallError(error) => ErrorInfo::header_body_details( "Error installing Ruby", formatdoc! {" Could not install the detected Ruby version. - - Details: - - {error} "}, - ), - RubyBuildpackError::MissingGemfileLock(error) => user::log_error( + error, + ) + .print(), + RubyBuildpackError::MissingGemfileLock(error) => ErrorInfo::header_body_details( "Error: Gemfile.lock required", formatdoc! {" To deploy a Ruby application, a Gemfile.lock file is required in the @@ -68,23 +58,19 @@ fn log_our_error(error: RubyBuildpackError) { If you have a Gemfile.lock in your application, you may not have it tracked in git, or you may be on a different branch. - - Details: - - {error} "}, - ), - RubyBuildpackError::InAppDirCacheError(error) => user::log_error( + error, + ) + .print(), + RubyBuildpackError::InAppDirCacheError(error) => ErrorInfo::header_body_details( "Internal cache error", formatdoc! {" An internal error occured while caching files. - - Details: - - {error} "}, - ), - RubyBuildpackError::BundleInstallDigestError(error) => user::log_error( + error, + ) + .print(), + RubyBuildpackError::BundleInstallDigestError(error) => ErrorInfo::header_body_details( "Could not generate digest", formatdoc! {" To provide the fastest possible install experience the Ruby buildpack @@ -92,44 +78,37 @@ fn log_our_error(error: RubyBuildpackError) { used in cache invalidation. While performing this process there was an unexpected internal error. - - Details: - {error} "}, - ), - RubyBuildpackError::BundleInstallCommandError(error) => user::log_error( + error, + ) + .print(), + RubyBuildpackError::BundleInstallCommandError(error) => ErrorInfo::header_body_details( "Error installing bundler", formatdoc! {" Installation of bundler failed. Bundler is the package managment library for Ruby. Bundler is needed to install your application's dependencies listed in the Gemfile. - - Command failed: - - {error} "}, - ), - RubyBuildpackError::RakeAssetsPrecompileFailed(error) => user::log_error( + error, + ) + .print(), + RubyBuildpackError::RakeAssetsPrecompileFailed(error) => ErrorInfo::header_body_details( "Asset compilation failed", formatdoc! {" An error occured while compiling assets via rake command. - - Command failed: - - {error} "}, - ), - RubyBuildpackError::GemInstallBundlerCommandError(error) => user::log_error( + error, + ) + .print(), + RubyBuildpackError::GemInstallBundlerCommandError(error) => ErrorInfo::header_body_details( "Installing gems failed", formatdoc! {" Could not install gems to the system via bundler. Gems are dependencies your application listed in the Gemfile and resolved in the Gemfile.lock. - - Command failed: - - {error} "}, - ), + error, + ) + .print(), } } diff --git a/buildpacks/ruby/tests/integration_test.rs b/buildpacks/ruby/tests/integration_test.rs index 7533f0a..dfe9f3f 100644 --- a/buildpacks/ruby/tests/integration_test.rs +++ b/buildpacks/ruby/tests/integration_test.rs @@ -16,17 +16,17 @@ fn test_default_app() { BuildConfig::new("heroku/builder:22", "tests/fixtures/default_ruby") .buildpacks(vec![BuildpackReference::Crate]), |context| { - assert_contains!(context.pack_stdout, "[Installing Ruby]"); + assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); assert_contains!( context.pack_stdout, - r#"$ BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install"#); + r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"#); println!("{}", context.pack_stdout); // Needed to get full failure as `rebuild` truncates stdout assert_contains!(context.pack_stdout, "Installing webrick"); let config = context.config.clone(); context.rebuild(config, |rebuild_context| { - assert_contains!(rebuild_context.pack_stdout, "Skipping 'bundle install', no changes found in /workspace/Gemfile, /workspace/Gemfile.lock, or user configured environment variables"); + assert_contains!(rebuild_context.pack_stdout, "Skipping `bundle install` (no changes found in /workspace/Gemfile, /workspace/Gemfile.lock, or user configured environment variables)"); rebuild_context.start_container( ContainerConfig::new() @@ -84,13 +84,12 @@ DEPENDENCIES BuildpackReference::Crate, ]), |context| { - assert_contains!(context.pack_stdout, "[Installing Ruby]"); + assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); assert_contains!( context.pack_stdout, - r#"$ BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install"#); - - assert_contains!(context.pack_stdout, "Installing ruby 2.6.8-jruby-9.3.6.0"); - + r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"# + ); + assert_contains!(context.pack_stdout, "Ruby version `2.6.8-jruby-9.3.6.0` from `Gemfile.lock`"); }); } @@ -105,11 +104,10 @@ fn test_ruby_app_with_yarn_app() { BuildpackReference::Crate, ]), |context| { - assert_contains!(context.pack_stdout, "[Installing Ruby]"); + assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); assert_contains!( context.pack_stdout, - r#"$ BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install"#); - + r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"#); } ); } diff --git a/commons/Cargo.toml b/commons/Cargo.toml index a685c7e..20557a8 100644 --- a/commons/Cargo.toml +++ b/commons/Cargo.toml @@ -19,8 +19,9 @@ tempfile = "3" thiserror = "1" walkdir = "2" which_problem = "0.1" +indoc = "2.0.1" +libherokubuildpack = "0.13" [dev-dependencies] filetime = "0.2" -libherokubuildpack = "0.13" toml = "0.7" diff --git a/commons/src/build_output.rs b/commons/src/build_output.rs new file mode 100644 index 0000000..1bc6cb9 --- /dev/null +++ b/commons/src/build_output.rs @@ -0,0 +1,717 @@ +use crate::fun_run::{self, CmdError}; +use libherokubuildpack::write::{line_mapped, mappers::add_prefix}; +use std::io::Write; +use std::time::Instant; +use time::BuildpackDuration; + +pub use section::{RunCommand, Section}; + +/// Build with style +/// +/// ```rust,no_run +/// use commons::build_output::{self, RunCommand}; +/// use std::process::Command; +/// +/// // Announce your buildpack and time it +/// let timer = build_output::buildpack_name("Buildpack name"); +/// // Do stuff here +/// timer.done(); +/// +/// // Create section with a topic +/// let section = build_output::section("Ruby version"); +/// +/// // Output stuff in that section +/// section.say("Installing"); +/// section.say_with_details("Installing", "important stuff"); +/// +/// // Live stream a progress timer in that section +/// let mut timer = section.say_with_inline_timer("Installing with progress"); +/// // Do stuff here +/// timer.done(); +/// +/// // Decorate and format your output +/// let version = build_output::fmt::value("3.1.2"); +/// section.say(format!("Installing {version}")); +/// +/// // Run a command in that section with a variety of formatting options +/// // Stream the output to the user: +/// section +/// .run(RunCommand::stream( +/// Command::new("echo").args(&["hello world"]), +/// )) +/// .unwrap(); +/// +/// // Run a command after announcing it. Show a progress timer but don't stream the output : +/// section +/// .run(RunCommand::inline_progress( +/// Command::new("echo").args(&["hello world"]), +/// )) +/// .unwrap(); +/// +/// +/// // Run a command with no output: +/// section +/// .run(RunCommand::silent( +/// Command::new("echo").args(&["hello world"]), +/// )) +/// .unwrap(); +/// +/// // Control the display of the command being run: +/// section +/// .run(RunCommand::stream( +/// Command::new("bash").args(&["-c", "exec", "echo \"hello world\""]), +/// ).with_name("echo \"hello world\"")) +/// .unwrap(); +///``` + +mod time { + use super::{fmt, raw_inline_print}; + use std::thread::{self, JoinHandle}; + use std::time::Duration; + use std::time::Instant; + + /// Time the entire buildpack execution + pub struct BuildpackDuration { + pub(crate) start: Instant, + } + + impl BuildpackDuration { + /// Emit timing details with done block + pub fn done_timed(self) { + let time = human(&self.start.elapsed()); + let details = fmt::details(format!("finished in {time}")); + println!("- Done {details}"); + } + + /// Emit done block without timing details + #[allow(clippy::unused_self)] + pub fn done(self) { + println!("- Done"); + } + + /// Finish without announcing anything + #[allow(clippy::unused_self)] + pub fn done_silently(self) {} + } + + /// Handles outputing inline progress based on timing + /// + /// i.e. `- Installing ... (5.733s)` + /// + /// In this example the dashes roughly equate to seconds. + /// The moving output in the build indicates we're waiting for something + pub struct LiveTimingInline { + start: Instant, + stop_dots: std::sync::mpsc::Sender, + join_dots: Option>, + } + + impl Default for LiveTimingInline { + fn default() -> Self { + Self::new() + } + } + + impl LiveTimingInline { + #[must_use] + pub fn new() -> Self { + let (stop_dots, receiver) = std::sync::mpsc::channel(); + + let join_dots = thread::spawn(move || { + raw_inline_print(fmt::colorize(fmt::DEFAULT_DIM, " .")); + + loop { + let msg = receiver.recv_timeout(Duration::from_secs(1)); + raw_inline_print(fmt::colorize(fmt::DEFAULT_DIM, ".")); + + if msg.is_ok() { + raw_inline_print(fmt::colorize(fmt::DEFAULT_DIM, ". ")); + break; + } + } + }); + + Self { + stop_dots, + join_dots: Some(join_dots), + start: Instant::now(), + } + } + + fn stop_dots(&mut self) { + if let Some(handle) = self.join_dots.take() { + self.stop_dots.send(1).expect("Thread is not dead"); + handle.join().expect("Thread is joinable"); + } + } + + pub fn done(&mut self) { + self.stop_dots(); + let time = fmt::details(human(&self.start.elapsed())); + + println!("{time}"); + } + } + + // 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 + } + + 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()); + } + } +} + +// Helper for printing without newlines that auto-flushes stdout +fn raw_inline_print(contents: impl AsRef) { + let contents = contents.as_ref(); + print!("{contents}"); + std::io::stdout().flush().expect("Stdout is writable"); +} + +/// All work is done inside of a section. Advertize a section topic +pub fn section(topic: impl AsRef) -> section::Section { + let topic = String::from(topic.as_ref()); + println!("- {topic}"); + + section::Section { topic } +} + +/// Top level buildpack header +/// +/// Should only use once per buildpack +#[must_use] +pub fn buildpack_name(buildpack: impl AsRef) -> BuildpackDuration { + let header = fmt::header(buildpack.as_ref()); + println!("{header}"); + println!(); + + let start = Instant::now(); + BuildpackDuration { start } +} + +mod section { + use super::{ + add_prefix, fmt, fun_run, line_mapped, raw_inline_print, time, time::LiveTimingInline, + CmdError, Instant, + }; + use libherokubuildpack::command::CommandExt; + use std::process::{Command, Output}; + + const CMD_INDENT: &str = " "; + const SECTION_INDENT: &str = " "; + const SECTION_PREFIX: &str = " - "; + + #[derive(Debug, Clone, Eq, PartialEq)] + pub struct Section { + pub(crate) topic: String, + } + + impl Section { + /// Emit contents to the buid output with indentation + pub fn say(&self, contents: impl AsRef) { + let contents = contents.as_ref(); + println!("{SECTION_PREFIX}{contents}"); + } + + pub fn say_with_details(&self, contents: impl AsRef, details: impl AsRef) { + let contents = contents.as_ref(); + let details = fmt::details(details.as_ref()); + + println!("{SECTION_PREFIX}{contents} {details}"); + } + + /// Emit an indented help section with a "! Help:" prefix auto added + pub fn help(&self, contents: impl AsRef) { + let contents = fmt::help(contents); + + println!("{SECTION_INDENT}{contents}"); + } + + /// Start a time and emit a reson for it + /// + /// The timer will emit an inline progress meter until `LiveTimingInline::done` is called + /// on it. + #[must_use] + pub fn say_with_inline_timer(&self, reason: impl AsRef) -> time::LiveTimingInline { + let reason = reason.as_ref(); + raw_inline_print(format!("{SECTION_PREFIX}{reason}")); + + time::LiveTimingInline::new() + } + + /// Run a command with the given configuration and name + /// + /// # Errors + /// + /// Returns an error if the command status is non-zero or if the + /// system cannot run the command. + pub fn run(&self, run_command: RunCommand) -> Result { + match run_command.output { + OutputConfig::Stream | OutputConfig::StreamNoTiming => { + Self::stream_command(self, run_command) + } + OutputConfig::Silent => Self::silent_command(self, run_command), + OutputConfig::InlineProgress => Self::inline_progress_command(self, run_command), + } + } + + /// If someone wants to build their own command invocation and wants to match styles with this + /// command runner they'll need access to the prefix for consistent execution. + #[must_use] + pub fn cmd_stream_prefix() -> String { + String::from(CMD_INDENT) + } + + /// Run a command and output nothing to the screen + fn silent_command(_section: &Section, run_command: RunCommand) -> Result { + let RunCommand { + command, + name, + output: _, + } = run_command; + + command + .output() + .map_err(|error| fun_run::on_system_error(name.clone(), error)) + .and_then(|output| fun_run::nonzero_captured(name, output)) + } + + /// Run a command. Output command name, but don't stream the contents + fn inline_progress_command( + _section: &Section, + run_command: RunCommand, + ) -> Result { + let RunCommand { + command, + name, + output: _, + } = run_command; + let name = fmt::command(name); + + raw_inline_print(format!("{SECTION_PREFIX}Running {name}")); + + let mut start = LiveTimingInline::new(); + let output = command.output(); + let result = output + .map_err(|error| fun_run::on_system_error(name.clone(), error)) + .and_then(|output| fun_run::nonzero_captured(name, output)); + + start.done(); + + result + } + + /// Run a command. Output command name, and stream the contents + fn stream_command(section: &Section, run_command: RunCommand) -> Result { + let RunCommand { + command, + name, + output, + } = run_command; + let name = fmt::command(name); + + section.say(format!("Running {name}")); + println!(); // Weird output from prior stream adds indentation that's unwanted + + let start = Instant::now(); + let result = command + .output_and_write_streams( + line_mapped(std::io::stdout(), add_prefix(CMD_INDENT)), + line_mapped(std::io::stderr(), add_prefix(CMD_INDENT)), + ) + .map_err(|error| fun_run::on_system_error(name.clone(), error)) + .and_then(|output| fun_run::nonzero_streamed(name, output)); + + println!(); // Weird output from prior stream adds indentation that's unwanted + + let duration = start.elapsed(); + let time = fmt::details(time::human(&duration)); + match output { + OutputConfig::Stream => { + section.say(format!("Done {time}")); + } + OutputConfig::StreamNoTiming => section.say("Done {time}"), + OutputConfig::Silent | OutputConfig::InlineProgress => unreachable!(), + } + + result + } + } + + /// Specify how you want a command to be run by `Section::run` + pub struct RunCommand<'a> { + command: &'a mut Command, + name: String, + output: OutputConfig, + } + + impl<'a> RunCommand<'a> { + /// Generate a new `RunCommand` with a different name + #[must_use] + pub fn with_name(self, name: impl AsRef) -> Self { + let name = name.as_ref().to_string(); + let RunCommand { + command, + name: _, + output, + } = self; + + Self { + command, + name, + output, + } + } + + /// Announce and stream the output of a command + pub fn stream(command: &'a mut Command) -> Self { + let name = fun_run::display(command); + Self { + command, + name, + output: OutputConfig::Stream, + } + } + + /// Announce and stream the output of a command without timing information at the end + pub fn stream_without_timing(command: &'a mut Command) -> Self { + let name = fun_run::display(command); + Self { + command, + name, + output: OutputConfig::StreamNoTiming, + } + } + + /// Do not announce or stream output of a command + pub fn silent(command: &'a mut Command) -> Self { + let name = fun_run::display(command); + Self { + command, + name, + output: OutputConfig::Silent, + } + } + + /// Announce a command inline. Do not stream it's output. Emit inline progress timer. + pub fn inline_progress(command: &'a mut Command) -> Self { + let name = fun_run::display(command); + Self { + command, + name, + output: OutputConfig::InlineProgress, + } + } + } + + enum OutputConfig { + Stream, + StreamNoTiming, + Silent, + InlineProgress, + } +} + +pub mod fmt { + use indoc::formatdoc; + use std::fmt::Display; + + pub(crate) const RED: &str = "\x1B[0;31m"; + pub(crate) const YELLOW: &str = "\x1B[0;33m"; + pub(crate) const CYAN: &str = "\x1B[0;36m"; + + #[allow(dead_code)] + pub(crate) const PURPLE: &str = "\x1B[0;35m"; // magenta + + 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"; + + #[allow(dead_code)] + 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 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; + + /// Used to decorate a command being run i.e. `bundle install` + #[must_use] + pub fn command(contents: impl AsRef) -> String { + value(colorize(COMMAND_COLOR, contents.as_ref())) + } + + /// Used to decorate a derived or user configured value + #[must_use] + pub fn value(contents: impl AsRef) -> String { + let contents = colorize(VALUE_COLOR, contents.as_ref()); + format!("`{contents}`") + } + + /// Used to decorate additional information + #[must_use] + pub fn details(contents: impl AsRef) -> String { + let contents = contents.as_ref(); + format!("({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}")) + } + + /// Used to standardize error/warning/important information + pub(crate) fn look_at_me( + color: &str, + noun: impl AsRef, + header: impl AsRef, + body: impl AsRef, + url: &Option, + ) -> String { + let noun = noun.as_ref(); + let header = header.as_ref(); + let body = help_url(body, url); + colorize( + color, + bangify(formatdoc! {" + {noun} {header} + + {body} + "}), + ) + } + + #[must_use] + pub(crate) fn help(contents: impl AsRef) -> String { + let contents = contents.as_ref(); + colorize(IMPORTANT_COLOR, bangify(format!("Help: {contents}"))) + } + + /// Holds the contents of an error + /// + /// Designed so that additional optional fields may be added later without + /// breaking compatability + #[derive(Debug, Clone, Default)] + pub struct ErrorInfo { + header: String, + body: String, + url: Option, + debug_details: Option, + } + + impl ErrorInfo { + pub fn header_body_details( + header: impl AsRef, + body: impl AsRef, + details: impl Display, + ) -> Self { + Self { + header: header.as_ref().to_string(), + body: body.as_ref().to_string(), + debug_details: Some(details.to_string()), + ..Default::default() + } + } + + pub fn print(&self) { + println!("{self}"); + } + } + + impl Display for ErrorInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(error(self).as_str()) + } + } + + /// Need feedback on this interface + /// + /// Should it take fields or a struct + /// Struct is more robust to change but extra boilerplate + /// If the struct is always needed, this should perhaps be an associated function + #[must_use] + pub fn error(info: &ErrorInfo) -> String { + let ErrorInfo { + header, + body, + url, + debug_details, + } = info; + + let body = look_at_me(ERROR_COLOR, "ERROR:", header, body, url); + if let Some(details) = debug_details { + format!("{body}\n\nDebug information: {details}") + } else { + body + } + } + + /// Need feedback on this interface + /// + /// Should it have a dedicated struct like `ErrorInfo` or be a function? + /// Do we want to bundle warnings together and emit them at the end? (I think so, but it's out of current scope) + pub fn warning(header: impl AsRef, body: impl AsRef, url: &Option) -> String { + let header = header.as_ref(); + let body = body.as_ref(); + + look_at_me(WARNING_COLOR, "WARNING:", header, body, url) + } + + /// Need feedback on this interface + /// + /// Same questions as `warning` + pub fn important( + header: impl AsRef, + body: impl AsRef, + url: &Option, + ) -> String { + let header = header.as_ref(); + let body = body.as_ref(); + + look_at_me(IMPORTANT_COLOR, "", header, body, url) + } + + fn help_url(body: impl AsRef, url: &Option) -> String { + let body = body.as_ref(); + + if let Some(url) = url { + let url = colorize(URL_COLOR, url); + + formatdoc! {" + {body} + + For more information, refer to the following documentation: + {url} + "} + } else { + body.to_string() + } + } + + /// Helper method that adds a bang i.e. `!` before strings + fn bangify(body: impl AsRef) -> String { + body.as_ref() + .split('\n') + .map(|section| format!("! {section}")) + .collect::>() + .join("\n") + } + + /// 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') + .map(|section| section.replace(RESET, &format!("{RESET}{color}"))) // Handles nested color + .map(|section| format!("{color}{section}{RESET}")) // Clear after every newline + .map(|section| section.replace(&format!("{RESET}{color}{RESET}"), RESET)) // Reduce useless color + .map(|section| section.replace(&format!("{color}{color}"), color)) // Reduce useless color + .collect::>() + .join("\n") + } + + #[cfg(test)] + mod test { + use super::*; + + #[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); + } + } +} diff --git a/commons/src/cache/app_cache_collection.rs b/commons/src/cache/app_cache_collection.rs index 2328781..7bb9c42 100644 --- a/commons/src/cache/app_cache_collection.rs +++ b/commons/src/cache/app_cache_collection.rs @@ -80,6 +80,7 @@ impl AppCacheCollection { logger: impl Fn(&str) + 'static, ) -> Result { let log_func = LogFunc(Box::new(logger)); + let caches = config .into_iter() .map(|config| { diff --git a/commons/src/fun_run.rs b/commons/src/fun_run.rs index 96bef7a..dfb78f2 100644 --- a/commons/src/fun_run.rs +++ b/commons/src/fun_run.rs @@ -162,6 +162,24 @@ where .join(" ") } +/// Adds diagnostic information to a `CmdError` using `which_problem` if error is `std::io::Error` +/// +/// This feature is experimental +pub fn map_which_problem( + error: CmdError, + cmd: &mut Command, + path_env: Option, +) -> CmdError { + match error { + CmdError::SystemError(name, error) => { + CmdError::SystemError(name, annotate_which_problem(error, cmd, path_env)) + } + CmdError::NonZeroExitNotStreamed(_, _) | CmdError::NonZeroExitAlreadyStreamed(_, _) => { + error + } + } +} + /// Adds diagnostic information to an `std::io::Error` using `which_problem` /// /// This feature is experimental diff --git a/commons/src/gemfile_lock.rs b/commons/src/gemfile_lock.rs index 5cd3f53..dbe563c 100644 --- a/commons/src/gemfile_lock.rs +++ b/commons/src/gemfile_lock.rs @@ -47,6 +47,22 @@ pub struct GemfileLock { } impl GemfileLock { + #[must_use] + pub fn ruby_source(&self) -> String { + match self.ruby_version { + RubyVersion::Explicit(_) => String::from("Gemfile.lock"), + RubyVersion::Default => String::from("default"), + } + } + + #[must_use] + pub fn bundler_source(&self) -> String { + match self.bundler_version { + BundlerVersion::Explicit(_) => String::from("Gemfile.lock"), + BundlerVersion::Default => String::from("default"), + } + } + #[must_use] pub fn resolve_ruby(&self, default: &str) -> ResolvedRubyVersion { match &self.ruby_version { diff --git a/commons/src/lib.rs b/commons/src/lib.rs index 79572ed..0cef16e 100644 --- a/commons/src/lib.rs +++ b/commons/src/lib.rs @@ -1,15 +1,13 @@ #![warn(unused_crate_dependencies)] #![warn(clippy::pedantic)] +pub mod build_output; pub mod cache; pub mod display; pub mod fun_run; -pub mod gem_list; pub mod gem_version; pub mod gemfile_lock; pub mod layer; pub mod metadata_digest; -pub mod rake_status; -pub mod rake_task_detect; mod err;