Merge pull request #151 from tavianator/globs

POSIX compliant globs
This commit is contained in:
Sylvestre Ledru
2022-03-05 21:01:56 +01:00
committed by GitHub
11 changed files with 468 additions and 144 deletions
Generated
-1
View File
@@ -223,7 +223,6 @@ dependencies = [
"chrono",
"clap",
"filetime",
"glob",
"once_cell",
"onig",
"predicates",
-1
View File
@@ -12,7 +12,6 @@ authors = ["uutils developers"]
[dependencies]
chrono = "0.4"
clap = "2.34"
glob = "0.3"
walkdir = "2.3"
regex = "1.5"
once_cell = "1.10"
-3
View File
@@ -4,9 +4,6 @@
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
extern crate findutils;
extern crate glob;
fn main() {
let args = std::env::args().collect::<Vec<String>>();
let strs: Vec<&str> = args.iter().map(|s| s.as_ref()).collect();
+238
View File
@@ -0,0 +1,238 @@
// Copyright 2022 Tavian Barnes
//
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
use onig::{self, Regex, RegexOptions, Syntax};
/// Parse a string as a POSIX Basic Regular Expression.
fn parse_bre(expr: &str, options: RegexOptions) -> Result<Regex, onig::Error> {
let bre = Syntax::posix_basic();
Regex::with_options(expr, bre.options() | options, bre)
}
/// Push a literal character onto a regex, escaping it if necessary.
fn regex_push_literal(regex: &mut String, ch: char) {
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html#tag_09_03_03
if matches!(ch, '.' | '[' | '\\' | '*' | '^' | '$') {
regex.push('\\');
}
regex.push(ch);
}
/// Extracts a bracket expression from a glob.
fn extract_bracket_expr(pattern: &str) -> Option<(String, &str)> {
// https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_13_01
//
// If an open bracket introduces a bracket expression as in XBD RE Bracket Expression,
// except that the <exclamation-mark> character ( '!' ) shall replace the <circumflex>
// character ( '^' ) in its role in a non-matching list in the regular expression notation,
// it shall introduce a pattern bracket expression. A bracket expression starting with an
// unquoted <circumflex> character produces unspecified results. Otherwise, '[' shall match
// the character itself.
//
// To check for valid bracket expressions, we scan for the closing bracket and
// attempt to parse that segment as a regex. If that fails, we treat the '['
// literally.
let mut expr = "[".to_string();
let mut chars = pattern.chars();
let mut next = chars.next();
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html#tag_09_03_05
//
// 3. A non-matching list expression begins with a <circumflex> ( '^' ) ...
//
// (but in a glob, '!' is used instead of '^')
if next == Some('!') {
expr.push('^');
next = chars.next();
}
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html#tag_09_03_05
//
// 1. ... The <right-square-bracket> ( ']' ) shall lose its special meaning and represent
// itself in a bracket expression if it occurs first in the list (after an initial
// <circumflex> ( '^' ), if any).
if next == Some(']') {
expr.push(']');
next = chars.next();
}
while let Some(ch) = next {
expr.push(ch);
match ch {
'[' => {
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html#tag_09_03_05
//
// 4. A collating symbol is a collating element enclosed within bracket-period
// ( "[." and ".]" ) delimiters. ...
//
// 5. An equivalence class expression shall ... be expressed by enclosing any
// one of the collating elements in the equivalence class within bracket-
// equal ( "[=" and "=]" ) delimiters.
//
// 6. ... A character class expression is expressed as a character class name
// enclosed within bracket- <colon> ( "[:" and ":]" ) delimiters.
next = chars.next();
if let Some(delim) = next {
expr.push(delim);
if matches!(delim, '.' | '=' | ':') {
let rest = chars.as_str();
let end = rest.find([delim, ']'])? + 2;
expr.push_str(&rest[..end]);
chars = rest[end..].chars();
}
}
}
']' => {
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html#tag_09_03_05
//
// 1. ... The <right-square-bracket> ( ']' ) shall ... terminate the bracket
// expression, unless it appears in a collating symbol (such as "[.].]" ) or is
// the ending <right-square-bracket> for a collating symbol, equivalence class,
// or character class.
break;
}
_ => {}
}
next = chars.next();
}
if parse_bre(&expr, RegexOptions::REGEX_OPTION_NONE).is_ok() {
Some((expr, chars.as_str()))
} else {
None
}
}
/// Converts a POSIX glob into a POSIX Basic Regular Expression
fn glob_to_regex(pattern: &str) -> String {
let mut regex = String::new();
let mut chars = pattern.chars();
while let Some(ch) = chars.next() {
// https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_13
match ch {
'?' => regex.push('.'),
'*' => regex.push_str(".*"),
'\\' => {
if let Some(ch) = chars.next() {
regex_push_literal(&mut regex, ch);
} else {
// https://pubs.opengroup.org/onlinepubs/9699919799/functions/fnmatch.html
//
// If pattern ends with an unescaped <backslash>, fnmatch() shall return a
// non-zero value (indicating either no match or an error).
//
// Most implementations return FNM_NOMATCH in this case, so return a regex that
// never matches.
return "$.".to_string();
}
}
'[' => {
if let Some((expr, rest)) = extract_bracket_expr(chars.as_str()) {
regex.push_str(&expr);
chars = rest.chars();
} else {
regex_push_literal(&mut regex, ch);
}
}
_ => regex_push_literal(&mut regex, ch),
}
}
regex
}
/// An fnmatch()-style glob matcher.
pub struct Pattern {
regex: Regex,
}
impl Pattern {
/// Parse an fnmatch()-style glob.
pub fn new(pattern: &str, caseless: bool) -> Self {
let options = if caseless {
RegexOptions::REGEX_OPTION_IGNORECASE
} else {
RegexOptions::REGEX_OPTION_NONE
};
// As long as glob_to_regex() is correct, this should never fail
let regex = parse_bre(&glob_to_regex(pattern), options).unwrap();
Self { regex }
}
/// Test if this patern matches a string.
pub fn matches(&self, string: &str) -> bool {
self.regex.is_match(string)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn literals() {
assert_eq!(glob_to_regex(r"foo.bar"), r"foo\.bar");
}
#[test]
fn regex_special() {
assert_eq!(glob_to_regex(r"^foo.bar$"), r"\^foo\.bar\$");
}
#[test]
fn wildcards() {
assert_eq!(glob_to_regex(r"foo?bar*baz"), r"foo.bar.*baz");
}
#[test]
fn escapes() {
assert_eq!(glob_to_regex(r"fo\o\?bar\*baz\\"), r"foo?bar\*baz\\");
}
#[test]
fn incomplete_escape() {
assert_eq!(glob_to_regex(r"foo\"), r"$.")
}
#[test]
fn valid_brackets() {
assert_eq!(glob_to_regex(r"foo[bar][!baz]"), r"foo[bar][^baz]");
}
#[test]
fn complex_brackets() {
assert_eq!(
glob_to_regex(r"[!]!.*[\[.].][=]=][:space:]-]"),
r"[^]!.*[\[.].][=]=][:space:]-]"
);
}
#[test]
fn invalid_brackets() {
assert_eq!(glob_to_regex(r"foo[bar[!baz"), r"foo\[bar\[!baz");
}
#[test]
fn pattern_matches() {
assert!(Pattern::new(r"foo*bar", false).matches("foo--bar"));
assert!(!Pattern::new(r"foo*bar", false).matches("bar--foo"));
}
#[test]
fn caseless_matches() {
assert!(Pattern::new(r"foo*BAR", true).matches("FOO--bar"));
assert!(!Pattern::new(r"foo*BAR", true).matches("BAR--foo"));
}
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright 2017 Google Inc.
//
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
use std::io::{stderr, Write};
use std::path::PathBuf;
use walkdir::DirEntry;
use super::glob::Pattern;
use super::{Matcher, MatcherIO};
fn read_link_target(file_info: &DirEntry) -> Option<PathBuf> {
match file_info.path().read_link() {
Ok(target) => Some(target),
Err(err) => {
// If it's not a symlink, then it's not an error that should be
// shown.
if err.kind() != std::io::ErrorKind::InvalidInput {
writeln!(
&mut stderr(),
"Error reading target of {}: {}",
file_info.path().display(),
err
)
.unwrap();
}
None
}
}
}
/// This matcher makes a comparison of the link target against a shell wildcard
/// pattern. See `glob::Pattern` for details on the exact syntax.
pub struct LinkNameMatcher {
pattern: Pattern,
}
impl LinkNameMatcher {
pub fn new(pattern_string: &str, caseless: bool) -> LinkNameMatcher {
let pattern = Pattern::new(pattern_string, caseless);
Self { pattern }
}
}
impl Matcher for LinkNameMatcher {
fn matches(&self, file_info: &DirEntry, _: &mut MatcherIO) -> bool {
if let Some(target) = read_link_target(file_info) {
self.pattern.matches(&target.to_string_lossy())
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::find::matchers::tests::get_dir_entry_for;
use crate::find::matchers::Matcher;
use crate::find::tests::FakeDependencies;
use std::io::ErrorKind;
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[cfg(windows)]
use std::os::windows::fs::symlink_file;
fn create_file_link() {
#[cfg(unix)]
if let Err(e) = symlink("abbbc", "test_data/links/link-f") {
if e.kind() != ErrorKind::AlreadyExists {
panic!("Failed to create sym link: {:?}", e);
}
}
#[cfg(windows)]
if let Err(e) = symlink_file("abbbc", "test_data/links/link-f") {
if e.kind() != ErrorKind::AlreadyExists {
panic!("Failed to create sym link: {:?}", e);
}
}
}
#[test]
fn matches_against_link_target() {
create_file_link();
let link_f = get_dir_entry_for("test_data/links", "link-f");
let matcher = LinkNameMatcher::new("ab?bc", false);
let deps = FakeDependencies::new();
assert!(matcher.matches(&link_f, &mut deps.new_matcher_io()));
}
#[test]
fn caseless_matches_against_link_target() {
create_file_link();
let link_f = get_dir_entry_for("test_data/links", "link-f");
let matcher = LinkNameMatcher::new("AbB?c", true);
let deps = FakeDependencies::new();
assert!(matcher.matches(&link_f, &mut deps.new_matcher_io()));
}
}
+17 -5
View File
@@ -7,8 +7,11 @@
mod delete;
mod empty;
pub mod exec;
mod glob;
mod lname;
mod logical_matchers;
mod name;
mod path;
mod perm;
mod printer;
mod printf;
@@ -28,10 +31,12 @@ use walkdir::DirEntry;
use self::delete::DeleteMatcher;
use self::empty::EmptyMatcher;
use self::exec::SingleExecMatcher;
use self::lname::LinkNameMatcher;
use self::logical_matchers::{
AndMatcherBuilder, FalseMatcher, ListMatcherBuilder, NotMatcher, TrueMatcher,
};
use self::name::{CaselessNameMatcher, NameMatcher};
use self::name::NameMatcher;
use self::path::PathMatcher;
use self::perm::PermMatcher;
use self::printer::{PrintDelimiter, Printer};
use self::printf::Printf;
@@ -279,19 +284,26 @@ fn build_matcher_tree(
}
"-true" => Some(TrueMatcher.into_box()),
"-false" => Some(FalseMatcher.into_box()),
"-name" | "-lname" => {
"-lname" | "-ilname" => {
if i >= args.len() - 1 {
return Err(From::from(format!("missing argument to {}", args[i])));
}
i += 1;
Some(NameMatcher::new(args[i], args[i - 1].starts_with("-l"))?.into_box())
Some(LinkNameMatcher::new(args[i], args[i - 1].starts_with("-i")).into_box())
}
"-iname" | "-ilname" => {
"-name" | "-iname" => {
if i >= args.len() - 1 {
return Err(From::from(format!("missing argument to {}", args[i])));
}
i += 1;
Some(CaselessNameMatcher::new(args[i], args[i - 1].starts_with("-il"))?.into_box())
Some(NameMatcher::new(args[i], args[i - 1].starts_with("-i")).into_box())
}
"-path" | "-ipath" | "-wholename" | "-iwholename" => {
if i >= args.len() - 1 {
return Err(From::from(format!("missing argument to {}", args[i])));
}
i += 1;
Some(PathMatcher::new(args[i], args[i - 1].starts_with("-i")).into_box())
}
"-regextype" => {
if i >= args.len() - 1 {
+17 -126
View File
@@ -4,109 +4,32 @@
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
use std::io::stderr;
use std::io::Write;
use std::path::PathBuf;
use glob::Pattern;
use glob::PatternError;
use walkdir::DirEntry;
use super::glob::Pattern;
use super::{Matcher, MatcherIO};
fn read_link_target(file_info: &DirEntry) -> Option<PathBuf> {
match file_info.path().read_link() {
Ok(target) => Some(target),
Err(err) => {
// If it's not a symlink, then it's not an error that should be
// shown.
if err.kind() != std::io::ErrorKind::InvalidInput {
writeln!(
&mut stderr(),
"Error reading target of {}: {}",
file_info.path().display(),
err
)
.unwrap();
}
None
}
}
}
/// This matcher makes a case-sensitive comparison of the name against a
/// shell wildcard pattern. See `glob::Pattern` for details on the exact
/// syntax.
/// This matcher makes a comparison of the name against a shell wildcard
/// pattern. See `glob::Pattern` for details on the exact syntax.
pub struct NameMatcher {
pattern: Pattern,
match_link_target: bool,
}
impl NameMatcher {
pub fn new(pattern_string: &str, match_link_target: bool) -> Result<NameMatcher, PatternError> {
let p = Pattern::new(pattern_string)?;
Ok(NameMatcher {
pattern: p,
match_link_target,
})
pub fn new(pattern_string: &str, caseless: bool) -> Self {
let pattern = Pattern::new(pattern_string, caseless);
Self { pattern }
}
}
impl Matcher for NameMatcher {
fn matches(&self, file_info: &DirEntry, _: &mut MatcherIO) -> bool {
if self.match_link_target {
if let Some(target) = read_link_target(file_info) {
self.pattern.matches(&target.to_string_lossy())
} else {
false
}
} else {
self.pattern
.matches(file_info.file_name().to_string_lossy().as_ref())
}
}
}
/// This matcher makes a case-insensitive comparison of the name against a
/// shell wildcard pattern. See `glob::Pattern` for details on the exact
/// syntax.
pub struct CaselessNameMatcher {
pattern: Pattern,
match_link_target: bool,
}
impl CaselessNameMatcher {
pub fn new(
pattern_string: &str,
match_link_target: bool,
) -> Result<CaselessNameMatcher, PatternError> {
let p = Pattern::new(&pattern_string.to_lowercase())?;
Ok(CaselessNameMatcher {
pattern: p,
match_link_target,
})
}
}
impl super::Matcher for CaselessNameMatcher {
fn matches(&self, file_info: &DirEntry, _: &mut MatcherIO) -> bool {
if self.match_link_target {
if let Some(target) = read_link_target(file_info) {
self.pattern
.matches(&target.to_string_lossy().to_lowercase())
} else {
false
}
} else {
self.pattern
.matches(&file_info.file_name().to_string_lossy().to_lowercase())
}
let name = file_info.file_name().to_string_lossy();
self.pattern.matches(&name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::find::matchers::tests::get_dir_entry_for;
@@ -119,7 +42,7 @@ mod tests {
use std::os::unix::fs::symlink;
#[cfg(windows)]
use std::os::windows::fs::{symlink_dir, symlink_file};
use std::os::windows::fs::symlink_file;
fn create_file_link() {
#[cfg(unix)]
@@ -139,7 +62,7 @@ mod tests {
#[test]
fn matching_with_wrong_case_returns_false() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = NameMatcher::new(&"A*C".to_string(), false).unwrap();
let matcher = NameMatcher::new("A*C", false);
let deps = FakeDependencies::new();
assert!(!matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
@@ -147,7 +70,7 @@ mod tests {
#[test]
fn matching_with_right_case_returns_true() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = NameMatcher::new(&"abb?c".to_string(), false).unwrap();
let matcher = NameMatcher::new("abb?c", false);
let deps = FakeDependencies::new();
assert!(matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
@@ -155,7 +78,7 @@ mod tests {
#[test]
fn not_matching_returns_false() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = NameMatcher::new(&"shouldn't match".to_string(), false).unwrap();
let matcher = NameMatcher::new("shouldn't match", false);
let deps = FakeDependencies::new();
assert!(!matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
@@ -165,31 +88,15 @@ mod tests {
create_file_link();
let link_f = get_dir_entry_for("test_data/links", "link-f");
let matcher = NameMatcher::new("link?f", false).unwrap();
let matcher = NameMatcher::new("link?f", false);
let deps = FakeDependencies::new();
assert!(matcher.matches(&link_f, &mut deps.new_matcher_io()));
}
#[test]
fn matches_against_link_target_if_requested() {
create_file_link();
let link_f = get_dir_entry_for("test_data/links", "link-f");
let matcher = NameMatcher::new("ab?bc", true).unwrap();
let deps = FakeDependencies::new();
assert!(matcher.matches(&link_f, &mut deps.new_matcher_io()));
}
#[test]
fn cant_create_with_invalid_pattern() {
let result = NameMatcher::new(&"a**c".to_string(), false);
assert!(result.is_err());
}
#[test]
fn caseless_matching_with_wrong_case_returns_true() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = CaselessNameMatcher::new(&"A*C".to_string(), false).unwrap();
let matcher = NameMatcher::new("A*C", true);
let deps = FakeDependencies::new();
assert!(matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
@@ -197,7 +104,7 @@ mod tests {
#[test]
fn caseless_matching_with_right_case_returns_true() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = CaselessNameMatcher::new(&"abb?c".to_string(), false).unwrap();
let matcher = NameMatcher::new("abb?c", true);
let deps = FakeDependencies::new();
assert!(matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
@@ -205,7 +112,7 @@ mod tests {
#[test]
fn caseless_not_matching_returns_false() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = CaselessNameMatcher::new(&"shouldn't match".to_string(), false).unwrap();
let matcher = NameMatcher::new("shouldn't match", true);
let deps = FakeDependencies::new();
assert!(!matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
@@ -215,24 +122,8 @@ mod tests {
create_file_link();
let link_f = get_dir_entry_for("test_data/links", "link-f");
let matcher = CaselessNameMatcher::new("linK?f", false).unwrap();
let matcher = NameMatcher::new("linK?f", true);
let deps = FakeDependencies::new();
assert!(matcher.matches(&link_f, &mut deps.new_matcher_io()));
}
#[test]
fn caseless_matches_against_link_target_if_requested() {
create_file_link();
let link_f = get_dir_entry_for("test_data/links", "link-f");
let matcher = CaselessNameMatcher::new("AbB?c", true).unwrap();
let deps = FakeDependencies::new();
assert!(matcher.matches(&link_f, &mut deps.new_matcher_io()));
}
#[test]
fn caseless_cant_create_with_invalid_pattern() {
let result = CaselessNameMatcher::new(&"a**c".to_string(), false);
assert!(result.is_err());
}
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2017 Google Inc.
//
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
use walkdir::DirEntry;
use super::glob::Pattern;
use super::{Matcher, MatcherIO};
/// This matcher makes a comparison of the path against a shell wildcard
/// pattern. See `glob::Pattern` for details on the exact syntax.
pub struct PathMatcher {
pattern: Pattern,
}
impl PathMatcher {
pub fn new(pattern_string: &str, caseless: bool) -> Self {
let pattern = Pattern::new(pattern_string, caseless);
Self { pattern }
}
}
impl Matcher for PathMatcher {
fn matches(&self, file_info: &DirEntry, _: &mut MatcherIO) -> bool {
let path = file_info.path().to_string_lossy();
self.pattern.matches(&path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::find::matchers::tests::get_dir_entry_for;
use crate::find::matchers::Matcher;
use crate::find::tests::FakeDependencies;
// Variants of fix_up_slashes that properly escape the forward slashes for
// being in a glob.
#[cfg(windows)]
fn fix_up_glob_slashes(re: &str) -> String {
re.replace("/", "\\\\")
}
#[cfg(not(windows))]
fn fix_up_glob_slashes(re: &str) -> String {
re.to_owned()
}
#[test]
fn matching_against_whole_path() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = PathMatcher::new(&fix_up_glob_slashes("test_*/*/a*c"), false);
let deps = FakeDependencies::new();
assert!(matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
#[test]
fn not_matching_against_just_name() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = PathMatcher::new("a*c", false);
let deps = FakeDependencies::new();
assert!(!matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
#[test]
fn not_matching_against_wrong_case() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = PathMatcher::new(&fix_up_glob_slashes("test_*/*/A*C"), false);
let deps = FakeDependencies::new();
assert!(!matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
#[test]
fn caseless_matching() {
let abbbc = get_dir_entry_for("test_data/simple", "abbbc");
let matcher = PathMatcher::new(&fix_up_glob_slashes("test_*/*/A*C"), true);
let deps = FakeDependencies::new();
assert!(matcher.matches(&abbbc, &mut deps.new_matcher_io()));
}
}
+1 -1
View File
@@ -130,7 +130,7 @@ mod tests {
// being in a regex.
#[cfg(windows)]
fn fix_up_regex_slashes(re: &str) -> String {
re.replace("/", "\\\\")
re.replace("/", r"\\")
}
#[cfg(not(windows))]
-7
View File
@@ -4,12 +4,5 @@
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
extern crate glob;
extern crate regex;
extern crate walkdir;
#[cfg(test)]
extern crate tempfile;
pub mod find;
pub mod xargs;
+6
View File
@@ -26,6 +26,12 @@ LOG_FILE=bfs/tests.log
PASS=$(sed -n "s/^tests passed: \(.*\)/\1/p" "$LOG_FILE" | head -n1)
SKIP=$(sed -n "s/^tests skipped: \(.*\)/\1/p" "$LOG_FILE" | head -n1)
FAIL=$(sed -n "s/^tests failed: \(.*\)/\1/p" "$LOG_FILE" | head -n1)
# Default any missing numbers to zero (e.g. no tests skipped)
: ${PASS:=0}
: ${SKIP:=0}
: ${FAIL:=0}
TOTAL=$((PASS + SKIP + FAIL))
if (( TOTAL <= 1 )); then
echo "Error in the execution, failing early"