diff --git a/README.md b/README.md index 9bd183e..f49f9fe 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ on macOS and Linux. - Creates a timestamped Pre-edit Backup before Privileged Mode begins. - Restores the current session's Pre-edit Backup after explicit confirmation. - Supports undo and redo during the current Privileged Mode session. +- Exports Host Entries as hosts, JSON, or CSV files. In Privileged Mode, + imports can add Host Entries or replace the Hosts File after confirmation. The application starts in Read-only Mode. Default Entries cannot be edited, deleted, activated, deactivated, or moved. @@ -76,6 +78,8 @@ including persistence behavior and manual recovery. | `Space` | Activate or deactivate the selected Host Entry | | `Ctrl+S` | Save the current in-memory state | | `Ctrl+F` | Open advanced filters | +| `Ctrl+X` | Export Host Entries (hosts, JSON, or CSV) | +| `Ctrl+O` | Import Host Entries from hosts, JSON, or CSV (Privileged Mode) | | `?` | Show help | | `q` or `Ctrl+C` | Quit | diff --git a/docs/user-guide.md b/docs/user-guide.md index 5ec7d36..e994ac6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -102,6 +102,18 @@ Both actions require Privileged Mode because a successful result changes `/etc/hosts`. A failed lookup reports the failure and keeps the previously stored mapping. +## Import and export Host Entries + +Press `Ctrl+X` to export the current in-memory Host Entries. Choose hosts, JSON, or +CSV and enter a destination path, or choose a directory with **Browse…** and +edit the suggested filename. The workflow validates the destination and +requires explicit confirmation before an existing file is overwritten. + +Press `Ctrl+O` in Privileged Mode to import a hosts, JSON, or CSV file. Choose the +format explicitly, then select the source path with **Browse…** or by typing it. A successful import replaces +the current Host Entries and saves the Hosts File; parse and save errors remain +visible in the message rail and leave the current file unchanged. + ## Verify what is on disk The details shown by the application normally match the last successful save. @@ -247,6 +259,7 @@ continually retrying it. | `Ctrl+R` | Reload `/etc/hosts` | | `Ctrl+F` | Open advanced filters | | `c` | Open configuration | +| `Ctrl+X` | Export Host Entries | | `?` | Show help | | `q` or `Ctrl+C` | Quit | | `Ctrl+E` | Enter or leave Privileged Mode | @@ -266,6 +279,7 @@ continually retrying it. | `Ctrl+Z` / `Ctrl+Y` | Undo or redo and save the result | | `Ctrl+S` | Save the current in-memory state | | `b` | Review session backup changes and confirm restoration | +| `Ctrl+O` | Import Host Entries from hosts, JSON, or CSV | Within the Entry Editor, use `Tab` and `Shift+Tab` to move between fields and `Escape` to leave the form. If values changed, the application asks whether to diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 9aa3b74..0fd0277 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -29,12 +29,21 @@ from ..core.manager import HostsManager, MutationState from ..core.restore_preview import RestorePreviewError, create_restore_preview from ..core.dns import DNSService from ..core.filters import EntryFilter, FilterOptions +from ..core.import_export import ImportExportService, ExportFormat, ImportFormat from .config_modal import ConfigModal from .add_entry_modal import AddEntryModal from .backup_restore_modal import BackupRestoreModal from .delete_confirmation_modal import DeleteConfirmationModal from .filter_modal import FilterModal from .help_modal import HelpModal +from .import_export_modal import ( + ExportModal, + ImportModal, + ExportRequest, + ImportMode, + ImportRequest, + ReplaceImportConfirmationModal, +) from .custom_footer import CustomFooter from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS @@ -85,6 +94,7 @@ class HostsManagerApp(App): self.parser = HostsParser() self.config = Config() self.manager = HostsManager() + self.import_export_service = ImportExportService() # Initialize DNS service dns_config = self.config.get("dns_resolution", {}) @@ -904,6 +914,109 @@ class HostsManagerApp(App): return self.navigation_handler.save_hosts_file() + def action_export_entries(self) -> None: + """Export the in-memory Host Entries without requiring Privileged Mode.""" + + def handle_export(request: ExportRequest | None) -> None: + if request is None: + return + exporters = { + ExportFormat.HOSTS: self.import_export_service.export_hosts_format, + ExportFormat.JSON: self.import_export_service.export_json_format, + ExportFormat.CSV: self.import_export_service.export_csv_format, + } + result = exporters[request.format](self.hosts_file, request.path) + if result.success: + self.update_status( + f"✓ Exported {result.entries_exported} Host Entries to {result.file_path}" + ) + else: + self.update_status( + f"Error exporting Host Entries: {'; '.join(result.errors)}" + ) + + self.push_screen( + ExportModal( + self.import_export_service.get_supported_export_formats(), + self.import_export_service.validate_export_path, + ), + handle_export, + ) + + def action_import_entries(self) -> None: + """Import and persist replacement Host Entries in Privileged Mode.""" + if not self._allow_mutation_action(): + return + if not self.edit_mode: + self.update_status( + "Cannot import Host Entries in Read-only Mode. Enter Privileged Mode first." + ) + return + + importers = { + ImportFormat.HOSTS: self.import_export_service.import_hosts_format, + ImportFormat.JSON: self.import_export_service.import_json_format, + ImportFormat.CSV: self.import_export_service.import_csv_format, + } + + def import_file(format: ImportFormat, path): + return importers[format](path) + + def apply_import(request: ImportRequest) -> None: + result = request.result + snapshot = self.capture_mutation_state() + if request.mode is ImportMode.ADD: + self.hosts_file = HostsFile( + entries=[*self.hosts_file.entries, *result.entries], + header_comments=list(self.hosts_file.header_comments), + footer_comments=list(self.hosts_file.footer_comments), + ) + action = "Add imported Host Entries" + outcome = "Added and saved" + else: + self.hosts_file = HostsFile(entries=list(result.entries)) + action = "Replace Hosts File from import" + outcome = "Replaced Hosts File with" + self.selected_entry_index = 0 + if self.save_mutation(snapshot, action): + self.table_handler.populate_entries_table() + self.details_handler.update_entry_details() + noun = ( + "Host Entry" + if result.successfully_imported == 1 + else "Host Entries" + ) + self.update_status( + f"✓ {outcome} {result.successfully_imported} " + f"{'imported ' if request.mode is ImportMode.REPLACE else ''}" + f"{noun} from {request.path}" + ) + + def handle_import(request: ImportRequest | None) -> None: + if request is None: + return + if request.mode is ImportMode.ADD: + apply_import(request) + return + + def handle_replace_confirmation(confirmed: bool | None) -> None: + if confirmed: + apply_import(request) + + self.push_screen( + ReplaceImportConfirmationModal( + len(self.hosts_file.entries), len(request.result.entries) + ), + handle_replace_confirmation, + ) + + self.push_screen( + ImportModal( + self.import_export_service.get_supported_import_formats(), import_file + ), + handle_import, + ) + def action_add_entry(self) -> None: """Show the add entry modal.""" if not self._allow_add_entry_action(): diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py index 63d5e1a..3ab92ef 100644 --- a/src/hosts/tui/help_modal.py +++ b/src/hosts/tui/help_modal.py @@ -48,6 +48,10 @@ class HelpModal(ModalScreen[None]): "Type in Search for immediate filtering Ctrl+F Advanced filters", classes="help-copy", ) + yield Static( + "Ctrl+X Export Host Entries Ctrl+O Import Host Entries", + classes="help-copy", + ) yield Static("Privileged Mode", classes="help-section") yield Static( "Ctrl+E Enter or leave Privileged Mode n Add Host Entry\n" diff --git a/src/hosts/tui/import_export_modal.py b/src/hosts/tui/import_export_modal.py new file mode 100644 index 0000000..a90f920 --- /dev/null +++ b/src/hosts/tui/import_export_modal.py @@ -0,0 +1,473 @@ +"""Keyboard-first import and export forms for Host Entries.""" + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Grid, Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static + +from ..core.import_export import ExportFormat, ImportFormat, ImportResult + + +@dataclass(frozen=True) +class ExportRequest: + """A validated request to export the displayed Hosts File.""" + + path: Path + format: ExportFormat + + +class ImportMode(Enum): + """How imported Host Entries are applied to the current Hosts File.""" + + ADD = "add" + REPLACE = "replace" + + +@dataclass(frozen=True) +class ImportRequest: + """A validated request to apply imported Host Entries.""" + + path: Path + format: ImportFormat + mode: ImportMode + result: ImportResult + + +class _FormatModal(ModalScreen): + """Shared form mechanics for selecting a file and supported format.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + CSS = """ + ExportModal, ImportModal { align: center middle; } + #format-container { + width: 72; height: auto; max-height: 90%; background: $surface; + border: thick $primary; padding: 1 2; + } + .format-title { text-align: center; text-style: bold; color: $primary; } + .format-copy { margin-top: 1; color: $text-muted; } + .path-section { + height: 3; margin-top: 1; padding: 0 1; border: round $primary; + } + #path-row { grid-size: 2; grid-columns: 1fr 14; height: 1; } + #path-input { width: 1fr; height: 1; margin: 0; padding: 0; border: none; } + #workflow-error { color: $error; height: auto; } + #workflow-warning { color: $warning; height: auto; } + .workflow-message.hidden { display: none; } + .browse-button { width: 14; height: 1; margin: 0; border: none; } + .format-section { + height: 5; margin-top: 1; padding: 0 1; border: round $primary; + } + #format-select { height: 3; margin: 0; padding: 0; border: none; } + .import-mode-section { + height: 4; margin-top: 1; padding: 0 1; border: round $primary; + } + #import-mode-select { height: 2; margin: 0; padding: 0; border: none; } + .button-row { margin-top: 1; height: 3; align: center middle; } + """ + + def _selected_format(self, enum_type): + for member in enum_type: + if self.query_one(f"#format-{member.value}", RadioButton).value: + return member + return next(iter(enum_type)) + + def _path_or_error(self) -> Path | None: + value = self.query_one("#path-input", Input).value.strip() + if value: + self._show_message("#workflow-error", "") + return Path(value).expanduser() + self._show_message("#workflow-error", "Choose a file path first.") + return None + + def _show_message(self, selector: str, message: str) -> None: + widget = self.query_one(selector, Static) + widget.update(message) + widget.set_class(not message, "hidden") + + def action_cancel(self) -> None: + self.dismiss(None) + + +class ExportModal(_FormatModal): + """Collect an export destination and require confirmation for overwrite.""" + + def __init__(self, formats: list[ExportFormat], validate_path): + super().__init__() + self._formats = formats + self._validate_path = validate_path + self._request: ExportRequest | None = None + + def compose(self) -> ComposeResult: + with Vertical(id="format-container"): + yield Static("Export Host Entries", classes="format-title") + yield Static( + "Choose a destination and format. Existing files require confirmation.", + classes="format-copy", + ) + with Vertical(classes="path-section") as location: + location.border_title = "Export file path" + with Grid(id="path-row"): + yield Input( + placeholder="/path/to/entries.hosts", + id="path-input", + compact=True, + ) + yield Button( + "Browse…", + id="browse-button", + classes="browse-button", + compact=True, + ) + yield Static("", id="workflow-error", classes="workflow-message hidden") + yield Static("", id="workflow-warning", classes="workflow-message hidden") + with Vertical(classes="format-section") as formats: + formats.border_title = "File format" + with RadioSet(id="format-select", compact=True): + for index, format in enumerate(self._formats): + yield RadioButton( + _format_label(format), + id=f"format-{format.value}", + value=index == 0, + compact=True, + ) + with Horizontal(classes="button-row"): + yield Button("Cancel", id="cancel-button") + yield Button("Continue", id="continue-button", variant="primary") + yield Button("Overwrite export", id="confirm-button", variant="error") + + def on_mount(self) -> None: + self.query_one("#path-input", Input).focus() + self.query_one("#confirm-button", Button).display = False + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel-button": + self.action_cancel() + elif event.button.id == "continue-button": + self.action_continue() + elif event.button.id == "browse-button": + self.action_browse() + elif event.button.id == "confirm-button" and self._request is not None: + self.dismiss(self._request) + + def action_continue(self) -> None: + path = self._path_or_error() + if path is None: + return + request = ExportRequest(path, self._selected_format(ExportFormat)) + warnings = self._validate_path(path, request.format) + self._request = request + self._show_message("#workflow-warning", "\n".join(warnings)) + blocking_warnings = [item for item in warnings if "already exists" not in item] + if blocking_warnings: + self._show_message( + "#workflow-error", + "Correct the destination before exporting: " + + "; ".join(blocking_warnings), + ) + return + if warnings: + self.query_one("#confirm-button", Button).display = True + return + self.dismiss(request) + + def action_browse(self) -> None: + """Choose an export directory without requiring a typed path.""" + value = self.query_one("#path-input", Input).value.strip() + start = Path(value).expanduser() if value else Path.cwd() + if not start.is_dir(): + start = start.parent + + def selected(directory: Path | None) -> None: + if directory is not None: + format = self._selected_format(ExportFormat) + self.query_one("#path-input", Input).value = str( + directory / f"hosts-export{_format_suffix(format)}" + ) + + self.app.push_screen(FileBrowserModal(start, choose_directory=True), selected) + + +class ImportModal(_FormatModal): + """Collect an import file, format, and application mode.""" + + def __init__(self, formats: list[ImportFormat], import_file): + super().__init__() + self._formats = formats + self._import_file = import_file + + def compose(self) -> ComposeResult: + with Vertical(id="format-container"): + yield Static("Import Host Entries", classes="format-title") + yield Static( + "Choose a file, format, and how to apply its Host Entries.", + classes="format-copy", + ) + with Vertical(classes="path-section") as location: + location.border_title = "Import file path" + with Grid(id="path-row"): + yield Input( + placeholder="/path/to/entries.hosts", + id="path-input", + compact=True, + ) + yield Button( + "Browse…", + id="browse-button", + classes="browse-button", + compact=True, + ) + yield Static("", id="workflow-error", classes="workflow-message hidden") + with Vertical(classes="format-section") as formats: + formats.border_title = "File format" + with RadioSet(id="format-select", compact=True): + for index, format in enumerate(self._formats): + yield RadioButton( + _format_label(format), + id=f"format-{format.value}", + value=index == 0, + compact=True, + ) + with Vertical(classes="import-mode-section") as modes: + modes.border_title = "Import action" + with RadioSet(id="import-mode-select", compact=True): + yield RadioButton( + "Add to Hosts File", id="mode-add", value=True, compact=True + ) + yield RadioButton( + "Replace Hosts File", id="mode-replace", compact=True + ) + with Horizontal(classes="button-row"): + yield Button("Cancel", id="cancel-button") + yield Button("Import and save", id="import-button", variant="primary") + + def on_mount(self) -> None: + self.query_one("#path-input", Input).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel-button": + self.action_cancel() + elif event.button.id == "import-button": + path = self._path_or_error() + if path is not None: + format = self._selected_format(ImportFormat) + result = self._import_file(format, path) + if not result.success: + self._show_message("#workflow-error", "; ".join(result.errors)) + return + mode = ( + ImportMode.REPLACE + if self.query_one("#mode-replace", RadioButton).value + else ImportMode.ADD + ) + self.dismiss(ImportRequest(path, format, mode, result)) + elif event.button.id == "browse-button": + self.action_browse() + + def action_browse(self) -> None: + """Choose an import file from the terminal file browser.""" + value = self.query_one("#path-input", Input).value.strip() + start = Path(value).expanduser() if value else Path.cwd() + if not start.is_dir(): + start = start.parent + + def selected(path: Path | None) -> None: + if path is not None: + self.query_one("#path-input", Input).value = str(path) + + self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected) + + +class ReplaceImportConfirmationModal(ModalScreen[bool]): + """Warn before replacing every current Host Entry with imported entries.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + CSS = """ + ReplaceImportConfirmationModal { align: center middle; } + #replace-import-confirmation-container { + width: 68; height: auto; max-width: 90%; background: $surface; + border: thick $error; padding: 1 2; + } + .replace-title { + text-align: center; text-style: bold; color: $error; + } + #replace-warning { height: auto; margin-top: 1; color: $warning; } + .replace-consequence { height: auto; margin-top: 1; } + .button-row { margin-top: 1; height: 3; align: center middle; } + """ + + def __init__(self, current_count: int, imported_count: int): + super().__init__() + self._current_count = current_count + self._imported_count = imported_count + + def compose(self) -> ComposeResult: + with Vertical(id="replace-import-confirmation-container"): + yield Static("Replace Hosts File?", classes="replace-title") + yield Static( + f"This will remove all {_entry_count(self._current_count, 'current')} " + f"and replace the Hosts File with " + f"{_entry_count(self._imported_count, 'imported')}.", + id="replace-warning", + ) + yield Static( + "The current Hosts File will not be changed unless you confirm.", + classes="replace-consequence", + ) + with Horizontal(classes="button-row"): + yield Button("Cancel", id="cancel-button") + yield Button("Replace Hosts File", id="replace-button", variant="error") + + def on_mount(self) -> None: + self.query_one("#cancel-button", Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "replace-button": + self.dismiss(True) + elif event.button.id == "cancel-button": + self.action_cancel() + + def action_cancel(self) -> None: + self.dismiss(False) + + +class ParentDirectoryTree(DirectoryTree): + """Directory tree with a conventional parent entry above its root.""" + + PARENT_ICON = "↑ " + + def _populate_node(self, node, content) -> None: + super()._populate_node(node, content) + if node is not self.root or node.data is None: + return + current = node.data.path.expanduser().resolve() + parent = current.parent + if parent != current: + node.add( + "..", + data=type(node.data)(parent), + allow_expand=False, + before=0, + ) + + def render_label(self, node, base_style, style) -> Text: + """Render the parent entry as navigation rather than as a file.""" + if str(node.label) != "..": + return super().render_label(node, base_style, style) + + label = node.label.copy() + label.stylize(style) + label.stylize_before( + self.get_component_rich_style("directory-tree--folder", partial=True) + ) + return Text.assemble((self.PARENT_ICON, base_style), label) + + +class FileBrowserModal(ModalScreen[Path | None]): + """A keyboard-accessible directory tree for file and folder selection.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + CSS = """ + FileBrowserModal { align: center middle; } + #file-browser-container { + width: 90; height: 90%; max-width: 90%; background: $surface; + border: thick $primary; padding: 1 2; + } + .browser-title { text-align: center; text-style: bold; color: $primary; } + .browser-copy { color: $text-muted; margin-top: 1; } + #file-browser-status { height: 1; color: $warning; margin-top: 1; } + #file-browser-tree { height: 1fr; margin-top: 1; border: round $primary; } + """ + + def __init__(self, start_path: Path, *, choose_directory: bool): + super().__init__() + self._start_path = start_path if start_path.is_dir() else Path.cwd() + self._current_path = self._start_path + self._choose_directory = choose_directory + self._selected_directory: Path | None = None + + def compose(self) -> ComposeResult: + with Vertical(id="file-browser-container"): + yield Static( + "Choose export directory" + if self._choose_directory + else "Choose import file", + classes="browser-title", + ) + yield Static( + "Use arrow keys to browse. Select .. to go up; expand folders with Right.", + classes="browser-copy", + ) + yield Static(str(self._start_path), id="file-browser-status") + yield ParentDirectoryTree(self._start_path, id="file-browser-tree") + with Horizontal(classes="button-row"): + yield Button("Cancel", id="cancel-button") + if self._choose_directory: + yield Button( + "Choose directory", + id="choose-directory-button", + variant="primary", + ) + + def on_mount(self) -> None: + self.query_one("#file-browser-tree", DirectoryTree).focus() + + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + if self._choose_directory: + self.query_one("#file-browser-status", Static).update( + "Choose a directory for the export destination." + ) + return + self.dismiss(event.path) + + def on_directory_tree_directory_selected( + self, event: DirectoryTree.DirectorySelected + ) -> None: + if str(event.node.label) == "..": + self._current_path = event.path + self._selected_directory = None + self.query_one("#file-browser-tree", DirectoryTree).path = event.path + self.query_one("#file-browser-status", Static).update(str(event.path)) + return + self._selected_directory = event.path + self.query_one("#file-browser-status", Static).update(str(event.path)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel-button": + self.action_cancel() + elif event.button.id == "choose-directory-button": + self.dismiss(self._selected_directory or self._current_path) + + def action_cancel(self) -> None: + self.dismiss(None) + + +def _format_label(format: ExportFormat | ImportFormat) -> str: + """Give the core-supported formats concise, file-oriented labels.""" + return { + "hosts": "Hosts file (.hosts)", + "json": "JSON (.json)", + "csv": "CSV (.csv)", + }[format.value] + + +def _entry_count(count: int, qualifier: str) -> str: + """Describe a qualified number of Host Entries with correct grammar.""" + noun = "Host Entry" if count == 1 else "Host Entries" + return f"{count} {qualifier} {noun}" + + +def _format_suffix(format: ExportFormat) -> str: + """Provide an editable default filename after choosing an export folder.""" + return { + ExportFormat.HOSTS: ".hosts", + ExportFormat.JSON: ".json", + ExportFormat.CSV: ".csv", + }[format] diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 20a7a25..4637241 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -34,6 +34,12 @@ HOSTS_MANAGER_BINDINGS = [ id="left:toggle_edit_mode", ), Binding("c", "config", "Configuration", show=True, id="right:config"), + Binding( + "ctrl+x", "export_entries", "Export Host Entries", show=False, priority=True + ), + Binding( + "ctrl+o", "import_entries", "Import Host Entries", show=False, priority=True + ), Binding( "ctrl+f", "show_filters", diff --git a/tests/test_import_export_workflow.py b/tests/test_import_export_workflow.py new file mode 100644 index 0000000..8e2a6a7 --- /dev/null +++ b/tests/test_import_export_workflow.py @@ -0,0 +1,378 @@ +"""User-visible import and export workflows.""" + +from unittest.mock import Mock + +import pytest +from rich.style import Style +from textual.containers import Vertical +from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static + +from hosts.core.import_export import ( + ExportFormat, + ExportResult, + ImportFormat, + ImportResult, +) +from hosts.core.models import HostEntry, HostsFile +from hosts.tui.app import HostsManagerApp +from hosts.tui.import_export_modal import ( + ExportModal, + FileBrowserModal, + ImportModal, + ReplaceImportConfirmationModal, +) + + +@pytest.mark.asyncio +async def test_export_shortcut_opens_a_format_chooser_and_reports_validation(): + """Export presents every core format and keeps invalid input visible.""" + app = HostsManagerApp() + app.hosts_file = HostsFile( + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["export.test"])] + ) + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+x") + await pilot.pause() + assert isinstance(app.screen, ExportModal) + assert [button.label.plain for button in app.screen.query(RadioButton)] == [ + "Hosts file (.hosts)", + "JSON (.json)", + "CSV (.csv)", + ] + + app.screen.query_one("#path-input", Input).value = "" + await pilot.click("#continue-button") + assert "Choose a file path" in str( + app.screen.query_one("#workflow-error", Static).render() + ) + + +@pytest.mark.asyncio +async def test_export_modal_is_centered_in_the_terminal_viewport(): + """The export form is centered rather than pinned to a terminal corner.""" + app = HostsManagerApp() + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+x") + await pilot.pause() + + container = app.screen.query_one("#format-container") + assert container.region.x == (app.size.width - container.region.width) // 2 + + assert container.region.y == (app.size.height - container.region.height) // 2 + + +@pytest.mark.asyncio +async def test_format_modal_uses_compact_consistent_form_sections(): + """Location and format fields use matching borders without excess rows.""" + app = HostsManagerApp() + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+x") + await pilot.pause() + + location = app.screen.query_one(".path-section", Vertical) + formats = app.screen.query_one(".format-section", Vertical) + path_input = app.screen.query_one("#path-input", Input) + browse = app.screen.query_one("#browse-button", Button) + format_select = app.screen.query_one("#format-select", RadioSet) + container = app.screen.query_one("#format-container") + + assert location.border_title == "Export file path" + assert path_input.region.height == 1 + assert browse.region.height == 1 + assert format_select.region.height == 3 + assert formats.region.height == 5 + assert container.region.height <= 22 + + +@pytest.mark.asyncio +async def test_export_browser_lets_the_user_choose_a_destination_directory(): + """Browse opens a centered directory tree rather than requiring a typed path.""" + app = HostsManagerApp() + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+x") + await pilot.pause() + browse = app.screen.query_one("#browse-button", Button) + path_input = app.screen.query_one("#path-input", Input) + container = app.screen.query_one("#format-container") + assert browse.region.width > 0 + assert path_input.region.height == 1 + assert browse.region.height == 1 + assert browse.region.x >= path_input.region.right + assert browse.region.y == path_input.region.y + assert browse.region.right <= container.region.right + assert browse.region.bottom <= container.region.bottom + await pilot.press("tab") + await pilot.press("enter") + await pilot.pause() + await pilot.pause() + + assert isinstance(app.screen, FileBrowserModal) + assert app.screen.query_one("#file-browser-tree") is not None + container = app.screen.query_one("#file-browser-container") + assert container.region.x == (app.size.width - container.region.width) // 2 + + await pilot.press("tab") + await pilot.press("tab") + await pilot.press("enter") + await pilot.pause() + await pilot.pause() + assert isinstance(app.screen, ExportModal) + assert app.screen.query_one("#path-input", Input).value.endswith( + "hosts-export.hosts" + ) + + +@pytest.mark.asyncio +async def test_file_browser_presents_parent_directory_as_dot_dot(tmp_path): + """The chooser exposes its parent as the first conventional tree entry.""" + child = tmp_path / "child" + child.mkdir() + app = HostsManagerApp() + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + app.push_screen(FileBrowserModal(child, choose_directory=True)) + await pilot.pause() + browser = app.screen + assert isinstance(browser, FileBrowserModal) + tree = browser.query_one("#file-browser-tree", DirectoryTree) + await pilot.pause() + + assert str(tree.root.children[0].label) == ".." + assert tree.render_label( + tree.root.children[0], Style(), Style() + ).plain.startswith("↑ ") + assert len(browser.query("#up-button")) == 0 + + tree.focus() + tree.select_node(tree.root.children[0]) + await pilot.pause() + + assert tree.path == tmp_path + + +@pytest.mark.asyncio +async def test_export_requires_overwrite_confirmation_before_calling_service(tmp_path): + """An existing output file is not replaced until the user confirms it.""" + target = tmp_path / "hosts.json" + target.write_text("existing", encoding="utf-8") + app = HostsManagerApp() + app.hosts_file = HostsFile( + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["export.test"])] + ) + app.import_export_service = Mock() + app.import_export_service.get_supported_export_formats.return_value = list( + ExportFormat + ) + app.import_export_service.validate_export_path.return_value = [ + f"File {target} already exists and will be overwritten" + ] + app.import_export_service.export_json_format.return_value = ExportResult( + success=True, + file_path=target, + entries_exported=1, + errors=[], + format=ExportFormat.JSON, + ) + + async with app.run_test(size=(120, 40)) as pilot: + app.action_export_entries() + await pilot.pause() + app.screen.query_one("#path-input", Input).value = str(target) + app.screen.query_one("#format-json", RadioButton).value = True + await pilot.click("#continue-button") + assert "already exists" in str( + app.screen.query_one("#workflow-warning", Static).render() + ) + app.import_export_service.export_json_format.assert_not_called() + + await pilot.click("#confirm-button") + await pilot.pause() + await pilot.pause() + + app.import_export_service.export_json_format.assert_called_once_with( + app.hosts_file, target + ) + + +@pytest.mark.asyncio +async def test_import_shortcut_requires_privileged_mode_and_surfaces_service_errors(): + """Import cannot mutate in Read-only Mode and reports parser failures.""" + app = HostsManagerApp() + app.hosts_file = HostsFile( + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])] + ) + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+o") + await pilot.pause() + assert not isinstance(app.screen, ImportModal) + assert "Read-only Mode" in str(app.query_one("#message-rail", Static).render()) + + app.edit_mode = True + app.manager.edit_mode = True + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + app.import_export_service.import_csv_format.return_value = Mock( + success=False, errors=["CSV column 'ip_address' is missing"] + ) + app.action_import_entries() + await pilot.pause() + assert isinstance(app.screen, ImportModal) + app.screen.query_one("#path-input", Input).value = "/tmp/broken.csv" + csv_button = app.screen.query_one("#format-csv", RadioButton) + csv_button.value = True + await pilot.click("#import-button") + await pilot.pause() + assert "CSV column 'ip_address' is missing" in str( + app.screen.query_one("#workflow-error", Static).render() + ) + + +@pytest.mark.asyncio +async def test_import_form_asks_how_to_apply_entries_at_minimum_supported_size(): + """Both import actions remain visible and reachable at 100 by 30.""" + app = HostsManagerApp() + app.edit_mode = True + app.manager.edit_mode = True + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + + async with app.run_test(size=(100, 30)) as pilot: + app.action_import_entries() + await pilot.pause() + + assert isinstance(app.screen, ImportModal) + actions = app.screen.query_one("#import-mode-select", RadioSet) + assert [button.label.plain for button in actions.query(RadioButton)] == [ + "Add to Hosts File", + "Replace Hosts File", + ] + assert app.screen.query_one("#mode-add", RadioButton).value + assert not app.screen.query_one("#mode-replace", RadioButton).value + container = app.screen.query_one("#format-container") + submit = app.screen.query_one("#import-button", Button) + assert submit.region.bottom <= container.region.bottom + + +@pytest.mark.asyncio +async def test_successful_import_adds_entries_and_saves_through_the_manager(): + """The safe default appends imported Host Entries to the Hosts File.""" + app = HostsManagerApp() + app.hosts_file = HostsFile( + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])], + header_comments=["existing header"], + footer_comments=["existing footer"], + ) + app.edit_mode = True + app.manager.edit_mode = True + app.load_hosts_file = Mock() + app.save_mutation = Mock(return_value=True) + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + imported = HostEntry(ip_address="198.51.100.3", hostnames=["imported.test"]) + app.import_export_service.import_json_format.return_value = ImportResult( + success=True, + entries=[imported], + errors=[], + warnings=[], + total_processed=1, + successfully_imported=1, + ) + + async with app.run_test(size=(120, 40)) as pilot: + app.action_import_entries() + await pilot.pause() + app.screen.query_one("#path-input", Input).value = "/tmp/entries.json" + app.screen.query_one("#format-json", RadioButton).value = True + await pilot.click("#import-button") + await pilot.pause() + await pilot.pause() + + assert [entry.hostnames for entry in app.hosts_file.entries] == [ + ["original.test"], + ["imported.test"], + ] + assert app.hosts_file.header_comments == ["existing header"] + assert app.hosts_file.footer_comments == ["existing footer"] + app.save_mutation.assert_called_once() + assert "Added and saved 1 Host Entry" in str( + app.query_one("#message-rail", Static).render() + ) + + +@pytest.mark.asyncio +async def test_replace_import_requires_explicit_destructive_confirmation(): + """Replacement cannot mutate until the warning modal is confirmed.""" + app = HostsManagerApp() + original = HostEntry(ip_address="192.0.2.10", hostnames=["original.test"]) + imported = HostEntry(ip_address="198.51.100.3", hostnames=["imported.test"]) + app.hosts_file = HostsFile(entries=[original]) + app.edit_mode = True + app.manager.edit_mode = True + app.load_hosts_file = Mock() + app.save_mutation = Mock(return_value=True) + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + app.import_export_service.import_json_format.return_value = ImportResult( + success=True, + entries=[imported], + errors=[], + warnings=[], + total_processed=1, + successfully_imported=1, + ) + + async with app.run_test(size=(120, 40)) as pilot: + + async def request_replacement() -> None: + app.action_import_entries() + await pilot.pause() + app.screen.query_one("#path-input", Input).value = "/tmp/entries.json" + app.screen.query_one("#format-json", RadioButton).value = True + app.screen.query_one("#mode-replace", RadioButton).value = True + await pilot.click("#import-button") + await pilot.pause() + + await request_replacement() + + assert isinstance(app.screen, ReplaceImportConfirmationModal) + assert app.focused is app.screen.query_one("#cancel-button", Button) + assert "remove all 1 current Host Entry" in str( + app.screen.query_one("#replace-warning", Static).render() + ) + assert app.hosts_file.entries == [original] + app.save_mutation.assert_not_called() + + await pilot.click("#cancel-button") + await pilot.pause() + assert app.hosts_file.entries == [original] + app.save_mutation.assert_not_called() + + await request_replacement() + await pilot.click("#replace-button") + await pilot.pause() + await pilot.pause() + + assert app.hosts_file.entries == [imported] + app.save_mutation.assert_called_once() + assert "Replaced Hosts File with 1 imported Host Entry" in str( + app.query_one("#message-rail", Static).render() + )