mirror of
https://github.com/shokinn/hosts-go.git
synced 2025-08-23 08:33:02 +00:00
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:
parent
d66ec51ebd
commit
b81f11f711
10 changed files with 1303 additions and 210 deletions
434
tests/parser_test.go
Normal file
434
tests/parser_test.go
Normal 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)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue