omnidiff 0.2.0

Fast, robust, syntax-aware code diffing using tree-sitter ASTs
Documentation
/*  This file is part of the OmniDiff code diffing tool.
 *
 *  Copyright (C) 2026 Marko Ivankovic
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU Affero General Public License as published
 *  by the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 *  GNU Affero General Public License for more details.
 *
 *  You should have received a copy of the GNU Affero General Public License
 *  along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
pub mod fixtures;
pub mod helper;

#[cfg(test)]
mod tests {
    use super::*;

    use anyhow::Result;

    /// Clamped stubs that predate the rule below and have no explanation yet. A backlog, not an
    /// exemption: shrink it by writing the explanation or tightening the limit; never grow it.
    #[cfg(feature = "test-fixtures")]
    const CLAMPS_WITHOUT_AN_EXPLANATION: &[&str] = &[
        "c-linux-small-change-struct-to-char",
        "cpp-ladybird-refactor-variables-if-changes",
        "cpp-tensorflow-switch-to-primitive-types",
        "csharp-cyanfish-naps2-add-condition-to-if",
        "csharp-jellyfin-add-function",
        "go-lazygit-switch-to-strings",
        "html-mozilla-firefox-firefox-remove-li-around-button",
        "javascript-typescript-interesting-small-edit-refactor",
        "json-excalidraw-excalidraw-change-translations-mostly-add",
        "json-kiwix-kiwix-desktop-add-a-few-change-a-few",
        "kotlin-jetbrains-kotlin-remove-one-comment-line",
        "php-zetacomponents-consoletools-file-with-parse-errors-and-a-few-deletions",
        "python-ansible-ansible-ridiculously-long-yaml-in-string-constant-and-actual-code-changes",
        "python-portagefilelist-client-remove-one-import-and-update-one-const-string",
        "scala-com-lihaoyi-mill-add-a-function-call",
        "scala-com-lihaoyi-mill-small-refactoring",
        "shellscript-scikit-learn-scikit-learn-string-to-regex",
        "swift-apple-swift-argument-parser-small-change",
        "swift-nextcloud-ios-move-function-and-refactor-logic",
        "swift-nextcloud-ios-refactor-and-change",
        "typescript-apache-echarts-envelop-2-lines-with-an-if-block",
        "vimscript-neovim-neovim-test-debian-package-parsing-awful-string-matching",
        "xml-gap-packages-toric-remove-two-attributes",
    ];

    /// **A clamped limit must say why it is clamped** (see `fixtures`' module doc): a limit with no
    /// stub comment is a number nobody can review.
    #[test]
    #[cfg(feature = "test-fixtures")]
    fn the_clamped_stubs_explain_their_limits() -> Result<()> {
        let mut undocumented = Vec::new();
        let mut stale_allowance = Vec::new();
        for dataset in helper::DIFF_DATASETS {
            let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("src")
                .join("test")
                .join("fixtures")
                .join(dataset);
            if !dir.exists() {
                continue;
            }
            for entry in std::fs::read_dir(&dir)? {
                let path = entry?.path();
                if path.extension().and_then(|e| e.to_str()) != Some("rs") {
                    continue;
                }
                let source = std::fs::read_to_string(&path)?;
                if !source.contains("assert_matches_human_mapping_within_limit") {
                    continue;
                }
                let Some(name) = source
                    .split_once("assert_matches_human_mapping_within_limit(")
                    .and_then(|(_, rest)| rest.split_once('"'))
                    .and_then(|(_, rest)| rest.split_once('"'))
                    .map(|(name, _)| name.to_string())
                else {
                    continue;
                };
                // Scoped to the test holding the clamped call, found by walking back to its
                // `#[test]` (not by name: `mapping_details()` may come first): a comment on the
                // painting or on another assertion does not explain *this* number. Indented one
                // level, since column-zero docs explain the file, not the limit.
                let call_at = source
                    .find("assert_matches_human_mapping_within_limit")
                    .unwrap_or(0);
                let test_at = source[..call_at].rfind("#[test]").unwrap_or(0);
                let explained = source[test_at..call_at]
                    .lines()
                    .any(|line| line.starts_with("    //"));
                let allowed = CLAMPS_WITHOUT_AN_EXPLANATION.contains(&name.as_str());
                match (explained, allowed) {
                    (false, false) => undocumented.push(name),
                    (true, true) => stale_allowance.push(name),
                    _ => {}
                }
            }
        }
        assert!(
            undocumented.is_empty(),
            "these clamped limits have no comment saying why omnidiff cannot do better. Write one, \
             or tighten the limit until none is needed:\n    {}",
            undocumented.join("\n    ")
        );
        assert!(
            stale_allowance.is_empty(),
            "these are now explained and must come off CLAMPS_WITHOUT_AN_EXPLANATION - the list \
             only shrinks:\n    {}",
            stale_allowance.join("\n    ")
        );
        Ok(())
    }

    /// **Every fixture stub is declared in its dataset's module file.** An undeclared stub is not
    /// compiled at all, so its tests silently never run and nothing else notices.
    #[test]
    #[cfg(feature = "test-fixtures")]
    fn every_fixture_stub_is_declared_in_its_dataset_module() -> Result<()> {
        // Imported here: at module level it is unused under the default features, which
        // `clippy -D warnings` rejects.
        use anyhow::Context;

        let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("src")
            .join("test")
            .join("fixtures");
        let mut orphaned = Vec::new();
        let mut datasets_seen = 0usize;
        for dataset in helper::DIFF_DATASETS {
            let dir = fixtures.join(dataset);
            if !dir.exists() {
                continue;
            }
            // Read as text: the compiler cannot report a declaration that is missing.
            let module_file = fixtures.join(format!("{dataset}.rs"));
            let declarations = std::fs::read_to_string(&module_file)
                .with_context(|| format!("reading {module_file:?}"))?;
            datasets_seen += 1;
            for entry in std::fs::read_dir(&dir)? {
                let path = entry?.path();
                if path.extension().and_then(|e| e.to_str()) != Some("rs") {
                    continue;
                }
                let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                    continue;
                };
                // With the `;`, so one stub cannot vouch for another whose name it is a prefix of
                // (`..._defaultkeyedvalues` vs `..._defaultkeyedvalues2d`, both real).
                if !declarations.contains(&format!("mod {stem};")) {
                    orphaned.push(format!("{dataset}/{stem}.rs"));
                }
            }
        }
        assert!(
            datasets_seen > 0,
            "no fixture directories found - this check would pass vacuously"
        );
        orphaned.sort();
        assert!(
            orphaned.is_empty(),
            "these fixture stubs are not declared in their dataset's module file, so none of \
             their tests run. Add `#[cfg(test)] mod <name>;` to \
             src/test/fixtures/<dataset>.rs:\n    {}",
            orphaned.join("\n    ")
        );
        Ok(())
    }

    /// A `stub_mapping_limits` that silently read nothing would make the baseline projection test
    /// below pass vacuously.
    #[test]
    #[cfg(feature = "test-fixtures")]
    fn stub_mapping_limits_reads_both_call_shapes_and_skips_the_hand_written_stub() -> Result<()> {
        let limits = helper::human_mapping::stub_mapping_limits()?;
        assert!(
            limits.len() > 400,
            "expected a limit for most of the corpus, got {}",
            limits.len()
        );
        assert_eq!(
            limits.get("c-awslabs-aws-c-common-only-insert"),
            Some(&(0, 0)),
            "the exact call shape reads as a zero limit"
        );
        assert_eq!(
            limits.get("c-sched-ext-scx-many-many-moves-some-deletes-some-adds"),
            Some(&(3, 3)),
            "the clamped call shape reads its two numbers"
        );
        assert_eq!(
            limits.get("rust-hash-optimization"),
            None,
            "rust_hash_optimization.rs asserts specific mappings by hand rather than calling \
             either helper, so it has no single limit to report and must not be guessed at"
        );
        Ok(())
    }

    /// **`quality_baseline.csv`'s accuracy columns must equal the fixture stubs' limits.**
    /// `write_baseline` writes a run's own numbers; this test is what keeps the two from
    /// drifting apart. `elapsed_ms` is machine-dependent and not pinned.
    #[test]
    #[cfg(feature = "test-fixtures")]
    fn the_quality_baseline_accuracy_columns_are_a_projection_of_the_stub_limits() -> Result<()> {
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("research")
            .join("data")
            .join("quality")
            .join("quality_baseline.csv");
        // Absent in a checkout that has never run the gate.
        if !path.exists() {
            return Ok(());
        }

        let limits = helper::human_mapping::stub_mapping_limits()?;
        let mut reader = csv::Reader::from_path(&path)?;
        let mut disagreeing = Vec::new();
        for record in reader.deserialize::<std::collections::HashMap<String, String>>() {
            let record = record?;
            let name = record.get("solution").cloned().unwrap_or_default();
            let Some(&(total, visible)) = limits.get(&name) else {
                continue;
            };
            let field = |key: &str| -> usize {
                record
                    .get(key)
                    .and_then(|v| v.trim().parse().ok())
                    .unwrap_or_default()
            };
            if (field("mismatches"), field("visible_mismatches")) != (total, visible) {
                disagreeing.push(format!(
                    "{name}: baseline {},{} vs stub limit {total},{visible}",
                    field("mismatches"),
                    field("visible_mismatches")
                ));
            }
        }
        assert!(
            disagreeing.is_empty(),
            "quality_baseline.csv has drifted from the stub limits it is derived from; \
             re-run `make update-quality-baseline`:\n    {}",
            disagreeing.join("\n    ")
        );
        Ok(())
    }

    #[test]
    fn handmade_test_code_loads() -> Result<()> {
        let test_codes = helper::handmade_test_code()?;

        assert!(!test_codes.is_empty());

        Ok(())
    }

    #[test]
    #[cfg(feature = "stats")]
    fn handmade_git_repository_loads() -> Result<()> {
        let test_git_repo_path = helper::handmade_git_repository()?;

        assert!(test_git_repo_path.is_dir());

        let git_dir = test_git_repo_path.join(".git");
        assert!(git_dir.is_dir());

        let repo = git2::Repository::open(&test_git_repo_path)?;
        let head = repo.head()?;
        let commit = head.peel_to_commit()?;

        let mut revwalk = repo.revwalk()?;
        revwalk.push(commit.id())?;
        let commit_count = revwalk.count();
        assert!(
            commit_count >= 2,
            "Expected at least 2 commits, found {}",
            commit_count
        );

        let main_rs_path = test_git_repo_path.join("main.rs");
        assert!(main_rs_path.is_file());
        let content = std::fs::read_to_string(main_rs_path)?;
        assert!(content.contains("Hello World"));

        Ok(())
    }
}