**Tests**: Included — the project constitution (Principle VI) mandates TDD. All new code follows Red-Green-Refactor.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
**Self-Containment**: Tasks will be executed by a DIFFERENT model than the one generating them. Every non-trivial task includes an indented **Context** block with existing code references, implementation patterns, key decisions, and acceptance signals.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create new package directory structure and update Go module configuration
**Phase Context**:
- **Files modified in this phase**: `go.mod` (module definition, currently Go 1.18 with `miekg/dns`), new directories under repo root
- **Key types/interfaces used**: None — this phase creates the skeleton
- **Codebase conventions to follow**: Go standard project layout with sub-packages. Module name is `dns-helper` (from current `go.mod`). Build tags for platform-specific files use `//go:build` directive (Go 1.17+ format).
- **Target state**: The project currently has a flat structure with all `.go` files in the root `main` package. Create four new package directories:
```
resolver/
hostfile/
platform/
lockfile/
```
- **Expected implementation**: Create the four empty directories at the repository root. No files yet — subsequent tasks populate them.
- **Key decisions**: These package names were chosen in the plan (see plan.md "Project Structure" section). Each package has a single responsibility: `resolver` = DNS lookups, `hostfile` = hosts file read/parse/write, `platform` = OS-specific paths, `lockfile` = concurrency serialization.
- **Acceptance signal**: All four directories exist at the repository root alongside the existing source files.
- **Expected implementation**: Change `go 1.18` to `go 1.20`. Change `golang.org/x/net v0.22.0` to `golang.org/x/net v0.35.0`. Do NOT remove `miekg/dns` yet — the old code still imports it. Removal happens in Phase 8 (US6) after all old files are deleted. Run `go mod download` to fetch the updated dependency.
- **Key decisions (R-006)**: `golang.org/x/net v0.35.0` is the LAST version with `go 1.18` directive (compatible with Go 1.20). Starting at `v0.36.0`, the module requires `go 1.23.0`. MUST pin to v0.35.0.
- **Gotcha**: Do not run `go mod tidy` yet — it would break because old source files still import `miekg/dns`. Only run `go mod download`.
- **Acceptance signal**: `go mod download` completes without errors. `go.mod` shows `go 1.20` and `golang.org/x/net v0.35.0`.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core interfaces and packages that ALL user stories depend on
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
**Phase Context**:
- **Files modified in this phase**: New files in `hostfile/`, `platform/`, `lockfile/` packages
- **Key types/interfaces used**: `os.FileInfo`, `os.FileMode`, `os.File` from Go stdlib
- **Codebase conventions to follow**: All package functions return errors to caller (Constitution Principle IV). No `os.Exit()` outside `main()`. Use `fmt.Errorf("context: %w", err)` for error wrapping. Use Go 1.20 `//go:build` directive for build tags (not the legacy `// +build` comment).
- **Implements**: The FileSystem abstraction from research R-007 for testability (Constitution Principle VI). All Host file I/O goes through this interface so tests can inject fakes.
- **Target file state**: `hostfile/` directory exists (empty) after T001. This is the first file in the package. Package declaration: `package hostfile`.
- **Expected implementation**:
```go
package hostfile
import "os"
// FileSystem abstracts filesystem operations for testability.
type FileSystem interface {
ReadFile(path string) ([]byte, error)
WriteFile(path string, data []byte, perm os.FileMode) error
Stat(path string) (os.FileInfo, error)
CreateTemp(dir, pattern string) (*os.File, error)
Rename(oldpath, newpath string) error
Remove(path string) error
Chmod(path string, mode os.FileMode) error
}
// OSFileSystem implements FileSystem using real OS calls.
- **Key decisions (R-007)**: Production code uses `OSFileSystem{}`. Tests inject a fake struct that records calls and returns configurable errors. `Chmod` is included because atomic write must copy permissions from the original hosts file to the temp file before rename (R-002 step 2).
- **Acceptance signal**: `go build ./hostfile/` compiles without errors.
- **Key decisions**: Current code for Linux/macOS calls `term(err)` which invokes `os.Exit(1)` — this VIOLATES Constitution Principle IV. The new code MUST return errors to the caller. This is the core fix for spec FR-008. `platform/platform.go` contains only the package doc comment: `// Package platform provides the platform-specific hosts file path.`
- **Gotcha**: Use `//go:build` directive (Go 1.17+ syntax), NOT the legacy `// +build` comment. Go 1.20 supports both but the new syntax is preferred.
- **Acceptance signal**: `go build ./platform/` compiles without errors on the current OS.
- **Target file state**: `lockfile/lockfile.go` exists from T005 with `Acquire()` and `Lock.Release()`.
- **Expected test cases** (from contracts/packages.md behavior contract):
1. **Acquire succeeds**: Call `Acquire(tempDir)` — returns a non-nil `Lock` and lock file exists on disk.
2. **Release deletes lock file**: After `Acquire`, call `Release()` — lock file no longer exists.
3. **Double acquire blocks then fails**: Acquire a lock, then attempt a second `Acquire` on the same dir — should fail with "another instance may be running" after timeout.
4. **Stale lock is broken**: Create a lock file manually with a ModTime >2 minutes in the past, then call `Acquire` — should succeed (stale lock broken).
5. **Release is idempotent**: Call `Release()` twice — second call should not return an error.
- **Test pattern**: Use `t.TempDir()` for isolated test directories. Use `os.Chtimes()` to set stale modification times.
- **Key decisions**: Keep max wait short in tests or provide a way to configure timeout. Since the constants are package-level, tests may need to accept the 2-second wait for the "double acquire" test case.
- **Acceptance signal**: `go test ./lockfile/` passes all test cases.
- **Target file state**: `platform/` package exists from T004 with `GetHostsFilePath()` for each OS.
- **Expected test cases** (from contracts/packages.md):
1. **Returns valid path on current OS**: Call `GetHostsFilePath()` — returns a non-empty string and nil error.
2. **Returned path exists**: Call `os.Stat()` on the returned path — file exists (test requires admin environment where hosts file is present).
3. **Error messages include path**: If we can simulate a missing file (difficult on real OS), verify the error mentions the path. This may be a documentation-only test note.
- **Test pattern**: Since these are platform-specific functions using real OS paths, tests run against the real filesystem. Use `//go:build` tags to write OS-specific test expectations if needed.
- **Key decisions**: Platform tests are inherently integration tests (they check the real hosts file location). On CI, ensure the hosts file exists at the expected location. On all three OSes, `/etc/hosts` (Linux/macOS) and `%SystemRoot%\System32\drivers\etc\hosts` (Windows) should exist by default.
- **Acceptance signal**: `go test ./platform/` passes on the current OS.
**Checkpoint**: Foundation ready — user story implementation can now begin
---
## Phase 3: User Story 1 — Hosts File Is Never Corrupted or Lost (Priority: P1) 🎯 MVP
**Goal**: Implement atomic write (temp file + rename) with pre-write backup, managed block parsing, and corruption detection. The hosts file is never truncated, partially written, or lost.
**Independent Test**: Run `go test ./hostfile/` — all tests for reading, parsing, corruption detection, atomic write, and backup lifecycle pass.
**Phase Context**:
- **Files modified in this phase**: `hostfile/hostfile.go` (types, Read, Write), `hostfile/managed.go` (block parsing, AddEntries), `hostfile/managed_test.go` (tests), `hostfile/hostfile_test.go` (tests)
- **Key types/interfaces used**:
```go
// From hostfile/filesystem.go (T003):
type FileSystem interface {
ReadFile(path string) ([]byte, error)
WriteFile(path string, data []byte, perm os.FileMode) error
Stat(path string) (os.FileInfo, error)
CreateTemp(dir, pattern string) (*os.File, error)
Rename(oldpath, newpath string) error
Remove(path string) error
Chmod(path string, mode os.FileMode) error
}
```
- **Codebase conventions to follow**: Error wrapping with `fmt.Errorf("context: %w", err)`. Constants for managed block markers (see data-model.md). Pure functions for managed block operations (no I/O — accept `[]string`, return `[]string`).
### Tests for User Story 1 ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation (TDD Red phase)**
- **Depends on**: T003 (FileSystem interface exists so the package compiles). T009 (types must exist for tests to reference).
- **TDD note**: Write tests first. They will FAIL (or not compile) until T012 implements the logic. If types from T009 don't exist yet, create minimal type stubs at the top of managed_test.go's test helpers or coordinate with T009 completion.
- **Target file state**: `hostfile/managed_test.go` is a new file. Package: `package hostfile` (same package testing for access to unexported helpers if needed, or `package hostfile_test` for black-box — prefer black-box `package hostfile_test`).
- **Test cases to cover** (from data-model.md validation rules and R-008):
1. **No managed block**: Input lines with no markers → `HasManagedBlock = false`, all lines in `PrefixContent`.
2. **Valid managed block**: Lines with both markers in correct order → `PrefixContent`, `ManagedContent`, `PostfixContent` split correctly.
3. **Start marker at line 0**: Start marker is the very first line → `PrefixContent` is empty, block parsed correctly. (Gotcha from R-008: must use sentinel, not zero-check.)
4. **Corruption: start without end**: → error containing "start marker found" and "no end marker".
5. **Corruption: end without start**: → error containing "end marker found" and "no start marker".
6. **Corruption: end before start**: → error containing "end marker" and "before start marker".
- **Depends on**: T003 (FileSystem interface for mocking).
- **TDD note**: Write tests first. They will fail until T013/T014 implement Read/Write. Create a `fakeFileSystem` struct in the test file that implements `FileSystem` interface with configurable behavior:
- **Implements**: Types from `data-model.md` and Manager interface from `contracts/packages.md`:
```go
type HostsFile struct {
Path string
OriginalContent []string
PrefixContent []string
ManagedContent []string
PostfixContent []string
HasManagedBlock bool
}
type DNSEntry struct {
IP string
Hostname string
}
type Manager interface {
Read(path string) (*HostsFile, error)
Write(hf *HostsFile, backupDir string) error
}
```
- **Target file state**: `hostfile/filesystem.go` already exists from T003 with `FileSystem` interface and `OSFileSystem`. This file adds the domain types and the `Manager` implementation struct.
- **Expected implementation**:
```go
package hostfile
const (
StartMarker = "# DNSHelper <<-> START CONFIG"
EndMarker = "# DNSHelper <->> END CONFIG"
)
// DNSEntry represents a single IP-to-hostname mapping.
type DNSEntry struct {
IP string
Hostname string
}
// String returns the hosts file line representation: "IP\tHostname".
func (e DNSEntry) String() string {
return e.IP + "\t" + e.Hostname
}
// HostsFile represents the parsed content of a hosts file.
type HostsFile struct {
Path string
OriginalContent []string
PrefixContent []string
ManagedContent []string
PostfixContent []string
HasManagedBlock bool
}
// Manager handles hosts file read and atomic write operations.
type Manager struct {
fs FileSystem
}
// NewManager creates a Manager with the given FileSystem implementation.
func NewManager(fs FileSystem) *Manager {
return &Manager{fs: fs}
}
```
- **Key decisions**: `Manager` is a concrete struct (not interface) — the interface is defined for documentation/contract purposes. `NewManager` accepts `FileSystem` for dependency injection. `DNSEntry.String()` produces the tab-separated format used in the hosts file (see data-model.md: `<IP>\t<Hostname>`).
- **Gotcha**: Do NOT implement `Read()` or `Write()` yet — just the types, constants, and constructor. Read/Write are implemented in T013 and T014.
- **Acceptance signal**: `go build ./hostfile/` compiles. Tests from T008/T009 can now reference the types (though they'll still fail on logic).
- **Implements**: Managed block parsing that splits raw file lines into prefix/managed/postfix sections. Also implements corruption detection (R-008, FR-011).
- **Target file state**: `hostfile/` package has `filesystem.go` (T003) and `hostfile.go` (T010) with types/constants.
- **Expected implementation**:
```go
package hostfile
import (
"fmt"
"strings"
)
// ParseManagedBlock splits raw file lines into prefix, managed, and postfix sections.
// Returns an error if the managed block markers are corrupt.
return nil, nil, nil, false, fmt.Errorf("managed block is corrupt: end marker (line %d) appears before start marker (line %d)", endIdx+1, startIdx+1)
}
if startIdx < 0 && endIdx < 0 {
// No managed block
return lines, nil, nil, false, nil
}
// Valid managed block
prefix = lines[:startIdx]
managed = lines[startIdx+1 : endIdx]
postfix = lines[endIdx+1:]
return prefix, managed, postfix, true, nil
}
```
- **Key decisions (R-008)**:
- Uses sentinel value `-1` (not zero) for "not found" because the start marker CAN be at line 0. The current code uses `startIndex == 0` to mean "not found" — this is a BUG being fixed.
- Four corruption scenarios detected: start without end, end without start, end before start, duplicate markers.
- Error messages include line numbers (1-based for human readability).
- Scans ALL lines to detect duplicates before deciding on corruption.
- **Gotcha**: The current code's `extractLinesToEdit` in `dataprep.go` (lines 14-51) has the zero-index bug. The new implementation must NOT replicate this. Use `-1` sentinel.
- **Acceptance signal**: `go test ./hostfile/ -run TestParseManagedBlock` passes (corruption scenarios and valid parsing).
- AddEntries first removes existing entries for the SAME hostnames, then adds new entries. This prevents stale IPs from lingering.
- Deduplication is by full line content (IP + hostname pair), matching the existing `dedupeSlice` pattern in `dataprep.go:107-117`.
- `AssembleContent` omits the managed block entirely when there are zero managed entries (per `data-model.md` ManagedBlock rules: "When all entries are removed, the entire block markers included is removed").
- **Acceptance signal**: `go test ./hostfile/ -run TestAddEntries` and `go test ./hostfile/ -run TestAssembleContent` pass.
- **Target file state**: `hostfile/hostfile.go` has types (T010). `hostfile/managed.go` has `ParseManagedBlock` (T011). Now add the `Read` method to `Manager`.
- **Key decisions**: Uses `FileSystem.ReadFile` (not direct `os.ReadFile`) for testability. Splits on `\n` and handles trailing newline. Trims `\r` from each line to handle Windows `\r\n` line endings. Error messages include the file path for diagnostics.
- **Gotcha**: The `\r` trim loop MUST be included — without it, all managed block marker matching and hostname matching will fail on Windows because lines will have a trailing `\r`. The current codebase uses `bufio.Scanner` which handles this automatically, but `strings.Split` does not.
- **Acceptance signal**: `go test ./hostfile/ -run TestRead` passes — correctly parses files with and without managed blocks.
if err := m.renameWithRetry(tempPath, hf.Path); err != nil {
return fmt.Errorf("renaming temp file to hosts file: %w", err)
}
// 9. Success — delete backup
success = true
m.fs.Remove(backupPath)
return nil
}
```
- **Helper functions to include in same file**:
- `createBackup(hostsPath, backupDir string) (string, error)` — copies hosts file to `<backupDir>/hosts.bak.<YYYYMMDD>-<4hex>` using `ReadFile` + `WriteFile`. Generate random suffix with `crypto/rand` or `math/rand`.
- `renameWithRetry(src, dst string) error` — calls `m.fs.Rename`, retries 2-3 times with 200ms delay on failure (for Windows sharing violations per R-002 step 6).
- `slicesEqual(a, b []string) bool` — compares two string slices.
- **Key decisions (R-002)**:
- Temp file MUST be in the same directory as hosts file to avoid cross-volume rename failures (`EXDEV` on Unix).
- `Sync()` before `Close()` for durability.
- `Close()` before `Rename()` — required on Windows.
- Backup uses `ReadFile` + `WriteFile` (copy, not rename) because backup dir is different from hosts dir.
- Backup naming: `hosts.bak.YYYYMMDD-XXXX` where XXXX is 4 hex chars (per data-model.md `BackupFile` entity).
- On success: delete backup. On failure: delete both temp and backup (original was never touched).
- **Gotcha**: The `defer` cleanup MUST check the `success` flag. If rename succeeds, we must NOT delete the temp file (it IS the new hosts file now). The defer should only clean up temp and backup on failure paths.
- **Acceptance signal**: `go test ./hostfile/ -run TestWrite` passes — backup created, temp file used, rename executed, backup deleted on success, cleanup on failure.
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently. `go test ./hostfile/` passes all tests.
---
## Phase 4: User Story 2 — Correct DNS Resolution Results (Priority: P1)
**Goal**: Replace `miekg/dns` with `golang.org/x/net/dns/dnsmessage`. Fix CNAME chain duplicate IP bug. Deduplicate resolved IPs. Support A record and CNAME chain queries.
**Independent Test**: Run `go test ./resolver/` — all tests for DNS resolution, CNAME following, dedup, and error handling pass.
**Phase Context**:
- **Files modified in this phase**: `resolver/resolver.go` (interface + implementation), `resolver/resolver_test.go` (tests)
- **Key types/interfaces used**:
```go
// From contracts/packages.md:
type Resolver interface {
LookupIP(hostname, server string) ([]string, error)
- **Codebase conventions to follow**: Return errors with context (`fmt.Errorf`). Interfaces for testability. No direct `fmt.Println` for errors (the current `dns.go` prints errors directly — this is being fixed).
### Tests for User Story 2 ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation (TDD Red phase)**
- **Depends on**: T016 (interface stubs must exist for tests to compile).
- **TDD note**: Create the test file with all test cases. Tests will fail until T017 implements the logic. To make tests compile, coordinate with T016 to have the interface + struct stubs.
- **Test strategy**: Since DNS resolution involves network I/O, tests should use a fake UDP server (start a `net.ListenPacket("udp", "127.0.0.1:0")` in each test) that returns crafted DNS responses. This avoids external DNS dependencies.
Note: No external test dependencies — use `t.Fatalf` / `t.Errorf` instead of `require` (no testify). Build DNS messages using `dnsmessage.Message{}.Pack()`.
- **Acceptance signal**: Tests compile with resolver stubs. After T017, `go test ./resolver/` passes.
- **Implements**: The full `LookupIP` method on `DNSResolver`. This replaces the current `lookupIP` function in `dns.go` which has two bugs:
1. **CNAME duplicate bug (R-005)**: The current code appends IPs back to themselves: `for _, ip := range ips { ips = append(ips, ip) }` — this doubles every IP.
2. **No CNAME depth limit**: Circular CNAMEs cause infinite recursion.
- **Current buggy code in `dns.go`** (being replaced):
```go
for _, ans := range r.Answer {
if rec, ok := ans.(*dns.CNAME); ok {
ips = lookupIP(rec.Target, resolver)
for _, ip := range ips {
ips = append(ips, ip) // BUG: doubles every IP
}
}
}
```
- **Expected implementation** (complete, using Parser API from R-001):
```go
import (
"fmt"
"math/rand"
"net"
"strings"
"time"
"golang.org/x/net/dns/dnsmessage"
)
func (r *DNSResolver) LookupIP(hostname, server string) ([]string, error) {
- Uses `Parser` API (not `Message.Unpack`) for streaming parse — more memory-efficient and the idiomatic `dnsmessage` approach.
- Random query ID via `math/rand` (acceptable for a non-security-critical CLI tool).
- Truncated response (TC flag) is treated as an error — no TCP fallback per the resolver behavior contract.
- CNAME following: only follows if zero A records were returned (some servers return both A and CNAME in the same response).
- `append(ips, cnameIPs...)` — NOT the buggy `for _, ip := range ips { ips = append(ips, ip) }`.
- **Gotcha**: `dnsmessage.MustNewName` panics on invalid names — use `dnsmessage.NewName` which returns an error.
- **Gotcha**: `parser.AResource()` / `parser.CNAMEResource()` MUST be called immediately after `parser.AnswerHeader()` for the matching type. Calling the wrong resource parser will produce garbled results.
- **Gotcha**: CNAME target strings from `dnsmessage` include a trailing dot (FQDN). Strip it with `strings.TrimSuffix(target, ".")` before recursive call to avoid double-dot issues.
- **Acceptance signal**: `go test ./resolver/` passes all test cases (A records, CNAME chains, depth limit, dedup, error cases).
**Checkpoint**: At this point, User Stories 1 AND 2 should work independently. `go test ./hostfile/ ./resolver/` passes.
---
## Phase 5: User Story 3 — Reliable Deletion of Managed Entries (Priority: P2)
**Goal**: Fix the multi-host deletion bug so that removing entries for specific hostnames correctly removes only those entries, and `delete all` removes the entire managed block.
**Independent Test**: Run `go test ./hostfile/ -run TestRemove` — all deletion test cases pass, including multi-host scenarios.
**Phase Context**:
- **Files modified in this phase**: `hostfile/managed.go` (add RemoveByHostname, RemoveAll), `hostfile/managed_test.go` (add deletion tests)
- **Codebase conventions to follow**: Same pure-function pattern as `AddEntries` — accept `[]string`, return `[]string`. No I/O in managed block functions.
### Tests for User Story 3 ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation (TDD Red phase)**
- **Target file state**: `hostfile/managed_test.go` already exists from T008 with parsing and AddEntries tests. Append new test functions for deletion.
1. **RemoveByHostname — single host**: Managed content has entries for host1 and host2. Remove host1 → only host2 entries remain.
2. **RemoveByHostname — multiple hosts**: Managed content has entries for host1, host2, host3. Remove host1 and host2 → only host3 entries remain.
3. **RemoveByHostname — host not found**: Managed content has entries for host1. Remove host2 → content unchanged, no error.
4. **RemoveByHostname — all hosts removed**: Managed content has entries for host1 only. Remove host1 → empty slice returned (block will be omitted on write).
5. **RemoveAll**: Managed content has entries → returns empty slice.
6. **RemoveAll on empty**: Managed content is already empty → returns empty slice.
7. **Regression: multi-host deletion produces no duplicates**: This is the R-004 bug. Given `ExistingContent = ["1.1.1.1\thost1", "2.2.2.2\thost2", "3.3.3.3\thost3"]`, removing host1 and host2 should produce `["3.3.3.3\thost3"]` — NOT a duplicated or incomplete list.
- **Current buggy code being replaced** (from `dataprep.go` lines 60-70):
for _, host := range data.Hosts { // BUG: wrong loop order
for _, line := range data.ExistingContent {
if !strings.Contains(line, host) {
updatedContent = append(updatedContent, line)
}
}
}
data.NewContent = updatedContent
}
```
The bug: iterates hosts on the outside and re-scans original content each time, producing duplicates and failing to remove entries. See R-004 for detailed trace.
- **Acceptance signal**: Tests compile and fail (TDD red). After T019, `go test ./hostfile/ -run TestRemove` passes.
- **Key decisions (R-004)**: The critical fix is inverting the loop order — iterate LINES on the outside, check ALL hostnames on the inside. The buggy code iterated hostnames on the outside and re-scanned original content each pass, producing duplicates and failing to remove.
- **Acceptance signal**: `go test ./hostfile/ -run TestRemoveByHostname` passes all 7 test cases from T018.
- **Implements**: `RemoveAll` from `contracts/packages.md`:
```go
// RemoveAll returns an empty slice (clears all managed entries).
func RemoveAll() []string
```
- **Target file state**: `hostfile/managed.go` has all previous functions. Add `RemoveAll`.
- **Expected implementation**:
```go
// RemoveAll returns nil, indicating all managed entries should be removed.
// When AssembleContent receives nil/empty managed content, it omits the
// managed block entirely (markers included).
func RemoveAll() []string {
return nil
}
```
- **Key decisions (data-model.md)**: When all entries are removed, `AssembleContent` (T012) omits the entire managed block including markers. This is already handled by T012's `if len(managed) > 0` check.
- **Acceptance signal**: `go test ./hostfile/ -run TestRemoveAll` passes.
**Checkpoint**: At this point, User Stories 1, 2, AND 3 should all work independently.
---
## Phase 6: User Story 4 — Clear Error Messages and Input Validation (Priority: P2)
**Goal**: Rewrite `main.go` to properly validate input, display clear error messages, integrate all packages, and handle partial DNS resolution failures. This is the orchestration layer that wires everything together.
**Independent Test**: Build and run the binary with various invalid inputs (missing flags, empty values, unknown subcommands). Each should produce a descriptive error, exit non-zero, and not modify the hosts file.
**Phase Context**:
- **Files modified in this phase**: `main.go` (complete rewrite)
func (r *DNSResolver) LookupIP(hostname, server string) ([]string, error)
// From platform package (Phase 2):
func GetHostsFilePath() (string, error)
// From lockfile package (Phase 2):
func Acquire(dir string) (Lock, error)
type Lock interface { Release() error }
```
- **Codebase conventions to follow**: Only `main()` calls `os.Exit()`. Errors printed to stderr (`fmt.Fprintf(os.Stderr, ...)`). Informational output to stdout. Banner prints on every invocation. See `contracts/cli.md` for exact output format.
- **Target file state**: `main.go` now contains complete implementations from T021: `main()`, `runAdd()`, `runDelete()`, `runDeleteAll()`, and `printUsage()`.
- **What this task does**: T021 now includes the full code for `runDelete()`, `runDeleteAll()`, and `printUsage()`. This task verifies:
1. `go build .` compiles with all functions present.
2. The `printUsage()` output matches `contracts/cli.md` exactly.
3. `runDeleteAll()` acquires lock → reads → calls `hostfile.RemoveAll()` → writes → prints "Removed all managed entries (N entries removed)" or "No managed entries found".
4. `runDelete()` acquires lock → reads → calls `hostfile.RemoveByHostname()` → writes → prints per-hostname summary "Removed N entries for hostname" or "No managed entries found for hostname".
5. `delete -host` with no matching entries exits 0 (per cli.md: not an error).
- **Acceptance signal**: All CLI paths work correctly per `contracts/cli.md` output examples. `go build .` succeeds.
**Checkpoint**: At this point, User Stories 1-4 should all work. The tool is functionally complete.
---
## Phase 7: User Story 5 — Consistent Behavior Across Operating Systems (Priority: P3)
**Goal**: Verify that all platform-specific code returns errors to callers (never calls `os.Exit`), and that error messages are descriptive and consistent across Windows, Linux, and macOS.
**Independent Test**: Review platform package implementations. All error paths return `error` values with descriptive messages including the file path. No process-terminating calls exist outside `main()`.
**Phase Context**:
- **Files modified in this phase**: `platform/platform_windows.go`, `platform/platform_linux.go`, `platform/platform_darwin.go` (if adjustments needed), `platform/platform_test.go`
- **Key types/interfaces used**: `func GetHostsFilePath() (string, error)` from T004
- **Codebase conventions to follow**: Error messages include the path that was checked. Use `fmt.Errorf` with `%w` for wrapping. No `os.Exit`, `log.Fatal`, or `term()` anywhere in the platform package.
- **Current state**: The platform package was created from scratch in T004 with correct error handling. This task verifies the implementations are consistent and adds any missing error detail.
- **What to verify** (from spec US5 acceptance scenarios):
1. Each platform's `GetHostsFilePath()` returns `error` (never calls `os.Exit` or `log.Fatal`).
2. Error messages include the path that was checked (e.g., `"file does not exist: /etc/hosts"`).
3. Permission errors are wrapped with descriptive context.
4. All three implementations follow the same error message patterns.
- **Current buggy code being replaced** (the OLD `fileoperations_linux.go` and `fileoperations_darwin.go`):
```go
func getHostsFileLocation() (string, error) {
hostsFileLocation = "/etc/hosts"
exists, err := fileExists(hostsFileLocation)
if err != nil {
term(err) // BUG: calls os.Exit(1) instead of returning error
}
if exists == false {
term(errors.New("hosts file does not exist at " + hostsFileLocation)) // BUG: same
}
return hostsFileLocation, nil
}
```
- **Expected state after this task**: All three platform files use `os.Stat` + `fmt.Errorf` and return errors. No calls to `term()`, `os.Exit()`, `log.Fatal()`, or any process-terminating function.
- **Acceptance signal**: `grep -r "os.Exit\|log.Fatal\|term(" platform/` returns no results. `go vet ./platform/` passes.
- **Target file state**: `platform/platform_test.go` exists from T007 with basic happy-path tests.
- **Tests to add**:
1. **Error message contains path**: If `GetHostsFilePath()` returns an error (e.g., on a system where hosts file is missing), the error string must contain a file path. This is hard to test on a real system where the hosts file exists. Consider a documentation-only test note, or a test that verifies the function signature returns `(string, error)`.
2. **No process termination**: This is verified by code review (T023), not a runtime test.
- **Practical approach**: Since the platform package uses real OS paths and the hosts file exists on all CI environments, the primary value is verifying the happy path. Add a test that confirms `GetHostsFilePath()` returns a path that `os.Stat` can find. Corruption/missing file tests require mocking the OS, which the platform package doesn't support (it uses direct `os.Stat`). This is acceptable — the platform package is thin enough to verify by inspection.
- **Acceptance signal**: `go test ./platform/` passes.
**Checkpoint**: Platform behavior is consistent across all OSes. No platform code terminates the process directly.
---
## Phase 8: User Story 6 — Clean, Maintainable Project Structure (Priority: P3)
**Goal**: Remove all old source files, dead code, and the `miekg/dns` dependency. Verify the project compiles cleanly with the new package structure and Go 1.20.
**Independent Test**: `go build .` succeeds with only approved dependencies. `go vet ./...` passes. No dead code or unused imports remain.
**Phase Context**:
- **Files removed in this phase**: `dns.go`, `dataprep.go`, `fileoperations.go`, `fileoperations_windows.go`, `fileoperations_linux.go`, `fileoperations_darwin.go`, `structs.go`
- **Files modified**: `go.mod`, `go.sum` (via `go mod tidy`)
- **Codebase conventions to follow**: Run `go vet ./...` and `go build ./...` to verify cleanliness. No unused imports or variables.
- `dns.go` — old DNS resolution using `miekg/dns`. Replaced by `resolver/resolver.go`.
- `dataprep.go` — old data preparation (parsing, removal, addition). Replaced by `hostfile/managed.go`.
- `fileoperations.go` — old file read/write (includes the unsafe `writeOverContentInFile` with `O_WRONLY|O_TRUNC`). Replaced by `hostfile/hostfile.go`.
- `fileoperations_windows.go` — old Windows platform code. Replaced by `platform/platform_windows.go`.
- `fileoperations_linux.go` — old Linux platform code (calls `term()`). Replaced by `platform/platform_linux.go`.
- `fileoperations_darwin.go` — old macOS platform code (calls `term()`). Replaced by `platform/platform_darwin.go`.
- `structs.go` — old `workingData` struct with unused `IsNew` field. Replaced by types in `hostfile/hostfile.go`.
- **Dead code being removed** (FR-009):
- `modePing` FlagSet in old `main.go` (unused `ping` subcommand) — already removed in T021's rewrite.
- `IsNew` field in `workingData` — removed with `structs.go`.
- `fileExists` function in `fileoperations.go` — removed with file.
- `slicesEqual` function in `dataprep.go` — reimplemented in `hostfile/hostfile.go` if needed, or removed if unused.
- Commented-out debug code in `dataprep.go` lines 46-56 — removed with file.
- **Expected implementation**: Delete all 7 files listed above. The new `main.go` (from T021-T022) imports only the new packages (`dns-helper/hostfile`, `dns-helper/resolver`, `dns-helper/platform`, `dns-helper/lockfile`).
- **Gotcha**: After deleting these files, `go build .` should succeed because `main.go` no longer imports `miekg/dns` or references any of the old functions. If compilation fails, check that `main.go` doesn't reference any old function names.
- **Acceptance signal**: All 7 old files deleted. `go build .` succeeds on the current OS.
require golang.org/x/sys v... // indirect (pulled by x/net)
```
- **What gets removed**: `github.com/miekg/dns`, `golang.org/x/mod`, `golang.org/x/tools` — these are only needed by `miekg/dns`.
- **Expected implementation**: Run `go mod tidy`. Verify `go.mod` no longer contains `miekg/dns`, `x/mod`, or `x/tools`. Run `go list -m all` to confirm only approved dependencies remain.
- **Acceptance signal**: `go mod tidy` succeeds. `go list -m all` shows only `golang.org/x/net` and `golang.org/x/sys`. No unapproved dependencies (FR-010, SC-006).
- **SC-001**: Hosts file never truncated or partially written — verified by atomic write tests (T009/T014). The old `O_WRONLY|O_TRUNC` pattern in `fileoperations.go` is removed (T025).
- **SC-002**: Zero duplicate IPs from CNAME resolution — verified by resolver tests (T015/T017). The self-appending bug in `dns.go` is removed (T025).
- **SC-003**: Multi-host deletion removes exactly targeted entries — verified by deletion tests (T018/T019). The loop-order bug in `dataprep.go` is removed (T025).
- **SC-004**: Invalid inputs produce descriptive errors and non-zero exit — verified by main.go validation logic (T021/T022).
- **SC-005**: Platform functions return errors, not `os.Exit` — verified by platform audit (T023).
- **SC-006**: Clean Go 1.20 build, no dead code, no unapproved deps — verified by T027.
- **SC-007**: Backup created before write, deleted on success — verified by write tests (T009/T014).
- **Acceptance signal**: All seven success criteria can be confirmed via test results and code review. Feature is complete.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — can start immediately
- **Foundational (Phase 2)**: Depends on Setup (T001 for directories, T002 for go.mod)
- **US1 (Phase 3)**: Depends on Foundational (T003 for FileSystem interface)
- **US2 (Phase 4)**: Depends on Setup (T002 for `golang.org/x/net`). Independent of US1.
- **US3 (Phase 5)**: Depends on US1 (T011/T012 for managed.go to extend)
- **US4 (Phase 6)**: Depends on US1 + US2 + US3 (imports all packages)
- **US5 (Phase 7)**: Depends on Foundational (T004 for platform package). Can run in parallel with US1-US3.
- **US6 (Phase 8)**: Depends on US4 (main.go must be complete before removing old files)
- **Polish (Phase 9)**: Depends on all previous phases