mdtablefix 0.5.0

`mdtablefix` unb0rks and reflows Markdown tables so that each column has a uniform width. When the `--wrap` option is used, it also wraps paragraphs and list items to 80 columns.
Documentation
//! List item wrapping tests.

use mdtablefix::{process::WRAP_COLS, wrap_text};
use rstest::rstest;
use unicode_width::UnicodeWidthStr;

use super::{wrap_assertions::assert_wrapped_list_item, *};

#[test]
fn test_wrap_list_item() {
    let input = lines_vec![
        r"- This bullet item is exceptionally long and must be wrapped to keep prefix formatting intact.",
    ];
    let output = process_stream(&input);
    assert_wrapped_list_item(&output, "- ", 2);
}

#[rstest]
#[case("- ", 3)]
#[case("1. ", 3)]
#[case("10. ", 3)]
#[case("100. ", 3)]
fn test_wrap_list_items_with_inline_code(#[case] prefix: &str, #[case] expected: usize) {
    let input = lines_vec![format!(
        "{prefix}`script`: A multi-line script declared with the YAML `|` block style. The entire \
         block is passed to an interpreter. If the first line begins with `#!`, Netsuke executes \
         the script verbatim, respecting the shebang."
    )];
    let output = process_stream(&input);
    assert_wrapped_list_item(&output, prefix, expected);
}

#[test]
fn test_wrap_preserves_inline_code_spans() {
    let input = lines_vec![
        "- `script`: A multi-line script declared with the YAML `|` block style. The entire block \
         is passed to an interpreter. If the first line begins with `#!`, Netsuke executes the \
         script verbatim, respecting the shebang.",
    ];
    let output = process_stream(&input);
    assert_wrapped_list_item(&output, "- ", 3);
}

#[test]
fn test_wrap_multi_backtick_code() {
    let input = lines_vec![
        "- ``cmd`` executes ```echo``` output with ``json`` format and prints results to the \
         console",
    ];
    let output = process_stream(&input);
    assert_wrapped_list_item(&output, "- ", 2);
}

#[test]
fn test_wrap_multiple_inline_code_spans() {
    let input = lines_vec![
        "- Use `foo` and `bar` inside ``baz`` for testing with additional commentary to exceed \
         wrapping width",
    ];
    let output = process_stream(&input);
    assert_wrapped_list_item(&output, "- ", 2);
}

#[test]
fn test_wrap_long_inline_code_item() {
    let input = lines_vec![concat!(
        "- `async def on_unhandled(self, ws: WebSocketLike, message: Union[str, bytes])`:",
        " A fallback handler for messages that are not dispatched by the more specific",
        " message handlers. This can be used for raw text/binary data or messages that",
        " don't conform to the expected structured format."
    )];
    let output = process_stream(&input);
    assert_wrapped_list_item(&output, "- ", 4);
    assert!(
        output
            .first()
            .expect("wrapped output should contain at least one line")
            .ends_with("`:")
    );
}

#[test]
fn test_wrap_opening_paren_before_inline_code() {
    let input = lines_vec![concat!(
        "- `src/cli/mod.rs` (240 lines): defines the `Cli` struct with ",
        "`#[derive(Parser, Serialize, Deserialize, OrthoConfig)]`, its subcommands ",
        "(`Commands` enum), and the `parse_with_localizer_from` function that creates ",
        "a localized clap command and parses arguments."
    )];
    let output = process_stream(&input);
    for window in output.windows(2) {
        assert!(
            !window[0].ends_with('('),
            "opening parenthesis must not be stranded at line end: {output:?}"
        );
    }
}

#[rstest]
#[case("(`code`)", '(')]
#[case("[`code`]", '[')]
#[case("(`code`)", '(')]
#[case("「`code`」", '「')]
fn test_wrap_opening_bracket_variants_before_inline_code(
    #[case] fragment: &str,
    #[case] opener: char,
) {
    let input = lines_vec![format!(
        "- Leading prose that is long enough to force wrapping before {fragment} and trailing \
         text."
    )];
    let output = process_stream(&input);
    for line in &output {
        if line.contains('`') {
            assert!(
                !line.ends_with(opener),
                "opening bracket must stay with inline code on line: {line:?}"
            );
        }
    }
}

