Spec-Driven

Spec-Driven Rust: Testing

four suites, 88 tests, the harness the language ships with

Synopsis:
This page covers the four suites and what they leave uncovered. Three unit suites for the three libraries, one integration suite driving the built executable.
  • 88 tests, which do not compare with the C++ page's 151 assertions - a Rust test holds as many assertions as its claim needs, so the difference is one of unit rather than of coverage.
  • This is the first implementation written against the verification requirement rather than found to satisfy it, that requirement having come out of the Rust audit.
No test framework, and in Rust that costs nothing.
  • #[test], assert_eq!, and cargo test ship with the language, where C++ writes a Checker struct into each of four test modules to get the same three things.
  • Unit suites live inside the crate so they can reach private items; the integration suite lives outside so it sees the binary, whose path Cargo supplies as a compile-time literal.
  • The Output suite depends on the harness running each test on its own thread, since the one-sink flag is a thread_local!.
Every suite passes, and Section 6 names four claims that are read rather than checked.
  • The unrenderable name - the rule that cost two implementations a code change is tested in neither.
  • The failed write, and both paths to exit code 2, none of which a test can provoke.
  • The POSIX side of everything, which a second machine would close without a line of new test code.

1.  Four Suites

Three unit suites cover the three libraries and one integration suite covers the binary by driving the built executable end to end. The division follows from what rust_textfinder_entry is: a binary whose behavior is its startup sequence, its exit codes, and its stream routing, none of which can be exercised without running it. That division is a requirement rather than a habit. Spec_TextFinder.md §6.2 asks every implementation for unit suites one per library component, one integration suite driving the built executable, one demonstration, and a runner per kind that announces each suite and exits with the number that failed. §6.2 arrived out of the Rust audit, so this is the first implementation written against it rather than found to satisfy it; the Contracts page covers what it asks of the two implementations still to come.
Suite Tests What it covers
Cmdline 29 Every default, both introducers, boolean case folding, last-occurrence-wins, /P accumulation, the /p normalization rules including the six trimmed characters, all six parse diagnostics, and the rendered help and option listings
Dirnav 33 Skip-list pruning including the root carve-out, /p selection with the dot-file rule, /s false, all four block forms, the /h gating, cannot open, LF, CRLF and bare CR splitting, BOM consumption, and the 10 MB limit
Output 4 The one-sink rule, the release of that right on drop, both write methods reporting nothing and panicking on nothing, and dispatch through the Output trait
Integration 22 The executable end to end: exit codes, stream routing, /H, the bare command line, the /v ordering, LF-only output, separator normalization, and all seven usage diagnostics
88 tests in total. A Rust test is one #[test] function holding as many assertions as the claim needs, so these counts are not comparable with the C++ suites' assertion counts. The C++ Testing page reports 151 assertions over the same ground, and the difference is one of unit rather than of coverage: every_parse_failure_reaches_stderr_with_exit_code_one is one test and 18 assertions, three for each of the six reason lines. Two suites deserve a note on what they prove beyond their counts. The Dirnav suite builds a temp tree and supplies its own Recorder, a struct holding one Vec<String> that implements Output, which satisfies the same bound the real sink does and so demonstrates that the generic parameter binds to any implementation of the trait. The Output suite cannot read stdout back, since the sink owns the only handle to it, so it checks what does not depend on reading the stream and leaves the bytes to the integration suite. The Dirnav and integration suites sort emitted lines before comparing wherever more than one file is involved, because Spec_TextFinder.md §3.2 leaves the order of a directory's entries to the filesystem. A suite that compared unsorted output would be asserting a property the specification declines to guarantee.

2.  The Harness

