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.
What you’ll be able to do
Section titled “What you’ll be able to do”- Name the two required trait methods on
FormatReaderand onFormatWriter, and whatOk(None)fromnext_recordsignals. - Implement the
FormatReadertrait 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 aBox<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/FormatWriterare 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.
Predict first
Section titled “Predict first”Two traits, two required methods each
Section titled “Two traits, two required methods each”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.
A concrete reader: CSV
Section titled “A concrete reader: CSV”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.
Worked → completion → faded
Section titled “Worked → completion → faded”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.
> output appears here — press Run
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.
> output appears here — press Run
💡 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.
Faded: write a whole FormatReader impl
Section titled “Faded: write a whole FormatReader impl”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.
> output appears here — press Run
💡 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.
The change-set, and the round-trip test
Section titled “The change-set, and the round-trip test”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:
- Add a variant (and its options struct) to
InputFormatand/orOutputFormatincrates/clinker-plan/src/config/format.rs, and add its lowercase name to theformat_name()match (also wildcard-free, so the compiler reminds you). - Implement
FormatReader(and/orFormatWriter) in a new module undercrates/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. - Add the dispatch arm in
build_format_reader(executor/ingest.rs) and/or the writer dispatch inexecutor/registry.rs. These are the compiler-mandatory edits, the non-exhaustive-matcherror 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}Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”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.