Skip to main content

markdown_prose_hooks/
cli.rs

1//! The command line: argument parsing, file walking, reporting, exit codes.
2//!
3//! The parity boundary is exit codes and stdout, byte for byte; stderr need only
4//! match in meaning, which is why the error prose here is not a translation of
5//! Python's. `corpus/cli/` judges all three.
6//!
7//! The parser reproduces argparse's contract rather than inventing one, and
8//! every behavior below was measured against CPython rather than recalled:
9//! unambiguous long-option abbreviation, `--opt=value`, `--` ending the option
10//! list without breaking the positional run, a lone `-` as a positional, and a
11//! token starting with `-` being a positional when it looks like a negative
12//! number or contains a space.
13//!
14//! One argparse behavior is deliberately *not* reproduced exactly. See
15//! [`is_negative_number`].
16
17use std::fmt::Write as _;
18use std::fs;
19use std::io::ErrorKind;
20use std::path::{Path, PathBuf};
21
22use crate::ignore::{IGNORE_FILE_NAME, IgnoreRules};
23use crate::scan::{py_splitlines_keepends, py_trim, split_eol};
24use crate::transcript::is_transcript_like_markdown;
25use crate::unwrap_markdown_prose;
26
27/// What one run of the program produced.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Outcome {
30    /// Bytes for stdout. Compared byte for byte against the other implementation.
31    pub stdout: String,
32    /// Bytes for stderr. Compared only in meaning.
33    pub stderr: String,
34    /// The process exit status.
35    pub code: u8,
36}
37
38/// The parsed command line.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct Args {
41    /// Markdown files to inspect.
42    pub paths: Vec<String>,
43    /// Read additional newline-delimited paths from this file.
44    pub files_from: Option<String>,
45    /// Rewrite files in place instead of only reporting.
46    pub write: bool,
47    /// Emit a machine-readable summary.
48    pub json: bool,
49    /// Exit non-zero when any file changed or would change.
50    pub fail_on_change: bool,
51    /// Read ignore patterns from here instead of `./.unwrapignore`.
52    pub ignore_file: Option<String>,
53    /// Skip paths matching these globs.
54    pub exclude: Vec<String>,
55}
56
57/// What a flag does with the token after it.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum Takes {
60    Nothing,
61    OneValue,
62}
63
64/// Every long option, in the order `--help` would list them.
65const OPTIONS: [(&str, Takes); 7] = [
66    ("--help", Takes::Nothing),
67    ("--files-from", Takes::OneValue),
68    ("--write", Takes::Nothing),
69    ("--json", Takes::Nothing),
70    ("--fail-on-change", Takes::Nothing),
71    ("--ignore-file", Takes::OneValue),
72    ("--exclude", Takes::OneValue),
73];
74
75/// One report line per file the tool actually opened.
76#[derive(Debug, Clone, PartialEq, Eq)]
77struct FileReport {
78    path: String,
79    changed: bool,
80    paragraphs_unwrapped: usize,
81    line_breaks_removed: usize,
82}
83
84/// Why a file could not be read, in the tool's own vocabulary.
85///
86/// Error strings travel in the `--json` payload on stdout, and stdout is the
87/// half of the parity boundary that must match byte for byte. Quoting the
88/// runtime makes that impossible: Python renders one thing and Rust another, and
89/// neither survives a change of platform or locale. So the tool says what
90/// happened in words it owns, and anything unrecognized answers `unreadable`
91/// rather than leaking a message.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93enum ReadError {
94    Io(ErrorKind),
95    NotUtf8,
96}
97
98/// Run the program and return everything it would have written.
99///
100/// `root` is the directory relative paths resolve against, which is the process
101/// working directory in `main` and a scratch tree in the tests. It changes which
102/// bytes are opened and nothing that is reported: a path is reported as it was
103/// written on the command line, never as it was resolved.
104#[must_use]
105pub fn run(argv: &[String], root: &Path) -> Outcome {
106    let args = match parse_args(argv) {
107        Ok(args) => args,
108        Err(message) => {
109            return Outcome {
110                stdout: String::new(),
111                stderr: format!("error: {message}\n"),
112                // argparse exits 2 on a parse error, and this is the only path
113                // that produces a code the corpus does not otherwise reach.
114                code: 2,
115            };
116        }
117    };
118    if args.paths.is_empty() && args.files_from.is_none() && wants_help(argv) {
119        return Outcome {
120            stdout: usage(),
121            stderr: String::new(),
122            code: 0,
123        };
124    }
125
126    let mut errors: Vec<String> = Vec::new();
127    let raw_paths = collect_input_paths(&args, root, &mut errors);
128    let rules = build_ignore_rules(&args, root, &mut errors);
129    let mut reports: Vec<FileReport> = Vec::new();
130
131    for raw in &raw_paths {
132        // Filtered here rather than inside any one discovery path, so exclusion
133        // means the same thing however the name arrived. An excluded file leaves
134        // no report and no error and so cannot trip `--fail-on-change`:
135        // exclusion is a statement about scope, and a file that was never in
136        // scope has nothing to fail about.
137        if rules.excludes(raw) {
138            continue;
139        }
140        let full = root.join(raw);
141        let reported = posix_display(raw);
142        // A symlink is skipped before anything follows it, and a missing or
143        // non-regular path is skipped silently too. Only a file the tool tried
144        // and failed to read is an error.
145        let Ok(metadata) = fs::symlink_metadata(&full) else {
146            continue;
147        };
148        if metadata.file_type().is_symlink() || !full.is_file() {
149            continue;
150        }
151        match process_file(&full, &reported, args.write) {
152            Ok(report) => reports.push(report),
153            // Reported rather than raised. A formatter given twenty files must
154            // not decline to format nineteen because the first was unreadable.
155            Err(error) => errors.push(format!(
156                "{reported}: cannot read ({})",
157                describe(&full, error)
158            )),
159        }
160    }
161
162    let changed = reports.iter().any(|report| report.changed);
163    let mut stdout = String::new();
164    let mut stderr = String::new();
165    if args.json {
166        stdout.push_str(&json_payload(changed, &reports, &errors));
167        stdout.push('\n');
168    } else {
169        for report in &reports {
170            if report.changed {
171                let _ = writeln!(
172                    stdout,
173                    "{}: removed {} manual line break(s)",
174                    report.path, report.line_breaks_removed
175                );
176            }
177        }
178        for error in &errors {
179            let _ = writeln!(stderr, "{error}");
180        }
181    }
182    // `pre-commit` notices a hook rewriting a file and fails the run itself, so
183    // the framework path needs no help. A GitHub Action has no such wrapper:
184    // without this, a workflow step that reformatted every file still reports
185    // success, which is the one outcome a check must never produce.
186    let code = u8::from(args.fail_on_change && changed || !errors.is_empty());
187    Outcome {
188        stdout,
189        stderr,
190        code,
191    }
192}
193
194/// Parse `argv`, or return the message a parse failure would print.
195///
196/// # Errors
197///
198/// Returns the failure text when a token is not a valid option, an abbreviation
199/// is ambiguous, or an option that takes a value was not given one.
200pub fn parse_args(argv: &[String]) -> Result<Args, String> {
201    let tokens = classify(argv);
202    let mut args = Args::default();
203    let mut paths_taken = false;
204    let mut extras: Vec<String> = Vec::new();
205    let mut index = 0;
206    while index < tokens.len() {
207        let Token::Option { name, inline } = &tokens[index] else {
208            // Positionals arrive in contiguous runs, and argparse gives the
209            // whole first run to the one `nargs='*'` action and then retires it.
210            // Every later run is therefore unrecognized -- measured, not
211            // assumed: `a.md --exclude x b.md` exits 2 under CPython.
212            let start = index;
213            while matches!(tokens.get(index), Some(Token::Positional(_))) {
214                index += 1;
215            }
216            let run = tokens[start..index]
217                .iter()
218                .map(Token::value)
219                .collect::<Vec<String>>();
220            if paths_taken {
221                extras.extend(run);
222            } else {
223                args.paths = run;
224                paths_taken = true;
225            }
226            continue;
227        };
228        let (option, takes) = resolve(name)?;
229        index += 1;
230        if takes == Takes::Nothing {
231            if inline.is_some() {
232                return Err(format!("argument {option}: ignored explicit argument"));
233            }
234            match option {
235                "--write" => args.write = true,
236                "--json" => args.json = true,
237                "--fail-on-change" => args.fail_on_change = true,
238                _ => {}
239            }
240            continue;
241        }
242        let value = match inline {
243            Some(value) => value.clone(),
244            None => match tokens.get(index) {
245                // An option's value may be a token that starts with `-` only
246                // when that token is not itself option-like, which is why
247                // `--exclude -12` is accepted and `--exclude --json` is not.
248                Some(Token::Positional(value)) => {
249                    index += 1;
250                    value.clone()
251                }
252                _ => return Err(format!("argument {option}: expected one argument")),
253            },
254        };
255        match option {
256            "--files-from" => args.files_from = Some(value),
257            "--ignore-file" => args.ignore_file = Some(value),
258            // Repeatable, and applied after the ignore file.
259            "--exclude" => args.exclude.push(value),
260            _ => {}
261        }
262    }
263    if extras.is_empty() {
264        Ok(args)
265    } else {
266        Err(format!("unrecognized arguments: {}", extras.join(" ")))
267    }
268}
269
270/// One command-line token, already decided to be an option or not.
271#[derive(Debug, Clone, PartialEq, Eq)]
272enum Token {
273    Option {
274        name: String,
275        inline: Option<String>,
276    },
277    Positional(String),
278}
279
280impl Token {
281    fn value(&self) -> String {
282        match self {
283            Token::Positional(value) => value.clone(),
284            Token::Option { name, .. } => name.clone(),
285        }
286    }
287}
288
289/// Decide what each token is, once, before anything is consumed.
290///
291/// argparse classifies the whole of `argv` up front and only then matches
292/// actions against the pattern, which is why an option's value is decided by
293/// what the *token* looks like rather than by what the option wanted.
294fn classify(argv: &[String]) -> Vec<Token> {
295    let mut tokens = Vec::with_capacity(argv.len());
296    let mut rest_are_positional = false;
297    for arg in argv {
298        if rest_are_positional {
299            tokens.push(Token::Positional(arg.clone()));
300            continue;
301        }
302        if arg == "--" {
303            // The first `--` is removed rather than kept, and it does not break
304            // the positional run around it: `a.md -- b.md` yields both paths.
305            // A second one is an ordinary positional.
306            rest_are_positional = true;
307            continue;
308        }
309        if !is_option_like(arg) {
310            tokens.push(Token::Positional(arg.clone()));
311            continue;
312        }
313        match arg.split_once('=') {
314            Some((name, value)) => tokens.push(Token::Option {
315                name: name.to_owned(),
316                inline: Some(value.to_owned()),
317            }),
318            None => tokens.push(Token::Option {
319                name: arg.clone(),
320                inline: None,
321            }),
322        }
323    }
324    tokens
325}
326
327/// Would argparse read this token as an option rather than as a positional?
328fn is_option_like(arg: &str) -> bool {
329    if !arg.starts_with('-') || arg.chars().count() == 1 {
330        return false;
331    }
332    // Both of these make a `-`-leading token a positional under argparse.
333    !is_negative_number(arg) && !arg.contains(' ')
334}
335
336/// argparse's `_negative_number_matcher`, narrowed to ASCII.
337///
338/// The standard library's is `^-\d+$|^-\d*\.\d+$`, and its `\d` is Unicode `Nd`
339/// — 650 code points on 3.10 and 680 on 3.13. That is the same defect the
340/// specification removed from this tool's own patterns in Task 3, except that
341/// this pattern belongs to CPython and cannot be narrowed from here without
342/// reaching into a private attribute of the standard library.
343///
344/// **So one divergence is accepted and stated rather than hidden.** A token like
345/// `-١٢` is a positional path under Python and an unknown option under this
346/// implementation. Every ASCII spelling agrees, which is what
347/// `corpus/cli/a-negative-number-argument-is-a-path` pins. The alternative —
348/// monkeypatching `_negative_number_matcher` — trades a rare, documented
349/// divergence for a silent breakage on any interpreter that renames it, which is
350/// the worse of the two.
351fn is_negative_number(arg: &str) -> bool {
352    let Some(rest) = arg.strip_prefix('-') else {
353        return false;
354    };
355    if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) {
356        return true;
357    }
358    // `^-\d*\.\d+$`: the digits before the point are optional, the ones after
359    // are not, so `-.5` is a number and `-5.` is not.
360    match rest.split_once('.') {
361        Some((whole, fraction)) => {
362            whole.bytes().all(|b| b.is_ascii_digit())
363                && !fraction.is_empty()
364                && fraction.bytes().all(|b| b.is_ascii_digit())
365        }
366        None => false,
367    }
368}
369
370/// Resolve an option name, allowing an unambiguous abbreviation.
371fn resolve(name: &str) -> Result<(&'static str, Takes), String> {
372    if let Some((option, takes)) = OPTIONS.iter().find(|(option, _)| *option == name) {
373        return Ok((option, *takes));
374    }
375    if name == "-h" {
376        return Ok(("--help", Takes::Nothing));
377    }
378    let candidates: Vec<&(&'static str, Takes)> = OPTIONS
379        .iter()
380        .filter(|(option, _)| option.starts_with(name))
381        .collect();
382    match candidates.as_slice() {
383        [(option, takes)] => Ok((option, *takes)),
384        [] => Err(format!("unrecognized arguments: {name}")),
385        many => Err(format!(
386            "ambiguous option: {name} could match {}",
387            many.iter()
388                .map(|(option, _)| *option)
389                .collect::<Vec<&str>>()
390                .join(", ")
391        )),
392    }
393}
394
395/// Did the caller ask for help?
396fn wants_help(argv: &[String]) -> bool {
397    argv.iter().any(|arg| {
398        arg == "-h" || (is_option_like(arg) && matches!(resolve(arg), Ok(("--help", _))))
399    })
400}
401
402/// The usage text.
403///
404/// Deliberately outside the parity boundary: it carries the program name, and
405/// the program name is exactly what differs between the two implementations, so
406/// `corpus/cli/README.md` states that no case asserts it.
407fn usage() -> String {
408    let mut text = String::from("Detect or remove manual line breaks in Markdown prose.\n\n");
409    text.push_str("usage: unwrap-markdown-prose-rs [options] [paths ...]\n\n");
410    for (option, takes) in OPTIONS {
411        let value = if takes == Takes::OneValue {
412            " VALUE"
413        } else {
414            ""
415        };
416        let _ = writeln!(text, "  {option}{value}");
417    }
418    text
419}
420
421/// `_collect_input_paths`: positional arguments, then any `--files-from` list.
422fn collect_input_paths(args: &Args, root: &Path, errors: &mut Vec<String>) -> Vec<String> {
423    let mut paths = args.paths.clone();
424    let Some(files_from) = &args.files_from else {
425        return paths;
426    };
427    let full = root.join(files_from);
428    match read_text(&full) {
429        // `Path.read_text` opens in text mode, which translates `\r\n` and a
430        // lone `\r` to `\n` on the way in. The ignore file below is opened with
431        // `newline=''` and is not translated, so the two differ on purpose.
432        Ok(contents) => {
433            let translated = contents.replace("\r\n", "\n").replace('\r', "\n");
434            paths.extend(
435                py_splitlines_keepends(&translated)
436                    .into_iter()
437                    .map(|line| split_eol(line).0)
438                    .filter(|line| !py_trim(line).is_empty())
439                    .map(str::to_owned),
440            );
441        }
442        Err(error) => errors.push(format!(
443            "{}: cannot read --files-from ({})",
444            posix_display(files_from),
445            describe(&full, error)
446        )),
447    }
448    paths
449}
450
451/// `_build_ignore_rules`: the patterns in force, and any error reading them.
452fn build_ignore_rules(args: &Args, root: &Path, errors: &mut Vec<String>) -> IgnoreRules {
453    let explicit = args.ignore_file.as_deref();
454    let name = explicit.unwrap_or(IGNORE_FILE_NAME);
455    let full = root.join(name);
456    // A missing default is silent because nobody asked for it. A missing
457    // `--ignore-file` was named on the command line, and honoring the default
458    // instead would format every file the caller meant to protect.
459    let text = if explicit.is_some() || full.is_file() {
460        match read_text(&full) {
461            Ok(text) => Some(text),
462            Err(error) => {
463                errors.push(format!(
464                    "{}: cannot read --ignore-file ({})",
465                    posix_display(name),
466                    describe(&full, error)
467                ));
468                None
469            }
470        }
471    } else {
472        None
473    };
474    IgnoreRules::new(text.as_deref(), args.exclude.iter().map(String::as_str))
475}
476
477/// `_process_file`: read, transform, optionally rewrite, and report.
478fn process_file(full: &Path, reported: &str, write: bool) -> Result<FileReport, ReadError> {
479    let original = read_text(full)?;
480    if is_transcript_like_markdown(&original) {
481        return Ok(FileReport {
482            path: reported.to_owned(),
483            changed: false,
484            paragraphs_unwrapped: 0,
485            line_breaks_removed: 0,
486        });
487    }
488    let result = unwrap_markdown_prose(&original);
489    let changed = result.content != original;
490    if write && changed {
491        // Written as bytes, so the file's original `\r\n` or `\r` style passes
492        // straight back through. A text-mode write would rewrite every line
493        // ending in the file on the first unwrap that lands.
494        fs::write(full, result.content.as_bytes()).map_err(|e| ReadError::Io(e.kind()))?;
495    }
496    Ok(FileReport {
497        path: reported.to_owned(),
498        changed,
499        paragraphs_unwrapped: result.paragraphs_unwrapped,
500        line_breaks_removed: result.line_breaks_removed,
501    })
502}
503
504/// Read a file with no newline translation and a strict UTF-8 decode.
505fn read_text(path: &Path) -> Result<String, ReadError> {
506    let bytes = fs::read(path).map_err(|error| ReadError::Io(error.kind()))?;
507    String::from_utf8(bytes).map_err(|_| ReadError::NotUtf8)
508}
509
510/// `_describe_error`: the tool's own name for a read failure.
511///
512/// The condition is resolved before the class, because for one case the class is
513/// itself platform-dependent: opening a directory is `EISDIR` on POSIX and
514/// `EACCES` on Windows. Keying on the class alone let the platform back into a
515/// payload that has to match byte for byte, and the CLI tier caught it.
516fn describe(path: &Path, error: ReadError) -> &'static str {
517    let kind = match error {
518        // A decode failure carries no filename in Python either, so it never
519        // reaches the directory test.
520        ReadError::NotUtf8 => return "not valid UTF-8",
521        ReadError::Io(kind) => kind,
522    };
523    if path.is_dir() {
524        return "is a directory";
525    }
526    match kind {
527        ErrorKind::NotFound => "not found",
528        ErrorKind::IsADirectory => "is a directory",
529        ErrorKind::NotADirectory => "not a directory",
530        ErrorKind::PermissionDenied => "permission denied",
531        _ => "unreadable",
532    }
533}
534
535/// `Path(raw).as_posix()`: how a path is reported, on every platform.
536///
537/// A Windows run prints `sub/nested.md`, never `sub\nested.md`. The
538/// normalization is the path type's own: duplicate separators collapse, `.`
539/// components go, and `..` stays -- which is why the ignore rules resolve `..`
540/// for themselves rather than relying on this.
541///
542/// UNC and drive-relative paths are out of scope; the corpus is what judges this
543/// and it holds neither.
544#[must_use]
545pub fn posix_display(raw: &str) -> String {
546    let separators: &[char] = if cfg!(windows) { &['/', '\\'] } else { &['/'] };
547    let leading = raw.chars().take_while(|c| separators.contains(c)).count();
548    // POSIX gives exactly two leading slashes their own meaning and collapses
549    // any longer run to one.
550    let root = match leading {
551        0 => "",
552        2 if !cfg!(windows) => "//",
553        _ => "/",
554    };
555    let parts: Vec<&str> = raw
556        .split(separators)
557        .filter(|part| !part.is_empty() && *part != ".")
558        .collect();
559    if parts.is_empty() {
560        return if root.is_empty() { "." } else { root }.to_owned();
561    }
562    format!("{root}{}", parts.join("/"))
563}
564
565/// The `--json` payload, matching `json.dumps(payload, indent=2, sort_keys=True)`.
566fn json_payload(changed: bool, reports: &[FileReport], errors: &[String]) -> String {
567    let mut out = String::from("{\n  \"changed\": ");
568    out.push_str(if changed { "true" } else { "false" });
569    out.push_str(",\n  \"errors\": ");
570    if errors.is_empty() {
571        out.push_str("[]");
572    } else {
573        out.push_str("[\n");
574        for (index, error) in errors.iter().enumerate() {
575            out.push_str("    ");
576            json_string(error, &mut out);
577            out.push_str(if index + 1 == errors.len() {
578                "\n"
579            } else {
580                ",\n"
581            });
582        }
583        out.push_str("  ]");
584    }
585    out.push_str(",\n  \"files\": ");
586    if reports.is_empty() {
587        out.push_str("[]");
588    } else {
589        out.push_str("[\n");
590        for (index, report) in reports.iter().enumerate() {
591            // Keys sorted, which puts `path` last rather than first.
592            let _ = write!(
593                out,
594                "    {{\n      \"changed\": {},\n      \"line_breaks_removed\": {},\n      \"paragraphs_unwrapped\": {},\n      \"path\": ",
595                report.changed, report.line_breaks_removed, report.paragraphs_unwrapped
596            );
597            json_string(&report.path, &mut out);
598            out.push_str("\n    }");
599            out.push_str(if index + 1 == reports.len() {
600                "\n"
601            } else {
602                ",\n"
603            });
604        }
605        out.push_str("  ]");
606    }
607    out.push_str("\n}");
608    out
609}
610
611/// One JSON string literal, escaped the way `ensure_ascii=True` escapes.
612///
613/// Everything outside `0x20..=0x7e` is escaped, which includes `U+007F`: it is
614/// ASCII, and it is still escaped. Emitting a raw DEL byte there diverges on
615/// stdout, which is the half of the boundary that has to match.
616fn json_string(text: &str, out: &mut String) {
617    out.push('"');
618    for c in text.chars() {
619        match c {
620            '"' => out.push_str("\\\""),
621            '\\' => out.push_str("\\\\"),
622            '\u{8}' => out.push_str("\\b"),
623            '\u{c}' => out.push_str("\\f"),
624            '\n' => out.push_str("\\n"),
625            '\r' => out.push_str("\\r"),
626            '\t' => out.push_str("\\t"),
627            '\u{20}'..='\u{7e}' => out.push(c),
628            _ => {
629                // Above the basic plane Python emits a surrogate pair, which is
630                // what encoding to UTF-16 produces.
631                let mut units = [0u16; 2];
632                for unit in c.encode_utf16(&mut units) {
633                    let _ = write!(out, "\\u{unit:04x}");
634                }
635            }
636        }
637    }
638    out.push('"');
639}
640
641/// The working directory, or `.` when it cannot be read.
642#[must_use]
643pub fn working_directory() -> PathBuf {
644    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    fn parse(argv: &[&str]) -> Result<Args, String> {
652        parse_args(
653            &argv
654                .iter()
655                .map(|s| (*s).to_owned())
656                .collect::<Vec<String>>(),
657        )
658    }
659
660    #[test]
661    fn the_first_positional_run_is_the_path_list() {
662        assert_eq!(parse(&["a.md", "b.md"]).unwrap().paths, ["a.md", "b.md"]);
663        assert_eq!(parse(&["--exclude", "x", "a.md"]).unwrap().paths, ["a.md"]);
664        assert_eq!(
665            parse(&["--write", "--json", "a.md", "b.md", "--exclude", "x"])
666                .unwrap()
667                .paths,
668            ["a.md", "b.md"]
669        );
670    }
671
672    #[test]
673    fn a_second_positional_run_is_unrecognized() {
674        // Measured against CPython. argparse retires the one `nargs='*'` action
675        // after the first run, so a later positional has nothing left to match.
676        assert!(parse(&["a.md", "--exclude", "x", "b.md"]).is_err());
677        assert!(parse(&["--json", "a.md", "--json", "b.md"]).is_err());
678    }
679
680    #[test]
681    fn an_abbreviation_is_taken_when_it_is_unambiguous() {
682        assert!(parse(&["--wr"]).unwrap().write);
683        assert!(parse(&["--w"]).unwrap().write);
684        assert!(parse(&["--j"]).unwrap().json);
685        assert!(parse(&["--fa"]).unwrap().fail_on_change);
686        assert_eq!(parse(&["--e", "x"]).unwrap().exclude, ["x"]);
687        assert_eq!(
688            parse(&["--i", "x"]).unwrap().ignore_file.as_deref(),
689            Some("x")
690        );
691        // `--files-from` and `--fail-on-change` share a prefix.
692        assert!(parse(&["--f", "x"]).is_err());
693    }
694
695    #[test]
696    fn a_value_may_be_given_inline_or_after() {
697        assert_eq!(parse(&["--exclude=x"]).unwrap().exclude, ["x"]);
698        assert_eq!(parse(&["--exc=x"]).unwrap().exclude, ["x"]);
699        assert_eq!(
700            parse(&["--files-from=list.txt"])
701                .unwrap()
702                .files_from
703                .as_deref(),
704            Some("list.txt")
705        );
706        assert!(parse(&["--exclude"]).is_err());
707        // A value has to be a positional token, so another option is not one.
708        assert!(parse(&["--exclude", "--json"]).is_err());
709    }
710
711    #[test]
712    fn the_last_value_wins_and_exclude_accumulates() {
713        assert_eq!(
714            parse(&["--ignore-file", "x", "--ignore-file", "y"])
715                .unwrap()
716                .ignore_file
717                .as_deref(),
718            Some("y")
719        );
720        assert_eq!(
721            parse(&["--exclude", "a", "--exclude", "b"])
722                .unwrap()
723                .exclude,
724            ["a", "b"]
725        );
726    }
727
728    #[test]
729    fn a_dash_leading_token_is_a_positional_when_argparse_says_so() {
730        assert_eq!(parse(&["--json", "-12"]).unwrap().paths, ["-12"]);
731        assert_eq!(parse(&["--json", "-1.5"]).unwrap().paths, ["-1.5"]);
732        assert_eq!(parse(&["--json", "-.5"]).unwrap().paths, ["-.5"]);
733        assert_eq!(parse(&["--json", "-0"]).unwrap().paths, ["-0"]);
734        assert_eq!(parse(&["-"]).unwrap().paths, ["-"]);
735        assert_eq!(parse(&["--json", "-a b"]).unwrap().paths, ["-a b"]);
736        assert!(parse(&["--json", "-x"]).is_err());
737        assert!(parse(&["--json", "-1a"]).is_err());
738        // `-5.` is not a number: the digits after the point are required.
739        assert!(parse(&["--json", "-5."]).is_err());
740        // An option's value may be a negative number.
741        assert_eq!(parse(&["--exclude", "-12"]).unwrap().exclude, ["-12"]);
742    }
743
744    #[test]
745    fn a_double_dash_ends_the_options_without_breaking_the_run() {
746        assert_eq!(
747            parse(&["a.md", "--", "b.md"]).unwrap().paths,
748            ["a.md", "b.md"]
749        );
750        assert_eq!(parse(&["--", "a.md"]).unwrap().paths, ["a.md"]);
751        assert_eq!(
752            parse(&["--json", "--"]).unwrap().paths,
753            Vec::<String>::new()
754        );
755        assert_eq!(parse(&["--json", "--", "-x"]).unwrap().paths, ["-x"]);
756        // Only the first one is removed; a second is an ordinary positional.
757        assert_eq!(parse(&["--", "--", "a.md"]).unwrap().paths, ["--", "a.md"]);
758        let args = parse(&["--write", "--", "--write"]).unwrap();
759        assert!(args.write);
760        assert_eq!(args.paths, ["--write"]);
761    }
762
763    #[test]
764    fn the_negative_number_rule_is_ascii_and_says_so() {
765        assert!(is_negative_number("-12"));
766        assert!(is_negative_number("-.5"));
767        assert!(is_negative_number("-1.5"));
768        assert!(!is_negative_number("-5."));
769        assert!(!is_negative_number("-"));
770        assert!(!is_negative_number("-1a"));
771        // The accepted divergence: CPython's `\d` takes this and this does not.
772        assert!(!is_negative_number("-\u{661}\u{662}"));
773    }
774
775    #[test]
776    fn a_path_is_reported_with_posix_separators() {
777        assert_eq!(posix_display("a.md"), "a.md");
778        assert_eq!(posix_display("./a.md"), "a.md");
779        assert_eq!(posix_display("a//b.md"), "a/b.md");
780        assert_eq!(posix_display("a/b.md/"), "a/b.md");
781        assert_eq!(posix_display("a/../b.md"), "a/../b.md");
782        assert_eq!(posix_display(""), ".");
783        assert_eq!(posix_display("/"), "/");
784        assert_eq!(posix_display("///"), "/");
785        assert_eq!(posix_display(".."), "..");
786    }
787
788    #[test]
789    fn the_json_payload_matches_pythons_dump() {
790        let payload = json_payload(
791            true,
792            &[FileReport {
793                path: "fine.md".to_owned(),
794                changed: true,
795                paragraphs_unwrapped: 1,
796                line_breaks_removed: 1,
797            }],
798            &["bad.md: cannot read (not valid UTF-8)".to_owned()],
799        );
800        assert_eq!(
801            payload,
802            "{\n  \"changed\": true,\n  \"errors\": [\n    \"bad.md: cannot read (not valid UTF-8)\"\n  ],\n  \"files\": [\n    {\n      \"changed\": true,\n      \"line_breaks_removed\": 1,\n      \"paragraphs_unwrapped\": 1,\n      \"path\": \"fine.md\"\n    }\n  ]\n}"
803        );
804        assert_eq!(
805            json_payload(false, &[], &[]),
806            "{\n  \"changed\": false,\n  \"errors\": [],\n  \"files\": []\n}"
807        );
808    }
809
810    #[test]
811    fn json_escapes_everything_outside_printable_ascii() {
812        let mut out = String::new();
813        json_string("a\u{7f}b", &mut out);
814        // U+007F is ASCII and is still escaped.
815        assert_eq!(out, "\"a\\u007fb\"");
816        out.clear();
817        json_string("\u{e9}\u{1f600}\"\\\n\t", &mut out);
818        assert_eq!(out, "\"\\u00e9\\ud83d\\ude00\\\"\\\\\\n\\t\"");
819    }
820}