Spec-Driven

Spec-Driven Rust: Output

83 lines that write a line, absorb a failure, and refuse a second sink

Synopsis:
This page covers rust_textfinder_output, the sink. It receives fully formed strings, writes each as one line, and adds nothing but the terminator.
  • Two write methods differing only in what they add, and the placement is the access control - write_text is inherent rather than part of the trait, so traversal cannot emit unterminated text.
  • It takes no configuration. Dirnav formats every line in full, so there is nothing left here to parameterize.
The constructor is interesting for what it does not do and for what it refuses.
  • Rust writes the bytes it is given, so the LF requirement is met by doing nothing - where the C++ sink needs a mode call, two headers, and a failure path.
  • new yields Some once and None thereafter, because two buffers over one stdout reorder the output silently and only under load.
  • Default is deliberately not implemented, since it would offer callers a constructor that silently produces a second buffer.
Failure is absorbed rather than reported, and the specification states the three properties that follow.
  • Flush first, one notice rather than one per discarded line, and nothing reaches a caller.
  • A method returning () where the operation returns a Result is usually a smell; here it is the interface the structure document asked for.
  • The flush on drop is named separately, being the write most likely to be the first one that fails.

1.  The Sink

rust_textfinder_output receives fully formed strings from rust_textfinder_dirnav, writes each as one line to stdout, and absorbs any write failure so that neither the traversal nor the binary has to reason about it.
pub struct StdoutSink { /* private */ }

impl StdoutSink {
    pub fn new() -> Option<Self>;
    pub fn write_text(&mut self, text: &str);
    pub fn flush(&mut self);
}

impl Output for StdoutSink {
    fn output(&mut self, text: &str);
}

impl Drop for StdoutSink {
    fn drop(&mut self);
}
Two methods write, and they differ only in what they add.
  • output, the trait method, writes the string it is given followed by the single LF Spec_TextFinder.md §3.4 fixes. It is the method rust_textfinder_dirnav reaches, and the trait is the only thing that library knows about this one.
  • write_text writes the string verbatim, adding nothing. It exists for the help text of §5.1 and the option listing of §5.3, which rust_textfinder_cmdline returns already newline-terminated.
write_text is an inherent method rather than part of the trait, and that placement is the access control: rust_textfinder_dirnav holds an O: Output and can reach only what the trait declares, so it cannot emit unterminated text. Both route through one private emit(text, terminate), so the failed-state check and the write have one definition. The type takes no configuration. rust_textfinder_dirnav formats every line in full before emitting it - a block's path line, a block's indented detail lines, and every announcement alike - so there is nothing left here to parameterize. The library does not know a block line from an announcement, does not indent a detail line, and does not apply /h, /n, or /L. Once constructed, neither method panics and neither reports failure to a caller.

2.  No Stream Mode to Set

Spec_TextFinder.md §3.4 obliges an implementation to prevent its runtime translating the LF terminator to CRLF. Rust's standard output writes the bytes it is given on every platform and performs no such translation, so this implementation meets the requirement by doing nothing. No #[cfg] on the target platform appears in this crate. That is worth a section because the C++ sink meets the same requirement with a constructor that calls _setmode(1, _O_BINARY), can fail trying, and needs two headers in a global module fragment to do it. One requirement, one parent specification, and two components whose constructors have nothing in common. The comparison also explains a difference in the startup sequences. C++ must construct its sink before any write to stdout, because the mode is a property of the stream and the help text would otherwise carry CRLF; Rust must construct its sink before any write to stdout for a different reason, because the sink owns the only handle. Both specifications place the step identically and give different reasons, which is what §2's delegation to each language's idiom looks like in practice. output adds the terminator and nothing else - no prefix, no separator, no trailing content - because the string arrives fully formatted.

3.  One Sink, One Buffer

new takes no arguments and yields Some the first time it is called and None every time after, so that the process holds one sink and one only. The flag lives in a thread_local! Cell<bool>, and Drop clears it, so a sink dropped before another is created releases the right to make one.
thread_local! {
    static SINK_TAKEN: Cell<bool> = Cell::new(false);
}
The reason is the buffer of Section 5. Two sinks would wrap the same stdout with two independent BufWriters, and their contents would reach the stream in the order the buffers happened to fill rather than the order the lines were written - which would break the emission order §3.4 fixes, silently and only under load. A rule against a second sink is cheaper to enforce than a defect that appears only on long searches. None is the only way construction fails, and rust_textfinder_entry builds exactly one sink, so its own code cannot provoke it. It handles the case all the same, and the specification gives the reason: a constructor with a failure mode that callers ignore is a constructor whose failure mode will eventually be reached. That None is one of the two paths to exit code 2, which Spec_TextFinder.md §3.4 fixes for a failure that is not about the command line. Default is not implemented, and the omission is deliberate rather than an oversight: Default::default must return Self, and this type cannot promise one. A #[derive(Default)] here would offer callers a constructor that silently produces a second buffer. The thread_local! choice is the same one the binary makes for its skip list, and for the same reason: interior mutability without unsafe and without a synchronization primitive, on a value only one thread reaches. A plain static would require Sync, and Cell is not.

4.  Error Handling

