From 9f7c64690a8c11be4cc2c152170ceb0e3a405dcb Mon Sep 17 00:00:00 2001 From: Vlad Lasky Date: Wed, 29 Jul 2026 20:03:38 +1000 Subject: [PATCH] feat: add --must-match to exit non-zero when nothing matched sd exits 0 whether or not the pattern matched, mirroring sed. That makes a typo in the pattern indistinguishable from a file that needed no changes, which is a problem for scripts and CI where a vacuous edit passes silently. Add an opt-in -m/--must-match flag that exits 2 when the pattern matched none of the input. The default is unchanged, per the discussion in #157. Errors keep status 1 so a caller can tell "found nothing" apart from "could not run", and the status covers the run as a whole, so one matching input out of several is enough to succeed. process_sources and its helpers now return whether anything matched, taken from the Cow variant that replace already returns, so no extra scan is needed. The exit-status policy stays in the CLI. Closes #157 --- README.md | 28 ++++++++ gen/completions/_sd | 2 + gen/completions/_sd.ps1 | 2 + gen/completions/sd.bash | 2 +- gen/completions/sd.elv | 2 + gen/completions/sd.fish | 1 + gen/sd.1 | 7 +- sd-cli/src/cli.rs | 6 ++ sd-cli/src/main.rs | 27 +++++-- sd-cli/tests/cli.rs | 151 ++++++++++++++++++++++++++++++++++++++-- sd/src/lib.rs | 124 +++++++++++++++++++++++++++++---- sd/src/replacer/mod.rs | 5 ++ xtask/src/generate.rs | 4 ++ 13 files changed, 335 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 220d7cd..4868995 100644 --- a/README.md +++ b/README.md @@ -221,3 +221,31 @@ To escape the `$` character, use `$$`: ❯ echo "foo" | sd 'foo' '$$bar' $bar ``` + +### Requiring a match + +Like `sed`, sd exits successfully whether or not the pattern matched anything, so +a typo in the pattern looks the same as a file that needed no changes: + +```bash +❯ echo "foo" | sd 'typo' 'bar' +foo +❯ echo $? +0 +``` + +Pass `-m` / `--must-match` to exit with status 2 when the pattern matched none of +the input. This is useful in scripts and CI, where a silently vacuous edit is +worse than a loud failure: + +```bash +❯ echo "foo" | sd -m 'typo' 'bar' +foo +❯ echo $? +2 +``` + +The flag only changes the exit status. Inputs that did match are replaced either +way, and the status refers to the run as a whole, so one matching file out of +several is enough to succeed. Errors keep their own status of 1, so a script can +tell "the pattern found nothing" apart from "sd could not do its job". diff --git a/gen/completions/_sd b/gen/completions/_sd index 3cdab05..5613ce0 100644 --- a/gen/completions/_sd +++ b/gen/completions/_sd @@ -25,6 +25,8 @@ _sd() { '--fixed-strings[Treat FIND and REPLACE_WITH args as literal strings]' \ '-A[Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming]' \ '--across[Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming]' \ +'-m[Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way]' \ +'--must-match[Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way]' \ '-h[Print help (see more with '\''--help'\'')]' \ '--help[Print help (see more with '\''--help'\'')]' \ '-V[Print version]' \ diff --git a/gen/completions/_sd.ps1 b/gen/completions/_sd.ps1 index 5e21729..769ec9b 100644 --- a/gen/completions/_sd.ps1 +++ b/gen/completions/_sd.ps1 @@ -31,6 +31,8 @@ Register-ArgumentCompleter -Native -CommandName 'sd' -ScriptBlock { [CompletionResult]::new('--fixed-strings', 'fixed-strings', [CompletionResultType]::ParameterName, 'Treat FIND and REPLACE_WITH args as literal strings') [CompletionResult]::new('-A', 'A ', [CompletionResultType]::ParameterName, 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming') [CompletionResult]::new('--across', 'across', [CompletionResultType]::ParameterName, 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming') + [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way') + [CompletionResult]::new('--must-match', 'must-match', [CompletionResultType]::ParameterName, 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way') [CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') [CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') [CompletionResult]::new('-V', 'V ', [CompletionResultType]::ParameterName, 'Print version') diff --git a/gen/completions/sd.bash b/gen/completions/sd.bash index 60584b5..c35d17f 100644 --- a/gen/completions/sd.bash +++ b/gen/completions/sd.bash @@ -19,7 +19,7 @@ _sd() { case "${cmd}" in sd) - opts="-p -F -n -f -A -h -V --preview --fixed-strings --max-replacements --flags --across --help --version [FILES]..." + opts="-p -F -n -f -A -m -h -V --preview --fixed-strings --max-replacements --flags --across --must-match --help --version [FILES]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/gen/completions/sd.elv b/gen/completions/sd.elv index 69b8a31..f71ddd2 100644 --- a/gen/completions/sd.elv +++ b/gen/completions/sd.elv @@ -28,6 +28,8 @@ set edit:completion:arg-completer[sd] = {|@words| cand --fixed-strings 'Treat FIND and REPLACE_WITH args as literal strings' cand -A 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming' cand --across 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming' + cand -m 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way' + cand --must-match 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way' cand -h 'Print help (see more with ''--help'')' cand --help 'Print help (see more with ''--help'')' cand -V 'Print version' diff --git a/gen/completions/sd.fish b/gen/completions/sd.fish index 1860893..e9349b3 100644 --- a/gen/completions/sd.fish +++ b/gen/completions/sd.fish @@ -3,5 +3,6 @@ complete -c sd -s f -l flags -d 'Regex flags. May be combined (like `-f mc`).' - complete -c sd -s p -l preview -d 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)' complete -c sd -s F -l fixed-strings -d 'Treat FIND and REPLACE_WITH args as literal strings' complete -c sd -s A -l across -d 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming' +complete -c sd -s m -l must-match -d 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way' complete -c sd -s h -l help -d 'Print help (see more with \'--help\')' complete -c sd -s V -l version -d 'Print version' diff --git a/gen/sd.1 b/gen/sd.1 index 9a41984..8828cbf 100644 --- a/gen/sd.1 +++ b/gen/sd.1 @@ -8,7 +8,7 @@ sd .ie \n(.g .ds Aq \(aq .el .ds Aq ' .SH SYNOPSIS -\fBsd\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-F\fR|\fB\-\-fixed\-strings\fR] [\fB\-n\fR|\fB\-\-max\-replacements\fR] [\fB\-f\fR|\fB\-\-flags\fR] [\fB\-A\fR|\fB\-\-across\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] <\fIFIND\fR> <\fIREPLACE_WITH\fR> [\fIFILES\fR] +\fBsd\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-F\fR|\fB\-\-fixed\-strings\fR] [\fB\-n\fR|\fB\-\-max\-replacements\fR] [\fB\-f\fR|\fB\-\-flags\fR] [\fB\-A\fR|\fB\-\-across\fR] [\fB\-m\fR|\fB\-\-must\-match\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] <\fIFIND\fR> <\fIREPLACE_WITH\fR> [\fIFILES\fR] .ie \n(.g .ds Aq \(aq .el .ds Aq ' .SH DESCRIPTION @@ -43,6 +43,9 @@ w \- match full words only \fB\-A\fR, \fB\-\-across\fR Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming .TP +\fB\-m\fR, \fB\-\-must\-match\fR +Exit with a non\-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way +.TP \fB\-h\fR, \fB\-\-help\fR Print help (see a summary with \*(Aq\-h\*(Aq) .TP @@ -66,6 +69,8 @@ Note: sd modifies files in\-place by default. See documentation for examples. Successful program execution. .IP 1 Unsuccessful program execution. +.IP 2 +The pattern matched no input, and \-\-must\-match was given. .IP 101 The program panicked. .ie \n(.g .ds Aq \(aq diff --git a/sd-cli/src/cli.rs b/sd-cli/src/cli.rs index 73de8fe..d28aebf 100644 --- a/sd-cli/src/cli.rs +++ b/sd-cli/src/cli.rs @@ -63,6 +63,12 @@ w - match full words only /// prevents streaming. pub across: bool, + #[arg(short = 'm', long = "must-match")] + /// Exit with a non-zero status if the pattern matched no input. Without + /// this flag sd exits 0 whether or not anything was replaced, which + /// matches sed. Inputs that did match are still replaced either way. + pub must_match: bool, + /// The regexp or string (if using `-F`) to search for. pub find: String, diff --git a/sd-cli/src/main.rs b/sd-cli/src/main.rs index a8c2812..0da70b1 100644 --- a/sd-cli/src/main.rs +++ b/sd-cli/src/main.rs @@ -5,14 +5,25 @@ use std::{io::stdout, process}; use sd::{Replacer, Result, Source, process_sources}; +/// Exit status for `--must-match` when the pattern matched no input. +/// +/// Distinct from the status used for errors, so that a script can tell "your +/// pattern found nothing" apart from "sd could not do its job". +const NO_MATCH_EXIT_CODE: i32 = 2; + fn main() { - if let Err(e) = try_main() { - eprintln!("error: {e}"); - process::exit(1); + match try_main() { + Ok(true) => {} + Ok(false) => process::exit(NO_MATCH_EXIT_CODE), + Err(e) => { + eprintln!("error: {e}"); + process::exit(1); + } } } -fn try_main() -> Result<()> { +/// Returns whether the run satisfied `--must-match`; always true without it. +fn try_main() -> Result { let options = cli::Options::parse(); let replacer = Replacer::new( @@ -31,11 +42,15 @@ fn try_main() -> Result<()> { let sources = sources?; let mut handle = stdout().lock(); - process_sources( + let matched = process_sources( &replacer, &sources, options.preview, !options.across, &mut handle, - ) + )?; + + // Any replacements have already been written by this point. --must-match + // reports that nothing matched, it does not make the run conditional. + Ok(matched || !options.must_match) } diff --git a/sd-cli/tests/cli.rs b/sd-cli/tests/cli.rs index 07e3403..9d5d3dd 100644 --- a/sd-cli/tests/cli.rs +++ b/sd-cli/tests/cli.rs @@ -340,6 +340,150 @@ mod cli { Ok(()) } + /// Exit status used by `--must-match` when nothing matched. + const NO_MATCH: i32 = 2; + + #[test] + fn no_match_succeeds_by_default() -> Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(b"abc\n")?; + let path = file.into_temp_path(); + + sd().args(["nowhere", "here", path.to_str().unwrap()]) + .assert() + .success(); + assert_file(&path, "abc\n"); + + sd().args(["nowhere", "here"]) + .write_stdin("abc\n") + .assert() + .success() + .stdout("abc\n"); + + Ok(()) + } + + #[test] + fn must_match_fails_when_nothing_matched() -> Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(b"abc\n")?; + let path = file.into_temp_path(); + + sd().args(["-m", "nowhere", "here", path.to_str().unwrap()]) + .assert() + .code(NO_MATCH); + // The exit status reports the outcome, it does not roll anything back, + // and with no match there was nothing to write anyway. + assert_file(&path, "abc\n"); + + Ok(()) + } + + #[test] + fn must_match_succeeds_when_something_matched() -> Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(b"abc\n")?; + let path = file.into_temp_path(); + + sd().args(["--must-match", "abc", "xyz", path.to_str().unwrap()]) + .assert() + .success(); + assert_file(&path, "xyz\n"); + + Ok(()) + } + + #[test] + fn must_match_stdin() { + sd().args(["-m", "foo", "bar"]) + .write_stdin("foo\n") + .assert() + .success() + .stdout("bar\n"); + + sd().args(["-m", "foo", "bar"]) + .write_stdin("baz\n") + .assert() + .code(NO_MATCH) + .stdout("baz\n"); + } + + #[test] + fn must_match_preview() { + sd().args(["-m", "-p", "foo", "bar"]) + .write_stdin("baz\n") + .assert() + .code(NO_MATCH); + } + + #[test] + fn must_match_across() -> Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(b"a\nb\n")?; + let path = file.into_temp_path(); + + sd().args(["-m", "-A", r"a\nb", "X", path.to_str().unwrap()]) + .assert() + .success(); + assert_file(&path, "X\n"); + + sd().args(["-m", "-A", "nowhere", "X", path.to_str().unwrap()]) + .assert() + .code(NO_MATCH); + + Ok(()) + } + + /// The status describes the run as a whole, so one matching input is enough. + #[test] + fn must_match_is_satisfied_by_any_input() -> Result<()> { + let dir = tempfile::tempdir()?; + let hit = dir.path().join("hit.txt"); + let miss = dir.path().join("miss.txt"); + fs::write(&hit, "abc\n")?; + fs::write(&miss, "xyz\n")?; + + sd().args([ + "-m", + "abc", + "def", + hit.to_str().unwrap(), + miss.to_str().unwrap(), + ]) + .assert() + .success(); + assert_file(&hit, "def\n"); + assert_file(&miss, "xyz\n"); + + fs::write(&hit, "xyz\n")?; + sd().args([ + "-m", + "abc", + "def", + hit.to_str().unwrap(), + miss.to_str().unwrap(), + ]) + .assert() + .code(NO_MATCH); + + Ok(()) + } + + /// Errors keep their own status, so a script can tell "found nothing" apart + /// from "could not run". + #[test] + fn must_match_does_not_mask_errors() -> Result<()> { + let dir = tempfile::tempdir()?; + let missing = dir.path().join("missing.txt"); + + sd().args(["-m", "abc", "def", missing.to_str().unwrap()]) + .assert() + .failure() + .code(1); + + Ok(()) + } + #[cfg(unix)] mod unix_only { use super::*; @@ -425,10 +569,9 @@ mod cli { assert_eq!(fs::read_to_string(&unwritable_dir_file1)?, ORIG_TEXT); assert_eq!(fs::read_to_string(&unwritable_dir_file2)?, ORIG_TEXT); - let stderr_orig = std::str::from_utf8( - &failed_command.get_output().stderr, - ) - .unwrap(); + let stderr_orig = + std::str::from_utf8(&failed_command.get_output().stderr) + .unwrap(); // Normalize unstable path bits let stderr_partial_norm = stderr_orig .replace(test_home.to_str().unwrap(), "") diff --git a/sd/src/lib.rs b/sd/src/lib.rs index 4f4d3b8..8854f17 100644 --- a/sd/src/lib.rs +++ b/sd/src/lib.rs @@ -4,6 +4,7 @@ pub mod replacer; mod unescape; use std::{ + borrow::Cow, fs, io::{BufRead, BufWriter, Read, Write}, path::PathBuf, @@ -14,13 +15,17 @@ pub use self::input::{Source, open_source, read_source}; pub use self::replacer::Replacer; /// Core processing function that handles file replacement +/// +/// Returns whether the pattern matched any of the inputs, so that callers can +/// implement `--must-match`. Reporting it rather than acting on it keeps the +/// exit-status policy in the CLI. pub fn process_sources( replacer: &Replacer, sources: &[Source], preview: bool, line_by_line: bool, output_writer: &mut dyn Write, -) -> Result<()> { +) -> Result { if line_by_line { return process_sources_line_by_line( replacer, @@ -56,6 +61,10 @@ pub fn process_sources( .collect() }; + // `replace` only allocates when the pattern matched, so the `Cow` variant + // already tells us whether anything changed. + let matched = replaced.iter().any(|r| matches!(r, Cow::Owned(_))); + if preview || sources.first() == Some(&Source::Stdin) { for (source, replaced) in sources.iter().zip(replaced) { if needs_separator { @@ -80,7 +89,7 @@ pub fn process_sources( } } - Ok(()) + Ok(matched) } fn process_sources_line_by_line( @@ -88,8 +97,9 @@ fn process_sources_line_by_line( sources: &[Source], preview: bool, output_writer: &mut dyn Write, -) -> Result<()> { +) -> Result { let needs_separator = sources.len() > 1; + let mut matched = false; if preview || sources.first() == Some(&Source::Stdin) { for source in sources { @@ -97,7 +107,8 @@ fn process_sources_line_by_line( writeln!(output_writer, "----- {} -----", source.display())?; } let reader = open_source(source)?; - process_reader_line_by_line(replacer, reader, output_writer)?; + matched |= + process_reader_line_by_line(replacer, reader, output_writer)?; } } else { // Pre-validate all files before modifying any, matching the @@ -118,8 +129,9 @@ fn process_sources_line_by_line( for source in sources { match source { Source::File(path) => { - if let Err(e) = write_file_line_by_line(replacer, path) { - failed_jobs.push((path.to_owned(), e)); + match write_file_line_by_line(replacer, path) { + Ok(file_matched) => matched |= file_matched, + Err(e) => failed_jobs.push((path.to_owned(), e)), } } _ => unreachable!("stdin should go previous branch"), @@ -130,18 +142,19 @@ fn process_sources_line_by_line( } } - Ok(()) + Ok(matched) } fn process_reader_line_by_line( replacer: &Replacer, mut reader: Box, writer: &mut dyn Write, -) -> Result<()> { +) -> Result { const CHUNK_SIZE: usize = 8192; let mut chunk = vec![0u8; CHUNK_SIZE]; let mut line = Vec::with_capacity(256); + let mut matched = false; loop { let n = reader.read(&mut chunk)?; @@ -149,6 +162,7 @@ fn process_reader_line_by_line( // Finish any remaining line if !line.is_empty() { let replaced = replacer.replace(&line); + matched |= matches!(replaced, Cow::Owned(_)); writer.write_all(&replaced)?; } break; @@ -160,6 +174,7 @@ fn process_reader_line_by_line( // Found a complete line line.extend_from_slice(&chunk[start..i]); let replaced = replacer.replace(&line); + matched |= matches!(replaced, Cow::Owned(_)); writer.write_all(&replaced)?; writer.write_all(b"\n")?; line.clear(); @@ -173,10 +188,13 @@ fn process_reader_line_by_line( } } - Ok(()) + Ok(matched) } -fn write_file_line_by_line(replacer: &Replacer, path: &PathBuf) -> Result<()> { +fn write_file_line_by_line( + replacer: &Replacer, + path: &PathBuf, +) -> Result { let canonical = fs::canonicalize(path)?; let temp = tempfile::NamedTempFile::new_in( @@ -189,17 +207,19 @@ fn write_file_line_by_line(replacer: &Replacer, path: &PathBuf) -> Result<()> { temp.as_file().set_permissions(metadata.permissions()).ok(); } - { + let matched = { let source = Source::File(path.clone()); let reader = open_source(&source)?; let mut writer = BufWriter::new(temp.as_file()); - process_reader_line_by_line(replacer, reader, &mut writer)?; + let matched = + process_reader_line_by_line(replacer, reader, &mut writer)?; writer.flush()?; - } + matched + }; temp.persist(&canonical)?; - Ok(()) + Ok(matched) } fn write_with_temp(path: &PathBuf, data: &[u8]) -> Result<()> { @@ -377,6 +397,82 @@ mod tests { Ok(()) } + #[test] + fn test_process_sources_reports_match() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "abc123def").unwrap(); + + let replacer = + Replacer::new("abc".into(), "xyz".into(), false, None, 0)?; + let sources = vec![Source::File(file_path)]; + + for line_by_line in [false, true] { + let mut output = Vec::new(); + assert!(process_sources( + &replacer, + &sources, + true, + line_by_line, + &mut output + )?); + } + + Ok(()) + } + + #[test] + fn test_process_sources_reports_no_match() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "abc123def").unwrap(); + + let replacer = + Replacer::new("nowhere".into(), "xyz".into(), false, None, 0)?; + let sources = vec![Source::File(file_path)]; + + for line_by_line in [false, true] { + let mut output = Vec::new(); + assert!(!process_sources( + &replacer, + &sources, + true, + line_by_line, + &mut output + )?); + } + + Ok(()) + } + + /// A match in any input counts, whichever input it was. + #[test] + fn test_process_sources_reports_match_in_later_source() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let miss = temp_dir.path().join("miss.txt"); + let hit = temp_dir.path().join("hit.txt"); + std::fs::write(&miss, "xyz\n").unwrap(); + std::fs::write(&hit, "abc\n").unwrap(); + + let replacer = + Replacer::new("abc".into(), "def".into(), false, None, 0)?; + let sources = + vec![Source::File(miss.clone()), Source::File(hit.clone())]; + + for line_by_line in [false, true] { + let mut output = Vec::new(); + assert!(process_sources( + &replacer, + &sources, + true, + line_by_line, + &mut output + )?); + } + + Ok(()) + } + #[test] fn test_line_by_line_whitespace_trim() -> Result<()> { let temp_dir = TempDir::new().unwrap(); diff --git a/sd/src/replacer/mod.rs b/sd/src/replacer/mod.rs index 2f04311..d3baf14 100644 --- a/sd/src/replacer/mod.rs +++ b/sd/src/replacer/mod.rs @@ -69,6 +69,11 @@ impl Replacer { }) } + /// Replaces every match in `content`, up to the configured limit. + /// + /// Returns [`Cow::Borrowed`] when the pattern did not match and + /// [`Cow::Owned`] otherwise, so a caller can tell a no-op apart from a + /// replacement without scanning the input a second time. pub fn replace<'a>(&'a self, content: &'a [u8]) -> Cow<'a, [u8]> { let regex = &self.regex; let limit = self.replacements; diff --git a/xtask/src/generate.rs b/xtask/src/generate.rs index f91f803..9e19423 100644 --- a/xtask/src/generate.rs +++ b/xtask/src/generate.rs @@ -45,6 +45,10 @@ fn gen_man(base_dir: &Path) { let statuses = [ ("0", "Successful program execution."), ("1", "Unsuccessful program execution."), + ( + "2", + "The pattern matched no input, and --must-match was given.", + ), ("101", "The program panicked."), ]; let mut sect = Roff::new();