From 1d0cb2c0124105dee5a7371bf4c2259e71fb30c8 Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 01:03:15 +0800 Subject: [PATCH 1/3] 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 2/3] 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 0bcb09f57c8c5e8b3c01adbedf491849da347d9c Mon Sep 17 00:00:00 2001 From: Yanxi Date: Sat, 21 Mar 2026 07:30:30 +0800 Subject: [PATCH 3/3] fix: handle cross-language test-filter renames --- src/lang/mod.rs | 7 +- src/test_filter.rs | 193 +++++++++++++++++++++++++++++++++++++++++-- tests/cli_smoke.rs | 38 +++++++++ tests/lang_filter.rs | 20 +++++ 4 files changed, 246 insertions(+), 12 deletions(-) diff --git a/src/lang/mod.rs b/src/lang/mod.rs index a275be9..2451e7c 100644 --- a/src/lang/mod.rs +++ b/src/lang/mod.rs @@ -14,9 +14,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)?, - _ => 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)?, + _ => continue, + }, }; if added + deleted == 0 { @@ -70,6 +82,7 @@ struct BuildContext<'a> { git: &'a Git, endpoints: &'a Option, patch_map: &'a HashMap, + langs: &'a [&'a str], mode: TestFilterMode, } @@ -130,6 +143,30 @@ fn build_counts_for_python( ) } +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>, @@ -193,6 +230,148 @@ where Ok(select_counts_from_split(&split, context.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, + ), + _ => 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_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; @@ -266,10 +445,6 @@ fn select_counts_from_split(split: &impl TestSplitCounts, mode: TestFilterMode) } } -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, langs: &[&str]) -> Result, String> { load_sources(git.tracked_files()?, langs, |path| { git.show_index_file(path) diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 1091f0e..8a1a8c8 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -738,6 +738,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 2e40eea..c001e75 100644 --- a/tests/lang_filter.rs +++ b/tests/lang_filter.rs @@ -54,3 +54,23 @@ fn filters_to_python_extension() { assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].path, "app/main.py"); } + +#[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()); +}