gabriel / muse public
test_cmd_content_grep.py python
371 lines 13.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for ``muse content-grep``.
2
3 Covers: no match exit-1, pattern found, --files-only, --count, --ignore-case,
4 --format json, binary skip, multi-file, stress: 100 files.
5 Working-tree mode: --working-tree searches disk, not the committed snapshot.
6 """
7
8 from __future__ import annotations
9
10 type _FileStore = dict[str, bytes]
11
12 import datetime
13 import hashlib
14 import json
15 import pathlib
16
17 import pytest
18 from tests.cli_test_helper import CliRunner
19
20 cli = None # argparse migration — CliRunner ignores this arg
21 from muse.core.object_store import write_object
22 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
23 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
24 from muse.core._types import Manifest
25
26 runner = CliRunner()
27
28 _REPO_ID = "cgrep-test"
29
30
31 # ---------------------------------------------------------------------------
32 # Helpers
33 # ---------------------------------------------------------------------------
34
35
36 def _sha(data: bytes) -> str:
37 return hashlib.sha256(data).hexdigest()
38
39
40 def _init_repo(path: pathlib.Path) -> pathlib.Path:
41 muse = path / ".muse"
42 for d in ("commits", "snapshots", "objects", "refs/heads"):
43 (muse / d).mkdir(parents=True, exist_ok=True)
44 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
45 (muse / "repo.json").write_text(
46 json.dumps({"repo_id": _REPO_ID, "domain": "midi"}), encoding="utf-8"
47 )
48 return path
49
50
51 def _env(repo: pathlib.Path) -> Manifest:
52 return {"MUSE_REPO_ROOT": str(repo)}
53
54
55 _counter = 0
56
57
58 def _commit_files(root: pathlib.Path, files: _FileStore) -> str:
59 global _counter
60 _counter += 1
61 manifest: Manifest = {}
62 for rel_path, content in files.items():
63 obj_id = _sha(content)
64 write_object(root, obj_id, content)
65 manifest[rel_path] = obj_id
66 snap_id = compute_snapshot_id(manifest)
67 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
68 committed_at = datetime.datetime.now(datetime.timezone.utc)
69 commit_id = compute_commit_id([], snap_id, f"commit {_counter}", committed_at.isoformat())
70 write_commit(root, CommitRecord(
71 commit_id=commit_id,
72 repo_id=_REPO_ID,
73 branch="main",
74 snapshot_id=snap_id,
75 message=f"commit {_counter}",
76 committed_at=committed_at,
77 ))
78 (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id, encoding="utf-8")
79 return commit_id
80
81
82 # ---------------------------------------------------------------------------
83 # Unit: help
84 # ---------------------------------------------------------------------------
85
86
87 def test_content_grep_help() -> None:
88 result = runner.invoke(cli, ["content-grep", "--help"])
89 assert result.exit_code == 0
90 assert "pattern" in result.output
91
92
93 # ---------------------------------------------------------------------------
94 # Unit: no match → exit 1
95 # ---------------------------------------------------------------------------
96
97
98 def test_content_grep_no_match(tmp_path: pathlib.Path) -> None:
99 _init_repo(tmp_path)
100 _commit_files(tmp_path, {"song.txt": b"chord: Am\ntempo: 120\n"})
101 result = runner.invoke(cli, ["content-grep", "ZZZNOMATCH", "--json"], env=_env(tmp_path))
102 assert result.exit_code != 0
103 # --json must always emit valid JSON even on no-match so agents can parse safely.
104 data = json.loads(result.output)
105 assert data["total_matches"] == 0
106 assert data["results"] == []
107
108
109 # ---------------------------------------------------------------------------
110 # Unit: match found → exit 0
111 # ---------------------------------------------------------------------------
112
113
114 def test_content_grep_match_found(tmp_path: pathlib.Path) -> None:
115 _init_repo(tmp_path)
116 _commit_files(tmp_path, {"song.txt": b"chord: Cm7\ntempo: 120\n"})
117 result = runner.invoke(cli, ["content-grep", "Cm7"], env=_env(tmp_path))
118 assert result.exit_code == 0
119 assert "song.txt" in result.output
120
121
122 # ---------------------------------------------------------------------------
123 # Unit: --ignore-case
124 # ---------------------------------------------------------------------------
125
126
127 def test_content_grep_ignore_case(tmp_path: pathlib.Path) -> None:
128 _init_repo(tmp_path)
129 _commit_files(tmp_path, {"notes.txt": b"VERSE: intro melody\n"})
130 result = runner.invoke(
131 cli, ["content-grep", "verse", "--ignore-case"], env=_env(tmp_path)
132 )
133 assert result.exit_code == 0
134 assert "notes.txt" in result.output
135
136
137 def test_content_grep_case_sensitive_no_match(tmp_path: pathlib.Path) -> None:
138 _init_repo(tmp_path)
139 _commit_files(tmp_path, {"notes.txt": b"VERSE: intro melody\n"})
140 result = runner.invoke(
141 cli, ["content-grep", "verse"], env=_env(tmp_path)
142 )
143 # Case-sensitive: "verse" ≠ "VERSE" → no match.
144 assert result.exit_code != 0
145
146
147 # ---------------------------------------------------------------------------
148 # Unit: --files-only
149 # ---------------------------------------------------------------------------
150
151
152 def test_content_grep_files_only(tmp_path: pathlib.Path) -> None:
153 _init_repo(tmp_path)
154 _commit_files(tmp_path, {
155 "a.txt": b"match here\n",
156 "b.txt": b"match here too\n",
157 })
158 result = runner.invoke(
159 cli, ["content-grep", "match", "--files-only"], env=_env(tmp_path)
160 )
161 assert result.exit_code == 0
162 lines = [l.strip() for l in result.output.strip().split("\n") if l.strip()]
163 for line in lines:
164 assert ":" not in line or line.startswith("a.txt") or line.startswith("b.txt")
165
166
167 # ---------------------------------------------------------------------------
168 # Unit: --count
169 # ---------------------------------------------------------------------------
170
171
172 def test_content_grep_count(tmp_path: pathlib.Path) -> None:
173 _init_repo(tmp_path)
174 _commit_files(tmp_path, {"multi.txt": b"hit\nhit\nhit\nmiss\n"})
175 result = runner.invoke(
176 cli, ["content-grep", "hit", "--count"], env=_env(tmp_path)
177 )
178 assert result.exit_code == 0
179 assert "3" in result.output
180
181
182 # ---------------------------------------------------------------------------
183 # Unit: --format json
184 # ---------------------------------------------------------------------------
185
186
187 def test_content_grep_json_output(tmp_path: pathlib.Path) -> None:
188 _init_repo(tmp_path)
189 _commit_files(tmp_path, {"song.midi.txt": b"note: C4\nnote: D4\n"})
190 result = runner.invoke(
191 cli, ["content-grep", "note", "--json"], env=_env(tmp_path)
192 )
193 assert result.exit_code == 0
194 data = json.loads(result.output)
195 assert isinstance(data, dict)
196 assert len(data["results"]) >= 1
197 assert data["results"][0]["match_count"] >= 2
198
199
200 # ---------------------------------------------------------------------------
201 # Unit: binary file skipped silently
202 # ---------------------------------------------------------------------------
203
204
205 def test_content_grep_binary_skipped(tmp_path: pathlib.Path) -> None:
206 _init_repo(tmp_path)
207 binary_content = b"\x00\x01\x02\x03" * 100
208 text_content = b"searchable text here\n"
209 _commit_files(tmp_path, {
210 "binary.bin": binary_content,
211 "text.txt": text_content,
212 })
213 result = runner.invoke(
214 cli, ["content-grep", "searchable"], env=_env(tmp_path)
215 )
216 assert result.exit_code == 0
217 assert "text.txt" in result.output
218
219
220 # ---------------------------------------------------------------------------
221 # Unit: short flags work
222 # ---------------------------------------------------------------------------
223
224
225 def test_content_grep_short_flags(tmp_path: pathlib.Path) -> None:
226 _init_repo(tmp_path)
227 _commit_files(tmp_path, {"f.txt": b"hello world\n"})
228 result = runner.invoke(
229 cli, ["content-grep", "hello", "-i", "--json"], env=_env(tmp_path)
230 )
231 assert result.exit_code == 0
232 data = json.loads(result.output)
233 assert len(data["results"]) >= 1
234
235
236 # ---------------------------------------------------------------------------
237 # Stress: 100 files, pattern matches 50
238 # ---------------------------------------------------------------------------
239
240
241 def test_content_grep_stress_100_files(tmp_path: pathlib.Path) -> None:
242 _init_repo(tmp_path)
243 files: _FileStore = {}
244 for i in range(100):
245 content = b"TARGET_LINE\n" if i % 2 == 0 else b"other content\n"
246 files[f"file_{i:04d}.txt"] = content
247 _commit_files(tmp_path, files)
248 result = runner.invoke(
249 cli, ["content-grep", "TARGET_LINE", "--json"], env=_env(tmp_path)
250 )
251 assert result.exit_code == 0
252 data = json.loads(result.output)
253 assert len(data["results"]) == 50
254
255
256 # ---------------------------------------------------------------------------
257 # Working-tree mode: --working-tree searches disk, not the committed snapshot
258 # ---------------------------------------------------------------------------
259
260
261 def test_content_grep_working_tree_finds_uncommitted_edit(tmp_path: pathlib.Path) -> None:
262 """--working-tree finds content written to disk that is not yet committed."""
263 _init_repo(tmp_path)
264 # Commit a file with one pattern.
265 _commit_files(tmp_path, {"song.txt": b"chord: Am\n"})
266 # Write an uncommitted edit with a different pattern.
267 (tmp_path / "song.txt").write_bytes(b"chord: WORKING_TREE_ONLY\n")
268
269 # Without --working-tree, finds the committed content.
270 result_committed = runner.invoke(
271 cli, ["content-grep", "Am"], env=_env(tmp_path)
272 )
273 assert result_committed.exit_code == 0
274
275 # With --working-tree, finds the disk content.
276 result_wt = runner.invoke(
277 cli, ["content-grep", "WORKING_TREE_ONLY", "--working-tree"],
278 env=_env(tmp_path),
279 )
280 assert result_wt.exit_code == 0
281 assert "song.txt" in result_wt.output
282
283
284 def test_content_grep_working_tree_no_match(tmp_path: pathlib.Path) -> None:
285 """--working-tree returns exit 1 when pattern absent; --json still emits valid JSON."""
286 _init_repo(tmp_path)
287 (tmp_path / "notes.txt").write_bytes(b"hello world\n")
288 result = runner.invoke(
289 cli, ["content-grep", "ZZZNOMATCH", "--working-tree", "--json"],
290 env=_env(tmp_path),
291 )
292 assert result.exit_code != 0
293 data = json.loads(result.output)
294 assert data["total_matches"] == 0
295 assert data["results"] == []
296
297
298 def test_content_grep_working_tree_skips_muse_dir(tmp_path: pathlib.Path) -> None:
299 """--working-tree never searches inside the .muse object store."""
300 _init_repo(tmp_path)
301 # Write a matching string inside .muse/ — must NOT be found.
302 (tmp_path / ".muse" / "stray.txt").write_bytes(b"SECRET_IN_MUSE\n")
303 # Write the same string outside .muse/ — must be found.
304 (tmp_path / "real.txt").write_bytes(b"SECRET_IN_MUSE\n")
305
306 result = runner.invoke(
307 cli, ["content-grep", "SECRET_IN_MUSE", "--working-tree", "--json"],
308 env=_env(tmp_path),
309 )
310 assert result.exit_code == 0
311 data = json.loads(result.output)
312 paths = [r["path"] for r in data["results"]]
313 assert "real.txt" in paths
314 assert not any(".muse" in p for p in paths)
315
316
317 def test_content_grep_working_tree_json_schema(tmp_path: pathlib.Path) -> None:
318 """--working-tree JSON output has source=working-tree and null commit_id/snapshot_id."""
319 _init_repo(tmp_path)
320 (tmp_path / "f.txt").write_bytes(b"TARGET\n")
321 result = runner.invoke(
322 cli, ["content-grep", "TARGET", "--working-tree", "--json"],
323 env=_env(tmp_path),
324 )
325 assert result.exit_code == 0
326 data = json.loads(result.output)
327 assert data["source"] == "working-tree"
328 assert data["commit_id"] is None
329 assert data["snapshot_id"] is None
330 assert data["results"][0]["object_id"] is None
331
332
333 def test_content_grep_working_tree_files_only(tmp_path: pathlib.Path) -> None:
334 """--working-tree --files-only prints only file paths, no line numbers."""
335 _init_repo(tmp_path)
336 (tmp_path / "a.txt").write_bytes(b"match\n")
337 (tmp_path / "b.txt").write_bytes(b"match\n")
338 result = runner.invoke(
339 cli, ["content-grep", "match", "--working-tree", "--files-only"],
340 env=_env(tmp_path),
341 )
342 assert result.exit_code == 0
343 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
344 assert all(":" not in l for l in lines)
345 assert {"a.txt", "b.txt"}.issubset(set(lines))
346
347
348 def test_content_grep_working_tree_and_ref_mutually_exclusive(tmp_path: pathlib.Path) -> None:
349 """Passing both --working-tree and --ref is a user error (exit non-zero)."""
350 _init_repo(tmp_path)
351 _commit_files(tmp_path, {"f.txt": b"content\n"})
352 result = runner.invoke(
353 cli,
354 ["content-grep", "content", "--working-tree", "--ref", "main"],
355 env=_env(tmp_path),
356 )
357 assert result.exit_code != 0
358
359
360 def test_content_grep_snapshot_json_has_source_commit(tmp_path: pathlib.Path) -> None:
361 """Snapshot mode JSON output has source=commit and non-null commit_id/snapshot_id."""
362 _init_repo(tmp_path)
363 _commit_files(tmp_path, {"f.txt": b"TARGET\n"})
364 result = runner.invoke(
365 cli, ["content-grep", "TARGET", "--json"], env=_env(tmp_path)
366 )
367 assert result.exit_code == 0
368 data = json.loads(result.output)
369 assert data["source"] == "commit"
370 assert data["commit_id"] is not None
371 assert data["snapshot_id"] is not None
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago