diff --git a/src/hosts/core/models.py b/src/hosts/core/models.py index c9dc8ee..35ec529 100644 --- a/src/hosts/core/models.py +++ b/src/hosts/core/models.py @@ -80,28 +80,71 @@ class HostEntry: if not hostname_pattern.match(hostname): raise ValueError(f"Invalid hostname '{hostname}'") - def to_hosts_line(self) -> str: + def to_hosts_line(self, ip_width: int = 0, hostname_width: int = 0) -> str: """ - Convert this entry to a hosts file line. + 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 Returns: String representation suitable for writing to hosts file """ line_parts = [] - # Add comment prefix if inactive + # Build the IP address part (with comment prefix if inactive) + ip_part = "" if not self.is_active: - line_parts.append("#") + ip_part = "# " + ip_part += self.ip_address - # Add IP and hostnames - line_parts.append(self.ip_address) - line_parts.extend(self.hostnames) + # 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 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) + 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) @classmethod def from_hosts_line(cls, line: str) -> Optional['HostEntry']: @@ -128,8 +171,10 @@ class HostEntry: if not line or line.startswith('#'): return None - # Split line into parts - parts = line.split() + # 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()) if len(parts) < 2: return None diff --git a/src/hosts/core/parser.py b/src/hosts/core/parser.py index bcfa34b..169a058 100644 --- a/src/hosts/core/parser.py +++ b/src/hosts/core/parser.py @@ -82,13 +82,13 @@ class HostsParser: def serialize(self, hosts_file: HostsFile) -> str: """ - Convert a HostsFile object back to hosts file format. + Convert a HostsFile object back to hosts file format with proper column alignment. Args: hosts_file: HostsFile object to serialize Returns: - String representation of the hosts file + String representation of the hosts file with tab-aligned columns """ lines = [] @@ -99,11 +99,13 @@ class HostsParser: lines.append(f"# {comment}") else: lines.append("#") - lines.append("") # Blank line after header - # Add host entries + # Calculate column widths for proper alignment + ip_width, hostname_width = self._calculate_column_widths(hosts_file.entries) + + # Add host entries with proper column alignment for entry in hosts_file.entries: - lines.append(entry.to_hosts_line()) + lines.append(entry.to_hosts_line(ip_width, hostname_width)) # Add footer comments if hosts_file.footer_comments: @@ -116,6 +118,39 @@ class HostsParser: return "\n".join(lines) + "\n" + 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/tests/test_models.py b/tests/test_models.py index d5155eb..c88281b 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 localhost local # Loopback" + assert line == "127.0.0.1\tlocalhost\tlocal\t# 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 router" + assert line == "# 192.168.1.1\trouter" def test_from_hosts_line_simple(self): """Test parsing simple hosts file line.""" diff --git a/tests/test_parser.py b/tests/test_parser.py index b997a96..0b61425 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -161,15 +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 = """127.0.0.1 localhost -192.168.1.1 router + + expected = """127.0.0.1\tlocalhost +192.168.1.1\trouter """ assert content == expected @@ -198,9 +198,8 @@ class TestHostsParser: expected = """# Header comment 1 # Header comment 2 - -127.0.0.1 localhost # Loopback -# 10.0.0.1 test +127.0.0.1\tlocalhost\t# Loopback +# 10.0.0.1\ttest # Footer comment """ @@ -227,7 +226,7 @@ class TestHostsParser: # Read back and verify with open(f.name, 'r') as read_file: content = read_file.read() - assert content == "127.0.0.1 localhost\n" + assert content == "127.0.0.1\tlocalhost\n" os.unlink(f.name) @@ -260,7 +259,7 @@ class TestHostsParser: # Check new content with open(f.name, 'r') as new_file: new_content = new_file.read() - assert new_content == "127.0.0.1 localhost\n" + assert new_content == "127.0.0.1\tlocalhost\n" # Cleanup os.unlink(backup_path) @@ -344,10 +343,10 @@ class TestHostsParser: final_content = read_file.read() # The content should be functionally equivalent - # (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 + # (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 os.unlink(f.name)