No test framework, and in Rust that costs nothing: #[test], assert_eq!, and cargo test ship with the language, so Spec_TextFinder.md §6's confinement to the standard library leaves the suites with a working runner, per-test isolation, and a failure report that prints both sides of a mismatch. The C++ implementation writes a Checker struct in each of its four test modules to get the same three things. Each unit suite sits beside the code it tests, as a src/unit_tests.rs the crate root declares:
#[cfg(test)]
mod unit_tests;
The module compiles only under cargo test, so nothing of a suite reaches the shipped library, and use super::*; at its top gives it the crate's private items. That is what lets the Output suite read SINK_TAKEN directly and the Dirnav suite call normalize, neither of which is pub. A tests/ directory would compile against the public interface alone and could reach neither. The integration suite is a tests/integration.rs for that reason reversed: it must see the binary rather than the library, and Cargo hands it the built executable's path as an environment variable at compile time.
const EXECUTABLE: &str = env!("CARGO_BIN_EXE_rust_textfinder");
env! resolves at compile time, so the constant is a literal and the suite cannot run against a path that does not exist. The C++ integration suite gets the same string through a CMake generator expression and a target_compile_definitions call; Cargo supplies it because the test target and the binary target are in one package. Both the Dirnav and integration suites build temp trees through a TempTree helper whose Drop removes the directory, so a failing test leaves nothing behind. Each tree's name carries the process id and an AtomicUsize counter, which is what keeps two trees apart when the harness runs the tests in parallel. That parallelism is worth one more sentence, because the Output suite depends on it. Cargo runs each #[test] on its own thread, and SINK_TAKEN is a thread_local!, so each test sees a fresh flag and the four sink tests do not interfere. A plain static would have made them order-dependent, which is the second argument for the choice Spec_Rust_TextFinder_Output.md §4 makes on other grounds.

3.  The Runners

Three batch files sit at the top of the Rust project. Each builds the workspace first, brackets every suite with a === starting ... banner and a === ... returned N line, then prints a summary and returns the number of failed suites as its own exit code.
Runner What it does
run_unit_tests.bat Runs the Cmdline, Dirnav, and Output suites in turn with cargo test -p <package> --lib
run_integration_tests.bat Runs cargo test -p rust_textfinder_entry --test integration
run_demo.bat Runs the demonstration with --nocapture and supplies the capture date through TEXTFINDER_DEMO_DATE
Each takes its working directory from its own location with cd /d "%~dp0", so any of the three runs from any working directory. A failed build stops before running a suite and says that every suite counts as failed, which satisfies §6.2's rule that a suite never built counts as a failure rather than being skipped silently. Each builds the workspace before its first suite, which is what §6.2 asks of a runner so that a fresh checkout needs no separate step and a reader cannot run yesterday's binary against today's source. Rust needs no work to satisfy that: cargo build is already incremental, so a run after a build costs a fraction of a second and one after an edit rebuilds only what changed. The C++ runners pay more for the same rule, having to configure CMake when there is no cache and to establish the MSVC environment when the shell lacks it. Each also holds the console after its summary, so a reader who starts one from a file manager sees its output rather than a window that closes. The hold is one :hold subroutine, it comes last, and it does not change the exit code - a pause leaves ERRORLEVEL untouched, and the runner returns its failure count explicitly in any case. Setting TEXTFINDER_NO_PAUSE to a non-empty value suppresses it, which is how the captures in Sections 4 and 5 were taken and how the demonstration of §7.5 is recaptured. The --lib flag on the unit runs is what keeps the three suites separate. Without it cargo test -p rust_textfinder_entry would also build and run the integration and demonstration targets, and the unit runner would report a number that included them. run_demo.bat passes the local date in rather than letting the demonstration compute it. The demonstration falls back to a civil date derived from the UTC clock when the variable is absent, which reads as the previous day late in the evening, so the runner is the authority for the date a capture carries.
run_unit_tests.bat
@echo off
rem run_unit_tests.bat - runs every rust_textfinder unit suite and reports each status
rem
rem Spec_TextFinder.md §6.2: the runner builds what it is about to run, announces each
rem suite and the status it returned, exits with the number of suites that failed, and
rem holds the console after its summary.

setlocal enabledelayedexpansion
cd /d "%~dp0"

set FAILURES=0

echo building the workspace
cargo build --workspace --quiet
if errorlevel 1 (
  echo   build failed; all 3 unit suites count as failed
  set FAILURES=3
  goto :summary
)

