diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 35b0636..8b0a4ab 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -2,7 +2,7 @@ ## Current Work Focus -**Phase 3 Complete - Edit Mode Foundation**: The hosts TUI application now has a complete edit mode foundation with permission management, entry manipulation, and safe file operations. All keyboard shortcuts are implemented and tested. The application is ready for Phase 4 advanced edit features. +**Phase 2 Complete - Enhanced Read-Only Features**: The hosts TUI application now has a complete and polished read-only interface with advanced features including configuration management, sorting, filtering, and professional visual design. The application is ready for Phase 3 edit mode implementation. ## Recent Changes diff --git a/memory-bank/progress.md b/memory-bank/progress.md index a5b38cd..b371170 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -49,22 +49,13 @@ ## What's Left to Build -### Phase 3: Edit Mode Foundation ✅ COMPLETE -- ✅ **Permission management**: Sudo request and management with PermissionManager class -- ✅ **Edit mode toggle**: Switch between read-only and edit modes with 'e' key -- ✅ **Entry activation**: Toggle entries active/inactive with space bar -- ✅ **Entry reordering**: Move entries up/down with Shift+Up/Down (updated from Ctrl+Up/Down) -- ✅ **File backup**: Automatic backup before modifications with timestamp naming -- ✅ **Safe file operations**: Atomic file writing with rollback capability -- ✅ **Manager module**: Complete HostsManager class for edit operations -- ✅ **Error handling**: Comprehensive error handling with user feedback -- ✅ **Enhanced read-only error messages**: Clear, informative error messages with ❌ indicator and helpful instructions -- ✅ **Status bar positioning**: Fixed status bar layout to appear above footer for maximum visibility -- ✅ **Keyboard shortcuts**: All edit mode shortcuts implemented and tested -- ✅ **Live testing**: Manual testing confirms all functionality works correctly -- ✅ **Human-readable formatting**: Tab-based column alignment with proper spacing -- ✅ **Management header**: Automatic addition of management header to hosts files -- ✅ **Auto-save functionality**: Immediate saving of changes when entries are edited +### Phase 3: Edit Mode Foundation (Next) +- ❌ **Permission management**: Sudo request and management +- ❌ **Edit mode toggle**: Switch between read-only and edit modes +- ❌ **Entry activation**: Toggle entries active/inactive +- ❌ **Entry reordering**: Move entries up/down in the list +- ❌ **Entry editing**: Modify IP addresses, hostnames, comments +- ❌ **File backup**: Automatic backup before modifications ### Phase 4: Advanced Edit Features - ❌ **Add new entries**: Create new host entries @@ -89,9 +80,9 @@ ## Current Status ### Development Stage -**Stage**: Phase 3 Complete - Moving to Phase 4 -**Progress**: 75% (Complete edit mode foundation with permission management) -**Next Milestone**: Advanced edit features (add/delete entries, bulk operations) +**Stage**: Phase 2 Complete - Moving to Phase 3 +**Progress**: 60% (Complete read-only functionality with advanced features) +**Next Milestone**: Edit mode foundation with permission management ### Phase 2 Final Achievements 1. ✅ **Advanced configuration system**: Complete settings management with persistence @@ -103,27 +94,23 @@ 7. ✅ **Enhanced user experience**: Visual sort indicators and comprehensive status information 8. ✅ **Robust configuration**: JSON-based settings with graceful error handling -### Phase 3 Final Achievements ✅ COMPLETE -1. ✅ **Permission management**: Complete PermissionManager class with sudo request and validation -2. ✅ **Edit mode toggle**: Safe transition between read-only and edit modes with 'e' key -3. ✅ **Entry modification**: Toggle active/inactive status for entries with space bar -4. ✅ **File safety**: Automatic backup system with timestamp naming before modifications -5. ✅ **Entry reordering**: Move entries up/down with Ctrl+Up/Down keyboard shortcuts -6. ✅ **Manager module**: Complete HostsManager class for all edit operations -7. ✅ **Safe file operations**: Atomic file writing with rollback capability -8. ✅ **Enhanced error messages**: Professional read-only mode error messages with clear instructions -9. ✅ **Comprehensive testing**: 38 new tests for manager module (135 total tests) +### Phase 3 Immediate Priorities +1. **Permission management**: Implement sudo request and management system +2. **Edit mode toggle**: Safe transition between read-only and edit modes +3. **Entry modification**: Toggle active/inactive status for entries +4. **File safety**: Automatic backup system before any modifications +5. **Entry editing**: Modify IP addresses, hostnames, and comments ### Recent Major Accomplishments -- ✅ **Complete Phase 3 implementation**: Full edit mode foundation with permission management -- ✅ **Manager module**: PermissionManager and HostsManager classes with comprehensive functionality -- ✅ **Edit mode integration**: Seamless integration with main TUI application -- ✅ **Permission system**: Robust sudo request, validation, and release functionality -- ✅ **File backup system**: Automatic backup creation with timestamp naming -- ✅ **Entry manipulation**: Toggle active/inactive and reorder entries safely -- ✅ **Comprehensive testing**: 135 total tests with 100% pass rate including edit operations -- ✅ **Error handling**: Graceful handling of permission errors and file operations -- ✅ **Keyboard shortcuts**: Complete set of edit mode bindings (e, space, Ctrl+Up/Down, Ctrl+S) +- ✅ **Complete Phase 2 implementation**: All enhanced read-only features achieved +- ✅ **Advanced configuration system**: Complete settings management with modal interface +- ✅ **Professional DataTable interface**: Rich styling with interactive sorting +- ✅ **Intelligent entry filtering**: Hide/show default entries based on configuration +- ✅ **Complete sorting system**: Sort by IP and hostname with visual indicators +- ✅ **Enhanced visual design**: Color-coded entries and professional styling +- ✅ **Comprehensive testing**: 97 tests covering all functionality including new features +- ✅ **Modal dialog system**: Professional configuration interface with keyboard bindings +- ✅ **Settings persistence**: JSON-based configuration saved to user directory ## Technical Implementation Details @@ -207,43 +194,25 @@ ## Next Session Priorities -### Phase 4 Implementation Focus -1. **Add new entries**: Create new host entries with validation -2. **Delete entries**: Remove host entries with confirmation -3. **Entry editing**: Modify IP addresses, hostnames, and comments inline -4. **Bulk operations**: Select and modify multiple entries -5. **Input validation**: Real-time validation of IP addresses and hostnames +### Phase 3 Implementation Focus +1. **Permission management system**: Implement sudo request and validation +2. **Edit mode toggle**: Safe transition between read-only and edit modes +3. **Entry state modification**: Toggle entries active/inactive +4. **File backup system**: Automatic backup before any modifications +5. **Entry editing interface**: Modify IP addresses, hostnames, and comments -### Advanced Edit Features -1. **Entry creation modal**: Professional dialog for adding new entries -2. **Inline editing**: Edit entries directly in the table -3. **Multi-selection**: Select multiple entries for bulk operations -4. **Validation system**: Real-time IP and hostname validation -5. **Undo/Redo**: Command pattern for operation history +### Safety and Security +1. **Permission validation**: Ensure proper file access before edit mode +2. **Atomic operations**: Safe file writing with rollback capability +3. **Input validation**: Real-time validation of IP addresses and hostnames +4. **Backup management**: Automatic backup creation and restoration +5. **Error recovery**: Graceful handling of permission and file errors ### Documentation and Testing -1. **Advanced edit testing**: Comprehensive tests for add/delete/edit operations -2. **Validation testing**: Test IP address and hostname validation -3. **Bulk operation testing**: Test multi-selection and bulk modifications -4. **README updates**: Document new advanced edit capabilities -5. **User guide**: Complete documentation for all edit features - -## Phase 3 Complete Success Summary - -Phase 3 has been **exceptionally successful** with all objectives exceeded: - -- ✅ **Complete edit mode foundation**: Full permission management and safe edit operations -- ✅ **Permission system**: Robust PermissionManager with sudo request, validation, and release -- ✅ **Manager architecture**: Clean HostsManager class for all edit operations -- ✅ **Edit mode integration**: Seamless toggle between read-only and edit modes -- ✅ **Entry manipulation**: Toggle active/inactive status and reorder entries safely -- ✅ **File safety**: Automatic backup system with timestamp naming before modifications -- ✅ **Atomic operations**: Safe file writing with rollback capability -- ✅ **Comprehensive testing**: 38 new tests for manager module (135 total tests) -- ✅ **Error handling**: Graceful handling of permission errors and file operations -- ✅ **Keyboard shortcuts**: Complete set of edit mode bindings (e, space, Ctrl+Up/Down, Ctrl+S) - -The project now has a complete and safe edit mode foundation, perfectly positioned for Phase 4 advanced edit features implementation. +1. **Edit mode testing**: Comprehensive tests for modification operations +2. **Permission testing**: Mock sudo operations for test coverage +3. **README updates**: Document new edit mode capabilities +4. **User guide**: Safety instructions for edit mode usage ## Phase 2 Complete Success Summary diff --git a/src/hosts/core/manager.py b/src/hosts/core/manager.py deleted file mode 100644 index 905e466..0000000 --- a/src/hosts/core/manager.py +++ /dev/null @@ -1,425 +0,0 @@ -""" -Manager for hosts file edit operations. - -This module handles permission management, edit mode operations, -and safe file modifications with backup and validation. -""" - -import os -import subprocess -import tempfile -from pathlib import Path -from typing import Optional, Tuple -from .models import HostEntry, HostsFile -from .parser import HostsParser - - -class PermissionManager: - """ - Manages sudo permissions for hosts file editing. - - Handles requesting, validating, and releasing elevated permissions - needed for modifying the system hosts file. - """ - - def __init__(self): - self.has_sudo = False - self._sudo_validated = False - - def request_sudo(self) -> Tuple[bool, str]: - """ - Request sudo permissions for hosts file editing. - - Returns: - Tuple of (success, message) - """ - try: - # Test sudo access with a simple command - result = subprocess.run( - ['sudo', '-n', 'true'], - capture_output=True, - text=True, - timeout=5 - ) - - if result.returncode == 0: - # Already have sudo access - self.has_sudo = True - self._sudo_validated = True - return True, "Sudo access already available" - - # Need to prompt for password - result = subprocess.run( - ['sudo', '-v'], - capture_output=True, - text=True, - timeout=30 - ) - - if result.returncode == 0: - self.has_sudo = True - self._sudo_validated = True - return True, "Sudo access granted" - else: - return False, "Sudo access denied" - - except subprocess.TimeoutExpired: - return False, "Sudo request timed out" - except Exception as e: - return False, f"Error requesting sudo: {e}" - - def validate_permissions(self, file_path: str = "/etc/hosts") -> bool: - """ - Validate that we have write permissions to the hosts file. - - Args: - file_path: Path to the hosts file - - Returns: - True if we can write to the file - """ - if not self.has_sudo: - return False - - try: - # Test write access with sudo - result = subprocess.run( - ['sudo', '-n', 'test', '-w', file_path], - capture_output=True, - timeout=5 - ) - return result.returncode == 0 - except Exception: - return False - - def release_sudo(self) -> None: - """Release sudo permissions.""" - try: - subprocess.run(['sudo', '-k'], capture_output=True, timeout=5) - except Exception: - pass - finally: - self.has_sudo = False - self._sudo_validated = False - - -class HostsManager: - """ - Main manager for hosts file edit operations. - - Provides high-level operations for modifying hosts file entries - with proper permission management, validation, and backup. - """ - - def __init__(self, file_path: str = "/etc/hosts"): - self.parser = HostsParser(file_path) - self.permission_manager = PermissionManager() - self.edit_mode = False - self._backup_path: Optional[Path] = None - - def enter_edit_mode(self) -> Tuple[bool, str]: - """ - Enter edit mode with proper permission management. - - Returns: - Tuple of (success, message) - """ - if self.edit_mode: - return True, "Already in edit mode" - - # Request sudo permissions - success, message = self.permission_manager.request_sudo() - if not success: - return False, f"Cannot enter edit mode: {message}" - - # Validate write permissions - if not self.permission_manager.validate_permissions(str(self.parser.file_path)): - return False, "Cannot write to hosts file even with sudo" - - # Create backup - try: - self._create_backup() - self.edit_mode = True - return True, "Edit mode enabled" - except Exception as e: - return False, f"Failed to create backup: {e}" - - def exit_edit_mode(self) -> Tuple[bool, str]: - """ - Exit edit mode and release permissions. - - Returns: - Tuple of (success, message) - """ - if not self.edit_mode: - return True, "Already in read-only mode" - - try: - self.permission_manager.release_sudo() - self.edit_mode = False - self._backup_path = None - return True, "Edit mode disabled" - except Exception as e: - return False, f"Error exiting edit mode: {e}" - - def toggle_entry(self, hosts_file: HostsFile, index: int) -> Tuple[bool, str]: - """ - Toggle the active state of an entry. - - Args: - hosts_file: The hosts file to modify - index: Index of the entry to toggle - - Returns: - Tuple of (success, message) - """ - if not self.edit_mode: - return False, "Not in edit mode" - - if not (0 <= index < len(hosts_file.entries)): - return False, "Invalid entry index" - - try: - entry = hosts_file.entries[index] - - # Prevent modification of default system entries - if entry.is_default_entry(): - return False, "Cannot modify default system entries" - - old_state = "active" if entry.is_active else "inactive" - entry.is_active = not entry.is_active - new_state = "active" if entry.is_active else "inactive" - - return True, f"Entry toggled from {old_state} to {new_state}" - except Exception as e: - return False, f"Error toggling entry: {e}" - - def move_entry_up(self, hosts_file: HostsFile, index: int) -> Tuple[bool, str]: - """ - Move an entry up in the list. - - Args: - hosts_file: The hosts file to modify - index: Index of the entry to move - - Returns: - Tuple of (success, message) - """ - if not self.edit_mode: - return False, "Not in edit mode" - - if index <= 0 or index >= len(hosts_file.entries): - return False, "Cannot move entry up" - - try: - entry = hosts_file.entries[index] - target_entry = hosts_file.entries[index - 1] - - # Prevent moving default system entries or moving entries above default entries - if entry.is_default_entry() or target_entry.is_default_entry(): - return False, "Cannot move default system entries" - - # Swap with previous entry - hosts_file.entries[index], hosts_file.entries[index - 1] = \ - hosts_file.entries[index - 1], hosts_file.entries[index] - return True, "Entry moved up" - except Exception as e: - return False, f"Error moving entry: {e}" - - def move_entry_down(self, hosts_file: HostsFile, index: int) -> Tuple[bool, str]: - """ - Move an entry down in the list. - - Args: - hosts_file: The hosts file to modify - index: Index of the entry to move - - Returns: - Tuple of (success, message) - """ - if not self.edit_mode: - return False, "Not in edit mode" - - if index < 0 or index >= len(hosts_file.entries) - 1: - return False, "Cannot move entry down" - - try: - entry = hosts_file.entries[index] - target_entry = hosts_file.entries[index + 1] - - # Prevent moving default system entries or moving entries below default entries - if entry.is_default_entry() or target_entry.is_default_entry(): - return False, "Cannot move default system entries" - - # Swap with next entry - hosts_file.entries[index], hosts_file.entries[index + 1] = \ - hosts_file.entries[index + 1], hosts_file.entries[index] - return True, "Entry moved down" - except Exception as e: - return False, f"Error moving entry: {e}" - - def update_entry(self, hosts_file: HostsFile, index: int, - ip_address: str, hostnames: list[str], - comment: Optional[str] = None) -> Tuple[bool, str]: - """ - Update an existing entry. - - Args: - hosts_file: The hosts file to modify - index: Index of the entry to update - ip_address: New IP address - hostnames: New list of hostnames - comment: New comment (optional) - - Returns: - Tuple of (success, message) - """ - if not self.edit_mode: - return False, "Not in edit mode" - - if not (0 <= index < len(hosts_file.entries)): - return False, "Invalid entry index" - - try: - entry = hosts_file.entries[index] - - # Prevent modification of default system entries - if entry.is_default_entry(): - return False, "Cannot modify default system entries" - - # Create new entry to validate - new_entry = HostEntry( - ip_address=ip_address, - hostnames=hostnames, - comment=comment, - is_active=hosts_file.entries[index].is_active, - dns_name=hosts_file.entries[index].dns_name - ) - - # Replace the entry - hosts_file.entries[index] = new_entry - return True, "Entry updated successfully" - - except ValueError as e: - return False, f"Invalid entry data: {e}" - except Exception as e: - return False, f"Error updating entry: {e}" - - def save_hosts_file(self, hosts_file: HostsFile) -> Tuple[bool, str]: - """ - Save the hosts file to disk with sudo permissions. - - Args: - hosts_file: The hosts file to save - - Returns: - Tuple of (success, message) - """ - if not self.edit_mode: - return False, "Not in edit mode" - - if not self.permission_manager.has_sudo: - return False, "No sudo permissions" - - try: - # Serialize the hosts file - content = self.parser.serialize(hosts_file) - - # Write to temporary file first - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.hosts') as temp_file: - temp_file.write(content) - temp_path = temp_file.name - - try: - # Use sudo to copy the temp file to the hosts file - result = subprocess.run( - ['sudo', 'cp', temp_path, str(self.parser.file_path)], - capture_output=True, - text=True, - timeout=10 - ) - - if result.returncode == 0: - return True, "Hosts file saved successfully" - else: - return False, f"Failed to save hosts file: {result.stderr}" - - finally: - # Clean up temp file - try: - os.unlink(temp_path) - except Exception: - pass - - except Exception as e: - return False, f"Error saving hosts file: {e}" - - def restore_backup(self) -> Tuple[bool, str]: - """ - Restore the hosts file from backup. - - Returns: - Tuple of (success, message) - """ - if not self.edit_mode: - return False, "Not in edit mode" - - if not self._backup_path or not self._backup_path.exists(): - return False, "No backup available" - - try: - result = subprocess.run( - ['sudo', 'cp', str(self._backup_path), str(self.parser.file_path)], - capture_output=True, - text=True, - timeout=10 - ) - - if result.returncode == 0: - return True, "Backup restored successfully" - else: - return False, f"Failed to restore backup: {result.stderr}" - - except Exception as e: - return False, f"Error restoring backup: {e}" - - def _create_backup(self) -> None: - """Create a backup of the current hosts file.""" - if not self.parser.file_path.exists(): - return - - # Create backup in temp directory - backup_dir = Path(tempfile.gettempdir()) / "hosts-manager-backups" - backup_dir.mkdir(exist_ok=True) - - import time - timestamp = int(time.time()) - self._backup_path = backup_dir / f"hosts.backup.{timestamp}" - - # Copy current hosts file to backup - result = subprocess.run( - ['sudo', 'cp', str(self.parser.file_path), str(self._backup_path)], - capture_output=True, - timeout=10 - ) - - if result.returncode != 0: - raise Exception(f"Failed to create backup: {result.stderr}") - - # Make backup readable by user - subprocess.run(['sudo', 'chmod', '644', str(self._backup_path)], capture_output=True) - - -class EditModeError(Exception): - """Base exception for edit mode errors.""" - pass - - -class PermissionError(EditModeError): - """Raised when there are permission issues.""" - pass - - -class ValidationError(EditModeError): - """Raised when validation fails.""" - pass diff --git a/src/hosts/core/models.py b/src/hosts/core/models.py index 35ec529..c1ec12c 100644 --- a/src/hosts/core/models.py +++ b/src/hosts/core/models.py @@ -33,28 +33,6 @@ class HostEntry: """Validate the entry after initialization.""" self.validate() - def is_default_entry(self) -> bool: - """ - Check if this entry is a system default entry. - - Returns: - True if this is a default system entry (localhost, broadcasthost, ::1) - """ - if not self.hostnames: - return False - - canonical_hostname = self.hostnames[0] - default_entries = [ - {"ip": "127.0.0.1", "hostname": "localhost"}, - {"ip": "255.255.255.255", "hostname": "broadcasthost"}, - {"ip": "::1", "hostname": "localhost"}, - ] - - for entry in default_entries: - if entry["ip"] == self.ip_address and entry["hostname"] == canonical_hostname: - return True - return False - def validate(self) -> None: """ Validate the host entry data. @@ -80,71 +58,28 @@ class HostEntry: if not hostname_pattern.match(hostname): raise ValueError(f"Invalid hostname '{hostname}'") - def to_hosts_line(self, ip_width: int = 0, hostname_width: int = 0) -> str: + def to_hosts_line(self) -> str: """ - Convert this entry to a hosts file line with proper tab alignment. - - Args: - ip_width: Width of the IP address column for alignment - hostname_width: Width of the canonical hostname column for alignment + Convert this entry to a hosts file line. Returns: String representation suitable for writing to hosts file """ line_parts = [] - # Build the IP address part (with comment prefix if inactive) - ip_part = "" + # Add comment prefix if inactive if not self.is_active: - ip_part = "# " - ip_part += self.ip_address + line_parts.append("#") - # Calculate tabs needed for IP column alignment - ip_tabs = self._calculate_tabs_needed(len(ip_part), ip_width) - - # Build the canonical hostname part - canonical_hostname = self.hostnames[0] if self.hostnames else "" - hostname_tabs = self._calculate_tabs_needed(len(canonical_hostname), hostname_width) - - # Start building the line - line_parts.append(ip_part) - line_parts.append("\t" * max(1, ip_tabs)) # At least one tab - line_parts.append(canonical_hostname) - - # Add additional hostnames (aliases) with single tab separation - if len(self.hostnames) > 1: - line_parts.append("\t" * max(1, hostname_tabs)) - line_parts.append("\t".join(self.hostnames[1:])) + # Add IP and hostnames + line_parts.append(self.ip_address) + line_parts.extend(self.hostnames) # Add comment if present if self.comment: - if len(self.hostnames) <= 1: - line_parts.append("\t" * max(1, hostname_tabs)) - else: - line_parts.append("\t") line_parts.append(f"# {self.comment}") - return "".join(line_parts) - - def _calculate_tabs_needed(self, current_length: int, target_width: int) -> int: - """ - Calculate number of tabs needed to reach target column width. - - Args: - current_length: Current string length - target_width: Target column width - - Returns: - Number of tabs needed (minimum 1) - """ - if target_width <= current_length: - return 1 - - # Calculate tabs needed (assuming tab width of 8) - tab_width = 8 - remaining_space = target_width - current_length - tabs_needed = (remaining_space + tab_width - 1) // tab_width # Ceiling division - return max(1, tabs_needed) + return " ".join(line_parts) @classmethod def from_hosts_line(cls, line: str) -> Optional['HostEntry']: @@ -171,10 +106,8 @@ class HostEntry: if not line or line.startswith('#'): return None - # Split line into parts, handling both spaces and tabs - import re - # Split on any whitespace (spaces, tabs, or combinations) - parts = re.split(r'\s+', line.strip()) + # Split line into parts + parts = line.split() if len(parts) < 2: return None @@ -243,89 +176,13 @@ class HostsFile: """Get all inactive entries.""" return [entry for entry in self.entries if not entry.is_active] - def sort_by_ip(self, ascending: bool = True) -> None: - """ - Sort entries by IP address, keeping default entries on top in fixed order. - - Args: - ascending: Sort in ascending order if True, descending if False - """ - # Separate default and non-default entries - default_entries = [entry for entry in self.entries if entry.is_default_entry()] - non_default_entries = [entry for entry in self.entries if not entry.is_default_entry()] - - def ip_sort_key(entry): - try: - ip_str = entry.ip_address.lstrip('# ') - ip_obj = ipaddress.ip_address(ip_str) - # Create a tuple for sorting: (version, ip_int) - return (ip_obj.version, int(ip_obj)) - except ValueError: - # If IP parsing fails, use string comparison - return (999, entry.ip_address) - - # Keep default entries in their natural fixed order (don't sort them) - # Define the fixed order for default entries - default_order = [ - {"ip": "127.0.0.1", "hostname": "localhost"}, - {"ip": "255.255.255.255", "hostname": "broadcasthost"}, - {"ip": "::1", "hostname": "localhost"}, - ] - - # Sort default entries according to their fixed order - def default_sort_key(entry): - for i, default in enumerate(default_order): - if (entry.ip_address == default["ip"] and - entry.hostnames and entry.hostnames[0] == default["hostname"]): - return i - return 999 # fallback for any unexpected default entries - - default_entries.sort(key=default_sort_key) - - # Sort non-default entries according to the specified direction - non_default_entries.sort(key=ip_sort_key, reverse=not ascending) - - # Combine: default entries always first, then sorted non-default entries - self.entries = default_entries + non_default_entries + def sort_by_ip(self) -> None: + """Sort entries by IP address.""" + self.entries.sort(key=lambda entry: ipaddress.ip_address(entry.ip_address)) - def sort_by_hostname(self, ascending: bool = True) -> None: - """ - Sort entries by first hostname, keeping default entries on top in fixed order. - - Args: - ascending: Sort in ascending order if True, descending if False - """ - # Separate default and non-default entries - default_entries = [entry for entry in self.entries if entry.is_default_entry()] - non_default_entries = [entry for entry in self.entries if not entry.is_default_entry()] - - def hostname_sort_key(entry): - hostname = (entry.hostnames[0] if entry.hostnames else "").lower() - return hostname - - # Keep default entries in their natural fixed order (don't sort them) - # Define the fixed order for default entries - default_order = [ - {"ip": "127.0.0.1", "hostname": "localhost"}, - {"ip": "255.255.255.255", "hostname": "broadcasthost"}, - {"ip": "::1", "hostname": "localhost"}, - ] - - # Sort default entries according to their fixed order - def default_sort_key(entry): - for i, default in enumerate(default_order): - if (entry.ip_address == default["ip"] and - entry.hostnames and entry.hostnames[0] == default["hostname"]): - return i - return 999 # fallback for any unexpected default entries - - default_entries.sort(key=default_sort_key) - - # Sort non-default entries according to the specified direction - non_default_entries.sort(key=hostname_sort_key, reverse=not ascending) - - # Combine: default entries always first, then sorted non-default entries - self.entries = default_entries + non_default_entries + def sort_by_hostname(self) -> None: + """Sort entries by first hostname.""" + self.entries.sort(key=lambda entry: entry.hostnames[0].lower()) def find_entries_by_hostname(self, hostname: str) -> List[int]: """ diff --git a/src/hosts/core/parser.py b/src/hosts/core/parser.py index 5407a84..bcfa34b 100644 --- a/src/hosts/core/parser.py +++ b/src/hosts/core/parser.py @@ -82,33 +82,28 @@ class HostsParser: def serialize(self, hosts_file: HostsFile) -> str: """ - Convert a HostsFile object back to hosts file format with proper column alignment. + Convert a HostsFile object back to hosts file format. Args: hosts_file: HostsFile object to serialize Returns: - String representation of the hosts file with tab-aligned columns + String representation of the hosts file """ lines = [] - # Ensure header has management line - header_comments = self._ensure_management_header(hosts_file.header_comments) - # Add header comments - if header_comments: - for comment in header_comments: + if hosts_file.header_comments: + for comment in hosts_file.header_comments: if comment.strip(): lines.append(f"# {comment}") else: lines.append("#") + lines.append("") # Blank line after header - # Calculate column widths for proper alignment - ip_width, hostname_width = self._calculate_column_widths(hosts_file.entries) - - # Add host entries with proper column alignment + # Add host entries for entry in hosts_file.entries: - lines.append(entry.to_hosts_line(ip_width, hostname_width)) + lines.append(entry.to_hosts_line()) # Add footer comments if hosts_file.footer_comments: @@ -121,165 +116,6 @@ class HostsParser: return "\n".join(lines) + "\n" - def _ensure_management_header(self, header_comments: list) -> list: - """ - Ensure the header contains the management line with proper formatting. - - Args: - header_comments: List of existing header comments - - Returns: - List of header comments with management line added if needed - """ - management_line = "Managed by hosts - https://git.s1q.dev/phg/hosts" - - # Check if management line already exists - for comment in header_comments: - if "git.s1q.dev/phg/hosts" in comment: - return header_comments - - # If no header exists, create default header - if not header_comments: - return [ - "#", - "Host Database", - "", - management_line, - "#" - ] - - # Check for enclosing comment patterns - enclosing_pattern = self._detect_enclosing_pattern(header_comments) - - if enclosing_pattern: - # Insert management line within the enclosing pattern - return self._insert_in_enclosing_pattern(header_comments, management_line, enclosing_pattern) - else: - # No enclosing pattern, append management line - result = header_comments.copy() - result.append(management_line) - return result - - def _detect_enclosing_pattern(self, header_comments: list) -> dict | None: - """ - Detect if header has enclosing comment patterns like ###, # #, etc. - - Args: - header_comments: List of header comments - - Returns: - Dictionary with pattern info or None if no pattern detected - """ - if len(header_comments) < 2: - return None - - # Look for matching patterns at start and end, ignoring management line if present - first_line = header_comments[0].strip() - - # Find the last line that could be a closing pattern (not the management line) - last_pattern_index = -1 - for i in range(len(header_comments) - 1, -1, -1): - line = header_comments[i].strip() - if "git.s1q.dev/phg/hosts" not in line: - last_pattern_index = i - break - - if last_pattern_index <= 0: - return None - - last_line = header_comments[last_pattern_index].strip() - - # Check for ### pattern - if first_line == "###" and last_line == "###": - return { - 'type': 'triple_hash', - 'start_index': 0, - 'end_index': last_pattern_index, - 'pattern': '###' - } - - # Check for # # pattern - if first_line == "#" and last_line == "#": - return { - 'type': 'single_hash', - 'start_index': 0, - 'end_index': last_pattern_index, - 'pattern': '#' - } - - # Check for other repeating patterns (like ####, #####, etc.) - if len(first_line) > 1 and first_line == last_line and all(c == '#' for c in first_line): - return { - 'type': 'repeating_hash', - 'start_index': 0, - 'end_index': last_pattern_index, - 'pattern': first_line - } - - return None - - def _insert_in_enclosing_pattern(self, header_comments: list, management_line: str, pattern_info: dict) -> list: - """ - Insert management line within an enclosing comment pattern. - - Args: - header_comments: List of header comments - management_line: Management line to insert - pattern_info: Information about the enclosing pattern - - Returns: - Updated list of header comments - """ - result = header_comments.copy() - - # Find the best insertion point (before the closing pattern) - insert_index = pattern_info['end_index'] - - # Look for an empty line before the closing pattern to insert after it - # Otherwise, insert right before the closing pattern - if insert_index > 1 and header_comments[insert_index - 1].strip() == "": - # Insert after the empty line, before closing pattern - result.insert(insert_index, management_line) - else: - # Insert empty line and management line before closing pattern - result.insert(insert_index, "") - result.insert(insert_index + 1, management_line) - - return result - - def _calculate_column_widths(self, entries: list) -> tuple[int, int]: - """ - Calculate the maximum width needed for IP and hostname columns. - - Args: - entries: List of HostEntry objects - - Returns: - Tuple of (ip_width, hostname_width) - """ - max_ip_width = 0 - max_hostname_width = 0 - - for entry in entries: - # Calculate IP column width (including comment prefix for inactive entries) - ip_part = "" - if not entry.is_active: - ip_part = "# " - ip_part += entry.ip_address - max_ip_width = max(max_ip_width, len(ip_part)) - - # Calculate canonical hostname width - if entry.hostnames: - canonical_hostname = entry.hostnames[0] - max_hostname_width = max(max_hostname_width, len(canonical_hostname)) - - # Round up to next tab stop (8-character boundaries) for better alignment - tab_width = 8 - ip_width = ((max_ip_width + tab_width - 1) // tab_width) * tab_width - hostname_width = ((max_hostname_width + tab_width - 1) // tab_width) * tab_width - - return ip_width, hostname_width - def write(self, hosts_file: HostsFile, backup: bool = True) -> None: """ Write a HostsFile object to the hosts file. diff --git a/src/hosts/main.py b/src/hosts/main.py index 4df0d95..a31bab0 100644 --- a/src/hosts/main.py +++ b/src/hosts/main.py @@ -14,7 +14,6 @@ from rich.text import Text from .core.parser import HostsParser from .core.models import HostsFile from .core.config import Config -from .core.manager import HostsManager from .tui.config_modal import ConfigModal @@ -28,7 +27,7 @@ class HostsManagerApp(App): CSS = """ .hosts-container { - height: 1fr; + height: 100%; } .left-pane { @@ -59,16 +58,6 @@ class HostsManagerApp(App): color: $text; height: 1; padding: 0 1; - dock: bottom; - } - - .status-error { - background: $error; - color: $text; - height: 1; - padding: 0 1; - text-style: bold; - dock: bottom; } /* DataTable styling to match background */ @@ -98,11 +87,6 @@ class HostsManagerApp(App): Binding("i", "sort_by_ip", "Sort by IP"), Binding("n", "sort_by_hostname", "Sort by Hostname"), Binding("c", "config", "Config"), - Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"), - Binding("space", "toggle_entry", "Toggle Entry", show=False), - Binding("ctrl+s", "save_file", "Save", show=False), - Binding("shift+up", "move_entry_up", "Move Up", show=False), - Binding("shift+down", "move_entry_down", "Move Down", show=False), ("ctrl+c", "quit", "Quit"), ] @@ -117,7 +101,6 @@ class HostsManagerApp(App): super().__init__() self.parser = HostsParser() self.config = Config() - self.manager = HostsManager() self.title = "Hosts Manager" self.sub_title = "Read-only mode" @@ -125,22 +108,20 @@ class HostsManagerApp(App): """Create the application layout.""" yield Header() - with Vertical(): - with Horizontal(classes="hosts-container"): - left_pane = Vertical(classes="left-pane") - left_pane.border_title = "Hosts Entries" - with left_pane: - yield DataTable(id="entries-table") - yield left_pane - - right_pane = Vertical(classes="right-pane") - right_pane.border_title = "Entry Details" - with right_pane: - yield Static("", id="entry-details") - yield right_pane + with Horizontal(classes="hosts-container"): + left_pane = Vertical(classes="left-pane") + left_pane.border_title = "Hosts Entries" + with left_pane: + yield DataTable(id="entries-table") + yield left_pane - yield Static("", classes="status-bar", id="status") + right_pane = Vertical(classes="right-pane") + right_pane.border_title = "Entry Details" + with right_pane: + yield Static("", id="entry-details") + yield right_pane + yield Static("", classes="status-bar", id="status") yield Footer() def on_ready(self) -> None: @@ -174,64 +155,6 @@ class HostsManagerApp(App): self.log(f"Error loading hosts file: {e}") self.update_status(f"Error: {e}") - def get_visible_entries(self) -> list: - """Get the list of entries that are visible in the table (after filtering).""" - show_defaults = self.config.should_show_default_entries() - visible_entries = [] - - for entry in self.hosts_file.entries: - canonical_hostname = entry.hostnames[0] if entry.hostnames else "" - # Skip default entries if configured to hide them - if not show_defaults and self.config.is_default_entry(entry.ip_address, canonical_hostname): - continue - visible_entries.append(entry) - - return visible_entries - - def get_first_visible_entry_index(self) -> int: - """Get the index of the first visible entry in the hosts file.""" - show_defaults = self.config.should_show_default_entries() - - for i, entry in enumerate(self.hosts_file.entries): - canonical_hostname = entry.hostnames[0] if entry.hostnames else "" - # Skip default entries if configured to hide them - if not show_defaults and self.config.is_default_entry(entry.ip_address, canonical_hostname): - continue - return i - - # If no visible entries found, return 0 - return 0 - - def display_index_to_actual_index(self, display_index: int) -> int: - """Convert a display table index to the actual hosts file entry index.""" - visible_entries = self.get_visible_entries() - if display_index >= len(visible_entries): - return 0 - - target_entry = visible_entries[display_index] - - # Find this entry in the full hosts file - for i, entry in enumerate(self.hosts_file.entries): - if entry is target_entry: - return i - - return 0 - - def actual_index_to_display_index(self, actual_index: int) -> int: - """Convert an actual hosts file entry index to a display table index.""" - if actual_index >= len(self.hosts_file.entries): - return 0 - - target_entry = self.hosts_file.entries[actual_index] - visible_entries = self.get_visible_entries() - - # Find this entry in the visible entries - for i, entry in enumerate(visible_entries): - if entry is target_entry: - return i - - return 0 - def populate_entries_table(self) -> None: """Populate the left pane with hosts entries using DataTable.""" table = self.query_one("#entries-table", DataTable) @@ -258,25 +181,20 @@ class HostsManagerApp(App): # Add columns with proper labels (Active column first) table.add_columns(active_label, ip_label, hostname_label) - # Get visible entries (after filtering) - visible_entries = self.get_visible_entries() + # Filter entries based on configuration + show_defaults = self.config.should_show_default_entries() # Add rows - for entry in visible_entries: + for entry in self.hosts_file.entries: # Get the canonical hostname (first hostname) canonical_hostname = entry.hostnames[0] if entry.hostnames else "" - # Check if this is a default system entry - is_default = entry.is_default_entry() + # Skip default entries if configured to hide them + if not show_defaults and self.config.is_default_entry(entry.ip_address, canonical_hostname): + continue - # Add row with styling based on active status and default entry status - if is_default: - # Default entries are always shown in dim grey regardless of active status - active_text = Text("✓" if entry.is_active else "", style="dim white") - ip_text = Text(entry.ip_address, style="dim white") - hostname_text = Text(canonical_hostname, style="dim white") - table.add_row(active_text, ip_text, hostname_text) - elif entry.is_active: + # Add row with styling based on active status + if entry.is_active: # Active entries in green with checkmark active_text = Text("✓", style="bold green") ip_text = Text(entry.ip_address, style="bold green") @@ -296,8 +214,8 @@ class HostsManagerApp(App): return if previous_entry is None: - # No previous selection, start at first visible entry - self.selected_entry_index = self.get_first_visible_entry_index() + # No previous selection, start at first entry + self.selected_entry_index = 0 else: # Try to find the same entry in the reloaded file for i, entry in enumerate(self.hosts_file.entries): @@ -307,15 +225,14 @@ class HostsManagerApp(App): self.selected_entry_index = i break else: - # Entry not found, default to first visible entry - self.selected_entry_index = self.get_first_visible_entry_index() + # Entry not found, default to first entry + self.selected_entry_index = 0 - # Update the DataTable cursor position using display index + # Update the DataTable cursor position table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index(self.selected_entry_index) - if table.row_count > 0 and display_index < table.row_count: + if table.row_count > 0 and self.selected_entry_index < table.row_count: # Move cursor to the selected row - table.move_cursor(row=display_index) + table.move_cursor(row=self.selected_entry_index) table.focus() # Update the details pane to match the selection self.update_entry_details() @@ -328,27 +245,6 @@ class HostsManagerApp(App): details_widget.update("No entries loaded") return - # Get visible entries to check if we need to adjust selection - visible_entries = self.get_visible_entries() - if not visible_entries: - details_widget.update("No visible entries") - return - - # If default entries are hidden and selected_entry_index points to a hidden entry, - # we need to find the corresponding visible entry - show_defaults = self.config.should_show_default_entries() - if not show_defaults: - # Check if the currently selected entry is a default entry (hidden) - if (self.selected_entry_index < len(self.hosts_file.entries) and - self.hosts_file.entries[self.selected_entry_index].is_default_entry()): - # The selected entry is hidden, so we should show the first visible entry instead - if visible_entries: - # Find the first visible entry in the hosts file - for i, entry in enumerate(self.hosts_file.entries): - if not entry.is_default_entry(): - self.selected_entry_index = i - break - if self.selected_entry_index >= len(self.hosts_file.entries): self.selected_entry_index = 0 @@ -360,12 +256,6 @@ class HostsManagerApp(App): f"Status: {'Active' if entry.is_active else 'Inactive'}", ] - # Add notice for default system entries - if entry.is_default_entry(): - details_lines.append("") - details_lines.append("⚠️ SYSTEM DEFAULT ENTRY") - details_lines.append("This is a default system entry and cannot be modified.") - if entry.comment: details_lines.append(f"Comment: {entry.comment}") @@ -379,26 +269,8 @@ class HostsManagerApp(App): status_widget = self.query_one("#status", Static) if message: - # Check if this is an error message (starts with ❌) - if message.startswith("❌"): - # Use error styling for error messages - status_widget.remove_class("status-bar") - status_widget.add_class("status-error") - status_widget.update(message) - # Auto-clear error message after 5 seconds - self.set_timer(5.0, lambda: self.update_status()) - else: - # Use normal styling for regular messages - status_widget.remove_class("status-error") - status_widget.add_class("status-bar") - status_widget.update(message) - # Auto-clear regular message after 3 seconds - self.set_timer(3.0, lambda: self.update_status()) + status_widget.update(message) else: - # Reset to normal status display - status_widget.remove_class("status-error") - status_widget.add_class("status-bar") - mode = "Edit mode" if self.edit_mode else "Read-only mode" entry_count = len(self.hosts_file.entries) active_count = len(self.hosts_file.get_active_entries()) @@ -415,22 +287,17 @@ class HostsManagerApp(App): def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: """Handle row highlighting (cursor movement) in the DataTable.""" if event.data_table.id == "entries-table": - # Convert display index to actual index - self.selected_entry_index = self.display_index_to_actual_index(event.cursor_row) + self.selected_entry_index = event.cursor_row self.update_entry_details() def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: """Handle row selection in the DataTable.""" if event.data_table.id == "entries-table": - # Convert display index to actual index - self.selected_entry_index = self.display_index_to_actual_index(event.cursor_row) + self.selected_entry_index = event.cursor_row self.update_entry_details() def action_reload(self) -> None: """Reload the hosts file.""" - # Reset sort state on reload - self.sort_column = "" - self.sort_ascending = True self.load_hosts_file() self.update_status("Hosts file reloaded") @@ -459,8 +326,20 @@ class HostsManagerApp(App): self.sort_column = "ip" self.sort_ascending = True - # Sort the entries using the new method that keeps defaults on top - self.hosts_file.sort_by_ip(self.sort_ascending) + # Sort the entries + import ipaddress + def ip_sort_key(entry): + try: + ip_str = entry.ip_address.lstrip('# ') + ip_obj = ipaddress.ip_address(ip_str) + # Create a tuple for sorting: (version, ip_int) + # This ensures IPv4 comes before IPv6, and within each version they're sorted numerically + return (ip_obj.version, int(ip_obj)) + except ValueError: + # If IP parsing fails, use string comparison with high sort priority + return (999, entry.ip_address) + + self.hosts_file.entries.sort(key=ip_sort_key, reverse=not self.sort_ascending) self.populate_entries_table() direction = "ascending" if self.sort_ascending else "descending" @@ -475,8 +354,11 @@ class HostsManagerApp(App): self.sort_column = "hostname" self.sort_ascending = True - # Sort the entries using the new method that keeps defaults on top - self.hosts_file.sort_by_hostname(self.sort_ascending) + # Sort the entries + self.hosts_file.entries.sort( + key=lambda entry: (entry.hostnames[0] if entry.hostnames else "").lower(), + reverse=not self.sort_ascending + ) self.populate_entries_table() direction = "ascending" if self.sort_ascending else "descending" @@ -491,134 +373,8 @@ class HostsManagerApp(App): elif "Canonical Hostname" in str(event.column_key): self.action_sort_by_hostname() - def action_toggle_edit_mode(self) -> None: - """Toggle between read-only and edit mode.""" - if self.edit_mode: - # Exit edit mode - success, message = self.manager.exit_edit_mode() - if success: - self.edit_mode = False - self.sub_title = "Read-only mode" - self.update_status(message) - else: - self.update_status(f"Error exiting edit mode: {message}") - else: - # Enter edit mode - success, message = self.manager.enter_edit_mode() - if success: - self.edit_mode = True - self.sub_title = "Edit mode" - self.update_status(message) - else: - self.update_status(f"Error entering edit mode: {message}") - - def action_toggle_entry(self) -> None: - """Toggle the active state of the selected entry.""" - if not self.edit_mode: - self.update_status("❌ Cannot toggle entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.") - return - - if not self.hosts_file.entries: - self.update_status("No entries to toggle") - return - - # Remember current entry for cursor position restoration - current_entry = self.hosts_file.entries[self.selected_entry_index] - - success, message = self.manager.toggle_entry(self.hosts_file, self.selected_entry_index) - if success: - # Auto-save the changes immediately - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) - if save_success: - self.populate_entries_table() - # Restore cursor position to the same entry - self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry)) - self.update_entry_details() - self.update_status(f"{message} - Changes saved automatically") - else: - self.update_status(f"Entry toggled but save failed: {save_message}") - else: - self.update_status(f"Error toggling entry: {message}") - - def action_move_entry_up(self) -> None: - """Move the selected entry up in the list.""" - if not self.edit_mode: - self.update_status("❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.") - return - - if not self.hosts_file.entries: - self.update_status("No entries to move") - return - - success, message = self.manager.move_entry_up(self.hosts_file, self.selected_entry_index) - if success: - # Auto-save the changes immediately - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) - if save_success: - # Update the selection index to follow the moved entry - if self.selected_entry_index > 0: - self.selected_entry_index -= 1 - self.populate_entries_table() - # Update the DataTable cursor position to follow the moved entry - table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index(self.selected_entry_index) - if table.row_count > 0 and display_index < table.row_count: - table.move_cursor(row=display_index) - self.update_entry_details() - self.update_status(f"{message} - Changes saved automatically") - else: - self.update_status(f"Entry moved but save failed: {save_message}") - else: - self.update_status(f"Error moving entry: {message}") - - def action_move_entry_down(self) -> None: - """Move the selected entry down in the list.""" - if not self.edit_mode: - self.update_status("❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.") - return - - if not self.hosts_file.entries: - self.update_status("No entries to move") - return - - success, message = self.manager.move_entry_down(self.hosts_file, self.selected_entry_index) - if success: - # Auto-save the changes immediately - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) - if save_success: - # Update the selection index to follow the moved entry - if self.selected_entry_index < len(self.hosts_file.entries) - 1: - self.selected_entry_index += 1 - self.populate_entries_table() - # Update the DataTable cursor position to follow the moved entry - table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index(self.selected_entry_index) - if table.row_count > 0 and display_index < table.row_count: - table.move_cursor(row=display_index) - self.update_entry_details() - self.update_status(f"{message} - Changes saved automatically") - else: - self.update_status(f"Entry moved but save failed: {save_message}") - else: - self.update_status(f"Error moving entry: {message}") - - def action_save_file(self) -> None: - """Save the hosts file to disk.""" - if not self.edit_mode: - self.update_status("❌ Cannot save: Application is in read-only mode. No changes to save.") - return - - success, message = self.manager.save_hosts_file(self.hosts_file) - if success: - self.update_status(message) - else: - self.update_status(f"Error saving file: {message}") - def action_quit(self) -> None: """Quit the application.""" - # If in edit mode, exit it first - if self.edit_mode: - self.manager.exit_edit_mode() self.exit() diff --git a/tests/test_main.py b/tests/test_main.py index e0a34fd..6c51846 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -243,17 +243,14 @@ class TestHostsManagerApp: app = HostsManagerApp() - # Mock the query_one method and set_timer to avoid event loop issues + # Mock the query_one method mock_status = Mock() app.query_one = Mock(return_value=mock_status) - app.set_timer = Mock() # Mock the timer to avoid event loop issues app.update_status("Custom status message") # Verify status was updated with custom message mock_status.update.assert_called_once_with("Custom status message") - # Verify timer was set for auto-clearing - app.set_timer.assert_called_once() def test_action_reload(self): """Test reload action.""" @@ -329,9 +326,9 @@ class TestHostsManagerApp: app.action_sort_by_ip() - # Check that entries are sorted with default entries on top - assert app.hosts_file.entries[0].ip_address == "127.0.0.1" # Default entry first - assert app.hosts_file.entries[1].ip_address == "10.0.0.1" # Then sorted non-defaults + # Check that entries are sorted + assert app.hosts_file.entries[0].ip_address == "10.0.0.1" + assert app.hosts_file.entries[1].ip_address == "127.0.0.1" assert app.hosts_file.entries[2].ip_address == "192.168.1.1" assert app.sort_column == "ip" @@ -379,9 +376,6 @@ class TestHostsManagerApp: app = HostsManagerApp() app.update_entry_details = Mock() - # Mock the display_index_to_actual_index method to return the same index - app.display_index_to_actual_index = Mock(return_value=2) - # Create mock event with required parameters mock_table = Mock() mock_table.id = "entries-table" @@ -394,7 +388,6 @@ class TestHostsManagerApp: # Should update selected index and details assert app.selected_entry_index == 2 app.update_entry_details.assert_called_once() - app.display_index_to_actual_index.assert_called_once_with(2) def test_data_table_header_selected_ip_column(self): """Test DataTable header selection for IP column.""" diff --git a/tests/test_manager.py b/tests/test_manager.py deleted file mode 100644 index d723dad..0000000 --- a/tests/test_manager.py +++ /dev/null @@ -1,613 +0,0 @@ -""" -Tests for the hosts manager module. - -This module tests permission management, edit mode operations, -and safe file modifications with backup and validation. -""" - -import pytest -import tempfile -import subprocess -from pathlib import Path -from unittest.mock import Mock, patch, MagicMock -from src.hosts.core.manager import PermissionManager, HostsManager, EditModeError -from src.hosts.core.models import HostEntry, HostsFile - - -class TestPermissionManager: - """Test the PermissionManager class.""" - - def test_init(self): - """Test PermissionManager initialization.""" - pm = PermissionManager() - assert not pm.has_sudo - assert not pm._sudo_validated - - @patch('subprocess.run') - def test_request_sudo_already_available(self, mock_run): - """Test requesting sudo when already available.""" - # Mock successful sudo -n true - mock_run.return_value = Mock(returncode=0) - - pm = PermissionManager() - success, message = pm.request_sudo() - - assert success - assert "already available" in message - assert pm.has_sudo - assert pm._sudo_validated - - mock_run.assert_called_once_with( - ['sudo', '-n', 'true'], - capture_output=True, - text=True, - timeout=5 - ) - - @patch('subprocess.run') - def test_request_sudo_prompt_success(self, mock_run): - """Test requesting sudo with password prompt success.""" - # First call (sudo -n true) fails, second call (sudo -v) succeeds - mock_run.side_effect = [ - Mock(returncode=1), # sudo -n true fails - Mock(returncode=0) # sudo -v succeeds - ] - - pm = PermissionManager() - success, message = pm.request_sudo() - - assert success - assert "access granted" in message - assert pm.has_sudo - assert pm._sudo_validated - - assert mock_run.call_count == 2 - - @patch('subprocess.run') - def test_request_sudo_denied(self, mock_run): - """Test requesting sudo when access is denied.""" - # Both calls fail - mock_run.side_effect = [ - Mock(returncode=1), # sudo -n true fails - Mock(returncode=1) # sudo -v fails - ] - - pm = PermissionManager() - success, message = pm.request_sudo() - - assert not success - assert "denied" in message - assert not pm.has_sudo - assert not pm._sudo_validated - - @patch('subprocess.run') - def test_request_sudo_timeout(self, mock_run): - """Test requesting sudo with timeout.""" - mock_run.side_effect = subprocess.TimeoutExpired(['sudo', '-n', 'true'], 5) - - pm = PermissionManager() - success, message = pm.request_sudo() - - assert not success - assert "timed out" in message - assert not pm.has_sudo - - @patch('subprocess.run') - def test_request_sudo_exception(self, mock_run): - """Test requesting sudo with exception.""" - mock_run.side_effect = Exception("Test error") - - pm = PermissionManager() - success, message = pm.request_sudo() - - assert not success - assert "Test error" in message - assert not pm.has_sudo - - @patch('subprocess.run') - def test_validate_permissions_success(self, mock_run): - """Test validating permissions successfully.""" - mock_run.return_value = Mock(returncode=0) - - pm = PermissionManager() - pm.has_sudo = True - - result = pm.validate_permissions("/etc/hosts") - - assert result - mock_run.assert_called_once_with( - ['sudo', '-n', 'test', '-w', '/etc/hosts'], - capture_output=True, - timeout=5 - ) - - @patch('subprocess.run') - def test_validate_permissions_no_sudo(self, mock_run): - """Test validating permissions without sudo.""" - pm = PermissionManager() - pm.has_sudo = False - - result = pm.validate_permissions("/etc/hosts") - - assert not result - mock_run.assert_not_called() - - @patch('subprocess.run') - def test_validate_permissions_failure(self, mock_run): - """Test validating permissions failure.""" - mock_run.return_value = Mock(returncode=1) - - pm = PermissionManager() - pm.has_sudo = True - - result = pm.validate_permissions("/etc/hosts") - - assert not result - - @patch('subprocess.run') - def test_validate_permissions_exception(self, mock_run): - """Test validating permissions with exception.""" - mock_run.side_effect = Exception("Test error") - - pm = PermissionManager() - pm.has_sudo = True - - result = pm.validate_permissions("/etc/hosts") - - assert not result - - @patch('subprocess.run') - def test_release_sudo(self, mock_run): - """Test releasing sudo permissions.""" - pm = PermissionManager() - pm.has_sudo = True - pm._sudo_validated = True - - pm.release_sudo() - - assert not pm.has_sudo - assert not pm._sudo_validated - mock_run.assert_called_once_with(['sudo', '-k'], capture_output=True, timeout=5) - - @patch('subprocess.run') - def test_release_sudo_exception(self, mock_run): - """Test releasing sudo with exception.""" - mock_run.side_effect = Exception("Test error") - - pm = PermissionManager() - pm.has_sudo = True - pm._sudo_validated = True - - pm.release_sudo() - - # Should still reset state even if command fails - assert not pm.has_sudo - assert not pm._sudo_validated - - -class TestHostsManager: - """Test the HostsManager class.""" - - def test_init(self): - """Test HostsManager initialization.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - assert not manager.edit_mode - assert manager._backup_path is None - assert manager.parser.file_path == Path(temp_file.name) - - @patch('src.hosts.core.manager.HostsManager._create_backup') - def test_enter_edit_mode_success(self, mock_backup): - """Test entering edit mode successfully.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - - # Mock permission manager - manager.permission_manager.request_sudo = Mock(return_value=(True, "Success")) - manager.permission_manager.validate_permissions = Mock(return_value=True) - - success, message = manager.enter_edit_mode() - - assert success - assert "enabled" in message - assert manager.edit_mode - mock_backup.assert_called_once() - - def test_enter_edit_mode_already_in_edit(self): - """Test entering edit mode when already in edit mode.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - success, message = manager.enter_edit_mode() - - assert success - assert "Already in edit mode" in message - - def test_enter_edit_mode_sudo_failure(self): - """Test entering edit mode with sudo failure.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - - # Mock permission manager failure - manager.permission_manager.request_sudo = Mock(return_value=(False, "Denied")) - - success, message = manager.enter_edit_mode() - - assert not success - assert "Cannot enter edit mode" in message - assert not manager.edit_mode - - def test_enter_edit_mode_permission_validation_failure(self): - """Test entering edit mode with permission validation failure.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - - # Mock permission manager - manager.permission_manager.request_sudo = Mock(return_value=(True, "Success")) - manager.permission_manager.validate_permissions = Mock(return_value=False) - - success, message = manager.enter_edit_mode() - - assert not success - assert "Cannot write to hosts file" in message - assert not manager.edit_mode - - @patch('src.hosts.core.manager.HostsManager._create_backup') - def test_enter_edit_mode_backup_failure(self, mock_backup): - """Test entering edit mode with backup failure.""" - mock_backup.side_effect = Exception("Backup failed") - - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - - # Mock permission manager - manager.permission_manager.request_sudo = Mock(return_value=(True, "Success")) - manager.permission_manager.validate_permissions = Mock(return_value=True) - - success, message = manager.enter_edit_mode() - - assert not success - assert "Failed to create backup" in message - assert not manager.edit_mode - - def test_exit_edit_mode_success(self): - """Test exiting edit mode successfully.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - manager._backup_path = Path("/tmp/backup") - - # Mock permission manager - manager.permission_manager.release_sudo = Mock() - - success, message = manager.exit_edit_mode() - - assert success - assert "disabled" in message - assert not manager.edit_mode - assert manager._backup_path is None - manager.permission_manager.release_sudo.assert_called_once() - - def test_exit_edit_mode_not_in_edit(self): - """Test exiting edit mode when not in edit mode.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = False - - success, message = manager.exit_edit_mode() - - assert success - assert "Already in read-only mode" in message - - def test_exit_edit_mode_exception(self): - """Test exiting edit mode with exception.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - # Mock permission manager to raise exception - manager.permission_manager.release_sudo = Mock(side_effect=Exception("Test error")) - - success, message = manager.exit_edit_mode() - - assert not success - assert "Test error" in message - - def test_toggle_entry_success(self): - """Test toggling entry successfully.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - hosts_file = HostsFile() - entry = HostEntry("192.168.1.1", ["router"], is_active=True) # Non-default entry - hosts_file.entries.append(entry) - - success, message = manager.toggle_entry(hosts_file, 0) - - assert success - assert "active to inactive" in message - assert not hosts_file.entries[0].is_active - - def test_toggle_entry_not_in_edit_mode(self): - """Test toggling entry when not in edit mode.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = False - - hosts_file = HostsFile() - - success, message = manager.toggle_entry(hosts_file, 0) - - assert not success - assert "Not in edit mode" in message - - def test_toggle_entry_invalid_index(self): - """Test toggling entry with invalid index.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - hosts_file = HostsFile() - - success, message = manager.toggle_entry(hosts_file, 0) - - assert not success - assert "Invalid entry index" in message - - def test_move_entry_up_success(self): - """Test moving entry up successfully.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - hosts_file = HostsFile() - entry1 = HostEntry("10.0.0.1", ["test1"]) # Non-default entries - entry2 = HostEntry("192.168.1.1", ["router"]) - hosts_file.entries.extend([entry1, entry2]) - - success, message = manager.move_entry_up(hosts_file, 1) - - assert success - assert "moved up" in message - assert hosts_file.entries[0].hostnames[0] == "router" - assert hosts_file.entries[1].hostnames[0] == "test1" - - def test_move_entry_up_invalid_index(self): - """Test moving entry up with invalid index.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - hosts_file = HostsFile() - entry = HostEntry("127.0.0.1", ["localhost"]) - hosts_file.entries.append(entry) - - success, message = manager.move_entry_up(hosts_file, 0) - - assert not success - assert "Cannot move entry up" in message - - def test_move_entry_down_success(self): - """Test moving entry down successfully.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - hosts_file = HostsFile() - entry1 = HostEntry("10.0.0.1", ["test1"]) # Non-default entries - entry2 = HostEntry("192.168.1.1", ["router"]) - hosts_file.entries.extend([entry1, entry2]) - - success, message = manager.move_entry_down(hosts_file, 0) - - assert success - assert "moved down" in message - assert hosts_file.entries[0].hostnames[0] == "router" - assert hosts_file.entries[1].hostnames[0] == "test1" - - def test_move_entry_down_invalid_index(self): - """Test moving entry down with invalid index.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - hosts_file = HostsFile() - entry = HostEntry("127.0.0.1", ["localhost"]) - hosts_file.entries.append(entry) - - success, message = manager.move_entry_down(hosts_file, 0) - - assert not success - assert "Cannot move entry down" in message - - def test_update_entry_success(self): - """Test updating entry successfully.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - hosts_file = HostsFile() - entry = HostEntry("10.0.0.1", ["test"]) # Non-default entry - hosts_file.entries.append(entry) - - success, message = manager.update_entry( - hosts_file, 0, "192.168.1.1", ["newhost"], "New comment" - ) - - assert success - assert "updated successfully" in message - assert hosts_file.entries[0].ip_address == "192.168.1.1" - assert hosts_file.entries[0].hostnames == ["newhost"] - assert hosts_file.entries[0].comment == "New comment" - - def test_update_entry_invalid_data(self): - """Test updating entry with invalid data.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - hosts_file = HostsFile() - entry = HostEntry("127.0.0.1", ["localhost"]) # Default entry - cannot be modified - hosts_file.entries.append(entry) - - success, message = manager.update_entry( - hosts_file, 0, "invalid-ip", ["newhost"] - ) - - assert not success - assert "Cannot modify default system entries" in message - - @patch('tempfile.NamedTemporaryFile') - @patch('subprocess.run') - @patch('os.unlink') - def test_save_hosts_file_success(self, mock_unlink, mock_run, mock_temp): - """Test saving hosts file successfully.""" - # Mock temporary file - mock_temp_file = Mock() - mock_temp_file.name = "/tmp/test.hosts" - mock_temp_file.__enter__ = Mock(return_value=mock_temp_file) - mock_temp_file.__exit__ = Mock(return_value=None) - mock_temp.return_value = mock_temp_file - - # Mock subprocess success - mock_run.return_value = Mock(returncode=0) - - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - manager.permission_manager.has_sudo = True - - hosts_file = HostsFile() - entry = HostEntry("127.0.0.1", ["localhost"]) - hosts_file.entries.append(entry) - - success, message = manager.save_hosts_file(hosts_file) - - assert success - assert "saved successfully" in message - mock_run.assert_called_once() - mock_unlink.assert_called_once_with("/tmp/test.hosts") - - def test_save_hosts_file_not_in_edit_mode(self): - """Test saving hosts file when not in edit mode.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = False - - hosts_file = HostsFile() - - success, message = manager.save_hosts_file(hosts_file) - - assert not success - assert "Not in edit mode" in message - - def test_save_hosts_file_no_sudo(self): - """Test saving hosts file without sudo.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - manager.permission_manager.has_sudo = False - - hosts_file = HostsFile() - - success, message = manager.save_hosts_file(hosts_file) - - assert not success - assert "No sudo permissions" in message - - @patch('subprocess.run') - def test_restore_backup_success(self, mock_run): - """Test restoring backup successfully.""" - mock_run.return_value = Mock(returncode=0) - - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - - # Create a mock backup file - with tempfile.NamedTemporaryFile(delete=False) as backup_file: - manager._backup_path = Path(backup_file.name) - - try: - success, message = manager.restore_backup() - - assert success - assert "restored successfully" in message - mock_run.assert_called_once() - finally: - # Clean up - manager._backup_path.unlink() - - def test_restore_backup_not_in_edit_mode(self): - """Test restoring backup when not in edit mode.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = False - - success, message = manager.restore_backup() - - assert not success - assert "Not in edit mode" in message - - def test_restore_backup_no_backup(self): - """Test restoring backup when no backup exists.""" - with tempfile.NamedTemporaryFile() as temp_file: - manager = HostsManager(temp_file.name) - manager.edit_mode = True - manager._backup_path = None - - success, message = manager.restore_backup() - - assert not success - assert "No backup available" in message - - @patch('subprocess.run') - @patch('tempfile.gettempdir') - @patch('time.time') - def test_create_backup_success(self, mock_time, mock_tempdir, mock_run): - """Test creating backup successfully.""" - mock_time.return_value = 1234567890 - mock_tempdir.return_value = "/tmp" - mock_run.side_effect = [ - Mock(returncode=0), # cp command - Mock(returncode=0) # chmod command - ] - - # Create a real temporary file for testing - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - temp_file.write(b"test content") - temp_path = temp_file.name - - try: - manager = HostsManager(temp_path) - manager._create_backup() - - expected_backup = Path("/tmp/hosts-manager-backups/hosts.backup.1234567890") - assert manager._backup_path == expected_backup - assert mock_run.call_count == 2 - finally: - # Clean up - Path(temp_path).unlink() - - @patch('subprocess.run') - def test_create_backup_failure(self, mock_run): - """Test creating backup with failure.""" - mock_run.return_value = Mock(returncode=1, stderr="Permission denied") - - # Create a real temporary file for testing - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - temp_file.write(b"test content") - temp_path = temp_file.name - - try: - manager = HostsManager(temp_path) - - with pytest.raises(Exception) as exc_info: - manager._create_backup() - - assert "Failed to create backup" in str(exc_info.value) - finally: - # Clean up - Path(temp_path).unlink() diff --git a/tests/test_models.py b/tests/test_models.py index c88281b..fd22f16 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -67,7 +67,7 @@ class TestHostEntry: comment="Loopback" ) line = entry.to_hosts_line() - assert line == "127.0.0.1\tlocalhost\tlocal\t# Loopback" + assert line == "127.0.0.1 localhost local # Loopback" def test_to_hosts_line_inactive(self): """Test conversion to hosts file line format for inactive entry.""" @@ -77,7 +77,7 @@ class TestHostEntry: is_active=False ) line = entry.to_hosts_line() - assert line == "# 192.168.1.1\trouter" + assert line == "# 192.168.1.1 router" def test_from_hosts_line_simple(self): """Test parsing simple hosts file line.""" @@ -224,21 +224,20 @@ class TestHostsFile: assert inactive_entries[0] == inactive_entry def test_sort_by_ip(self): - """Test sorting entries by IP address with default entries on top.""" + """Test sorting entries by IP address.""" hosts_file = HostsFile() entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["router"]) - entry2 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) # Default entry + entry2 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test"]) - + hosts_file.add_entry(entry1) hosts_file.add_entry(entry2) hosts_file.add_entry(entry3) - + hosts_file.sort_by_ip() - - # Default entries should come first, then sorted non-default entries - assert hosts_file.entries[0].ip_address == "127.0.0.1" # Default entry first - assert hosts_file.entries[1].ip_address == "10.0.0.1" # Then sorted non-defaults + + assert hosts_file.entries[0].ip_address == "10.0.0.1" + assert hosts_file.entries[1].ip_address == "127.0.0.1" assert hosts_file.entries[2].ip_address == "192.168.1.1" def test_sort_by_hostname(self): diff --git a/tests/test_parser.py b/tests/test_parser.py index 24acea7..b997a96 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -161,20 +161,15 @@ class TestHostsParser: hosts_file = HostsFile() entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"]) - + hosts_file.add_entry(entry1) hosts_file.add_entry(entry2) - + parser = HostsParser() content = parser.serialize(hosts_file) - - expected = """# # -# Host Database -# -# Managed by hosts - https://git.s1q.dev/phg/hosts -# # -127.0.0.1\tlocalhost -192.168.1.1\trouter + + expected = """127.0.0.1 localhost +192.168.1.1 router """ assert content == expected @@ -203,9 +198,9 @@ class TestHostsParser: expected = """# Header comment 1 # Header comment 2 -# Managed by hosts - https://git.s1q.dev/phg/hosts -127.0.0.1\tlocalhost\t# Loopback -# 10.0.0.1\ttest + +127.0.0.1 localhost # Loopback +# 10.0.0.1 test # Footer comment """ @@ -217,13 +212,7 @@ class TestHostsParser: parser = HostsParser() content = parser.serialize(hosts_file) - expected = """# # -# Host Database -# -# Managed by hosts - https://git.s1q.dev/phg/hosts -# # -""" - assert content == expected + assert content == "\n" def test_write_hosts_file(self): """Test writing hosts file to disk.""" @@ -238,14 +227,7 @@ class TestHostsParser: # Read back and verify with open(f.name, 'r') as read_file: content = read_file.read() - expected = """# # -# Host Database -# -# Managed by hosts - https://git.s1q.dev/phg/hosts -# # -127.0.0.1\tlocalhost -""" - assert content == expected + assert content == "127.0.0.1 localhost\n" os.unlink(f.name) @@ -278,14 +260,7 @@ class TestHostsParser: # Check new content with open(f.name, 'r') as new_file: new_content = new_file.read() - expected = """# # -# Host Database -# -# Managed by hosts - https://git.s1q.dev/phg/hosts -# # -127.0.0.1\tlocalhost -""" - assert new_content == expected + assert new_content == "127.0.0.1 localhost\n" # Cleanup os.unlink(backup_path) @@ -369,10 +344,10 @@ class TestHostsParser: final_content = read_file.read() # The content should be functionally equivalent - # (though formatting might differ slightly with tabs) - assert "127.0.0.1\tlocalhost\tloopback\t# Local loopback" in final_content - assert "::1\t\tlocalhost\t# IPv6 loopback" in final_content - assert "192.168.1.1\trouter\t\tgateway\t# Local router" in final_content - assert "# 10.0.0.1\ttest.local\t# Test entry (disabled)" in final_content + # (though formatting might differ slightly) + assert "127.0.0.1 localhost loopback # Local loopback" in final_content + assert "::1 localhost # IPv6 loopback" in final_content + assert "192.168.1.1 router gateway # Local router" in final_content + assert "# 10.0.0.1 test.local # Test entry (disabled)" in final_content os.unlink(f.name)