#[test]
fn test_wrap_future_attribute_punctuation() {
    let input = lines_vec![concat!(
        "- Test function (`#[awt]`) or a specific `#[future]` argument ",
        "(`#[future(awt)]`), tells `rstest` to automatically insert `.await` ",
        "calls for those futures."
    )];
    let output = process_stream(&input);
    assert_eq!(
        output,
        vec![
            "- Test function (`#[awt]`) or a specific `#[future]` argument".to_string(),
            "  (`#[future(awt)]`), tells `rstest` to automatically insert `.await` calls for"
                .to_string(),
            "  those futures.".to_string(),
        ]
    );
}

#[test]
fn test_wrap_short_list_item() {
    let input = lines_vec!["- short item"];
    let output = process_stream(&input);
    assert_eq!(output, input);
}

#[test]
fn test_wrap_list_item_period_after_code() {
    let input: Vec<String> = include_lines!("../data/bullet_full_stop_input.txt");
    let expected: Vec<String> = include_lines!("../data/bullet_full_stop_expected.txt");
    let output = process_stream(&input);
    assert_eq!(output, expected);
}

#[test]
fn test_wrap_bullet_backslash_terminated_code_span_idempotent() {
    let input: Vec<String> = include_lines!("../data/bullet_backslash_code_span_input.txt");
    let expected: Vec<String> = include_lines!("../data/bullet_backslash_code_span_expected.txt");

    let output = wrap_text(&input, WRAP_COLS);

    assert_eq!(output, expected);
    insta::with_settings!({
        snapshot_path => "../snapshots",
        prepend_module_to_snapshot => false,
    }, {
        insta::assert_snapshot!(
            "bullet_backslash_terminated_code_span",
            output.join("\n")
        );
    });
    assert_eq!(wrap_text(&output, WRAP_COLS), output);
    assert_wrapped_list_item(&output[2..4], "- ", 2);
    assert!(
        output
            .iter()
            .all(|line| UnicodeWidthStr::width(line.as_str()) <= WRAP_COLS)
    );
    let rendered = output.join("\n");
    assert!(rendered.contains(r"`C:\Program Files\<Vendor>\<Product>\bin\`"));
    assert_eq!(rendered.chars().filter(|&ch| ch == '`').count(), 2);
}

#[test]
fn test_wrap_list_item_question_mark_after_code() {
    let input: Vec<String> = include_lines!("../data/bullet_question_mark_input.txt");
    let expected: Vec<String> = include_lines!("../data/bullet_question_mark_expected.txt");
    let output = process_stream(&input);
    assert_eq!(output, expected);
}

#[test]
fn test_wrap_list_item_exclamation_mark_after_code() {
    let input: Vec<String> = include_lines!("../data/bullet_exclamation_mark_input.txt");
    let expected: Vec<String> = include_lines!("../data/bullet_exclamation_mark_expected.txt");
    let output = process_stream(&input);
    assert_eq!(output, expected);
}

#[test]
fn test_wrap_list_item_comma_after_code() {
    let input: Vec<String> = include_lines!("../data/bullet_comma_input.txt");
    let expected: Vec<String> = include_lines!("../data/bullet_comma_expected.txt");
    let output = process_stream(&input);
    assert_eq!(output, expected);
}

#[test]
fn test_wrap_list_item_colon_after_code() {
    let input: Vec<String> = include_lines!("../data/bullet_colon_input.txt");
    let expected: Vec<String> = include_lines!("../data/bullet_colon_expected.txt");
    let output = process_stream(&input);
    assert_eq!(output, expected);
}

#[test]
fn test_wrap_list_item_semicolon_after_code() {
    let input: Vec<String> = include_lines!("../data/bullet_semicolon_input.txt");
    let expected: Vec<String> = include_lines!("../data/bullet_semicolon_expected.txt");
    let output = process_stream(&input);
    assert_eq!(output, expected);
}

#[test]
fn test_wrap_list_items_with_checkboxes() {
    let input = lines_vec![
        "- [ ] Create a `HttpTravelTimeProvider` struct that implements the `TravelTimeProvider` \
         trait.",
        concat!(
            "- [ ] Using `tokio` and `reqwest`, implement the `get_travel_time_matrix` ",
            "method to make concurrent requests to an external OSRM API's `table` ",
            "service."
        ),
    ];
    let expected = lines_vec![
        "- [ ] Create a `HttpTravelTimeProvider` struct that implements the",
        "      `TravelTimeProvider` trait.",
        "- [ ] Using `tokio` and `reqwest`, implement the `get_travel_time_matrix`",
        "      method to make concurrent requests to an external OSRM API's `table`",
        "      service.",
    ];
    let output = process_stream(&input);
    assert_eq!(output, expected);
}

