Spec-Driven

Spec-Driven Rust: Entry

eleven steps, a thread_local skip list, three exit codes, and no call to process::exit

Synopsis:
This page covers rust_textfinder_entry, the binary. It wires the three libraries together and does no matching and no file I/O of its own.
  • It owns the skip list, the lifetime of all three values, and the exit code. Here the borrow checker enforces the lifetime rule rather than leaving it to convention.
  • 114 lines of code against a 106-line specification. Everything harder than wiring lives in a library.
Eleven startup steps, one more than C++ needs, and the order is the specification rather than a consequence of it.
  • Step 1 uses args_os, because std::env::args panics on an argument that is not valid Unicode and a command line the user typed must not crash the program.
  • Step 3 constructs the sink first, and here it matters because the sink owns the process's only stdout handle - so the binary's own text shares one buffer with the search output.
  • Step 10 asks for the run summary, supplying the one fact the library cannot have: that step 9 is finished.
Two places where the type system shapes the code rather than the specification.
  • A thread_local! RefCell holds the skip list, which recovers the one-argument signature without unsafe and without a lock.
  • Steps 8 and 9 are one match rather than two statements, because the sink cannot be written to directly while the Dirnav borrowing it exists.

1.  What the Binary Owns

rust_textfinder_entry is the command-line entry point. It collects the program's arguments, parses them through rust_textfinder_cmdline, wires the three libraries together, and drives traversal. Matching and file I/O belong to rust_textfinder_dirnav and rust_textfinder_output; the binary performs neither. The binary does write process-level text of its own - help, the resolved option listing, and startup diagnostics - but it composes none of it. Each is a string rust_textfinder_cmdline hands it, and the binary decides only when to write it and to which stream. Block lines and announcements it neither composes nor writes. Three things it owns outright:
  • The skip list, and add_skip_directory as the build-time extension point Spec_TextFinder.md §3.5 defines. Section 3 covers it.
  • The lifetime of all three values. Dirnav borrows rather than clones, so the StdoutSink, the finalized skip list, and the parsed commands must outlive it. All three are locals of main, and the borrow checker enforces the rule rather than leaving it to convention.
  • The exit code. Section 4 covers the three values and how they map onto this binary's failure modes.
It is 114 lines, and the specification that fixes it is 106. That ratio is the point of the component: the design goal is to make the wiring itself readable, and everything harder than wiring lives in a library.

2.  The Startup Sequence

fn main() -> ExitCode performs eleven steps in order. The order is the specification rather than a consequence of it: three of the eleven are placed where they are for reasons that would not survive rearranging.
Step What happens
1 Collect the arguments from std::env::args_os and decode each to a String. An argument that will not decode writes invalid argument encoding: <token> to stderr and returns 2, having parsed nothing
2 Invoke parse on the decoded arguments. On Err, write the returned usage diagnostic to stderr unaltered and return 1. A malformed /r is not detected here; step 8 reaches it
3 Construct the StdoutSink. On None, write cannot initialize output to stderr and return 2
4 If /H true, write help_text() through the sink and return 0
5 If the argument list holds one element, write options_text(&commands) through the sink and return 0. This is the bare command line of §3.1
6 If /v true, write options_text(&commands) through the sink before traversal begins
7 Finalize the skip list: the defaults, then the add_skip_directory calls compiled into the binary, then RefCell::take into a local that outlives the traversal
8 Construct the Dirnav, generic over StdoutSink. Regex compilation happens here, and Dirnav::new returns Err when /r will not compile
9 For each root path, in the order /P gave them, invoke search on the same value
10 Call emit_run_summary on that same value, once, after the last root path returns
11 Return exit code 0
Step 10 is the whole of this binary's part in the run summary. Spec_TextFinder.md §3.6 puts the two counts in rust_textfinder_dirnav, so this binary supplies no count and composes no text. What it supplies is the one fact the library cannot have: that step 9 is finished. The call sits inside the Ok arm of step 8, where the Dirnav value is in scope, and it is the reason that arm ends in a statement rather than in the loop. Every exit above returns before step 9, which is what makes the summary a mark of a run that traversed. /H, the bare command line, a parse failure, an undecodable argument, and a malformed /r each leave at their own step, and none of them reaches step 10 - so none writes a summary, which is exactly what §3.6 asks. The integration suite asserts the absence for three of those five cases. Step 1 uses args_os and not args. std::env::args panics on an argument that is not valid Unicode, and a command line the user typed must not crash the program. The decode is an explicit into_string per argument, and its Err carries the undecodable OsString, which to_string_lossy renders for the diagnostic. That is the only rendering available for text that is not valid Unicode. Step 3 precedes every write to stdout. The sink owns the process's only stdout handle, so the binary's own help text and option listing pass through it rather than through a handle of their own. They therefore share one buffer with the search output and reach the stream in the order written. The C++ implementation needs this ordering for a second reason, since constructing its sink also sets the stream mode; Rust needs no stream mode, and the ordering still holds because of the shared buffer. Step 5 reads the argument list's length and nothing else. It inspects no element, because "no switch at all" is exactly "no tokens after the program name". commands here is ProgramCommands::default(), so the listing names every default and its /v line reads false. Steps 5 and 6 are mutually exclusive: a command line bearing /v is not bare. Step 8 is where a bad pattern is refused, and it takes four sub-steps to report:
  1. If step 6 did not already write it, write options_text(&commands) through the sink now, so the user sees the /r line carrying the expression that failed.
  2. Call flush on the sink, so the listing reaches the stream ahead of the diagnostic that explains it. The sink buffers stdout without per-line flushing, so without this the two arrive out of order.
  3. Compose the diagnostic - invalid regex for switch: /r, a newline, then usage_line() - and write it to stderr. The binary composes it; neither library does, the one supplying only usage_line() and the other only the failure that triggers it.
  4. Return exit code 1, having traversed nothing.