call :suite rust_textfinder_cmdline
call :suite rust_textfinder_dirnav
call :suite rust_textfinder_output

:summary
echo.
echo !FAILURES! unit suite^(s^) failed
call :hold
exit /b !FAILURES!

:suite
echo.
echo === starting unit suite: %1
cargo test -p %1 --lib
set STATUS=!errorlevel!
echo === unit suite %1 returned !STATUS!
if not "!STATUS!"=="0" set /a FAILURES+=1
exit /b 0

rem --- §6.2: hold the console unless a capture suppressed it. ---
:hold
if not "%TEXTFINDER_NO_PAUSE%"=="" exit /b 0
echo.
pause
exit /b 0
run_integration_tests.bat
@echo off
rem run_integration_tests.bat - runs the rust_textfinder integration suite and reports its status
rem
rem Spec_TextFinder.md §6.2: the runner builds what it is about to run, announces the
rem suite and the status it returned, exits with the number of suites that failed, and
rem holds the console after its summary.

setlocal enabledelayedexpansion
cd /d "%~dp0"

set FAILURES=0

echo building the workspace
cargo build --workspace --quiet
if errorlevel 1 (
  echo   build failed; the integration suite counts as failed
  set FAILURES=1
  goto :summary
)

echo.
echo === starting integration suite: integration
cargo test -p rust_textfinder_entry --test integration
set STATUS=!errorlevel!
echo === integration suite integration returned !STATUS!
if not "!STATUS!"=="0" set /a FAILURES+=1

:summary
echo.
echo !FAILURES! integration suite^(s^) failed
call :hold
exit /b !FAILURES!

rem --- §6.2: hold the console unless a capture suppressed it. ---
:hold
if not "%TEXTFINDER_NO_PAUSE%"=="" exit /b 0
echo.
pause
exit /b 0

4.  Unit Suite Output

Captured 2026-09-16 from run_unit_tests.bat. Each suite names every test, prints its count, and ends with the line the harness writes. The test names are the specification restated as claims, which is what makes a passing run readable as coverage rather than only as a number. The order the names appear in is the order the tests finished, not the order they are written, since the harness runs them in parallel. A reader looking for a particular claim reads the list rather than scanning it in source order.
=== starting unit suite: rust_textfinder_cmdline
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running unittests src\lib.rs (target\debug\deps\rust_textfinder_cmdline-96a227066ace06c9.exe)

running 29 tests
test unit_tests::a_boolean_switch_rejects_any_other_value ... ok
test unit_tests::a_token_without_an_introducer_is_not_a_switch ... ok
test unit_tests::an_argument_is_consumed_verbatim_even_when_it_looks_like_a_switch ... ok
test unit_tests::an_undefined_introducer_led_token_is_unrecognized ... ok
test unit_tests::a_trailing_switch_is_missing_its_argument ... ok
test unit_tests::booleans_are_matched_case_insensitively ... ok
test unit_tests::both_introducers_are_equivalent ... ok
test unit_tests::duplicate_extensions_are_retained_and_order_preserved ... ok
test unit_tests::empty_arguments_are_rejected_for_root_path_and_expression ... ok
test unit_tests::empty_command_line_yields_the_defaults ... ok
test unit_tests::every_diagnostic_ends_with_the_usage_line ... ok
test unit_tests::every_returned_string_ends_with_a_newline ... ok
test unit_tests::extensions_are_split_trimmed_and_stripped_of_one_dot ... ok
test unit_tests::extension_case_is_not_folded_by_the_parser ... ok
test unit_tests::first_root_path_replaces_the_default_even_when_equal_to_it ... ok
test unit_tests::last_occurrence_wins_for_every_other_switch ... ok
test unit_tests::no_line_of_the_option_listing_ends_in_whitespace ... ok
test unit_tests::empty_extension_items_are_discarded ... ok
test unit_tests::only_one_leading_dot_is_stripped ... ok
test unit_tests::help_text_carries_every_switch_and_ends_with_a_newline ... ok
test unit_tests::option_listing_emits_one_line_per_root_and_a_joined_extension_list ... ok
test unit_tests::option_listing_of_a_bare_v_true_command_line ... ok
test unit_tests::option_listing_of_the_defaults_reads_v_false ... ok
test unit_tests::parsing_stops_at_the_first_violation ... ok
test unit_tests::program_name_is_not_inspected ... ok
test unit_tests::repeated_root_paths_accumulate_in_order ... ok
test unit_tests::switch_letters_are_case_sensitive ... ok
test unit_tests::the_six_named_characters_are_trimmed_and_no_others ... ok
test unit_tests::usage_line_is_the_first_line_of_the_help_text ... ok

