Spec-Driven

Spec-Driven Rust: Cmdline

an argument slice to ProgramCommands, with the defaults in one Default impl

Synopsis:
This page covers rust_textfinder_cmdline, the single place in the Rust implementation where switch letters, argument syntax, and defaults are known.
  • One struct and four functions. parse returns Result, whose Err carries the complete diagnostic, so the caller's error path is one print and a return.
  • It takes a slice the binary already decoded, so it never sees an undecodable argument and defines no behavior for one - which is why its 29 tests run without spawning a process.
The Default implementation is the sole authority in code for the nine defaults.
  • #[derive(Default)] would not do, since four fields need a value the derive cannot produce.
  • The unit suite asserts each default against the specification separately, rather than asserting the struct equals its own Default - which would compare the code against itself.
Several rules here are written against a plausible wrong call rather than from scratch.
  • switch_letter walks a Chars iterator, because len() counts bytes and a two-character token holding one non-ASCII character would be refused by the wrong route.
  • str::trim is rejected for trimming too much, and char::is_ascii_whitespace for trimming one character too little - the omission that sent the whitespace rule up to the parent specification.
  • The exhaustive match carries an arm no input reaches, which the record marks as the compiler's requirement rather than a second validation.

1.  Public Interface

rust_textfinder_cmdline converts a slice of argument strings into a struct that controls the other two libraries. It is the single place in the Rust implementation where switch letters, argument syntax, and defaults are known, and it performs no traversal, no matching, no file I/O, and no stream writing. The crate root lib.rs exports one struct and four functions.
pub struct ProgramCommands {
    pub root_paths: Vec<String>,    // /P
    pub extensions: Vec<String>,    // /p
    pub regex_text: String,         // /r
    pub recurse: bool,              // /s
    pub suppress_on_no_match: bool, // /h
    pub verbose: bool,              // /v
    pub help: bool,                 // /H
    pub line_numbers: bool,         // /n
    pub matched_line: bool,         // /L
}

pub fn parse(args: &[String]) -> Result<ProgramCommands, String>;
pub fn usage_line() -> String;
pub fn help_text() -> String;
pub fn options_text(commands: &ProgramCommands) -> String;
parse returns Result because rust_textfinder_entry needs two things from a failure: the signal, and the text to write. The Err variant carries the complete diagnostic, so the caller's error path is one eprint! and a return rather than a lookup and a format. parse takes &[String] rather than reading the arguments itself. The binary collects them and decodes them, per Spec_Rust_TextFinder_Entry.md §4 step 1, so this library never sees an undecodable argument and defines no behavior for one. The unit suite hands it hand-built slices as a consequence, which is why 29 tests run in under a millisecond and none of them spawns a process. ProgramCommands derives Clone and Debug and has no invariants: every field combination the parser can produce is valid. Dirnav borrows the value rust_textfinder_entry owns rather than cloning it, so that value must outlive the Dirnav it was passed to. The borrow checker enforces that rule, where the C++ implementation states the same lifetime requirement in prose and relies on the caller. The field names are the switch meanings spelled out rather than the switch letters. suppress_on_no_match for /h is the one worth pausing on: it names what the flag does rather than what it is called, so the gating check in rust_textfinder_dirnav reads as a sentence.

2.  Defaults Live in the Default Impl

The field comments are the switch-to-field mapping, and the Default implementation is the sole authority in code for the defaults of Spec_TextFinder.md §5.
impl Default for ProgramCommands {
    fn default() -> Self {
        ProgramCommands {
            root_paths: vec![String::from(".")],
            extensions: Vec::new(),
            regex_text: String::from("."),
            recurse: true,
            suppress_on_no_match: true,
            verbose: false,
            help: false,
            line_numbers: false,
            matched_line: false,
        }
    }
}
ProgramCommands::default() equals the result of parsing an empty command line, because parse starts from it and overwrites only what the arguments name. That is what makes step 5 of the Entry startup sequence a one-liner: the bare command line prints the listing of a default-constructed value. #[derive(Default)] would not do. Three of the nine fields need a value the derive cannot produce - . for root_paths, . for regex_text, and true for recurse and suppress_on_no_match - so the implementation is written out, and every default §5 fixes appears once, in table order, in one block a reader can compare against the specification line by line. The wording in the specification is load-bearing: this is the sole authority in code. Spec_TextFinder.md §5 remains the authority overall, and a disagreement between the two is a defect in this crate. The unit suite asserts each of the nine against §5 separately rather than asserting the struct equals its own Default, which would compare the code against itself.

3.  Parsing Rules

