From 1d0cb2c0124105dee5a7371bf4c2259e71fb30c8 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:03:15 +0800 Subject: [PATCH 01/14] feat: add python support and default rs,py --- Cargo.lock | 11 + Cargo.toml | 1 + README.md | 15 +- .../2026-03-21-python-lang-support-design.md | 178 +++++++++++ docs/plans/2026-03-21-python-lang-support.md | 230 ++++++++++++++ src/cli.rs | 4 +- src/lang.rs | 33 -- src/lang/mod.rs | 53 ++++ src/lang/python.rs | 29 ++ src/lang/rust.rs | 29 ++ src/lib.rs | 2 + src/main.rs | 175 +--------- src/python_tests.rs | 222 +++++++++++++ src/test_filter.rs | 300 ++++++++++++++++++ tests/cli_smoke.rs | 242 +++++++++++++- tests/lang_filter.rs | 27 ++ tests/python_tests.rs | 119 +++++++ 17 files changed, 1458 insertions(+), 212 deletions(-) create mode 100644 docs/plans/2026-03-21-python-lang-support-design.md create mode 100644 docs/plans/2026-03-21-python-lang-support.md delete mode 100644 src/lang.rs create mode 100644 src/lang/mod.rs create mode 100644 src/lang/python.rs create mode 100644 src/lang/rust.rs create mode 100644 src/python_tests.rs create mode 100644 src/test_filter.rs create mode 100644 tests/python_tests.rs diff --git a/Cargo.lock b/Cargo.lock index bd234b8..dd77fcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,6 +240,7 @@ dependencies = [ "serde_json", "tempfile", "tree-sitter", + "tree-sitter-python", "tree-sitter-rust", ] @@ -575,6 +576,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-rust" version = "0.24.0" diff --git a/Cargo.toml b/Cargo.toml index 111c0c6..d45ae73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tree-sitter = "0.25" +tree-sitter-python = "0.25" tree-sitter-rust = "0.24" [dev-dependencies] diff --git a/README.md b/README.md index ac7734d..a317bd6 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ - untracked files included in default stats - language filtering with `--lang` - Rust-only, non-test-only stats by default, with `--test`, `--no-test`, and `--no-test-filter` +- test-aware filtering for Rust and Python - single-commit and revision-range support This repository also ships `rust-test-audit`, a companion CLI for auditing Rust source trees @@ -73,21 +74,24 @@ git diff-stat --commit HEAD git diff-stat --last git diff-stat --last --no-test-filter git diff-stat HEAD~1..HEAD --lang py --no-test-filter +git diff-stat --lang py --test git diff-stat --test ``` ## Usage ```bash -git diff-stat [ | | ] [--lang rs,js] [--test | --no-test | --no-test-filter] +git diff-stat [ | | ] [--lang rs,py,js] [--test | --no-test | --no-test-filter] ``` Defaults: -- `--lang` defaults to `rs` +- `--lang` defaults to `rs,py` - test filtering defaults to `--no-test` - output always begins with a header line describing the comparison scope, languages, and test scope +That means plain `git diff-stat` already reports Rust and Python non-test changes together. + ## Rust Test Audit ```bash @@ -126,8 +130,9 @@ test regions cross configurable density thresholds. - `--lang` currently uses file extensions. - `--test` and `--no-test` treat Rust files under `tests/` and Rust files imported by `#[cfg(test)]` module declarations as whole-file test code. Other Rust files still use code-region splitting for `#[cfg(test)]` modules and test-annotated functions such as `#[test]` and `#[tokio::test]`. -- `--no-test-filter` disables Rust test splitting entirely and reports full-file stats for the selected languages. -- because `--lang` defaults to `rs`, use `--no-test-filter --lang ` when you want non-Rust output. +- `--test` and `--no-test` treat Python files under `tests/`, `test_*.py`, `*_test.py`, and `conftest.py` as whole-file test code. Other Python files split test regions using `def test_*` and `class Test*`. +- `--no-test-filter` disables Rust and Python test splitting entirely and reports full-file stats for the selected languages. +- because `--lang` defaults to `rs,py`, use `--lang rs` or `--lang py` when you want a narrower language set. - `--last` is sugar for the patch introduced by `HEAD`, equivalent to `HEAD^!`. -- rendered output starts with a Chinese description line such as `未提交的 rs 文件中,非测试代码统计如下:`. +- rendered output starts with a Chinese description line such as `未提交的 rs,py 文件中,非测试代码统计如下:`. - Output is intentionally close to `git diff --stat`, but not byte-for-byte identical. diff --git a/docs/plans/2026-03-21-python-lang-support-design.md b/docs/plans/2026-03-21-python-lang-support-design.md new file mode 100644 index 0000000..faa7529 --- /dev/null +++ b/docs/plans/2026-03-21-python-lang-support-design.md @@ -0,0 +1,178 @@ +# Python Language Support Design + +**Context** + +`git-diff-stat` currently treats `--lang` as a thin file-extension filter in [`src/lang.rs`](../../src/lang.rs), while Rust test-aware behavior is implemented separately in [`src/filter.rs`](../../src/filter.rs) and [`src/rust_tests.rs`](../../src/rust_tests.rs). This works for a single language, but it couples the main execution path to Rust-specific logic and makes each new language support request disproportionately expensive. + +The immediate goal is to support `--lang py` with the same test-filter semantics that Rust already participates in: + +- default / `--no-test`: report only non-test code +- `--test`: report only test code +- `--no-test-filter`: report full-file stats without test splitting + +The target Python test style is the one used by `winq_bt`: `pytest`-style test discovery centered around `tests/`, `test_*.py`, `*_test.py`, `conftest.py`, top-level `def test_*`, and `class Test*`. + +**Goal** + +Add first-class Python support without turning `main.rs` into a per-language switchboard. The structure should make future languages easier to add, while keeping the current Rust behavior unchanged. + +**Approaches** + +1. Extend the current code path with Python-specific branches in `main.rs` and `filter.rs`. + - Lowest short-term cost. + - Rejected because it keeps the architecture centered on Rust special-cases and makes future additions harder. + +2. Introduce lightweight language backends and move test-aware behavior behind a shared interface. + - Slightly more up-front work. + - Keeps CLI/rendering stable while moving language-specific rules into isolated modules. + - Recommended. + +3. Build a fully generic plugin system now. + - Over-designed for the current repository size and only one additional language. + - Rejected as unnecessary complexity. + +**Decision** + +Use lightweight language backends. + +The refactor should not aim for a public plugin API. It only needs enough structure to answer these questions in one place per language: + +- does this path belong to the language? +- which files are whole-file tests? +- for mixed files, which changed lines are test lines vs non-test lines? + +`main.rs` should keep orchestrating Git I/O, revision selection, rendering, and CLI interpretation. It should stop knowing Rust-specific details. + +**Proposed Structure** + +- `src/lang/mod.rs` + - language parsing and normalization + - registry of supported languages + - path-to-language detection +- `src/lang/rust.rs` + - wraps current Rust-specific behavior + - owns Rust whole-file test-path detection and line-region splitting +- `src/lang/python.rs` + - Python path matching and test-region detection +- `src/test_filter.rs` + - shared orchestration for building test-only or non-test-only stats across requested languages +- `src/rust_tests.rs` + - can remain as a Rust parser helper used by `src/lang/rust.rs` + +This is a moderate refactor, not a rewrite. Existing types such as `FileChange`, `FilePatch`, `DisplayStat`, and `TestFilterMode` remain useful as-is. + +**Backend Shape** + +The shared test-filter orchestration should operate in terms of a small internal backend contract. The exact Rust type names can vary, but the responsibilities should look like this: + +- language identity and aliases, such as `rs` and `py` +- file matching by extension +- optional whole-file-test classification +- optional per-file region splitting for tracked and untracked files + +One practical model is: + +- `LanguageKind` enum for supported languages +- helper functions in each language module instead of trait objects +- a dispatcher in `test_filter.rs` that groups changed files by language and invokes the relevant backend helpers + +This avoids unnecessary dynamic dispatch while still removing language conditionals from `main.rs`. + +**Python Test Semantics** + +Python support should match common `pytest` conventions first. + +Whole-file test rules: + +- any `.py` file under a `tests/` path component +- `test_*.py` +- `*_test.py` +- `conftest.py` + +Mixed-file region rules: + +- top-level `def test_*` +- methods named `test_*` +- `class Test*` + +That gives useful behavior for projects like `winq_bt` without trying to model every Python test framework on day one. + +Not in scope for the first version: + +- full `unittest.TestCase` inference beyond names already covered by `test_*` +- custom pytest discovery configuration +- doctests +- dynamic test generation + +**Parsing Strategy** + +Use `tree-sitter-python` alongside the existing `tree-sitter` setup. + +This aligns with the current Rust implementation style: + +- accurate line ranges for test functions and classes +- support for tracked diffs and untracked files +- no need to invent a fragile indentation-based parser + +The Python parser only needs to detect class and function definition ranges. It does not need semantic import resolution similar to Rust's `#[cfg(test)] mod` handling. + +**Data Flow** + +The runtime flow should become: + +1. Parse CLI and revision selection. +2. Parse requested languages into supported language kinds. +3. Filter `FileChange` values by requested languages. +4. If `--no-test-filter`, render full-file stats directly. +5. Otherwise, call a shared test-aware stats builder. +6. The builder: + - parses the diff patch once + - loads per-revision or worktree sources as needed + - asks each language backend for whole-file test paths + - asks each language backend to split changed lines into test/non-test counts +7. Render using the existing header machinery. + +The important shift is that the builder should work over "requested supported languages", not over "Rust files only". + +**Compatibility Rules** + +- Default `--lang` remains `rs` for now. +- `--lang py` participates in the same `--test`, `--no-test`, and `--no-test-filter` flags. +- `--lang rs,py` should combine both backends in one run. +- Non-test-filtered output remains full-file diff stats for any selected language. +- Unknown or unsupported language names should continue to be ignored or rejected consistently with current behavior; if validation is added, it should happen centrally in `src/lang/mod.rs`. + +**Testing Strategy** + +Add coverage at three layers: + +1. Unit tests for language recognition and normalization. +2. Unit tests for Python test-region detection and whole-file test-path classification. +3. CLI smoke tests proving end-to-end behavior for: + - `--lang py` default non-test filtering + - `--lang py --test` + - `--lang py --no-test-filter` + - mixed `--lang rs,py` + +Python smoke tests should include: + +- a production file under `src/` +- a `tests/test_*.py` file +- a mixed file containing both production code and `def test_*` +- optionally `conftest.py` to prove whole-file test classification + +**Risks** + +- The current loader helpers in `main.rs` are named and shaped around Rust sources. Moving them into shared test-filter orchestration will require careful renaming so behavior does not regress. +- Path normalization rules must stay consistent across languages, especially for whole-file test matching. +- Python region detection should stay intentionally narrow; a too-clever first version is more likely to misclassify production code. + +**Outcome** + +After this refactor, adding a new language should mainly mean: + +1. register a new language kind +2. implement one language module +3. add backend tests and one or two CLI regressions + +That is the right level of structure for the repository at its current size. diff --git a/docs/plans/2026-03-21-python-lang-support.md b/docs/plans/2026-03-21-python-lang-support.md new file mode 100644 index 0000000..a958529 --- /dev/null +++ b/docs/plans/2026-03-21-python-lang-support.md @@ -0,0 +1,230 @@ +# Python Language Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `--lang py` with Python test-aware filtering and refactor language-specific filtering so Rust and Python both run through the same orchestration path. + +**Architecture:** Replace the current Rust-only test-filter path with lightweight language backends. Keep CLI and rendering behavior stable, move Rust-specific logic behind `src/lang/rust.rs`, add a Python backend in `src/lang/python.rs`, and centralize multi-language test-aware stat construction in a shared module. + +**Tech Stack:** Rust, clap, tree-sitter, tree-sitter-rust, tree-sitter-python, assert_cmd, predicates, cargo test + +--- + +### Task 1: Lock down `py` language selection behavior + +**Files:** +- Modify: `tests/lang_filter.rs` +- Modify: `tests/cli_smoke.rs` +- Modify: `README.md` + +**Step 1: Write the failing test** + +Add one unit test proving `filter_by_langs` keeps `.py` files for `["py"]`, and one CLI smoke test proving `--lang py --no-test-filter` includes a Python file and excludes the default Rust-only path when Python is explicitly requested. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test lang_filter --test cli_smoke -v` +Expected: FAIL because `.py` is not a recognized language yet. + +**Step 3: Write minimal implementation** + +Teach language detection to recognize `.py` and update README usage examples so Python is documented as a supported language. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test lang_filter --test cli_smoke -v` +Expected: PASS + +### Task 2: Introduce a language module layout that can host multiple backends + +**Files:** +- Create: `src/lang/mod.rs` +- Create: `src/lang/rust.rs` +- Create: `src/lang/python.rs` +- Modify: `src/lib.rs` +- Modify: `src/main.rs` +- Modify: `src/lang.rs` or move its contents into `src/lang/mod.rs` + +**Step 1: Write the failing test** + +Add or adjust unit tests so language parsing still accepts `rs`, `js`, `ts`, and now `py`, and so path-based language detection works after the module split. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test lang_filter -v` +Expected: FAIL while the old single-file `lang.rs` layout is being replaced. + +**Step 3: Write minimal implementation** + +Move language parsing and extension detection into `src/lang/mod.rs`. Add backend-oriented helpers in `src/lang/rust.rs` and `src/lang/python.rs`. Keep the public surface small: parsing requested languages, detecting a file's language, and exposing backend helpers needed by test filtering. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test lang_filter -v` +Expected: PASS + +### Task 3: Add Python test detection primitives + +**Files:** +- Create: `src/python_tests.rs` or keep parser helpers inside `src/lang/python.rs` +- Create: `tests/python_tests.rs` +- Modify: `Cargo.toml` + +**Step 1: Write the failing test** + +Add focused tests proving Python detection marks these as test code: + +```python +def test_basic(): + assert True + +class TestApi: + def test_fetch(self): + assert True +``` + +Also add tests proving a production helper such as `def build_report():` is not test code, and path tests proving `tests/foo.py`, `test_bar.py`, `bar_test.py`, and `conftest.py` are whole-file tests. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test python_tests -v` +Expected: FAIL because Python parsing and path classification do not exist yet. + +**Step 3: Write minimal implementation** + +Add `tree-sitter-python` and implement Python helpers for: + +- whole-file test path detection +- line-region detection for test functions and `class Test*` +- untracked file splitting +- tracked patch splitting + +Keep the first version intentionally narrow and pytest-oriented. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test python_tests -v` +Expected: PASS + +### Task 4: Replace the Rust-only builder with shared multi-language test filtering + +**Files:** +- Create: `src/test_filter.rs` +- Modify: `src/main.rs` +- Modify: `src/filter.rs` +- Modify: `src/lang/rust.rs` +- Modify: `src/lang/python.rs` + +**Step 1: Write the failing test** + +Add or update smoke coverage to prove: + +- `--lang py` default output excludes Python test-only changes +- `--lang py --test` includes only Python test changes +- `--lang rs,py` handles both languages in one run + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test cli_smoke -v` +Expected: FAIL because `main.rs` still only builds test-aware stats for Rust files. + +**Step 3: Write minimal implementation** + +Extract the current Rust-only test-aware stat builder from `main.rs` into a shared module. Generalize the source-loading helpers so they operate on selected language kinds, not only Rust. Dispatch whole-file test detection and region splitting through the relevant language backend, while preserving current Rust behavior. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test cli_smoke -v` +Expected: PASS + +### Task 5: Preserve Rust behavior during the refactor + +**Files:** +- Modify: `tests/rust_tests.rs` +- Modify: `tests/cli_smoke.rs` +- Modify: `tests/lang_filter.rs` +- Modify: `src/rust_tests.rs` +- Modify: `src/lang/rust.rs` + +**Step 1: Write the failing test** + +Add one regression proving a Rust integration test file is still treated as a whole-file test after the backend extraction, and one regression proving mixed Rust source with `#[cfg(test)]` still splits lines correctly. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test rust_tests --test cli_smoke -v` +Expected: FAIL if the refactor changes Rust classification behavior. + +**Step 3: Write minimal implementation** + +Move Rust-specific orchestration into `src/lang/rust.rs` without changing the existing region and imported-module semantics. Keep `src/rust_tests.rs` as a parsing helper if that reduces churn. + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test rust_tests --test cli_smoke -v` +Expected: PASS + +### Task 6: Update help text and docs for Python support + +**Files:** +- Modify: `src/cli.rs` +- Modify: `README.md` +- Modify: `tests/cli_smoke.rs` + +**Step 1: Write the failing test** + +Adjust help-text expectations so examples mention `--lang py` as a supported, test-aware language path and keep the existing Rust-default examples intact. + +**Step 2: Run test to verify it fails** + +Run: `cargo test --test cli_smoke help_mentions_common_examples -v` +Expected: FAIL because help text still only reflects the old language set. + +**Step 3: Write minimal implementation** + +Update `after_help`, usage examples, and README notes to explain: + +- `--lang py` +- pytest-oriented Python test detection +- default `--lang rs` +- `--no-test-filter` behavior across selected languages + +**Step 4: Run test to verify it passes** + +Run: `cargo test --test cli_smoke help_mentions_common_examples -v` +Expected: PASS + +### Task 7: Run the full verification suite + +**Files:** +- Modify: none + +**Step 1: Run targeted tests** + +Run: + +```bash +cargo test --test lang_filter -v +cargo test --test python_tests -v +cargo test --test rust_tests -v +cargo test --test cli_smoke -v +``` + +Expected: PASS + +**Step 2: Run the full test suite** + +Run: `cargo test -v` +Expected: PASS + +**Step 3: Run lint if available** + +Run: `cargo clippy --all-targets --all-features -- -D warnings` +Expected: PASS + +**Step 4: Commit** + +```bash +git add Cargo.toml Cargo.lock src tests README.md docs/plans/2026-03-21-python-lang-support-design.md docs/plans/2026-03-21-python-lang-support.md +git commit -m "feat: add python test-aware language support" +``` diff --git a/src/cli.rs b/src/cli.rs index c2d82ff..2359dcd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,7 +11,7 @@ pub enum TestFilterMode { #[command(name = "git-diff-stat")] #[command(about = "Enhanced git diff --stat with untracked and test filtering")] #[command( - after_help = "Examples:\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --test" + after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --test\n\nDefaults:\n --lang rs,py\n test filter: --no-test" )] pub struct Cli { #[arg(long, conflicts_with_all = ["no_test", "no_test_filter"])] @@ -29,7 +29,7 @@ pub struct Cli { #[arg(long, conflicts_with_all = ["commit", "revisions"])] pub last: bool, - #[arg(long, value_name = "LANGS", default_value = "rs")] + #[arg(long, value_name = "LANGS", default_value = "rs,py")] pub lang: Option, #[arg(value_name = "REVISION")] diff --git a/src/lang.rs b/src/lang.rs deleted file mode 100644 index 0e3d6a0..0000000 --- a/src/lang.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::path::Path; - -use crate::change::FileChange; - -pub fn filter_by_langs(changes: &[FileChange], langs: &[&str]) -> Result, String> { - let requested = langs - .iter() - .map(|lang| normalize_lang(lang)) - .collect::>(); - - Ok(changes - .iter() - .filter(|change| { - detect_language(&change.path) - .map(|language| requested.contains(&language)) - .unwrap_or(false) - }) - .cloned() - .collect()) -} - -fn normalize_lang(lang: &str) -> &str { - lang.trim() -} - -fn detect_language(path: &str) -> Option<&'static str> { - match Path::new(path).extension().and_then(|ext| ext.to_str()) { - Some("rs") => Some("rs"), - Some("js") => Some("js"), - Some("ts") => Some("ts"), - _ => None, - } -} diff --git a/src/lang/mod.rs b/src/lang/mod.rs new file mode 100644 index 0000000..a275be9 --- /dev/null +++ b/src/lang/mod.rs @@ -0,0 +1,53 @@ +use std::path::Path; + +use crate::change::FileChange; + +pub mod python; +pub mod rust; + +pub fn filter_by_langs(changes: &[FileChange], langs: &[&str]) -> Result, String> { + let requested = langs + .iter() + .map(|lang| normalize_lang(lang)) + .collect::>(); + + Ok(changes + .iter() + .filter(|change| { + detect_language(&change.path) + .map(|language| requested.contains(&language)) + .unwrap_or(false) + }) + .cloned() + .collect()) +} + +pub fn parse_langs(value: Option<&str>) -> Vec<&str> { + value + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +pub fn detect_language(path: &str) -> Option<&'static str> { + if rust::matches_path(path) { + Some("rs") + } else if python::matches_path(path) { + Some("py") + } else { + match Path::new(path).extension().and_then(|ext| ext.to_str()) { + Some("js") => Some("js"), + Some("ts") => Some("ts"), + _ => None, + } + } +} + +fn normalize_lang(lang: &str) -> &str { + lang.trim() +} diff --git a/src/lang/python.rs b/src/lang/python.rs new file mode 100644 index 0000000..329560f --- /dev/null +++ b/src/lang/python.rs @@ -0,0 +1,29 @@ +use std::path::Path; + +use crate::patch::FilePatch; +use crate::python_tests::{ + PythonTestSplit, collect_python_whole_test_paths, split_file_patch_for_python_tests, + split_untracked_python_source, +}; + +pub fn matches_path(path: &str) -> bool { + Path::new(path).extension().and_then(|ext| ext.to_str()) == Some("py") +} + +pub fn collect_whole_test_paths( + sources: &[(String, String)], +) -> Result, String> { + collect_python_whole_test_paths(sources) +} + +pub fn split_file_patch( + patch: &FilePatch, + old_source: &str, + new_source: &str, +) -> Result { + split_file_patch_for_python_tests(patch, old_source, new_source) +} + +pub fn split_untracked_source(source: &str) -> Result { + split_untracked_python_source(source) +} diff --git a/src/lang/rust.rs b/src/lang/rust.rs new file mode 100644 index 0000000..fbab81d --- /dev/null +++ b/src/lang/rust.rs @@ -0,0 +1,29 @@ +use std::path::Path; + +use crate::filter::{ + RustTestSplit, collect_rust_whole_test_paths, split_file_patch_for_rust_tests, + split_untracked_rust_source, +}; +use crate::patch::FilePatch; + +pub fn matches_path(path: &str) -> bool { + Path::new(path).extension().and_then(|ext| ext.to_str()) == Some("rs") +} + +pub fn collect_whole_test_paths( + sources: &[(String, String)], +) -> Result, String> { + collect_rust_whole_test_paths(sources) +} + +pub fn split_file_patch( + patch: &FilePatch, + old_source: &str, + new_source: &str, +) -> Result { + split_file_patch_for_rust_tests(patch, old_source, new_source) +} + +pub fn split_untracked_source(source: &str) -> Result { + split_untracked_rust_source(source) +} diff --git a/src/lib.rs b/src/lib.rs index 6258d70..41e9499 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,8 @@ pub mod filter; pub mod git; pub mod lang; pub mod patch; +pub mod python_tests; pub mod render; pub mod revision; pub mod rust_tests; +pub mod test_filter; diff --git a/src/main.rs b/src/main.rs index 2949696..555302a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,12 @@ -use std::collections::HashMap; use std::env; -use git_diff_stat::change::{FileChange, collect_changes}; +use git_diff_stat::change::collect_changes; use git_diff_stat::cli::{Cli, TestFilterMode}; -use git_diff_stat::filter::{ - collect_rust_whole_test_paths, split_file_patch_for_rust_tests, split_untracked_rust_source, -}; use git_diff_stat::git::Git; -use git_diff_stat::lang::filter_by_langs; -use git_diff_stat::patch::parse_patch; +use git_diff_stat::lang::{filter_by_langs, parse_langs}; use git_diff_stat::render::{DisplayStat, StatsDescription, render_stats}; use git_diff_stat::revision::RevisionSelection; +use git_diff_stat::test_filter::build_test_filtered_stats; fn main() { if let Err(error) = run() { @@ -24,15 +20,16 @@ fn run() -> Result<(), String> { let git = Git::new(env::current_dir().map_err(|error| format!("failed to read cwd: {error}"))?); let selection = RevisionSelection::from_cli(&cli)?; let mut changes = collect_changes(&git, &selection)?; - let langs = parse_langs(cli.lang.as_deref()).unwrap_or_default(); + let langs = parse_langs(cli.lang.as_deref()); if !langs.is_empty() { changes = filter_by_langs(&changes, &langs)?; } let stats = match cli.test_filter_mode() { - TestFilterMode::TestOnly => build_rust_test_stats(&git, &selection, &changes, true)?, - TestFilterMode::NonTestOnly => build_rust_test_stats(&git, &selection, &changes, false)?, + TestFilterMode::TestOnly | TestFilterMode::NonTestOnly => { + build_test_filtered_stats(&git, &selection, &changes, cli.test_filter_mode())? + } TestFilterMode::All => changes .into_iter() .map(|change| DisplayStat { @@ -53,164 +50,6 @@ fn run() -> Result<(), String> { Ok(()) } -fn build_rust_test_stats( - git: &Git, - selection: &RevisionSelection, - changes: &[FileChange], - test_only: bool, -) -> Result, String> { - let patch_output = git.diff_patch(&selection.git_diff_args())?; - let patch = parse_patch(&patch_output)?; - let patch_map = patch - .files - .into_iter() - .map(|file| (file.path.clone(), file)) - .collect::>(); - let endpoints = selection.endpoints(git)?; - let whole_test_paths = build_whole_test_paths(git, endpoints.as_ref())?; - let mut stats = Vec::new(); - - for change in changes { - if !change.old_path.ends_with(".rs") && !change.new_path.ends_with(".rs") { - continue; - } - - if change.added + change.deleted == 0 { - continue; - } - - let old_is_whole_test = whole_test_paths.old.contains(&change.old_path); - let new_is_whole_test = whole_test_paths.new.contains(&change.new_path); - let (added, deleted) = if old_is_whole_test || new_is_whole_test { - if test_only { - ( - if new_is_whole_test { change.added } else { 0 }, - if old_is_whole_test { change.deleted } else { 0 }, - ) - } else { - ( - if new_is_whole_test { 0 } else { change.added }, - if old_is_whole_test { 0 } else { change.deleted }, - ) - } - } else if change.untracked { - let source = git.read_worktree_file(&change.new_path)?; - let split = split_untracked_rust_source(&source)?; - if test_only { - (split.test_added, split.test_deleted) - } else { - (split.non_test_added, split.non_test_deleted) - } - } else { - let file_patch = patch_map - .get(&change.new_path) - .ok_or_else(|| format!("missing patch data for {}", change.path))?; - let old_source = match &endpoints { - Some(endpoints) => git - .show_file_at_revision(&endpoints.old, &change.old_path) - .unwrap_or_default(), - None => git.show_index_file(&change.old_path).unwrap_or_default(), - }; - let new_source = match &endpoints { - Some(endpoints) => git - .show_file_at_revision(&endpoints.new, &change.new_path) - .unwrap_or_default(), - None => git.read_worktree_file(&change.new_path).unwrap_or_default(), - }; - let split = split_file_patch_for_rust_tests(file_patch, &old_source, &new_source)?; - if test_only { - (split.test_added, split.test_deleted) - } else { - (split.non_test_added, split.non_test_deleted) - } - }; - - if added + deleted == 0 { - continue; - } - - stats.push(DisplayStat { - path: change.path.clone(), - added, - deleted, - }); - } - - Ok(stats) -} - -struct WholeTestPaths { - old: std::collections::HashSet, - new: std::collections::HashSet, -} - -fn build_whole_test_paths( - git: &Git, - endpoints: Option<&git_diff_stat::revision::RevisionEndpoints>, -) -> Result { - let (old_sources, new_sources) = match endpoints { - Some(endpoints) => ( - load_revision_rust_sources(git, &endpoints.old)?, - load_revision_rust_sources(git, &endpoints.new)?, - ), - None => ( - load_index_rust_sources(git)?, - load_worktree_rust_sources(git)?, - ), - }; - Ok(WholeTestPaths { - old: collect_rust_whole_test_paths(&old_sources)?, - new: collect_rust_whole_test_paths(&new_sources)?, - }) -} - -fn load_index_rust_sources(git: &Git) -> Result, String> { - load_rust_sources(git.tracked_files()?, |path| git.show_index_file(path)) -} - -fn load_worktree_rust_sources(git: &Git) -> Result, String> { - let mut paths = git.tracked_files()?; - paths.retain(|path| git.worktree_file_exists(path)); - paths.extend(git.untracked_files()?); - load_rust_sources(paths, |path| git.read_worktree_file(path)) -} - -fn load_revision_rust_sources(git: &Git, revision: &str) -> Result, String> { - load_rust_sources(git.revision_files(revision)?, |path| { - git.show_file_at_revision(revision, path) - }) -} - -fn load_rust_sources( - paths: Vec, - mut read_source: F, -) -> Result, String> -where - F: FnMut(&str) -> Result, -{ - let mut sources = Vec::new(); - - for path in paths { - if !path.ends_with(".rs") { - continue; - } - - sources.push((path.clone(), read_source(&path)?)); - } - - Ok(sources) -} - -fn parse_langs(value: Option<&str>) -> Option> { - value.map(|value| { - value - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .collect() - }) -} - fn describe_language_scope(langs: &[&str]) -> String { if langs.is_empty() { "所有文件".to_string() diff --git a/src/python_tests.rs b/src/python_tests.rs new file mode 100644 index 0000000..383bb3b --- /dev/null +++ b/src/python_tests.rs @@ -0,0 +1,222 @@ +use std::collections::HashSet; +use std::path::Path; + +use tree_sitter::{Node, Parser}; + +use crate::change::line_count; +use crate::patch::{FilePatch, LineKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestRegions { + regions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PythonTestSplit { + pub test_added: usize, + pub test_deleted: usize, + pub non_test_added: usize, + pub non_test_deleted: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LineRange { + start: usize, + end: usize, +} + +impl TestRegions { + pub fn contains_line(&self, line: usize) -> bool { + self.regions + .iter() + .any(|region| region.start <= line && line <= region.end) + } +} + +pub fn collect_python_whole_test_paths( + sources: &[(String, String)], +) -> Result, String> { + Ok(sources + .iter() + .map(|(path, _)| path) + .filter(|path| is_python_whole_test_path(path)) + .cloned() + .collect()) +} + +pub fn detect_test_regions(source: &str) -> Result { + let tree = parse_python_source(source)?; + let mut regions = Vec::new(); + collect_regions(tree.root_node(), source.as_bytes(), &mut regions)?; + + Ok(TestRegions { regions }) +} + +pub fn split_file_patch_for_python_tests( + patch: &FilePatch, + old_source: &str, + new_source: &str, +) -> Result { + let old_regions = detect_test_regions(old_source)?; + let new_regions = detect_test_regions(new_source)?; + let mut split = PythonTestSplit { + test_added: 0, + test_deleted: 0, + non_test_added: 0, + non_test_deleted: 0, + }; + + for event in &patch.line_events { + match event.kind { + LineKind::Added => { + let line = event + .new_line + .ok_or_else(|| "added line event missing new line".to_string())?; + if new_regions.contains_line(line) { + split.test_added += 1; + } else { + split.non_test_added += 1; + } + } + LineKind::Deleted => { + let line = event + .old_line + .ok_or_else(|| "deleted line event missing old line".to_string())?; + if old_regions.contains_line(line) { + split.test_deleted += 1; + } else { + split.non_test_deleted += 1; + } + } + } + } + + Ok(split) +} + +pub fn split_untracked_python_source(source: &str) -> Result { + let regions = detect_test_regions(source)?; + let mut split = PythonTestSplit { + test_added: 0, + test_deleted: 0, + non_test_added: 0, + non_test_deleted: 0, + }; + + for line in 1..=line_count(source) { + if regions.contains_line(line) { + split.test_added += 1; + } else { + split.non_test_added += 1; + } + } + + Ok(split) +} + +pub fn is_python_whole_test_path(path: &str) -> bool { + if Path::new(path).extension().and_then(|ext| ext.to_str()) != Some("py") { + return false; + } + + let filename = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if filename == "conftest.py" || filename.starts_with("test_") || filename.ends_with("_test.py") + { + return true; + } + + Path::new(path) + .components() + .any(|component| component.as_os_str() == "tests") +} + +fn parse_python_source(source: &str) -> Result { + let mut parser = Parser::new(); + let language = tree_sitter_python::LANGUAGE.into(); + parser + .set_language(&language) + .map_err(|error| format!("failed to load python grammar: {error}"))?; + + parser + .parse(source, None) + .ok_or_else(|| "failed to parse python source".to_string()) +} + +fn collect_regions( + node: Node<'_>, + source: &[u8], + regions: &mut Vec, +) -> Result<(), String> { + if let Some(range) = test_range_for_node(node, source)? { + regions.push(range); + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_regions(child, source, regions)?; + } + + Ok(()) +} + +fn test_range_for_node(node: Node<'_>, source: &[u8]) -> Result, String> { + if node.kind() == "decorated_definition" { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if is_test_definition(child, source)? { + let range = node.range(); + return Ok(Some(LineRange { + start: range.start_point.row + 1, + end: range.end_point.row + 1, + })); + } + } + return Ok(None); + } + + if is_test_definition(node, source)? { + let range = node.range(); + return Ok(Some(LineRange { + start: range.start_point.row + 1, + end: range.end_point.row + 1, + })); + } + + Ok(None) +} + +fn is_test_definition(node: Node<'_>, source: &[u8]) -> Result { + match node.kind() { + "function_definition" => Ok(extract_name(node, source)? + .map(|name| name.starts_with("test_")) + .unwrap_or(false)), + "class_definition" => Ok(extract_name(node, source)? + .map(|name| name.starts_with("Test")) + .unwrap_or(false)), + _ => Ok(false), + } +} + +fn extract_name(node: Node<'_>, source: &[u8]) -> Result, String> { + if let Some(name) = node.child_by_field_name("name") { + return name + .utf8_text(source) + .map(|text| Some(text.to_string())) + .map_err(|error| format!("invalid utf8 in python identifier: {error}")); + } + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind() == "identifier" { + return child + .utf8_text(source) + .map(|text| Some(text.to_string())) + .map_err(|error| format!("invalid utf8 in python identifier: {error}")); + } + } + + Ok(None) +} diff --git a/src/test_filter.rs b/src/test_filter.rs new file mode 100644 index 0000000..0ffd5b5 --- /dev/null +++ b/src/test_filter.rs @@ -0,0 +1,300 @@ +use std::collections::{HashMap, HashSet}; + +use crate::change::FileChange; +use crate::cli::TestFilterMode; +use crate::git::Git; +use crate::lang::{detect_language, python, rust}; +use crate::patch::parse_patch; +use crate::render::DisplayStat; +use crate::revision::{RevisionEndpoints, RevisionSelection}; + +pub fn build_test_filtered_stats( + git: &Git, + selection: &RevisionSelection, + changes: &[FileChange], + mode: TestFilterMode, +) -> Result, String> { + let patch_output = git.diff_patch(&selection.git_diff_args())?; + let patch = parse_patch(&patch_output)?; + let patch_map = patch + .files + .into_iter() + .map(|file| (file.path.clone(), file)) + .collect::>(); + let endpoints = selection.endpoints(git)?; + let whole_test_paths = build_whole_test_paths(git, endpoints.as_ref())?; + let context = BuildContext { + git, + endpoints: &endpoints, + patch_map: &patch_map, + mode, + }; + let mut stats = Vec::new(); + + for change in changes { + if change.added + change.deleted == 0 { + continue; + } + + let Some(language) = change_language(change) else { + continue; + }; + + let (added, deleted) = match language { + "rs" => build_counts_for_rust(&context, &whole_test_paths, change)?, + "py" => build_counts_for_python(&context, &whole_test_paths, change)?, + _ => continue, + }; + + if added + deleted == 0 { + continue; + } + + stats.push(DisplayStat { + path: change.path.clone(), + added, + deleted, + }); + } + + Ok(stats) +} + +struct WholeTestPaths { + old: HashMap<&'static str, HashSet>, + new: HashMap<&'static str, HashSet>, +} + +struct BuildContext<'a> { + git: &'a Git, + endpoints: &'a Option, + patch_map: &'a HashMap, + mode: TestFilterMode, +} + +fn build_whole_test_paths( + git: &Git, + endpoints: Option<&RevisionEndpoints>, +) -> Result { + let (old_sources, new_sources) = match endpoints { + Some(endpoints) => ( + load_revision_sources(git, &endpoints.old)?, + load_revision_sources(git, &endpoints.new)?, + ), + None => (load_index_sources(git)?, load_worktree_sources(git)?), + }; + + let mut old = HashMap::new(); + old.insert("rs", rust::collect_whole_test_paths(&old_sources)?); + old.insert("py", python::collect_whole_test_paths(&old_sources)?); + + let mut new = HashMap::new(); + new.insert("rs", rust::collect_whole_test_paths(&new_sources)?); + new.insert("py", python::collect_whole_test_paths(&new_sources)?); + + Ok(WholeTestPaths { old, new }) +} + +fn build_counts_for_rust( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, +) -> Result<(usize, usize), String> { + build_counts( + context, + whole_test_paths.old.get("rs"), + whole_test_paths.new.get("rs"), + change, + rust::split_untracked_source, + rust::split_file_patch, + ) +} + +fn build_counts_for_python( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, +) -> Result<(usize, usize), String> { + build_counts( + context, + whole_test_paths.old.get("py"), + whole_test_paths.new.get("py"), + change, + python::split_untracked_source, + python::split_file_patch, + ) +} + +fn build_counts( + context: &BuildContext<'_>, + old_whole_test_paths: Option<&HashSet>, + new_whole_test_paths: Option<&HashSet>, + change: &FileChange, + split_untracked: UntrackedFn, + split_patch: PatchFn, +) -> Result<(usize, usize), String> +where + Split: TestSplitCounts, + UntrackedFn: Fn(&str) -> Result, + PatchFn: Fn(&crate::patch::FilePatch, &str, &str) -> Result, +{ + let old_is_whole_test = old_whole_test_paths + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = new_whole_test_paths + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + if old_is_whole_test || new_is_whole_test { + return Ok(select_counts_from_whole_file( + change, + old_is_whole_test, + new_is_whole_test, + context.mode, + )); + } + + if change.untracked { + let source = context.git.read_worktree_file(&change.new_path)?; + let split = split_untracked(&source)?; + return Ok(select_counts_from_split(&split, context.mode)); + } + + let file_patch = context + .patch_map + .get(&change.new_path) + .ok_or_else(|| format!("missing patch data for {}", change.path))?; + let old_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.old, &change.old_path) + .unwrap_or_default(), + None => context + .git + .show_index_file(&change.old_path) + .unwrap_or_default(), + }; + let new_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.new, &change.new_path) + .unwrap_or_default(), + None => context + .git + .read_worktree_file(&change.new_path) + .unwrap_or_default(), + }; + let split = split_patch(file_patch, &old_source, &new_source)?; + Ok(select_counts_from_split(&split, context.mode)) +} + +trait TestSplitCounts { + fn test_added(&self) -> usize; + fn test_deleted(&self) -> usize; + fn non_test_added(&self) -> usize; + fn non_test_deleted(&self) -> usize; +} + +impl TestSplitCounts for crate::filter::RustTestSplit { + fn test_added(&self) -> usize { + self.test_added + } + + fn test_deleted(&self) -> usize { + self.test_deleted + } + + fn non_test_added(&self) -> usize { + self.non_test_added + } + + fn non_test_deleted(&self) -> usize { + self.non_test_deleted + } +} + +impl TestSplitCounts for crate::python_tests::PythonTestSplit { + fn test_added(&self) -> usize { + self.test_added + } + + fn test_deleted(&self) -> usize { + self.test_deleted + } + + fn non_test_added(&self) -> usize { + self.non_test_added + } + + fn non_test_deleted(&self) -> usize { + self.non_test_deleted + } +} + +fn select_counts_from_whole_file( + change: &FileChange, + old_is_whole_test: bool, + new_is_whole_test: bool, + mode: TestFilterMode, +) -> (usize, usize) { + match mode { + TestFilterMode::TestOnly => ( + if new_is_whole_test { change.added } else { 0 }, + if old_is_whole_test { change.deleted } else { 0 }, + ), + TestFilterMode::NonTestOnly => ( + if new_is_whole_test { 0 } else { change.added }, + if old_is_whole_test { 0 } else { change.deleted }, + ), + TestFilterMode::All => (change.added, change.deleted), + } +} + +fn select_counts_from_split(split: &impl TestSplitCounts, mode: TestFilterMode) -> (usize, usize) { + match mode { + TestFilterMode::TestOnly => (split.test_added(), split.test_deleted()), + TestFilterMode::NonTestOnly => (split.non_test_added(), split.non_test_deleted()), + TestFilterMode::All => ( + split.test_added() + split.non_test_added(), + split.test_deleted() + split.non_test_deleted(), + ), + } +} + +fn change_language(change: &FileChange) -> Option<&'static str> { + detect_language(&change.new_path).or_else(|| detect_language(&change.old_path)) +} + +fn load_index_sources(git: &Git) -> Result, String> { + load_sources(git.tracked_files()?, |path| git.show_index_file(path)) +} + +fn load_worktree_sources(git: &Git) -> Result, String> { + let mut paths = git.tracked_files()?; + paths.retain(|path| git.worktree_file_exists(path)); + paths.extend(git.untracked_files()?); + load_sources(paths, |path| git.read_worktree_file(path)) +} + +fn load_revision_sources(git: &Git, revision: &str) -> Result, String> { + load_sources(git.revision_files(revision)?, |path| { + git.show_file_at_revision(revision, path) + }) +} + +fn load_sources(paths: Vec, mut read_source: F) -> Result, String> +where + F: FnMut(&str) -> Result, +{ + let mut sources = Vec::new(); + + for path in paths { + if detect_language(&path).is_none() { + continue; + } + + sources.push((path.clone(), read_source(&path)?)); + } + + Ok(sources) +} diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 9c21de5..95fac4d 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -41,7 +41,7 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs 文件中,非测试代码统计如下:", + "未提交的 rs,py 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")); } @@ -90,7 +90,7 @@ fn last_flag_reports_head_patch() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/tracked.rs")) .stdout(predicate::str::contains("1 insertion")); @@ -152,7 +152,7 @@ fn default_filters_to_rust_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs 文件中,非测试代码统计如下:", + "最后一次提交的 rs,py 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs").not()) @@ -215,13 +215,71 @@ fn no_test_filter_includes_all_rust_changes_but_keeps_default_lang() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs")) .stdout(predicate::str::contains("web.js").not()); } +#[test] +fn default_lang_includes_rust_and_python_non_test_changes() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("app")).unwrap(); + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 41\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 41\n\n\ndef test_inline() -> None:\n assert True\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert True\n", + ) + .unwrap(); + run_git( + tempdir.path(), + ["add", "src/lib.rs", "app/main.py", "tests/test_app.py"], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 42\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 42\n\n\ndef test_inline() -> None:\n assert False\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert False\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .assert() + .success() + .stdout(predicate::str::contains( + "未提交的 rs,py 文件中,非测试代码统计如下:", + )) + .stdout(predicate::str::contains("src/lib.rs")) + .stdout(predicate::str::contains("app/main.py")) + .stdout(predicate::str::contains("tests/test_app.py").not()); +} + #[test] fn revision_range_output_mentions_range_langs_and_test_mode() { let tempdir = tempdir().unwrap(); @@ -267,6 +325,182 @@ fn revision_range_output_mentions_range_langs_and_test_mode() { .stdout(predicate::str::contains("web.js")); } +#[test] +fn explicit_python_lang_uses_python_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("app")).unwrap(); + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 41\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 41\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs", "app/main.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 42\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 42\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "py", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains( + "未提交的 py 文件中,测试与非测试代码统计如下:", + )) + .stdout(predicate::str::contains("app/main.py")) + .stdout(predicate::str::contains("src/lib.rs").not()); +} + +#[test] +fn python_default_non_test_filter_excludes_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::write( + tempdir.path().join("src/app.py"), + "def answer() -> int:\n return 41\n\n\ndef test_inline() -> None:\n assert True\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert True\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/app.py", "tests/test_app.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/app.py"), + "def answer() -> int:\n return 42\n\n\ndef test_inline() -> None:\n assert False\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert False\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "py"]) + .assert() + .success() + .stdout(predicate::str::contains( + "未提交的 py 文件中,非测试代码统计如下:", + )) + .stdout(predicate::str::contains("src/app.py")) + .stdout(predicate::str::contains("tests/test_app.py").not()); +} + +#[test] +fn python_test_filter_includes_test_files_and_regions() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::write( + tempdir.path().join("src/app.py"), + "def answer() -> int:\n return 41\n\n\ndef test_inline() -> None:\n assert True\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert True\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/app.py", "tests/test_app.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/app.py"), + "def answer() -> int:\n return 42\n\n\ndef test_inline() -> None:\n assert False\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_external() -> None:\n assert False\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "py", "--test"]) + .assert() + .success() + .stdout(predicate::str::contains( + "未提交的 py 文件中,测试代码统计如下:", + )) + .stdout(predicate::str::contains("src/app.py")) + .stdout(predicate::str::contains("tests/test_app.py")); +} + +#[test] +fn mixed_rust_and_python_non_test_filter_handles_both_languages() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("app")).unwrap(); + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 41\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 41\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs", "app/main.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 42\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 42\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "rs,py"]) + .assert() + .success() + .stdout(predicate::str::contains( + "未提交的 rs,py 文件中,非测试代码统计如下:", + )) + .stdout(predicate::str::contains("src/lib.rs")) + .stdout(predicate::str::contains("app/main.py")); +} + #[test] fn test_filter_counts_rust_integration_test_files_as_test() { let tempdir = tempdir().unwrap(); diff --git a/tests/lang_filter.rs b/tests/lang_filter.rs index ca908d1..2e40eea 100644 --- a/tests/lang_filter.rs +++ b/tests/lang_filter.rs @@ -27,3 +27,30 @@ fn filters_to_requested_extensions() { assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].path, "src/lib.rs"); } + +#[test] +fn filters_to_python_extension() { + let changes = vec![ + FileChange { + path: "src/lib.rs".to_string(), + old_path: "src/lib.rs".to_string(), + new_path: "src/lib.rs".to_string(), + added: 3, + deleted: 1, + untracked: false, + }, + FileChange { + path: "app/main.py".to_string(), + old_path: "app/main.py".to_string(), + new_path: "app/main.py".to_string(), + added: 5, + deleted: 0, + untracked: false, + }, + ]; + + let filtered = filter_by_langs(&changes, &["py"]).unwrap(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].path, "app/main.py"); +} diff --git a/tests/python_tests.rs b/tests/python_tests.rs new file mode 100644 index 0000000..565af6b --- /dev/null +++ b/tests/python_tests.rs @@ -0,0 +1,119 @@ +use git_diff_stat::patch::{FilePatch, LineEvent, LineKind}; +use git_diff_stat::python_tests::{ + collect_python_whole_test_paths, detect_test_regions, split_file_patch_for_python_tests, + split_untracked_python_source, +}; + +const PYTHON_SOURCE: &str = "\ +def build_report(): + return 1 + + +def test_basic(): + assert True + + +class TestApi: + def test_fetch(self): + assert True + + +class Helper: + def build(self): + return 1 +"; + +#[test] +fn identifies_python_test_regions() { + let regions = detect_test_regions(PYTHON_SOURCE).unwrap(); + + assert!(!regions.contains_line(1)); + assert!(!regions.contains_line(2)); + assert!(regions.contains_line(5)); + assert!(regions.contains_line(6)); + assert!(regions.contains_line(9)); + assert!(regions.contains_line(10)); + assert!(regions.contains_line(11)); + assert!(!regions.contains_line(14)); +} + +#[test] +fn collects_pytest_style_whole_test_paths() { + let sources = vec![ + ("src/app.py".to_string(), String::new()), + ("tests/test_app.py".to_string(), String::new()), + ("pkg/test_utils.py".to_string(), String::new()), + ("pkg/helpers_test.py".to_string(), String::new()), + ("pkg/conftest.py".to_string(), String::new()), + ]; + + let whole_test_paths = collect_python_whole_test_paths(&sources).unwrap(); + + assert!(whole_test_paths.contains("tests/test_app.py")); + assert!(whole_test_paths.contains("pkg/test_utils.py")); + assert!(whole_test_paths.contains("pkg/helpers_test.py")); + assert!(whole_test_paths.contains("pkg/conftest.py")); + assert!(!whole_test_paths.contains("src/app.py")); +} + +#[test] +fn splits_python_patch_lines_into_test_and_non_test_counts() { + let old_source = "\ +def build_report(): + return 1 + + +def test_basic(): + assert True +"; + let new_source = "\ +def build_report(): + return 2 + + +def test_basic(): + assert False +"; + let patch = FilePatch { + path: "src/report.py".to_string(), + line_events: vec![ + LineEvent { + kind: LineKind::Deleted, + old_line: Some(2), + new_line: None, + }, + LineEvent { + kind: LineKind::Added, + old_line: None, + new_line: Some(2), + }, + LineEvent { + kind: LineKind::Deleted, + old_line: Some(6), + new_line: None, + }, + LineEvent { + kind: LineKind::Added, + old_line: None, + new_line: Some(6), + }, + ], + }; + + let split = split_file_patch_for_python_tests(&patch, old_source, new_source).unwrap(); + + assert_eq!(split.non_test_added, 1); + assert_eq!(split.non_test_deleted, 1); + assert_eq!(split.test_added, 1); + assert_eq!(split.test_deleted, 1); +} + +#[test] +fn splits_untracked_python_source_into_test_and_non_test_counts() { + let split = split_untracked_python_source(PYTHON_SOURCE).unwrap(); + + assert_eq!(split.non_test_added, 11); + assert_eq!(split.test_added, 5); + assert_eq!(split.test_deleted, 0); + assert_eq!(split.non_test_deleted, 0); +} From 69a935187242a0e70a70cdf1ad3b2fb647b8489e Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:21:40 +0800 Subject: [PATCH 02/14] fix: address python review feedback --- src/main.rs | 2 +- src/python_tests.rs | 28 ++++++++++++++++++++------ src/test_filter.rs | 47 ++++++++++++++++++++++++++++++++----------- tests/cli_smoke.rs | 32 +++++++++++++++++++++++++++++ tests/python_tests.rs | 29 ++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 19 deletions(-) diff --git a/src/main.rs b/src/main.rs index 555302a..2ffc99d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,7 +28,7 @@ fn run() -> Result<(), String> { let stats = match cli.test_filter_mode() { TestFilterMode::TestOnly | TestFilterMode::NonTestOnly => { - build_test_filtered_stats(&git, &selection, &changes, cli.test_filter_mode())? + build_test_filtered_stats(&git, &selection, &changes, &langs, cli.test_filter_mode())? } TestFilterMode::All => changes .into_iter() diff --git a/src/python_tests.rs b/src/python_tests.rs index 383bb3b..6decb16 100644 --- a/src/python_tests.rs +++ b/src/python_tests.rs @@ -190,16 +190,32 @@ fn test_range_for_node(node: Node<'_>, source: &[u8]) -> Result, source: &[u8]) -> Result { match node.kind() { - "function_definition" => Ok(extract_name(node, source)? - .map(|name| name.starts_with("test_")) - .unwrap_or(false)), - "class_definition" => Ok(extract_name(node, source)? - .map(|name| name.starts_with("Test")) - .unwrap_or(false)), + "function_definition" => Ok(is_top_level_definition(node) + && extract_name(node, source)? + .map(|name| name.starts_with("test_")) + .unwrap_or(false)), + "class_definition" => Ok(is_top_level_definition(node) + && extract_name(node, source)? + .map(|name| name.starts_with("Test")) + .unwrap_or(false)), _ => Ok(false), } } +fn is_top_level_definition(node: Node<'_>) -> bool { + let mut parent = node.parent(); + + while let Some(current) = parent { + match current.kind() { + "block" | "decorated_definition" => parent = current.parent(), + "module" => return true, + _ => return false, + } + } + + false +} + fn extract_name(node: Node<'_>, source: &[u8]) -> Result, String> { if let Some(name) = node.child_by_field_name("name") { return name diff --git a/src/test_filter.rs b/src/test_filter.rs index 0ffd5b5..94da4b3 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -12,6 +12,7 @@ pub fn build_test_filtered_stats( git: &Git, selection: &RevisionSelection, changes: &[FileChange], + langs: &[&str], mode: TestFilterMode, ) -> Result, String> { let patch_output = git.diff_patch(&selection.git_diff_args())?; @@ -22,7 +23,7 @@ pub fn build_test_filtered_stats( .map(|file| (file.path.clone(), file)) .collect::>(); let endpoints = selection.endpoints(git)?; - let whole_test_paths = build_whole_test_paths(git, endpoints.as_ref())?; + let whole_test_paths = build_whole_test_paths(git, endpoints.as_ref(), langs)?; let context = BuildContext { git, endpoints: &endpoints, @@ -75,13 +76,17 @@ struct BuildContext<'a> { fn build_whole_test_paths( git: &Git, endpoints: Option<&RevisionEndpoints>, + langs: &[&str], ) -> Result { let (old_sources, new_sources) = match endpoints { Some(endpoints) => ( - load_revision_sources(git, &endpoints.old)?, - load_revision_sources(git, &endpoints.new)?, + load_revision_sources(git, &endpoints.old, langs)?, + load_revision_sources(git, &endpoints.new, langs)?, + ), + None => ( + load_index_sources(git, langs)?, + load_worktree_sources(git, langs)?, ), - None => (load_index_sources(git)?, load_worktree_sources(git)?), }; let mut old = HashMap::new(); @@ -265,31 +270,41 @@ fn change_language(change: &FileChange) -> Option<&'static str> { detect_language(&change.new_path).or_else(|| detect_language(&change.old_path)) } -fn load_index_sources(git: &Git) -> Result, String> { - load_sources(git.tracked_files()?, |path| git.show_index_file(path)) +fn load_index_sources(git: &Git, langs: &[&str]) -> Result, String> { + load_sources(git.tracked_files()?, langs, |path| { + git.show_index_file(path) + }) } -fn load_worktree_sources(git: &Git) -> Result, String> { +fn load_worktree_sources(git: &Git, langs: &[&str]) -> Result, String> { let mut paths = git.tracked_files()?; paths.retain(|path| git.worktree_file_exists(path)); paths.extend(git.untracked_files()?); - load_sources(paths, |path| git.read_worktree_file(path)) + load_sources(paths, langs, |path| git.read_worktree_file(path)) } -fn load_revision_sources(git: &Git, revision: &str) -> Result, String> { - load_sources(git.revision_files(revision)?, |path| { +fn load_revision_sources( + git: &Git, + revision: &str, + langs: &[&str], +) -> Result, String> { + load_sources(git.revision_files(revision)?, langs, |path| { git.show_file_at_revision(revision, path) }) } -fn load_sources(paths: Vec, mut read_source: F) -> Result, String> +fn load_sources( + paths: Vec, + langs: &[&str], + mut read_source: F, +) -> Result, String> where F: FnMut(&str) -> Result, { let mut sources = Vec::new(); for path in paths { - if detect_language(&path).is_none() { + if !should_load_source(&path, langs) { continue; } @@ -298,3 +313,11 @@ where Ok(sources) } + +fn should_load_source(path: &str, langs: &[&str]) -> bool { + let Some(language) = detect_language(path) else { + return false; + }; + + langs.is_empty() || langs.contains(&language) +} diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 95fac4d..1091f0e 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -369,6 +369,38 @@ fn explicit_python_lang_uses_python_files() { .stdout(predicate::str::contains("src/lib.rs").not()); } +#[test] +fn explicit_python_lang_skips_loading_unselected_rust_sources() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::create_dir_all(tempdir.path().join("app")).unwrap(); + fs::write(tempdir.path().join("src/lib.rs"), [0xff, 0xfe, 0xfd]).unwrap(); + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 41\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs", "app/main.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("app/main.py"), + "def answer() -> int:\n return 42\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--lang", "py"]) + .assert() + .success() + .stdout(predicate::str::contains("app/main.py")) + .stdout(predicate::str::contains("src/lib.rs").not()); +} + #[test] fn python_default_non_test_filter_excludes_test_files() { let tempdir = tempdir().unwrap(); diff --git a/tests/python_tests.rs b/tests/python_tests.rs index 565af6b..edb0188 100644 --- a/tests/python_tests.rs +++ b/tests/python_tests.rs @@ -37,6 +37,35 @@ fn identifies_python_test_regions() { assert!(!regions.contains_line(14)); } +#[test] +fn ignores_test_named_methods_on_non_test_classes() { + let source = "\ +class Service: + def test_connection(self): + return True + + +class TestApi: + def test_fetch(self): + assert True + + +def test_top_level(): + assert True +"; + + let regions = detect_test_regions(source).unwrap(); + + assert!(!regions.contains_line(1)); + assert!(!regions.contains_line(2)); + assert!(!regions.contains_line(3)); + assert!(regions.contains_line(6)); + assert!(regions.contains_line(7)); + assert!(regions.contains_line(8)); + assert!(regions.contains_line(11)); + assert!(regions.contains_line(12)); +} + #[test] fn collects_pytest_style_whole_test_paths() { let sources = vec![ From e74bfd53a2db1b9ce3574657e5f213cf9de5867a Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:42:16 +0800 Subject: [PATCH 03/14] feat: default to all supported languages --- src/cli.rs | 4 ++-- src/lang/mod.rs | 8 +++++++- src/test_filter.rs | 1 + tests/cli_smoke.rs | 35 ++++++++++++++++++++++++++--------- tests/lang_filter.rs | 7 ++++++- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 2359dcd..f8439c4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,7 +11,7 @@ pub enum TestFilterMode { #[command(name = "git-diff-stat")] #[command(about = "Enhanced git diff --stat with untracked and test filtering")] #[command( - after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --test\n\nDefaults:\n --lang rs,py\n test filter: --no-test" + after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --test\n\nDefaults:\n --lang rs,py,js,ts\n test filter: --no-test" )] pub struct Cli { #[arg(long, conflicts_with_all = ["no_test", "no_test_filter"])] @@ -29,7 +29,7 @@ pub struct Cli { #[arg(long, conflicts_with_all = ["commit", "revisions"])] pub last: bool, - #[arg(long, value_name = "LANGS", default_value = "rs,py")] + #[arg(long, value_name = "LANGS")] pub lang: Option, #[arg(value_name = "REVISION")] diff --git a/src/lang/mod.rs b/src/lang/mod.rs index a275be9..1fcd1e4 100644 --- a/src/lang/mod.rs +++ b/src/lang/mod.rs @@ -2,6 +2,8 @@ use std::path::Path; use crate::change::FileChange; +const SUPPORTED_LANGS: &[&str] = &["rs", "py", "js", "ts"]; + pub mod python; pub mod rust; @@ -31,7 +33,11 @@ pub fn parse_langs(value: Option<&str>) -> Vec<&str> { .filter(|value| !value.is_empty()) .collect() }) - .unwrap_or_default() + .unwrap_or_else(|| supported_langs().to_vec()) +} + +pub fn supported_langs() -> &'static [&'static str] { + SUPPORTED_LANGS } pub fn detect_language(path: &str) -> Option<&'static str> { diff --git a/src/test_filter.rs b/src/test_filter.rs index 94da4b3..a3a4a66 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -44,6 +44,7 @@ pub fn build_test_filtered_stats( let (added, deleted) = match language { "rs" => build_counts_for_rust(&context, &whole_test_paths, change)?, "py" => build_counts_for_python(&context, &whole_test_paths, change)?, + "js" | "ts" => select_counts_from_whole_file(change, false, false, context.mode), _ => continue, }; diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 1091f0e..a6d56ff 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -26,6 +26,11 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { "pub fn answer() -> i32 {\n 41\n}\n", ) .unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); run_git(tempdir.path(), ["add", "src/lib.rs"]); run_git(tempdir.path(), ["commit", "-m", "initial"]); @@ -41,9 +46,10 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py 文件中,非测试代码统计如下:", + "未提交的 rs,py,js,ts 文件中,非测试代码统计如下:", )) - .stdout(predicate::str::contains("src/lib.rs")); + .stdout(predicate::str::contains("src/lib.rs")) + .stdout(predicate::str::contains("web.js")); } #[test] @@ -90,7 +96,7 @@ fn last_flag_reports_head_patch() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/tracked.rs")) .stdout(predicate::str::contains("1 insertion")); @@ -152,11 +158,11 @@ fn default_filters_to_rust_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py 文件中,非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs").not()) - .stdout(predicate::str::contains("web.js").not()); + .stdout(predicate::str::contains("web.js")); } #[test] @@ -215,11 +221,11 @@ fn no_test_filter_includes_all_rust_changes_but_keeps_default_lang() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs")) - .stdout(predicate::str::contains("web.js").not()); + .stdout(predicate::str::contains("web.js")); } #[test] @@ -240,6 +246,11 @@ fn default_lang_includes_rust_and_python_non_test_changes() { "def answer() -> int:\n return 41\n\n\ndef test_inline() -> None:\n assert True\n", ) .unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); fs::write( tempdir.path().join("tests/test_app.py"), "def test_external() -> None:\n assert True\n", @@ -247,7 +258,7 @@ fn default_lang_includes_rust_and_python_non_test_changes() { .unwrap(); run_git( tempdir.path(), - ["add", "src/lib.rs", "app/main.py", "tests/test_app.py"], + ["add", "src/lib.rs", "app/main.py", "web.js", "tests/test_app.py"], ); run_git(tempdir.path(), ["commit", "-m", "initial"]); @@ -261,6 +272,11 @@ fn default_lang_includes_rust_and_python_non_test_changes() { "def answer() -> int:\n return 42\n\n\ndef test_inline() -> None:\n assert False\n", ) .unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 42;\n", + ) + .unwrap(); fs::write( tempdir.path().join("tests/test_app.py"), "def test_external() -> None:\n assert False\n", @@ -273,10 +289,11 @@ fn default_lang_includes_rust_and_python_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py 文件中,非测试代码统计如下:", + "未提交的 rs,py,js,ts 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("app/main.py")) + .stdout(predicate::str::contains("web.js")) .stdout(predicate::str::contains("tests/test_app.py").not()); } diff --git a/tests/lang_filter.rs b/tests/lang_filter.rs index 2e40eea..b7b756b 100644 --- a/tests/lang_filter.rs +++ b/tests/lang_filter.rs @@ -1,5 +1,5 @@ use git_diff_stat::change::FileChange; -use git_diff_stat::lang::filter_by_langs; +use git_diff_stat::lang::{filter_by_langs, parse_langs}; #[test] fn filters_to_requested_extensions() { @@ -54,3 +54,8 @@ fn filters_to_python_extension() { assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].path, "app/main.py"); } + +#[test] +fn omitted_lang_defaults_to_all_supported_languages() { + assert_eq!(parse_langs(None), vec!["rs", "py", "js", "ts"]); +} From f4520ae04f3bf8e3f66cf58218d91f528367cd28 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:47:16 +0800 Subject: [PATCH 04/14] feat: add js and ts family language detection --- src/lang/javascript.rs | 13 ++++++++ src/lang/mod.rs | 11 ++----- tests/lang_filter.rs | 73 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 src/lang/javascript.rs diff --git a/src/lang/javascript.rs b/src/lang/javascript.rs new file mode 100644 index 0000000..0901b16 --- /dev/null +++ b/src/lang/javascript.rs @@ -0,0 +1,13 @@ +use std::path::Path; + +pub fn detect_language(path: &str) -> Option<&'static str> { + match Path::new(path).extension().and_then(|ext| ext.to_str()) { + Some("js") => Some("js"), + Some("ts") => Some("ts"), + Some("jsx") => Some("jsx"), + Some("tsx") => Some("tsx"), + Some("cjs") => Some("cjs"), + Some("mjs") => Some("mjs"), + _ => None, + } +} diff --git a/src/lang/mod.rs b/src/lang/mod.rs index 1fcd1e4..f488193 100644 --- a/src/lang/mod.rs +++ b/src/lang/mod.rs @@ -1,9 +1,8 @@ -use std::path::Path; - use crate::change::FileChange; -const SUPPORTED_LANGS: &[&str] = &["rs", "py", "js", "ts"]; +const SUPPORTED_LANGS: &[&str] = &["rs", "py", "js", "ts", "jsx", "tsx", "cjs", "mjs"]; +pub mod javascript; pub mod python; pub mod rust; @@ -46,11 +45,7 @@ pub fn detect_language(path: &str) -> Option<&'static str> { } else if python::matches_path(path) { Some("py") } else { - match Path::new(path).extension().and_then(|ext| ext.to_str()) { - Some("js") => Some("js"), - Some("ts") => Some("ts"), - _ => None, - } + javascript::detect_language(path) } } diff --git a/tests/lang_filter.rs b/tests/lang_filter.rs index b7b756b..5e1ef87 100644 --- a/tests/lang_filter.rs +++ b/tests/lang_filter.rs @@ -57,5 +57,76 @@ fn filters_to_python_extension() { #[test] fn omitted_lang_defaults_to_all_supported_languages() { - assert_eq!(parse_langs(None), vec!["rs", "py", "js", "ts"]); + assert_eq!( + parse_langs(None), + vec!["rs", "py", "js", "ts", "jsx", "tsx", "cjs", "mjs"] + ); +} + +#[test] +fn filters_js_ts_family_extensions_individually() { + let changes = vec![ + FileChange { + path: "web/app.js".to_string(), + old_path: "web/app.js".to_string(), + new_path: "web/app.js".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/app.ts".to_string(), + old_path: "web/app.ts".to_string(), + new_path: "web/app.ts".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/component.jsx".to_string(), + old_path: "web/component.jsx".to_string(), + new_path: "web/component.jsx".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/component.tsx".to_string(), + old_path: "web/component.tsx".to_string(), + new_path: "web/component.tsx".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/config.cjs".to_string(), + old_path: "web/config.cjs".to_string(), + new_path: "web/config.cjs".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + FileChange { + path: "web/entry.mjs".to_string(), + old_path: "web/entry.mjs".to_string(), + new_path: "web/entry.mjs".to_string(), + added: 1, + deleted: 0, + untracked: false, + }, + ]; + + let jsx = filter_by_langs(&changes, &["jsx"]).unwrap(); + let tsx = filter_by_langs(&changes, &["tsx"]).unwrap(); + let cjs = filter_by_langs(&changes, &["cjs"]).unwrap(); + let mjs = filter_by_langs(&changes, &["mjs"]).unwrap(); + + assert_eq!(jsx.len(), 1); + assert_eq!(jsx[0].path, "web/component.jsx"); + assert_eq!(tsx.len(), 1); + assert_eq!(tsx[0].path, "web/component.tsx"); + assert_eq!(cjs.len(), 1); + assert_eq!(cjs[0].path, "web/config.cjs"); + assert_eq!(mjs.len(), 1); + assert_eq!(mjs[0].path, "web/entry.mjs"); } From 18efcea8d92d8e51410a830b412387d6ad2886e7 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:48:24 +0800 Subject: [PATCH 05/14] feat: classify js and ts family test paths --- src/lang/javascript.rs | 37 +++++++++++++++++++++++++++++++++++++ tests/javascript_tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/javascript_tests.rs diff --git a/src/lang/javascript.rs b/src/lang/javascript.rs index 0901b16..4a1e056 100644 --- a/src/lang/javascript.rs +++ b/src/lang/javascript.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::Path; pub fn detect_language(path: &str) -> Option<&'static str> { @@ -11,3 +12,39 @@ pub fn detect_language(path: &str) -> Option<&'static str> { _ => None, } } + +pub fn collect_whole_test_paths( + sources: &[(String, String)], +) -> Result, String> { + Ok(sources + .iter() + .map(|(path, _)| path) + .filter(|path| is_whole_test_path(path)) + .cloned() + .collect()) +} + +fn is_whole_test_path(path: &str) -> bool { + let Some(language) = detect_language(path) else { + return false; + }; + + if Path::new(path).components().any(|component| { + matches!( + component.as_os_str().to_str(), + Some("__tests__" | "e2e" | "cypress" | "playwright") + ) + }) { + return true; + } + + let filename = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + let Some(stem) = filename.strip_suffix(&format!(".{language}")) else { + return false; + }; + + stem.ends_with(".test") || stem.ends_with(".spec") || stem.ends_with(".cy") +} diff --git a/tests/javascript_tests.rs b/tests/javascript_tests.rs new file mode 100644 index 0000000..e00af24 --- /dev/null +++ b/tests/javascript_tests.rs @@ -0,0 +1,28 @@ +use git_diff_stat::lang::javascript::collect_whole_test_paths; + +#[test] +fn collects_javascript_and_typescript_test_paths() { + let sources = vec![ + ("src/app.ts".to_string(), String::new()), + ("src/__tests__/app.ts".to_string(), String::new()), + ("web/app.test.tsx".to_string(), String::new()), + ("web/app.spec.jsx".to_string(), String::new()), + ("tests/e2e/login.ts".to_string(), String::new()), + ("cypress/e2e/home.cy.js".to_string(), String::new()), + ("playwright/auth.spec.ts".to_string(), String::new()), + ("playwright.config.ts".to_string(), String::new()), + ("scripts/build.mjs".to_string(), String::new()), + ]; + + let whole_test_paths = collect_whole_test_paths(&sources).unwrap(); + + assert!(whole_test_paths.contains("src/__tests__/app.ts")); + assert!(whole_test_paths.contains("web/app.test.tsx")); + assert!(whole_test_paths.contains("web/app.spec.jsx")); + assert!(whole_test_paths.contains("tests/e2e/login.ts")); + assert!(whole_test_paths.contains("cypress/e2e/home.cy.js")); + assert!(whole_test_paths.contains("playwright/auth.spec.ts")); + assert!(!whole_test_paths.contains("src/app.ts")); + assert!(!whole_test_paths.contains("playwright.config.ts")); + assert!(!whole_test_paths.contains("scripts/build.mjs")); +} From bc0b1721ac16cad257d9b63ba5b7597c2b90dbdd Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:50:51 +0800 Subject: [PATCH 06/14] feat: add js and ts family test filtering --- src/lang/javascript.rs | 6 ++ src/test_filter.rs | 47 ++++++++++++- tests/cli_smoke.rs | 153 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/lang/javascript.rs b/src/lang/javascript.rs index 4a1e056..e9de245 100644 --- a/src/lang/javascript.rs +++ b/src/lang/javascript.rs @@ -1,6 +1,12 @@ use std::collections::HashSet; use std::path::Path; +const JS_TS_FAMILY_LANGS: &[&str] = &["js", "ts", "jsx", "tsx", "cjs", "mjs"]; + +pub fn family_langs() -> &'static [&'static str] { + JS_TS_FAMILY_LANGS +} + pub fn detect_language(path: &str) -> Option<&'static str> { match Path::new(path).extension().and_then(|ext| ext.to_str()) { Some("js") => Some("js"), diff --git a/src/test_filter.rs b/src/test_filter.rs index a3a4a66..44e3904 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use crate::change::FileChange; use crate::cli::TestFilterMode; use crate::git::Git; -use crate::lang::{detect_language, python, rust}; +use crate::lang::{detect_language, javascript, python, rust}; use crate::patch::parse_patch; use crate::render::DisplayStat; use crate::revision::{RevisionEndpoints, RevisionSelection}; @@ -44,7 +44,9 @@ pub fn build_test_filtered_stats( let (added, deleted) = match language { "rs" => build_counts_for_rust(&context, &whole_test_paths, change)?, "py" => build_counts_for_python(&context, &whole_test_paths, change)?, - "js" | "ts" => select_counts_from_whole_file(change, false, false, context.mode), + "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => { + build_counts_for_javascript(&context, &whole_test_paths, change) + } _ => continue, }; @@ -93,10 +95,18 @@ fn build_whole_test_paths( let mut old = HashMap::new(); old.insert("rs", rust::collect_whole_test_paths(&old_sources)?); old.insert("py", python::collect_whole_test_paths(&old_sources)?); + let old_javascript_paths = javascript::collect_whole_test_paths(&old_sources)?; + for language in javascript::family_langs() { + old.insert(*language, old_javascript_paths.clone()); + } let mut new = HashMap::new(); new.insert("rs", rust::collect_whole_test_paths(&new_sources)?); new.insert("py", python::collect_whole_test_paths(&new_sources)?); + let new_javascript_paths = javascript::collect_whole_test_paths(&new_sources)?; + for language in javascript::family_langs() { + new.insert(*language, new_javascript_paths.clone()); + } Ok(WholeTestPaths { old, new }) } @@ -131,6 +141,23 @@ fn build_counts_for_python( ) } +fn build_counts_for_javascript( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, +) -> (usize, usize) { + let Some(language) = change_language(change) else { + return (0, 0); + }; + + select_counts_for_whole_file_only( + change, + whole_test_paths.old.get(language), + whole_test_paths.new.get(language), + context.mode, + ) +} + fn build_counts( context: &BuildContext<'_>, old_whole_test_paths: Option<&HashSet>, @@ -194,6 +221,22 @@ where Ok(select_counts_from_split(&split, context.mode)) } +fn select_counts_for_whole_file_only( + change: &FileChange, + old_whole_test_paths: Option<&HashSet>, + new_whole_test_paths: Option<&HashSet>, + mode: TestFilterMode, +) -> (usize, usize) { + let old_is_whole_test = old_whole_test_paths + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = new_whole_test_paths + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + select_counts_from_whole_file(change, old_is_whole_test, new_is_whole_test, mode) +} + trait TestSplitCounts { fn test_added(&self) -> usize; fn test_deleted(&self) -> usize; diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index a6d56ff..32106e6 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -550,6 +550,159 @@ fn mixed_rust_and_python_non_test_filter_handles_both_languages() { .stdout(predicate::str::contains("app/main.py")); } +#[test] +fn default_non_test_filter_excludes_javascript_and_typescript_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("tests/e2e")).unwrap(); + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.test.tsx"), + "test('app', () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/e2e/login.ts"), + "test('login', async () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + run_git( + tempdir.path(), + ["add", "web/app.tsx", "web/app.test.tsx", "tests/e2e/login.ts"], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.test.tsx"), + "test('app', () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("tests/e2e/login.ts"), + "test('login', async () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .assert() + .success() + .stdout(predicate::str::contains("web/app.tsx")) + .stdout(predicate::str::contains("web/app.test.tsx").not()) + .stdout(predicate::str::contains("tests/e2e/login.ts").not()); +} + +#[test] +fn test_filter_includes_javascript_and_typescript_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("cypress/e2e")).unwrap(); + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.spec.tsx"), + "test('app', () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("cypress/e2e/home.cy.js"), + "it('home', () => {\n expect(true).to.eq(true);\n});\n", + ) + .unwrap(); + run_git( + tempdir.path(), + ["add", "web/app.tsx", "web/app.spec.tsx", "cypress/e2e/home.cy.js"], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("web/app.spec.tsx"), + "test('app', () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("cypress/e2e/home.cy.js"), + "it('home', () => {\n expect(false).to.eq(false);\n});\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .arg("--test") + .assert() + .success() + .stdout(predicate::str::contains("web/app.tsx").not()) + .stdout(predicate::str::contains("web/app.spec.tsx")) + .stdout(predicate::str::contains("cypress/e2e/home.cy.js")); +} + +#[test] +fn no_test_filter_includes_javascript_and_typescript_test_files() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("playwright")).unwrap(); + fs::write( + tempdir.path().join("web/app.jsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("playwright/auth.spec.ts"), + "test('auth', async () => {\n expect(true).toBe(true);\n});\n", + ) + .unwrap(); + run_git( + tempdir.path(), + ["add", "web/app.jsx", "playwright/auth.spec.ts"], + ); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.jsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + fs::write( + tempdir.path().join("playwright/auth.spec.ts"), + "test('auth', async () => {\n expect(false).toBe(false);\n});\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .arg("--no-test-filter") + .assert() + .success() + .stdout(predicate::str::contains("web/app.jsx")) + .stdout(predicate::str::contains("playwright/auth.spec.ts")); +} + #[test] fn test_filter_counts_rust_integration_test_files_as_test() { let tempdir = tempdir().unwrap(); From d1c8339834e649fb1cd064f05b7ed0f591850803 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:52:58 +0800 Subject: [PATCH 07/14] perf: avoid bulk reading frontend sources --- src/test_filter.rs | 74 +++++++++++++++++++++++++++++++++++----------- tests/cli_smoke.rs | 31 +++++++++++++++++++ 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/test_filter.rs b/src/test_filter.rs index 44e3904..98eac5c 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -81,29 +81,45 @@ fn build_whole_test_paths( endpoints: Option<&RevisionEndpoints>, langs: &[&str], ) -> Result { - let (old_sources, new_sources) = match endpoints { + let (old_paths, new_paths) = match endpoints { Some(endpoints) => ( - load_revision_sources(git, &endpoints.old, langs)?, - load_revision_sources(git, &endpoints.new, langs)?, + load_revision_paths(git, &endpoints.old, langs)?, + load_revision_paths(git, &endpoints.new, langs)?, ), None => ( - load_index_sources(git, langs)?, - load_worktree_sources(git, langs)?, + load_index_paths(git, langs)?, + load_worktree_paths(git, langs)?, ), }; + let (old_rust_sources, new_rust_sources) = if langs.contains(&"rs") { + match endpoints { + Some(endpoints) => ( + load_revision_sources(git, &endpoints.old, &["rs"])?, + load_revision_sources(git, &endpoints.new, &["rs"])?, + ), + None => ( + load_index_sources(git, &["rs"])?, + load_worktree_sources(git, &["rs"])?, + ), + } + } else { + (Vec::new(), Vec::new()) + }; + let old_path_entries = path_entries(&old_paths); + let new_path_entries = path_entries(&new_paths); let mut old = HashMap::new(); - old.insert("rs", rust::collect_whole_test_paths(&old_sources)?); - old.insert("py", python::collect_whole_test_paths(&old_sources)?); - let old_javascript_paths = javascript::collect_whole_test_paths(&old_sources)?; + old.insert("rs", rust::collect_whole_test_paths(&old_rust_sources)?); + old.insert("py", python::collect_whole_test_paths(&old_path_entries)?); + let old_javascript_paths = javascript::collect_whole_test_paths(&old_path_entries)?; for language in javascript::family_langs() { old.insert(*language, old_javascript_paths.clone()); } let mut new = HashMap::new(); - new.insert("rs", rust::collect_whole_test_paths(&new_sources)?); - new.insert("py", python::collect_whole_test_paths(&new_sources)?); - let new_javascript_paths = javascript::collect_whole_test_paths(&new_sources)?; + new.insert("rs", rust::collect_whole_test_paths(&new_rust_sources)?); + new.insert("py", python::collect_whole_test_paths(&new_path_entries)?); + let new_javascript_paths = javascript::collect_whole_test_paths(&new_path_entries)?; for language in javascript::family_langs() { new.insert(*language, new_javascript_paths.clone()); } @@ -320,6 +336,10 @@ fn load_index_sources(git: &Git, langs: &[&str]) -> Result }) } +fn load_index_paths(git: &Git, langs: &[&str]) -> Result, String> { + Ok(filter_paths(git.tracked_files()?, langs)) +} + fn load_worktree_sources(git: &Git, langs: &[&str]) -> Result, String> { let mut paths = git.tracked_files()?; paths.retain(|path| git.worktree_file_exists(path)); @@ -327,6 +347,13 @@ fn load_worktree_sources(git: &Git, langs: &[&str]) -> Result Result, String> { + let mut paths = git.tracked_files()?; + paths.retain(|path| git.worktree_file_exists(path)); + paths.extend(git.untracked_files()?); + Ok(filter_paths(paths, langs)) +} + fn load_revision_sources( git: &Git, revision: &str, @@ -337,6 +364,10 @@ fn load_revision_sources( }) } +fn load_revision_paths(git: &Git, revision: &str, langs: &[&str]) -> Result, String> { + Ok(filter_paths(git.revision_files(revision)?, langs)) +} + fn load_sources( paths: Vec, langs: &[&str], @@ -347,21 +378,30 @@ where { let mut sources = Vec::new(); - for path in paths { - if !should_load_source(&path, langs) { - continue; - } - + for path in filter_paths(paths, langs) { sources.push((path.clone(), read_source(&path)?)); } Ok(sources) } -fn should_load_source(path: &str, langs: &[&str]) -> bool { +fn filter_paths(paths: Vec, langs: &[&str]) -> Vec { + paths.into_iter() + .filter(|path| should_include_path(path, langs)) + .collect() +} + +fn should_include_path(path: &str, langs: &[&str]) -> bool { let Some(language) = detect_language(path) else { return false; }; langs.is_empty() || langs.contains(&language) } + +fn path_entries(paths: &[String]) -> Vec<(String, String)> { + paths.iter() + .cloned() + .map(|path| (path, String::new())) + .collect() +} diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 32106e6..e6a7b17 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -703,6 +703,37 @@ fn no_test_filter_includes_javascript_and_typescript_test_files() { .stdout(predicate::str::contains("playwright/auth.spec.ts")); } +#[test] +fn default_non_test_filter_skips_bulk_reading_frontend_sources_for_path_rules() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::create_dir_all(tempdir.path().join("scripts")).unwrap(); + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
before
;\n}\n", + ) + .unwrap(); + fs::write(tempdir.path().join("scripts/build.mjs"), [0xff, 0xfe, 0xfd]).unwrap(); + run_git(tempdir.path(), ["add", "web/app.tsx", "scripts/build.mjs"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web/app.tsx"), + "export function App() {\n return
after
;\n}\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .assert() + .success() + .stdout(predicate::str::contains("web/app.tsx")) + .stdout(predicate::str::contains("scripts/build.mjs").not()); +} + #[test] fn test_filter_counts_rust_integration_test_files_as_test() { let tempdir = tempdir().unwrap(); From 3185cf2892e1ed31c35db39700166d5946ebded1 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:54:40 +0800 Subject: [PATCH 08/14] docs: describe all supported language defaults --- README.md | 18 ++++++++++-------- src/cli.rs | 2 +- tests/cli_smoke.rs | 4 ++++ tests/readme_presence.rs | 3 +++ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a317bd6..38dfbb2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ - untracked files included in default stats - language filtering with `--lang` -- Rust-only, non-test-only stats by default, with `--test`, `--no-test`, and `--no-test-filter` -- test-aware filtering for Rust and Python +- non-test-only stats by default across all supported languages, with `--test`, `--no-test`, and `--no-test-filter` +- test-aware filtering for Rust, Python, and JS/TS families - single-commit and revision-range support This repository also ships `rust-test-audit`, a companion CLI for auditing Rust source trees @@ -75,22 +75,23 @@ git diff-stat --last git diff-stat --last --no-test-filter git diff-stat HEAD~1..HEAD --lang py --no-test-filter git diff-stat --lang py --test +git diff-stat --lang tsx --test git diff-stat --test ``` ## Usage ```bash -git diff-stat [ | | ] [--lang rs,py,js] [--test | --no-test | --no-test-filter] +git diff-stat [ | | ] [--lang rs,py,js,ts,jsx,tsx,cjs,mjs] [--test | --no-test | --no-test-filter] ``` Defaults: -- `--lang` defaults to `rs,py` +- `--lang` defaults to all supported languages: `rs,py,js,ts,jsx,tsx,cjs,mjs` - test filtering defaults to `--no-test` - output always begins with a header line describing the comparison scope, languages, and test scope -That means plain `git diff-stat` already reports Rust and Python non-test changes together. +That means plain `git diff-stat` already reports non-test changes across all currently supported languages. ## Rust Test Audit @@ -131,8 +132,9 @@ test regions cross configurable density thresholds. - `--lang` currently uses file extensions. - `--test` and `--no-test` treat Rust files under `tests/` and Rust files imported by `#[cfg(test)]` module declarations as whole-file test code. Other Rust files still use code-region splitting for `#[cfg(test)]` modules and test-annotated functions such as `#[test]` and `#[tokio::test]`. - `--test` and `--no-test` treat Python files under `tests/`, `test_*.py`, `*_test.py`, and `conftest.py` as whole-file test code. Other Python files split test regions using `def test_*` and `class Test*`. -- `--no-test-filter` disables Rust and Python test splitting entirely and reports full-file stats for the selected languages. -- because `--lang` defaults to `rs,py`, use `--lang rs` or `--lang py` when you want a narrower language set. +- `--test` and `--no-test` treat JS/TS family files under `__tests__/`, `e2e/`, `cypress/`, and `playwright/`, plus files matching `*.test.*`, `*.spec.*`, and `*.cy.*`, as whole-file test code. +- `--no-test-filter` disables Rust and Python region splitting and reports full-file stats for the selected languages. +- `--lang` defaults to all supported languages, so use `--lang rs`, `--lang py`, or `--lang tsx` when you want a narrower language set. - `--last` is sugar for the patch introduced by `HEAD`, equivalent to `HEAD^!`. -- rendered output starts with a Chinese description line such as `未提交的 rs,py 文件中,非测试代码统计如下:`. +- rendered output starts with a Chinese description line such as `未提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:`. - Output is intentionally close to `git diff --stat`, but not byte-for-byte identical. diff --git a/src/cli.rs b/src/cli.rs index f8439c4..cf82f0d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,7 +11,7 @@ pub enum TestFilterMode { #[command(name = "git-diff-stat")] #[command(about = "Enhanced git diff --stat with untracked and test filtering")] #[command( - after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --test\n\nDefaults:\n --lang rs,py,js,ts\n test filter: --no-test" + after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --lang tsx --test\n git diff-stat --test\n\nDefaults:\n --lang all supported languages (rs,py,js,ts,jsx,tsx,cjs,mjs)\n test filter: --no-test" )] pub struct Cli { #[arg(long, conflicts_with_all = ["no_test", "no_test_filter"])] diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index e6a7b17..bd4e8a2 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -64,6 +64,10 @@ fn help_mentions_common_examples() { .stdout(predicate::str::contains( "git diff-stat --last --no-test-filter", )) + .stdout(predicate::str::contains("git diff-stat --lang tsx --test")) + .stdout(predicate::str::contains( + "--lang all supported languages (rs,py,js,ts,jsx,tsx,cjs,mjs)", + )) .stdout(predicate::str::contains("--no-test-filter")); } diff --git a/tests/readme_presence.rs b/tests/readme_presence.rs index 4bb1e9d..ae88651 100644 --- a/tests/readme_presence.rs +++ b/tests/readme_presence.rs @@ -4,4 +4,7 @@ fn readme_mentions_github_release_install() { assert!(readme.contains("GitHub Releases")); assert!(readme.contains("v0.1.0")); + assert!(readme.contains("`--lang` defaults to all supported languages")); + assert!(readme.contains("*.test.*")); + assert!(readme.contains("cypress/")); } From 57e6859bc0b86f180d4f248729463931bf6b0856 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 02:02:51 +0800 Subject: [PATCH 09/14] chore: finalize js ts family support --- .../2026-03-21-js-ts-family-support-design.md | 274 +++++++++++++++ docs/plans/2026-03-21-js-ts-family-support.md | 311 ++++++++++++++++++ src/lang/javascript.rs | 4 +- src/test_filter.rs | 6 +- tests/cli_smoke.rs | 32 +- 5 files changed, 614 insertions(+), 13 deletions(-) create mode 100644 docs/plans/2026-03-21-js-ts-family-support-design.md create mode 100644 docs/plans/2026-03-21-js-ts-family-support.md diff --git a/docs/plans/2026-03-21-js-ts-family-support-design.md b/docs/plans/2026-03-21-js-ts-family-support-design.md new file mode 100644 index 0000000..4a37cdc --- /dev/null +++ b/docs/plans/2026-03-21-js-ts-family-support-design.md @@ -0,0 +1,274 @@ +# JS/TS Family Language Support Design + +**Context** + +`git-diff-stat` currently supports Rust and Python as first-class test-aware languages. The language layer in [`src/lang/mod.rs`](../../src/lang/mod.rs) still has two structural limits: + +- the default `--lang` behavior is represented as a hard-coded CLI string instead of "all supported languages" +- JS and TS are only partially recognized as file extensions, and they do not participate in `--test` or `--no-test` + +The next goal is broader frontend language coverage: + +- support `js`, `ts`, `jsx`, `tsx`, `cjs`, and `mjs` +- treat unit tests and e2e tests as test code +- change default `--lang` semantics from a fixed subset to "all supported languages" + +The user explicitly approved a narrow first version for JS/TS test semantics: + +- whole-file test classification only +- no file-internal `describe` / `it` / `test` region splitting + +**Goal** + +Add first-class JS/TS family support with test-aware filtering, while making default language selection come from the language registry rather than from a duplicated string literal in the CLI layer. + +The result should make future language additions easier, not harder. + +**Approaches** + +1. Minimal patching + - Add more extensions directly in [`src/lang/mod.rs`](../../src/lang/mod.rs) + - Change the CLI default string from `rs,py` to a longer comma-separated list + - Add ad hoc JS/TS branches in [`src/test_filter.rs`](../../src/test_filter.rs) + - Rejected because the default language list would still be duplicated across language detection, CLI help, README, and tests. + +2. Registry-driven defaults with lightweight JS/TS backend support + - Introduce a single source of truth for supported languages + - Derive default `--lang` behavior from that registry + - Add a JS/TS backend that performs whole-file test classification only + - Recommended. + +3. Full backend capability framework + - Build a more generic trait/capability model for path matching, whole-file classification, region splitting, aliasing, and default inclusion + - Technically clean, but over-designed for the current repository size + - Rejected for now. + +**Decision** + +Use registry-driven defaults plus a lightweight JS/TS backend. + +This keeps the current Rust/Python design direction, but tightens two pieces that are now becoming important: + +- "supported languages" must live in one place +- test-aware orchestration must support languages that only provide whole-file test classification + +**Default Language Semantics** + +`--lang` should no longer default to a hard-coded subset such as `rs,py`. + +Instead: + +- if the user passes `--lang`, respect exactly that explicit set +- if the user omits `--lang`, treat it as "all supported languages" + +For the current repository state after this change, "all supported languages" means: + +- `rs` +- `py` +- `js` +- `ts` +- `jsx` +- `tsx` +- `cjs` +- `mjs` + +This should be surfaced consistently in: + +- CLI parsing +- output headers +- help text examples +- README defaults +- tests + +The critical rule is that the support list should be declared once in the language layer and reused everywhere else. + +**Proposed Structure** + +- `src/lang/mod.rs` + - registry of supported language tokens + - parsing for explicit `--lang` values + - default-language expansion when `--lang` is omitted + - path-to-language detection +- `src/lang/rust.rs` + - existing Rust support + - whole-file test classification plus region splitting +- `src/lang/python.rs` + - existing Python support + - whole-file test classification plus region splitting +- `src/lang/javascript.rs` + - JS/TS family path matching + - whole-file test classification only +- `src/test_filter.rs` + - shared orchestration across selected languages + - support for backends that only classify whole-file test paths + +This is still a moderate refactor, not a rewrite. + +**Language Registry Shape** + +The registry only needs to answer a few central questions: + +- which language tokens are supported? +- which token matches a given path? +- what is the default language set when `--lang` is omitted? + +One practical model is: + +- `supported_langs() -> &'static [&'static str]` +- `default_langs() -> &'static [&'static str]` +- `parse_langs(value: Option<&str>) -> Vec<&str>` +- `detect_language(path: &str) -> Option<&'static str>` + +For now, `default_langs()` can simply return the same list as `supported_langs()`. + +This avoids duplicating the support list in [`src/cli.rs`](../../src/cli.rs) and [`README.md`](../../README.md). + +**JS/TS Family Matching** + +The new frontend backend should recognize these extensions directly: + +- `.js` +- `.ts` +- `.jsx` +- `.tsx` +- `.cjs` +- `.mjs` + +Each extension should map to its own `--lang` token. This keeps filtering precise: + +- `--lang js` should not automatically include `ts` +- `--lang tsx` should only include `.tsx` +- omitting `--lang` includes all of them + +This is a better fit for current CLI semantics than collapsing everything into a single `web` alias. + +**JS/TS Test Semantics** + +The approved first version is whole-file classification only. + +Treat these as test files: + +- any file under a `__tests__/` path component +- filenames matching `*.test.` +- filenames matching `*.spec.` +- any file under an `e2e/` path component +- any file under a `cypress/` path component +- any file under a `playwright/` path component +- filenames matching `*.cy.` + +Where `` is one of: + +- `js` +- `ts` +- `jsx` +- `tsx` +- `cjs` +- `mjs` + +These rules intentionally cover both unit and e2e test conventions. + +**Out of Scope** + +Not in scope for the first JS/TS version: + +- file-internal test block detection using `describe`, `it`, `test`, or `suite` +- `vitest` inline test detection such as `import.meta.vitest` +- framework-specific config discovery from Jest, Vitest, Playwright, Cypress, or custom tooling +- special handling for snapshot files + +This is intentional. Most real-world JS/TS repositories still place tests in dedicated files or directories, so whole-file classification captures the highest-value cases with low false-positive risk. + +**Test-Filter Orchestration** + +The shared builder in [`src/test_filter.rs`](../../src/test_filter.rs) currently assumes that selected languages either: + +- have whole-file test paths and region splitting, or +- are ignored entirely + +JS/TS adds a third useful case: + +- whole-file test classification only + +The orchestration should therefore support: + +1. languages with whole-file and region split support +2. languages with whole-file-only support + +For JS/TS family files: + +- if a file matches a whole-file test rule, count it as test code +- otherwise, count the full file diff as non-test code + +That preserves correct semantics for `--test`, `--no-test`, and `--no-test-filter` without introducing AST parsing. + +**Source Loading Strategy** + +This addition makes source-loading efficiency more important. + +Rust still needs source contents for path-imported `#[cfg(test)]` module detection. Python and JS/TS whole-file classification are path-driven. The design should avoid eager content reads for languages that only need paths. + +That means the shared builder should distinguish between: + +- path-only whole-file classification +- source-assisted whole-file classification +- region splitting + +Even if the implementation stays simple, it should at least avoid bulk-reading JS/TS files just to classify them by filename or directory name. + +**Data Flow** + +After the refactor, runtime behavior should look like this: + +1. Parse CLI. +2. Resolve revision selection. +3. Parse explicit `--lang`, or expand to all supported languages if omitted. +4. Filter `FileChange` values to the selected languages. +5. If `--no-test-filter`, render full-file stats directly. +6. Otherwise: + - compute whole-file test paths for each selected language backend + - use region splitting only for languages that implement it + - treat JS/TS non-test files as full-file non-test diffs +7. Render the existing header with the updated language scope. + +The biggest behavioral change is that plain `git diff-stat` now means "all supported languages" instead of "Rust and Python only". + +**Testing Strategy** + +Add coverage at three levels: + +1. Registry and extension tests + - supported language parsing + - default language expansion + - path detection for `js`, `ts`, `jsx`, `tsx`, `cjs`, `mjs` +2. JS/TS test classification unit tests + - `__tests__/` + - `*.test.*` + - `*.spec.*` + - `e2e/` + - `cypress/` + - `playwright/` + - `*.cy.*` +3. CLI smoke tests + - default run includes supported frontend files + - default `--no-test` excludes JS/TS unit and e2e test files + - `--test` includes those files + - `--no-test-filter` restores full-file counting + - explicit `--lang tsx` or `--lang cjs` behaves narrowly + +The smoke suite should also prove that mixed repositories still combine Rust, Python, and JS/TS families correctly. + +**Risks** + +- If default-language logic remains duplicated, future additions will drift again between CLI, README, and actual behavior. +- If JS/TS whole-file rules are too broad, application code under directories like `tests-data/` or `playwright.config.ts` could be misclassified; the patterns should stay intentionally narrow and component-based. +- If the builder eagerly reads all JS/TS sources, repositories with many frontend assets could take an unnecessary performance hit. + +**Outcome** + +After this change: + +1. plain `git diff-stat` covers all supported languages +2. JS/TS family files participate in `--test` and `--no-test` +3. test-aware orchestration no longer assumes every language must support region splitting + +That is enough structure to add more path-driven languages later without reworking the CLI defaults again. diff --git a/docs/plans/2026-03-21-js-ts-family-support.md b/docs/plans/2026-03-21-js-ts-family-support.md new file mode 100644 index 0000000..c4482b7 --- /dev/null +++ b/docs/plans/2026-03-21-js-ts-family-support.md @@ -0,0 +1,311 @@ +# JS/TS Family Language Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `js`, `ts`, `jsx`, `tsx`, `cjs`, and `mjs` as first-class `--lang` values, make omitted `--lang` mean "all supported languages", and extend `--test` / `--no-test` to JS/TS unit and e2e test files. + +**Architecture:** Centralize supported/default languages in `src/lang/mod.rs`, add a `src/lang/javascript.rs` backend for whole-file test classification, and update `src/test_filter.rs` to support languages that only provide whole-file test semantics. Preserve Rust and Python behavior while broadening default language coverage. + +**Tech Stack:** Rust, clap, assert_cmd, predicates, cargo test, cargo clippy + +--- + +### Task 1: Lock down registry-driven default language behavior + +**Files:** +- Modify: `tests/lang_filter.rs` +- Modify: `tests/cli_smoke.rs` +- Modify: `src/lang/mod.rs` +- Modify: `src/cli.rs` + +**Step 1: Write the failing test** + +Add unit coverage proving that omitted `--lang` expands to all supported languages instead of a fixed `rs,py` subset. Add one CLI smoke regression proving plain `git diff-stat` includes a frontend source file in addition to Rust/Python when that file has non-test changes. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test lang_filter --test cli_smoke -v +``` + +Expected: FAIL because the CLI and language parser still default to `rs,py`. + +**Step 3: Write minimal implementation** + +Move default-language responsibility into `src/lang/mod.rs`. Remove the hard-coded CLI default value and make omitted `--lang` resolve to all supported tokens from the registry. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test lang_filter --test cli_smoke -v +``` + +Expected: PASS + +### Task 2: Add full JS/TS family extension support + +**Files:** +- Modify: `src/lang/mod.rs` +- Create: `src/lang/javascript.rs` +- Modify: `src/lib.rs` +- Modify: `tests/lang_filter.rs` + +**Step 1: Write the failing test** + +Add unit tests proving language detection and filtering recognize: + +- `app.js` +- `app.ts` +- `component.jsx` +- `component.tsx` +- `config.cjs` +- `entry.mjs` + +Also add a regression proving explicit `--lang tsx` only keeps `.tsx` files. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test lang_filter -v +``` + +Expected: FAIL because the new extensions are not fully supported yet. + +**Step 3: Write minimal implementation** + +Add `src/lang/javascript.rs` and route JS/TS family extension detection through it. Keep the token model explicit: each extension remains its own `--lang` value rather than collapsing into one alias. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test lang_filter -v +``` + +Expected: PASS + +### Task 3: Add JS/TS whole-file test classification primitives + +**Files:** +- Create: `tests/javascript_tests.rs` +- Modify: `src/lang/javascript.rs` + +**Step 1: Write the failing test** + +Add focused unit tests proving these are classified as whole-file tests: + +- `src/__tests__/app.ts` +- `web/app.test.tsx` +- `web/app.spec.jsx` +- `tests/e2e/login.ts` +- `cypress/e2e/home.cy.js` +- `playwright/auth.spec.ts` + +Also add negative cases such as: + +- `src/app.ts` +- `scripts/build.mjs` +- `playwright.config.ts` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test javascript_tests -v +``` + +Expected: FAIL because JS/TS whole-file test classification does not exist yet. + +**Step 3: Write minimal implementation** + +Implement path-based whole-file classification in `src/lang/javascript.rs` using path components and filename patterns only. Do not add AST parsing or inline test detection. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test javascript_tests -v +``` + +Expected: PASS + +### Task 4: Teach the shared test filter to handle whole-file-only languages + +**Files:** +- Modify: `src/test_filter.rs` +- Modify: `src/main.rs` +- Modify: `src/lang/javascript.rs` +- Modify: `tests/cli_smoke.rs` + +**Step 1: Write the failing test** + +Add CLI smoke coverage proving: + +- default `--no-test` excludes `*.test.tsx` and e2e files from the output +- `--test` includes those same files +- `--no-test-filter` reports them as ordinary full-file stats +- non-test JS/TS source files are still counted under `--no-test` + +Use a mixed repository that contains at least one frontend source file and at least one frontend test file. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: FAIL because the shared builder currently only dispatches Rust and Python test-aware behavior. + +**Step 3: Write minimal implementation** + +Extend `src/test_filter.rs` so JS/TS family backends can contribute whole-file test paths without region splitting. For files that are not whole-file tests, treat the full diff as non-test code. + +Keep the builder scoped to selected languages only. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: PASS + +### Task 5: Avoid unnecessary frontend source reads during whole-file classification + +**Files:** +- Modify: `src/test_filter.rs` +- Modify: `src/lang/rust.rs` +- Modify: `src/lang/python.rs` +- Modify: `src/lang/javascript.rs` +- Modify: `tests/cli_smoke.rs` + +**Step 1: Write the failing test** + +Add a regression similar to the existing Python review fix: create a tracked JS/TS family file with non-UTF8 bytes that should be ignored when the selected languages do not include it, and prove the command still succeeds. + +Also add coverage proving JS/TS whole-file classification can be computed without bulk-reading frontend source contents when only path-based rules are needed. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: FAIL if the builder still eagerly reads irrelevant frontend sources. + +**Step 3: Write minimal implementation** + +Separate path-only whole-file classification from source-assisted whole-file classification inside `src/test_filter.rs`. Preserve Rust behavior for imported `#[cfg(test)]` modules while avoiding unnecessary JS/TS content reads. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test cli_smoke -v +``` + +Expected: PASS + +### Task 6: Update help text and README for all-supported-language defaults + +**Files:** +- Modify: `src/cli.rs` +- Modify: `README.md` +- Modify: `tests/cli_smoke.rs` +- Modify: `tests/readme_presence.rs` + +**Step 1: Write the failing test** + +Update help-text expectations and README assertions so they require: + +- default `--lang` meaning all supported languages +- examples that mention JS/TS family usage +- notes describing frontend whole-file test detection + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test cli_smoke help_mentions_common_examples -v +cargo test --test readme_presence -v +``` + +Expected: FAIL because docs and help still describe the older default set. + +**Step 3: Write minimal implementation** + +Update help examples, defaults text, README usage, and notes so the supported language list and default behavior are explicit and consistent with the registry. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +cargo test --test cli_smoke help_mentions_common_examples -v +cargo test --test readme_presence -v +``` + +Expected: PASS + +### Task 7: Run the full verification suite + +**Files:** +- Modify: none + +**Step 1: Run targeted tests** + +Run: + +```bash +cargo test --test lang_filter -v +cargo test --test javascript_tests -v +cargo test --test python_tests -v +cargo test --test rust_tests -v +cargo test --test cli_smoke -v +``` + +Expected: PASS + +**Step 2: Run the full test suite** + +Run: + +```bash +cargo test -v +``` + +Expected: PASS + +**Step 3: Run lint** + +Run: + +```bash +cargo clippy --all-targets --all-features -- -D warnings +``` + +Expected: PASS + +**Step 4: Commit** + +```bash +git add README.md src tests docs/plans/2026-03-21-js-ts-family-support-design.md docs/plans/2026-03-21-js-ts-family-support.md +git commit -m "feat: add js and ts family language support" +``` diff --git a/src/lang/javascript.rs b/src/lang/javascript.rs index e9de245..93fe975 100644 --- a/src/lang/javascript.rs +++ b/src/lang/javascript.rs @@ -19,9 +19,7 @@ pub fn detect_language(path: &str) -> Option<&'static str> { } } -pub fn collect_whole_test_paths( - sources: &[(String, String)], -) -> Result, String> { +pub fn collect_whole_test_paths(sources: &[(String, String)]) -> Result, String> { Ok(sources .iter() .map(|(path, _)| path) diff --git a/src/test_filter.rs b/src/test_filter.rs index 98eac5c..0d685cc 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -386,7 +386,8 @@ where } fn filter_paths(paths: Vec, langs: &[&str]) -> Vec { - paths.into_iter() + paths + .into_iter() .filter(|path| should_include_path(path, langs)) .collect() } @@ -400,7 +401,8 @@ fn should_include_path(path: &str, langs: &[&str]) -> bool { } fn path_entries(paths: &[String]) -> Vec<(String, String)> { - paths.iter() + paths + .iter() .cloned() .map(|path| (path, String::new())) .collect() diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index bd4e8a2..0ed6888 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -46,7 +46,7 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py,js,ts 文件中,非测试代码统计如下:", + "未提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("web.js")); @@ -100,7 +100,7 @@ fn last_flag_reports_head_patch() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py,js,ts 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/tracked.rs")) .stdout(predicate::str::contains("1 insertion")); @@ -162,7 +162,7 @@ fn default_filters_to_rust_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py,js,ts 文件中,非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs").not()) @@ -225,7 +225,7 @@ fn no_test_filter_includes_all_rust_changes_but_keeps_default_lang() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py,js,ts 文件中,测试与非测试代码统计如下:", + "最后一次提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,测试与非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs")) @@ -262,7 +262,13 @@ fn default_lang_includes_rust_and_python_non_test_changes() { .unwrap(); run_git( tempdir.path(), - ["add", "src/lib.rs", "app/main.py", "web.js", "tests/test_app.py"], + [ + "add", + "src/lib.rs", + "app/main.py", + "web.js", + "tests/test_app.py", + ], ); run_git(tempdir.path(), ["commit", "-m", "initial"]); @@ -293,7 +299,7 @@ fn default_lang_includes_rust_and_python_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py,js,ts 文件中,非测试代码统计如下:", + "未提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("app/main.py")) @@ -578,7 +584,12 @@ fn default_non_test_filter_excludes_javascript_and_typescript_test_files() { .unwrap(); run_git( tempdir.path(), - ["add", "web/app.tsx", "web/app.test.tsx", "tests/e2e/login.ts"], + [ + "add", + "web/app.tsx", + "web/app.test.tsx", + "tests/e2e/login.ts", + ], ); run_git(tempdir.path(), ["commit", "-m", "initial"]); @@ -632,7 +643,12 @@ fn test_filter_includes_javascript_and_typescript_test_files() { .unwrap(); run_git( tempdir.path(), - ["add", "web/app.tsx", "web/app.spec.tsx", "cypress/e2e/home.cy.js"], + [ + "add", + "web/app.tsx", + "web/app.spec.tsx", + "cypress/e2e/home.cy.js", + ], ); run_git(tempdir.path(), ["commit", "-m", "initial"]); From 2c094a5c32f5646c115c02243ae701ff7bbcd422 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 07:30:30 +0800 Subject: [PATCH 10/14] fix: handle cross-language test-filter renames --- src/lang/mod.rs | 7 +- src/test_filter.rs | 221 +++++++++++++++++++++++++++++++++++++++++-- tests/cli_smoke.rs | 38 ++++++++ tests/lang_filter.rs | 20 ++++ 4 files changed, 275 insertions(+), 11 deletions(-) diff --git a/src/lang/mod.rs b/src/lang/mod.rs index f488193..5788e92 100644 --- a/src/lang/mod.rs +++ b/src/lang/mod.rs @@ -15,9 +15,10 @@ pub fn filter_by_langs(changes: &[FileChange], langs: &[&str]) -> Result build_counts_for_rust(&context, &whole_test_paths, change)?, - "py" => build_counts_for_python(&context, &whole_test_paths, change)?, - "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => { - build_counts_for_javascript(&context, &whole_test_paths, change) - } - _ => continue, + let (added, deleted) = match (old_language, new_language) { + (Some(old), Some(new)) if old != new => build_counts_for_cross_language_change( + &context, + &whole_test_paths, + change, + old, + new, + )?, + _ => match language { + "rs" => build_counts_for_rust(&context, &whole_test_paths, change)?, + "py" => build_counts_for_python(&context, &whole_test_paths, change)?, + "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => { + build_counts_for_javascript(&context, &whole_test_paths, change) + } + _ => continue, + }, }; if added + deleted == 0 { @@ -73,6 +85,7 @@ struct BuildContext<'a> { git: &'a Git, endpoints: &'a Option, patch_map: &'a HashMap, + langs: &'a [&'a str], mode: TestFilterMode, } @@ -174,6 +187,31 @@ fn build_counts_for_javascript( ) } +fn build_counts_for_cross_language_change( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + old_language: &'static str, + new_language: &'static str, +) -> Result<(usize, usize), String> { + let (old_added, old_deleted) = build_side_counts_for_language( + context, + whole_test_paths, + change, + old_language, + ChangeSide::Old, + )?; + let (new_added, new_deleted) = build_side_counts_for_language( + context, + whole_test_paths, + change, + new_language, + ChangeSide::New, + )?; + + Ok((old_added + new_added, old_deleted + new_deleted)) +} + fn build_counts( context: &BuildContext<'_>, old_whole_test_paths: Option<&HashSet>, @@ -253,6 +291,173 @@ fn select_counts_for_whole_file_only( select_counts_from_whole_file(change, old_is_whole_test, new_is_whole_test, mode) } +#[derive(Clone, Copy)] +enum ChangeSide { + Old, + New, +} + +fn build_side_counts_for_language( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + language: &'static str, + side: ChangeSide, +) -> Result<(usize, usize), String> { + if !context.langs.contains(&language) { + return Ok((0, 0)); + } + + match language { + "rs" => build_side_counts( + context, + whole_test_paths, + change, + language, + side, + rust::split_file_patch, + ), + "py" => build_side_counts( + context, + whole_test_paths, + change, + language, + side, + python::split_file_patch, + ), + "js" | "ts" | "jsx" | "tsx" | "cjs" | "mjs" => Ok(select_side_counts_for_whole_file_only( + change, + whole_test_paths.old.get(language), + whole_test_paths.new.get(language), + side, + context.mode, + )), + _ => Ok((0, 0)), + } +} + +fn build_side_counts( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + language: &'static str, + side: ChangeSide, + split_patch: PatchFn, +) -> Result<(usize, usize), String> +where + Split: TestSplitCounts, + PatchFn: Fn(&crate::patch::FilePatch, &str, &str) -> Result, +{ + let old_is_whole_test = whole_test_paths + .old + .get(language) + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = whole_test_paths + .new + .get(language) + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + if old_is_whole_test || new_is_whole_test { + return Ok(select_side_counts_from_whole_file( + change, + old_is_whole_test, + new_is_whole_test, + side, + context.mode, + )); + } + + let file_patch = context + .patch_map + .get(&change.new_path) + .ok_or_else(|| format!("missing patch data for {}", change.path))?; + let old_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.old, &change.old_path) + .unwrap_or_default(), + None => context + .git + .show_index_file(&change.old_path) + .unwrap_or_default(), + }; + let new_source = match context.endpoints { + Some(endpoints) => context + .git + .show_file_at_revision(&endpoints.new, &change.new_path) + .unwrap_or_default(), + None => context + .git + .read_worktree_file(&change.new_path) + .unwrap_or_default(), + }; + let split = match side { + ChangeSide::Old => split_patch(file_patch, &old_source, "")?, + ChangeSide::New => split_patch(file_patch, "", &new_source)?, + }; + + Ok(select_side_counts_from_split(&split, side, context.mode)) +} + +fn select_side_counts_from_whole_file( + change: &FileChange, + old_is_whole_test: bool, + new_is_whole_test: bool, + side: ChangeSide, + mode: TestFilterMode, +) -> (usize, usize) { + match side { + ChangeSide::Old => match mode { + TestFilterMode::TestOnly => (0, if old_is_whole_test { change.deleted } else { 0 }), + TestFilterMode::NonTestOnly => (0, if old_is_whole_test { 0 } else { change.deleted }), + TestFilterMode::All => (0, change.deleted), + }, + ChangeSide::New => match mode { + TestFilterMode::TestOnly => (if new_is_whole_test { change.added } else { 0 }, 0), + TestFilterMode::NonTestOnly => (if new_is_whole_test { 0 } else { change.added }, 0), + TestFilterMode::All => (change.added, 0), + }, + } +} + +fn select_side_counts_for_whole_file_only( + change: &FileChange, + old_whole_test_paths: Option<&HashSet>, + new_whole_test_paths: Option<&HashSet>, + side: ChangeSide, + mode: TestFilterMode, +) -> (usize, usize) { + let old_is_whole_test = old_whole_test_paths + .map(|paths| paths.contains(&change.old_path)) + .unwrap_or(false); + let new_is_whole_test = new_whole_test_paths + .map(|paths| paths.contains(&change.new_path)) + .unwrap_or(false); + + select_side_counts_from_whole_file(change, old_is_whole_test, new_is_whole_test, side, mode) +} + +fn select_side_counts_from_split( + split: &impl TestSplitCounts, + side: ChangeSide, + mode: TestFilterMode, +) -> (usize, usize) { + match side { + ChangeSide::Old => match mode { + TestFilterMode::TestOnly => (0, split.test_deleted()), + TestFilterMode::NonTestOnly => (0, split.non_test_deleted()), + TestFilterMode::All => (0, split.test_deleted() + split.non_test_deleted()), + }, + ChangeSide::New => match mode { + TestFilterMode::TestOnly => (split.test_added(), 0), + TestFilterMode::NonTestOnly => (split.non_test_added(), 0), + TestFilterMode::All => (split.test_added() + split.non_test_added(), 0), + }, + } +} + trait TestSplitCounts { fn test_added(&self) -> usize; fn test_deleted(&self) -> usize; diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 0ed6888..9e9b927 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -959,6 +959,44 @@ fn no_test_filter_handles_renamed_rust_files() { .stdout(predicate::str::contains("1 deletion")); } +#[test] +fn test_filter_counts_deleted_python_tests_across_language_rename() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::write( + tempdir.path().join("tests/test_mod.py"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 1\n assert value == 4\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "tests/test_mod.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git(tempdir.path(), ["mv", "tests/test_mod.py", "src/lib.rs"]); + fs::write( + tempdir.path().join("src/lib.rs"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 2\n assert value == 5\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs"]); + run_git( + tempdir.path(), + ["commit", "-m", "rename python test to rust file"], + ); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "py,rs", "--test"]) + .assert() + .success() + .stdout(predicate::str::contains("test_mod.py => src/lib.rs")) + .stdout(predicate::str::contains("2 deletions(-)")) + .stdout(predicate::str::contains("0 files changed").not()); +} + fn init_repo(repo: &Path) { run_git(repo, ["init"]); run_git(repo, ["config", "user.name", "Codex"]); diff --git a/tests/lang_filter.rs b/tests/lang_filter.rs index 5e1ef87..1d41dfd 100644 --- a/tests/lang_filter.rs +++ b/tests/lang_filter.rs @@ -130,3 +130,23 @@ fn filters_js_ts_family_extensions_individually() { assert_eq!(mjs.len(), 1); assert_eq!(mjs[0].path, "web/entry.mjs"); } + +#[test] +fn keeps_cross_language_renames_for_either_selected_language() { + let changes = vec![FileChange { + path: "tests/test_mod.py => src/lib.rs".to_string(), + old_path: "tests/test_mod.py".to_string(), + new_path: "src/lib.rs".to_string(), + added: 2, + deleted: 2, + untracked: false, + }]; + + let python = filter_by_langs(&changes, &["py"]).unwrap(); + let rust = filter_by_langs(&changes, &["rs"]).unwrap(); + let javascript = filter_by_langs(&changes, &["js"]).unwrap(); + + assert_eq!(python.len(), 1); + assert_eq!(rust.len(), 1); + assert!(javascript.is_empty()); +} From f525a4860fe037d0bb563a6a9f15589b5c2dacc3 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 07:49:14 +0800 Subject: [PATCH 11/14] fix: split mixed-language rename stats by side --- src/main.rs | 17 ++-------- src/test_filter.rs | 26 +++++++++++---- tests/cli_smoke.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2ffc99d..748b815 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use git_diff_stat::change::collect_changes; use git_diff_stat::cli::{Cli, TestFilterMode}; use git_diff_stat::git::Git; use git_diff_stat::lang::{filter_by_langs, parse_langs}; -use git_diff_stat::render::{DisplayStat, StatsDescription, render_stats}; +use git_diff_stat::render::{StatsDescription, render_stats}; use git_diff_stat::revision::RevisionSelection; use git_diff_stat::test_filter::build_test_filtered_stats; @@ -26,19 +26,8 @@ fn run() -> Result<(), String> { changes = filter_by_langs(&changes, &langs)?; } - let stats = match cli.test_filter_mode() { - TestFilterMode::TestOnly | TestFilterMode::NonTestOnly => { - build_test_filtered_stats(&git, &selection, &changes, &langs, cli.test_filter_mode())? - } - TestFilterMode::All => changes - .into_iter() - .map(|change| DisplayStat { - path: change.path, - added: change.added, - deleted: change.deleted, - }) - .collect(), - }; + let stats = + build_test_filtered_stats(&git, &selection, &changes, &langs, cli.test_filter_mode())?; let description = StatsDescription { comparison_scope: selection.describe_scope(&git, cli.last)?, diff --git a/src/test_filter.rs b/src/test_filter.rs index a0c43eb..ac681eb 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -45,7 +45,7 @@ pub fn build_test_filtered_stats( }; let (added, deleted) = match (old_language, new_language) { - (Some(old), Some(new)) if old != new => build_counts_for_cross_language_change( + (old, new) if old != new => build_counts_for_mixed_language_change( &context, &whole_test_paths, change, @@ -187,21 +187,21 @@ fn build_counts_for_javascript( ) } -fn build_counts_for_cross_language_change( +fn build_counts_for_mixed_language_change( context: &BuildContext<'_>, whole_test_paths: &WholeTestPaths, change: &FileChange, - old_language: &'static str, - new_language: &'static str, + old_language: Option<&'static str>, + new_language: Option<&'static str>, ) -> Result<(usize, usize), String> { - let (old_added, old_deleted) = build_side_counts_for_language( + let (old_added, old_deleted) = build_side_counts_for_detected_language( context, whole_test_paths, change, old_language, ChangeSide::Old, )?; - let (new_added, new_deleted) = build_side_counts_for_language( + let (new_added, new_deleted) = build_side_counts_for_detected_language( context, whole_test_paths, change, @@ -212,6 +212,20 @@ fn build_counts_for_cross_language_change( Ok((old_added + new_added, old_deleted + new_deleted)) } +fn build_side_counts_for_detected_language( + context: &BuildContext<'_>, + whole_test_paths: &WholeTestPaths, + change: &FileChange, + language: Option<&'static str>, + side: ChangeSide, +) -> Result<(usize, usize), String> { + let Some(language) = language else { + return Ok((0, 0)); + }; + + build_side_counts_for_language(context, whole_test_paths, change, language, side) +} + fn build_counts( context: &BuildContext<'_>, old_whole_test_paths: Option<&HashSet>, diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 9e9b927..728225b 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -997,6 +997,85 @@ fn test_filter_counts_deleted_python_tests_across_language_rename() { .stdout(predicate::str::contains("0 files changed").not()); } +#[test] +fn no_test_filter_splits_cross_language_rename_by_selected_language() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::write( + tempdir.path().join("tests/test_mod.py"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 1\n assert value == 4\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "tests/test_mod.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git(tempdir.path(), ["mv", "tests/test_mod.py", "src/lib.rs"]); + fs::write( + tempdir.path().join("src/lib.rs"), + "def test_old():\n value = 1\n value = value + 1\n value = value + 1\n value = value + 2\n assert value == 5\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs"]); + run_git( + tempdir.path(), + ["commit", "-m", "rename python test to rust file"], + ); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "py", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("test_mod.py => src/lib.rs")) + .stdout(predicate::str::contains("0 insertions(+), 2 deletions(-)")); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "rs", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("test_mod.py => src/lib.rs")) + .stdout(predicate::str::contains("2 insertions(+), 0 deletions(-)")); +} + +#[test] +fn non_test_filter_splits_supported_to_unsupported_rename_by_selected_language() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::write( + tempdir.path().join("README.md"), + "pub fn answer() -> i32 {\n 41\n}\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "README.md"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + run_git(tempdir.path(), ["mv", "README.md", "src/lib.rs"]); + fs::write( + tempdir.path().join("src/lib.rs"), + "pub fn answer() -> i32 {\n 42\n}\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs"]); + run_git(tempdir.path(), ["commit", "-m", "rename markdown to rust"]); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "rs", "--no-test"]) + .assert() + .success() + .stdout(predicate::str::contains("README.md => src/lib.rs")) + .stdout(predicate::str::contains("1 insertions(+), 0 deletions(-)")); +} + fn init_repo(repo: &Path) { run_git(repo, ["init"]); run_git(repo, ["config", "user.name", "Codex"]); From fed250a1c2c7156a6134af776cf51217838b4956 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 10:09:43 +0800 Subject: [PATCH 12/14] fix: short-circuit unfiltered stats --- src/test_filter.rs | 38 +++++++++++++++++++++++++++++ tests/cli_smoke.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/test_filter.rs b/src/test_filter.rs index ac681eb..0e4517c 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -15,6 +15,10 @@ pub fn build_test_filtered_stats( langs: &[&str], mode: TestFilterMode, ) -> Result, String> { + if mode == TestFilterMode::All { + return Ok(build_unfiltered_stats(changes, langs)); + } + let patch_output = git.diff_patch(&selection.git_diff_args())?; let patch = parse_patch(&patch_output)?; let patch_map = patch @@ -76,6 +80,40 @@ pub fn build_test_filtered_stats( Ok(stats) } +fn build_unfiltered_stats(changes: &[FileChange], langs: &[&str]) -> Vec { + let mut stats = Vec::new(); + + for change in changes { + let old_language = detect_language(&change.old_path); + let new_language = detect_language(&change.new_path); + let added = selected_side_change_count(new_language, change.added, langs); + let deleted = selected_side_change_count(old_language, change.deleted, langs); + + if added + deleted == 0 && change.added + change.deleted > 0 { + continue; + } + + stats.push(DisplayStat { + path: change.path.clone(), + added, + deleted, + }); + } + + stats +} + +fn selected_side_change_count( + language: Option<&'static str>, + count: usize, + langs: &[&str], +) -> usize { + match language { + Some(language) if langs.is_empty() || langs.contains(&language) => count, + _ => 0, + } +} + struct WholeTestPaths { old: HashMap<&'static str, HashSet>, new: HashMap<&'static str, HashSet>, diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 728225b..a1fe66b 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -232,6 +232,37 @@ fn no_test_filter_includes_all_rust_changes_but_keeps_default_lang() { .stdout(predicate::str::contains("web.js")); } +#[test] +fn no_test_filter_does_not_parse_invalid_rust_sources() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("src")).unwrap(); + fs::write(tempdir.path().join("src/lib.rs"), [0xff, 0xfe, 0xfd]).unwrap(); + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "src/lib.rs", "web.js"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::write( + tempdir.path().join("web.js"), + "export const answer = () => 42;\n", + ) + .unwrap(); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("web.js")) + .stdout(predicate::str::contains("src/lib.rs").not()); +} + #[test] fn default_lang_includes_rust_and_python_non_test_changes() { let tempdir = tempdir().unwrap(); @@ -959,6 +990,35 @@ fn no_test_filter_handles_renamed_rust_files() { .stdout(predicate::str::contains("1 deletion")); } +#[test] +fn no_test_filter_keeps_rename_only_entries() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web/old")).unwrap(); + fs::write( + tempdir.path().join("web/old/app.js"), + "export const answer = () => 41;\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "web/old/app.js"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + fs::create_dir_all(tempdir.path().join("web/new")).unwrap(); + run_git(tempdir.path(), ["mv", "web/old/app.js", "web/new/app.js"]); + run_git(tempdir.path(), ["commit", "-m", "rename js file"]); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "js", "--no-test-filter"]) + .assert() + .success() + .stdout(predicate::str::contains("web/{old => new}/app.js")) + .stdout(predicate::str::contains("1 files changed")) + .stdout(predicate::str::contains("0 insertions(+), 0 deletions(-)")); +} + #[test] fn test_filter_counts_deleted_python_tests_across_language_rename() { let tempdir = tempdir().unwrap(); From 9c4b68bcc1921fdca31c6804cd4087bfa9cdbc3c Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 10:26:25 +0800 Subject: [PATCH 13/14] fix: preserve rename-only test stats --- src/test_filter.rs | 15 ++++++++--- tests/cli_smoke.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/test_filter.rs b/src/test_filter.rs index 0e4517c..9c78e6f 100644 --- a/src/test_filter.rs +++ b/src/test_filter.rs @@ -38,16 +38,23 @@ pub fn build_test_filtered_stats( let mut stats = Vec::new(); for change in changes { - if change.added + change.deleted == 0 { - continue; - } - let old_language = detect_language(&change.old_path); let new_language = detect_language(&change.new_path); let Some(language) = new_language.or(old_language) else { continue; }; + if change.added + change.deleted == 0 { + if change.old_path != change.new_path { + stats.push(DisplayStat { + path: change.path.clone(), + added: 0, + deleted: 0, + }); + } + continue; + } + let (added, deleted) = match (old_language, new_language) { (old, new) if old != new => build_counts_for_mixed_language_change( &context, diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index a1fe66b..a95ee6b 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -1057,6 +1057,39 @@ fn test_filter_counts_deleted_python_tests_across_language_rename() { .stdout(predicate::str::contains("0 files changed").not()); } +#[test] +fn test_filter_keeps_python_rename_only_entries() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("tests")).unwrap(); + fs::write( + tempdir.path().join("tests/test_app.py"), + "def test_value() -> None:\n assert True\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "tests/test_app.py"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git( + tempdir.path(), + ["mv", "tests/test_app.py", "tests/test_math.py"], + ); + run_git(tempdir.path(), ["commit", "-m", "rename python test"]); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "py", "--test"]) + .assert() + .success() + .stdout(predicate::str::contains( + "tests/{test_app.py => test_math.py}", + )) + .stdout(predicate::str::contains("1 files changed")) + .stdout(predicate::str::contains("0 insertions(+), 0 deletions(-)")); +} + #[test] fn no_test_filter_splits_cross_language_rename_by_selected_language() { let tempdir = tempdir().unwrap(); @@ -1136,6 +1169,39 @@ fn non_test_filter_splits_supported_to_unsupported_rename_by_selected_language() .stdout(predicate::str::contains("1 insertions(+), 0 deletions(-)")); } +#[test] +fn default_non_test_filter_keeps_javascript_rename_only_entries() { + let tempdir = tempdir().unwrap(); + init_repo(tempdir.path()); + + fs::create_dir_all(tempdir.path().join("web")).unwrap(); + fs::write( + tempdir.path().join("web/app.spec.tsx"), + "export const value = 41;\n", + ) + .unwrap(); + run_git(tempdir.path(), ["add", "web/app.spec.tsx"]); + run_git(tempdir.path(), ["commit", "-m", "initial"]); + + run_git( + tempdir.path(), + ["mv", "web/app.spec.tsx", "web/app.test.tsx"], + ); + run_git(tempdir.path(), ["commit", "-m", "rename tsx test"]); + + Command::cargo_bin("git-diff-stat") + .unwrap() + .current_dir(tempdir.path()) + .args(["--last", "--lang", "tsx"]) + .assert() + .success() + .stdout(predicate::str::contains( + "web/{app.spec.tsx => app.test.tsx}", + )) + .stdout(predicate::str::contains("1 files changed")) + .stdout(predicate::str::contains("0 insertions(+), 0 deletions(-)")); +} + fn init_repo(repo: &Path) { run_git(repo, ["init"]); run_git(repo, ["config", "user.name", "Codex"]); From b811be64ab5f3c2f7f220e6dda3df9e4ebf3095f Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 10:46:16 +0800 Subject: [PATCH 14/14] refactor: rename non-test filter output --- README.md | 14 +++++++------- src/cli.rs | 8 ++++---- src/main.rs | 10 +++++----- src/render.rs | 4 ++-- src/revision.rs | 8 ++++---- tests/cli_args.rs | 14 ++++++++++++-- tests/cli_smoke.rs | 31 ++++++++++++++++--------------- 7 files changed, 50 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 38dfbb2..7a110e9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - untracked files included in default stats - language filtering with `--lang` -- non-test-only stats by default across all supported languages, with `--test`, `--no-test`, and `--no-test-filter` +- non-test-only stats by default across all supported languages, with `--test`, `--non-test`, and `--no-test-filter` - test-aware filtering for Rust, Python, and JS/TS families - single-commit and revision-range support @@ -82,13 +82,13 @@ git diff-stat --test ## Usage ```bash -git diff-stat [ | | ] [--lang rs,py,js,ts,jsx,tsx,cjs,mjs] [--test | --no-test | --no-test-filter] +git diff-stat [ | | ] [--lang rs,py,js,ts,jsx,tsx,cjs,mjs] [--test | --non-test | --no-test-filter] ``` Defaults: - `--lang` defaults to all supported languages: `rs,py,js,ts,jsx,tsx,cjs,mjs` -- test filtering defaults to `--no-test` +- test filtering defaults to `--non-test` - output always begins with a header line describing the comparison scope, languages, and test scope That means plain `git diff-stat` already reports non-test changes across all currently supported languages. @@ -130,11 +130,11 @@ test regions cross configurable density thresholds. ## Notes - `--lang` currently uses file extensions. -- `--test` and `--no-test` treat Rust files under `tests/` and Rust files imported by `#[cfg(test)]` module declarations as whole-file test code. Other Rust files still use code-region splitting for `#[cfg(test)]` modules and test-annotated functions such as `#[test]` and `#[tokio::test]`. -- `--test` and `--no-test` treat Python files under `tests/`, `test_*.py`, `*_test.py`, and `conftest.py` as whole-file test code. Other Python files split test regions using `def test_*` and `class Test*`. -- `--test` and `--no-test` treat JS/TS family files under `__tests__/`, `e2e/`, `cypress/`, and `playwright/`, plus files matching `*.test.*`, `*.spec.*`, and `*.cy.*`, as whole-file test code. +- `--test` and `--non-test` treat Rust files under `tests/` and Rust files imported by `#[cfg(test)]` module declarations as whole-file test code. Other Rust files still use code-region splitting for `#[cfg(test)]` modules and test-annotated functions such as `#[test]` and `#[tokio::test]`. +- `--test` and `--non-test` treat Python files under `tests/`, `test_*.py`, `*_test.py`, and `conftest.py` as whole-file test code. Other Python files split test regions using `def test_*` and `class Test*`. +- `--test` and `--non-test` treat JS/TS family files under `__tests__/`, `e2e/`, `cypress/`, and `playwright/`, plus files matching `*.test.*`, `*.spec.*`, and `*.cy.*`, as whole-file test code. - `--no-test-filter` disables Rust and Python region splitting and reports full-file stats for the selected languages. - `--lang` defaults to all supported languages, so use `--lang rs`, `--lang py`, or `--lang tsx` when you want a narrower language set. - `--last` is sugar for the patch introduced by `HEAD`, equivalent to `HEAD^!`. -- rendered output starts with a Chinese description line such as `未提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:`. +- rendered output starts with an English description line such as `Non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the working tree:`. - Output is intentionally close to `git diff --stat`, but not byte-for-byte identical. diff --git a/src/cli.rs b/src/cli.rs index cf82f0d..0da3370 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,16 +11,16 @@ pub enum TestFilterMode { #[command(name = "git-diff-stat")] #[command(about = "Enhanced git diff --stat with untracked and test filtering")] #[command( - after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --lang tsx --test\n git diff-stat --test\n\nDefaults:\n --lang all supported languages (rs,py,js,ts,jsx,tsx,cjs,mjs)\n test filter: --no-test" + after_help = "Examples:\n git diff-stat\n git diff-stat --commit HEAD\n git diff-stat --last\n git diff-stat --last --no-test-filter\n git diff-stat HEAD~1..HEAD --lang py --no-test-filter\n git diff-stat --lang py --test\n git diff-stat --lang tsx --test\n git diff-stat --test\n\nDefaults:\n --lang all supported languages (rs,py,js,ts,jsx,tsx,cjs,mjs)\n test filter: --non-test" )] pub struct Cli { - #[arg(long, conflicts_with_all = ["no_test", "no_test_filter"])] + #[arg(long, conflicts_with_all = ["non_test", "no_test_filter"])] pub test: bool, #[arg(long, conflicts_with_all = ["test", "no_test_filter"])] - pub no_test: bool, + pub non_test: bool, - #[arg(long, conflicts_with_all = ["test", "no_test"])] + #[arg(long, conflicts_with_all = ["test", "non_test"])] pub no_test_filter: bool, #[arg(long, value_name = "REV", conflicts_with_all = ["last", "revisions"])] diff --git a/src/main.rs b/src/main.rs index 748b815..fd1b354 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,16 +41,16 @@ fn run() -> Result<(), String> { fn describe_language_scope(langs: &[&str]) -> String { if langs.is_empty() { - "所有文件".to_string() + "all files".to_string() } else { - format!("{} 文件", langs.join(",")) + format!("{} files", langs.join(",")) } } fn describe_test_scope(mode: TestFilterMode) -> String { match mode { - TestFilterMode::TestOnly => "测试代码".to_string(), - TestFilterMode::NonTestOnly => "非测试代码".to_string(), - TestFilterMode::All => "测试与非测试代码".to_string(), + TestFilterMode::TestOnly => "Test code".to_string(), + TestFilterMode::NonTestOnly => "Non-test code".to_string(), + TestFilterMode::All => "Test and non-test code".to_string(), } } diff --git a/src/render.rs b/src/render.rs index f784faa..bea9dc6 100644 --- a/src/render.rs +++ b/src/render.rs @@ -18,8 +18,8 @@ pub fn render_stats(description: &StatsDescription, stats: &[DisplayStat]) -> St let mut total_deleted = 0usize; lines.push(format!( - "{} {}中,{}统计如下:", - description.comparison_scope, description.language_scope, description.test_scope + "{} stats for {} {}:", + description.test_scope, description.language_scope, description.comparison_scope )); for stat in stats { diff --git a/src/revision.rs b/src/revision.rs index 8e567ba..9df2fe3 100644 --- a/src/revision.rs +++ b/src/revision.rs @@ -63,19 +63,19 @@ impl RevisionSelection { pub fn describe_scope(&self, git: &Git, last_flag: bool) -> Result { match self { - Self::WorkingTree => Ok("未提交的".to_string()), + Self::WorkingTree => Ok("in the working tree".to_string()), Self::CommitPatch(revision) => { if last_flag { - Ok("最后一次提交的".to_string()) + Ok("in the last commit".to_string()) } else { - Ok(format!("{revision} 这个提交的")) + Ok(format!("in commit {revision}")) } } Self::Revisions(_) => { let endpoints = self .endpoints(git)? .ok_or_else(|| "missing revision endpoints".to_string())?; - Ok(format!("{} 到 {} 的", endpoints.old, endpoints.new)) + Ok(format!("from {} to {}", endpoints.old, endpoints.new)) } } } diff --git a/tests/cli_args.rs b/tests/cli_args.rs index babf2c6..08f4899 100644 --- a/tests/cli_args.rs +++ b/tests/cli_args.rs @@ -5,7 +5,7 @@ use predicates::prelude::predicate; fn rejects_test_and_no_test_together() { Command::cargo_bin("git-diff-stat") .unwrap() - .args(["--test", "--no-test"]) + .args(["--test", "--non-test"]) .assert() .failure() .stderr(predicate::str::contains("cannot be used with")); @@ -25,12 +25,22 @@ fn rejects_test_and_no_test_filter_together() { fn rejects_no_test_and_no_test_filter_together() { Command::cargo_bin("git-diff-stat") .unwrap() - .args(["--no-test", "--no-test-filter"]) + .args(["--non-test", "--no-test-filter"]) .assert() .failure() .stderr(predicate::str::contains("cannot be used with")); } +#[test] +fn rejects_legacy_no_test_flag() { + Command::cargo_bin("git-diff-stat") + .unwrap() + .arg("--no-test") + .assert() + .failure() + .stderr(predicate::str::contains("unexpected argument '--no-test'")); +} + #[test] fn rejects_last_with_commit() { Command::cargo_bin("git-diff-stat") diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index a95ee6b..e610524 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -46,7 +46,7 @@ fn working_tree_output_mentions_scope_lang_and_test_mode() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:", + "Non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the working tree:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("web.js")); @@ -68,6 +68,7 @@ fn help_mentions_common_examples() { .stdout(predicate::str::contains( "--lang all supported languages (rs,py,js,ts,jsx,tsx,cjs,mjs)", )) + .stdout(predicate::str::contains("test filter: --non-test")) .stdout(predicate::str::contains("--no-test-filter")); } @@ -100,7 +101,7 @@ fn last_flag_reports_head_patch() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,测试与非测试代码统计如下:", + "Test and non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the last commit:", )) .stdout(predicate::str::contains("src/tracked.rs")) .stdout(predicate::str::contains("1 insertion")); @@ -162,7 +163,7 @@ fn default_filters_to_rust_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:", + "Non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the last commit:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs").not()) @@ -225,7 +226,7 @@ fn no_test_filter_includes_all_rust_changes_but_keeps_default_lang() { .assert() .success() .stdout(predicate::str::contains( - "最后一次提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,测试与非测试代码统计如下:", + "Test and non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the last commit:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("tests/integration.rs")) @@ -330,7 +331,7 @@ fn default_lang_includes_rust_and_python_non_test_changes() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py,js,ts,jsx,tsx,cjs,mjs 文件中,非测试代码统计如下:", + "Non-test code stats for rs,py,js,ts,jsx,tsx,cjs,mjs files in the working tree:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("app/main.py")) @@ -377,7 +378,7 @@ fn revision_range_output_mentions_range_langs_and_test_mode() { .assert() .success() .stdout(predicate::str::contains( - "HEAD~1 到 HEAD 的 rs,js 文件中,测试与非测试代码统计如下:", + "Test and non-test code stats for rs,js files from HEAD~1 to HEAD:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("web.js")); @@ -421,7 +422,7 @@ fn explicit_python_lang_uses_python_files() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 py 文件中,测试与非测试代码统计如下:", + "Test and non-test code stats for py files in the working tree:", )) .stdout(predicate::str::contains("app/main.py")) .stdout(predicate::str::contains("src/lib.rs").not()); @@ -497,7 +498,7 @@ fn python_default_non_test_filter_excludes_test_files() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 py 文件中,非测试代码统计如下:", + "Non-test code stats for py files in the working tree:", )) .stdout(predicate::str::contains("src/app.py")) .stdout(predicate::str::contains("tests/test_app.py").not()); @@ -541,7 +542,7 @@ fn python_test_filter_includes_test_files_and_regions() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 py 文件中,测试代码统计如下:", + "Test code stats for py files in the working tree:", )) .stdout(predicate::str::contains("src/app.py")) .stdout(predicate::str::contains("tests/test_app.py")); @@ -585,7 +586,7 @@ fn mixed_rust_and_python_non_test_filter_handles_both_languages() { .assert() .success() .stdout(predicate::str::contains( - "未提交的 rs,py 文件中,非测试代码统计如下:", + "Non-test code stats for rs,py files in the working tree:", )) .stdout(predicate::str::contains("src/lib.rs")) .stdout(predicate::str::contains("app/main.py")); @@ -843,7 +844,7 @@ fn no_test_filter_excludes_rust_integration_test_files() { Command::cargo_bin("git-diff-stat") .unwrap() .current_dir(tempdir.path()) - .args(["--last", "--lang", "rs", "--no-test"]) + .args(["--last", "--lang", "rs", "--non-test"]) .assert() .success() .stdout(predicate::str::contains("0 files changed")); @@ -923,7 +924,7 @@ fn no_test_filter_excludes_cfg_test_path_module_files() { Command::cargo_bin("git-diff-stat") .unwrap() .current_dir(tempdir.path()) - .args(["--last", "--lang", "rs", "--no-test"]) + .args(["--last", "--lang", "rs", "--non-test"]) .assert() .success() .stdout(predicate::str::contains("0 files changed")); @@ -946,7 +947,7 @@ fn no_test_filter_ignores_zero_line_deleted_rust_files() { Command::cargo_bin("git-diff-stat") .unwrap() .current_dir(tempdir.path()) - .args(["--last", "--lang", "rs", "--no-test"]) + .args(["--last", "--lang", "rs", "--non-test"]) .assert() .success() .stdout(predicate::str::contains("0 files changed")); @@ -982,7 +983,7 @@ fn no_test_filter_handles_renamed_rust_files() { Command::cargo_bin("git-diff-stat") .unwrap() .current_dir(tempdir.path()) - .args(["--last", "--lang", "rs", "--no-test"]) + .args(["--last", "--lang", "rs", "--non-test"]) .assert() .success() .stdout(predicate::str::contains("logging.rs")) @@ -1162,7 +1163,7 @@ fn non_test_filter_splits_supported_to_unsupported_rename_by_selected_language() Command::cargo_bin("git-diff-stat") .unwrap() .current_dir(tempdir.path()) - .args(["--last", "--lang", "rs", "--no-test"]) + .args(["--last", "--lang", "rs", "--non-test"]) .assert() .success() .stdout(predicate::str::contains("README.md => src/lib.rs"))