test result: ok. 29 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

=== unit suite rust_textfinder_cmdline returned 0
The 29 Cmdline tests divide three ways: the defaults against Spec_TextFinder.md §5, the four parsing rules and the six diagnostics this library owns, and the rendered text against §5.1 and §5.3. Two names carry the audit findings of the Process page as claims: the_six_named_characters_are_trimmed_and_no_others is the whitespace rule that moved up to the parent specification, and first_root_path_replaces_the_default_even_when_equal_to_it is the -P . case that rules out inferring the first occurrence from the vector.
=== starting unit suite: rust_textfinder_dirnav
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running unittests src\lib.rs (target\debug\deps\rust_textfinder_dirnav-aee1a9ce695c7482.exe)

running 33 tests
test unit_tests::a_malformed_expression_is_returned_rather_than_panicking ... ok
test unit_tests::a_root_path_that_cannot_be_opened_is_announced ... ok
test unit_tests::an_error_announcement_is_not_gated_on_h ... ok
test unit_tests::a_block_writes_its_path_once_however_many_lines_match ... ok
test unit_tests::a_root_named_in_the_skip_list_is_traversed_and_pruning_resumes_below_it ... ok
test unit_tests::a_matching_line_yields_one_detail_line_however_many_occurrences_it_holds ... ok
test unit_tests::line_iteration_treats_a_final_unterminated_run_as_a_line ... ok
test unit_tests::a_root_path_that_is_a_regular_file_is_searched_and_filtered_like_any_other ... ok
test unit_tests::a_leading_bom_does_not_belong_to_the_first_line ... ok
test unit_tests::a_detail_line_carries_only_the_fields_n_and_l_select ... ok
test unit_tests::a_searched_file_that_matched_nothing_is_announced_only_under_h_false ... ok
test unit_tests::path_joining_never_doubles_a_separator ... ok
test unit_tests::a_skip_list_directory_is_pruned_silently ... ok
test unit_tests::evaluation_stops_at_the_first_match_when_neither_n_nor_l_is_set ... ok
test unit_tests::an_empty_extension_list_selects_every_file ... ok
test unit_tests::a_skipped_file_is_silent_under_the_default_h ... ok
test unit_tests::a_file_holding_a_nul_byte_or_invalid_utf8_is_skipped ... ok
test unit_tests::a_file_above_the_size_limit_is_never_read ... ok
test unit_tests::a_pruned_directory_is_counted_as_neither ... ok
test unit_tests::a_root_that_is_a_regular_file_counts_as_a_file_and_neither_noun_inflects ... ok
test unit_tests::default_command_line_reports_every_nonempty_file_without_opening_it ... ok
test unit_tests::a_root_that_cannot_be_opened_is_counted_as_neither ... ok
test unit_tests::every_path_begins_with_the_root_and_uses_forward_separators ... ok
test unit_tests::lf_crlf_and_bare_cr_each_terminate_a_line ... ok
test unit_tests::line_numbers_count_lines_that_did_not_match ... ok
test unit_tests::one_navigator_serves_every_root_path ... ok
test unit_tests::a_file_the_extension_list_excluded_is_not_counted ... ok
test unit_tests::recursion_off_searches_the_root_directory_alone ... ok
test unit_tests::every_examined_file_and_every_entered_directory_is_counted ... ok
test unit_tests::a_non_empty_extension_list_selects_by_last_dot_suffix ... ok
test unit_tests::under_recursion_off_no_subdirectory_is_counted ... ok
test unit_tests::the_counts_are_of_the_whole_run_and_an_entry_under_two_roots_counts_twice ... ok
test unit_tests::the_summary_is_not_gated_on_h ... ok

test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

=== unit suite rust_textfinder_dirnav returned 0
The Dirnav suite tests the accepted costs rather than only the rules. default_command_line_reports_every_nonempty_file_without_opening_it is Spec_TextFinder.md §3.3's no-content case with both of its costs in one claim: a binary file is reported, and an empty one is not. a_root_named_in_the_skip_list_is_traversed_and_pruning_resumes_below_it is the §3.2 carve-out and its limit in one name, and every_path_begins_with_the_root_and_uses_forward_separators is the normalization gap the audit found. Eight of its 33 tests cover the run summary of §3.6, and they read as that section's exclusion list turned into claims: a_file_the_extension_list_excluded_is_not_counted, a_pruned_directory_is_counted_as_neither, under_recursion_off_no_subdirectory_is_counted, a_root_that_cannot_be_opened_is_counted_as_neither. the_counts_are_of_the_whole_run_and_an_entry_under_two_roots_counts_twice drives one navigator over two roots the way the binary does, which is the only way to test that the counts are never reset between them. And a_root_that_is_a_regular_file_counts_as_a_file_and_neither_noun_inflects asserts accessed 1 files, 0 directories verbatim, pinning the uninflected singular §3.6 accepts rather than leaving a later reader to correct it.
=== starting unit suite: rust_textfinder_output
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running unittests src\lib.rs (target\debug\deps\rust_textfinder_output-828f4f4650b5899b.exe)

running 4 tests
test unit_tests::a_second_sink_is_refused_while_the_first_lives ... verbatim text, already terminated
one line, terminator added by the sink

okreached through the trait

test unit_tests::writing_through_both_methods_reports_nothing_and_panics_on_nothing ... ok
test unit_tests::the_sink_is_the_output_trait_implementation_dirnav_binds_to ... ok
test unit_tests::dropping_a_sink_releases_the_right_to_make_one ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

=== unit suite rust_textfinder_output returned 0
Four lines in that capture are not the harness speaking. verbatim text, already terminated, one line, terminator added by the sink, the empty line after it, and reached through the trait are what two of the tests wrote to the real stdout, which is the one thing this library owns and therefore the one thing its suite cannot redirect. The empty line is the output("") case, and seeing it in the capture is the whole assertion made visible: an empty string still terminates a line. Four tests for an 83-line library is the right ratio when three of the library's four properties can only be observed from outside the process. The C++ Output suite asserts nine things because it can redirect std::cout's stream buffer and inspect the bytes; this one asserts what holds in process and leaves the bytes to Section 5.

5.  Integration Suite Output

Captured in the same run, from run_integration_tests.bat. Each of these 22 tests spawns the executable one or more times, which is why this suite accounts for nearly all the runtime while the three in-process suites finish in hundredths of a second combined. The twenty-second arrived with Spec_TextFinder.md §3.6 and asserts an absence: a_run_that_traverses_nothing_writes_no_summary drives the bare command line, /H, and a malformed /r and requires that none of the three write the closing line. A rule about runs that do not traverse can only be tested that way, and the other 21 tests gained the line in their expected stdout instead - which is what asserting exact output costs when the output grows, and what it buys.
=== starting integration suite: integration
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running tests\integration.rs (target\debug\deps\integration-be2acb173234ccd1.exe)

