Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apt - Minimum Viable Buildpack - 02 - Aptfile Parsing #2

Merged
merged 7 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
690 changes: 665 additions & 25 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ edition = "2021"
rust-version = "1.76"

[dependencies]
commons = { git = "https://github.com/heroku/buildpacks-ruby", branch = "main" }
libcnb = "=0.18.0"

[dev-dependencies]
libcnb-test = "=0.18.0"
indoc = "2"

[lints.rust]
unreachable_pub = "warn"
Expand Down
136 changes: 136 additions & 0 deletions src/aptfile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use std::collections::HashSet;
use std::str::FromStr;

#[derive(Debug, Eq, PartialEq)]
pub(crate) struct Aptfile {
packages: HashSet<DebianPackageName>,
}

impl FromStr for Aptfile {
type Err = ParseAptfileError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
value
.lines()
.map(str::trim)
.filter(|line| !line.starts_with('#') && !line.is_empty())
.map(DebianPackageName::from_str)
.collect::<Result<HashSet<_>, _>>()
edmorley marked this conversation as resolved.
Show resolved Hide resolved
.map_err(ParseAptfileError)
.map(|packages| Aptfile { packages })
}
}

#[derive(Debug, PartialEq)]
pub(crate) struct ParseAptfileError(ParseDebianPackageNameError);

#[derive(Debug, Eq, PartialEq, Hash)]
pub(crate) struct DebianPackageName(String);

impl FromStr for DebianPackageName {
type Err = ParseDebianPackageNameError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
colincasey marked this conversation as resolved.
Show resolved Hide resolved
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#source
// Package names (both source and binary, see Package) must consist only of
// lower case letters (a-z), digits (0-9), plus (+) and minus (-) signs,
// and periods (.). They must be at least two characters long and must
// start with an alphanumeric character.
let is_valid_package_name = value
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '+' | '-' | '.'))
&& value.chars().count() >= 2
&& value.starts_with(|c: char| c.is_ascii_alphanumeric());

if is_valid_package_name {
Ok(DebianPackageName(value.to_string()))
} else {
Err(ParseDebianPackageNameError(value.to_string()))
}
}
}

#[derive(Debug, PartialEq)]
pub(crate) struct ParseDebianPackageNameError(String);