#[test]
fn test_wrap_indented_list_items_with_checkboxes() {
    let input = lines_vec![
        "  - [ ] Create a `HttpTravelTimeProvider` struct that implements the \
         `TravelTimeProvider` trait.",
        concat!(
            "  - [ ] Using `tokio` and `reqwest`, implement the `get_travel_time_matrix` ",
            "method to make concurrent requests to an external OSRM API's `table` ",
            "service."
        ),
    ];
    let expected = lines_vec![
        "  - [ ] Create a `HttpTravelTimeProvider` struct that implements the",
        "        `TravelTimeProvider` trait.",
        "  - [ ] Using `tokio` and `reqwest`, implement the `get_travel_time_matrix`",
        "        method to make concurrent requests to an external OSRM API's `table`",
        "        service.",
    ];
    let output = process_stream(&input);
    assert_eq!(output, expected);
}

#[rstest]
#[case("- [ ] ")]
#[case("  - [ ] ")]
#[case("- [x] ")]
#[case("- [X] ")]
#[case("- [x]")]
#[case("- [X]")]
#[case("- [x ] ")]
#[case("- [ X] ")] // asymmetric inner space before X
#[case("- [X ] ")] // asymmetric inner space after X
#[case("- [ x ] ")]
#[case("- [  ] ")]
#[case("- [ ]  ")]
#[case("- [x]  ")]
#[case("- [ ]")]
#[case("1. [ ] ")]
#[case("1. [X] ")]
#[case("12) [x] ")]
#[case("12) [X] ")]
#[case("* [ ] ")]
#[case("+ [ ] ")]
fn test_wrap_checkbox_prefixes(#[case] prefix: &str) {
    let body =
        "Create a `HttpTravelTimeProvider` struct that implements the `TravelTimeProvider` trait.";
    let input = lines_vec![format!("{prefix}{body}")];
    let output = process_stream(&input);
    assert!(
        output.len() >= 2,
        "expected wrapping to occur for: {prefix}{body}"
    );
    assert!(
        output[0].starts_with(prefix),
        "prefix mutated in first line for: {prefix}{body}"
    );
    let indent = prefix.chars().count();
    for (i, line) in output.iter().enumerate().skip(1) {
        assert!(
            line.starts_with(&" ".repeat(indent)),
            "indent mismatch on line {i} for: {prefix}{body}"
        );
    }
}

#[test]
fn test_wrap_tab_indented_checkbox_list_items() {
    let input = lines_vec![
        "\t- [ ] Create a `HttpTravelTimeProvider` struct that implements the \
         `TravelTimeProvider` trait.",
        concat!(
            "\t- [ ] Using `tokio` and `reqwest`, implement the `get_travel_time_matrix` ",
            "method to make concurrent requests to an external OSRM API's `table` ",
            "service."
        ),
    ];
    let output = process_stream(&input);
    // A leading tab counts as four columns of indentation, so these lines are
    // treated as indented code blocks and remain verbatim during wrapping.
    assert_eq!(output, input);
}

#[test]
fn test_wrap_tab_indented_checkbox_without_trailing_space() {
    let input = lines_vec![
        "\t- [x]Create a `HttpTravelTimeProvider` struct that implements the `TravelTimeProvider` \
         trait.",
    ];
    let output = process_stream(&input);
    assert_eq!(output, input);
}

#[rstest]
#[case("- [y] ", 2)]
#[case("- [Y] ", 2)]
#[case("- [✓] ", 2)]
#[case("- [] ", 2)]
fn test_wrap_non_task_markers_do_not_expand_prefix(#[case] prefix: &str, #[case] indent: usize) {
    let body =
        "Create a `HttpTravelTimeProvider` struct that implements the `TravelTimeProvider` trait.";
    let input = lines_vec![format!("{prefix}{body}")];
    let output = process_stream(&input);
    assert!(
        output.len() >= 2,
        "expected wrapping to occur for: {prefix}{body}"
    );
    assert!(
        output[0].starts_with(prefix),
        "prefix should be retained for: {prefix}{body}"
    );
    for (i, line) in output.iter().enumerate().skip(1) {
        assert!(
            line.starts_with(&" ".repeat(indent)),
            "indent must match bullet only on line {i} for: {prefix}{body}"
        );
    }
}