running 22 tests
test a_malformed_expression_prints_the_listing_to_stdout_then_the_diagnostic ... ok
test a_malformed_expression_under_verbose_lists_the_options_once ... ok
test help_prints_the_specified_text_to_stdout_and_exits_zero ... ok
test a_root_of_dot_contributes_no_leading_dot_slash ... ok
test a_named_root_is_part_of_every_path_and_separators_are_normalized ... ok
test a_bare_command_line_lists_the_resolved_options_and_exits_zero ... ok
test a_block_carries_its_path_once_and_its_detail_lines_beneath_it ... ok
test a_crlf_file_yields_lines_free_of_the_carriage_return ... ok
test an_unopenable_root_is_announced_and_leaves_the_exit_code_zero ... ok
test help_is_taken_before_any_traversal ... ok
test no_path_is_ever_printed_twice ... ok
test recursion_off_searches_the_root_directory_alone ... ok
test the_default_h_hides_only_the_files_that_matched_nothing ... ok
test every_examined_file_appears_exactly_once_under_h_false ... ok
test the_extension_filter_selects_by_last_dot_suffix ... ok
test the_compiled_skip_list_prunes_a_matching_directory ... ok
test the_no_content_case_reports_a_binary_file_and_omits_an_empty_one ... ok
test verbose_lists_the_options_ahead_of_the_search_output ... ok
test each_root_is_traversed_in_the_order_given ... ok
test stdout_is_terminated_with_lf_on_every_platform ... ok
test a_run_that_traverses_nothing_writes_no_summary ... ok
test every_parse_failure_reaches_stderr_with_exit_code_one ... ok

test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.84s

=== integration suite integration returned 0
Two tests carry the seven usage diagnostics. every_parse_failure_reaches_stderr_with_exit_code_one checks the six parse produces three ways each - the diagnostic text, the exit code, and that stdout stayed empty - through one expect_usage_failure helper. The third of those three is the one a reader might not expect, and it is where the specification is strictest. a_malformed_expression_prints_the_listing_to_stdout_then_the_diagnostic is the seventh, and it is the one refusal that leaves the option listing on stdout first. Four tests cover ordering and terminator rules that no unit suite can reach: that the /v listing precedes the search output, that help is taken before any traversal, that stdout carries no CR on any platform, and that a CRLF file yields lines free of the carriage return. The last of those is the pair to the third: one asserts the program does not add a CR, the other that it removes one the file carried. The suite compares expected stdout against help_text() and usage_line() imported from rust_textfinder_cmdline rather than against string literals of its own, so the fixture and the source cannot drift. It then checks the two properties a literal would have caught anyway - that the help text begins with usage: rust_textfinder [ and ends with searching.\n - so a library that returned the wrong text consistently still fails.

6.  What the Suites Do Not Reach

Every suite passes, and four claims in the specifications are unchecked. Naming them is worth more than the count above, since §6.2 asks the suites to check a claim rather than let it be read.
  • The unrenderable name. §3.4 settles what happens to a file whose name the implementation cannot render as text, and Spec_Rust_TextFinder_Dirnav.md §5 rule 6 places the gate. No fixture creates such a name: it means an unpaired surrogate on Windows or a non-UTF-8 byte sequence on POSIX, and neither TempTree builds one. The C++ suite reports the same gap, so the rule that cost two implementations a code change is tested in neither.
  • The failed write. Nothing breaks stdout mid-run, so the output failed notice, the permanence of the failed state, and the rule that one notice is written rather than one per discarded line are all read rather than checked.
  • Exit code 2. Both of its paths are unreachable from a test. The unit suite confirms that a second StdoutSink::new returns None, but no run of the binary can provoke it, since the binary builds one sink. An undecodable argument cannot be produced on Windows at all, where every argument arrives as UTF-16.
  • The POSIX side of everything. The capture above is a Windows run. same_name folds case under #[cfg(windows)] and compares exactly otherwise, and only the first branch is exercised here.
Three of the four are properties of failure paths, which is the ordinary shape of a coverage gap. The fourth is a platform, and it is the one a second machine would close without a line of new test code.

7.  Prompt Records

This page carries none. Page_Structure.md §8 assigns it a Prompts_Rust_Spec_driven_TextFinder_Tests.md, and the turn that wrote the four suites and the three runners produced no record. The Process page says what follows from that for the thread as a whole.