parse scans args[1..] left to right, alternating switch token and argument token; args[0] is the program name and is not inspected. It stops at the first violation and returns that diagnostic; no partial result is produced. It touches no filesystem: root paths are not tested for existence, and extensions are not compared against any file. Four rules govern it.
  1. Switch tokens. A token in switch position is valid only when it is exactly two characters, the first / or -, the second one of the nine letters. A token with no introducer is not a switch; any other introducer-led token, including a bare / or -, is an unrecognized switch.
  2. Arguments. Each switch consumes the following token verbatim, including when that token begins with / or -, since there are no bare flags. A switch with no following token is missing its argument.
  3. Conversion. Boolean switches accept only true or false, compared with eq_ignore_ascii_case. /r and /P take the token verbatim and each rejects an empty argument. /p is normalized.
  4. Accumulation. /P clears the default vec![String::from(".")] on its first occurrence and appends thereafter, preserving argument order. Every other switch overwrites any earlier value, silently discarding it.
Rule 1 partitions the first two diagnostics of §5.2 rather than leaving them to overlap. Before the partition, /ss, -abc, and a bare / each satisfied both conditions, and two implementations could have reported different reason lines for the same token while both following the specification. switch_letter reads the partition directly: it takes the first character from an iterator and returns not a switch unless that character is an introducer, then takes two more and returns unrecognized switch unless the second is one of the nine letters and the third does not exist. Walking a Chars iterator rather than indexing is what keeps the length test correct for a multi-byte token. token.len() counts bytes, so a two-character token holding one non-ASCII character reports 3 or more and would be refused as over-long rather than as an unrecognized switch - the same outcome by the wrong route. An earlier draft of the specification defended counting characters on the ground that the two differ, and the review struck that sentence: both routes produce unrecognized switch for every input, so the claim did not hold even though the code it defended is the one to write. Rule 4's first-occurrence-clears behavior needs one bit of state, roots_supplied, and the code carries it as a local rather than inferring it from the vector's contents. Inferring would work until a user typed -P ., which is indistinguishable from the default by value. The match over the switch letter carries a _ arm returning unrecognized switch that no input reaches, since switch_letter has already refused every letter outside the nine. Rust requires a match over char to be exhaustive, so the arm is the compiler's requirement rather than a second validation, and it returns the diagnostic the token would have drawn had the earlier test not caught it. Every violation returns the complete usage diagnostic Spec_TextFinder.md §5.2 binds - its reason line, a newline, then usage_line() - built by one three-line diagnostic helper, so the shape has one definition. Six of the seven rows are this library's. The seventh, a malformed /r, is detected later: Dirnav::new compiles the expression and returns its failure, and the binary composes the diagnostic. Neither library composes it - the one supplies a string, the other reports the failure, and the binary joins them. Spec_Rust_TextFinder_Cmdline.md §6 carries the six reason lines and owns them. Spec_TextFinder.md §2 leaves the wording of anything reaching stderr to each language, and this implementation adopts §5.2's supplied wording unchanged, which costs nothing and leaves its stderr comparable with the C++ implementation's. There is no error condition for a duplicated switch or an empty /p list. Duplicates resolve by rule 4, and an empty extension list means every file is searched.

4.  Extension-List Normalization

The /p argument arrives as one token, the shell having already removed the quotes. Normalization implements the /p rules of Spec_TextFinder.md §5 as one iterator chain: split on commas, trim each item, strip one leading . if present, discard empty items, and collect the survivors in order.
/// The six characters Spec_TextFinder.md section 5 names, and no others.
fn is_trimmed(c: char) -> bool {
    matches!(c, ' ' | '\t' | '\n' | '\u{000B}' | '\u{000C}' | '\r')
}

fn normalize_extensions(argument: &str) -> Vec<String> {
    argument
        .split(',')
        .map(|item| item.trim_matches(is_trimmed))
        .map(|item| item.strip_prefix('.').unwrap_or(item))
        .filter(|item| !item.is_empty())
        .map(String::from)
        .collect()
}
" .rs , , txt " therefore normalizes to rs, txt, which is the pair the option listing reports. Two standard-library calls are rejected by name, and each stops an implementer writing something plausible and wrong.
  • str::trim is not used. It trims every character Unicode calls whitespace, a set including no-break space and the en and em spaces, so this implementation would accept an extension list another rejects.
  • char::is_ascii_whitespace is not used either. It omits vertical tab, which §5 names. That omission is the one-character difference that sent the rule up to the parent specification: C's isspace in the C locale includes U+000B, so /p "cpp,\vrs" would have normalized differently in the two implementations. §5 now names the six characters, and all three implementations test against that list and nothing else.
