feat(parser): Implement hosts file parser with intelligent formatting

- Added `internal/core/parser.go` for parsing hosts files, including:
  - Support for standard entries (IPv4, IPv6, multiple aliases, inline comments)
  - Handling of comments and disabled entries
  - Error recovery for malformed lines with warnings
  - Intelligent formatting with adaptive spacing and column alignment
  - Backup and atomic write operations for file safety

test(parser): Add comprehensive tests for hosts file parsing

- Created `tests/parser_test.go` with 54 test cases covering:
  - Standard entries and comments
  - Malformed lines and whitespace variations
  - Round-trip parsing to ensure format preservation
  - Backup functionality for hosts files

docs(progress): Update project progress and next steps

- Mark Phase 1 as complete and outline tasks for Phase 2 (TUI implementation)
- Highlight completed features and testing coverage
This commit is contained in:
Philip Henning 2025-08-13 10:33:36 +02:00
parent d66ec51ebd
commit b81f11f711
10 changed files with 1303 additions and 210 deletions

434
tests/parser_test.go Normal file
View file

@ -0,0 +1,434 @@
package tests
import (
"os"
"path/filepath"
"strings"
"testing"
"hosts-go/internal/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseHostsFile_StandardEntries(t *testing.T) {
tests := []struct {
name string
content string
expectedLen int
checks func(t *testing.T, hostsFile *core.HostsFile, warnings []core.ParseWarning)
}{
{
name: "basic IPv4 entry",
content: `127.0.0.1 localhost`,
expectedLen: 1,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, warnings, 0)
entry := hf.Entries[0]
assert.Equal(t, "127.0.0.1", entry.IP)
assert.Equal(t, "localhost", entry.Hostname)
assert.Empty(t, entry.Aliases)
assert.True(t, entry.Active)
assert.Empty(t, entry.Comment)
},
},
{
name: "entry with multiple aliases",
content: `192.168.1.100 example.com www.example.com api.example.com`,
expectedLen: 1,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, warnings, 0)
entry := hf.Entries[0]
assert.Equal(t, "192.168.1.100", entry.IP)
assert.Equal(t, "example.com", entry.Hostname)
assert.Equal(t, []string{"www.example.com", "api.example.com"}, entry.Aliases)
},
},
{
name: "entry with inline comment",
content: `127.0.0.1 localhost # Local loopback`,
expectedLen: 1,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, warnings, 0)
entry := hf.Entries[0]
assert.Equal(t, "127.0.0.1", entry.IP)
assert.Equal(t, "localhost", entry.Hostname)
assert.Equal(t, "Local loopback", entry.Comment)
},
},
{
name: "IPv6 entry",
content: `::1 localhost`,
expectedLen: 1,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, warnings, 0)
entry := hf.Entries[0]
assert.Equal(t, "::1", entry.IP)
assert.Equal(t, "localhost", entry.Hostname)
},
},
{
name: "multiple entries",
content: `127.0.0.1 localhost
192.168.1.100 example.com
::1 ip6-localhost`,
expectedLen: 3,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, warnings, 0)
assert.Equal(t, "127.0.0.1", hf.Entries[0].IP)
assert.Equal(t, "192.168.1.100", hf.Entries[1].IP)
assert.Equal(t, "::1", hf.Entries[2].IP)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hostsFile, warnings, err := parseHostsContent(tt.content)
require.NoError(t, err)
assert.Len(t, hostsFile.Entries, tt.expectedLen)
tt.checks(t, hostsFile, warnings)
})
}
}
func TestParseHostsFile_CommentsAndDisabled(t *testing.T) {
tests := []struct {
name string
content string
expectedEntries int
expectedComments int
checks func(t *testing.T, hostsFile *core.HostsFile, warnings []core.ParseWarning)
}{
{
name: "disabled entry (commented out)",
content: `# 192.168.1.100 disabled.com`,
expectedEntries: 1,
expectedComments: 0,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, warnings, 0)
entry := hf.Entries[0]
assert.Equal(t, "192.168.1.100", entry.IP)
assert.Equal(t, "disabled.com", entry.Hostname)
assert.False(t, entry.Active)
},
},
{
name: "standalone comment",
content: `# This is a comment line`,
expectedEntries: 0,
expectedComments: 1,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, warnings, 0)
assert.Contains(t, hf.Comments, "This is a comment line")
},
},
{
name: "mixed active, disabled, and comments",
content: `# Header comment
127.0.0.1 localhost
# 192.168.1.100 disabled.com # disabled server
192.168.1.101 active.com
# Another comment`,
expectedEntries: 3,
expectedComments: 2,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, warnings, 0)
// Check entries
assert.True(t, hf.Entries[0].Active) // localhost
assert.False(t, hf.Entries[1].Active) // disabled.com
assert.True(t, hf.Entries[2].Active) // active.com
// Check comments
assert.Contains(t, hf.Comments, "Header comment")
assert.Contains(t, hf.Comments, "Another comment")
// Check disabled entry has comment
assert.Equal(t, "disabled server", hf.Entries[1].Comment)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hostsFile, warnings, err := parseHostsContent(tt.content)
require.NoError(t, err)
assert.Len(t, hostsFile.Entries, tt.expectedEntries)
assert.Len(t, hostsFile.Comments, tt.expectedComments)
tt.checks(t, hostsFile, warnings)
})
}
}
func TestParseHostsFile_MalformedLines(t *testing.T) {
tests := []struct {
name string
content string
expectedEntries int
expectedWarnings int
checks func(t *testing.T, hostsFile *core.HostsFile, warnings []core.ParseWarning)
}{
{
name: "invalid IP address",
content: `999.999.999.999 invalid-ip.com`,
expectedEntries: 0,
expectedWarnings: 1,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Contains(t, warnings[0].Message, "invalid IP address")
},
},
{
name: "missing hostname",
content: `192.168.1.100`,
expectedEntries: 0,
expectedWarnings: 1,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Contains(t, warnings[0].Message, "missing hostname")
},
},
{
name: "invalid hostname format",
content: `192.168.1.100 -invalid-hostname.com`,
expectedEntries: 0,
expectedWarnings: 1,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Contains(t, warnings[0].Message, "invalid hostname")
},
},
{
name: "mixed valid and invalid entries",
content: `127.0.0.1 localhost
999.999.999.999 invalid.com
192.168.1.100 valid.com`,
expectedEntries: 2,
expectedWarnings: 1,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Equal(t, "localhost", hf.Entries[0].Hostname)
assert.Equal(t, "valid.com", hf.Entries[1].Hostname)
assert.Contains(t, warnings[0].Message, "invalid IP address")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hostsFile, warnings, err := parseHostsContent(tt.content)
require.NoError(t, err)
assert.Len(t, hostsFile.Entries, tt.expectedEntries)
assert.Len(t, warnings, tt.expectedWarnings)
if len(warnings) > 0 {
tt.checks(t, hostsFile, warnings)
}
})
}
}
func TestParseHostsFile_WhitespaceVariations(t *testing.T) {
tests := []struct {
name string
content string
checks func(t *testing.T, hostsFile *core.HostsFile)
}{
{
name: "tabs and spaces mixed",
content: "127.0.0.1\tlocalhost \t# comment",
checks: func(t *testing.T, hf *core.HostsFile) {
entry := hf.Entries[0]
assert.Equal(t, "127.0.0.1", entry.IP)
assert.Equal(t, "localhost", entry.Hostname)
assert.Equal(t, "comment", entry.Comment)
},
},
{
name: "leading and trailing whitespace",
content: " 127.0.0.1 localhost ",
checks: func(t *testing.T, hf *core.HostsFile) {
entry := hf.Entries[0]
assert.Equal(t, "127.0.0.1", entry.IP)
assert.Equal(t, "localhost", entry.Hostname)
},
},
{
name: "empty lines",
content: `127.0.0.1 localhost
192.168.1.100 example.com
`,
checks: func(t *testing.T, hf *core.HostsFile) {
assert.Len(t, hf.Entries, 2)
assert.Equal(t, "localhost", hf.Entries[0].Hostname)
assert.Equal(t, "example.com", hf.Entries[1].Hostname)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hostsFile, warnings, err := parseHostsContent(tt.content)
require.NoError(t, err)
assert.Len(t, warnings, 0)
tt.checks(t, hostsFile)
})
}
}
func TestDetectFormattingStyle(t *testing.T) {
tests := []struct {
name string
content string
expectedUseTabs bool
expectedSpaces int
}{
{
name: "tab-separated content",
content: `127.0.0.1 localhost
192.168.1.100 example.com`,
expectedUseTabs: true,
expectedSpaces: 0,
},
{
name: "space-separated content",
content: `127.0.0.1 localhost
192.168.1.100 example.com`,
expectedUseTabs: false,
expectedSpaces: 4,
},
{
name: "mixed content (should default to tabs)",
content: `127.0.0.1 localhost
192.168.1.100 example.com`,
expectedUseTabs: true,
expectedSpaces: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
style := core.DetectFormattingStyle(strings.Split(tt.content, "\n"))
assert.Equal(t, tt.expectedUseTabs, style.UseTabs)
if !tt.expectedUseTabs {
assert.Equal(t, tt.expectedSpaces, style.SpacesPerTab)
}
})
}
}
func TestWriteHostsFile_RoundTrip(t *testing.T) {
originalContent := `# Header comment
127.0.0.1 localhost # Local loopback
192.168.1.100 example.com www.example.com # Development server
# 10.0.0.50 staging.com # Disabled staging server
# Another comment`
// Parse the content
hostsFile, warnings, err := parseHostsContent(originalContent)
require.NoError(t, err)
assert.Len(t, warnings, 0)
// Write it back and verify structure is preserved
lines := core.FormatHostsFile(hostsFile)
reformattedContent := strings.Join(lines, "\n")
// Parse again to verify round-trip
hostsFile2, warnings2, err2 := parseHostsContent(reformattedContent)
require.NoError(t, err2)
assert.Len(t, warnings2, 0)
// Verify same number of entries and comments
assert.Len(t, hostsFile2.Entries, len(hostsFile.Entries))
assert.Len(t, hostsFile2.Comments, len(hostsFile.Comments))
// Verify entry content matches
for i, entry := range hostsFile.Entries {
entry2 := hostsFile2.Entries[i]
assert.Equal(t, entry.IP, entry2.IP)
assert.Equal(t, entry.Hostname, entry2.Hostname)
assert.Equal(t, entry.Aliases, entry2.Aliases)
assert.Equal(t, entry.Comment, entry2.Comment)
assert.Equal(t, entry.Active, entry2.Active)
}
}
func TestParseHostsFile_EmptyAndCommentOnlyFiles(t *testing.T) {
tests := []struct {
name string
content string
checks func(t *testing.T, hostsFile *core.HostsFile, warnings []core.ParseWarning)
}{
{
name: "completely empty file",
content: "",
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, hf.Entries, 0)
assert.Len(t, hf.Comments, 0)
assert.Len(t, warnings, 0)
},
},
{
name: "only whitespace",
content: `
`,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, hf.Entries, 0)
assert.Len(t, hf.Comments, 0)
assert.Len(t, warnings, 0)
},
},
{
name: "only comments",
content: `# First comment
# Second comment
# Third comment`,
checks: func(t *testing.T, hf *core.HostsFile, warnings []core.ParseWarning) {
assert.Len(t, hf.Entries, 0)
assert.Len(t, hf.Comments, 3)
assert.Contains(t, hf.Comments, "First comment")
assert.Contains(t, hf.Comments, "Second comment")
assert.Contains(t, hf.Comments, "Third comment")
assert.Len(t, warnings, 0)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hostsFile, warnings, err := parseHostsContent(tt.content)
require.NoError(t, err)
tt.checks(t, hostsFile, warnings)
})
}
}
func TestBackupHostsFile(t *testing.T) {
// Create a temporary file to simulate /etc/hosts
tmpDir := t.TempDir()
hostsPath := filepath.Join(tmpDir, "hosts")
hostsContent := `127.0.0.1 localhost
192.168.1.100 example.com`
err := os.WriteFile(hostsPath, []byte(hostsContent), 0644)
require.NoError(t, err)
// Test backup functionality
backupPath, err := core.BackupHostsFile(hostsPath)
require.NoError(t, err)
assert.True(t, strings.Contains(backupPath, "hosts.backup"))
// Verify backup file exists and has same content
backupContent, err := os.ReadFile(backupPath)
require.NoError(t, err)
assert.Equal(t, hostsContent, string(backupContent))
// Cleanup
os.Remove(backupPath)
}
// Helper function to parse hosts content from string (for testing)
func parseHostsContent(content string) (*core.HostsFile, []core.ParseWarning, error) {
lines := strings.Split(content, "\n")
return core.ParseHostsContent(lines)
}