"""Tests for structural ``--grep`` matching and match highlighting.""" from datetime import UTC, datetime from kanta.__main__ import main from kanta.grep import ( GrepPattern, _Entry, _find_spans, _match_path, _match_value, evaluate, mark_spans, ) from kanta.logging import _USER_PATH from kanta.serialization import JsonSerializer from kanta.serialization.framing import LineFramer from kanta.structs import ChangeRecord, Snapshot from kanta.tty import strip_ansi TS = datetime(2026, 1, 1, tzinfo=UTC) MARK = "\x1b[48;5;220m" UNMARK = "\x1b[49m" def test_match_path_whole_elements_anywhere(): assert _match_path("users", ["users"]) == frozenset({0}) assert _match_path("users", ["data", "users", "alice"]) == frozenset({1}) # Partial element matches require explicit wildcards. assert _match_path("users", ["foousers"]) is None assert _match_path("us*rs", ["foousers"]) is None assert _match_path("*users", ["foousers"]) == frozenset({0}) def test_match_path_contiguous_element_sequence(): assert _match_path("alice.email", ["users", "alice", "email"]) == frozenset({1, 2}) # The sequence must be contiguous. assert _match_path("users.email", ["users", "alice", "email"]) is None assert _match_path("users.*.email", ["users", "alice", "email"]) == frozenset( {0, 1, 2} ) # Matching is case-insensitive and empty terms match without marking. assert _match_path("USERS", ["users"]) == frozenset({0}) assert _match_path("", ["anything"]) == frozenset() def test_match_value_substring_for_strings_full_for_scalars(): assert _match_value("lice", "alice@example.com", "alice@example.com") assert not _match_value("bob", "alice@example.com", "alice@example.com") # Booleans, numbers and null match only in full. assert _match_value("true", True, "true") assert not _match_value("tru", True, "true") assert _match_value("42", 42, "42") assert not _match_value("4", 42, "42") assert _match_value("null", None, "null") # Wildcards match the whole value text of any type. assert _match_value("tru*", True, "true") assert _match_value("4*", 42, "42") # Containers do not match; their leaves are matched individually. assert not _match_value("email", {"email": "a@b.c"}, '{"email": "a@b.c"}') # An empty term matches anything. assert _match_value("", True, "true") def test_mark_spans_merges_overlaps_and_adjacents(): assert mark_spans("alice@example.com", [(0, 5), (3, 11)]) == ( f"{MARK}alice@examp{UNMARK}le.com" ) assert mark_spans("aab", [(0, 1), (1, 3)]) == f"{MARK}aab{UNMARK}" assert mark_spans("ab", [(1, 1), (2, 2)]) == "ab" def test_mark_spans_offsets_into_styled_text(): styled = "\x1b[32mal\x1b[0mlice" # A span crossing an escape sequence wraps each escape-free run. assert mark_spans(styled, [(0, 3)]) == ( f"\x1b[32m{MARK}al{UNMARK}\x1b[0m{MARK}l{UNMARK}ice" ) # A span covering everything wraps each escape-free run separately. assert mark_spans(styled, [(0, 6)]) == ( f"\x1b[32m{MARK}al{UNMARK}\x1b[0m{MARK}lice{UNMARK}" ) assert mark_spans(styled, []) == styled def test_find_spans_case_insensitive_occurrences(): assert _find_spans("Alice likes ALICE", "alice") == [(0, 5), (12, 17)] assert _find_spans("nope", "alice") == [] assert _find_spans("anything", "") == [] def test_pattern_parse_forms(): bare = GrepPattern.parse("alice") assert bare.term == "alice" and bare.path_term is None pair = GrepPattern.parse("users.alice.age=30") assert pair.term is None assert pair.path_term == "users.alice.age" assert pair.value_term == "30" value_only = GrepPattern.parse("=alice@example.com") assert value_only.path_term == "" and value_only.value_term == "alice@example.com" path_only = GrepPattern.parse("users.alice=") assert path_only.path_term == "users.alice" and path_only.value_term == "" # Split on the first '=' only; the value may contain '='. multi = GrepPattern.parse("key=a=b") assert multi.path_term == "key" and multi.value_term == "a=b" def _patterns(*raws: str) -> list[GrepPattern]: return [GrepPattern.parse(raw) for raw in raws] def test_evaluate_bare_term_against_path_value_action_user(): record = ChangeRecord( ts=TS, a="create_user", u="admin", diff={"users": {"alice": {"age": 30}}} ) assert evaluate(record, {}, _patterns("users.alice")) assert evaluate(record, {}, _patterns("30")) assert evaluate(record, {}, _patterns("CREATE_user")) assert evaluate(record, {}, _patterns("admin")) assert evaluate(record, {}, _patterns("age=30")) assert not evaluate(record, {}, _patterns("bob")) assert not evaluate(record, {}, _patterns("3")) # not a full number match def test_evaluate_unescapes_dollar_keys(): record = ChangeRecord(ts=TS, a="set", diff={"$$config": 5}) assert evaluate(record, {}, _patterns("$config=5")) def test_evaluate_path_value_form_requires_same_line(): record = ChangeRecord(ts=TS, a="set", diff={"a": {"x": 1}, "b": {"y": 2}}) previous = {"a": {"x": 0}, "b": {"y": 0}} assert evaluate(record, previous, _patterns("a.x=1")) # 'a' matches one line's path, '2' another line's value: no match. assert not evaluate(record, previous, _patterns("a=2")) def test_evaluate_all_patterns_same_record_any_line(): record = ChangeRecord(ts=TS, a="set", diff={"a": {"x": 1}, "b": {"y": 2}}) previous = {"a": {"x": 0}, "b": {"y": 0}} assert evaluate(record, previous, _patterns("a.x", "=2")) assert not evaluate(record, previous, _patterns("a.x", "=2", "missing")) def test_evaluate_matches_deleted_content_by_previous_value(): record = ChangeRecord(ts=TS, a="delete_user", diff={"users": {"$delete": "alice"}}) previous = {"users": {"alice": {"email": "alice@example.com"}}} assert evaluate(record, previous, _patterns("alice@example.com")) assert evaluate(record, previous, _patterns("users.alice.email")) assert not evaluate(record, {}, _patterns("alice@example.com")) def test_highlighter_marks_exactly_the_matched_regions(): record = ChangeRecord( ts=TS, a="set", diff={ "users": {"alice": {"email": "same@x.com"}, "bob": {"email": "same@x.com"}} }, ) previous = { "users": {"alice": {"email": "old@x.com"}, "bob": {"email": "same@x.com"}} } hl = evaluate(record, previous, _patterns("users.alice.email")) assert hl is not None # Path-side match: the matched elements are lit, the value is not. assert hl.path("alice", "users.alice") == f"{MARK}alice{UNMARK}" assert hl.value("same@x.com", "users.alice.email") == "same@x.com" # Bob's identical value is a different path: untouched. assert hl.path("bob", "users.bob") == "bob" assert hl.value("same@x.com", "users.bob.email") == "same@x.com" hl = evaluate(record, previous, _patterns("=same@x.com")) assert hl is not None # Both lines genuinely match the value: both are marked. assert hl.value("same@x.com", "users.alice.email") == f"{MARK}same@x.com{UNMARK}" assert hl.value("same@x.com", "users.bob.email") == f"{MARK}same@x.com{UNMARK}" assert hl.path("alice", "users.alice") == "alice" def test_highlighter_merges_overlapping_needles_from_different_patterns(): record = ChangeRecord(ts=TS, a="set", diff={"email": "alice@example.com"}) hl = evaluate(record, {}, _patterns("alice", "lice@exam")) assert hl is not None assert hl.value("alice@example.com", "email") == ( f"{MARK}alice@exam{UNMARK}ple.com" ) def test_highlighter_marks_delete_marker_on_removed_content_match(): record = ChangeRecord(ts=TS, a="delete_user", diff={"users": {"$delete": "bob"}}) previous = {"users": {"bob": {"email": "bob@example.com"}}} hl = evaluate(record, previous, _patterns("bob@example.com")) assert hl is not None # The matched content is gone: the deletion marker is lit, not the path. assert hl.path("users", "users") == "users" assert hl.path("bob", "users.bob") == "bob" assert hl.delete("✗", "users.bob") == f"{MARK}✗{UNMARK}" assert hl.delete("✗", "users.alice") == "✗" # A path-side match lights the genuinely matched elements of the anchor, # and the marker for the element below it. hl = evaluate(record, previous, _patterns("users.bob.email")) assert hl is not None assert hl.path("users", "users") == f"{MARK}users{UNMARK}" assert hl.path("bob", "users.bob") == f"{MARK}bob{UNMARK}" assert hl.delete("✗", "users.bob") == f"{MARK}✗{UNMARK}" # A path-side match of one displayed element lights only that element. hl = evaluate(record, previous, _patterns("bob")) assert hl is not None assert hl.path("users", "users") == "users" assert hl.path("bob", "users.bob") == f"{MARK}bob{UNMARK}" def test_highlighter_meta_marks_action_and_user(): record = ChangeRecord(ts=TS, a="create_user", u="admin", diff={"x": 1}) hl = evaluate(record, {}, _patterns("create", "ADM")) assert hl is not None assert hl.meta("create_user", "action") == f"{MARK}create{UNMARK}_user" assert hl.meta("admin", "user") == f"{MARK}adm{UNMARK}in" assert hl.meta("extra", "other") == "extra" def test_entry_dataclass_holds_anchor_for_deletes(): entry = _Entry(["a", "b"], 1, "1", anchor=["a"]) assert entry.anchor == ["a"] def _write_db(path, changes, state=None): serializer = JsonSerializer() framer = LineFramer() snapshot = Snapshot(ts=TS, v=1, state=state or {}) data = framer.frame_snapshot(serializer.encode(snapshot), record_offset=0) for change in changes: data += framer.frame_change(serializer.encode(change), record_offset=0) path.write_bytes(data) def _sample_changes(): return [ ChangeRecord( ts=TS, a="create_alice", u="admin", diff={ "users": { "alice": {"email": "alice@example.com", "admin": True}, } }, ), ChangeRecord( ts=TS, a="update_alice", u="admin", diff={"users": {"alice": {"age": 30}}}, ), ChangeRecord( ts=TS, a="create_bob", u="bob", diff={"users": {"bob": {"email": "bob@example.com"}}}, ), ] def _run_cli(tmp_path, capsys, monkeypatch, changes, *args, color=False): if color: monkeypatch.setenv("FORCE_COLOR", "1") monkeypatch.delenv("NO_COLOR", raising=False) else: monkeypatch.setenv("NO_COLOR", "1") monkeypatch.delenv("FORCE_COLOR", raising=False) path = tmp_path / "test.kantadb" _write_db(path, changes) code = main([str(path), *args]) assert code == 0 return capsys.readouterr().err def test_cli_grep_filters_records(tmp_path, capsys, monkeypatch): """Only matching change records are printed.""" err = _run_cli(tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "alice") assert "create_alice" in err assert "update_alice" in err assert "create_bob" not in err assert "bob@example.com" not in err def test_cli_grep_prints_entire_transaction(tmp_path, capsys, monkeypatch): """A match prints the whole record, not just the matching line.""" err = _run_cli( tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "users.alice.email" ) assert "create_alice" in err # Non-matching lines of the same record are printed too. assert "admin" in err and "true" in err # The update record does not contain the path. assert "update_alice" not in err def test_cli_grep_repeated_patterns_must_all_match(tmp_path, capsys, monkeypatch): """Repeated --grep options are ANDed within the same record.""" err = _run_cli( tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "alice", "--grep", "=alice@example.com", ) assert "create_alice" in err # update_alice matches 'alice' but has no email value. assert "update_alice" not in err err = _run_cli( tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "alice", "--grep", "bob", ) assert "create_alice" not in err assert "create_bob" not in err def test_cli_grep_no_match_exits_zero(tmp_path, capsys, monkeypatch): """No matching records is not an error; snapshot lines still print.""" err = _run_cli(tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "nobody") assert "snapshot s0" in err assert "create_alice" not in err assert "create_bob" not in err def test_cli_grep_path_value_forms(tmp_path, capsys, monkeypatch): """The 'path=value', 'path=' and '=value' forms restrict the match side.""" err = _run_cli( tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "users.*.admin=true" ) assert "create_alice" in err assert "create_bob" not in err err = _run_cli( tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "users.alice.age=" ) assert "update_alice" in err assert "create_alice" not in err err = _run_cli( tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "=bob@example.com" ) assert "create_bob" in err assert "create_alice" not in err def test_cli_grep_path_elements_match_in_full(tmp_path, capsys, monkeypatch): """'users' does not match a 'foousers' element; 'us*' does.""" changes = [ChangeRecord(ts=TS, a="trap", diff={"foousers": {"note": "x"}})] err = _run_cli(tmp_path, capsys, monkeypatch, changes, "--grep", "users") assert "trap" not in err err = _run_cli(tmp_path, capsys, monkeypatch, changes, "--grep", "*users") assert "trap" in err def _shared_email_changes(): return [ ChangeRecord( ts=TS, a="create_alice", u="admin", diff={"users": {"alice": {"email": "shared@example.com", "admin": True}}}, ), ChangeRecord( ts=TS, a="create_bob", u="admin", diff={"users": {"bob": {"email": "shared@example.com"}}}, ), ChangeRecord( ts=TS, a="delete_bob", u="admin", diff={"users": {"$delete": "bob"}}, ), ] def _lines_with(err: str, text: str) -> list[str]: return [line for line in err.splitlines() if text in strip_ansi(line)] def test_cli_highlight_marks_exactly_the_matched_regions(tmp_path, capsys, monkeypatch): """A path-side match lights only the matched elements, not the value.""" err = _run_cli( tmp_path, capsys, monkeypatch, _shared_email_changes(), "--grep", "users.alice.email", color=True, ) (alice_line,) = _lines_with(err, "shared@example.com") assert f"{MARK}alice{UNMARK}" in alice_line assert f"{MARK}email{UNMARK}" in alice_line # The value itself did not match, so it is not highlighted. assert f"{MARK}shared@example.com{UNMARK}" not in alice_line (header_line,) = _lines_with(err, "users =") assert f"{MARK}users{UNMARK}" in header_line assert "create_bob" not in err def test_cli_highlight_value_matches_on_all_matching_lines( tmp_path, capsys, monkeypatch ): """A value-side match lights the value on every line that genuinely matched.""" err = _run_cli( tmp_path, capsys, monkeypatch, _shared_email_changes(), "--grep", "=shared@example.com", color=True, ) lines = _lines_with(err, "shared@example.com") assert len(lines) == 2 # alice's and bob's records both matched for line in lines: assert f"{MARK}shared@example.com{UNMARK}" in line # The deletion of bob matched by its previous (removed) value: the # deletion marker is lit, not the deleted path. (delete_line,) = _lines_with(err, "✗") assert f"{MARK}✗{UNMARK}" in delete_line assert f"{MARK}bob{UNMARK}" not in delete_line assert f"{MARK}users{UNMARK}" not in delete_line def test_cli_highlight_merges_overlapping_matches(tmp_path, capsys, monkeypatch): """Overlapping matches from different patterns form one continuous mark.""" err = _run_cli( tmp_path, capsys, monkeypatch, _shared_email_changes(), "--grep", "shared", "--grep", "red@exam", color=True, ) lines = _lines_with(err, "shared@example.com") assert len(lines) == 2 # both records genuinely match both patterns for line in lines: assert f"{MARK}shared@exam{UNMARK}ple.com" in line def test_cli_highlight_full_scalar_and_header_fields(tmp_path, capsys, monkeypatch): """Scalars are marked in full; matched action/user substrings are marked.""" err = _run_cli( tmp_path, capsys, monkeypatch, _shared_email_changes(), "--grep", "admin=true", "--grep", "create_al", color=True, ) (line,) = _lines_with(err, "shared@example.com") assert f"{MARK}true{UNMARK}" in line (header,) = _lines_with(err, "create_alice") assert f"{MARK}create_al{UNMARK}ice" in header # Only the record matching both patterns is printed. assert "create_bob" not in err assert "delete_bob" not in err def test_evaluate_matches_prettified_and_raw_value_forms(): """With logfmt, both the raw and the prettified form of a value match.""" record = ChangeRecord(ts=TS, a="set", diff={"when": 1767225600}) def logfmt(value, path): return "2026-01-01" if value == 1767225600 else None # The prettified form matches; the needle is located in the display text. hl = evaluate(record, {}, _patterns("2026"), logfmt=logfmt) assert hl is not None assert hl.value("2026-01-01", "when") == f"{MARK}2026{UNMARK}-01-01" # The raw form matches too; its needle is absent from the displayed # text, so the whole displayed value is marked. hl = evaluate(record, {}, _patterns("1767225600"), logfmt=logfmt) assert hl is not None assert hl.value("2026-01-01", "when") == f"{MARK}2026-01-01{UNMARK}" # Without logfmt the raw value is matched and marked precisely. hl = evaluate(record, {}, _patterns("1767225600")) assert hl.value("1767225600", "when") == f"{MARK}1767225600{UNMARK}" # Neither form matches. assert evaluate(record, {}, _patterns("1999"), logfmt=logfmt) is None def test_evaluate_matches_prettified_user(): """The user field matches both the raw id and the logfmt-resolved name.""" def logfmt(value, path): return "Alice Admin" if path == _USER_PATH else None record = ChangeRecord(ts=TS, a="set", u="u123", diff={"x": 1}) hl = evaluate(record, {}, _patterns("alice"), logfmt=logfmt) assert hl is not None assert hl.meta("Alice Admin", "user") == f"{MARK}Alice{UNMARK} Admin" # A raw-only user match marks the whole displayed name. hl = evaluate(record, {}, _patterns("u123"), logfmt=logfmt) assert hl is not None assert hl.meta("Alice Admin", "user") == f"{MARK}Alice Admin{UNMARK}" # The action is never prettified. assert hl.meta("set", "action") == "set" def test_evaluate_without_logfmt_matches_raw_only(): record = ChangeRecord(ts=TS, a="set", u="u123", diff={"when": 1767225600}) assert evaluate(record, {}, _patterns("1767225600")) assert evaluate(record, {}, _patterns("2026")) is None assert evaluate(record, {}, _patterns("alice")) is None def test_cli_grep_matches_prettified_forms_with_kanta_object( tmp_path, capsys, monkeypatch ): """End to end: a -k object's logfmt formatter doubles the match surface.""" db_path = tmp_path / "test.kantadb" module = tmp_path / "dbmod.py" module.write_text( "from typing import Any\n" "from kanta import Kanta\n" f"kanta = Kanta({str(db_path)!r}, {{}}, type=dict)\n" "@kanta.logfmt\n" "def pretty(value: Any, path: str) -> str | None:\n" " if path == 'when':\n" " return 'Nov 3, 2025'\n" " if path == '$user':\n" " return 'Alice Admin'\n" " return None\n" ) changes = [ChangeRecord(ts=TS, a="set", u="u123", diff={"when": 1767225600})] kanta_args = ("-k", str(module)) # A term matching only the prettified value finds the record. err = _run_cli( tmp_path, capsys, monkeypatch, changes, *kanta_args, "--grep", "nov", color=True ) (line,) = _lines_with(err, "Nov 3, 2025") assert f"{MARK}Nov{UNMARK} 3, 2025" in line # A term matching only the raw value marks the whole prettified display. err = _run_cli( tmp_path, capsys, monkeypatch, changes, *kanta_args, "--grep", "1767225600", color=True, ) (line,) = _lines_with(err, "Nov 3, 2025") assert f"{MARK}Nov 3, 2025{UNMARK}" in line # The prettified user matches, and the raw id marks the whole display. err = _run_cli( tmp_path, capsys, monkeypatch, changes, *kanta_args, "--grep", "alice", color=True, ) (header,) = _lines_with(err, "Alice Admin") assert f"{MARK}Alice{UNMARK} Admin" in header err = _run_cli( tmp_path, capsys, monkeypatch, changes, *kanta_args, "--grep", "u123", color=True, ) (header,) = _lines_with(err, "Alice Admin") assert f"{MARK}Alice Admin{UNMARK}" in header # Without -k there is no prettified form to match. err = _run_cli(tmp_path, capsys, monkeypatch, changes, "--grep", "nov") assert not _lines_with(err, "u123") err = _run_cli(tmp_path, capsys, monkeypatch, changes, "--grep", "1767225600") assert _lines_with(err, "u123")