A write that fails - a closed pipe, a full disk - sets an internal failed state. On the first such failure the library flushes stdout and then writes the single line output failed to stderr. Thereafter it discards every string it is given and writes nothing more, to stdout or stderr. Three properties of that sequence are stated in the specification rather than left to the code.
  • The flush comes first, so every line already buffered reaches the stream ahead of the notice explaining why the lines stop. It is best-effort: whatever broke the write may break the flush too, and the code discards that Result with let _ = rather than acting on it.
  • The failed state is permanent. One notice, not one per discarded line, which is what keeps a broken pipe from turning a long search into a long stderr transcript.
  • The failure reaches no caller. All three methods swallow the io::Result their writes return. The library never panics, never returns a status, and never lets the failure reach rust_textfinder_dirnav, which goes on traversing. unwrap and expect appear nowhere in it.
A method returning () where the operation returns a Result is usually a smell, and here it is the interface the structure document asked for. The Output trait's method returns nothing, so absorbing the failure is the contract; the alternative is a trait method returning Result and a traversal that must decide what to do about a sink it was given rather than chose. The flush performed when the value is dropped obeys the same rule, and §6 of the specification explains why that case is named separately: the final flush is the write most likely to be the first one that fails, being the only one that must reach the stream and, on a short search, the only one that reaches it at all. Nothing follows it, so nothing is left to discard. A failed write does not affect the exit code, which the Entry specification reserves for command-line and startup failures. A run whose output went nowhere still exits 0: the exit code answers whether TextFinder could do what it was asked, not whether the reader received it.

5.  Buffering and the One Flush

std::io::stdout returns a handle that flushes on every newline, and every line this library writes ends in one, so writing through it directly would flush once per emitted line. A search emitting 60 lines would pay 60 system calls for nothing. The sink therefore holds a std::io::BufWriter wrapping that handle, which defers the write until its buffer fills. No flush is performed per line. The stream is flushed when the value is dropped, and rust_textfinder_entry reaches that drop on every path out of the program, since each of its exits returns from main rather than calling std::process::exit. Those two facts are one requirement stated in two documents, and the Entry page covers the other half. Drop is implemented explicitly rather than left to BufWriter's own, and the specification says why: BufWriter discards a failing flush without notice, and §6 fixes what this one does with it instead. The inherent flush performs the same write on demand, for the one case that needs the buffer drained before the process ends. One rule keeps the deferral from reordering the output: stdout is flushed before any write to stderr. This library applies the rule to its own output failed notice, and rust_textfinder_entry applies it to the diagnostic that follows the option listing on an invalid /r. Invocation 10 of the demonstration is that case: nine listing lines on stdout, then two diagnostic lines on stderr, in that order. Because this value owns the only stdout handle in the process, the binary's help text and option listing pass through write_text rather than through a handle of their own. They therefore share this buffer and reach the stream in the order written, and the ordering rule above is the only coordination needed.

6.  Source

lib.rs in full. It is the smallest source in the project at 83 lines, and the ratio to its 110-line specification is the point of the component: a sink that does one thing can be specified exhaustively.
Rust_Spec_driven_Output/src/lib.rs
//! rust_textfinder_output - the process's one stdout sink.
//! Implements Spec_Rust_TextFinder_Output.md.

use rust_textfinder_dirnav::Output;
use std::cell::Cell;
use std::io::{BufWriter, Stdout, Write};

#[cfg(test)]
mod unit_tests;

thread_local! {
    static SINK_TAKEN: Cell<bool> = Cell::new(false);
}

pub struct StdoutSink {
    writer: BufWriter<Stdout>,
    failed: bool,
}

impl StdoutSink {
    pub fn new() -> Option<Self> {
        let available = SINK_TAKEN.with(|taken| {
            let free = !taken.get();
            taken.set(true);
            free
        });
        if available {
            Some(StdoutSink { writer: BufWriter::new(std::io::stdout()), failed: false })
        } else {
            None
        }
    }

    /// Writes the text verbatim. The help text and option listing arrive already terminated.
    pub fn write_text(&mut self, text: &str) {
        self.emit(text, false);
    }

    pub fn flush(&mut self) {
        if self.failed {
            return;
        }
        if self.writer.flush().is_err() {
            self.fail();
        }
    }

    fn emit(&mut self, text: &str, terminate: bool) {
        if self.failed {
            return;
        }
        let mut wrote = self.writer.write_all(text.as_bytes());
        if wrote.is_ok() && terminate {
            wrote = self.writer.write_all(b"\n");
        }
        if wrote.is_err() {
            self.fail();
        }
    }

    /// Spec section 6: flush what is buffered, then write one notice, then discard everything after.
    fn fail(&mut self) {
        self.failed = true;
        let _ = self.writer.flush();
        let _ = writeln!(std::io::stderr(), "output failed");
    }
}

impl Output for StdoutSink {
    fn output(&mut self, text: &str) {
        self.emit(text, true);
    }
}

impl Drop for StdoutSink {
    fn drop(&mut self) {
        if !self.failed && self.writer.flush().is_err() {
            self.failed = true;
            let _ = writeln!(std::io::stderr(), "output failed");
        }
        SINK_TAKEN.with(|taken| taken.set(false));
    }
}
Drop repeats the failure handling rather than calling fail, because fail flushes first and the flush is what has just been attempted. The duplication is four lines and the alternative is a flush of a buffer that was reported unwritable one statement earlier.

7.  Prompt Records

This page carries none. Page_Structure.md §8 assigns it Prompts_Spec_Rust_TextFinder_Output.md and its Fix companion, and neither was written: the Output specification was produced in the same turn as the other four documents, recorded at the project level. Three audit items reach this component - the missing flush, the unspecified failing flush in Drop, and the second sink nothing prevented - and all three sit on the Process page.