The error text the regex crate puts in its own Error is not written, and the specification gives a reason that outlives any wording change: that text belongs to the crate, so a crate upgrade would alter this program's output with no document in this tree recording the change. Steps 8 and 9 are one match in the source rather than two statements, and the borrow checker is why. The Dirnav borrows the sink for as long as it lives, so the sink cannot be written to directly while the Dirnav exists. The match runs the traversal inside its Ok arm and yields a bool; the failure path then writes its listing after the borrow has ended. The specification asks only that the listing precede the diagnostic, and this arrangement satisfies both that and the borrow checker. Step 9 passes a root-path failure to nobody. A root that cannot be searched - unopenable, a symbolic link, or neither a regular file nor a directory - is announced by rust_textfinder_dirnav itself, and the binary neither formats nor inspects that notice. No root-path outcome affects the exit code.

3.  The Skip List, RefCell, and thread_local

The binary owns the process-wide skip list, initialized with the 11 defaults of Spec_TextFinder.md §3.2, and implements the extension point with that section's own one-argument signature:
thread_local! {
    static SKIP_LIST: RefCell<SkipList> = RefCell::new(default_skip_list());
}

fn default_skip_list() -> SkipList;
fn add_skip_directory(name: &str);
add_skip_directory takes the name alone, as §3.5 writes it, and reaches the list through the cell. The interior mutability a RefCell supplies is what makes that possible without an unsafe block and without a synchronization primitive: the borrow is checked at run time rather than at compile time, and the check costs one integer comparison per call. The declaration is a thread_local! rather than a plain static. A static must be Sync, and RefCell is deliberately not, its borrow flag being an ordinary integer rather than an atomic one. thread_local! carries no Sync bound, since each thread receives its own value, and that costs this binary nothing: the list is built before traversal begins and traversal runs on one thread. Neither function is pub, and both are defined in the binary crate, so no library and no test can call them; the calls that extend the list are written into the binary's source and compiled with it. add_skip_directory ignores a name the list already holds, and #[allow(dead_code)] marks it so that a build adding no directory compiles without a warning. The source carries a fn extend_skip_list() with an empty body, which is where such calls go. That is the narrow reading of §3.5, which requires each language's specification to say whether the function is exported from a library or confined to the component that owns the list, and does not require it to be callable from outside the program. The C++ implementation reads it the same way, for the same reason. Step 7 then takes the finished list out of the cell with RefCell::take and holds it in a local. That local is what the Dirnav borrows, so the borrow lasts the whole traversal rather than the body of a closure passed to with. The Dirnav takes it as a shared borrow, so it consults the list and cannot modify it - a property the type system carries rather than a comment.

