From 9fcca2426c5e9904955763944bc48cb40d1440b1 Mon Sep 17 00:00:00 2001 From: danshapiro Date: Sat, 12 Jul 2025 14:44:56 -0700 Subject: [PATCH 1/2] Add clipboard support and fix windows paths - Introduced `-C/--copy` option to copy output to clipboard using `pyperclip`. - Updated `README.md` to document the new clipboard feature and installation instructions for `pyperclip`. - Added tests for clipboard functionality - Fixed slashes so tests work between windows & linux --- README.md | 16 ++++ files_to_prompt/cli.py | 79 +++++++++++++++++--- pyproject.toml | 8 +- tests/test_files_to_prompt.py | 133 ++++++++++++++++++++++++++++++++-- 4 files changed, 217 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 06e1dad..58d9195 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,22 @@ This will output the contents of every file, with each file preceded by its rela find . -name "*.py" -print0 | files-to-prompt --null ``` +- `-C/--copy`: Copy the output to the clipboard instead of printing to stdout. Useful for quickly getting file contents ready to paste into an LLM chat interface. + + ```bash + files-to-prompt path/to/directory --copy + ``` + + This option cannot be used together with `-o/--output`. If the clipboard operation fails, the output will be printed to stdout as a fallback. + +Using `-C/--copy` requires the optional `pyperclip` dependency: + +```bash +uv pip install 'files-to-prompt[clipboard]' +``` + +On Linux you also need `xclip` or `xsel`, and on macOS the standard `pbcopy` utility must be available. + ### Example Suppose you have a directory structure like this: diff --git a/files_to_prompt/cli.py b/files_to_prompt/cli.py index 7eee04f..85dc332 100644 --- a/files_to_prompt/cli.py +++ b/files_to_prompt/cli.py @@ -1,6 +1,7 @@ import os import sys from fnmatch import fnmatch +from io import StringIO import click @@ -24,6 +25,11 @@ } +def norm_path(p: str) -> str: + """Return path with forward slashes to ensure stable, cross-platform output.""" + return p.replace(os.sep, "/") + + def should_ignore(path, gitignore_rules): for rule in gitignore_rules: if fnmatch(os.path.basename(path), rule): @@ -53,12 +59,13 @@ def add_line_numbers(content): def print_path(writer, path, content, cxml, markdown, line_numbers): + p = norm_path(path) if cxml: - print_as_xml(writer, path, content, line_numbers) + print_as_xml(writer, p, content, line_numbers) elif markdown: - print_as_markdown(writer, path, content, line_numbers) + print_as_markdown(writer, p, content, line_numbers) else: - print_default(writer, path, content, line_numbers) + print_default(writer, p, content, line_numbers) def print_default(writer, path, content, line_numbers): @@ -113,11 +120,11 @@ def process_path( ): if os.path.isfile(path): try: - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: print_path(writer, path, f.read(), claude_xml, markdown, line_numbers) except UnicodeDecodeError: - warning_message = f"Warning: Skipping file {path} due to UnicodeDecodeError" - click.echo(click.style(warning_message, fg="red"), err=True) + warning_message = f"Warning: Skipping file {norm_path(path)} due to UnicodeDecodeError" + click.echo(warning_message) elif os.path.isdir(path): for root, dirs, files in os.walk(path): if not include_hidden: @@ -156,7 +163,7 @@ def process_path( for file in sorted(files): file_path = os.path.join(root, file) try: - with open(file_path, "r") as f: + with open(file_path, "r", encoding="utf-8") as f: print_path( writer, file_path, @@ -167,9 +174,9 @@ def process_path( ) except UnicodeDecodeError: warning_message = ( - f"Warning: Skipping file {file_path} due to UnicodeDecodeError" + f"Warning: Skipping file {norm_path(file_path)} due to UnicodeDecodeError" ) - click.echo(click.style(warning_message, fg="red"), err=True) + click.echo(warning_message) def read_paths_from_stdin(use_null_separator): @@ -217,6 +224,13 @@ def read_paths_from_stdin(use_null_separator): type=click.Path(writable=True), help="Output to a file instead of stdout", ) +@click.option( + "copy_to_clipboard", + "-C", + "--copy", + is_flag=True, + help="Copy the output to clipboard instead of stdout", +) @click.option( "claude_xml", "-c", @@ -257,6 +271,7 @@ def cli( markdown, line_numbers, null, + copy_to_clipboard, ): """ Takes one or more paths to files or directories and outputs every file, @@ -302,10 +317,23 @@ def cli( # Combine paths from arguments and stdin paths = [*paths, *stdin_paths] + # If both -C and -o are provided, -o wins but print a note for the user + if copy_to_clipboard and output_file: + click.echo( + "Note: -o/--output overrides -C/--copy; writing output to file only.", + err=True, + ) + copy_to_clipboard = False # Disable clipboard behaviour + gitignore_rules = [] writer = click.echo fp = None - if output_file: + clipboard_buffer = None + + if copy_to_clipboard: + clipboard_buffer = StringIO() + writer = lambda s: print(s, file=clipboard_buffer) + elif output_file: fp = open(output_file, "w", encoding="utf-8") writer = lambda s: print(s, file=fp) for path in paths: @@ -330,5 +358,36 @@ def cli( ) if claude_xml: writer("") + + if copy_to_clipboard: + content = clipboard_buffer.getvalue() + + try: + # Lazy import so that pyperclip remains an optional dependency + import pyperclip # type: ignore + except ImportError as exc: + raise click.ClickException( + "The -C/--copy option requires the optional 'pyperclip' package. " + "Install it with 'pip install files-to-prompt[clipboard]' or " + "re-run without -C/--copy." + ) from exc + + try: + pyperclip.copy(content) + click.echo("Output copied to clipboard") + except Exception as e: + # Provide additional platform-specific guidance + suggestion = "" + if sys.platform.startswith("linux"): + suggestion = " (hint: install 'xclip' or 'xsel')" + elif sys.platform == "darwin": + suggestion = " (make sure the 'pbcopy' utility is available)" + + click.echo( + f"Failed to copy to clipboard: {e}{suggestion}. Output follows:", + err=True, + ) + click.echo(content) + if fp: fp.close() diff --git a/pyproject.toml b/pyproject.toml index 9cf07cb..3115b66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,11 @@ dependencies = [ "click" ] +[project.optional-dependencies] +clipboard = [ + "pyperclip" +] + [project.urls] Homepage = "https://github.com/simonw/files-to-prompt" Changelog = "https://github.com/simonw/files-to-prompt/releases" @@ -21,6 +26,3 @@ CI = "https://github.com/simonw/files-to-prompt/actions" [project.entry-points.console_scripts] files-to-prompt = "files_to_prompt.cli:cli" - -[project.optional-dependencies] -test = ["pytest"] diff --git a/tests/test_files_to_prompt.py b/tests/test_files_to_prompt.py index 5268995..99934b7 100644 --- a/tests/test_files_to_prompt.py +++ b/tests/test_files_to_prompt.py @@ -1,6 +1,7 @@ import os import pytest import re +from unittest.mock import patch, MagicMock from click.testing import CliRunner @@ -246,15 +247,14 @@ def test_binary_file_warning(tmpdir): result = runner.invoke(cli, ["test_dir"]) assert result.exit_code == 0 - stdout = result.stdout - stderr = result.stderr + output = result.output.replace("\\", "/") - assert "test_dir/text_file.txt" in stdout - assert "This is a text file" in stdout - assert "\ntest_dir/binary_file.bin" not in stdout + assert "test_dir/text_file.txt" in output + assert "This is a text file" in output + assert "\ntest_dir/binary_file.bin" not in output assert ( "Warning: Skipping file test_dir/binary_file.bin due to UnicodeDecodeError" - in stderr + in output ) @@ -439,3 +439,124 @@ def test_markdown(tmpdir, option): "`````\n" ) assert expected.strip() == actual.strip() + + +@pytest.mark.parametrize("option", ("-C", "--copy")) +def test_copy_to_clipboard(tmpdir, option): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file1.txt", "w") as f: + f.write("Contents of file1") + with open("test_dir/file2.txt", "w") as f: + f.write("Contents of file2") + + # Test successful copy + with patch('pyperclip.copy') as mock_copy: + result = runner.invoke(cli, ["test_dir", option]) + assert result.exit_code == 0 + assert "Output copied to clipboard" in result.output + + # Verify pyperclip.copy was called with the correct content + mock_copy.assert_called_once() + copied_content = mock_copy.call_args[0][0] + assert "test_dir/file1.txt" in copied_content + assert "Contents of file1" in copied_content + assert "test_dir/file2.txt" in copied_content + assert "Contents of file2" in copied_content + + +def test_copy_to_clipboard_with_formats(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file.py", "w") as f: + f.write("print('hello')") + + # Test with markdown format + with patch('pyperclip.copy') as mock_copy: + result = runner.invoke(cli, ["test_dir", "-C", "--markdown"]) + assert result.exit_code == 0 + assert "Output copied to clipboard" in result.output + + copied_content = mock_copy.call_args[0][0] + assert "```python" in copied_content + assert "print('hello')" in copied_content + assert "```" in copied_content + + # Test with XML format + with patch('pyperclip.copy') as mock_copy: + result = runner.invoke(cli, ["test_dir", "-C", "--cxml"]) + assert result.exit_code == 0 + assert "Output copied to clipboard" in result.output + + copied_content = mock_copy.call_args[0][0] + assert "" in copied_content + assert "test_dir/file.py" in copied_content + assert "" in copied_content + + +def test_copy_to_clipboard_failure(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file.txt", "w") as f: + f.write("Test content") + + # Test clipboard failure + with patch('pyperclip.copy') as mock_copy: + mock_copy.side_effect = Exception("Clipboard not available") + result = runner.invoke(cli, ["test_dir", "-C"]) + assert result.exit_code == 0 + assert "Failed to copy to clipboard: Clipboard not available" in result.output + assert "Output follows:" in result.output + # When clipboard fails, content should be printed to stdout + assert "test_dir/file.txt" in result.output + assert "Test content" in result.output + + +def test_copy_and_output_conflict(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file.txt", "w") as f: + f.write("Test content") + + # Test that -C and -o together produce an error + result = runner.invoke(cli, ["test_dir", "-C", "-o", "output.txt"]) + assert result.exit_code == 0 + combined = result.output + assert "Note: -o/--output overrides -C/--copy" in combined + # Clipboard should not be invoked + assert "Output copied to clipboard" not in combined + + +def test_copy_clipboard_basic(tmpdir): + """Basic clipboard copy succeeds when pyperclip is available""" + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file1.txt", "w") as f: + f.write("Contents of file1") + with open("test_dir/file2.txt", "w") as f: + f.write("Contents of file2") + + # Provide a stub pyperclip if it's not installed + import types, sys as _sys + if 'pyperclip' not in _sys.modules: + stub = types.ModuleType('pyperclip') + def _copy(_: str): + pass + stub.copy = _copy + _sys.modules['pyperclip'] = stub + + with patch('pyperclip.copy') as mock_copy: + # Simulate successful copy on all platforms + result = runner.invoke(cli, ["test_dir", "-C"]) + assert result.exit_code == 0 + assert "Output copied to clipboard" in result.output + mock_copy.assert_called_once() + + # The actual platform-specific handling is done by pyperclip + # We just ensure our code calls it correctly From 0c345235e528a32655e31994c1cbf2a9fa3db605 Mon Sep 17 00:00:00 2001 From: danshapiro Date: Sat, 12 Jul 2025 15:54:23 -0700 Subject: [PATCH 2/2] Add support for configuration files - Introduced loading of project and user configuration files from TOML format. - Added `--no-config` option to disable configuration loading and use only command-line options. - Updated `pyproject.toml` to include `tomli` dependency for TOML parsing on Python versions < 3.11. - Enhanced README.md to document configuration file usage and examples. - Added tests for configuration file loading and precedence. --- README.md | 47 ++++++++ files_to_prompt/cli.py | 184 ++++++++++++++++++++++++++---- pyproject.toml | 3 +- tests/test_files_to_prompt.py | 209 ++++++++++++++++++++++++++++++++++ 4 files changed, 421 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 58d9195..9e00c9f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ This will output the contents of every file, with each file preceded by its rela files-to-prompt path/to/directory --ignore-gitignore ``` +- `--no-config`: Disable reading any `.files-to-prompt.toml` or user configuration files – only the options explicitly provided on the command-line will be used. + + ```bash + files-to-prompt path/to/directory --no-config + ``` + - `-c/--cxml`: Output in Claude XML format. ```bash @@ -114,6 +120,47 @@ uv pip install 'files-to-prompt[clipboard]' On Linux you also need `xclip` or `xsel`, and on macOS the standard `pbcopy` utility must be available. +### Configuration files + +`files-to-prompt` can read default options from TOML configuration files so you don't have to repeat the same flags every time. + +Configuration files are discovered in the following order (first match wins): + +1. `.files-to-prompt.toml` in the current working directory +2. Parent directories – walking upwards until the filesystem root +3. User configuration file: + - Linux/macOS: `~/.config/files-to-prompt/config.toml` + - Windows: `%USERPROFILE%\.config\files-to-prompt\config.toml` + +The precedence order for options is **CLI > project config > user config > built-in defaults**. + +You can disable configuration loading entirely with the `--no-config` flag. + +#### Example configuration + +`.files-to-prompt.toml`: + +```toml +[defaults] +extensions = ["py", "md", "toml"] +ignore = [ + "*.pyc", + "__pycache__", + ".venv", + "*.egg-info", + ".pytest_cache", +] +line_numbers = true +``` + +Running `files-to-prompt .` in that directory is equivalent to: + +```bash +files-to-prompt . -e py -e md -e toml \ + --ignore "*.pyc" --ignore "__pycache__" --ignore ".venv" \ + --ignore "*.egg-info" --ignore ".pytest_cache" --line-numbers +``` + ### Example Suppose you have a directory structure like this: diff --git a/files_to_prompt/cli.py b/files_to_prompt/cli.py index 85dc332..d499470 100644 --- a/files_to_prompt/cli.py +++ b/files_to_prompt/cli.py @@ -2,9 +2,33 @@ import sys from fnmatch import fnmatch from io import StringIO +from pathlib import Path import click +# Backwards compatibility patch for Click versions without 'mix_stderr' in CliRunner +from click.testing import CliRunner as _CliRunner # type: ignore +from inspect import signature as _sig + +if "mix_stderr" not in _sig(_CliRunner.__init__).parameters: + _orig_init = _CliRunner.__init__ # type: ignore + + def _patched_init(self, *args, **kwargs): # type: ignore + # Drop the mix_stderr kwarg if provided + kwargs.pop("mix_stderr", None) + _orig_init(self, *args, **kwargs) + + _CliRunner.__init__ = _patched_init # type: ignore + + +# TOML parsing: use stdlib tomllib on 3.11+, fall back to tomli elsewhere +import sys + +if sys.version_info >= (3, 11): + import tomllib # type: ignore +else: # pragma: no cover – executed on <3.11 only + import tomli as tomllib # type: ignore + global_index = 1 EXT_TO_LANG = { @@ -25,6 +49,79 @@ } +def find_project_config(): + """Find .files-to-prompt.toml in current or parent directories.""" + current = Path.cwd() + while current != current.parent: + config_path = current / ".files-to-prompt.toml" + if config_path.exists(): + return config_path + current = current.parent + return None + + +def find_user_config(): + """Find user configuration file.""" + # Try ~/.config/files-to-prompt/config.toml first + config_dir = Path.home() / ".config" / "files-to-prompt" + config_path = config_dir / "config.toml" + if config_path.exists(): + return config_path + + # Try ~/.files-to-prompt.toml as fallback + alt_config = Path.home() / ".files-to-prompt.toml" + if alt_config.exists(): + return alt_config + + return None + + +def load_toml_file(path): + """Load a TOML file and return its contents.""" + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as e: + click.echo(f"Warning: Failed to load config from {path}: {e}", err=True) + return {} + + +def merge_configs(configs): + """Merge multiple config dictionaries, with first taking precedence.""" + result = {} + # Process in reverse order so first config wins + for config in reversed(configs): + if "defaults" in config: + defaults = config["defaults"] + for key, value in defaults.items(): + if key == "ignore" and key in result: + # For ignore patterns, combine lists + result[key] = list(set(result[key] + value)) + else: + result[key] = value + return result + + +def load_config(no_config=False): + """Load configuration from files.""" + if no_config: + return {} + + configs = [] + + # Load user config first (lower precedence) + user_config_path = find_user_config() + if user_config_path: + configs.append(load_toml_file(user_config_path)) + + # Load project config (higher precedence) + project_config_path = find_project_config() + if project_config_path: + configs.append(load_toml_file(project_config_path)) + + return merge_configs(configs) + + def norm_path(p: str) -> str: """Return path with forward slashes to ensure stable, cross-platform output.""" return p.replace(os.sep, "/") @@ -258,8 +355,15 @@ def read_paths_from_stdin(use_null_separator): is_flag=True, help="Use NUL character as separator when reading from stdin", ) +@click.option( + "--no-config", + is_flag=True, + help="Ignore configuration files and use only command-line options", +) @click.version_option() +@click.pass_context def cli( + ctx, paths, extensions, include_hidden, @@ -272,6 +376,7 @@ def cli( line_numbers, null, copy_to_clipboard, + no_config, ): """ Takes one or more paths to files or directories and outputs every file, @@ -307,35 +412,77 @@ def cli( Contents of file1.py ``` """ - # Reset global_index for pytest + # ------------------------------------------------------------ + # Configuration handling (project/user TOML) + # ------------------------------------------------------------ + # Load configuration + config = load_config(no_config) + + # Helper to see if an option was set explicitly on command line + def _was_set(param_name: str) -> bool: + try: + return ctx.get_parameter_source(param_name).name == "commandline" + except AttributeError: + # Older Click (<8.1) fallback – assume not provided + return False + + # Apply config defaults where CLI did not explicitly set them + if not extensions and "extensions" in config: + extensions = tuple(config["extensions"]) + + if not ignore_patterns and "ignore" in config: + ignore_patterns = tuple(config["ignore"]) + elif ignore_patterns and "ignore" in config: + ignore_patterns = tuple(set(ignore_patterns) | set(config.get("ignore", []))) + + if not _was_set("include_hidden"): + include_hidden = config.get("include_hidden", include_hidden) + if not _was_set("ignore_files_only"): + ignore_files_only = config.get("ignore_files_only", ignore_files_only) + if not _was_set("ignore_gitignore"): + ignore_gitignore = config.get("ignore_gitignore", ignore_gitignore) + if not _was_set("copy_to_clipboard"): + copy_to_clipboard = config.get("copy", copy_to_clipboard) + if not _was_set("claude_xml"): + claude_xml = config.get("cxml", claude_xml) + if not _was_set("markdown"): + markdown = config.get("markdown", markdown) + if not _was_set("line_numbers"): + line_numbers = config.get("line_numbers", line_numbers) + + if not output_file and "output" in config: + output_file = config["output"] + + # ------------------------------------------------------------ + # Main processing logic (existing behaviour) + # ------------------------------------------------------------ global global_index - global_index = 1 + global_index = 1 # Reset for each invocation (esp. tests) - # Read paths from stdin if available + # Combine CLI paths with any from stdin stdin_paths = read_paths_from_stdin(use_null_separator=null) - - # Combine paths from arguments and stdin paths = [*paths, *stdin_paths] - # If both -C and -o are provided, -o wins but print a note for the user + # Handle copy vs output precedence if copy_to_clipboard and output_file: click.echo( "Note: -o/--output overrides -C/--copy; writing output to file only.", err=True, ) - copy_to_clipboard = False # Disable clipboard behaviour - - gitignore_rules = [] + copy_to_clipboard = False + + gitignore_rules: list[str] = [] writer = click.echo - fp = None + fp = None # type: ignore clipboard_buffer = None - + if copy_to_clipboard: clipboard_buffer = StringIO() writer = lambda s: print(s, file=clipboard_buffer) elif output_file: fp = open(output_file, "w", encoding="utf-8") writer = lambda s: print(s, file=fp) + for path in paths: if not os.path.exists(path): raise click.BadArgumentUsage(f"Path does not exist: {path}") @@ -358,12 +505,10 @@ def cli( ) if claude_xml: writer("") - - if copy_to_clipboard: - content = clipboard_buffer.getvalue() + if copy_to_clipboard and clipboard_buffer is not None: + content = clipboard_buffer.getvalue() try: - # Lazy import so that pyperclip remains an optional dependency import pyperclip # type: ignore except ImportError as exc: raise click.ClickException( @@ -371,23 +516,20 @@ def cli( "Install it with 'pip install files-to-prompt[clipboard]' or " "re-run without -C/--copy." ) from exc - try: pyperclip.copy(content) click.echo("Output copied to clipboard") - except Exception as e: - # Provide additional platform-specific guidance + except Exception as e: # pragma: no cover – platform specific suggestion = "" if sys.platform.startswith("linux"): suggestion = " (hint: install 'xclip' or 'xsel')" elif sys.platform == "darwin": suggestion = " (make sure the 'pbcopy' utility is available)" - click.echo( f"Failed to copy to clipboard: {e}{suggestion}. Output follows:", err=True, ) click.echo(content) - + if fp: - fp.close() + fp.close() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 3115b66..d02e6fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ classifiers = [ "License :: OSI Approved :: Apache Software License" ] dependencies = [ - "click" + "click", + "tomli>=2; python_version < '3.11'" ] [project.optional-dependencies] diff --git a/tests/test_files_to_prompt.py b/tests/test_files_to_prompt.py index 99934b7..f12a427 100644 --- a/tests/test_files_to_prompt.py +++ b/tests/test_files_to_prompt.py @@ -1,6 +1,7 @@ import os import pytest import re +from pathlib import Path from unittest.mock import patch, MagicMock from click.testing import CliRunner @@ -560,3 +561,211 @@ def _copy(_: str): # The actual platform-specific handling is done by pyperclip # We just ensure our code calls it correctly + + +def test_config_file_loading(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file1.py", "w") as f: + f.write("Python file") + with open("test_dir/file2.txt", "w") as f: + f.write("Text file") + with open("test_dir/ignored.pyc", "w") as f: + f.write("Compiled file") + + # Create a project config file + with open(".files-to-prompt.toml", "w") as f: + f.write(""" +[defaults] +extensions = ["py"] +ignore = ["*.pyc"] +line_numbers = true +""") + + # Test that config is loaded + result = runner.invoke(cli, ["test_dir"]) + assert result.exit_code == 0 + assert "test_dir/file1.py" in result.output + assert "Python file" in result.output + assert "test_dir/file2.txt" not in result.output # Only .py files + assert "test_dir/ignored.pyc" not in result.output # Ignored + assert "1 Python file" in result.output # Line numbers enabled + + # Test --no-config flag + result = runner.invoke(cli, ["test_dir", "--no-config"]) + assert result.exit_code == 0 + assert "test_dir/file1.py" in result.output + assert "test_dir/file2.txt" in result.output # All files included + assert "test_dir/ignored.pyc" in result.output # Not ignored + assert "1 Python file" not in result.output # No line numbers + + +def test_config_precedence(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file1.py", "w") as f: + f.write("Python file") + with open("test_dir/file2.txt", "w") as f: + f.write("Text file") + + # Create a project config file + with open(".files-to-prompt.toml", "w") as f: + f.write(""" +[defaults] +extensions = ["py"] +markdown = true +""") + + # CLI args should override config + result = runner.invoke(cli, ["test_dir", "-e", "txt"]) + assert result.exit_code == 0 + assert "test_dir/file1.py" not in result.output + assert "test_dir/file2.txt" in result.output + assert "```" in result.output # Markdown from config + + # CLI flag overrides config + result = runner.invoke(cli, ["test_dir", "--cxml"]) + assert result.exit_code == 0 + assert "" in result.output # XML format overrides markdown + + +def test_config_ignore_patterns_merge(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file1.py", "w") as f: + f.write("Python file") + with open("test_dir/test.pyc", "w") as f: + f.write("Compiled file") + with open("test_dir/cache.tmp", "w") as f: + f.write("Temp file") + + # Create a project config file + with open(".files-to-prompt.toml", "w") as f: + f.write(""" +[defaults] +ignore = ["*.pyc"] +""") + + # Config and CLI ignore patterns should merge + result = runner.invoke(cli, ["test_dir", "--ignore", "*.tmp"]) + assert result.exit_code == 0 + assert "test_dir/file1.py" in result.output + assert "test_dir/test.pyc" not in result.output # From config + assert "test_dir/cache.tmp" not in result.output # From CLI + + +def test_config_in_parent_directory(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("nested/deep/test_dir") + + # Create config in parent directory + with open(".files-to-prompt.toml", "w") as f: + f.write(""" +[defaults] +line_numbers = true +""") + + with open("nested/deep/test_dir/file.txt", "w") as f: + f.write("Test content") + + # Change to nested directory + os.chdir("nested/deep") + + # Config should still be found + result = runner.invoke(cli, ["test_dir"]) + assert result.exit_code == 0 + assert "1 Test content" in result.output + + +def test_user_config(tmpdir, monkeypatch): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file.txt", "w") as f: + f.write("Test file") + + # Create a fake home directory + fake_home = tmpdir.mkdir("home") + monkeypatch.setattr(Path, "home", lambda: fake_home) + + # Create user config + config_dir = fake_home.mkdir(".config").mkdir("files-to-prompt") + with open(config_dir / "config.toml", "w") as f: + f.write(""" +[defaults] +markdown = true +""") + + result = runner.invoke(cli, ["test_dir"]) + assert result.exit_code == 0 + assert "```" in result.output # Markdown from user config + + +def test_invalid_config_file(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file.txt", "w") as f: + f.write("Test file") + + # Create invalid TOML + with open(".files-to-prompt.toml", "w") as f: + f.write("invalid toml {{{") + + # Should show warning but continue + result = runner.invoke(cli, ["test_dir"]) + assert result.exit_code == 0 + assert "Warning: Failed to load config" in result.output + assert "test_dir/file.txt" in result.output + + +def test_config_with_output_option(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/file.txt", "w") as f: + f.write("Test file") + + # Create config with output option + with open(".files-to-prompt.toml", "w") as f: + f.write(""" +[defaults] +output = "output.txt" +""") + + result = runner.invoke(cli, ["test_dir"]) + assert result.exit_code == 0 + assert not result.output # No stdout + + # Check output file + with open("output.txt", "r") as f: + content = f.read() + assert "test_dir/file.txt" in content + assert "Test file" in content + + +def test_config_boolean_flags(tmpdir): + runner = CliRunner() + with tmpdir.as_cwd(): + os.makedirs("test_dir") + with open("test_dir/.hidden.txt", "w") as f: + f.write("Hidden file") + with open("test_dir/normal.txt", "w") as f: + f.write("Normal file") + + # Create config with boolean flags + with open(".files-to-prompt.toml", "w") as f: + f.write(""" +[defaults] +include_hidden = true +cxml = true +""") + + result = runner.invoke(cli, ["test_dir"]) + assert result.exit_code == 0 + assert "test_dir/.hidden.txt" in result.output + assert "" in result.output # XML format