blockwatch 0.4.0

Language agnostic linter that keeps your code and documentation in sync and valid
Documentation
use assert_cmd::assert::OutputAssertExt;
use assert_cmd::cargo_bin_cmd;
use predicates::prelude::predicate;
use serde_json::json;

#[test]
fn diff_with_unsatisfied_blocks_fails() {
    let diff_content = r#"
diff --git a/tests/testdata/affects.md b/tests/testdata/affects.md
index abc123..def456 100644
--- a/tests/testdata/affects.md
+++ b/tests/testdata/affects.md
@@ -1,6 +1,5 @@
 # Testing data for integration tests

 [//]: # (<block affects=":foo">)
-First block.

 [//]: # (</block>)
"#;

    let mut cmd = cargo_bin_cmd!();
    cmd.args(["--diff", "--only-changed"]);
    let output = cmd.write_stdin(diff_content).output().unwrap();

    output.assert()
        .failure()
        .code(1)
        .stderr(predicate::function(|output: &str| {
            let output_json: serde_json::Value = serde_json::from_str(output).unwrap();
            let value: serde_json::Value = json!({
              "tests/testdata/affects.md": [
                {
                  "range": {
                    "start": {
                        "line": 3,
                        "character": 10
                    },
                    "end": {
                        "line": 3,
                        "character": 31
                    }
                  },
                  "code": "affects",
                  "message": "Block tests/testdata/affects.md:(unnamed) at line 3 is modified, but tests/testdata/affects.md:foo is not",
                  "severity": 1,
                  "data": {
                    "affected_block_file_path": "tests/testdata/affects.md",
                    "affected_block_name": "foo",
                  }
                }
              ]
            });
            assert_eq!(output_json, value);
            true
        }));
}

#[test]
fn diff_with_satisfied_blocks_succeeds() {
    // The diff deletes one line inside each block, so its post-image matches the on-disk fixture
    // and both blocks' contents count as modified.
    let diff_content = r#"
diff --git a/tests/testdata/affects.md b/tests/testing_data
index abc123..def456 100644
--- a/tests/testdata/affects.md
+++ b/tests/testdata/affects.md
@@ -1,13 +1,11 @@
 # Testing data for integration tests

 [//]: # (<block affects=":foo">)
-Deleted first line.
 First block.

 [//]: # (</block>)

 [//]: # (<block name="foo">)
-Deleted second line.
 Second block.

 [//]: # (</block>)
"#;

    let mut cmd = cargo_bin_cmd!();
    cmd.args(["--diff", "--only-changed"]);
    cmd.write_stdin(diff_content);

    let output = cmd.output().expect("Failed to get command output");

    output.assert().success();
}

#[test]
fn diff_with_satisfied_blocks_non_root_dir_succeeds() {
    // Same post-image-consistent diff as `diff_with_satisfied_blocks_succeeds`.
    let diff_content = r#"
diff --git a/tests/testdata/affects.md b/tests/testing_data
index abc123..def456 100644
--- a/tests/testdata/affects.md
+++ b/tests/testdata/affects.md
@@ -1,13 +1,11 @@
 # Testing data for integration tests

 [//]: # (<block affects=":foo">)
-Deleted first line.
 First block.

 [//]: # (</block>)

 [//]: # (<block name="foo">)
-Deleted second line.
 Second block.

 [//]: # (</block>)
"#;

    let mut cmd = cargo_bin_cmd!();
    cmd.args(["--diff", "--only-changed"]);
    cmd.current_dir("./tests");
    cmd.write_stdin(diff_content);

    let output = cmd.output().expect("Failed to get command output");

    output.assert().success();
}

#[test]
fn diff_with_only_tag_modified_succeeds() {
    let diff_content = r#"
diff --git a/tests/testdata/affects.md b/tests/testdata/affects.md
index abc123..def456 100644
--- a/tests/testdata/affects.md
+++ b/tests/testdata/affects.md
@@ -1,6 +1,6 @@
 # Testing data for integration tests

-[//]: # (<block affects=":foo" name="first">)
+[//]: # (<block affects=":foo">)
 First block.

 [//]: # (</block>)
"#;

    let mut cmd = cargo_bin_cmd!();
    cmd.args(["--diff", "--only-changed"]);
    cmd.write_stdin(diff_content);

    let output = cmd.output().expect("Failed to get command output");

    output.assert().success();
}

#[test]
fn diff_with_only_tag_modified_in_hunk_with_more_added_than_deleted_lines_succeeds() {
    let diff_content = r#"
diff --git a/tests/testdata/affects.py b/tests/testdata/affects.py
index abc123..def456 100644
--- a/tests/testdata/affects.py
+++ b/tests/testdata/affects.py
@@ -1,2 +1,3 @@
-# Project dependencies.
-# <block name="deps" affects=":deps-docs">
+# Project dependencies, kept sorted
+# and unique.
+# <block name="deps" affects=":deps-docs" keep-sorted="asc">
"#;

    let mut cmd = cargo_bin_cmd!();
    cmd.args(["--diff", "--only-changed"]);
    cmd.write_stdin(diff_content);

    let output = cmd.output().expect("Failed to get command output");

    output.assert().success();
}

#[test]
fn diff_dependent_block_with_only_tag_modified_fails() {
    let diff_content = r#"
diff --git a/tests/testdata/affects.md b/tests/testing_data
index abc123..def456 100644
--- a/tests/testdata/affects.md
+++ b/tests/testdata/affects.md
@@ -1,11 +1,9 @@
 # Testing data for integration tests

 [//]: # (<block affects=":foo">)
-First block.

 [//]: # (</block>)

- [//]: # (<block name="foo" test="value">)
+ [//]: # (<block name="foo">)
 Second block.

 [//]: # (</block>)
"#;

    let mut cmd = cargo_bin_cmd!();
    cmd.args(["--diff", "--only-changed"]);
    cmd.write_stdin(diff_content);

    let output = cmd.output().expect("Failed to get command output");

    output.assert()
        .failure()
        .code(1)
        .stderr(predicate::function(|output: &str| {
            let output_json: serde_json::Value = serde_json::from_str(output).unwrap();
            let value: serde_json::Value = json!({
              "tests/testdata/affects.md": [
                {
                  "range": {
                    "start": {
                        "line": 3,
                        "character": 10
                    },
                    "end": {
                        "line": 3,
                        "character": 31
                    }
                  },
                  "code": "affects",
                  "message": "Block tests/testdata/affects.md:(unnamed) at line 3 is modified, but tests/testdata/affects.md:foo is not",
                  "severity": 1,
                  "data": {
                    "affected_block_file_path": "tests/testdata/affects.md",
                    "affected_block_name": "foo",
                  }
                }
              ]
            });
            assert_eq!(output_json, value);
            true
        }));
}

/// The diff that both cross-file tests below feed in. It changes the source block and its `affects`
/// target together, which is exactly what the rule asks for, so no invocation over it may fail.
const CROSS_FILE_DIFF: &str = r#"
diff --git a/tests/testdata/affects_cross_file_source.rs b/tests/testdata/affects_cross_file_source.rs
index abc123..def456 100644
--- a/tests/testdata/affects_cross_file_source.rs
+++ b/tests/testdata/affects_cross_file_source.rs
@@ -1,3 +1,3 @@
 // <block name="limits" affects="tests/testdata/affects_cross_file_target.md:limits">
-pub const MAX: usize = 10;
+pub const MAX: usize = 20;
 // </block>
diff --git a/tests/testdata/affects_cross_file_target.md b/tests/testdata/affects_cross_file_target.md
index abc123..def456 100644
--- a/tests/testdata/affects_cross_file_target.md
+++ b/tests/testdata/affects_cross_file_target.md
@@ -1,5 +1,5 @@
 [//]: # (<block name="limits">)

-Max is 10.
+Max is 20.

 [//]: # (</block>)
"#;

#[test]
fn only_changed_with_globs_excluding_a_modified_affects_target_succeeds() {
    // Globs choose what a run validates; they must not shrink the set of files `affects` resolves
    // its targets against, or narrowing a run to one language would fail every cross-language rule.
    let mut cmd = cargo_bin_cmd!();
    cmd.args([
        "--diff",
        "--only-changed",
        "tests/testdata/affects_cross_file_source.rs",
    ]);
    cmd.write_stdin(CROSS_FILE_DIFF);

    let output = cmd.output().expect("Failed to get command output");

    output.assert().success();
}

#[test]
fn diff_with_globs_excluding_a_modified_affects_target_succeeds() {
    // The same guarantee on a full-tree run, where the globs filter the walk rather than the diff.
    let mut cmd = cargo_bin_cmd!();
    cmd.args(["--diff", "tests/testdata/affects_cross_file_source.rs"]);
    cmd.write_stdin(CROSS_FILE_DIFF);

    let output = cmd.output().expect("Failed to get command output");

    output.assert().success();
}

#[test]
fn only_changed_with_globs_excluding_an_unmodified_affects_target_fails() {
    // The counterpart of the two tests above: resolving targets outside the validated set must
    // still report the ones the diff left alone, rather than assuming any excluded target is fine.
    let diff_content = r#"
diff --git a/tests/testdata/affects_cross_file_source.rs b/tests/testdata/affects_cross_file_source.rs
index abc123..def456 100644
--- a/tests/testdata/affects_cross_file_source.rs
+++ b/tests/testdata/affects_cross_file_source.rs
@@ -1,3 +1,3 @@
 // <block name="limits" affects="tests/testdata/affects_cross_file_target.md:limits">
-pub const MAX: usize = 10;
+pub const MAX: usize = 20;
 // </block>
"#;

    let mut cmd = cargo_bin_cmd!();
    cmd.args([
        "--diff",
        "--only-changed",
        "tests/testdata/affects_cross_file_source.rs",
    ]);
    cmd.write_stdin(diff_content);

    let output = cmd.output().expect("Failed to get command output");

    output
        .assert()
        .failure()
        .code(1)
        .stderr(predicate::str::contains(
            "tests/testdata/affects_cross_file_target.md:limits is not",
        ));
}