Skip to content

Add a reader/writer/format

In the IO seam you met formats from the outside: they are an open, runtime-chosen plug-in surface, so the engine reaches them through Box<dyn FormatReader>. Now you’re on the inside, adding one. This lesson shows the full change-set, and it pulls together two earlier ideas: the dyn seam is where your code plugs in, and the closed exhaustive match from dispatching a node is what forces every dispatch site to acknowledge your new format.

  • Name the two required trait methods on FormatReader and on FormatWriter, and what Ok(None) from next_record signals.
  • Implement the FormatReader trait for a new type so it plugs into the engine’s boxed-reader seam.
  • Trace the two-stage path from a YAML type: string to a concrete reader: serde maps the string to an enum variant, then an exhaustive match maps the variant to a Box<dyn FormatReader>.
  • List the edit sites a new format touches and distinguish which one the compiler makes mandatory from the ones it does not.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • format reader/writer trait: FormatReader/FormatWriter are the two contracts a format satisfies.
  • the seam: the plug-in boundary those traits create, expressed at the boundary as a trait object (Box<dyn FormatReader>).
  • dyn: dynamic dispatch, so one boxed reader can be any format.
  • format registration: the act of wiring a new variant into the config enum and its dispatch match; pre-taught where it appears below.

A format is whatever can stream Records in and out. The two contracts are tiny:

clinker-format ·traits.rs ·FormatReader trait @19acdcb4
/// Streaming record reader. Yields records one at a time.
///
/// `&mut self` on `schema()` because some formats (e.g. CSV) must read
/// the first row to discover column names. Must be `Send` for executor
/// ownership transfer; not `Sync` — single-threaded streaming.
pub trait FormatReader: Send {
fn schema(&mut self) -> Result<Arc<Schema>, FormatError>;
fn next_record(&mut self) -> Result<Option<Record>, FormatError>;
// ...plus a few defaulted methods for envelopes / multi-file readers
}
clinker-format ·traits.rs ·FormatWriter trait @19acdcb4
/// Streaming record writer. Consumes records one at a time.
pub trait FormatWriter: Send {
fn write_record(&mut self, record: &Record) -> Result<(), FormatError>;
fn flush(&mut self) -> Result<(), FormatError>;
// ...plus defaulted begin_document / end_document for envelope framing
}

Two required methods on each, both fallible, both streaming. next_record returning Ok(None) means clean end-of-stream, not an error, the same three-outcome read you learned in Option and Result. The : Send bound is the same one the worker pool explained: the executor moves readers onto worker threads, so a reader must be Send. A minimal new format implements just those two-each methods; the defaulted ones (envelopes, multi-file) are opt-in.

Here’s the shape of a real implementation: CsvReader, the simplest complete one:

clinker-format ·reader.rs ·CsvReader type @19acdcb4
pub struct CsvReader<R: Read> {
inner: csv::Reader<SkipBom<R>>,
schema: Option<Arc<Schema>>, // discovered from the header, cached
config: CsvReaderConfig,
row_count: u64,
record_buf: csv::StringRecord,
}
impl<R: Read + Send> FormatReader for CsvReader<R> {
fn schema(&mut self) -> Result<Arc<Schema>, FormatError> {
self.ensure_schema() // reads the header row once
}
fn next_record(&mut self) -> Result<Option<Record>, FormatError> {
let schema = self.ensure_schema()?;
if !self.inner.read_record(&mut self.record_buf)? {
return Ok(None); // end of stream
}
let values = self.record_buf.iter().map(|f| Value::String(f.into())).collect();
Ok(Some(Record::new(schema, values)))
}
}

That’s the whole pattern: hold the underlying byte source plus a cached Arc<Schema>, and have next_record pull one row and emit Ok(Some(Record)) until the source is dry. The impl is generic over R: Read + Send, not tied to files.

From a YAML string to your reader, in two stages

Section titled “From a YAML string to your reader, in two stages”

A pipeline says type: csv in YAML. How does that string reach CsvReader? Not by a string-match you write; it’s a two-stage hop, and tracing it tells you exactly what to edit.

Stage 1, string to enum variant, by serde. The config layer has a closed enum of known formats, adjacently tagged so serde maps type: csv to a variant automatically:

clinker-plan ·format.rs ·InputFormat type @19acdcb4
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "options", rename_all = "snake_case")]
pub enum InputFormat {
Csv(Option<CsvInputOptions>),
Json(Option<JsonInputOptions>),
Xml(Option<XmlInputOptions>),
FixedWidth(Option<FixedWidthInputOptions>),
Edifact(Option<EdifactInputOptions>),
X12(Option<X12InputOptions>),
Hl7(Option<Hl7InputOptions>),
Swift(Option<SwiftInputOptions>),
}

The #[serde(tag = "type", rename_all = "snake_case")] is what turns the string "csv" into the variant InputFormat::Csv. You never write name-matching code.

Stage 2, enum variant to boxed reader, by an exhaustive match. In the executor, one function matches the variant and returns the boxed trait object:

