diff --git a/pyproject.toml b/pyproject.toml index 100e5bd..ce78689 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "colorama", "jaraco.text", # required by pkg_resources during runtime "nodriver==0.47.*", # Pin to 0.47 until upstream fixes UTF-8 decoding issues introduced in 0.48 + "platformdirs>=2.1.0", "pydantic>=2.11.0", "ruamel.yaml", "psutil", @@ -60,7 +61,6 @@ dev = [ "autopep8", "yamlfix", "pyinstaller", - "platformdirs", "types-requests>=2.32.0.20250515", "pytest-mock>=3.14.0", ] diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 1b1230b..10609de 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -616,3 +616,21 @@ kleinanzeigen_bot/model/update_check_state.py: "Interval too short: %s. Minimum interval is 1d. Using default interval for this run.": "Intervall zu kurz: %s. Das Mindestintervall beträgt 1 Tag. Es wird das Standardintervall für diesen Durchlauf verwendet." "Invalid interval format or unsupported unit: %s. Using default interval for this run.": "Ungültiges Intervallformat oder nicht unterstützte Einheit: %s. Es wird das Standardintervall für diesen Durchlauf verwendet." "Negative interval: %s. Minimum interval is 1d. Using default interval for this run.": "Negatives Intervall: %s. Das Mindestintervall beträgt 1 Tag. Es wird das Standardintervall für diesen Durchlauf verwendet." + +################################################# +kleinanzeigen_bot/utils/xdg_paths.py: +################################################# + _ensure_directory: + "Failed to create %s %s: %s": "Fehler beim Erstellen von %s %s: %s" + detect_installation_mode: + "Detected installation mode: %s": "Erkannter Installationsmodus: %s" + "No existing installation found": "Keine bestehende Installation gefunden" + prompt_installation_mode: + "Non-interactive mode detected, defaulting to portable installation": "Nicht-interaktiver Modus erkannt, Standard-Installation: portabel" + "Choose installation type:": "Installationstyp wählen:" + "[1] Portable (current directory)": "[1] Portabel (aktuelles Verzeichnis)" + "[2] System-wide (XDG directories)": "[2] Systemweit (XDG-Verzeichnisse)" + "Enter 1 or 2: ": "1 oder 2 eingeben: " + "Defaulting to portable installation mode": "Standard-Installationsmodus: portabel" + "User selected installation mode: %s": "Benutzer hat Installationsmodus gewählt: %s" + "Invalid choice. Please enter 1 or 2.": "Ungültige Auswahl. Bitte 1 oder 2 eingeben." diff --git a/src/kleinanzeigen_bot/utils/xdg_paths.py b/src/kleinanzeigen_bot/utils/xdg_paths.py new file mode 100644 index 0000000..1c4f829 --- /dev/null +++ b/src/kleinanzeigen_bot/utils/xdg_paths.py @@ -0,0 +1,269 @@ +# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ + +"""XDG Base Directory path resolution with backward compatibility. + +Supports two installation modes: +- Portable: All files in current working directory (for existing installations) +- System-wide: Files organized in XDG directories (for new installations or package managers) +""" + +from __future__ import annotations + +import sys +from gettext import gettext as _ +from pathlib import Path +from typing import Final, Literal, cast + +import platformdirs + +from kleinanzeigen_bot.utils import loggers + +LOG: Final[loggers.Logger] = loggers.get_logger(__name__) + +APP_NAME: Final[str] = "kleinanzeigen-bot" + +InstallationMode = Literal["portable", "xdg"] +PathCategory = Literal["config", "cache", "state"] + + +def _normalize_mode(mode: str | InstallationMode) -> InstallationMode: + """Validate and normalize installation mode input.""" + if mode in {"portable", "xdg"}: + return cast(InstallationMode, mode) + raise ValueError(f"Unsupported installation mode: {mode}") + + +def _ensure_directory(path: Path, description: str) -> None: + """Create directory and verify it exists.""" + LOG.debug("Creating directory: %s", path) + try: + path.mkdir(parents=True, exist_ok=True) + except OSError as exc: + LOG.error("Failed to create %s %s: %s", description, path, exc) + raise + if not path.is_dir(): + raise NotADirectoryError(str(path)) + + +def get_xdg_base_dir(category: PathCategory) -> Path: + """Get XDG base directory for the given category. + + Args: + category: The XDG category (config, cache, or state) + + Returns: + Path to the XDG base directory for this app + """ + resolved: str | None = None + match category: + case "config": + resolved = platformdirs.user_config_dir(APP_NAME) + case "cache": + resolved = platformdirs.user_cache_dir(APP_NAME) + case "state": + resolved = platformdirs.user_state_dir(APP_NAME) + case _: + raise ValueError(f"Unsupported XDG category: {category}") + + if resolved is None: + raise RuntimeError(f"Failed to resolve XDG base directory for category: {category}") + + base_dir = Path(resolved) + + LOG.debug("XDG %s directory: %s", category, base_dir) + return base_dir + + +def detect_installation_mode() -> InstallationMode | None: + """Detect installation mode based on config file location. + + Returns: + "portable" if ./config.yaml exists in CWD + "xdg" if config exists in XDG location + None if neither exists (first run) + """ + # Check for portable installation (./config.yaml in CWD) + portable_config = Path.cwd() / "config.yaml" + LOG.debug("Checking for portable config at: %s", portable_config) + + if portable_config.exists(): + LOG.info("Detected installation mode: %s", "portable") + return "portable" + + # Check for XDG installation + xdg_config = get_xdg_base_dir("config") / "config.yaml" + LOG.debug("Checking for XDG config at: %s", xdg_config) + + if xdg_config.exists(): + LOG.info("Detected installation mode: %s", "xdg") + return "xdg" + + # Neither exists - first run + LOG.info("No existing installation found") + return None + + +def prompt_installation_mode() -> InstallationMode: + """Prompt user to choose installation mode on first run. + + Returns: + "portable" or "xdg" based on user choice, or "portable" as default for non-interactive mode + """ + # Check if running in non-interactive mode (no stdin or not a TTY) + if not sys.stdin or not sys.stdin.isatty(): + LOG.info("Non-interactive mode detected, defaulting to portable installation") + return "portable" + + print(_("Choose installation type:")) + print(_("[1] Portable (current directory)")) + print(_("[2] System-wide (XDG directories)")) + + while True: + try: + choice = input(_("Enter 1 or 2: ")).strip() + except (EOFError, KeyboardInterrupt): + # Non-interactive or interrupted - default to portable + print() # newline after ^C or EOF + LOG.info("Defaulting to portable installation mode") + return "portable" + + if choice == "1": + mode: InstallationMode = "portable" + LOG.info("User selected installation mode: %s", mode) + return mode + if choice == "2": + mode = "xdg" + LOG.info("User selected installation mode: %s", mode) + return mode + print(_("Invalid choice. Please enter 1 or 2.")) + + +def get_config_file_path(mode: str | InstallationMode) -> Path: + """Get config.yaml file path for the given mode. + + Args: + mode: Installation mode (portable or xdg) + + Returns: + Path to config.yaml + """ + mode = _normalize_mode(mode) + config_path = Path.cwd() / "config.yaml" if mode == "portable" else get_xdg_base_dir("config") / "config.yaml" + + LOG.debug("Resolving config file path for mode '%s': %s", mode, config_path) + return config_path + + +def get_ad_files_search_dir(mode: str | InstallationMode) -> Path: + """Get directory to search for ad files. + + Ad files are searched relative to the config file directory, + matching the documented behavior that glob patterns are relative to config.yaml. + + Args: + mode: Installation mode (portable or xdg) + + Returns: + Path to ad files search directory (same as config file directory) + """ + mode = _normalize_mode(mode) + search_dir = Path.cwd() if mode == "portable" else get_xdg_base_dir("config") + + LOG.debug("Resolving ad files search directory for mode '%s': %s", mode, search_dir) + return search_dir + + +def get_downloaded_ads_path(mode: str | InstallationMode) -> Path: + """Get downloaded ads directory path. + + Args: + mode: Installation mode (portable or xdg) + + Returns: + Path to downloaded ads directory + + Note: + Creates the directory if it doesn't exist. + """ + mode = _normalize_mode(mode) + ads_path = Path.cwd() / "downloaded-ads" if mode == "portable" else get_xdg_base_dir("config") / "downloaded-ads" + + LOG.debug("Resolving downloaded ads path for mode '%s': %s", mode, ads_path) + + # Create directory if it doesn't exist + _ensure_directory(ads_path, "downloaded ads directory") + + return ads_path + + +def get_browser_profile_path(mode: str | InstallationMode, config_override: str | None = None) -> Path: + """Get browser profile directory path. + + Args: + mode: Installation mode (portable or xdg) + config_override: Optional config override path (takes precedence) + + Returns: + Path to browser profile directory + + Note: + Creates the directory if it doesn't exist. + """ + mode = _normalize_mode(mode) + if config_override: + profile_path = Path(config_override) + LOG.debug("Resolving browser profile path for mode '%s' (config override): %s", mode, profile_path) + elif mode == "portable": + profile_path = Path.cwd() / ".temp" / "browser-profile" + LOG.debug("Resolving browser profile path for mode '%s': %s", mode, profile_path) + else: # xdg + profile_path = get_xdg_base_dir("cache") / "browser-profile" + LOG.debug("Resolving browser profile path for mode '%s': %s", mode, profile_path) + + # Create directory if it doesn't exist + _ensure_directory(profile_path, "browser profile directory") + + return profile_path + + +def get_log_file_path(basename: str, mode: str | InstallationMode) -> Path: + """Get log file path. + + Args: + basename: Log file basename (without .log extension) + mode: Installation mode (portable or xdg) + + Returns: + Path to log file + """ + mode = _normalize_mode(mode) + log_path = Path.cwd() / f"{basename}.log" if mode == "portable" else get_xdg_base_dir("state") / f"{basename}.log" + + LOG.debug("Resolving log file path for mode '%s': %s", mode, log_path) + + # Create parent directory if it doesn't exist + _ensure_directory(log_path.parent, "log directory") + + return log_path + + +def get_update_check_state_path(mode: str | InstallationMode) -> Path: + """Get update check state file path. + + Args: + mode: Installation mode (portable or xdg) + + Returns: + Path to update check state file + """ + mode = _normalize_mode(mode) + state_path = Path.cwd() / ".temp" / "update_check_state.json" if mode == "portable" else get_xdg_base_dir("state") / "update_check_state.json" + + LOG.debug("Resolving update check state path for mode '%s': %s", mode, state_path) + + # Create parent directory if it doesn't exist + _ensure_directory(state_path.parent, "update check state directory") + + return state_path diff --git a/tests/unit/test_xdg_paths.py b/tests/unit/test_xdg_paths.py new file mode 100644 index 0000000..57b7674 --- /dev/null +++ b/tests/unit/test_xdg_paths.py @@ -0,0 +1,485 @@ +# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ + +"""Unit tests for XDG paths module.""" + +import io +from pathlib import Path + +import pytest + +from kleinanzeigen_bot.utils import xdg_paths + +pytestmark = pytest.mark.unit + + +class TestGetXdgBaseDir: + """Tests for get_xdg_base_dir function.""" + + def test_returns_state_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test resolving XDG state directory.""" + state_dir = tmp_path / "state" + monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(state_dir / app_name)) + + resolved = xdg_paths.get_xdg_base_dir("state") + + assert resolved == state_dir / "kleinanzeigen-bot" + + def test_raises_for_unknown_category(self) -> None: + """Test invalid category handling.""" + with pytest.raises(ValueError, match="Unsupported XDG category"): + xdg_paths.get_xdg_base_dir("invalid") # type: ignore[arg-type] + + def test_raises_when_base_dir_is_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test runtime error when platformdirs returns None.""" + monkeypatch.setattr("platformdirs.user_state_dir", lambda _app_name, *args, **kwargs: None) + + with pytest.raises(RuntimeError, match="Failed to resolve XDG base directory"): + xdg_paths.get_xdg_base_dir("state") + + +class TestDetectInstallationMode: + """Tests for detect_installation_mode function.""" + + def test_detects_portable_mode_when_config_exists_in_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that portable mode is detected when config.yaml exists in CWD.""" + # Setup: Create config.yaml in CWD + monkeypatch.chdir(tmp_path) + (tmp_path / "config.yaml").touch() + + # Execute + mode = xdg_paths.detect_installation_mode() + + # Verify + assert mode == "portable" + + def test_detects_xdg_mode_when_config_exists_in_xdg_location(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that XDG mode is detected when config exists in XDG location.""" + # Setup: Create config in mock XDG directory + xdg_config = tmp_path / "config" / "kleinanzeigen-bot" + xdg_config.mkdir(parents=True) + (xdg_config / "config.yaml").touch() + + # Mock platformdirs to return our test directory + monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "config" / app_name)) + + # Change to a different directory (no local config) + cwd = tmp_path / "cwd" + cwd.mkdir() + monkeypatch.chdir(cwd) + + # Execute + mode = xdg_paths.detect_installation_mode() + + # Verify + assert mode == "xdg" + + def test_returns_none_when_no_config_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that None is returned when no config exists anywhere.""" + # Setup: Empty directories + monkeypatch.chdir(tmp_path) + # Mock XDG to return a non-existent path + monkeypatch.setattr( + "platformdirs.user_config_dir", + lambda app_name, *args, **kwargs: str(tmp_path / "nonexistent-xdg" / app_name), + ) + + # Execute + mode = xdg_paths.detect_installation_mode() + + # Verify + assert mode is None + + +class TestGetConfigFilePath: + """Tests for get_config_file_path function.""" + + def test_returns_cwd_path_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that portable mode returns ./config.yaml.""" + monkeypatch.chdir(tmp_path) + + path = xdg_paths.get_config_file_path("portable") + + assert path == tmp_path / "config.yaml" + + def test_returns_xdg_path_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that XDG mode returns XDG config path.""" + xdg_config = tmp_path / "config" + monkeypatch.setattr( + "platformdirs.user_config_dir", + lambda app_name, *args, **kwargs: str(xdg_config / app_name), + ) + + path = xdg_paths.get_config_file_path("xdg") + + assert "kleinanzeigen-bot" in str(path) + assert path.name == "config.yaml" + + +class TestGetAdFilesSearchDir: + """Tests for get_ad_files_search_dir function.""" + + def test_returns_cwd_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that portable mode searches in CWD.""" + monkeypatch.chdir(tmp_path) + + search_dir = xdg_paths.get_ad_files_search_dir("portable") + + assert search_dir == tmp_path + + def test_returns_xdg_config_dir_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that XDG mode searches in XDG config directory (same as config file).""" + xdg_config = tmp_path / "config" + monkeypatch.setattr( + "platformdirs.user_config_dir", + lambda app_name, *args, **kwargs: str(xdg_config / app_name), + ) + + search_dir = xdg_paths.get_ad_files_search_dir("xdg") + + assert "kleinanzeigen-bot" in str(search_dir) + # Ad files searched in same directory as config file, not separate ads/ subdirectory + assert search_dir.name == "kleinanzeigen-bot" + + +class TestGetDownloadedAdsPath: + """Tests for get_downloaded_ads_path function.""" + + def test_returns_cwd_downloaded_ads_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that portable mode uses ./downloaded-ads/.""" + monkeypatch.chdir(tmp_path) + + ads_path = xdg_paths.get_downloaded_ads_path("portable") + + assert ads_path == tmp_path / "downloaded-ads" + + def test_creates_directory_if_not_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that directory is created if it doesn't exist.""" + monkeypatch.chdir(tmp_path) + + ads_path = xdg_paths.get_downloaded_ads_path("portable") + + assert ads_path.exists() + assert ads_path.is_dir() + + def test_returns_xdg_downloaded_ads_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that XDG mode uses XDG config/downloaded-ads/.""" + xdg_config = tmp_path / "config" + monkeypatch.setattr( + "platformdirs.user_config_dir", + lambda app_name, *args, **kwargs: str(xdg_config / app_name), + ) + + ads_path = xdg_paths.get_downloaded_ads_path("xdg") + + assert "kleinanzeigen-bot" in str(ads_path) + assert ads_path.name == "downloaded-ads" + + +class TestGetBrowserProfilePath: + """Tests for get_browser_profile_path function.""" + + def test_returns_cwd_temp_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that portable mode uses ./.temp/browser-profile.""" + monkeypatch.chdir(tmp_path) + + profile_path = xdg_paths.get_browser_profile_path("portable") + + assert profile_path == tmp_path / ".temp" / "browser-profile" + + def test_returns_xdg_cache_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that XDG mode uses XDG cache directory.""" + xdg_cache = tmp_path / "cache" + monkeypatch.setattr( + "platformdirs.user_cache_dir", + lambda app_name, *args, **kwargs: str(xdg_cache / app_name), + ) + + profile_path = xdg_paths.get_browser_profile_path("xdg") + + assert "kleinanzeigen-bot" in str(profile_path) + assert profile_path.name == "browser-profile" + + def test_creates_directory_if_not_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that browser profile directory is created.""" + monkeypatch.chdir(tmp_path) + + profile_path = xdg_paths.get_browser_profile_path("portable") + + assert profile_path.exists() + assert profile_path.is_dir() + + +class TestGetLogFilePath: + """Tests for get_log_file_path function.""" + + def test_returns_cwd_log_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that portable mode uses ./{basename}.log.""" + monkeypatch.chdir(tmp_path) + + log_path = xdg_paths.get_log_file_path("test", "portable") + + assert log_path == tmp_path / "test.log" + + def test_returns_xdg_state_log_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that XDG mode uses XDG state directory.""" + xdg_state = tmp_path / "state" + monkeypatch.setattr( + "platformdirs.user_state_dir", + lambda app_name, *args, **kwargs: str(xdg_state / app_name), + ) + + log_path = xdg_paths.get_log_file_path("test", "xdg") + + assert "kleinanzeigen-bot" in str(log_path) + assert log_path.name == "test.log" + + +class TestGetUpdateCheckStatePath: + """Tests for get_update_check_state_path function.""" + + def test_returns_cwd_temp_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that portable mode uses ./.temp/update_check_state.json.""" + monkeypatch.chdir(tmp_path) + + state_path = xdg_paths.get_update_check_state_path("portable") + + assert state_path == tmp_path / ".temp" / "update_check_state.json" + + def test_returns_xdg_state_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that XDG mode uses XDG state directory.""" + xdg_state = tmp_path / "state" + monkeypatch.setattr( + "platformdirs.user_state_dir", + lambda app_name, *args, **kwargs: str(xdg_state / app_name), + ) + + state_path = xdg_paths.get_update_check_state_path("xdg") + + assert "kleinanzeigen-bot" in str(state_path) + assert state_path.name == "update_check_state.json" + + +class TestPromptInstallationMode: + """Tests for prompt_installation_mode function.""" + + @pytest.fixture(autouse=True) + def _force_identity_translation(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure prompt strings are stable regardless of locale.""" + monkeypatch.setattr(xdg_paths, "_", lambda message: message) + + def test_returns_portable_for_non_interactive_mode_no_stdin(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that non-interactive mode (no stdin) defaults to portable.""" + # Mock sys.stdin to be None (simulates non-interactive environment) + monkeypatch.setattr("sys.stdin", None) + + mode = xdg_paths.prompt_installation_mode() + + assert mode == "portable" + + def test_returns_portable_for_non_interactive_mode_not_tty(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that non-interactive mode (not a TTY) defaults to portable.""" + # Mock sys.stdin.isatty() to return False (simulates piped input or file redirect) + mock_stdin = io.StringIO() + mock_stdin.isatty = lambda: False # type: ignore[method-assign] + monkeypatch.setattr("sys.stdin", mock_stdin) + + mode = xdg_paths.prompt_installation_mode() + + assert mode == "portable" + + def test_returns_portable_when_user_enters_1(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """Test that user entering '1' selects portable mode.""" + # Mock sys.stdin to simulate interactive terminal + mock_stdin = io.StringIO() + mock_stdin.isatty = lambda: True # type: ignore[method-assign] + monkeypatch.setattr("sys.stdin", mock_stdin) + + # Mock interactive input + monkeypatch.setattr("builtins.input", lambda _: "1") + + mode = xdg_paths.prompt_installation_mode() + + assert mode == "portable" + # Verify prompt was shown + captured = capsys.readouterr() + assert "Choose installation type:" in captured.out + assert "[1] Portable" in captured.out + + def test_returns_xdg_when_user_enters_2(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """Test that user entering '2' selects XDG mode.""" + # Mock sys.stdin to simulate interactive terminal + mock_stdin = io.StringIO() + mock_stdin.isatty = lambda: True # type: ignore[method-assign] + monkeypatch.setattr("sys.stdin", mock_stdin) + + # Mock interactive input + monkeypatch.setattr("builtins.input", lambda _: "2") + + mode = xdg_paths.prompt_installation_mode() + + assert mode == "xdg" + # Verify prompt was shown + captured = capsys.readouterr() + assert "Choose installation type:" in captured.out + assert "[2] System-wide" in captured.out + + def test_reprompts_on_invalid_input_then_accepts_valid(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """Test that invalid input causes re-prompt, then valid input is accepted.""" + # Mock sys.stdin to simulate interactive terminal + mock_stdin = io.StringIO() + mock_stdin.isatty = lambda: True # type: ignore[method-assign] + monkeypatch.setattr("sys.stdin", mock_stdin) + + # Mock sequence of inputs: invalid, then valid + inputs = iter(["3", "invalid", "1"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + mode = xdg_paths.prompt_installation_mode() + + assert mode == "portable" + # Verify error message was shown + captured = capsys.readouterr() + assert "Invalid choice" in captured.out + + def test_returns_portable_on_eof_error(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """Test that EOFError (Ctrl+D) defaults to portable mode.""" + # Mock sys.stdin to simulate interactive terminal + mock_stdin = io.StringIO() + mock_stdin.isatty = lambda: True # type: ignore[method-assign] + monkeypatch.setattr("sys.stdin", mock_stdin) + + # Mock input raising EOFError + def mock_input(_: str) -> str: + raise EOFError + + monkeypatch.setattr("builtins.input", mock_input) + + mode = xdg_paths.prompt_installation_mode() + + assert mode == "portable" + # Verify newline was printed after EOF + captured = capsys.readouterr() + assert captured.out.endswith("\n") + + def test_returns_portable_on_keyboard_interrupt(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """Test that KeyboardInterrupt (Ctrl+C) defaults to portable mode.""" + # Mock sys.stdin to simulate interactive terminal + mock_stdin = io.StringIO() + mock_stdin.isatty = lambda: True # type: ignore[method-assign] + monkeypatch.setattr("sys.stdin", mock_stdin) + + # Mock input raising KeyboardInterrupt + def mock_input(_: str) -> str: + raise KeyboardInterrupt + + monkeypatch.setattr("builtins.input", mock_input) + + mode = xdg_paths.prompt_installation_mode() + + assert mode == "portable" + # Verify newline was printed after interrupt + captured = capsys.readouterr() + assert captured.out.endswith("\n") + + +class TestGetBrowserProfilePathWithOverride: + """Tests for get_browser_profile_path config_override parameter.""" + + def test_respects_config_override_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that config_override takes precedence in portable mode.""" + monkeypatch.chdir(tmp_path) + + custom_path = str(tmp_path / "custom" / "browser") + profile_path = xdg_paths.get_browser_profile_path("portable", config_override=custom_path) + + assert profile_path == Path(custom_path) + assert profile_path.exists() # Verify directory was created + assert profile_path.is_dir() + + def test_respects_config_override_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that config_override takes precedence in XDG mode.""" + xdg_cache = tmp_path / "cache" + monkeypatch.setattr( + "platformdirs.user_cache_dir", + lambda app_name, *args, **kwargs: str(xdg_cache / app_name), + ) + + custom_path = str(tmp_path / "custom" / "browser") + profile_path = xdg_paths.get_browser_profile_path("xdg", config_override=custom_path) + + assert profile_path == Path(custom_path) + # Verify it didn't use XDG cache directory + assert str(profile_path) != str(xdg_cache / "kleinanzeigen-bot" / "browser-profile") + assert profile_path.exists() + assert profile_path.is_dir() + + +class TestUnicodeHandling: + """Tests for Unicode path handling (NFD vs NFC normalization).""" + + def test_portable_mode_handles_unicode_in_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that portable mode works with Unicode characters in CWD path. + + This tests the edge case where the current directory contains Unicode + characters (e.g., user names with umlauts), which may be stored in + different normalization forms (NFD on macOS, NFC on Linux/Windows). + """ + # Create directory with German umlaut in composed (NFC) form + # ä = U+00E4 (NFC) vs a + ̈ = U+0061 + U+0308 (NFD) + unicode_dir = tmp_path / "Müller_config" + unicode_dir.mkdir() + monkeypatch.chdir(unicode_dir) + + # Get paths - should work regardless of normalization + config_path = xdg_paths.get_config_file_path("portable") + log_path = xdg_paths.get_log_file_path("test", "portable") + + # Verify paths are within the Unicode directory + assert config_path.parent == unicode_dir + assert log_path.parent == unicode_dir + assert config_path.name == "config.yaml" + assert log_path.name == "test.log" + + def test_xdg_mode_handles_unicode_in_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that XDG mode handles Unicode in XDG directory paths. + + This tests the edge case where XDG directories contain Unicode + characters (e.g., /Users/Müller/.config/), which may be in NFD + form on macOS filesystems. + """ + # Create XDG directory with umlaut + xdg_base = tmp_path / "Users" / "Müller" / ".config" + xdg_base.mkdir(parents=True) + + monkeypatch.setattr( + "platformdirs.user_config_dir", + lambda app_name, *args, **kwargs: str(xdg_base / app_name), + ) + + # Get config path + config_path = xdg_paths.get_config_file_path("xdg") + + # Verify path contains the Unicode directory + assert "Müller" in str(config_path) or "Mu\u0308ller" in str(config_path) + assert config_path.name == "config.yaml" + + def test_downloaded_ads_path_handles_unicode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that downloaded ads directory creation works with Unicode paths.""" + # Create XDG config directory with umlaut + xdg_config = tmp_path / "config" / "Müller" + xdg_config.mkdir(parents=True) + + monkeypatch.setattr( + "platformdirs.user_config_dir", + lambda app_name, *args, **kwargs: str(xdg_config / app_name), + ) + + # Get downloaded ads path - this will create the directory + ads_path = xdg_paths.get_downloaded_ads_path("xdg") + + # Verify directory was created successfully + assert ads_path.exists() + assert ads_path.is_dir() + assert ads_path.name == "downloaded-ads"