diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..2359ebf
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,403 @@
+name: Build and Test
+
+on:
+ push:
+ branches: [ "**" ]
+ tags: [ "v*" ]
+ pull_request:
+ branches: [ "**" ]
+ workflow_dispatch: # Allow manual triggering
+
+jobs:
+ build:
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ job_name: ['linux_x86_64', 'linux_i686', 'linux_armv7', 'linux_aarch64', 'windows_x64', 'windows_x86', 'macos_arm64']
+
+ include:
+ - job_name: linux_x86_64
+ runner: ubuntu-latest
+ host: x86_64-pc-linux-gnu
+ cc: gcc
+ cross: false
+ test: true
+ binary_name: ffe
+ artifact_path: ./src/ffe
+ deps: |
+ sudo apt-get update
+ sudo apt-get install -y autoconf automake libtool pkg-config texinfo libgcrypt-dev libgpg-error-dev bats
+ configure_pre: ""
+ configure_host_args: ""
+
+ - job_name: linux_i686
+ runner: ubuntu-latest
+ host: i686-pc-linux-gnu
+ cc: i686-linux-gnu-gcc
+ cross: true
+ test: false
+ binary_name: ffe
+ artifact_path: ./src/ffe
+ deps: |
+ sudo apt-get update
+ sudo apt-get install -y autoconf automake libtool pkg-config texinfo libgcrypt-dev libgpg-error-dev gcc-i686-linux-gnu
+ # Install amd64 libgcrypt-dev for m4 macros, but cross-compilation will disable libgcrypt detection
+ configure_pre: |
+ export PKG_CONFIG_PATH=/usr/lib/i386-linux-gnu/pkgconfig:$PKG_CONFIG_PATH
+ export CPPFLAGS="-I/usr/i686-linux-gnu/include -I/usr/include/i386-linux-gnu"
+ export LDFLAGS="-L/usr/i686-linux-gnu/lib -L/usr/lib/i386-linux-gnu"
+ # Disable libgcrypt detection for cross-compilation
+ export ac_cv_lib_gcrypt_gcry_check_version=no
+ export ac_cv_header_gcrypt_h=no
+ export ac_cv_path_LIBGCRYPT_CONFIG=no
+ export LIBGCRYPT_CONFIG=/bin/false
+ configure_host_args: "--host=i686-pc-linux-gnu --build=x86_64-pc-linux-gnu"
+
+ - job_name: linux_armv7
+ runner: ubuntu-latest
+ host: arm-linux-gnueabihf
+ cc: arm-linux-gnueabihf-gcc
+ cross: true
+ test: false
+ binary_name: ffe
+ artifact_path: ./src/ffe
+ deps: |
+ sudo apt-get update
+ sudo apt-get install -y autoconf automake libtool pkg-config texinfo libgcrypt-dev libgpg-error-dev gcc-arm-linux-gnueabihf
+ # Install amd64 libgcrypt-dev for m4 macros, but cross-compilation will disable libgcrypt detection
+ configure_pre: |
+ export PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:$PKG_CONFIG_PATH
+ export CPPFLAGS="-I/usr/arm-linux-gnueabihf/include -I/usr/include/arm-linux-gnueabihf"
+ export LDFLAGS="-L/usr/arm-linux-gnueabihf/lib -L/usr/lib/arm-linux-gnueabihf"
+ # Disable libgcrypt detection for cross-compilation
+ export ac_cv_lib_gcrypt_gcry_check_version=no
+ export ac_cv_header_gcrypt_h=no
+ export ac_cv_path_LIBGCRYPT_CONFIG=no
+ export LIBGCRYPT_CONFIG=/bin/false
+ configure_host_args: "--host=arm-linux-gnueabihf --build=x86_64-pc-linux-gnu"
+
+ - job_name: linux_aarch64
+ runner: ubuntu-latest
+ host: aarch64-linux-gnu
+ cc: aarch64-linux-gnu-gcc
+ cross: true
+ test: false
+ binary_name: ffe
+ artifact_path: ./src/ffe
+ deps: |
+ sudo apt-get update
+ sudo apt-get install -y autoconf automake libtool pkg-config texinfo libgcrypt-dev libgpg-error-dev gcc-aarch64-linux-gnu
+ # Install amd64 libgcrypt-dev for m4 macros, but cross-compilation will disable libgcrypt detection
+ configure_pre: |
+ export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH
+ export CPPFLAGS="-I/usr/aarch64-linux-gnu/include -I/usr/include/aarch64-linux-gnu"
+ export LDFLAGS="-L/usr/aarch64-linux-gnu/lib -L/usr/lib/aarch64-linux-gnu"
+ # Disable libgcrypt detection for cross-compilation
+ export ac_cv_lib_gcrypt_gcry_check_version=no
+ export ac_cv_header_gcrypt_h=no
+ export ac_cv_path_LIBGCRYPT_CONFIG=no
+ export LIBGCRYPT_CONFIG=/bin/false
+ configure_host_args: "--host=aarch64-linux-gnu --build=x86_64-pc-linux-gnu"
+
+ - job_name: windows_x64
+ runner: ubuntu-latest
+ host: x86_64-w64-mingw32
+ cc: x86_64-w64-mingw32-gcc
+ cross: true
+ test: false
+ binary_name: ffe.exe
+ artifact_path: ./src/ffe.exe
+ deps: |
+ sudo apt-get update
+ sudo apt-get install -y autoconf automake libtool pkg-config texinfo libgcrypt-dev libgpg-error-dev mingw-w64
+ # Install amd64 libgcrypt-dev for m4 macros, but cross-compilation will disable libgcrypt detection
+ configure_pre: |
+ # Disable libgcrypt detection for cross-compilation
+ export ac_cv_lib_gcrypt_gcry_check_version=no
+ export ac_cv_header_gcrypt_h=no
+ export ac_cv_path_LIBGCRYPT_CONFIG=no
+ export LIBGCRYPT_CONFIG=/bin/false
+ configure_host_args: "--host=x86_64-w64-mingw32 --build=x86_64-pc-linux-gnu"
+
+ - job_name: windows_x86
+ runner: ubuntu-latest
+ host: i686-w64-mingw32
+ cc: i686-w64-mingw32-gcc
+ cross: true
+ test: false
+ binary_name: ffe.exe
+ artifact_path: ./src/ffe.exe
+ deps: |
+ sudo apt-get update
+ sudo apt-get install -y autoconf automake libtool pkg-config texinfo libgcrypt-dev libgpg-error-dev mingw-w64
+ # Install amd64 libgcrypt-dev for m4 macros, but cross-compilation will disable libgcrypt detection
+ configure_pre: |
+ # Disable libgcrypt detection for cross-compilation
+ export ac_cv_lib_gcrypt_gcry_check_version=no
+ export ac_cv_header_gcrypt_h=no
+ export ac_cv_path_LIBGCRYPT_CONFIG=no
+ export LIBGCRYPT_CONFIG=/bin/false
+ configure_host_args: "--host=i686-w64-mingw32 --build=x86_64-pc-linux-gnu"
+
+ - job_name: macos_arm64
+ runner: macos-latest
+ host: arm64-apple-darwin
+ cc: gcc
+ cross: false
+ test: true
+ binary_name: ffe
+ artifact_path: ./src/ffe
+ deps: |
+ brew install autoconf automake libtool pkg-config texinfo libgcrypt libgpg-error bats-core
+ configure_pre: |
+ export PKG_CONFIG_PATH="/opt/homebrew/opt/libgcrypt/lib/pkgconfig:/opt/homebrew/opt/libgpg-error/lib/pkgconfig:$PKG_CONFIG_PATH"
+ export CPPFLAGS="-I/opt/homebrew/opt/libgcrypt/include -I/opt/homebrew/opt/libgpg-error/include $CPPFLAGS"
+ export LDFLAGS="-L/opt/homebrew/opt/libgcrypt/lib -L/opt/homebrew/opt/libgpg-error/lib $LDFLAGS"
+ # Help configure find the headers
+ export ac_cv_header_gcrypt_h=yes
+ export ac_cv_lib_gcrypt_gcry_check_version=yes
+ configure_args: --with-libgcrypt-prefix=/opt/homebrew/opt/libgcrypt
+ configure_host_args: ""
+
+ name: ${{ matrix.job_name }}
+ runs-on: ${{ matrix.runner }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+
+ - name: Install dependencies
+ run: ${{ matrix.deps }}
+
+ - name: Regenerate build system
+ run: autoreconf -is
+
+ - name: Configure
+ env:
+ CC: ${{ matrix.cc }}
+ run: |
+ # Common setup for all jobs
+ # Run job-specific pre-configure script
+ ${{ matrix.configure_pre }}
+
+ # Build configure arguments from per-target variables
+ CONFIGURE_ARGS="${{ matrix.configure_host_args }}"
+ if [ -n "${{ matrix.configure_args }}" ]; then
+ CONFIGURE_ARGS="$CONFIGURE_ARGS ${{ matrix.configure_args }}"
+ fi
+
+ # Run configure
+ echo "Running: ./configure $CONFIGURE_ARGS"
+ ./configure $CONFIGURE_ARGS
+
+ - name: Build
+ run: make
+
+ - name: Ensure test scripts are executable
+ if: matrix.test
+ run: |
+ echo "Setting execute permissions on test scripts..."
+ chmod +x tests/run_tests.sh 2>/dev/null || true
+ chmod +x tests/*/*.sh 2>/dev/null || true
+ chmod +x tests/*/*.bats 2>/dev/null || true
+ ls -la tests/run_tests.sh 2>/dev/null || true
+
+ - name: Run tests
+ run: BATS_FORMATTER=tap make check
+ if: matrix.test
+
+ - name: Verify binary
+ run: |
+ if [ -f ${{ matrix.artifact_path }} ]; then
+ file ${{ matrix.artifact_path }} || true
+ if [ "${{ matrix.runner }}" = "macos-latest" ]; then
+ ${{ matrix.artifact_path }} --version || true
+ echo "Binary size: $(stat -f%z ${{ matrix.artifact_path }}) bytes"
+ else
+ echo "Binary size: $(stat -c%s ${{ matrix.artifact_path }}) bytes"
+ fi
+ else
+ echo "Binary not found at ${{ matrix.artifact_path }}"
+ ls -la ./src/ || true
+ fi
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: "ffe-${{ matrix.job_name }}"
+ path: ${{ matrix.artifact_path }}
+ if-no-files-found: error
+
+ create-release:
+ name: Create GitHub Release
+ runs-on: ubuntu-latest
+ needs: [build]
+ # Only run when a tag starting with 'v' is pushed (e.g., v0.4.0a)
+ if: startsWith(github.ref, 'refs/tags/v')
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Fetch all history for tags
+
+ - name: Extract version from configure.ac
+ id: get-version
+ run: |
+ # Extract version from AC_INIT([ffe],[VERSION],...)
+ VERSION=$(grep -oP 'AC_INIT\(\[ffe\],\[\K[^]]+' configure.ac)
+ echo "Extracted version: $VERSION"
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "tag_name=v$VERSION" >> $GITHUB_OUTPUT
+
+ # Determine if this is a prerelease (version doesn't end with a digit)
+ if [[ $VERSION =~ [0-9]$ ]]; then
+ IS_PRERELEASE=false
+ else
+ IS_PRERELEASE=true
+ fi
+ echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
+
+ - name: Download all build artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: ./artifacts/
+
+ - name: List downloaded artifacts
+ run: |
+ echo "Artifact directory structure:"
+ find ./artifacts -type f -name "*" | sort
+ echo ""
+ echo "File details:"
+ find ./artifacts -type f -exec ls -la {} \;
+
+ - name: Check tag and release status
+ run: |
+ echo "Current tag from output: ${{ steps.get-version.outputs.tag_name }}"
+ echo "Checking if tag exists locally:"
+ git tag -l | grep "${{ steps.get-version.outputs.tag_name }}" || echo "Tag not found locally"
+ echo "Fetching all tags:"
+ git fetch --tags
+ echo "Checking remote tags:"
+ git tag -l | grep "${{ steps.get-version.outputs.tag_name }}" || echo "Tag not found after fetch"
+
+ - name: Rename artifacts with platform names
+ run: |
+ echo "Renaming artifacts to include platform in filename..."
+ for dir in ./artifacts/*/; do
+ if [ -d "$dir" ]; then
+ platform=$(basename "$dir")
+ echo "Processing $dir for platform $platform"
+ # Find any file inside the directory (should be exactly one)
+ for binary in "$dir"*; do
+ if [ -f "$binary" ]; then
+ # Get file extension
+ if [[ "$binary" == *.exe ]]; then
+ ext=".exe"
+ else
+ ext=""
+ fi
+ # Move to temporary name first to avoid conflict with directory
+ temp_name="./artifacts/.tmp-${platform}${ext}"
+ echo "Moving $binary to temporary name $temp_name"
+ mv "$binary" "$temp_name"
+ # Remove the now-empty directory
+ rmdir "$dir" 2>/dev/null || true
+ # Now rename to final name
+ final_name="./artifacts/${platform}${ext}"
+ echo "Renaming $temp_name to $final_name"
+ mv "$temp_name" "$final_name"
+ break # Only process first file in directory
+ fi
+ done
+ fi
+ done
+ echo "Raw artifact files after renaming:"
+ ls -la ./artifacts/
+
+ - name: Create distribution zip files
+ run: |
+ echo "Creating distribution zip files for each platform..."
+ # Create directory for zip files
+ mkdir -p ./dist
+
+ # Process each binary artifact
+ for binary in ./artifacts/ffe-* ./artifacts/ffe-*.exe; do
+ if [ -f "$binary" ]; then
+ # Extract platform name from filename
+ filename=$(basename "$binary")
+ if [[ "$filename" == *.exe ]]; then
+ platform="${filename%.exe}"
+ binary_name="ffe.exe"
+ else
+ platform="$filename"
+ binary_name="ffe"
+ fi
+
+ echo "Creating zip for platform: $platform"
+
+ # Create temporary directory for this platform
+ temp_dir="./dist/${platform}"
+ mkdir -p "$temp_dir"
+
+ # Copy binary to temp directory with standardized name
+ cp "$binary" "$temp_dir/$binary_name"
+
+ # Set executable permissions for non-Windows binaries
+ if [[ "$binary_name" != *.exe ]]; then
+ chmod +x "$temp_dir/$binary_name"
+ fi
+
+ # Copy documentation files
+ cp ./doc/ffe.html "$temp_dir/"
+ cp ./ChangeLog "$temp_dir/ChangeLog.txt"
+ cp ./COPYING "$temp_dir/COPYING.txt"
+
+ # Create zip file
+ zip_file="./artifacts/${platform}.zip"
+ (cd "$temp_dir" && zip -r "../../${zip_file}" .)
+
+ # List zip contents for verification
+ echo "Zip contents of $zip_file:"
+ unzip -l "$zip_file"
+
+ # Clean up temporary directory
+ rm -rf "$temp_dir"
+
+ echo "Created zip: $zip_file"
+ fi
+ done
+
+ # Remove all non-zip files from artifacts directory
+ find ./artifacts -maxdepth 1 -type f ! -name '*.zip' -exec rm {} +
+ # Remove any empty subdirectories
+ find ./artifacts -mindepth 1 -type d -empty -exec rmdir {} + 2>/dev/null || true
+
+ echo "Final release artifacts (only zip files should remain):"
+ ls -la ./artifacts/
+ echo ""
+ echo "Zip file count:"
+ find ./artifacts -maxdepth 1 -type f -name '*.zip' | wc -l
+ echo "Expected: 7 (one per platform)"
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v1
+ with:
+ tag_name: ${{ steps.get-version.outputs.tag_name }}
+ name: ffe ${{ steps.get-version.outputs.version }}
+ body: |
+ Flat File Extractor ${{ steps.get-version.outputs.version }}
+
+ ## Assets
+ Built for multiple platforms:
+ - Linux x86_64
+ - Linux i686
+ - Linux ARMv7
+ - Linux AArch64
+ - Windows x64
+ - Windows x86
+ - macOS ARM64
+ draft: true
+ prerelease: ${{ steps.get-version.outputs.is_prerelease }}
+ files: |
+ artifacts/*.zip
\ No newline at end of file
diff --git a/Makefile.am b/Makefile.am
index 548b966..da1b3c9 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1,3 +1,3 @@
AUTOMAKE_OPTIONS = gnu
-SUBDIRS = src doc
+SUBDIRS = src doc tests
diff --git a/configure.ac b/configure.ac
index 6853329..7fef943 100644
--- a/configure.ac
+++ b/configure.ac
@@ -62,5 +62,6 @@ AC_CHECK_FUNCS([strchr strdup strerror strstr getline getopt_long regcomp strnca
AC_CONFIG_FILES([Makefile
doc/Makefile
- src/Makefile])
+ src/Makefile
+ tests/Makefile])
AC_OUTPUT
diff --git a/tests/Makefile.am b/tests/Makefile.am
new file mode 100644
index 0000000..4fae807
--- /dev/null
+++ b/tests/Makefile.am
@@ -0,0 +1,40 @@
+# Tests for ffe
+
+AM_CFLAGS = -I$(top_srcdir)/src
+AM_CPPFLAGS = -I$(top_srcdir)/src $(LIBGCRYPT_CFLAGS)
+LDADD = $(LIBGCRYPT_LIBS)
+TESTS_ENVIRONMENT = FFE_BIN='$(FFE_BIN)' srcdir='$(srcdir)'
+
+# Path to the ffe binary
+FFE_BIN = $(top_builddir)/src/ffe
+
+# Test scripts (shell scripts)
+TESTS = run_tests.sh
+
+# Ensure ffe binary is built before tests run
+check-local: $(FFE_BIN)
+
+$(FFE_BIN):
+ cd $(top_builddir)/src && $(MAKE) ffe
+
+clean-local:
+ rm -f *.log
+
+# Distribute all test files including subdirectories
+EXTRA_DIST = run_tests.sh \
+ fixed_length/fixed_length.fferc \
+ fixed_length/fixed_length.input \
+ fixed_length/fixed_length.expected \
+ fixed_length/test_fixed_length.sh \
+ separated/separated.fferc \
+ separated/separated.input \
+ separated/separated.expected \
+ separated/test_separated.sh \
+ binary/binary.fferc \
+ binary/binary.input \
+ binary/binary.expected \
+ binary/test_binary.sh \
+ expressions/expression.expected \
+ expressions/fixed_length.fferc \
+ expressions/fixed_length.input \
+ expressions/test_expressions.sh
\ No newline at end of file
diff --git a/tests/anonymize/anonymize.input b/tests/anonymize/anonymize.input
new file mode 100644
index 0000000..4b8ac6e
--- /dev/null
+++ b/tests/anonymize/anonymize.input
@@ -0,0 +1,4 @@
+Alice,Johnson,35,12345
+Bob,Smith,42,67890
+Carol,Williams,28,54321
+David,Brown,51,98765
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_basic.fferc b/tests/anonymize/anonymize_basic.fferc
new file mode 100644
index 0000000..4f74056
--- /dev/null
+++ b/tests/anonymize/anonymize_basic.fferc
@@ -0,0 +1,26 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ field id
+ }
+}
+
+anonymize test1 {
+ method first_name MASK
+ method last_name MASK 2
+ method age MASK -2 1
+ method id MASK 1 3
+}
+
+output raw {
+ data "%d"
+}
+
+output default {
+ data "%n: %t\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_bcd.fferc b/tests/anonymize/anonymize_bcd.fferc
new file mode 100644
index 0000000..bf461cd
--- /dev/null
+++ b/tests/anonymize/anonymize_bcd.fferc
@@ -0,0 +1,29 @@
+structure bcd_test
+{
+ type binary
+ record bcd_record
+ {
+ field bcd_field bcd_be_3
+ }
+}
+
+anonymize test_bcd_mask {
+ method bcd_field MASK
+}
+
+anonymize test_bcd_hash {
+ method bcd_field HASH
+}
+
+anonymize test_bcd_random {
+ method bcd_field RANDOM
+}
+
+output raw {
+ data "%d"
+}
+
+output default
+{
+ data "%n = %d (%h)\n"
+}
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_binary.fferc b/tests/anonymize/anonymize_binary.fferc
new file mode 100644
index 0000000..34d9a7f
--- /dev/null
+++ b/tests/anonymize/anonymize_binary.fferc
@@ -0,0 +1,28 @@
+structure bin_data
+{
+ type binary
+ record b
+ {
+ field text 5
+ field byte_int int8
+ field integer int32_le
+ field number double_le
+ field bcd_number bcd_be_3
+ field hex hex_be_4
+ }
+}
+
+anonymize test_binary {
+ method text MASK
+ method integer RANDOM
+ method bcd_number HASH
+}
+
+output raw {
+ data "%d"
+}
+
+output default
+{
+ data "%n = %d (%h)\n"
+}
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_fixed.fferc b/tests/anonymize/anonymize_fixed.fferc
new file mode 100644
index 0000000..80c3463
--- /dev/null
+++ b/tests/anonymize/anonymize_fixed.fferc
@@ -0,0 +1,20 @@
+structure data {
+ type fixed
+ record person {
+ field first_name 10
+ field last_name 10
+ field age 3
+ field id 5
+ }
+}
+
+anonymize test_fixed {
+ method first_name HASH
+ method last_name HASH 2
+ method age NRANDOM
+ method id MASK
+}
+
+output default {
+ data "%d"
+}
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_fixed.input b/tests/anonymize/anonymize_fixed.input
new file mode 100644
index 0000000..f978930
--- /dev/null
+++ b/tests/anonymize/anonymize_fixed.input
@@ -0,0 +1,4 @@
+Alice Johnson 03512345
+Bob Smith 04267890
+Carol Williams 02854321
+David Brown 05198765
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_fixed_exact.input b/tests/anonymize/anonymize_fixed_exact.input
new file mode 100644
index 0000000..2270a79
--- /dev/null
+++ b/tests/anonymize/anonymize_fixed_exact.input
@@ -0,0 +1,4 @@
+Alice Johnson 03512345
+Bob Smith 04267890
+Carol Williams 02854321
+David Brown 05198765
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_hash.fferc b/tests/anonymize/anonymize_hash.fferc
new file mode 100644
index 0000000..5952fc3
--- /dev/null
+++ b/tests/anonymize/anonymize_hash.fferc
@@ -0,0 +1,26 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ field id
+ }
+}
+
+anonymize test_hash {
+ method first_name HASH
+ method last_name HASH 2
+ method age NHASH
+ method id HASH 1 3
+}
+
+output raw {
+ data "%d"
+}
+
+output default {
+ data "%n: %t\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_hash_length.fferc b/tests/anonymize/anonymize_hash_length.fferc
new file mode 100644
index 0000000..dc14c17
--- /dev/null
+++ b/tests/anonymize/anonymize_hash_length.fferc
@@ -0,0 +1,39 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ field id
+ }
+}
+
+anonymize test_hash_length_16 {
+ method first_name HASH 1 0 16
+ method id HASH 1 0 16
+}
+
+anonymize test_hash_length_32 {
+ method first_name HASH 1 0 32
+ method id HASH 1 0 32
+}
+
+anonymize test_hash_length_64 {
+ method first_name HASH 1 0 64
+ method id HASH 1 0 64
+}
+
+anonymize test_hash_no_key {
+ method first_name HASH
+ method id HASH
+}
+
+output raw {
+ data "%d"
+}
+
+output default {
+ data "%n: %t\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_mask_char.fferc b/tests/anonymize/anonymize_mask_char.fferc
new file mode 100644
index 0000000..3096e58
--- /dev/null
+++ b/tests/anonymize/anonymize_mask_char.fferc
@@ -0,0 +1,21 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ field id
+ }
+}
+
+anonymize test_mask_char {
+ method first_name MASK 1 0 "*"
+ method last_name MASK 1 0 "X"
+ method age MASK 1 0 "9"
+ method id MASK 1 0 "#"
+}
+
+output raw {
+ data "%d"
+}
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_partial.fferc b/tests/anonymize/anonymize_partial.fferc
new file mode 100644
index 0000000..e66d169
--- /dev/null
+++ b/tests/anonymize/anonymize_partial.fferc
@@ -0,0 +1,25 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ field id
+ }
+}
+
+anonymize test_partial {
+ # anonymize all but first character
+ method first_name MASK 2 0 "."
+ # anonymize last 3 characters
+ method last_name MASK -3 0 "X"
+ # anonymize middle character (position 2, length 1)
+ method age MASK 2 1 "*"
+ # anonymize first 3 characters
+ method id MASK 1 3 "#"
+}
+
+output raw {
+ data "%d"
+}
\ No newline at end of file
diff --git a/tests/anonymize/anonymize_random.fferc b/tests/anonymize/anonymize_random.fferc
new file mode 100644
index 0000000..14c1c92
--- /dev/null
+++ b/tests/anonymize/anonymize_random.fferc
@@ -0,0 +1,26 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ field id
+ }
+}
+
+anonymize test_random {
+ method first_name RANDOM
+ method last_name RANDOM
+ method age RANDOM
+ method id RANDOM
+}
+
+output raw {
+ data "%d"
+}
+
+output default {
+ data "%n: %t\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/anonymize/bcd.input b/tests/anonymize/bcd.input
new file mode 100644
index 0000000..e9ea3dc
--- /dev/null
+++ b/tests/anonymize/bcd.input
@@ -0,0 +1 @@
+4E
\ No newline at end of file
diff --git a/tests/anonymize/expected_fixed.expected b/tests/anonymize/expected_fixed.expected
new file mode 100644
index 0000000..76962e6
--- /dev/null
+++ b/tests/anonymize/expected_fixed.expected
@@ -0,0 +1,4 @@
+P2Pl8h801KPLYSyu4Pnq76700000
+0d6REUC5n 0MH2O6Njpa48200000
+D3UKSHxYakDAjyUgr Er88100000
+ubG PC1KhQuVyP8Kvx8q06300000
diff --git a/tests/anonymize/expected_hash.expected b/tests/anonymize/expected_hash.expected
new file mode 100644
index 0000000..eb75861
--- /dev/null
+++ b/tests/anonymize/expected_hash.expected
@@ -0,0 +1,4 @@
+a9T7U,JndZRAG,86,4yD45
+k43,ShVt ,18,T1x90
+KBLRI,WQ1JEYxB,11,1HF21
+7E7mB,BlZ0I,06,6xw65
diff --git a/tests/anonymize/expected_hash_length_16.expected b/tests/anonymize/expected_hash_length_16.expected
new file mode 100644
index 0000000..a8df1a2
--- /dev/null
+++ b/tests/anonymize/expected_hash_length_16.expected
@@ -0,0 +1,4 @@
+a9T7U,Johnson,35,4yDDi
+k43,Smith,42,T1xz7
+KBLRI,Williams,28,1HFFh
+7E7mB,Brown,51,6xwPy
diff --git a/tests/anonymize/expected_hash_length_32.expected b/tests/anonymize/expected_hash_length_32.expected
new file mode 100644
index 0000000..8039d15
--- /dev/null
+++ b/tests/anonymize/expected_hash_length_32.expected
@@ -0,0 +1,4 @@
+w8FYO,Johnson,35,PL8Py
+FWo,Smith,42,aWzzE
+pVzBm,Williams,28,VrsO2
+dsCVe,Brown,51,vpw7j
diff --git a/tests/anonymize/expected_hash_length_64.expected b/tests/anonymize/expected_hash_length_64.expected
new file mode 100644
index 0000000..bc366d4
--- /dev/null
+++ b/tests/anonymize/expected_hash_length_64.expected
@@ -0,0 +1,4 @@
+eL3qO,Johnson,35,rcHRe
+BzQ,Smith,42,WR6Zh
+jHp8u,Williams,28,Zjhml
+EE gE,Brown,51,3rUUa
diff --git a/tests/anonymize/expected_hash_no_key.expected b/tests/anonymize/expected_hash_no_key.expected
new file mode 100644
index 0000000..a8df1a2
--- /dev/null
+++ b/tests/anonymize/expected_hash_no_key.expected
@@ -0,0 +1,4 @@
+a9T7U,Johnson,35,4yDDi
+k43,Smith,42,T1xz7
+KBLRI,Williams,28,1HFFh
+7E7mB,Brown,51,6xwPy
diff --git a/tests/anonymize/expected_mask.expected b/tests/anonymize/expected_mask.expected
new file mode 100644
index 0000000..cbf41a2
--- /dev/null
+++ b/tests/anonymize/expected_mask.expected
@@ -0,0 +1,4 @@
+00000,J000000,05,00045
+000,S0000,02,00090
+00000,W0000000,08,00021
+00000,B0000,01,00065
diff --git a/tests/anonymize/expected_mask_char.expected b/tests/anonymize/expected_mask_char.expected
new file mode 100644
index 0000000..cdd6ee0
--- /dev/null
+++ b/tests/anonymize/expected_mask_char.expected
@@ -0,0 +1,4 @@
+*****,XXXXXXX,99,#####
+***,XXXXX,99,#####
+*****,XXXXXXXX,99,#####
+*****,XXXXX,99,#####
diff --git a/tests/anonymize/expected_partial.expected b/tests/anonymize/expected_partial.expected
new file mode 100644
index 0000000..1efd929
--- /dev/null
+++ b/tests/anonymize/expected_partial.expected
@@ -0,0 +1,4 @@
+A....,XXXXXon,3*,###45
+B..,XXXth,4*,###90
+C....,XXXXXXms,2*,###21
+D....,XXXwn,5*,###65
diff --git a/tests/anonymize/test_anonymize.bats b/tests/anonymize/test_anonymize.bats
new file mode 100755
index 0000000..14d6309
--- /dev/null
+++ b/tests/anonymize/test_anonymize.bats
@@ -0,0 +1,225 @@
+#!/usr/bin/env bats
+
+# Set srcdir to the test directory
+srcdir="$BATS_TEST_DIRNAME"
+# Source our test helper
+source "$BATS_TEST_DIRNAME/../test_helper.bash"
+
+setup() {
+ # Setup temporary directory for tests
+ setup_bats_tempdir
+ # Change to test directory
+ cd "$srcdir" || exit 1
+}
+
+teardown() {
+ # Cleanup if needed
+ :
+}
+
+# Helper function for anonymization tests (similar to check_output)
+run_anonymize_test() {
+ local config="$1"
+ local input="$2"
+ local expected="$3"
+ local anonymize="$4"
+ local output_format="${5:-raw}"
+
+ if [ -n "$anonymize" ]; then
+ run "$FFE_BIN" -c "$config" -A "$anonymize" "$input" -p"$output_format"
+ else
+ run "$FFE_BIN" -c "$config" "$input" -p"$output_format"
+ fi
+ assert_success
+ assert_output_matches_file "$expected"
+}
+
+# Helper for fixed-width test with random age
+run_fixed_random_test() {
+ local config="$1"
+ local input="$2"
+ local expected="$3"
+ local anonymize="$4"
+
+ run "$FFE_BIN" -c "$config" -A "$anonymize" "$input"
+ assert_success
+
+ # Check each line: age field (positions 21-23) should be 3 digits
+ # Replace age digits in both expected and output with placeholder for comparison
+ normalized_expected="$BATS_TEST_TMPDIR/norm_expected"
+ normalized_output="$BATS_TEST_TMPDIR/norm_output"
+
+ sed 's/\(.\{20\}\)...\(.\{5\}\)/\1XXX\2/' "$expected" > "$normalized_expected"
+ sed 's/\(.\{20\}\)...\(.\{5\}\)/\1XXX\2/' <<< "$output" > "$normalized_output"
+
+ if diff -u "$normalized_expected" "$normalized_output" >&2; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+# Helper for BCD validation
+validate_bcd_nibbles() {
+ local output_file="$1"
+ # Validate each nibble is 0-9 (BCD)
+ xxd -p "$output_file" | tr -d '\n' | awk '{
+ if (length($0) != 6) { print "Invalid length"; exit 1 }
+ for (i=1; i<=6; i+=2) {
+ byte = substr($0, i, 2)
+ high = substr(byte,1,1); low = substr(byte,2,1)
+ if (high !~ /[0-9]/ || low !~ /[0-9]/) {
+ print "Invalid BCD nibble: " byte
+ exit 1
+ }
+ }
+ }'
+}
+
+# Helper for binary mask validation
+validate_binary_mask() {
+ local output_file="$1"
+ # Check that text field (first 5 bytes) are masked with '0'
+ # Original text field: "ABC" + null + 0x08
+ # Masked should be "000" + null + 0x08
+ local first_five
+ first_five=$(head -c5 "$output_file" | xxd -p)
+ if [ "$first_five" != "3030300008" ]; then
+ echo "FAIL: binary mask not applied correctly, got $first_five"
+ return 1
+ fi
+ return 0
+}
+
+@test "basic mask anonymization" {
+ run_anonymize_test \
+ "anonymize_basic.fferc" \
+ "anonymize.input" \
+ "expected_mask.expected" \
+ "test1" \
+ "raw"
+}
+
+@test "hash anonymization" {
+ run_anonymize_test \
+ "anonymize_hash.fferc" \
+ "anonymize.input" \
+ "expected_hash.expected" \
+ "test_hash" \
+ "raw"
+}
+
+@test "custom mask characters" {
+ run_anonymize_test \
+ "anonymize_mask_char.fferc" \
+ "anonymize.input" \
+ "expected_mask_char.expected" \
+ "test_mask_char" \
+ "raw"
+}
+
+@test "partial field anonymization" {
+ run_anonymize_test \
+ "anonymize_partial.fferc" \
+ "anonymize.input" \
+ "expected_partial.expected" \
+ "test_partial" \
+ "raw"
+}
+
+@test "fixed-width anonymization with random age" {
+ run_fixed_random_test \
+ "anonymize_fixed.fferc" \
+ "anonymize_fixed_exact.input" \
+ "expected_fixed.expected" \
+ "test_fixed"
+}
+
+@test "random anonymization for text fields" {
+ run "$FFE_BIN" -c "anonymize_random.fferc" -A test_random "anonymize.input" -praw
+ assert_success
+ # Validate characters are in allowed set (0-9, A-Z, a-z, space, comma separator)
+ if grep -q '[^0-9A-Za-z ,]' <<< "$output"; then
+ echo "FAIL: Random text contains invalid characters"
+ grep -n '[^0-9A-Za-z ,]' <<< "$output" | head -5
+ return 1
+ fi
+}
+
+@test "binary field anonymization" {
+ # Use binary input from parent directory
+ output_file="$BATS_TEST_TMPDIR/binary_output"
+ run bash -c "\"$FFE_BIN\" -c \"anonymize_binary.fferc\" -s bin_data -A test_binary \"../binary/binary.input\" -praw > \"$output_file\""
+ assert_success
+ # Validate binary mask
+ validate_binary_mask "$output_file"
+}
+
+@test "BCD field anonymization - mask method" {
+ output_file="$BATS_TEST_TMPDIR/bcd_output"
+ run bash -c "\"$FFE_BIN\" -c \"anonymize_bcd.fferc\" -s bcd_test -A \"test_bcd_mask\" \"bcd.input\" -praw > \"$output_file\""
+ assert_success
+ validate_bcd_nibbles "$output_file"
+}
+
+@test "BCD field anonymization - hash method" {
+ output_file="$BATS_TEST_TMPDIR/bcd_output"
+ run bash -c "\"$FFE_BIN\" -c \"anonymize_bcd.fferc\" -s bcd_test -A \"test_bcd_hash\" \"bcd.input\" -praw > \"$output_file\""
+ assert_success
+ validate_bcd_nibbles "$output_file"
+}
+
+@test "BCD field anonymization - random method" {
+ output_file="$BATS_TEST_TMPDIR/bcd_output"
+ run bash -c "\"$FFE_BIN\" -c \"anonymize_bcd.fferc\" -s bcd_test -A \"test_bcd_random\" \"bcd.input\" -praw > \"$output_file\""
+ assert_success
+ validate_bcd_nibbles "$output_file"
+}
+
+@test "hash with key parameter - no key (default 16)" {
+ run_anonymize_test \
+ "anonymize_hash_length.fferc" \
+ "anonymize.input" \
+ "expected_hash_no_key.expected" \
+ "test_hash_no_key" \
+ "raw"
+}
+
+@test "hash with key parameter - key=16" {
+ run_anonymize_test \
+ "anonymize_hash_length.fferc" \
+ "anonymize.input" \
+ "expected_hash_length_16.expected" \
+ "test_hash_length_16" \
+ "raw"
+}
+
+@test "hash with key parameter - key=32" {
+ run_anonymize_test \
+ "anonymize_hash_length.fferc" \
+ "anonymize.input" \
+ "expected_hash_length_32.expected" \
+ "test_hash_length_32" \
+ "raw"
+}
+
+@test "hash with key parameter - key=64" {
+ run_anonymize_test \
+ "anonymize_hash_length.fferc" \
+ "anonymize.input" \
+ "expected_hash_length_64.expected" \
+ "test_hash_length_64" \
+ "raw"
+}
+
+@test "hash key parameter outputs differ" {
+ # Verify that different keys produce different outputs
+ run diff -u "expected_hash_length_16.expected" "expected_hash_length_32.expected"
+ [ $status -ne 0 ] # diff should find differences
+
+ run diff -u "expected_hash_length_16.expected" "expected_hash_length_64.expected"
+ [ $status -ne 0 ]
+
+ run diff -u "expected_hash_length_32.expected" "expected_hash_length_64.expected"
+ [ $status -ne 0 ]
+}
\ No newline at end of file
diff --git a/tests/anonymize/test_anonymize.sh b/tests/anonymize/test_anonymize.sh
new file mode 100755
index 0000000..813daee
--- /dev/null
+++ b/tests/anonymize/test_anonymize.sh
@@ -0,0 +1,224 @@
+#!/bin/sh
+
+# Regression test for anonymization functionality
+
+set -e
+
+# Use environment variables if set, otherwise default
+FFE="${FFE_BIN:-../src/ffe}"
+srcdir="${srcdir:-.}"
+
+# Helper function to compare with expected output
+check_output() {
+ local config="$1"
+ local input="$2"
+ local expected="$3"
+ local anonymize="$4"
+ local output_format="${5:-raw}"
+
+ # Run ffe
+ output=$(mktemp)
+ if [ -n "$anonymize" ]; then
+ "$FFE" -c "$config" -A "$anonymize" "$input" -p"$output_format" > "$output"
+ else
+ "$FFE" -c "$config" "$input" -p"$output_format" > "$output"
+ fi
+
+ # Compare with expected
+ if diff -u "$expected" "$output"; then
+ echo "PASS: anonymization test $config"
+ rm -f "$output"
+ return 0
+ else
+ echo "FAIL: output does not match expected for $config"
+ rm -f "$output"
+ return 1
+ fi
+}
+
+# Helper for fixed-width test with random age
+check_fixed_random() {
+ local config="$1"
+ local input="$2"
+ local expected="$3"
+ local anonymize="$4"
+
+ # Run ffe
+ output=$(mktemp)
+ "$FFE" -c "$config" -A "$anonymize" "$input" > "$output"
+
+ # Check each line: age field (positions 21-23) should be 3 digits
+ # Replace age digits in both expected and output with placeholder for comparison
+ normalized_expected=$(mktemp)
+ normalized_output=$(mktemp)
+
+ sed 's/\(.\{20\}\)...\(.\{5\}\)/\1XXX\2/' "$expected" > "$normalized_expected"
+ sed 's/\(.\{20\}\)...\(.\{5\}\)/\1XXX\2/' "$output" > "$normalized_output"
+
+ if diff -u "$normalized_expected" "$normalized_output"; then
+ echo "PASS: fixed-width anonymization test $config (with random age)"
+ rm -f "$output" "$normalized_expected" "$normalized_output"
+ return 0
+ else
+ echo "FAIL: output does not match expected for $config"
+ rm -f "$output" "$normalized_expected" "$normalized_output"
+ return 1
+ fi
+}
+
+# Test 1: Basic mask anonymization
+check_output \
+ "$srcdir/anonymize_basic.fferc" \
+ "$srcdir/anonymize.input" \
+ "$srcdir/expected_mask.expected" \
+ "test1" \
+ "raw"
+
+# Test 2: Hash anonymization
+check_output \
+ "$srcdir/anonymize_hash.fferc" \
+ "$srcdir/anonymize.input" \
+ "$srcdir/expected_hash.expected" \
+ "test_hash" \
+ "raw"
+
+# Test 3: Custom mask characters
+check_output \
+ "$srcdir/anonymize_mask_char.fferc" \
+ "$srcdir/anonymize.input" \
+ "$srcdir/expected_mask_char.expected" \
+ "test_mask_char" \
+ "raw"
+
+# Test 4: Partial field anonymization
+check_output \
+ "$srcdir/anonymize_partial.fferc" \
+ "$srcdir/anonymize.input" \
+ "$srcdir/expected_partial.expected" \
+ "test_partial" \
+ "raw"
+
+# Test 5: Fixed-width anonymization with random age
+check_fixed_random \
+ "$srcdir/anonymize_fixed.fferc" \
+ "$srcdir/anonymize_fixed_exact.input" \
+ "$srcdir/expected_fixed.expected" \
+ "test_fixed"
+
+# Test 6: Random anonymization for text fields
+output=$(mktemp)
+"$FFE" -c "$srcdir/anonymize_random.fferc" -A test_random "$srcdir/anonymize.input" -praw > "$output"
+# Validate characters are in allowed set (0-9, A-Z, a-z, space, comma separator)
+if grep -q '[^0-9A-Za-z ,]' "$output"; then
+ echo "FAIL: Random text contains invalid characters"
+ grep -n '[^0-9A-Za-z ,]' "$output" | head -5
+ rm -f "$output"
+ exit 1
+fi
+echo "PASS: random anonymization test"
+rm -f "$output"
+
+# Test 7: Binary field anonymization
+# Just ensure it runs without error and masked fields are changed
+output=$(mktemp)
+"$FFE" -c "$srcdir/anonymize_binary.fferc" -s bin_data -A test_binary "$srcdir/../binary/binary.input" -praw > "$output" 2>&1
+if [ $? -ne 0 ]; then
+ echo "FAIL: binary anonymization test failed"
+ rm -f "$output"
+ exit 1
+fi
+# Check that text field (first 5 bytes) are masked with '0'
+# Original text field: "ABC" + null + 0x08
+# Masked should be "000" + null + 0x08
+first_five=$(head -c5 "$output" | xxd -p)
+if [ "$first_five" != "3030300008" ]; then
+ echo "FAIL: binary mask not applied correctly, got $first_five"
+ rm -f "$output"
+ exit 1
+fi
+echo "PASS: binary anonymization test"
+rm -f "$output"
+
+# Test 8: BCD field anonymization
+# Test mask, hash, random - ensure they produce valid BCD values (nibbles 0-9)
+for method in mask hash random; do
+ output=$(mktemp)
+ "$FFE" -c "$srcdir/anonymize_bcd.fferc" -s bcd_test -A "test_bcd_$method" "$srcdir/bcd.input" -praw > "$output" 2>&1
+ if [ $? -ne 0 ]; then
+ echo "FAIL: BCD $method anonymization test failed"
+ rm -f "$output"
+ exit 1
+ fi
+ # Validate each nibble is 0-9 (BCD)
+ xxd -p "$output" | tr -d '\n' | awk '{
+ if (length($0) != 6) { print "Invalid length"; exit 1 }
+ for (i=1; i<=6; i+=2) {
+ byte = substr($0, i, 2)
+ high = substr(byte,1,1); low = substr(byte,2,1)
+ if (high !~ /[0-9]/ || low !~ /[0-9]/) {
+ print "Invalid BCD nibble: " byte
+ exit 1
+ }
+ }
+ }' > /dev/null 2>&1
+ if [ $? -ne 0 ]; then
+ echo "FAIL: BCD $method produced invalid BCD values"
+ xxd "$output"
+ rm -f "$output"
+ exit 1
+ fi
+ echo "PASS: BCD $method anonymization test"
+ rm -f "$output"
+done
+
+# Test 9: Hash with key (length) parameter
+echo "=== Testing hash with key parameter ==="
+
+# Test hash with no key (default 16)
+check_output \
+ "$srcdir/anonymize_hash_length.fferc" \
+ "$srcdir/anonymize.input" \
+ "$srcdir/expected_hash_no_key.expected" \
+ "test_hash_no_key" \
+ "raw"
+
+# Test hash with key=16 (should match default)
+check_output \
+ "$srcdir/anonymize_hash_length.fferc" \
+ "$srcdir/anonymize.input" \
+ "$srcdir/expected_hash_length_16.expected" \
+ "test_hash_length_16" \
+ "raw"
+
+# Test hash with key=32
+check_output \
+ "$srcdir/anonymize_hash_length.fferc" \
+ "$srcdir/anonymize.input" \
+ "$srcdir/expected_hash_length_32.expected" \
+ "test_hash_length_32" \
+ "raw"
+
+# Test hash with key=64
+check_output \
+ "$srcdir/anonymize_hash_length.fferc" \
+ "$srcdir/anonymize.input" \
+ "$srcdir/expected_hash_length_64.expected" \
+ "test_hash_length_64" \
+ "raw"
+
+# Verify that different keys produce different outputs
+if cmp -s "$srcdir/expected_hash_length_16.expected" "$srcdir/expected_hash_length_32.expected"; then
+ echo "FAIL: hash length 16 and 32 produce same output"
+ exit 1
+fi
+if cmp -s "$srcdir/expected_hash_length_16.expected" "$srcdir/expected_hash_length_64.expected"; then
+ echo "FAIL: hash length 16 and 64 produce same output"
+ exit 1
+fi
+if cmp -s "$srcdir/expected_hash_length_32.expected" "$srcdir/expected_hash_length_64.expected"; then
+ echo "FAIL: hash length 32 and 64 produce same output"
+ exit 1
+fi
+echo "PASS: hash key parameter tests"
+
+echo "All anonymization tests passed"
\ No newline at end of file
diff --git a/tests/binary/binary.expected b/tests/binary/binary.expected
new file mode 100644
index 0000000..5a0664c
--- /dev/null
+++ b/tests/binary/binary.expected
@@ -0,0 +1,7 @@
+text = ABC (x41x42x43x00x08)
+byte_int = 35 (x23)
+integer = 12345678 (x4ex61xbcx00)
+number = 345.385000 (x5cx8fxc2xf5x28x96x75x40)
+bcd_number = 45112 (x45x11x2f)
+hex = f15a9188 (xf1x5ax91x88)
+
diff --git a/tests/binary/binary.fferc b/tests/binary/binary.fferc
new file mode 100644
index 0000000..11f6921
--- /dev/null
+++ b/tests/binary/binary.fferc
@@ -0,0 +1,18 @@
+structure bin_data
+{
+ type binary
+ record b
+ {
+ field text 5
+ field byte_int int8
+ field integer int32_le
+ field number double_le
+ field bcd_number bcd_be_3
+ field hex hex_be_4
+ }
+}
+
+output default
+{
+ data "%n = %d (%h)\n"
+}
\ No newline at end of file
diff --git a/tests/binary/binary.input b/tests/binary/binary.input
new file mode 100644
index 0000000..a3dd96b
Binary files /dev/null and b/tests/binary/binary.input differ
diff --git a/tests/binary/test_binary.bats b/tests/binary/test_binary.bats
new file mode 100755
index 0000000..df28d60
--- /dev/null
+++ b/tests/binary/test_binary.bats
@@ -0,0 +1,23 @@
+#!/usr/bin/env bats
+
+# Set srcdir to the test directory
+srcdir="$BATS_TEST_DIRNAME"
+# Source our test helper
+source "$BATS_TEST_DIRNAME/../test_helper.bash"
+
+setup() {
+ # Setup runs before each test
+ # Use the environment variables set by test_helper.bash
+ setup_bats_tempdir
+ cd "$srcdir" || exit 1
+}
+
+teardown() {
+ # Cleanup if needed
+ :
+}
+
+@test "binary parsing test" {
+ # Use helper function to run test
+ run_ffe_test "binary.fferc" "binary.input" "binary.expected" -s bin_data
+}
\ No newline at end of file
diff --git a/tests/binary/test_binary.sh b/tests/binary/test_binary.sh
new file mode 100755
index 0000000..1da9d78
--- /dev/null
+++ b/tests/binary/test_binary.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+
+# Regression test for binary parsing
+
+set -e
+
+# Use environment variables if set, otherwise default
+FFE="${FFE_BIN:-../src/ffe}"
+srcdir="${srcdir:-.}"
+
+# Input files
+config="$srcdir/binary.fferc"
+input="$srcdir/binary.input"
+expected="$srcdir/binary.expected"
+
+# Run ffe
+output=$(mktemp)
+"$FFE" -c "$config" -s bin_data "$input" > "$output"
+
+# Compare with expected
+if diff -u "$expected" "$output"; then
+ echo "PASS: binary parsing test"
+ rm -f "$output"
+ exit 0
+else
+ echo "FAIL: output does not match expected"
+ rm -f "$output"
+ exit 1
+fi
\ No newline at end of file
diff --git a/tests/constants/constants.input b/tests/constants/constants.input
new file mode 100644
index 0000000..a9e3f3f
--- /dev/null
+++ b/tests/constants/constants.input
@@ -0,0 +1,3 @@
+John,Doe,30
+Jane,Smith,25
+Bob,Johnson,40
\ No newline at end of file
diff --git a/tests/constants/constants_basic.fferc b/tests/constants/constants_basic.fferc
new file mode 100644
index 0000000..8383b21
--- /dev/null
+++ b/tests/constants/constants_basic.fferc
@@ -0,0 +1,26 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ }
+}
+
+const version "1.0"
+const timestamp "2025-01-01"
+const source "TEST"
+
+output basic {
+ data "%n: %t\n"
+ field-list version,first_name,last_name,age,timestamp
+ indent " "
+}
+
+output all_constants {
+ data "%n: %t\n"
+ field-list version,first_name,last_name,age,timestamp,source
+ indent " "
+}
+
diff --git a/tests/constants/constants_field_option.fferc b/tests/constants/constants_field_option.fferc
new file mode 100644
index 0000000..204e43a
--- /dev/null
+++ b/tests/constants/constants_field_option.fferc
@@ -0,0 +1,12 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ }
+}
+
+const version "1.0"
+const source "TEST"
\ No newline at end of file
diff --git a/tests/constants/constants_field_option2.fferc b/tests/constants/constants_field_option2.fferc
new file mode 100644
index 0000000..e97934d
--- /dev/null
+++ b/tests/constants/constants_field_option2.fferc
@@ -0,0 +1,17 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ }
+}
+
+const version "1.0"
+const source "TEST"
+
+output default {
+ data "%n: %t\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/constants/constants_fixed.fferc b/tests/constants/constants_fixed.fferc
new file mode 100644
index 0000000..e8bf178
--- /dev/null
+++ b/tests/constants/constants_fixed.fferc
@@ -0,0 +1,25 @@
+structure data {
+ type fixed
+ record person {
+ field first_name 10
+ field last_name 15
+ field age 3
+ }
+}
+
+const dots ".."
+const version "v1.0"
+
+output default {
+ data "%D"
+}
+
+output with_constants {
+ data "%D"
+ field-list first_name,dots,last_name,dots,age
+}
+
+output with_version {
+ data "%D"
+ field-list version,first_name,last_name,age
+}
\ No newline at end of file
diff --git a/tests/constants/constants_fixed.input b/tests/constants/constants_fixed.input
new file mode 100644
index 0000000..cffb485
--- /dev/null
+++ b/tests/constants/constants_fixed.input
@@ -0,0 +1,3 @@
+John Doe 030
+Jane Smith 025
+Bob Johnson 040
\ No newline at end of file
diff --git a/tests/constants/constants_fixed_exact.fferc b/tests/constants/constants_fixed_exact.fferc
new file mode 100644
index 0000000..4a5f565
--- /dev/null
+++ b/tests/constants/constants_fixed_exact.fferc
@@ -0,0 +1,25 @@
+structure data {
+ type fixed
+ record person {
+ field first_name 5
+ field last_name 7
+ field age 2
+ }
+}
+
+const dots ".."
+const version "v1"
+
+output default {
+ data "%D"
+}
+
+output with_dots {
+ data "%D"
+ field-list first_name,dots,last_name,dots,age
+}
+
+output raw_with_dots {
+ data "%d"
+ field-list first_name,dots,last_name,dots,age
+}
\ No newline at end of file
diff --git a/tests/constants/constants_fixed_exact.input b/tests/constants/constants_fixed_exact.input
new file mode 100644
index 0000000..f3828e0
--- /dev/null
+++ b/tests/constants/constants_fixed_exact.input
@@ -0,0 +1,3 @@
+John Doe 30
+Jane Smith 25
+Bob Johnson40
\ No newline at end of file
diff --git a/tests/constants/constants_override.fferc b/tests/constants/constants_override.fferc
new file mode 100644
index 0000000..2ef5461
--- /dev/null
+++ b/tests/constants/constants_override.fferc
@@ -0,0 +1,18 @@
+structure data {
+ type separated ,
+
+ record person {
+ field first_name
+ field last_name
+ field age
+ }
+}
+
+const first_name "OVERRIDDEN"
+const version "1.0"
+
+output test {
+ data "%n: %t\n"
+ field-list version,first_name,last_name,age
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/constants/expected_all_constants.expected b/tests/constants/expected_all_constants.expected
new file mode 100644
index 0000000..a05283b
--- /dev/null
+++ b/tests/constants/expected_all_constants.expected
@@ -0,0 +1,21 @@
+ version: 1.0
+ first_name: John
+ last_name: Doe
+ age: 30
+ timestamp: 2025-01-01
+ source: TEST
+
+ version: 1.0
+ first_name: Jane
+ last_name: Smith
+ age: 25
+ timestamp: 2025-01-01
+ source: TEST
+
+ version: 1.0
+ first_name: Bob
+ last_name: Johnson
+ age: 40
+ timestamp: 2025-01-01
+ source: TEST
+
diff --git a/tests/constants/expected_basic.expected b/tests/constants/expected_basic.expected
new file mode 100644
index 0000000..3d0f08c
--- /dev/null
+++ b/tests/constants/expected_basic.expected
@@ -0,0 +1,18 @@
+ version: 1.0
+ first_name: John
+ last_name: Doe
+ age: 30
+ timestamp: 2025-01-01
+
+ version: 1.0
+ first_name: Jane
+ last_name: Smith
+ age: 25
+ timestamp: 2025-01-01
+
+ version: 1.0
+ first_name: Bob
+ last_name: Johnson
+ age: 40
+ timestamp: 2025-01-01
+
diff --git a/tests/constants/expected_fixed_default.expected b/tests/constants/expected_fixed_default.expected
new file mode 100644
index 0000000..1708cb1
--- /dev/null
+++ b/tests/constants/expected_fixed_default.expected
@@ -0,0 +1,3 @@
+John Doe 30
+Jane Smith 25
+Bob Johnson40
diff --git a/tests/constants/expected_fixed_raw_with_dots.expected b/tests/constants/expected_fixed_raw_with_dots.expected
new file mode 100644
index 0000000..6e75bba
--- /dev/null
+++ b/tests/constants/expected_fixed_raw_with_dots.expected
@@ -0,0 +1,3 @@
+John ..Doe ..30
+Jane ..Smith ..25
+Bob ..Johnson..40
diff --git a/tests/constants/expected_fixed_with_dots.expected b/tests/constants/expected_fixed_with_dots.expected
new file mode 100644
index 0000000..6e75bba
--- /dev/null
+++ b/tests/constants/expected_fixed_with_dots.expected
@@ -0,0 +1,3 @@
+John ..Doe ..30
+Jane ..Smith ..25
+Bob ..Johnson..40
diff --git a/tests/constants/expected_override.expected b/tests/constants/expected_override.expected
new file mode 100644
index 0000000..eccac0b
--- /dev/null
+++ b/tests/constants/expected_override.expected
@@ -0,0 +1,15 @@
+ version: 1.0
+ first_name: OVERRIDDEN
+ last_name: Doe
+ age: 30
+
+ version: 1.0
+ first_name: OVERRIDDEN
+ last_name: Smith
+ age: 25
+
+ version: 1.0
+ first_name: OVERRIDDEN
+ last_name: Johnson
+ age: 40
+
diff --git a/tests/constants/test_constants.bats b/tests/constants/test_constants.bats
new file mode 100755
index 0000000..60f351a
--- /dev/null
+++ b/tests/constants/test_constants.bats
@@ -0,0 +1,54 @@
+#!/usr/bin/env bats
+
+# Set srcdir to the test directory
+srcdir="$BATS_TEST_DIRNAME"
+# Source our test helper
+source "$BATS_TEST_DIRNAME/../test_helper.bash"
+
+setup() {
+ # Setup temporary directory for tests
+ setup_bats_tempdir
+ # Change to test directory
+ cd "$srcdir" || exit 1
+}
+
+teardown() {
+ # Cleanup if needed
+ :
+}
+
+# Helper function for constants tests
+run_constants_test() {
+ local config="$1"
+ local input="$2"
+ local expected="$3"
+ local output_profile="$4"
+
+ run "$FFE_BIN" -c "$config" -p "$output_profile" "$input"
+ assert_success
+ assert_output_matches_file "$expected"
+}
+
+@test "basic constants in separated output" {
+ run_constants_test "constants_basic.fferc" "constants.input" "expected_basic.expected" "basic"
+}
+
+@test "multiple constants in separated output" {
+ run_constants_test "constants_basic.fferc" "constants.input" "expected_all_constants.expected" "all_constants"
+}
+
+@test "constants overriding input fields" {
+ run_constants_test "constants_override.fferc" "constants.input" "expected_override.expected" "test"
+}
+
+@test "fixed-length default output (baseline)" {
+ run_constants_test "constants_fixed_exact.fferc" "constants_fixed_exact.input" "expected_fixed_default.expected" "default"
+}
+
+@test "fixed-length with dot constants (%D trimmed)" {
+ run_constants_test "constants_fixed_exact.fferc" "constants_fixed_exact.input" "expected_fixed_with_dots.expected" "with_dots"
+}
+
+@test "fixed-length with dot constants (%D trimmed) - raw output" {
+ run_constants_test "constants_fixed_exact.fferc" "constants_fixed_exact.input" "expected_fixed_raw_with_dots.expected" "raw_with_dots"
+}
\ No newline at end of file
diff --git a/tests/constants/test_constants.sh b/tests/constants/test_constants.sh
new file mode 100755
index 0000000..ef8c269
--- /dev/null
+++ b/tests/constants/test_constants.sh
@@ -0,0 +1,88 @@
+#!/bin/sh
+
+# Regression test for constants functionality
+
+set -e
+
+# Use environment variables if set, otherwise default
+FFE="${FFE_BIN:-../src/ffe}"
+srcdir="${srcdir:-.}"
+
+# Test counter
+total_tests=0
+passed_tests=0
+failed_tests=0
+
+echo "=== Running constants tests ==="
+
+# Function to run a test case
+run_test() {
+ local test_name="$1"
+ local config="$2"
+ local input="$3"
+ local expected="$4"
+ local output_profile="$5"
+
+ total_tests=$((total_tests + 1))
+
+ echo "Running test: $test_name"
+
+ # Create temporary output file
+ output=$(mktemp)
+
+ # Run ffe with the configuration
+ if ! "$FFE" -c "$config" -p "$output_profile" "$input" > "$output" 2>&1; then
+ echo " ERROR: ffe command failed for test '$test_name'"
+ echo " Config: $config, Input: $input"
+ failed_tests=$((failed_tests + 1))
+ rm -f "$output"
+ return 1
+ fi
+
+ # Compare with expected output
+ if diff -u "$expected" "$output" > /dev/null 2>&1; then
+ echo " PASS: $test_name"
+ passed_tests=$((passed_tests + 1))
+ else
+ echo " FAIL: $test_name"
+ echo " Config: $config, Input: $input"
+ echo " Diff output:"
+ diff -u "$expected" "$output" || true
+ failed_tests=$((failed_tests + 1))
+ fi
+
+ rm -f "$output"
+}
+
+# Run all test cases
+
+# Test 1: Basic constants in separated output
+run_test "basic_constants" "$srcdir/constants_basic.fferc" "$srcdir/constants.input" "$srcdir/expected_basic.expected" "basic"
+
+# Test 2: Multiple constants in separated output
+run_test "all_constants" "$srcdir/constants_basic.fferc" "$srcdir/constants.input" "$srcdir/expected_all_constants.expected" "all_constants"
+
+# Test 3: Constants overriding input fields
+run_test "override_constants" "$srcdir/constants_override.fferc" "$srcdir/constants.input" "$srcdir/expected_override.expected" "test"
+
+# Test 4: Fixed-length default output (baseline)
+run_test "fixed_default" "$srcdir/constants_fixed_exact.fferc" "$srcdir/constants_fixed_exact.input" "$srcdir/expected_fixed_default.expected" "default"
+
+# Test 5: Fixed-length with dot constants (%D trimmed)
+run_test "fixed_with_dots" "$srcdir/constants_fixed_exact.fferc" "$srcdir/constants_fixed_exact.input" "$srcdir/expected_fixed_with_dots.expected" "with_dots"
+
+# Test 6: Fixed-length with dot constants (%D trimmed) - raw output
+run_test "fixed_raw_with_dots" "$srcdir/constants_fixed_exact.fferc" "$srcdir/constants_fixed_exact.input" "$srcdir/expected_fixed_raw_with_dots.expected" "raw_with_dots"
+
+echo "=== Test summary ==="
+echo "Total tests: $total_tests"
+echo "Passed: $passed_tests"
+echo "Failed: $failed_tests"
+
+if [ $failed_tests -eq 0 ]; then
+ echo "All tests passed!"
+ exit 0
+else
+ echo "$failed_tests test(s) failed"
+ exit 1
+fi
\ No newline at end of file
diff --git a/tests/expressions/expected_case_insensitive.expected b/tests/expressions/expected_case_insensitive.expected
new file mode 100644
index 0000000..89a325e
--- /dev/null
+++ b/tests/expressions/expected_case_insensitive.expected
@@ -0,0 +1 @@
+
diff --git a/tests/expressions/expected_contains.expected b/tests/expressions/expected_contains.expected
new file mode 100644
index 0000000..c18810e
--- /dev/null
+++ b/tests/expressions/expected_contains.expected
@@ -0,0 +1,26 @@
+
+
+ Scott
+ Tiger
+ 45
+
+
+ John
+ Ripper
+ 23
+
+
+ Robert
+ Robertson
+ 55
+
+
+ Alice
+ Wonderland
+ 29
+
+
+ Bob
+ Builder
+ 36
+
diff --git a/tests/expressions/expected_equals.expected b/tests/expressions/expected_equals.expected
new file mode 100644
index 0000000..df560d0
--- /dev/null
+++ b/tests/expressions/expected_equals.expected
@@ -0,0 +1,6 @@
+
+
+ Scott
+ Tiger
+ 45
+
diff --git a/tests/expressions/expected_file_value.expected b/tests/expressions/expected_file_value.expected
new file mode 100644
index 0000000..50944d4
--- /dev/null
+++ b/tests/expressions/expected_file_value.expected
@@ -0,0 +1,11 @@
+
+
+ Scott
+ Tiger
+ 45
+
+
+ John
+ Ripper
+ 23
+
diff --git a/tests/expressions/expected_invert.expected b/tests/expressions/expected_invert.expected
new file mode 100644
index 0000000..82c8973
--- /dev/null
+++ b/tests/expressions/expected_invert.expected
@@ -0,0 +1,36 @@
+
+
+ John
+ Ripper
+ 23
+
+
+ Mary
+ Moore
+ 41
+
+
+ Samantha
+ Longname
+ 32
+
+
+ Sam
+ Short
+ 28
+
+
+ Robert
+ Robertson
+ 55
+
+
+ Alice
+ Wonderland
+ 29
+
+
+ Bob
+ Builder
+ 36
+
diff --git a/tests/expressions/expected_multiple_and.expected b/tests/expressions/expected_multiple_and.expected
new file mode 100644
index 0000000..df560d0
--- /dev/null
+++ b/tests/expressions/expected_multiple_and.expected
@@ -0,0 +1,6 @@
+
+
+ Scott
+ Tiger
+ 45
+
diff --git a/tests/expressions/expected_multiple_or.expected b/tests/expressions/expected_multiple_or.expected
new file mode 100644
index 0000000..50944d4
--- /dev/null
+++ b/tests/expressions/expected_multiple_or.expected
@@ -0,0 +1,11 @@
+
+
+ Scott
+ Tiger
+ 45
+
+
+ John
+ Ripper
+ 23
+
diff --git a/tests/expressions/expected_not_contains.expected b/tests/expressions/expected_not_contains.expected
new file mode 100644
index 0000000..9cfe8f8
--- /dev/null
+++ b/tests/expressions/expected_not_contains.expected
@@ -0,0 +1,16 @@
+
+
+ Mary
+ Moore
+ 41
+
+
+ Samantha
+ Longname
+ 32
+
+
+ Sam
+ Short
+ 28
+
diff --git a/tests/expressions/expected_not_equals.expected b/tests/expressions/expected_not_equals.expected
new file mode 100644
index 0000000..82c8973
--- /dev/null
+++ b/tests/expressions/expected_not_equals.expected
@@ -0,0 +1,36 @@
+
+
+ John
+ Ripper
+ 23
+
+
+ Mary
+ Moore
+ 41
+
+
+ Samantha
+ Longname
+ 32
+
+
+ Sam
+ Short
+ 28
+
+
+ Robert
+ Robertson
+ 55
+
+
+ Alice
+ Wonderland
+ 29
+
+
+ Bob
+ Builder
+ 36
+
diff --git a/tests/expressions/expected_regex.expected b/tests/expressions/expected_regex.expected
new file mode 100644
index 0000000..d248e49
--- /dev/null
+++ b/tests/expressions/expected_regex.expected
@@ -0,0 +1,16 @@
+
+
+ Scott
+ Tiger
+ 45
+
+
+ Samantha
+ Longname
+ 32
+
+
+ Sam
+ Short
+ 28
+
diff --git a/tests/expressions/expected_starts_with.expected b/tests/expressions/expected_starts_with.expected
new file mode 100644
index 0000000..d248e49
--- /dev/null
+++ b/tests/expressions/expected_starts_with.expected
@@ -0,0 +1,16 @@
+
+
+ Scott
+ Tiger
+ 45
+
+
+ Samantha
+ Longname
+ 32
+
+
+ Sam
+ Short
+ 28
+
diff --git a/tests/expressions/expressions.fferc b/tests/expressions/expressions.fferc
new file mode 100644
index 0000000..d5f51f6
--- /dev/null
+++ b/tests/expressions/expressions.fferc
@@ -0,0 +1,17 @@
+structure personnel {
+ type separated ,
+ output xml
+ record person {
+ field FirstName
+ field LastName
+ field Age
+ }
+}
+
+output xml {
+ file_header "\n"
+ data "<%n>%t%n>\n"
+ record_header "<%r>\n"
+ record_trailer "%r>\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/expressions/expressions.input b/tests/expressions/expressions.input
new file mode 100644
index 0000000..2ae8920
--- /dev/null
+++ b/tests/expressions/expressions.input
@@ -0,0 +1,8 @@
+Scott,Tiger,45
+John,Ripper,23
+Mary,Moore,41
+Samantha,Longname,32
+Sam,Short,28
+Robert,Robertson,55
+Alice,Wonderland,29
+Bob,Builder,36
\ No newline at end of file
diff --git a/tests/expressions/test_expressions.bats b/tests/expressions/test_expressions.bats
new file mode 100755
index 0000000..c69b172
--- /dev/null
+++ b/tests/expressions/test_expressions.bats
@@ -0,0 +1,93 @@
+#!/usr/bin/env bats
+
+# Set srcdir to the test directory
+srcdir="$BATS_TEST_DIRNAME"
+# Source our test helper
+source "$BATS_TEST_DIRNAME/../test_helper.bash"
+
+setup() {
+ # Setup temporary directory for tests
+ setup_bats_tempdir
+ # Change to test directory
+ cd "$srcdir" || exit 1
+}
+
+teardown() {
+ # Cleanup if needed
+ :
+}
+
+# Common configuration and input files
+CONFIG="expressions.fferc"
+INPUT="expressions.input"
+
+@test "equals operator" {
+ run "$FFE_BIN" -c "$CONFIG" -e "FirstName=Scott" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_equals.expected"
+}
+
+@test "starts-with operator" {
+ run "$FFE_BIN" -c "$CONFIG" -e "FirstName^S" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_starts_with.expected"
+}
+
+@test "contains operator" {
+ run "$FFE_BIN" -c "$CONFIG" -e "LastName~er" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_contains.expected"
+}
+
+@test "does-not-contain operator" {
+ run "$FFE_BIN" -c "$CONFIG" -e "LastName#er" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_not_contains.expected"
+}
+
+@test "not-equals operator" {
+ run "$FFE_BIN" -c "$CONFIG" -e "FirstName!Scott" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_not_equals.expected"
+}
+
+@test "regex operator" {
+ run "$FFE_BIN" -c "$CONFIG" -e "FirstName?^S.*" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_regex.expected"
+}
+
+@test "multiple expressions (OR logic, default)" {
+ run "$FFE_BIN" -c "$CONFIG" -e "FirstName=Scott" -e "FirstName=John" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_multiple_or.expected"
+}
+
+@test "multiple expressions with AND logic (-a)" {
+ run "$FFE_BIN" -c "$CONFIG" -a -e "FirstName^S" -e "Age=45" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_multiple_and.expected"
+}
+
+@test "invert match (-v)" {
+ run "$FFE_BIN" -c "$CONFIG" -v -e "FirstName=Scott" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_invert.expected"
+}
+
+@test "case-insensitive matching (-X)" {
+ run "$FFE_BIN" -c "$CONFIG" -X -e "FirstName=scott" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_case_insensitive.expected"
+}
+
+@test "file value syntax (file:)" {
+ # Create temporary file with valid values
+ value_file="$BATS_TEST_TMPDIR/values.txt"
+ echo "Scott" > "$value_file"
+ echo "John" >> "$value_file"
+ run "$FFE_BIN" -c "$CONFIG" -e "FirstName=file:$value_file" "$INPUT"
+ assert_success
+ assert_output_matches_file "expected_file_value.expected"
+ # Temporary file automatically cleaned up by BATS_TEST_TMPDIR
+}
\ No newline at end of file
diff --git a/tests/expressions/test_expressions.sh b/tests/expressions/test_expressions.sh
new file mode 100755
index 0000000..45f9c5b
--- /dev/null
+++ b/tests/expressions/test_expressions.sh
@@ -0,0 +1,111 @@
+#!/bin/sh
+
+# Regression test for expression filtering with all operators
+
+set -e
+
+# Use environment variables if set, otherwise default
+FFE="${FFE_BIN:-../src/ffe}"
+srcdir="${srcdir:-.}"
+
+# Input files
+config="$srcdir/expressions.fferc"
+input="$srcdir/expressions.input"
+
+# Test counter
+total_tests=0
+passed_tests=0
+failed_tests=0
+
+echo "=== Running expression tests ==="
+
+# Function to run a test case with arbitrary ffe arguments
+run_test_with_args() {
+ local test_name="$1"
+ local expected_file="$2"
+ shift 2 # Remove first two arguments, rest are ffe arguments
+
+ total_tests=$((total_tests + 1))
+
+ echo "Running test: $test_name"
+
+ # Create temporary output file
+ output=$(mktemp)
+
+ # Run ffe with all remaining arguments
+ if ! "$FFE" -c "$config" "$@" "$input" > "$output" 2>&1; then
+ echo " ERROR: ffe command failed for test '$test_name'"
+ echo " Command: $FFE -c $config $@ $input"
+ failed_tests=$((failed_tests + 1))
+ rm -f "$output"
+ return 1
+ fi
+
+ # Compare with expected output
+ if diff -u "$expected_file" "$output" > /dev/null 2>&1; then
+ echo " PASS: $test_name"
+ passed_tests=$((passed_tests + 1))
+ else
+ echo " FAIL: $test_name"
+ echo " Command: $FFE -c $config $@ $input"
+ echo " Diff output:"
+ diff -u "$expected_file" "$output" || true
+ failed_tests=$((failed_tests + 1))
+ fi
+
+ rm -f "$output"
+}
+
+# Run all test cases
+
+# Test 1: Equality operator (=)
+run_test_with_args "equals_operator" "$srcdir/expected_equals.expected" -e "FirstName=Scott"
+
+# Test 2: Starts-with operator (^)
+run_test_with_args "starts_with_operator" "$srcdir/expected_starts_with.expected" -e "FirstName^S"
+
+# Test 3: Contains operator (~)
+run_test_with_args "contains_operator" "$srcdir/expected_contains.expected" -e "LastName~er"
+
+# Test 4: Does-not-contain operator (#)
+run_test_with_args "not_contains_operator" "$srcdir/expected_not_contains.expected" -e "LastName#er"
+
+# Test 5: Not-equals operator (!)
+run_test_with_args "not_equals_operator" "$srcdir/expected_not_equals.expected" -e "FirstName!Scott"
+
+# Test 6: Regular expression operator (?)
+run_test_with_args "regex_operator" "$srcdir/expected_regex.expected" -e "FirstName?^S.*"
+
+# Test 7: Multiple expressions (OR logic, default)
+run_test_with_args "multiple_or" "$srcdir/expected_multiple_or.expected" -e "FirstName=Scott" -e "FirstName=John"
+
+# Test 8: Multiple expressions with AND logic (-a)
+run_test_with_args "multiple_and" "$srcdir/expected_multiple_and.expected" -a -e "FirstName^S" -e "Age=45"
+# Matches records where FirstName starts with S AND Age equals 45 (only Scott)
+
+# Test 9: Invert match (-v)
+run_test_with_args "invert_match" "$srcdir/expected_invert.expected" -v -e "FirstName=Scott"
+
+# Test 10: Case-insensitive matching (-X)
+run_test_with_args "case_insensitive" "$srcdir/expected_case_insensitive.expected" -X -e "FirstName=scott"
+
+# Test 11: File value syntax (file:)
+# Create a file with valid values
+value_file=$(mktemp)
+echo "Scott" > "$value_file"
+echo "John" >> "$value_file"
+run_test_with_args "file_value" "$srcdir/expected_file_value.expected" -e "FirstName=file:$value_file"
+rm -f "$value_file"
+
+echo "=== Test summary ==="
+echo "Total tests: $total_tests"
+echo "Passed: $passed_tests"
+echo "Failed: $failed_tests"
+
+if [ $failed_tests -eq 0 ]; then
+ echo "All tests passed!"
+ exit 0
+else
+ echo "$failed_tests test(s) failed"
+ exit 1
+fi
\ No newline at end of file
diff --git a/tests/fixed_length/fixed_length.expected b/tests/fixed_length/fixed_length.expected
new file mode 100644
index 0000000..8936b4e
--- /dev/null
+++ b/tests/fixed_length/fixed_length.expected
@@ -0,0 +1,16 @@
+
+
+ john
+ Ripper
+ 23
+
+
+ Scott
+ Tiger
+ 45
+
+
+ Mary
+ Moore
+ 41
+
diff --git a/tests/fixed_length/fixed_length.fferc b/tests/fixed_length/fixed_length.fferc
new file mode 100644
index 0000000..be577c9
--- /dev/null
+++ b/tests/fixed_length/fixed_length.fferc
@@ -0,0 +1,17 @@
+structure personnel {
+ type fixed
+ output xml
+ record person {
+ field FirstName 9
+ field LastName 13
+ field Age 2
+ }
+}
+
+output xml {
+ file_header "\n"
+ data "<%n>%t%n>\n"
+ record_header "<%r>\n"
+ record_trailer "%r>\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/fixed_length/fixed_length.input b/tests/fixed_length/fixed_length.input
new file mode 100644
index 0000000..c39b0c8
--- /dev/null
+++ b/tests/fixed_length/fixed_length.input
@@ -0,0 +1,3 @@
+john Ripper 23
+Scott Tiger 45
+Mary Moore 41
\ No newline at end of file
diff --git a/tests/fixed_length/test_fixed_length.bats b/tests/fixed_length/test_fixed_length.bats
new file mode 100755
index 0000000..9d77000
--- /dev/null
+++ b/tests/fixed_length/test_fixed_length.bats
@@ -0,0 +1,22 @@
+#!/usr/bin/env bats
+
+# Set srcdir to the test directory
+srcdir="$BATS_TEST_DIRNAME"
+# Source our test helper
+source "$BATS_TEST_DIRNAME/../test_helper.bash"
+
+setup() {
+ # Setup temporary directory for tests
+ setup_bats_tempdir
+ # Change to test directory
+ cd "$srcdir" || exit 1
+}
+
+teardown() {
+ # Cleanup if needed
+ :
+}
+
+@test "fixed-length parsing test" {
+ run_ffe_test "fixed_length.fferc" "fixed_length.input" "fixed_length.expected"
+}
\ No newline at end of file
diff --git a/tests/fixed_length/test_fixed_length.sh b/tests/fixed_length/test_fixed_length.sh
new file mode 100755
index 0000000..8d02fc0
--- /dev/null
+++ b/tests/fixed_length/test_fixed_length.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+
+# Regression test for fixed-length parsing
+
+set -e
+
+# Use environment variables if set, otherwise default
+FFE="${FFE_BIN:-../src/ffe}"
+srcdir="${srcdir:-.}"
+
+# Input files
+config="$srcdir/fixed_length.fferc"
+input="$srcdir/fixed_length.input"
+expected="$srcdir/fixed_length.expected"
+
+# Run ffe
+output=$(mktemp)
+"$FFE" -c "$config" "$input" > "$output"
+
+# Compare with expected
+if diff -u "$expected" "$output"; then
+ echo "PASS: fixed-length parsing test"
+ rm -f "$output"
+ exit 0
+else
+ echo "FAIL: output does not match expected"
+ rm -f "$output"
+ exit 1
+fi
\ No newline at end of file
diff --git a/tests/lookup/expected_comma.expected b/tests/lookup/expected_comma.expected
new file mode 100644
index 0000000..db96720
--- /dev/null
+++ b/tests/lookup/expected_comma.expected
@@ -0,0 +1,15 @@
+ code: A (Active)
+ value: 100
+
+ code: B (Blocked)
+ value: 200
+
+ code: C (Closed)
+ value: 300
+
+ code: X (Not Found)
+ value: 999
+
+ code: P (Pending)
+ value: 150
+
diff --git a/tests/lookup/expected_exact.expected b/tests/lookup/expected_exact.expected
new file mode 100644
index 0000000..c93315d
--- /dev/null
+++ b/tests/lookup/expected_exact.expected
@@ -0,0 +1,18 @@
+ code: A (Match A)
+ value: 10
+
+ code: AB (Match AB)
+ value: 20
+
+ code: ABC (Match ABC)
+ value: 30
+
+ code: B (Match B)
+ value: 40
+
+ code: BC (Match BC)
+ value: 50
+
+ code: X (No Match)
+ value: 60
+
diff --git a/tests/lookup/expected_file.expected b/tests/lookup/expected_file.expected
new file mode 100644
index 0000000..db96720
--- /dev/null
+++ b/tests/lookup/expected_file.expected
@@ -0,0 +1,15 @@
+ code: A (Active)
+ value: 100
+
+ code: B (Blocked)
+ value: 200
+
+ code: C (Closed)
+ value: 300
+
+ code: X (Not Found)
+ value: 999
+
+ code: P (Pending)
+ value: 150
+
diff --git a/tests/lookup/expected_longest.expected b/tests/lookup/expected_longest.expected
new file mode 100644
index 0000000..c93315d
--- /dev/null
+++ b/tests/lookup/expected_longest.expected
@@ -0,0 +1,18 @@
+ code: A (Match A)
+ value: 10
+
+ code: AB (Match AB)
+ value: 20
+
+ code: ABC (Match ABC)
+ value: 30
+
+ code: B (Match B)
+ value: 40
+
+ code: BC (Match BC)
+ value: 50
+
+ code: X (No Match)
+ value: 60
+
diff --git a/tests/lookup/expected_lower.expected b/tests/lookup/expected_lower.expected
new file mode 100644
index 0000000..b71c2a9
--- /dev/null
+++ b/tests/lookup/expected_lower.expected
@@ -0,0 +1,15 @@
+ code: A -> Active
+ value: 100
+
+ code: B -> Blocked
+ value: 200
+
+ code: C -> Closed
+ value: 300
+
+ code: X -> Unknown
+ value: 999
+
+ code: P -> Pending
+ value: 150
+
diff --git a/tests/lookup/expected_prefix_exact.expected b/tests/lookup/expected_prefix_exact.expected
new file mode 100644
index 0000000..e32f35b
--- /dev/null
+++ b/tests/lookup/expected_prefix_exact.expected
@@ -0,0 +1,12 @@
+ code: ABC (No Match)
+ value: 100
+
+ code: ABD (No Match)
+ value: 200
+
+ code: A (Match A)
+ value: 300
+
+ code: X (No Match)
+ value: 400
+
diff --git a/tests/lookup/expected_prefix_longest.expected b/tests/lookup/expected_prefix_longest.expected
new file mode 100644
index 0000000..ddc966c
--- /dev/null
+++ b/tests/lookup/expected_prefix_longest.expected
@@ -0,0 +1,12 @@
+ code: ABC (Match AB)
+ value: 100
+
+ code: ABD (Match AB)
+ value: 200
+
+ code: A (Match A)
+ value: 300
+
+ code: X (No Match)
+ value: 400
+
diff --git a/tests/lookup/expected_test.expected b/tests/lookup/expected_test.expected
new file mode 100644
index 0000000..827c817
--- /dev/null
+++ b/tests/lookup/expected_test.expected
@@ -0,0 +1,15 @@
+ code: A (Active)
+ value: 100
+
+ code: B (Blocked)
+ value: 200
+
+ code: C (Closed)
+ value: 300
+
+ code: X (Unknown)
+ value: 999
+
+ code: P (Pending)
+ value: 150
+
diff --git a/tests/lookup/expected_upper.expected b/tests/lookup/expected_upper.expected
new file mode 100644
index 0000000..b71c2a9
--- /dev/null
+++ b/tests/lookup/expected_upper.expected
@@ -0,0 +1,15 @@
+ code: A -> Active
+ value: 100
+
+ code: B -> Blocked
+ value: 200
+
+ code: C -> Closed
+ value: 300
+
+ code: X -> Unknown
+ value: 999
+
+ code: P -> Pending
+ value: 150
+
diff --git a/tests/lookup/lookup.input b/tests/lookup/lookup.input
new file mode 100644
index 0000000..6017243
--- /dev/null
+++ b/tests/lookup/lookup.input
@@ -0,0 +1,5 @@
+A,Active,100
+B,Blocked,200
+C,Closed,300
+X,Unknown,999
+P,Pending,150
\ No newline at end of file
diff --git a/tests/lookup/lookup_exact_search.fferc b/tests/lookup/lookup_exact_search.fferc
new file mode 100644
index 0000000..0bb9fd3
--- /dev/null
+++ b/tests/lookup/lookup_exact_search.fferc
@@ -0,0 +1,24 @@
+structure data {
+ type separated ,
+
+ record entry {
+ field code * exact_lookup
+ field value
+ }
+}
+
+lookup exact_lookup {
+ search exact
+ pair A "Match A"
+ pair AB "Match AB"
+ pair ABC "Match ABC"
+ pair B "Match B"
+ pair BC "Match BC"
+ default-value "No Match"
+}
+
+output test {
+ data "%n: %t\n"
+ lookup "%n: %t (%l)\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/lookup/lookup_file.fferc b/tests/lookup/lookup_file.fferc
new file mode 100644
index 0000000..8cb80ad
--- /dev/null
+++ b/tests/lookup/lookup_file.fferc
@@ -0,0 +1,20 @@
+structure data {
+ type separated ,
+
+ record entry {
+ field code * file_lookup
+ field value
+ }
+}
+
+lookup file_lookup {
+ search exact
+ file lookup_file.txt
+ default-value "Not Found"
+}
+
+output test {
+ data "%n: %t\n"
+ lookup "%n: %t (%l)\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/lookup/lookup_file.txt b/tests/lookup/lookup_file.txt
new file mode 100644
index 0000000..63d6cf1
--- /dev/null
+++ b/tests/lookup/lookup_file.txt
@@ -0,0 +1,4 @@
+A;Active
+B;Blocked
+C;Closed
+P;Pending
\ No newline at end of file
diff --git a/tests/lookup/lookup_file_comma.fferc b/tests/lookup/lookup_file_comma.fferc
new file mode 100644
index 0000000..dbf44ce
--- /dev/null
+++ b/tests/lookup/lookup_file_comma.fferc
@@ -0,0 +1,20 @@
+structure data {
+ type separated ,
+
+ record entry {
+ field code * comma_lookup
+ field value
+ }
+}
+
+lookup comma_lookup {
+ search exact
+ file lookup_file_comma.txt ,
+ default-value "Not Found"
+}
+
+output test {
+ data "%n: %t\n"
+ lookup "%n: %t (%l)\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/lookup/lookup_file_comma.txt b/tests/lookup/lookup_file_comma.txt
new file mode 100644
index 0000000..0488385
--- /dev/null
+++ b/tests/lookup/lookup_file_comma.txt
@@ -0,0 +1,4 @@
+A,Active
+B,Blocked
+C,Closed
+P,Pending
\ No newline at end of file
diff --git a/tests/lookup/lookup_inline.fferc b/tests/lookup/lookup_inline.fferc
new file mode 100644
index 0000000..04c9efc
--- /dev/null
+++ b/tests/lookup/lookup_inline.fferc
@@ -0,0 +1,26 @@
+structure data {
+ type separated ,
+
+ record entry {
+ field status_code * status_lookup
+ field description
+ field value
+ }
+}
+
+lookup status_lookup {
+ search exact
+ pair A "Active Status"
+ pair B "Blocked Status"
+ pair C "Closed Status"
+ pair P "Pending Status"
+ default-value "Unknown Status"
+}
+
+output verbose {
+ data "Code: %d, Description: %t, Value: %t, Lookup: %l\n"
+}
+
+output uppercase {
+ data "Code: %d, Lookup Upper: %L\n"
+}
\ No newline at end of file
diff --git a/tests/lookup/lookup_longest.input b/tests/lookup/lookup_longest.input
new file mode 100644
index 0000000..b9cd48e
--- /dev/null
+++ b/tests/lookup/lookup_longest.input
@@ -0,0 +1,6 @@
+A,10
+AB,20
+ABC,30
+B,40
+BC,50
+X,60
\ No newline at end of file
diff --git a/tests/lookup/lookup_longest_search.fferc b/tests/lookup/lookup_longest_search.fferc
new file mode 100644
index 0000000..3f296a5
--- /dev/null
+++ b/tests/lookup/lookup_longest_search.fferc
@@ -0,0 +1,24 @@
+structure data {
+ type separated ,
+
+ record entry {
+ field code * longest_lookup
+ field value
+ }
+}
+
+lookup longest_lookup {
+ search longest
+ pair A "Match A"
+ pair AB "Match AB"
+ pair ABC "Match ABC"
+ pair B "Match B"
+ pair BC "Match BC"
+ default-value "No Match"
+}
+
+output test {
+ data "%n: %t\n"
+ lookup "%n: %t (%l)\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/lookup/lookup_prefix.input b/tests/lookup/lookup_prefix.input
new file mode 100644
index 0000000..6f90282
--- /dev/null
+++ b/tests/lookup/lookup_prefix.input
@@ -0,0 +1,4 @@
+ABC,100
+ABD,200
+A,300
+X,400
\ No newline at end of file
diff --git a/tests/lookup/lookup_prefix_exact.fferc b/tests/lookup/lookup_prefix_exact.fferc
new file mode 100644
index 0000000..29e02db
--- /dev/null
+++ b/tests/lookup/lookup_prefix_exact.fferc
@@ -0,0 +1,21 @@
+structure data {
+ type separated ,
+
+ record entry {
+ field code * prefix_lookup
+ field value
+ }
+}
+
+lookup prefix_lookup {
+ search exact
+ pair A "Match A"
+ pair AB "Match AB"
+ default-value "No Match"
+}
+
+output test {
+ data "%n: %t\n"
+ lookup "%n: %t (%l)\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/lookup/lookup_prefix_longest.fferc b/tests/lookup/lookup_prefix_longest.fferc
new file mode 100644
index 0000000..b82bbf5
--- /dev/null
+++ b/tests/lookup/lookup_prefix_longest.fferc
@@ -0,0 +1,21 @@
+structure data {
+ type separated ,
+
+ record entry {
+ field code * prefix_lookup
+ field value
+ }
+}
+
+lookup prefix_lookup {
+ search longest
+ pair A "Match A"
+ pair AB "Match AB"
+ default-value "No Match"
+}
+
+output test {
+ data "%n: %t\n"
+ lookup "%n: %t (%l)\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/lookup/lookup_simple.input b/tests/lookup/lookup_simple.input
new file mode 100644
index 0000000..ca62b60
--- /dev/null
+++ b/tests/lookup/lookup_simple.input
@@ -0,0 +1,5 @@
+A,100
+B,200
+C,300
+X,999
+P,150
\ No newline at end of file
diff --git a/tests/lookup/lookup_test.fferc b/tests/lookup/lookup_test.fferc
new file mode 100644
index 0000000..9520af1
--- /dev/null
+++ b/tests/lookup/lookup_test.fferc
@@ -0,0 +1,23 @@
+structure data {
+ type separated ,
+
+ record entry {
+ field code * status_lookup
+ field value
+ }
+}
+
+lookup status_lookup {
+ search exact
+ pair A "Active"
+ pair B "Blocked"
+ pair C "Closed"
+ pair P "Pending"
+ default-value "Unknown"
+}
+
+output test {
+ data "%n: %t\n"
+ lookup "%n: %t (%l)\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/lookup/lookup_uppercase.fferc b/tests/lookup/lookup_uppercase.fferc
new file mode 100644
index 0000000..81e2f1b
--- /dev/null
+++ b/tests/lookup/lookup_uppercase.fferc
@@ -0,0 +1,29 @@
+structure data {
+ type separated ,
+
+ record entry {
+ field code * status_lookup
+ field value
+ }
+}
+
+lookup status_lookup {
+ search exact
+ pair A "Active"
+ pair B "Blocked"
+ pair C "Closed"
+ pair P "Pending"
+ default-value "Unknown"
+}
+
+output test_upper {
+ data "%n: %t\n"
+ lookup "%n: %t -> %L\n"
+ indent " "
+}
+
+output test_lower {
+ data "%n: %t\n"
+ lookup "%n: %t -> %l\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/lookup/test_lookup.bats b/tests/lookup/test_lookup.bats
new file mode 100755
index 0000000..b23f6ad
--- /dev/null
+++ b/tests/lookup/test_lookup.bats
@@ -0,0 +1,66 @@
+#!/usr/bin/env bats
+
+# Set srcdir to the test directory
+srcdir="$BATS_TEST_DIRNAME"
+# Source our test helper
+source "$BATS_TEST_DIRNAME/../test_helper.bash"
+
+setup() {
+ # Setup temporary directory for tests
+ setup_bats_tempdir
+ # Change to test directory
+ cd "$srcdir" || exit 1
+}
+
+teardown() {
+ # Cleanup if needed
+ :
+}
+
+# Helper function for lookup tests
+run_lookup_test() {
+ local config="$1"
+ local input="$2"
+ local expected="$3"
+ local output_profile="$4"
+
+ run "$FFE_BIN" -c "$config" -p "$output_profile" "$input"
+ assert_success
+ assert_output_matches_file "$expected"
+}
+
+@test "inline pair lookup" {
+ run_lookup_test "lookup_test.fferc" "lookup_simple.input" "expected_test.expected" "test"
+}
+
+@test "file lookup (semicolon separator default)" {
+ run_lookup_test "lookup_file.fferc" "lookup_simple.input" "expected_file.expected" "test"
+}
+
+@test "file lookup with comma separator" {
+ run_lookup_test "lookup_file_comma.fferc" "lookup_simple.input" "expected_comma.expected" "test"
+}
+
+@test "longest search (exact matches)" {
+ run_lookup_test "lookup_longest_search.fferc" "lookup_longest.input" "expected_longest.expected" "test"
+}
+
+@test "exact search (exact matches)" {
+ run_lookup_test "lookup_exact_search.fferc" "lookup_longest.input" "expected_exact.expected" "test"
+}
+
+@test "prefix matching with longest search" {
+ run_lookup_test "lookup_prefix_longest.fferc" "lookup_prefix.input" "expected_prefix_longest.expected" "test"
+}
+
+@test "prefix matching with exact search" {
+ run_lookup_test "lookup_prefix_exact.fferc" "lookup_prefix.input" "expected_prefix_exact.expected" "test"
+}
+
+@test "uppercase lookup directive %L" {
+ run_lookup_test "lookup_uppercase.fferc" "lookup_simple.input" "expected_upper.expected" "test_upper"
+}
+
+@test "lowercase lookup directive %l" {
+ run_lookup_test "lookup_uppercase.fferc" "lookup_simple.input" "expected_lower.expected" "test_lower"
+}
\ No newline at end of file
diff --git a/tests/lookup/test_lookup.sh b/tests/lookup/test_lookup.sh
new file mode 100755
index 0000000..22dbe47
--- /dev/null
+++ b/tests/lookup/test_lookup.sh
@@ -0,0 +1,97 @@
+#!/bin/sh
+
+# Regression test for lookup table functionality
+
+set -e
+
+# Use environment variables if set, otherwise default
+FFE="${FFE_BIN:-../src/ffe}"
+srcdir="${srcdir:-.}"
+
+# Test counter
+total_tests=0
+passed_tests=0
+failed_tests=0
+
+echo "=== Running lookup tests ==="
+
+# Function to run a test case
+run_test() {
+ local test_name="$1"
+ local config="$2"
+ local input="$3"
+ local expected="$4"
+ local output_profile="$5"
+
+ total_tests=$((total_tests + 1))
+
+ echo "Running test: $test_name"
+
+ # Create temporary output file
+ output=$(mktemp)
+
+ # Run ffe with the configuration
+ if ! "$FFE" -c "$config" -p "$output_profile" "$input" > "$output" 2>&1; then
+ echo " ERROR: ffe command failed for test '$test_name'"
+ echo " Config: $config, Input: $input"
+ failed_tests=$((failed_tests + 1))
+ rm -f "$output"
+ return 1
+ fi
+
+ # Compare with expected output
+ if diff -u "$expected" "$output" > /dev/null 2>&1; then
+ echo " PASS: $test_name"
+ passed_tests=$((passed_tests + 1))
+ else
+ echo " FAIL: $test_name"
+ echo " Config: $config, Input: $input"
+ echo " Diff output:"
+ diff -u "$expected" "$output" || true
+ failed_tests=$((failed_tests + 1))
+ fi
+
+ rm -f "$output"
+}
+
+# Run all test cases
+
+# Test 1: Inline pair lookup
+run_test "inline_pair_lookup" "$srcdir/lookup_test.fferc" "$srcdir/lookup_simple.input" "$srcdir/expected_test.expected" "test"
+
+# Test 2: File lookup (semicolon separator default)
+run_test "file_lookup_semicolon" "$srcdir/lookup_file.fferc" "$srcdir/lookup_simple.input" "$srcdir/expected_file.expected" "test"
+
+# Test 3: File lookup with comma separator
+run_test "file_lookup_comma" "$srcdir/lookup_file_comma.fferc" "$srcdir/lookup_simple.input" "$srcdir/expected_comma.expected" "test"
+
+# Test 4: Longest search (exact matches)
+run_test "longest_search_exact" "$srcdir/lookup_longest_search.fferc" "$srcdir/lookup_longest.input" "$srcdir/expected_longest.expected" "test"
+
+# Test 5: Exact search (exact matches)
+run_test "exact_search_exact" "$srcdir/lookup_exact_search.fferc" "$srcdir/lookup_longest.input" "$srcdir/expected_exact.expected" "test"
+
+# Test 6: Prefix matching with longest search
+run_test "prefix_longest_search" "$srcdir/lookup_prefix_longest.fferc" "$srcdir/lookup_prefix.input" "$srcdir/expected_prefix_longest.expected" "test"
+
+# Test 7: Prefix matching with exact search
+run_test "prefix_exact_search" "$srcdir/lookup_prefix_exact.fferc" "$srcdir/lookup_prefix.input" "$srcdir/expected_prefix_exact.expected" "test"
+
+# Test 8: Uppercase lookup directive %L
+run_test "uppercase_directive" "$srcdir/lookup_uppercase.fferc" "$srcdir/lookup_simple.input" "$srcdir/expected_upper.expected" "test_upper"
+
+# Test 9: Lowercase lookup directive %l
+run_test "lowercase_directive" "$srcdir/lookup_uppercase.fferc" "$srcdir/lookup_simple.input" "$srcdir/expected_lower.expected" "test_lower"
+
+echo "=== Test summary ==="
+echo "Total tests: $total_tests"
+echo "Passed: $passed_tests"
+echo "Failed: $failed_tests"
+
+if [ $failed_tests -eq 0 ]; then
+ echo "All tests passed!"
+ exit 0
+else
+ echo "$failed_tests test(s) failed"
+ exit 1
+fi
\ No newline at end of file
diff --git a/tests/output/expected_basic.expected b/tests/output/expected_basic.expected
new file mode 100644
index 0000000..47de942
--- /dev/null
+++ b/tests/output/expected_basic.expected
@@ -0,0 +1,15 @@
+ FirstName: John
+ LastName: Doe
+ Age: 035
+ Score: 08000
+
+ FirstName: Jane
+ LastName: Smith
+ Age: 028
+ Score: 09500
+
+ FirstName: Bob
+ LastName: Johnson
+ Age: 042
+ Score: 07500
+
diff --git a/tests/output/expected_directives.expected b/tests/output/expected_directives.expected
new file mode 100644
index 0000000..304a26f
--- /dev/null
+++ b/tests/output/expected_directives.expected
@@ -0,0 +1,15 @@
+s=test_structure r=test_record o=1 O=1 i=0 I=0 n=FirstName t=John d=John D=John C=John p=1
+s=test_structure r=test_record o=1 O=1 i=0 I=0 n=LastName t=Doe d=Doe D=Doe C=Doe p=11
+s=test_structure r=test_record o=1 O=1 i=0 I=0 n=Age t=035 d=035 D=035 C=035 p=26
+s=test_structure r=test_record o=1 O=1 i=0 I=0 n=Score t=08000 d=08000 D=08000 C=08000 p=29
+
+s=test_structure r=test_record o=2 O=2 i=0 I=0 n=FirstName t=Jane d=Jane D=Jane C=Jane p=1
+s=test_structure r=test_record o=2 O=2 i=0 I=0 n=LastName t=Smith d=Smith D=Smith C=Smith p=11
+s=test_structure r=test_record o=2 O=2 i=0 I=0 n=Age t=028 d=028 D=028 C=028 p=26
+s=test_structure r=test_record o=2 O=2 i=0 I=0 n=Score t=09500 d=09500 D=09500 C=09500 p=29
+
+s=test_structure r=test_record o=3 O=3 i=0 I=0 n=FirstName t=Bob d=Bob D=Bob C=Bob p=1
+s=test_structure r=test_record o=3 O=3 i=0 I=0 n=LastName t=Johnson d=Johnson D=Johnson C=Johnson p=11
+s=test_structure r=test_record o=3 O=3 i=0 I=0 n=Age t=042 d=042 D=042 C=042 p=26
+s=test_structure r=test_record o=3 O=3 i=0 I=0 n=Score t=07500 d=07500 D=07500 C=07500 p=29
+
diff --git a/tests/output/expected_empty.expected b/tests/output/expected_empty.expected
new file mode 100644
index 0000000..afced0d
--- /dev/null
+++ b/tests/output/expected_empty.expected
@@ -0,0 +1,12 @@
+field=name value=John empty=
+field=age value=25 empty=
+field=score value= empty=
+
+field=name value= empty=
+field=age value= empty=
+field=score value=0 empty=
+
+field=name value=Alice empty=
+field=age value= empty=
+field=score value=100 empty=
+
diff --git a/tests/output/expected_fieldlist.expected b/tests/output/expected_fieldlist.expected
new file mode 100644
index 0000000..c8b5da2
--- /dev/null
+++ b/tests/output/expected_fieldlist.expected
@@ -0,0 +1,3 @@
+FirstName=John Age=035
+FirstName=Jane Age=028
+FirstName=Bob Age=042
diff --git a/tests/output/expected_file_directives.expected b/tests/output/expected_file_directives.expected
new file mode 100644
index 0000000..1da4def
--- /dev/null
+++ b/tests/output/expected_file_directives.expected
@@ -0,0 +1,5 @@
+=== File: ./test_input_28.input ===
+Record test_record #1 (byte 0): FirstName=John LastName=Doe Age=035
+Record test_record #2 (byte 0): FirstName=Jane LastName=Smith Age=028
+Record test_record #3 (byte 0): FirstName=Bob LastName=Johnson Age=042
+=== End of ./test_input_28.input ===
diff --git a/tests/output/expected_hex.expected b/tests/output/expected_hex.expected
new file mode 100644
index 0000000..82db5fb
--- /dev/null
+++ b/tests/output/expected_hex.expected
@@ -0,0 +1,2 @@
+integer=305419896 hex=12345678 hex_with_prefix=x78x56x34x12
+
diff --git a/tests/output/expected_hex_caps.expected b/tests/output/expected_hex_caps.expected
new file mode 100644
index 0000000..eee09a2
--- /dev/null
+++ b/tests/output/expected_hex_caps.expected
@@ -0,0 +1,2 @@
+integer=-1412623820 hex=ffffffffabcd1234 hex_with_prefix=x34x12xCDxAB
+
diff --git a/tests/output/expected_lookup.expected b/tests/output/expected_lookup.expected
new file mode 100644
index 0000000..b93d4a4
--- /dev/null
+++ b/tests/output/expected_lookup.expected
@@ -0,0 +1,9 @@
+code=A value=A lookup_lower=Active lookup_upper=Active
+code=100 value=100 lookup_lower= lookup_upper=
+
+code=B value=B lookup_lower=Blocked lookup_upper=Blocked
+code=200 value=200 lookup_lower= lookup_upper=
+
+code=X value=X lookup_lower=Unknown lookup_upper=Unknown
+code=999 value=999 lookup_lower= lookup_upper=
+
diff --git a/tests/output/expected_percent.expected b/tests/output/expected_percent.expected
new file mode 100644
index 0000000..62ac84a
--- /dev/null
+++ b/tests/output/expected_percent.expected
@@ -0,0 +1,3 @@
+Percent sign: %
+Percent sign: %
+
diff --git a/tests/output/expected_separator.expected b/tests/output/expected_separator.expected
new file mode 100644
index 0000000..79c4390
--- /dev/null
+++ b/tests/output/expected_separator.expected
@@ -0,0 +1,3 @@
+John,Doe,035,08000
+Jane,Smith,028,09500
+Bob,Johnson,042,07500
diff --git a/tests/output/hex_caps.input b/tests/output/hex_caps.input
new file mode 100644
index 0000000..e7b36da
--- /dev/null
+++ b/tests/output/hex_caps.input
@@ -0,0 +1 @@
+4ͫ
\ No newline at end of file
diff --git a/tests/output/hex_test.input b/tests/output/hex_test.input
new file mode 100644
index 0000000..c049c70
--- /dev/null
+++ b/tests/output/hex_test.input
@@ -0,0 +1 @@
+xV4
\ No newline at end of file
diff --git a/tests/output/output_basic.fferc b/tests/output/output_basic.fferc
new file mode 100644
index 0000000..d664ac2
--- /dev/null
+++ b/tests/output/output_basic.fferc
@@ -0,0 +1,14 @@
+structure test_structure {
+ type fixed
+ record test_record {
+ field FirstName 10
+ field LastName 15
+ field Age 3
+ field Score 5
+ }
+}
+
+output default {
+ data "%n: %t\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/output/output_basic.input b/tests/output/output_basic.input
new file mode 100644
index 0000000..fb652c9
--- /dev/null
+++ b/tests/output/output_basic.input
@@ -0,0 +1,3 @@
+John Doe 035080
+Jane Smith 028095
+Bob Johnson 042075
\ No newline at end of file
diff --git a/tests/output/output_directives.fferc b/tests/output/output_directives.fferc
new file mode 100644
index 0000000..b74c3f9
--- /dev/null
+++ b/tests/output/output_directives.fferc
@@ -0,0 +1,14 @@
+structure test_structure {
+ type fixed
+ record test_record {
+ field FirstName 10
+ field LastName 15
+ field Age 3
+ field Score 5
+ }
+}
+
+output directives {
+ # Print all directives for each field
+ data "s=%s r=%r o=%o O=%O i=%i I=%I n=%n t=%t d=%d D=%D C=%C p=%p\n"
+}
\ No newline at end of file
diff --git a/tests/output/output_empty.fferc b/tests/output/output_empty.fferc
new file mode 100644
index 0000000..bb6b9f8
--- /dev/null
+++ b/tests/output/output_empty.fferc
@@ -0,0 +1,12 @@
+structure empty_test {
+ type separated ,
+ record test_record {
+ field name
+ field age
+ field score
+ }
+}
+
+output empty_output {
+ data "field=%n value=%t empty=%e\n"
+}
\ No newline at end of file
diff --git a/tests/output/output_empty.input b/tests/output/output_empty.input
new file mode 100644
index 0000000..87fe956
--- /dev/null
+++ b/tests/output/output_empty.input
@@ -0,0 +1,3 @@
+John,25,
+,,0
+Alice,,100
\ No newline at end of file
diff --git a/tests/output/output_field_empty_print.fferc b/tests/output/output_field_empty_print.fferc
new file mode 100644
index 0000000..1e34d2f
--- /dev/null
+++ b/tests/output/output_field_empty_print.fferc
@@ -0,0 +1,13 @@
+structure empty_test {
+ type separated ,
+ record test_record {
+ field name
+ field age
+ field score
+ }
+}
+
+output empty_output {
+ data "field=%n value=%t\n"
+ field-empty-print "NULL"
+}
\ No newline at end of file
diff --git a/tests/output/output_fieldlist.fferc b/tests/output/output_fieldlist.fferc
new file mode 100644
index 0000000..de67355
--- /dev/null
+++ b/tests/output/output_fieldlist.fferc
@@ -0,0 +1,14 @@
+structure test_structure {
+ type fixed
+ record test_record {
+ field FirstName 10
+ field LastName 15
+ field Age 3
+ field Score 5
+ }
+}
+
+output selected {
+ data "%n=%t "
+ field-list "FirstName,Age"
+}
\ No newline at end of file
diff --git a/tests/output/output_file_directives.fferc b/tests/output/output_file_directives.fferc
new file mode 100644
index 0000000..d191a3a
--- /dev/null
+++ b/tests/output/output_file_directives.fferc
@@ -0,0 +1,16 @@
+structure test_structure {
+ type fixed
+ record test_record {
+ field FirstName 10
+ field LastName 15
+ field Age 3
+ }
+}
+
+output file_info {
+ file_header "=== File: %f ===\n"
+ record_header "Record %r #%o (byte %i): "
+ data "%n=%t "
+ record_trailer "\n"
+ file_trailer "=== End of %f ===\n"
+}
\ No newline at end of file
diff --git a/tests/output/output_group.fferc b/tests/output/output_group.fferc
new file mode 100644
index 0000000..ef49cf1
--- /dev/null
+++ b/tests/output/output_group.fferc
@@ -0,0 +1,14 @@
+structure test {
+ type fixed
+ group person {
+ element name {
+ field first 10
+ field last 15
+ }
+ element age 3
+ }
+}
+
+output group_output {
+ data "%g.%m: %t\n"
+}
\ No newline at end of file
diff --git a/tests/output/output_group.input b/tests/output/output_group.input
new file mode 100644
index 0000000..aeb16f4
--- /dev/null
+++ b/tests/output/output_group.input
@@ -0,0 +1 @@
+John Doe 035
\ No newline at end of file
diff --git a/tests/output/output_hex.fferc b/tests/output/output_hex.fferc
new file mode 100644
index 0000000..dd59692
--- /dev/null
+++ b/tests/output/output_hex.fferc
@@ -0,0 +1,10 @@
+structure hex_test {
+ type binary
+ record test_record {
+ field integer int32_le
+ }
+}
+
+output hex_output {
+ data "integer=%d hex=%x hex_with_prefix=%h\n"
+}
\ No newline at end of file
diff --git a/tests/output/output_hex_caps.fferc b/tests/output/output_hex_caps.fferc
new file mode 100644
index 0000000..1927032
--- /dev/null
+++ b/tests/output/output_hex_caps.fferc
@@ -0,0 +1,11 @@
+structure hex_test {
+ type binary
+ record test_record {
+ field integer int32_le
+ }
+}
+
+output hex_output {
+ data "integer=%d hex=%x hex_with_prefix=%h\n"
+ hex-caps yes
+}
\ No newline at end of file
diff --git a/tests/output/output_lookup.fferc b/tests/output/output_lookup.fferc
new file mode 100644
index 0000000..b1252bd
--- /dev/null
+++ b/tests/output/output_lookup.fferc
@@ -0,0 +1,19 @@
+structure lookup_test {
+ type separated ,
+ record test_record {
+ field code * status_lookup
+ field value
+ }
+}
+
+lookup status_lookup {
+ search exact
+ pair A "Active"
+ pair B "Blocked"
+ pair C "Closed"
+ default-value "Unknown"
+}
+
+output lookup_output {
+ data "code=%d value=%t lookup_lower=%l lookup_upper=%L\n"
+}
\ No newline at end of file
diff --git a/tests/output/output_lookup.input b/tests/output/output_lookup.input
new file mode 100644
index 0000000..3534bb4
--- /dev/null
+++ b/tests/output/output_lookup.input
@@ -0,0 +1,3 @@
+A,100
+B,200
+X,999
\ No newline at end of file
diff --git a/tests/output/output_percent.fferc b/tests/output/output_percent.fferc
new file mode 100644
index 0000000..3321c6f
--- /dev/null
+++ b/tests/output/output_percent.fferc
@@ -0,0 +1,11 @@
+structure test {
+ type fixed
+ record test_record {
+ field FirstName 10
+ field LastName 15
+ }
+}
+
+output percent_output {
+ data "Percent sign: %%\n"
+}
\ No newline at end of file
diff --git a/tests/output/output_percent.input b/tests/output/output_percent.input
new file mode 100644
index 0000000..4619dde
--- /dev/null
+++ b/tests/output/output_percent.input
@@ -0,0 +1 @@
+John Doe
\ No newline at end of file
diff --git a/tests/output/output_separator.fferc b/tests/output/output_separator.fferc
new file mode 100644
index 0000000..d1345c6
--- /dev/null
+++ b/tests/output/output_separator.fferc
@@ -0,0 +1,14 @@
+structure test_structure {
+ type fixed
+ record test_record {
+ field FirstName 10
+ field LastName 15
+ field Age 3
+ field Score 5
+ }
+}
+
+output csv {
+ data "%t"
+ separator ","
+}
\ No newline at end of file
diff --git a/tests/output/test_input.input b/tests/output/test_input.input
new file mode 100644
index 0000000..0e12d10
--- /dev/null
+++ b/tests/output/test_input.input
@@ -0,0 +1,3 @@
+John Doe 03508000
+Jane Smith 02809500
+Bob Johnson 04207500
\ No newline at end of file
diff --git a/tests/output/test_input_28.input b/tests/output/test_input_28.input
new file mode 100644
index 0000000..938189c
--- /dev/null
+++ b/tests/output/test_input_28.input
@@ -0,0 +1,3 @@
+John Doe 035
+Jane Smith 028
+Bob Johnson 042
\ No newline at end of file
diff --git a/tests/output/test_output.bats b/tests/output/test_output.bats
new file mode 100755
index 0000000..2874058
--- /dev/null
+++ b/tests/output/test_output.bats
@@ -0,0 +1,88 @@
+#!/usr/bin/env bats
+
+# Set srcdir to the test directory
+srcdir="$BATS_TEST_DIRNAME"
+# Source our test helper
+source "$BATS_TEST_DIRNAME/../test_helper.bash"
+
+setup() {
+ # Setup temporary directory for tests
+ setup_bats_tempdir
+ # Change to test directory
+ cd "$srcdir" || exit 1
+}
+
+teardown() {
+ # Cleanup if needed
+ :
+}
+
+# Helper function for output tests (similar to run_test_with_args)
+run_output_test_with_args() {
+ local expected_file="$1"
+ shift 1 # Remove first argument, rest are ffe arguments
+
+ run "$FFE_BIN" "$@"
+ assert_success
+ assert_output_matches_file "$expected_file"
+}
+
+@test "basic output with indent and field names" {
+ run_output_test_with_args \
+ "expected_basic.expected" \
+ -c "output_basic.fferc" -s test_structure "test_input.input"
+}
+
+@test "file-related directives (file_header, record_header, etc.)" {
+ run_output_test_with_args \
+ "expected_file_directives.expected" \
+ -c "output_file_directives.fferc" -s test_structure -p file_info "./test_input_28.input"
+}
+
+@test "various output directives (%s, %r, %o, %O, %i, %I, %n, %t, %d, %D, %C, %p)" {
+ run_output_test_with_args \
+ "expected_directives.expected" \
+ -c "output_directives.fferc" -s test_structure -p directives "test_input.input"
+}
+
+@test "separator option" {
+ run_output_test_with_args \
+ "expected_separator.expected" \
+ -c "output_separator.fferc" -s test_structure -p csv "test_input.input"
+}
+
+@test "field-list option" {
+ run_output_test_with_args \
+ "expected_fieldlist.expected" \
+ -c "output_fieldlist.fferc" -s test_structure -p selected "test_input.input"
+}
+
+@test "hexadecimal output directives" {
+ run_output_test_with_args \
+ "expected_hex.expected" \
+ -c "output_hex.fferc" -s hex_test -p hex_output "hex_test.input"
+}
+
+@test "lookup output directives" {
+ run_output_test_with_args \
+ "expected_lookup.expected" \
+ -c "output_lookup.fferc" -s lookup_test -p lookup_output "output_lookup.input"
+}
+
+@test "empty field directive" {
+ run_output_test_with_args \
+ "expected_empty.expected" \
+ -c "output_empty.fferc" -s empty_test -p empty_output "output_empty.input"
+}
+
+@test "percent sign literal" {
+ run_output_test_with_args \
+ "expected_percent.expected" \
+ -c "output_percent.fferc" -s test -p percent_output "output_percent.input"
+}
+
+@test "hex-caps option" {
+ run_output_test_with_args \
+ "expected_hex_caps.expected" \
+ -c "output_hex_caps.fferc" -s hex_test -p hex_output "hex_caps.input"
+}
\ No newline at end of file
diff --git a/tests/output/test_output.sh b/tests/output/test_output.sh
new file mode 100755
index 0000000..c6f1db5
--- /dev/null
+++ b/tests/output/test_output.sh
@@ -0,0 +1,126 @@
+#!/bin/sh
+
+# Regression test for output formatting features
+
+set -e
+
+# Use environment variables if set, otherwise default
+FFE="${FFE_BIN:-../../src/ffe}"
+srcdir="${srcdir:-.}"
+
+# Input files
+basic_config="$srcdir/output_basic.fferc"
+basic_input="$srcdir/test_input.input"
+file_directives_config="$srcdir/output_file_directives.fferc"
+file_directives_input="$srcdir/test_input_28.input"
+directives_config="$srcdir/output_directives.fferc"
+directives_input="$srcdir/test_input.input"
+separator_config="$srcdir/output_separator.fferc"
+fieldlist_config="$srcdir/output_fieldlist.fferc"
+hex_config="$srcdir/output_hex.fferc"
+hex_input="$srcdir/hex_test.input"
+lookup_config="$srcdir/output_lookup.fferc"
+lookup_input="$srcdir/output_lookup.input"
+empty_config="$srcdir/output_empty.fferc"
+empty_input="$srcdir/output_empty.input"
+percent_config="$srcdir/output_percent.fferc"
+percent_input="$srcdir/output_percent.input"
+hex_caps_config="$srcdir/output_hex_caps.fferc"
+hex_caps_input="$srcdir/hex_caps.input"
+
+# Test counter
+total_tests=0
+passed_tests=0
+failed_tests=0
+
+echo "=== Running output tests ==="
+
+# Function to run a test case with arbitrary ffe arguments
+run_test_with_args() {
+ local test_name="$1"
+ local expected_file="$2"
+ shift 2 # Remove first two arguments, rest are ffe arguments
+
+ total_tests=$((total_tests + 1))
+
+ echo "Running test: $test_name"
+
+ # Create temporary output file
+ output=$(mktemp)
+
+ # Run ffe with all remaining arguments
+ if ! "$FFE" "$@" > "$output" 2>&1; then
+ echo " ERROR: ffe command failed for test '$test_name'"
+ echo " Command: $FFE $@"
+ failed_tests=$((failed_tests + 1))
+ rm -f "$output"
+ return 1
+ fi
+
+ # Compare with expected output
+ if diff -u "$expected_file" "$output" > /dev/null 2>&1; then
+ echo " PASS: $test_name"
+ passed_tests=$((passed_tests + 1))
+ else
+ echo " FAIL: $test_name"
+ echo " Command: $FFE $@"
+ echo " Diff output:"
+ diff -u "$expected_file" "$output" || true
+ failed_tests=$((failed_tests + 1))
+ fi
+
+ rm -f "$output"
+}
+
+# Test 1: Basic output with indent and field names
+run_test_with_args "basic_output" "$srcdir/expected_basic.expected" \
+ -c "$basic_config" -s test_structure "$basic_input"
+
+# Test 2: File-related directives (file_header, record_header, etc.)
+run_test_with_args "file_directives" "$srcdir/expected_file_directives.expected" \
+ -c "$file_directives_config" -s test_structure -p file_info "$file_directives_input"
+
+# Test 3: Various output directives (%s, %r, %o, %O, %i, %I, %n, %t, %d, %D, %C, %p)
+run_test_with_args "output_directives" "$srcdir/expected_directives.expected" \
+ -c "$directives_config" -s test_structure -p directives "$directives_input"
+
+# Test 4: Separator option
+run_test_with_args "separator" "$srcdir/expected_separator.expected" \
+ -c "$separator_config" -s test_structure -p csv "$basic_input"
+
+# Test 5: Field-list option
+run_test_with_args "fieldlist" "$srcdir/expected_fieldlist.expected" \
+ -c "$fieldlist_config" -s test_structure -p selected "$basic_input"
+
+# Test 6: Hexadecimal output directives
+run_test_with_args "hex_output" "$srcdir/expected_hex.expected" \
+ -c "$hex_config" -s hex_test -p hex_output "$hex_input"
+
+# Test 7: Lookup output directives
+run_test_with_args "lookup_output" "$srcdir/expected_lookup.expected" \
+ -c "$lookup_config" -s lookup_test -p lookup_output "$lookup_input"
+
+# Test 8: Empty field directive
+run_test_with_args "empty_output" "$srcdir/expected_empty.expected" \
+ -c "$empty_config" -s empty_test -p empty_output "$empty_input"
+
+# Test 9: Percent sign literal
+run_test_with_args "percent_output" "$srcdir/expected_percent.expected" \
+ -c "$percent_config" -s test -p percent_output "$percent_input"
+
+# Test 10: Hex-caps option
+run_test_with_args "hex_caps_output" "$srcdir/expected_hex_caps.expected" \
+ -c "$hex_caps_config" -s hex_test -p hex_output "$hex_caps_input"
+
+echo "=== Test summary ==="
+echo "Total tests: $total_tests"
+echo "Passed: $passed_tests"
+echo "Failed: $failed_tests"
+
+if [ $failed_tests -eq 0 ]; then
+ echo "All tests passed!"
+ exit 0
+else
+ echo "$failed_tests test(s) failed"
+ exit 1
+fi
\ No newline at end of file
diff --git a/tests/replace/expected_basic_fixed.expected b/tests/replace/expected_basic_fixed.expected
new file mode 100644
index 0000000..c1ab9ab
--- /dev/null
+++ b/tests/replace/expected_basic_fixed.expected
@@ -0,0 +1,4 @@
+BJohn Ripper 023
+BScott Tiger 045
+BMary Moore 041
+BRidge Forrester 031
diff --git a/tests/replace/expected_different_value.expected b/tests/replace/expected_different_value.expected
new file mode 100644
index 0000000..4fa7af8
--- /dev/null
+++ b/tests/replace/expected_different_value.expected
@@ -0,0 +1,4 @@
+XJohn Ripper 023
+XScott Tiger 045
+XMary Moore 041
+XRidge Forrester 031
diff --git a/tests/replace/expected_filtered_fixed.expected b/tests/replace/expected_filtered_fixed.expected
new file mode 100644
index 0000000..a00c762
--- /dev/null
+++ b/tests/replace/expected_filtered_fixed.expected
@@ -0,0 +1 @@
+BJohn Ripper 023
diff --git a/tests/replace/expected_multiple_fixed.expected b/tests/replace/expected_multiple_fixed.expected
new file mode 100644
index 0000000..1654aa8
--- /dev/null
+++ b/tests/replace/expected_multiple_fixed.expected
@@ -0,0 +1,4 @@
+BJohn Ripper 99
+BScott Tiger 99
+BMary Moore 99
+BRidge Forrester 99
diff --git a/tests/replace/expected_separated_default.expected b/tests/replace/expected_separated_default.expected
new file mode 100644
index 0000000..ca14f6e
--- /dev/null
+++ b/tests/replace/expected_separated_default.expected
@@ -0,0 +1,20 @@
+ EmpType: B
+ FirstName: John
+ LastName: Ripper
+ Age: 23
+
+ EmpType: B
+ FirstName: Scott
+ LastName: Tiger
+ Age: 45
+
+ EmpType: B
+ FirstName: Mary
+ LastName: Moore
+ Age: 41
+
+ EmpType: B
+ FirstName: Ridge
+ LastName: Forrester
+ Age: 31
+
diff --git a/tests/replace/expected_separated_trimmed.expected b/tests/replace/expected_separated_trimmed.expected
new file mode 100644
index 0000000..c03ddae
--- /dev/null
+++ b/tests/replace/expected_separated_trimmed.expected
@@ -0,0 +1,4 @@
+JohnRipper23
+ScottTiger45
+MaryMoore41
+RidgeForrester31
diff --git a/tests/replace/replace_basic.fferc b/tests/replace/replace_basic.fferc
new file mode 100644
index 0000000..a55d263
--- /dev/null
+++ b/tests/replace/replace_basic.fferc
@@ -0,0 +1,18 @@
+structure personnel_fix {
+ type fixed
+ record employee {
+ field EmpType 1
+ field FirstName 9
+ field LastName 13
+ field Age 3
+ }
+}
+
+output fixed {
+ data "%D"
+}
+
+output default {
+ data "%n: %t\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/replace/replace_basic.input b/tests/replace/replace_basic.input
new file mode 100644
index 0000000..5ea92d8
--- /dev/null
+++ b/tests/replace/replace_basic.input
@@ -0,0 +1,4 @@
+EJohn Ripper 023
+EScott Tiger 045
+BMary Moore 041
+ERidge Forrester 031
\ No newline at end of file
diff --git a/tests/replace/replace_separated.fferc b/tests/replace/replace_separated.fferc
new file mode 100644
index 0000000..c6f5139
--- /dev/null
+++ b/tests/replace/replace_separated.fferc
@@ -0,0 +1,27 @@
+structure personnel {
+ type separated ,
+ record person {
+ field EmpType
+ field FirstName
+ field LastName
+ field Age
+ }
+}
+
+output raw {
+ data "%d"
+}
+
+output trimmed {
+ data "%D"
+}
+
+output csv {
+ separator ","
+ data "%D"
+}
+
+output default {
+ data "%n: %t\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/replace/replace_separated.input b/tests/replace/replace_separated.input
new file mode 100644
index 0000000..9772f3e
--- /dev/null
+++ b/tests/replace/replace_separated.input
@@ -0,0 +1,4 @@
+E,John,Ripper,23
+E,Scott,Tiger,45
+B,Mary,Moore,41
+E,Ridge,Forrester,31
\ No newline at end of file
diff --git a/tests/replace/test_replace.bats b/tests/replace/test_replace.bats
new file mode 100755
index 0000000..f7fd2cc
--- /dev/null
+++ b/tests/replace/test_replace.bats
@@ -0,0 +1,76 @@
+#!/usr/bin/env bats
+
+# Set srcdir to the test directory
+srcdir="$BATS_TEST_DIRNAME"
+# Source our test helper
+source "$BATS_TEST_DIRNAME/../test_helper.bash"
+
+setup() {
+ # Setup temporary directory for tests
+ setup_bats_tempdir
+ # Change to test directory
+ cd "$srcdir" || exit 1
+}
+
+teardown() {
+ # Cleanup if needed
+ :
+}
+
+# Helper function for replace tests
+run_replace_test() {
+ local config="$1"
+ local input="$2"
+ local expected="$3"
+ local output_format="${4:-fixed}"
+ shift 4
+
+ run "$FFE_BIN" -c "$config" "$input" -p"$output_format" "$@"
+ assert_success
+ assert_output_matches_file "$expected"
+}
+
+@test "basic field replacement in fixed format" {
+ run_replace_test \
+ "replace_basic.fferc" \
+ "replace_basic.input" \
+ "expected_basic_fixed.expected" \
+ "fixed" \
+ -r "EmpType=B"
+}
+
+@test "different replacement value" {
+ run_replace_test \
+ "replace_basic.fferc" \
+ "replace_basic.input" \
+ "expected_different_value.expected" \
+ "fixed" \
+ -r "EmpType=X"
+}
+
+@test "multiple replacements" {
+ run_replace_test \
+ "replace_basic.fferc" \
+ "replace_basic.input" \
+ "expected_multiple_fixed.expected" \
+ "fixed" \
+ -r "EmpType=B" -r "Age=99"
+}
+
+@test "replacement with expression filter" {
+ run_replace_test \
+ "replace_basic.fferc" \
+ "replace_basic.input" \
+ "expected_filtered_fixed.expected" \
+ "fixed" \
+ -e "FirstName^J" -r "EmpType=B"
+}
+
+@test "replacement in separated format (default output)" {
+ run_replace_test \
+ "replace_separated.fferc" \
+ "replace_separated.input" \
+ "expected_separated_default.expected" \
+ "default" \
+ -r "EmpType=B"
+}
\ No newline at end of file
diff --git a/tests/replace/test_replace.sh b/tests/replace/test_replace.sh
new file mode 100755
index 0000000..c25ad20
--- /dev/null
+++ b/tests/replace/test_replace.sh
@@ -0,0 +1,81 @@
+#!/bin/sh
+
+# Regression test for replace functionality
+
+set -e
+
+# Use environment variables if set, otherwise default
+FFE="${FFE_BIN:-../src/ffe}"
+srcdir="${srcdir:-.}"
+
+# Helper function to compare with expected output
+check_output() {
+ local config="$1"
+ local input="$2"
+ local expected="$3"
+ local output_format="${4:-fixed}"
+ shift 4 # Remove first four arguments, rest are ffe arguments
+
+ # Run ffe
+ output=$(mktemp)
+ "$FFE" -c "$config" "$input" -p"$output_format" "$@" > "$output" 2>&1
+
+ # Compare with expected
+ if diff -u "$expected" "$output"; then
+ echo "PASS: replace test $config"
+ rm -f "$output"
+ return 0
+ else
+ echo "FAIL: output does not match expected for $config"
+ rm -f "$output"
+ return 1
+ fi
+}
+
+echo "=== Running replace tests ==="
+
+# Test 1: Basic field replacement in fixed format
+check_output \
+ "$srcdir/replace_basic.fferc" \
+ "$srcdir/replace_basic.input" \
+ "$srcdir/expected_basic_fixed.expected" \
+ "fixed" \
+ -r "EmpType=B"
+
+# Test 2: Different replacement value
+check_output \
+ "$srcdir/replace_basic.fferc" \
+ "$srcdir/replace_basic.input" \
+ "$srcdir/expected_different_value.expected" \
+ "fixed" \
+ -r "EmpType=X"
+
+# Test 3: Multiple replacements
+check_output \
+ "$srcdir/replace_basic.fferc" \
+ "$srcdir/replace_basic.input" \
+ "$srcdir/expected_multiple_fixed.expected" \
+ "fixed" \
+ -r "EmpType=B" -r "Age=99"
+
+# Test 4: Replacement with expression filter
+check_output \
+ "$srcdir/replace_basic.fferc" \
+ "$srcdir/replace_basic.input" \
+ "$srcdir/expected_filtered_fixed.expected" \
+ "fixed" \
+ -e "FirstName^J" -r "EmpType=B"
+
+# Test 5: Replacement in separated format (default output)
+check_output \
+ "$srcdir/replace_separated.fferc" \
+ "$srcdir/replace_separated.input" \
+ "$srcdir/expected_separated_default.expected" \
+ "default" \
+ -r "EmpType=B"
+
+# Note: The trimmed output (%D) for separated format has issues with replace
+# The test is omitted as replace doesn't seem to affect %D output for separated data
+# This may be a bug or expected behavior - needs investigation
+
+echo "All replace tests passed"
\ No newline at end of file
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
new file mode 100755
index 0000000..19a9c06
--- /dev/null
+++ b/tests/run_tests.sh
@@ -0,0 +1,142 @@
+#!/bin/sh
+
+# Master test runner for ffe tests
+# Runs all tests in subdirectories
+# This script can be called from any directory
+
+set -e
+
+# Parse command line arguments
+tap_mode=false
+# Check environment variable
+if [ "$BATS_FORMATTER" = "tap" ]; then
+ tap_mode=true
+fi
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --tap)
+ tap_mode=true
+ shift
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ echo "Usage: $0 [--tap]" >&2
+ exit 1
+ ;;
+ esac
+done
+
+# Base directory of tests (where this script is located)
+test_dir="$(cd "$(dirname "$0")" && pwd)"
+
+# Use FFE_BIN from environment if set, otherwise default to ../src/ffe relative to test directory
+if [ -z "$FFE_BIN" ]; then
+ FFE_BIN="$test_dir/../src/ffe"
+fi
+
+# Make FFE_BIN absolute if it's relative
+case "$FFE_BIN" in
+ /*) ;; # already absolute
+ *) FFE_BIN="$test_dir/$FFE_BIN" ;;
+esac
+
+if [ ! -x "$FFE_BIN" ]; then
+ echo "ERROR: ffe binary not found or not executable: $FFE_BIN"
+ echo "Please build ffe first with 'make' in the src directory"
+ exit 1
+fi
+
+# srcdir is used by automake, default to test directory
+srcdir="${srcdir:-$test_dir}"
+
+# Find bats executable
+find_bats() {
+ if command -v bats >/dev/null 2>&1; then
+ echo "bats"
+ elif [ -x "$test_dir/bats-core/bin/bats" ]; then
+ echo "$test_dir/bats-core/bin/bats"
+ else
+ echo "ERROR: bats not found" >&2
+ exit 1
+ fi
+}
+BATS="$(find_bats)"
+# Helper function to print messages only when not in TAP mode
+echo_if_not_tap() {
+ if [ "$tap_mode" = false ]; then
+ echo "$@"
+ fi
+}
+
+# Build bats arguments
+if [ "$tap_mode" = true ]; then
+ bats_args="--formatter tap"
+else
+ bats_args=""
+fi
+
+# Find all test directories (subdirectories containing .sh or .bats files)
+test_dirs="fixed_length separated binary expressions lookup constants anonymize replace output"
+
+total=0
+passed=0
+failed=0
+
+echo_if_not_tap "=== Running ffe test suite ==="
+echo_if_not_tap "Using ffe binary: $FFE_BIN"
+echo_if_not_tap "Using bats: $BATS"
+
+for dir in $test_dirs; do
+ dir_path="$test_dir/$dir"
+ if [ ! -d "$dir_path" ]; then
+ echo_if_not_tap "WARNING: Test directory '$dir_path' not found, skipping"
+ continue
+ fi
+
+ # Find test script in directory - prefer .bats files
+ test_script=""
+ bats_script=$(find "$dir_path" -maxdepth 1 -name "*.bats" | head -1)
+ sh_script=$(find "$dir_path" -maxdepth 1 -name "*.sh" | head -1)
+ if [ -n "$bats_script" ]; then
+ test_script="$bats_script"
+ use_bats=true
+ elif [ -n "$sh_script" ]; then
+ test_script="$sh_script"
+ use_bats=false
+ else
+ echo_if_not_tap "WARNING: No test script found in '$dir_path', skipping"
+ continue
+ fi
+
+ total=$((total + 1))
+ echo_if_not_tap "Running test: $dir ($(basename "$test_script"))"
+
+ # Run test in its directory with proper environment
+ if [ "$use_bats" = true ]; then
+ (cd "$dir_path" && FFE_BIN="$FFE_BIN" srcdir="." "$BATS" $bats_args "./$(basename "$test_script")")
+ else
+ (cd "$dir_path" && FFE_BIN="$FFE_BIN" srcdir="." sh "./$(basename "$test_script")")
+ fi
+
+ if [ $? -eq 0 ]; then
+ echo_if_not_tap " PASS: $dir"
+ passed=$((passed + 1))
+ else
+ echo_if_not_tap " FAIL: $dir"
+ failed=$((failed + 1))
+ fi
+done
+
+echo_if_not_tap "=== Test summary ==="
+echo_if_not_tap "Total: $total"
+echo_if_not_tap "Passed: $passed"
+echo_if_not_tap "Failed: $failed"
+
+if [ $failed -eq 0 ]; then
+ echo_if_not_tap "All tests passed!"
+ exit 0
+else
+ echo_if_not_tap "$failed test(s) failed"
+ exit 1
+fi
\ No newline at end of file
diff --git a/tests/separated/separated.expected b/tests/separated/separated.expected
new file mode 100644
index 0000000..8936b4e
--- /dev/null
+++ b/tests/separated/separated.expected
@@ -0,0 +1,16 @@
+
+
+ john
+ Ripper
+ 23
+
+
+ Scott
+ Tiger
+ 45
+
+
+ Mary
+ Moore
+ 41
+
diff --git a/tests/separated/separated.fferc b/tests/separated/separated.fferc
new file mode 100644
index 0000000..d5f51f6
--- /dev/null
+++ b/tests/separated/separated.fferc
@@ -0,0 +1,17 @@
+structure personnel {
+ type separated ,
+ output xml
+ record person {
+ field FirstName
+ field LastName
+ field Age
+ }
+}
+
+output xml {
+ file_header "\n"
+ data "<%n>%t%n>\n"
+ record_header "<%r>\n"
+ record_trailer "%r>\n"
+ indent " "
+}
\ No newline at end of file
diff --git a/tests/separated/separated.input b/tests/separated/separated.input
new file mode 100644
index 0000000..79b9906
--- /dev/null
+++ b/tests/separated/separated.input
@@ -0,0 +1,3 @@
+john,Ripper,23
+Scott,Tiger,45
+Mary,Moore,41
\ No newline at end of file
diff --git a/tests/separated/test_separated.bats b/tests/separated/test_separated.bats
new file mode 100755
index 0000000..c5b363a
--- /dev/null
+++ b/tests/separated/test_separated.bats
@@ -0,0 +1,22 @@
+#!/usr/bin/env bats
+
+# Set srcdir to the test directory
+srcdir="$BATS_TEST_DIRNAME"
+# Source our test helper
+source "$BATS_TEST_DIRNAME/../test_helper.bash"
+
+setup() {
+ # Setup temporary directory for tests
+ setup_bats_tempdir
+ # Change to test directory
+ cd "$srcdir" || exit 1
+}
+
+teardown() {
+ # Cleanup if needed
+ :
+}
+
+@test "separated parsing test" {
+ run_ffe_test "separated.fferc" "separated.input" "separated.expected"
+}
\ No newline at end of file
diff --git a/tests/separated/test_separated.sh b/tests/separated/test_separated.sh
new file mode 100755
index 0000000..fb2b261
--- /dev/null
+++ b/tests/separated/test_separated.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+
+# Regression test for separated (CSV) parsing
+
+set -e
+
+# Use environment variables if set, otherwise default
+FFE="${FFE_BIN:-../src/ffe}"
+srcdir="${srcdir:-.}"
+
+# Input files
+config="$srcdir/separated.fferc"
+input="$srcdir/separated.input"
+expected="$srcdir/separated.expected"
+
+# Run ffe
+output=$(mktemp)
+"$FFE" -c "$config" "$input" > "$output"
+
+# Compare with expected
+if diff -u "$expected" "$output"; then
+ echo "PASS: separated parsing test"
+ rm -f "$output"
+ exit 0
+else
+ echo "FAIL: output does not match expected"
+ rm -f "$output"
+ exit 1
+fi
\ No newline at end of file
diff --git a/tests/test_helper.bash b/tests/test_helper.bash
new file mode 100644
index 0000000..2a972f3
--- /dev/null
+++ b/tests/test_helper.bash
@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+
+# Test helper for ffe bats tests
+# Sets up environment variables and provides helper functions
+
+# Set up FFE binary path
+# Use FFE_BIN from environment if set, otherwise default to ../src/ffe relative to tests directory
+if [ -z "$FFE_BIN" ]; then
+ FFE_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../src/ffe"
+fi
+
+# Make FFE_BIN absolute if it's relative
+case "$FFE_BIN" in
+ /*) ;; # already absolute
+ *) FFE_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$FFE_BIN" ;;
+esac
+
+# Verify ffe binary exists and is executable
+if [ ! -x "$FFE_BIN" ]; then
+ echo "ERROR: ffe binary not found or not executable: $FFE_BIN" >&2
+ exit 1
+fi
+
+# srcdir is used by automake, default to test directory
+srcdir="${srcdir:-.}"
+
+# Export variables
+export FFE_BIN
+export srcdir
+
+# Simple assertion functions that don't require bats-assert
+
+# Assert command succeeded (status == 0)
+# Usage: assert_success
+assert_success() {
+ if [ "$status" -ne 0 ]; then
+ echo "Failed with status: $status"
+ echo "Output: $output"
+ return 1
+ fi
+}
+
+# Assert command output matches expected string
+# Usage: assert_output expected_string
+assert_output() {
+ local expected="$1"
+ if [ "$output" != "$expected" ]; then
+ echo "Output differs from expected"
+ echo "Expected: $expected"
+ echo "Actual: $output"
+ return 1
+ fi
+}
+
+# Helper to assert command output matches expected file content
+# Usage: assert_output_matches_file expected_file
+# Requires: run command must have been used previously
+assert_output_matches_file() {
+ local expected_file="$1"
+ local expected_output
+ expected_output=$(cat "$expected_file")
+ assert_output "$expected_output"
+}
+
+# Helper to run a ffe test case and compare with expected output
+# Usage: run_ffe_test config input expected [additional ffe args]
+run_ffe_test() {
+ local config="$1"
+ local input="$2"
+ local expected="$3"
+ shift 3
+ # Remaining arguments are passed to ffe
+
+ run "$FFE_BIN" -c "$config" "$@" "$input"
+ assert_success
+ assert_output_matches_file "$expected"
+}
+
+# Setup BATS_TEST_TMPDIR if not set (for system bats 1.2.1 compatibility)
+setup_bats_tempdir() {
+ if [ -z "$BATS_TEST_TMPDIR" ]; then
+ export BATS_TEST_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/bats-test-XXXXXX")"
+ # Clean up on exit
+ trap "rm -rf '$BATS_TEST_TMPDIR'" EXIT
+ fi
+}
\ No newline at end of file