#[cfg(test)]
mod tests {
edmorley marked this conversation as resolved.
Show resolved Hide resolved
use super::*;
use indoc::indoc;

#[test]
fn parse_valid_debian_package_name() {
let valid_names = [
"a0", // min length, starting with number
"0a", // min length, starting with letter
"g++", // alphanumeric to start followed by non-alphanumeric characters
"libevent-2.1-6", // just a mix of allowed characters
"a0+.-", // all the allowed characters
];
for valid_name in valid_names {
assert_eq!(
DebianPackageName::from_str(valid_name).unwrap(),
DebianPackageName(valid_name.to_string())
);
}
}

#[test]
fn parse_invalid_debian_package_name() {
colincasey marked this conversation as resolved.
Show resolved Hide resolved
let invalid_names = [
"a", // too short
"+a", // can't start with non-alphanumeric character
"ab_c", // can't contain invalid characters
"aBc", // uppercase is not allowed
"package=1.2.3-1", // versioning is not allowed, package name only
];
for invalid_name in invalid_names {
colincasey marked this conversation as resolved.
Show resolved Hide resolved
assert_eq!(
DebianPackageName::from_str(invalid_name).unwrap_err(),
ParseDebianPackageNameError(invalid_name.to_string())
);
}
}

#[test]
fn parse_aptfile() {
let aptfile = Aptfile::from_str(indoc! { "
# comment line
# comment line with leading whitespace

package-name-1
package-name-2

# Package name has leading and trailing whitespace
package-name-3 \t
# Duplicates are allowed (at least for now)
package-name-1

" })
.unwrap();
assert_eq!(
aptfile,
Aptfile {
packages: HashSet::from([
DebianPackageName("package-name-1".to_string()),
DebianPackageName("package-name-2".to_string()),
DebianPackageName("package-name-3".to_string()),
])
}
);
}

#[test]
fn parse_invalid_aptfile() {
let error = Aptfile::from_str(indoc! { "
invalid package name!
" })
.unwrap_err();
assert_eq!(
error,
ParseAptfileError(ParseDebianPackageNameError(
"invalid package name!".to_string()
))
);
}
}
15 changes: 14 additions & 1 deletion src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,15 @@
use crate::aptfile::ParseAptfileError;

#[derive(Debug)]
pub(crate) enum AptBuildpackError {}
#[allow(clippy::enum_variant_names)]
pub(crate) enum AptBuildpackError {
DetectAptfile(std::io::Error),
ReadAptfile(std::io::Error),
ParseAptfile(ParseAptfileError),
}

impl From<AptBuildpackError> for libcnb::Error<AptBuildpackError> {
fn from(value: AptBuildpackError) -> Self {
Self::BuildpackError(value)
}
}
38 changes: 32 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,54 @@
use crate::aptfile::Aptfile;
use crate::errors::AptBuildpackError;
use libcnb::build::{BuildContext, BuildResult};
use libcnb::detect::{DetectContext, DetectResult};
use commons::output::build_log::{BuildLog, Logger};
use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder};
use libcnb::detect::{DetectContext, DetectResult, DetectResultBuilder};
use libcnb::generic::{GenericMetadata, GenericPlatform};
use libcnb::{buildpack_main, Buildpack};
use std::fs;
use std::io::stdout;

#[cfg(test)]
use libcnb_test as _;

mod aptfile;
mod errors;

buildpack_main!(AptBuildpack);

const APTFILE_PATH: &str = "Aptfile";

struct AptBuildpack;

impl Buildpack for AptBuildpack {
type Platform = GenericPlatform;
type Metadata = GenericMetadata;
type Error = AptBuildpackError;

fn detect(&self, _context: DetectContext<Self>) -> libcnb::Result<DetectResult, Self::Error> {
todo!()
fn detect(&self, context: DetectContext<Self>) -> libcnb::Result<DetectResult, Self::Error> {
let aptfile_exists = context
.app_dir
.join(APTFILE_PATH)
.try_exists()
.map_err(AptBuildpackError::DetectAptfile)?;

if aptfile_exists {
DetectResultBuilder::pass().build()
} else {
BuildLog::new(stdout())
.without_buildpack_name()
.announce()
.warning("No Aptfile found.");
DetectResultBuilder::fail().build()
colincasey marked this conversation as resolved.
Show resolved Hide resolved
}
}

fn build(&self, _context: BuildContext<Self>) -> libcnb::Result<BuildResult, Self::Error> {
todo!()
fn build(&self, context: BuildContext<Self>) -> libcnb::Result<BuildResult, Self::Error> {
let _aptfile: Aptfile = fs::read_to_string(context.app_dir.join(APTFILE_PATH))
.map_err(AptBuildpackError::ReadAptfile)?
.parse()
.map_err(AptBuildpackError::ParseAptfile)?;

BuildResultBuilder::new().build()
}
}
Empty file added tests/fixtures/basic/Aptfile
Empty file.
Empty file.
30 changes: 30 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,33 @@

// Required due to: https://github.com/rust-lang/rust/issues/95513
#![allow(unused_crate_dependencies)]

colincasey marked this conversation as resolved.
Show resolved Hide resolved
use libcnb_test::{assert_contains, BuildConfig, PackResult, TestRunner};

#[test]
#[ignore = "integration test"]
fn test_successful_detection() {
TestRunner::default().build(
BuildConfig::new(get_integration_test_builder(), "tests/fixtures/basic")
.expected_pack_result(PackResult::Success),
colincasey marked this conversation as resolved.
Show resolved Hide resolved
|_| {},
);
}

#[test]
#[ignore = "integration test"]
fn test_failed_detection() {
TestRunner::default().build(
BuildConfig::new(get_integration_test_builder(), "tests/fixtures/no_aptfile")
.expected_pack_result(PackResult::Failure),
|ctx| {
assert_contains!(ctx.pack_stdout, "No Aptfile found.");
},
);
}

const DEFAULT_BUILDER: &str = "heroku/builder:22";

fn get_integration_test_builder() -> String {
std::env::var("INTEGRATION_TEST_CNB_BUILDER").unwrap_or(DEFAULT_BUILDER.to_string())
}