test_fragments_repomix.py

  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}"