clinker-exec ·ingest.rs ·build_format_reader fn @19acdcb4
fn build_format_reader(
input: &clinker_plan::config::SourceConfig,
source: ReopenableSource,
) -> Result<Box<dyn FormatReader>, PipelineError> {
match &input.format {
InputFormat::Csv(opts) => Ok(Box::new(CsvReader::from_reader(/* ... */))),
InputFormat::Json(opts) => Ok(Box::new(JsonReader::from_source(/* ... */)?)),
InputFormat::Xml(opts) => { /* ... */ }
// ...one arm per variant, NO `_ =>` catch-all
}
}

This is the enum-dispatch payoff in action: the match is wildcard-free, so the moment you add a variant to InputFormat, this function stops compiling until you add its arm. The open dyn seam (return type Box<dyn FormatReader>) and the closed enum dispatch (match with no wildcard) cooperate: dyn lets your reader plug in; the exhaustive enum makes the compiler hand you the list of sites to wire.

Now you implement the seam yourself, scaffolded down to a from-scratch reader. Each rung adds exactly one new idea: first see a working enum→trait-object dispatch, then fill the gap the compiler flags, then write a whole FormatReader impl on your own.

Worked: the exhaustive seam, runnable end to end

Section titled “Worked: the exhaustive seam, runnable end to end”

Here is the enum-match-to-trait-object pattern on its own, the same division of labor as the real build_format_reader, shrunk to fit in your head. Run it and watch one boxed Box<dyn Reader> drive two different concrete readers.

rust // editable

build_reader returns Box<dyn Reader> (the open seam) and dispatches through a wildcard-free match (the closed enum). That is exactly how build_format_reader turns an InputFormat variant into a Box<dyn FormatReader>.

Completion: add the variant, let the compiler flag the gap

Section titled “Completion: add the variant, let the compiler flag the gap”

Below, a Tsv variant has been added to Format, but build_reader was not updated, so it no longer compiles, exactly as you predicted at the top. One arm is missing. Add it so a Tsv builds a TsvReader (already written for you), and the match is exhaustive again.

rust // editable
💡 Hint 1

The compiler error is non-exhaustive patterns: \Format::Tsv` not covered, pointing at the match. Mirror the Csvarm: map the new variant to a boxedTsvReader`.

Show solution
fn build_reader(fmt: Format) -> Box<dyn Reader> {
match fmt {
Format::Csv => Box::new(CsvReader { rows: vec!["a,b\tc".into()] }),
Format::Tsv => Box::new(TsvReader { rows: vec!["a\tb".into()] }),
}
}

The arm you just added is the compiler-mandatory edit, the one rustc refused to let you skip. In the real engine this same arm lives in build_format_reader; the non-exhaustive-match error is what names the exact site for you.

Now the part with no scaffolding: write the trait impl itself. Implement FormatReader for LinesReader, a from-scratch format that emits each input line as a one-field record. You write both required methods: schema returns the cached schema, and next_record pulls the next line, returning Ok(None) at end-of-input. The struct, the trait, and the loop that drives it are given.

rust // editable
💡 Hint 1

schema() is one line: clone the Arc and wrap it in Ok. For next_record, match self.lines.pop(): None is the clean end, Some(line) becomes a Record { schema: self.schema.clone(), values: vec![line] }.

Show solution
impl FormatReader for LinesReader {
fn schema(&mut self) -> Result<Arc<Schema>, FormatError> {
Ok(self.schema.clone()) // Arc::clone bumps a count, not a copy
}
fn next_record(&mut self) -> Result<Option<Record>, FormatError> {
match self.lines.pop() {
None => Ok(None), // clean end-of-stream
Some(line) => Ok(Some(Record {
schema: self.schema.clone(),
values: vec![line],
})),
}
}
}

That is a complete format implementation in miniature: the same two required methods, the same Ok(None)-means-done contract, the same shared Arc<Schema> as the real CsvReader. To put it in the engine you’d then register it (add the variant and the dispatch arm), which is the change-set the next section lists.

To add a format end-to-end, there are three edit sites, and the predict-first sorted them for you, compiler-mandatory versus merely-necessary:

  1. Add a variant (and its options struct) to InputFormat and/or OutputFormat in crates/clinker-plan/src/config/format.rs, and add its lowercase name to the format_name() match (also wildcard-free, so the compiler reminds you).
  2. Implement FormatReader (and/or FormatWriter) in a new module under crates/clinker-format/src/<fmt>/, exactly the trait impl you just wrote in the faded rung. The compiler does not force this on its own; it’s necessary, not mandatory.
  3. Add the dispatch arm in build_format_reader (executor/ingest.rs) and/or the writer dispatch in executor/registry.rs. These are the compiler-mandatory edits, the non-exhaustive-match error you triggered in the completion rung.

Prove it with a round-trip: read a sample, write it back, read again, assert every field survived, exactly what the CSV test does:

clinker-format ·writer.rs ·test_csv_roundtrip_lossless test @19acdcb4
#[test]
fn test_csv_roundtrip_lossless() {
let input = "name,age,active\nAlice,30,true\nBob,25,false\n";
// read -> records, write -> string, read again -> assert schemas + fields match
}

You can now name the two format contracts, implement FormatReader for a new type, trace the string→variant→boxed-reader path, and sort the edit sites by which the compiler forces. Next: extend the engine’s middle by adding a new operator to the execution DAG, the change that touches the most places.

Go deeper on the Rust (optional, one-directional, for traits and dyn taught from first principles, with no engine framing):

Glossary terms used: trait, trait object, dyn, seam.