strip_prefix('.') removes the dot, and one call removes at most one, which is the rule §5 states. unwrap_or(item) supplies the unstripped item when there was no dot, so the chain needs no branch. Duplicates are retained. They are harmless to the membership test rust_textfinder_dirnav performs, and the specification says so rather than leaving a reader to wonder whether the omission was an oversight. Case folding is not applied here: the platform-dependent comparison §5 fixes is performed by rust_textfinder_dirnav when it matches a file name against the list, because that is where the platform question arises.

5.  Help Text and Option Listing

Three functions render text the binary writes, and none of them writes it.
  • help_text() returns the text Spec_TextFinder.md §5.1 fixes with <executable> replaced by rust_textfinder. The body is a raw string literal, r#"..."#, so the fixture and the source agree by construction and the backslashes and quotes inside it need no escaping.
  • usage_line() returns its first line - the line that terminates every usage diagnostic - and help_text() is built from it, so the synopsis has one definition.
  • options_text(commands) returns the resolved option set in the form §5.3 fixes.
One function serves all three cases §5.3 calls for, since the text is the same in each and only the caller's next move differs: /v true, after which traversal follows; the bare command line, after which the process exits 0; and the invalid-regex diagnostic, where the listing precedes that diagnostic whatever /v says and the process then exits 1. The listing reflects whatever commands holds, so its /v line reads false in the latter two unless /v was itself typed. The Rust specification adds one sentence about options_text that is worth more than it looks: it chooses none of that form and must not be read as the place the form is decided. A reader who wants to change the listing changes Spec_TextFinder.md §5.3. The listing's construction shows where the form is fixed and where it is not. The six boolean lines come from one loop over an array of switch-and-value pairs in §5 table order, so no line can be forgotten and none can be reordered without moving an array element. The /P, /p, and /r lines are written out, because each has a rule of its own: one line per root path, the extension list joined by ", " with /p alone when the list is empty, and the expression verbatim. The unit suite asserts that no line of the listing ends in whitespace, which is the rule that lets this text serve as a test fixture. All three functions end their returned string with a newline, and none writes to a stream. The library opens no stream at all, which is what lets its unit suite check the rendered text as a string rather than by capturing output.

6.  Source

lib.rs in full - 188 lines, of which the help body is 21. The comments cite the Rust specification, which cites Spec_TextFinder.md in turn.
Rust_Spec_driven_Cmdline/src/lib.rs
//! rust_textfinder_cmdline - command-line parsing for TextFinder.
//! Implements Spec_Rust_TextFinder_Cmdline.md, the Rust binding of Spec_TextFinder.md sections 4-5.

#[cfg(test)]
mod unit_tests;

#[derive(Clone, Debug)]
pub struct ProgramCommands {
    pub root_paths: Vec<String>,    // /P
    pub extensions: Vec<String>,    // /p
    pub regex_text: String,         // /r
    pub recurse: bool,              // /s
    pub suppress_on_no_match: bool, // /h
    pub verbose: bool,              // /v
    pub help: bool,                 // /H
    pub line_numbers: bool,         // /n
    pub matched_line: bool,         // /L
}

impl Default for ProgramCommands {
    fn default() -> Self {
        ProgramCommands {
            root_paths: vec![String::from(".")],
            extensions: Vec::new(),
            regex_text: String::from("."),
            recurse: true,
            suppress_on_no_match: true,
            verbose: false,
            help: false,
            line_numbers: false,
            matched_line: false,
        }
    }
}

const EXECUTABLE: &str = "rust_textfinder";

const SWITCH_LETTERS: [char; 9] = ['P', 'p', 'r', 's', 'h', 'v', 'H', 'n', 'L'];

const HELP_BODY: &str = r#"
  /P  path (.)             root path for traversal; repeat to add more root paths
  /p  "ext, ext" ()        comma-separated bare extensions to search; empty searches every file
  /r  regex (.)            regular expression evaluated against each line
  /s  true|false (true)    recurse into subdirectories
  /h  true|false (true)    hide files that matched nothing; errors always appear
  /v  true|false (false)   list the resolved option set before traversal
  /H  true|false (false)   print this help and exit
  /n  true|false (false)   add a detail line per match, carrying the line number
  /L  true|false (false)   add a detail line per match, carrying the line text

A matching file prints its path on one line; /n and /L add indented detail
lines beneath it. A path is never printed twice. A search ends with a line
counting the files and directories it reached.

Switch introducers / and - are equivalent. Switch letters are case-sensitive,
so /h and /H differ. Every switch takes exactly one argument; there are no bare
flags. Arguments containing whitespace or commas must be quoted.

Run with no switches at all to list the resolved options and exit without
searching.
"#;