4.  Exit Codes

The three codes Spec_TextFinder.md §3.4 fixes map onto four failure modes. No other value is returned from main.
Code Cause in this binary What reaches which stream
0 Startup and traversal completed; or /H; or the bare command line Search output, help, or the option listing on stdout. Match count and unopenable roots do not change it
1 Parse failure, or a malformed /r A usage diagnostic on stderr. Parse failure leaves stdout empty; a malformed /r puts the option listing there first
2 An argument would not decode, or StdoutSink::new returned None invalid argument encoding: <token> or cannot initialize output on stderr, no usage line, stdout empty
Only one exit-code-1 path writes to stdout, and the asymmetry is deliberate: §5.2 requires the listing ahead of the invalid-regex diagnostic so the /r line shows what failed, and leaves stdout empty for every other violation. Every one of those strings is fixed in the Rust specifications rather than the project specification. §2 leaves the wording of anything reaching stderr to each language, so Spec_Rust_TextFinder_Cmdline.md §6 carries the six reason lines parse produces and Spec_Rust_TextFinder_Entry.md §6 carries the seventh, invalid regex for switch: /r, plus the two code-2 strings. What binds everyone is the shape of a usage diagnostic, the destination, the exit codes, and what each failure leaves on stdout. Rust adopts §5.2's supplied wording without changing a character, so its stderr stays comparable with the C++ implementation's, which §6 no longer requires but does not forbid. An undecodable argument taking code 2 rather than code 1 is a decision, and the specification records why either would look defensible. §5.2 fixes no reason line for the case, since it cannot arise in an implementation whose argument vector carries bytes, and §3.4 defines code 1 as the code that accompanies a §5.2 diagnostic. The binary cannot form the command line it would have to validate, so the failure precedes command-line validation rather than resulting from it. A malformed regex taking code 1 is a decision too, and it went the other way: the expression is something the user typed, so the failure is a usage failure even though it surfaces inside a library constructor.

5.  Why Every Exit Is a Return

Every exit in the sequence returns from main, never calling std::process::exit. The StdoutSink is a local of main, and only a return drops it and flushes its stdout buffer. Steps 4, 5, and 8 each write to stdout and then leave, and std::process::exit would discard what they wrote. ExitCode is what makes that practical. main returns ExitCode rather than (), so a numeric status and a normal return are the same act, and ExitCode::from(2) reads as plainly as std::process::exit(2) would while leaving every destructor to run. One rule pairs with it: nothing writes to stderr before stdout has been flushed. Steps 1 and 2 predate the sink and so have no buffer to flush; step 3 fails before one exists; step 8 flushes explicitly. rust_textfinder_output applies the same rule to its own output failed notice. Both rules exist because the sink defers flushing to Drop, which the Output page covers. Every exit being a return is the half of that requirement this component owns. Nothing in the sequence panics. No step calls unwrap or expect, and every failure it can meet resolves to a diagnostic and an exit code. That is a claim a reader can check against the 113 lines of Section 7 rather than take on trust, which is why the specification states it as a property of the sequence rather than as advice.

6.  Stated Limits

Three non-goals, and one of them is a portability limit rather than a design choice.
  • No per-file state in the binary. A single Dirnav value is reused across every root path, and it carries no state from one search call to the next.
  • Every argument must be valid Unicode. The binary takes args_os and decodes, and an argument that will not decode is refused with exit code 2. On Windows that admits any command line the shell can express, since the arguments arrive as UTF-16; on POSIX it refuses a path holding bytes that are not valid UTF-8. Spec_TextFinder.md §4 leaves the argument type and its encoding to this document, so this is a stated limit of the Rust implementation rather than a departure from the parent specification.
  • No configuration file, and no runtime means of extending the skip list.
The second limit cuts both ways against C++, and the specification says so. This implementation accepts non-ASCII root paths and expressions the C++ implementation cannot carry, since C++ takes main's char* argv[] undecoded, and it refuses POSIX paths that the C++ implementation passes through as bytes. §6's consistency guarantee speaks only to the command lines all four implementations accept.

7.  Source

