1from llm_fragments_repomix import repomix_loader
2import pytest
3from unittest.mock import patch, Mock, MagicMock
4import subprocess
5import tempfile
6import pathlib
7
8
9class TestRepomixLoader:
10 """Test the repomix_loader function"""
11
12 def test_invalid_url_formats(self):
13 """Test that invalid URL formats raise ValueError"""
14 invalid_urls = [
15 "not-a-url",
16 "ftp://example.com/repo",
17 "file://local/path",
18 "http://example.com", # No path
19 "", # Empty string
20 ]
21
22 for invalid_url in invalid_urls:
23 with pytest.raises(ValueError) as ex:
24 repomix_loader(invalid_url)
25 assert "Repository URL must start with https://, ssh://, or git@" in str(ex.value)
26
27 @patch('llm_fragments_repomix.shutil.which')
28 def test_repomix_not_installed(self, mock_which):
29 """Test error when repomix is not installed"""
30 mock_which.return_value = None
31
32 with pytest.raises(ValueError) as ex:
33 repomix_loader("https://github.com/user/repo")
34 assert "repomix command not found" in str(ex.value)
35 assert "https://github.com/yamadashy/repomix" in str(ex.value)
36
37 @patch('llm_fragments_repomix.shutil.which')
38 @patch('llm_fragments_repomix.subprocess.run')
39 def test_git_clone_failure(self, mock_run, mock_which):
40 """Test handling of git clone failures"""
41 mock_which.return_value = "/usr/bin/repomix"
42
43 # Mock git clone failure
44 mock_run.side_effect = subprocess.CalledProcessError(
45 1, ["git", "clone"], stderr="Repository not found"
46 )
47
48 with pytest.raises(ValueError) as ex:
49 repomix_loader("https://github.com/user/nonexistent")
50 assert "Failed to clone repository" in str(ex.value)
51 assert "Repository not found" in str(ex.value)
52
53 @patch('llm_fragments_repomix.shutil.which')
54 @patch('llm_fragments_repomix.subprocess.run')
55 def test_repomix_failure(self, mock_run, mock_which):
56 """Test handling of repomix execution failures"""
57 mock_which.return_value = "/usr/bin/repomix"
58
59 # Mock successful git clone, failed repomix
60 def mock_run_side_effect(cmd, **kwargs):
61 if cmd[0] == "git":
62 return Mock(stdout="", stderr="")
63 elif cmd[0] == "repomix":
64 raise subprocess.CalledProcessError(
65 1, ["repomix"], stderr="Repomix error"
66 )
67
68 mock_run.side_effect = mock_run_side_effect
69
70 with pytest.raises(ValueError) as ex:
71 repomix_loader("https://github.com/user/repo")
72 assert "Failed to run repomix" in str(ex.value)
73 assert "Repomix error" in str(ex.value)
74
75 @patch('llm_fragments_repomix.shutil.which')
76 @patch('llm_fragments_repomix.subprocess.run')
77 def test_successful_repomix_execution(self, mock_run, mock_which):
78 """Test successful repomix execution"""
79 mock_which.return_value = "/usr/bin/repomix"
80
81 # Mock repomix output
82 repomix_output = """
83# Repository Structure
84
85## File: main.py
86```python
87print("Hello, World!")
88```
89
90## File: README.md
91```markdown
92# Test Project
93This is a test project.
94```
95"""
96
97 def mock_run_side_effect(cmd, **kwargs):
98 if cmd[0] == "git":
99 return Mock(stdout="", stderr="")
100 elif cmd[0] == "repomix":
101 return Mock(stdout=repomix_output, stderr="")
102
103 mock_run.side_effect = mock_run_side_effect
104
105 fragments = repomix_loader("https://github.com/user/repo")
106
107 assert len(fragments) == 1
108 assert str(fragments[0]) == repomix_output
109 assert fragments[0].source == "repomix:https://github.com/user/repo"
110
111 @patch('llm_fragments_repomix.shutil.which')
112 @patch('llm_fragments_repomix.subprocess.run')
113 def test_different_url_formats(self, mock_run, mock_which):
114 """Test that different valid URL formats work"""
115 mock_which.return_value = "/usr/bin/repomix"
116
117 def mock_run_side_effect(cmd, **kwargs):
118 if cmd[0] == "git":
119 return Mock(stdout="", stderr="")
120 elif cmd[0] == "repomix":
121 return Mock(stdout="test output", stderr="")
122
123 mock_run.side_effect = mock_run_side_effect
124
125 valid_urls = [
126 "https://github.com/user/repo",
127 "https://git.sr.ht/~user/repo",
128 "ssh://git@github.com:user/repo.git",
129 "git@github.com:user/repo.git",
130 ]
131
132 for url in valid_urls:
133 fragments = repomix_loader(url)
134 assert len(fragments) == 1
135 assert fragments[0].source == f"repomix:{url}"
136
137 @patch('llm_fragments_repomix.shutil.which')
138 @patch('llm_fragments_repomix.subprocess.run')
139 def test_subprocess_calls(self, mock_run, mock_which):
140 """Test that subprocess calls are made correctly"""
141 mock_which.return_value = "/usr/bin/repomix"
142
143 def mock_run_side_effect(cmd, **kwargs):
144 if cmd[0] == "git":
145 return Mock(stdout="", stderr="")
146 elif cmd[0] == "repomix":
147 return Mock(stdout="test output", stderr="")
148
149 mock_run.side_effect = mock_run_side_effect
150
151 repo_url = "https://github.com/user/repo"
152 repomix_loader(repo_url)
153
154 # Check git clone call
155 git_call = mock_run.call_args_list[0]
156 assert git_call[0][0][:3] == ["git", "clone", "--depth=1"]
157 assert git_call[0][0][3] == repo_url
158 assert git_call[1]["check"] is True
159 assert git_call[1]["capture_output"] is True
160 assert git_call[1]["text"] is True
161
162 # Check repomix call
163 repomix_call = mock_run.call_args_list[1]
164 assert repomix_call[0][0][:2] == ["repomix", "--stdout"]
165 assert repomix_call[1]["check"] is True
166 assert repomix_call[1]["capture_output"] is True
167 assert repomix_call[1]["text"] is True
168
169 @patch('llm_fragments_repomix.shutil.which')
170 @patch('llm_fragments_repomix.subprocess.run')
171 def test_generic_exception_handling(self, mock_run, mock_which):
172 """Test handling of generic exceptions"""
173 mock_which.return_value = "/usr/bin/repomix"
174
175 def mock_run_side_effect(cmd, **kwargs):
176 if cmd[0] == "git":
177 return Mock(stdout="", stderr="")
178 elif cmd[0] == "repomix":
179 raise OSError("Permission denied")
180
181 mock_run.side_effect = mock_run_side_effect
182
183 with pytest.raises(ValueError) as ex:
184 repomix_loader("https://github.com/user/repo")
185 assert "Error processing repository" in str(ex.value)
186 assert "Permission denied" in str(ex.value)
187
188 @patch('llm_fragments_repomix.shutil.which')
189 @patch('llm_fragments_repomix.subprocess.run')
190 @patch('llm_fragments_repomix.tempfile.TemporaryDirectory')
191 def test_temporary_directory_cleanup(self, mock_tempdir, mock_run, mock_which):
192 """Test that temporary directory is properly cleaned up"""
193 mock_which.return_value = "/usr/bin/repomix"
194
195 # Create a mock context manager for TemporaryDirectory
196 mock_context = MagicMock()
197 mock_context.__enter__.return_value = "/tmp/test_dir"
198 mock_context.__exit__.return_value = None
199 mock_tempdir.return_value = mock_context
200
201 def mock_run_side_effect(cmd, **kwargs):
202 if cmd[0] == "git":
203 return Mock(stdout="", stderr="")
204 elif cmd[0] == "repomix":
205 return Mock(stdout="test output", stderr="")
206
207 mock_run.side_effect = mock_run_side_effect
208
209 repomix_loader("https://github.com/user/repo")
210
211 # Verify that the temporary directory context manager was used
212 mock_tempdir.assert_called_once()
213 mock_context.__enter__.assert_called_once()
214 mock_context.__exit__.assert_called_once()
215
216
217class TestRepomixArgumentParsing:
218 """Test argument parsing for colon-separated options"""
219
220 def test_parse_fragment_string_url_only(self):
221 """Test parsing URL without arguments"""
222 from llm_fragments_repomix import parse_fragment_string
223
224 url, args = parse_fragment_string("https://github.com/user/repo")
225 assert url == "https://github.com/user/repo"
226 assert args == {}
227
228 def test_parse_fragment_string_with_compress(self):
229 """Test parsing URL with compress flag"""
230 from llm_fragments_repomix import parse_fragment_string
231
232 url, args = parse_fragment_string("https://github.com/user/repo:compress")
233 assert url == "https://github.com/user/repo"
234 assert args == {"compress": True}
235
236 def test_parse_fragment_string_with_include_patterns(self):
237 """Test parsing URL with include patterns"""
238 from llm_fragments_repomix import parse_fragment_string
239
240 url, args = parse_fragment_string("https://github.com/user/repo:include=*.ts,*.js")
241 assert url == "https://github.com/user/repo"
242 assert args == {"include": "*.ts,*.js"}
243
244 def test_parse_fragment_string_with_ignore_patterns(self):
245 """Test parsing URL with ignore patterns"""
246 from llm_fragments_repomix import parse_fragment_string
247
248 url, args = parse_fragment_string("https://github.com/user/repo:ignore=*.log,tmp/")
249 assert url == "https://github.com/user/repo"
250 assert args == {"ignore": "*.log,tmp/"}
251
252 def test_parse_fragment_string_multiple_args(self):
253 """Test parsing URL with multiple arguments"""
254 from llm_fragments_repomix import parse_fragment_string
255
256 url, args = parse_fragment_string("https://github.com/user/repo:compress:include=*.py:ignore=tests/")
257 assert url == "https://github.com/user/repo"
258 assert args == {
259 "compress": True,
260 "include": "*.py",
261 "ignore": "tests/"
262 }
263
264 def test_parse_fragment_string_complex_patterns(self):
265 """Test parsing with complex glob patterns"""
266 from llm_fragments_repomix import parse_fragment_string
267
268 url, args = parse_fragment_string("https://github.com/user/repo:include=src/**/*.ts,**/*.md:ignore=**/*.test.ts,node_modules/")
269 assert url == "https://github.com/user/repo"
270 assert args == {
271 "include": "src/**/*.ts,**/*.md",
272 "ignore": "**/*.test.ts,node_modules/"
273 }
274
275 def test_parse_fragment_string_boolean_flags(self):
276 """Test parsing various boolean flags"""
277 from llm_fragments_repomix import parse_fragment_string
278
279 url, args = parse_fragment_string("https://github.com/user/repo:compress:remove-comments:remove-empty-lines")
280 assert url == "https://github.com/user/repo"
281 assert args == {
282 "compress": True,
283 "remove-comments": True,
284 "remove-empty-lines": True
285 }
286
287 def test_parse_fragment_string_output_options(self):
288 """Test parsing output-related options"""
289 from llm_fragments_repomix import parse_fragment_string
290
291 url, args = parse_fragment_string("https://github.com/user/repo:style=markdown:output-show-line-numbers")
292 assert url == "https://github.com/user/repo"
293 assert args == {
294 "style": "markdown",
295 "output-show-line-numbers": True
296 }
297
298 def test_parse_fragment_string_ssh_url(self):
299 """Test parsing SSH URLs with arguments"""
300 from llm_fragments_repomix import parse_fragment_string
301
302 url, args = parse_fragment_string("git@github.com:user/repo.git:compress:include=*.py")
303 assert url == "git@github.com:user/repo.git"
304 assert args == {
305 "compress": True,
306 "include": "*.py"
307 }
308
309 def test_parse_fragment_string_ssh_protocol_url(self):
310 """Test parsing SSH protocol URLs with arguments"""
311 from llm_fragments_repomix import parse_fragment_string
312
313 url, args = parse_fragment_string("ssh://git@github.com:user/repo.git:compress")
314 assert url == "ssh://git@github.com:user/repo.git"
315 assert args == {"compress": True}
316
317 def test_parse_fragment_string_empty_args(self):
318 """Test parsing with empty argument values"""
319 from llm_fragments_repomix import parse_fragment_string
320
321 url, args = parse_fragment_string("https://github.com/user/repo:include=")
322 assert url == "https://github.com/user/repo"
323 assert args == {"include": ""}
324
325 def test_parse_fragment_string_duplicate_args(self):
326 """Test parsing with duplicate arguments (last one wins)"""
327 from llm_fragments_repomix import parse_fragment_string
328
329 url, args = parse_fragment_string("https://github.com/user/repo:include=*.ts:include=*.js")
330 assert url == "https://github.com/user/repo"
331 assert args == {"include": "*.js"}
332
333 def test_build_repomix_command_no_args(self):
334 """Test building repomix command without arguments"""
335 from llm_fragments_repomix import build_repomix_command
336
337 cmd = build_repomix_command("/tmp/repo", {})
338 assert cmd == ["repomix", "--stdout", "/tmp/repo"]
339
340 def test_build_repomix_command_with_compress(self):
341 """Test building repomix command with compress flag"""
342 from llm_fragments_repomix import build_repomix_command
343
344 cmd = build_repomix_command("/tmp/repo", {"compress": True})
345 assert cmd == ["repomix", "--stdout", "--compress", "/tmp/repo"]
346
347 def test_build_repomix_command_with_include(self):
348 """Test building repomix command with include patterns"""
349 from llm_fragments_repomix import build_repomix_command
350
351 cmd = build_repomix_command("/tmp/repo", {"include": "*.ts,*.js"})
352 assert cmd == ["repomix", "--stdout", "--include", "*.ts,*.js", "/tmp/repo"]
353
354 def test_build_repomix_command_with_ignore(self):
355 """Test building repomix command with ignore patterns"""
356 from llm_fragments_repomix import build_repomix_command
357
358 cmd = build_repomix_command("/tmp/repo", {"ignore": "*.log,tmp/"})
359 assert cmd == ["repomix", "--stdout", "--ignore", "*.log,tmp/", "/tmp/repo"]
360
361 def test_build_repomix_command_multiple_args(self):
362 """Test building repomix command with multiple arguments"""
363 from llm_fragments_repomix import build_repomix_command
364
365 args = {
366 "compress": True,
367 "include": "*.py",
368 "ignore": "tests/",
369 "remove-comments": True
370 }
371 cmd = build_repomix_command("/tmp/repo", args)
372 expected = ["repomix", "--stdout", "--compress", "--include", "*.py", "--ignore", "tests/", "--remove-comments", "/tmp/repo"]
373 assert cmd == expected
374
375 def test_build_repomix_command_with_style(self):
376 """Test building repomix command with style option"""
377 from llm_fragments_repomix import build_repomix_command
378
379 cmd = build_repomix_command("/tmp/repo", {"style": "markdown"})
380 assert cmd == ["repomix", "--stdout", "--style", "markdown", "/tmp/repo"]
381
382 def test_build_repomix_command_boolean_flags(self):
383 """Test building repomix command with various boolean flags"""
384 from llm_fragments_repomix import build_repomix_command
385
386 args = {
387 "compress": True,
388 "remove-comments": True,
389 "remove-empty-lines": True,
390 "output-show-line-numbers": True,
391 "no-file-summary": True
392 }
393 cmd = build_repomix_command("/tmp/repo", args)
394 expected = [
395 "repomix", "--stdout",
396 "--compress",
397 "--remove-comments",
398 "--remove-empty-lines",
399 "--output-show-line-numbers",
400 "--no-file-summary",
401 "/tmp/repo"
402 ]
403 assert cmd == expected
404
405 def test_build_repomix_command_unsupported_arg(self):
406 """Test that unsupported arguments are ignored"""
407 from llm_fragments_repomix import build_repomix_command
408
409 args = {
410 "compress": True,
411 "unsupported-option": "value",
412 "include": "*.py"
413 }
414 cmd = build_repomix_command("/tmp/repo", args)
415 expected = ["repomix", "--stdout", "--compress", "--include", "*.py", "/tmp/repo"]
416 assert cmd == expected
417
418 def test_build_repomix_command_empty_string_values(self):
419 """Test that empty string values are handled correctly"""
420 from llm_fragments_repomix import build_repomix_command
421
422 args = {
423 "include": "",
424 "ignore": "*.log"
425 }
426 cmd = build_repomix_command("/tmp/repo", args)
427 # Empty include should be ignored, ignore should be included
428 expected = ["repomix", "--stdout", "--ignore", "*.log", "/tmp/repo"]
429 assert cmd == expected
430
431
432class TestRepomixIntegrationWithArguments:
433 """Integration tests for repomix loader with arguments"""
434
435 @patch('llm_fragments_repomix.shutil.which')
436 @patch('llm_fragments_repomix.subprocess.run')
437 def test_repomix_loader_with_compress(self, mock_run, mock_which):
438 """Test repomix loader with compress argument"""
439 mock_which.return_value = "/usr/bin/repomix"
440
441 def mock_run_side_effect(cmd, **kwargs):
442 if cmd[0] == "git":
443 return Mock(stdout="", stderr="")
444 elif cmd[0] == "repomix":
445 # Verify compress flag is passed
446 assert "--compress" in cmd
447 return Mock(stdout="compressed output", stderr="")
448
449 mock_run.side_effect = mock_run_side_effect
450
451 fragments = repomix_loader("https://github.com/user/repo:compress")
452 assert len(fragments) == 1
453 assert str(fragments[0]) == "compressed output"
454 assert fragments[0].source == "repomix:https://github.com/user/repo:compress"
455
456 @patch('llm_fragments_repomix.shutil.which')
457 @patch('llm_fragments_repomix.subprocess.run')
458 def test_repomix_loader_with_include_patterns(self, mock_run, mock_which):
459 """Test repomix loader with include patterns"""
460 mock_which.return_value = "/usr/bin/repomix"
461
462 def mock_run_side_effect(cmd, **kwargs):
463 if cmd[0] == "git":
464 return Mock(stdout="", stderr="")
465 elif cmd[0] == "repomix":
466 # Verify include patterns are passed
467 assert "--include" in cmd
468 include_idx = cmd.index("--include")
469 assert cmd[include_idx + 1] == "*.ts,*.js"
470 return Mock(stdout="filtered output", stderr="")
471
472 mock_run.side_effect = mock_run_side_effect
473
474 fragments = repomix_loader("https://github.com/user/repo:include=*.ts,*.js")
475 assert len(fragments) == 1
476 assert str(fragments[0]) == "filtered output"
477
478 @patch('llm_fragments_repomix.shutil.which')
479 @patch('llm_fragments_repomix.subprocess.run')
480 def test_repomix_loader_with_multiple_args(self, mock_run, mock_which):
481 """Test repomix loader with multiple arguments"""
482 mock_which.return_value = "/usr/bin/repomix"
483
484 def mock_run_side_effect(cmd, **kwargs):
485 if cmd[0] == "git":
486 return Mock(stdout="", stderr="")
487 elif cmd[0] == "repomix":
488 # Verify multiple arguments are passed
489 assert "--compress" in cmd
490 assert "--include" in cmd
491 assert "--ignore" in cmd
492 include_idx = cmd.index("--include")
493 assert cmd[include_idx + 1] == "*.py"
494 ignore_idx = cmd.index("--ignore")
495 assert cmd[ignore_idx + 1] == "tests/"
496 return Mock(stdout="multi-arg output", stderr="")
497
498 mock_run.side_effect = mock_run_side_effect
499
500 fragments = repomix_loader("https://github.com/user/repo:compress:include=*.py:ignore=tests/")
501 assert len(fragments) == 1
502 assert str(fragments[0]) == "multi-arg output"
503
504 @patch('llm_fragments_repomix.shutil.which')
505 @patch('llm_fragments_repomix.subprocess.run')
506 def test_repomix_loader_with_ssh_url_and_args(self, mock_run, mock_which):
507 """Test repomix loader with SSH URL and arguments"""
508 mock_which.return_value = "/usr/bin/repomix"
509
510 def mock_run_side_effect(cmd, **kwargs):
511 if cmd[0] == "git":
512 return Mock(stdout="", stderr="")
513 elif cmd[0] == "repomix":
514 assert "--compress" in cmd
515 return Mock(stdout="ssh output", stderr="")
516
517 mock_run.side_effect = mock_run_side_effect
518
519 fragments = repomix_loader("git@github.com:user/repo.git:compress")
520 assert len(fragments) == 1
521 assert str(fragments[0]) == "ssh output"
522 assert fragments[0].source == "repomix:git@github.com:user/repo.git:compress"
523
524 @patch('llm_fragments_repomix.shutil.which')
525 @patch('llm_fragments_repomix.subprocess.run')
526 def test_repomix_loader_preserves_original_source(self, mock_run, mock_which):
527 """Test that the original fragment string is preserved in source"""
528 mock_which.return_value = "/usr/bin/repomix"
529
530 def mock_run_side_effect(cmd, **kwargs):
531 if cmd[0] == "git":
532 return Mock(stdout="", stderr="")
533 elif cmd[0] == "repomix":
534 return Mock(stdout="test output", stderr="")
535
536 mock_run.side_effect = mock_run_side_effect
537
538 original_string = "https://github.com/user/repo:compress:include=*.ts,*.js:ignore=*.md"
539 fragments = repomix_loader(original_string)
540 assert fragments[0].source == f"repomix:{original_string}"