# fwf

A Rust library for reading and writing fixed-width files.

Fixed-width files are a legacy format still common today, especially in financial, government, and healthcare data. Rather than relying on a delimiter, like CSV, each column occupies a fixed number of bytes. This library provides a fast, flexible reader and writer with minimal overhead.

## Usage

### Reading records

A reader is driven by a `FwReaderSchema`, which describes the record width, fields, and (optional) record separator. Build a schema once, then hand it to `FwReader::options(...)` to configure the reader.

```rust
use std::fs::File;
use fwf::{Alignment, FwReader, FwReaderField, FwReaderSchema, RecordSeparator, Result};

fn read_fixed_width(file: File) -> Result<()> {
    let schema = FwReaderSchema::builder(12)
        .field(FwReaderField::builder(0..10).build())              // name, 10 bytes
        .field(
            FwReaderField::builder(10..12)
                .padding(b'0')
                .alignment(Alignment::Right)
                .build(),
        )                                                           // age, 2 bytes, zero-padded
        .separator(RecordSeparator::Lf)                             // Unix-style newlines (optional)
        .build()?;

    let mut reader = FwReader::options(schema).from_file(file);

    while let Some(record) = reader.read_byte_record()? {
        let name = &record[0];   // &[u8], padding stripped
        let age = &record[1];
        println!("{}: {}", String::from_utf8_lossy(name), String::from_utf8_lossy(age));
    }

    println!("Records read: {}", reader.record_number());
    Ok(())
}
```

### Using the iterator API

```rust
use std::fs::File;
use fwf::{FwReader, FwReaderField, FwReaderSchema, RecordSeparator, Result};

fn iterate_records(file: File) -> Result<()> {
    let schema = FwReaderSchema::builder(7)
        .field(FwReaderField::builder(0..5).build())
        .field(FwReaderField::builder(5..7).build())
        .separator(RecordSeparator::Lf)
        .build()?;

    let reader = FwReader::options(schema).from_file(file);

    for record in reader.byte_records() {
        let record = record?;
        println!("{}: {}", String::from_utf8_lossy(&record[0]), String::from_utf8_lossy(&record[1]));
    }

    Ok(())
}
```

### Switching schemas mid-stream

Some fixed-width formats interleave records with different layouts (e.g. a header schema followed by a body schema, or formats that split a long record across multiple lines, each with its own schema). Pre-build each schema once, then alternate between them with `into_reader_for_schema`. The reader's position and the schemas' internal buffers are preserved across the swap, so repeated switches don't reallocate.

```rust
use std::fs::File;
use fwf::{FwReader, FwReaderField, FwReaderSchema, RecordSeparator, Result};

fn read_alternating(file: File) -> Result<()> {
    let header_schema = FwReaderSchema::builder(40)
        .field(FwReaderField::builder(0..40).build())
        .separator(RecordSeparator::Lf)
        .build()?;

    let body_schema = FwReaderSchema::builder(20)
        .field(FwReaderField::builder(0..10).build())
        .field(FwReaderField::builder(10..20).build())
        .separator(RecordSeparator::Lf)
        .build()?;

    let mut reader = FwReader::options(header_schema).from_file(file);

    // Read one header record, then swap to the body schema for the rest.
    let _header = reader.read_byte_record()?;
    let (mut reader, _header_schema) = reader.into_reader_for_schema(body_schema);

    while let Some(_record) = reader.read_byte_record()? {
        // ...
    }

    Ok(())
}
```

### Writing records

A writer is driven by a `FwWriterSchema`, which describes the fields, the (optional) record separator, and the global truncation policy. Build a schema once, then hand it to `FwWriter::options(...)`.

```rust
use std::fs::File;
use fwf::{
    Alignment, FwWriter, FwWriterField, FwWriterSchema, RecordSeparator,
    TruncationPolicy,
};

fn write_fixed_width(file: File) -> fwf::Result<()> {
    let schema = FwWriterSchema::builder()
        .field(FwWriterField::builder(10).build())                   // name
        .field(FwWriterField::builder(1).padding(b'|').build())      // separator column
        .field(
            FwWriterField::builder(3)
                .alignment(Alignment::Right)
                .padding(b'0')
                .build(),
        )                                                            // age, zero-padded right-aligned
        .separator(RecordSeparator::Lf)
        .truncation(TruncationPolicy::Right)
        .build();

    let mut writer = FwWriter::options(schema).from_file(file);

    writer.write_byte_record(&[b"Name", b"|", b"Age"])?;
    writer.write_byte_record(&[b"Alice", b"", b"30"])?;
    writer.write_byte_record(&[b"Bob", b"", b"7"])?;
    writer.flush()?;

    // Output:
    // Name      |Age
    // Alice     |030
    // Bob       |007

    Ok(())
}
```

Writers also support `into_writer_for_schema(...)` to swap between schemas mid-stream while preserving the destination and reusing both schemas' internal buffers — mirroring the reader-side `into_reader_for_schema`.

### Leading and trailing content

Use `into_inner()` to recover the underlying writer for content outside the fixed-width schema:

```rust
use std::io::Write;
use fwf::{FwWriter, FwWriterField, FwWriterSchema, RecordSeparator};

fn write_with_trailer(mut dest: impl Write) -> fwf::Result<()> {
    // Write a preamble before the fixed-width records.
    dest.write_all(b"HEADER: Example file\n")?;

    let schema = FwWriterSchema::builder()
        .field(FwWriterField::builder(5).build())
        .separator(RecordSeparator::Lf)
        .build();
    let mut writer = FwWriter::options(schema).from_writer(dest);
    writer.write_byte_record(&[b"Alice"])?;
    writer.write_byte_record(&[b"Bob"])?;
    writer.flush()?;

    // Recover the writer and append trailing content.
    let mut dest = writer.into_inner();
    dest.write_all(b"TRAILER: 2 records\n")?;

    Ok(())
}
```

## Feature Flags

### `encoding_rs` -- Multi-encoding support

Disabled by default. Enable it to read and write files in encodings other than UTF-8, powered by the [encoding_rs](https://docs.rs/encoding_rs) crate.

```toml
[dependencies]
fwf = { version = "0.1", features = ["encoding_rs"] }
```

This unlocks:

- `FwReader::read_string_record()` and `FwReader::string_records()` -- decode fields into `StringRecord` using the configured encoding
- `FwWriter::write_string_record()` -- encode strings before writing
- `.encoding()` on both `FwReaderOptions` and `FwWriterOptions`

```rust
use std::fs::File;
use fwf::{FwReader, FwReaderField, FwReaderSchema, RecordSeparator};

fn read_strings(file: File) -> fwf::Result<()> {
    let schema = FwReaderSchema::builder(7)
        .field(FwReaderField::builder(0..5).build())
        .field(FwReaderField::builder(5..7).build())
        .separator(RecordSeparator::Lf)
        .build()?;

    let mut reader = FwReader::options(schema)
        .encoding(encoding_rs::SHIFT_JIS)
        .from_file(file)?;

    while let Some(record) = reader.read_string_record()? {
        println!("{}: {}", &record[0], &record[1]);
    }

    Ok(())
}
```

### `tokio` -- Async I/O support

Disabled by default. Enable it for async reading and writing with [tokio](https://docs.rs/tokio).

```toml
[dependencies]
fwf = { version = "0.1", features = ["tokio"] }
tokio = { version = "1", features = ["fs", "io-util", "rt", "macros"] }
```

This unlocks:

- `AsyncFwReader` -- async counterpart of `FwReader`, with `read_byte_record().await` and `read_string_record().await` (when combined with `encoding_rs`)
- `AsyncFwWriter` -- async counterpart of `FwWriter`, with `write_byte_record().await` and `write_string_record().await`
- `.from_tokio_reader()`, `.from_tokio_file()` on `FwReaderOptions`
- `.from_tokio_writer()`, `.from_tokio_file()` on `FwWriterOptions`

The same `FwReaderSchema` configures both sync and async readers:

```rust
use fwf::{FwReader, FwReaderField, FwReaderSchema, RecordSeparator};

async fn read_async(path: &str) -> fwf::Result<()> {
    let schema = FwReaderSchema::builder(7)
        .field(FwReaderField::builder(0..5).build())
        .field(FwReaderField::builder(5..7).build())
        .separator(RecordSeparator::Lf)
        .build()?;

    let file = tokio::fs::File::open(path).await.unwrap();
    let mut reader = FwReader::options(schema).from_tokio_file(file);

    while let Some(record) = reader.read_byte_record().await? {
        println!("{}: {}", String::from_utf8_lossy(&record[0]), String::from_utf8_lossy(&record[1]));
    }

    Ok(())
}
```

```rust
use fwf::{FwWriter, FwWriterField, FwWriterSchema, RecordSeparator};

async fn write_async(path: &str) -> fwf::Result<()> {
    let schema = FwWriterSchema::builder()
        .field(FwWriterField::builder(5).build())
        .field(FwWriterField::builder(2).build())
        .separator(RecordSeparator::Lf)
        .build();

    let file = tokio::fs::File::create(path).await.unwrap();
    let mut writer = FwWriter::options(schema).from_tokio_file(file);

    writer.write_byte_record(&[b"Alice", b"30"]).await?;
    writer.write_byte_record(&[b"Bob", b"25"]).await?;
    writer.flush().await?;

    Ok(())
}
```

## Profiling

The repository includes a profiling workflow built on [samply](https://github.com/mstange/samply). One-time setup:

```sh
# Install samply (Linux/macOS)
cargo install --locked samply

# On Linux, samply needs perf_event_open access:
echo 1 | sudo tee /proc/sys/kernel/perf_event_paranoid
```

Then run the profiling script:

```sh
# Default: 1M records, top 10 functions
./profile.sh

# Customize record count and report depth
./profile.sh --records 500000 --top 20
```

On Windows, use `.\profile.ps1` with the same options (`-Records`, `-Top`).

The script builds optimized binaries with debug symbols, records separate write and read profiles with samply, and prints a table of the hottest functions by inclusive and self time.

### Benchmarks

The project includes [Criterion](https://docs.rs/criterion) benchmarks for sync and async read/write throughput:

```sh
# Byte-only benchmarks (no optional features)
cargo bench

# Include string (encoding_rs) and async (tokio) benchmarks
cargo bench --all-features
```

Benchmark results are saved to `target/criterion/` with HTML reports. To run a single benchmark by name:

```sh
cargo bench --all-features -- "read_byte_records"
```

## About

This is a passion project that I maintain on my own time. I care deeply about its quality and want it to be genuinely useful, but I also want to keep it fun and sustainable. To that end:

- **Bug reports** are always welcome. Please file issues for anything that isn't working correctly.
- **Feature requests** are best expressed as pull requests. I'm much more likely to engage with a well-crafted PR than a request for new work.
- **Timelines** are my own. I'll get to things when I can, and I may close issues or PRs that don't align with the project's direction – nothing personal.

If you find this library valuable, the best way to support it is to contribute or share it with others.