main.rs in full, then the package manifest. The step numbers in the comments are the eleven steps of Section 2, and the citations name the specification section each step satisfies.
main.rs
//! rust_textfinder_entry - the TextFinder binary.
//! Implements Spec_Rust_TextFinder_Entry.md.

use rust_textfinder_cmdline::{help_text, options_text, parse, usage_line, ProgramCommands};
use rust_textfinder_dirnav::{Dirnav, SkipList};
use rust_textfinder_output::StdoutSink;
use std::cell::RefCell;
use std::path::Path;
use std::process::ExitCode;

thread_local! {
    static SKIP_LIST: RefCell<SkipList> = RefCell::new(default_skip_list());
}

fn default_skip_list() -> SkipList {
    [
        "archive",
        ".git",
        ".svn",
        ".hg",
        "build",
        "out",
        "target",
        "bin",
        "obj",
        "__pycache__",
        "node_modules",
    ]
    .iter()
    .map(|name| String::from(*name))
    .collect()
}

/// The build-time extension point of Spec_TextFinder.md section 3.5.
#[allow(dead_code)]
fn add_skip_directory(name: &str) {
    SKIP_LIST.with(|list| {
        let mut list = list.borrow_mut();
        if !list.iter().any(|held| held == name) {
            list.push(String::from(name));
        }
    });
}

/// Every call compiled in here runs before traversal begins. None is at present.
fn extend_skip_list() {}

fn main() -> ExitCode {
    let mut args: Vec<String> = Vec::new();
    for argument in std::env::args_os() {
        match argument.into_string() {
            Ok(text) => args.push(text),
            Err(raw) => {
                eprintln!("invalid argument encoding: {}", raw.to_string_lossy());
                return ExitCode::from(2);
            }
        }
    }

    let commands: ProgramCommands = match parse(&args) {
        Ok(commands) => commands,
        Err(diagnostic) => {
            eprint!("{diagnostic}");
            return ExitCode::from(1);
        }
    };

    let mut sink = match StdoutSink::new() {
        Some(sink) => sink,
        None => {
            eprintln!("cannot initialize output");
            return ExitCode::from(2);
        }
    };

    if commands.help {
        sink.write_text(&help_text());
        return ExitCode::SUCCESS;
    }

    if args.len() == 1 {
        sink.write_text(&options_text(&commands));
        return ExitCode::SUCCESS;
    }

    if commands.verbose {
        sink.write_text(&options_text(&commands));
    }

    extend_skip_list();
    let skips: SkipList = SKIP_LIST.with(|list| list.take());

    let compiled = match Dirnav::new(&mut sink, &skips, &commands) {
        Ok(mut navigator) => {
            for root in &commands.root_paths {
                navigator.search(Path::new(root));
            }
            navigator.emit_run_summary();   // only main knows the last root has returned
            true
        }
        Err(_) => false,
    };

    if !compiled {
        if !commands.verbose {
            sink.write_text(&options_text(&commands));
        }
        sink.flush();
        eprint!("invalid regex for switch: /r\n{}", usage_line());
        return ExitCode::from(1);
    }

    ExitCode::SUCCESS
}
Rust_Spec_driven_TextFinder_Entry/Cargo.toml
[package]
name = "rust_textfinder_entry"
version = "0.1.0"
edition = "2021"
rust-version = "1.70"

[[bin]]
name = "rust_textfinder"
path = "src/main.rs"

[dependencies]
rust_textfinder_cmdline = { path = "../Rust_Spec_driven_Cmdline" }
rust_textfinder_dirnav = { path = "../Rust_Spec_driven_Dirnav" }
rust_textfinder_output = { path = "../Rust_Spec_driven_Output" }
The [[bin]] section is what separates the package name from the executable name. Without it Cargo would produce rust_textfinder_entry.exe, and Spec_TextFinder.md §5.1 fixes the help text's synopsis around rust_textfinder.

8.  Prompt Records

This page carries none. Page_Structure.md §8 assigns it Prompts_Spec_Rust_TextFinder_Entry.md and its Fix companion, and neither was written: the Entry specification was produced in the same turn as the other four documents, recorded at the project level. The audit that reshaped it sits on the Process page, where three of its four behavioral items reach this component.