mirror of
https://github.com/shokinn/hosts-go.git
synced 2025-08-23 08:33:02 +00:00
- 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
434 lines
12 KiB
Go
434 lines
12 KiB
Go
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)
|
|
}
|