pub fn usage_line() -> String {
    format!(
        "usage: {EXECUTABLE} [/P path] [/p \"ext, ext\"] [/r regex] [/s bool] [/h bool] [/v bool] [/H bool] [/n bool] [/L bool]\n"
    )
}

pub fn help_text() -> String {
    format!("{}{}", usage_line(), HELP_BODY)
}

pub fn options_text(commands: &ProgramCommands) -> String {
    let mut text = String::new();
    for root in &commands.root_paths {
        text.push_str("/P ");
        text.push_str(root);
        text.push('\n');
    }
    if commands.extensions.is_empty() {
        text.push_str("/p\n");
    } else {
        text.push_str("/p ");
        text.push_str(&commands.extensions.join(", "));
        text.push('\n');
    }
    text.push_str("/r ");
    text.push_str(&commands.regex_text);
    text.push('\n');
    for (switch, value) in [
        ("/s", commands.recurse),
        ("/h", commands.suppress_on_no_match),
        ("/v", commands.verbose),
        ("/H", commands.help),
        ("/n", commands.line_numbers),
        ("/L", commands.matched_line),
    ] {
        text.push_str(switch);
        text.push(' ');
        text.push_str(if value { "true" } else { "false" });
        text.push('\n');
    }
    text
}

pub fn parse(args: &[String]) -> Result<ProgramCommands, String> {
    let mut commands = ProgramCommands::default();
    let mut roots_supplied = false;
    let mut index = 1;

    while index < args.len() {
        let token = args[index].as_str();
        let letter = switch_letter(token)?;
        let value = match args.get(index + 1) {
            Some(next) => next.as_str(),
            None => return Err(diagnostic(&format!("missing argument for switch: {token}"))),
        };

        match letter {
            'P' => {
                if value.is_empty() {
                    return Err(diagnostic(&format!("empty root path for switch: {token}")));
                }
                if !roots_supplied {
                    commands.root_paths.clear();
                    roots_supplied = true;
                }
                commands.root_paths.push(String::from(value));
            }
            'p' => commands.extensions = normalize_extensions(value),
            'r' => {
                if value.is_empty() {
                    return Err(diagnostic(&format!("empty expression for switch: {token}")));
                }
                commands.regex_text = String::from(value);
            }
            's' => commands.recurse = boolean(token, value)?,
            'h' => commands.suppress_on_no_match = boolean(token, value)?,
            'v' => commands.verbose = boolean(token, value)?,
            'H' => commands.help = boolean(token, value)?,
            'n' => commands.line_numbers = boolean(token, value)?,
            'L' => commands.matched_line = boolean(token, value)?,
            _ => return Err(diagnostic(&format!("unrecognized switch: {token}"))),
        }
        index += 2;
    }
    Ok(commands)
}

fn switch_letter(token: &str) -> Result<char, String> {
    let mut chars = token.chars();
    match chars.next() {
        Some('/') | Some('-') => {}
        _ => return Err(diagnostic(&format!("not a switch: {token}"))),
    }
    match (chars.next(), chars.next()) {
        (Some(letter), None) if SWITCH_LETTERS.contains(&letter) => Ok(letter),
        _ => Err(diagnostic(&format!("unrecognized switch: {token}"))),
    }
}

fn boolean(switch: &str, value: &str) -> Result<bool, String> {
    if value.eq_ignore_ascii_case("true") {
        Ok(true)
    } else if value.eq_ignore_ascii_case("false") {
        Ok(false)
    } else {
        Err(diagnostic(&format!("invalid boolean for {switch}: {value}")))
    }
}

fn diagnostic(reason: &str) -> String {
    format!("{reason}\n{}", usage_line())
}

/// The six characters Spec_TextFinder.md section 5 names, and no others.
fn is_trimmed(c: char) -> bool {
    matches!(c, ' ' | '\t' | '\n' | '\u{000B}' | '\u{000C}' | '\r')
}

fn normalize_extensions(argument: &str) -> Vec<String> {
    argument
        .split(',')
        .map(|item| item.trim_matches(is_trimmed))
        .map(|item| item.strip_prefix('.').unwrap_or(item))
        .filter(|item| !item.is_empty())
        .map(String::from)
        .collect()
}
#[cfg(test)] mod unit_tests; at the top is how the 29 assertions of the Testing page reach the private items beside them. The module compiles only under cargo test, so nothing of the suite reaches the shipped library.

7.  Prompt Records

This page carries none. Page_Structure.md §8 assigns it Prompts_Spec_Rust_TextFinder_Cmdline.md and its Fix companion, and neither was written: the Cmdline specification was produced in the same turn as the other four documents, recorded at the project level. The two audit items that reach this component - the struck char-counting claim and the whitespace set - sit on the Process page.