├── .actrc ├── .github ├── dependabot.yml └── workflows │ ├── cli-release.yml │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── src └── main.rs └── tests └── integration_tests.rs /.actrc: -------------------------------------------------------------------------------- 1 | # Act configuration for hostie project 2 | 3 | # Use proper Ubuntu images with required tools 4 | 5 | -P ubuntu-latest=catthehacker/ubuntu:act-latest 6 | -P macos-latest=catthehacker/ubuntu:act-latest 7 | -P windows-latest=catthehacker/ubuntu:act-latest 8 | 9 | # Container architecture for Apple Silicon 10 | 11 | --container-architecture linux/amd64 12 | 13 | # Resource limits for performance 14 | 15 | --container-options "--memory=4g" 16 | --container-options "--cpus=2" 17 | 18 | # Reduce verbosity for faster execution 19 | 20 | # --verbose 21 | 22 | # Bind workspace to container 23 | 24 | --bind 25 | 26 | # Use local cache directory 27 | 28 | --artifact-server-path /tmp/act-artifacts 29 | 30 | # Environment variables for testing 31 | 32 | --env CARGO_TERM_COLOR=always 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/cli-release.yml: -------------------------------------------------------------------------------- 1 | name: CLI Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | build: 17 | name: Build (${{ matrix.target }}) 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | include: 23 | - target: x86_64-unknown-linux-gnu 24 | os: ubuntu-latest 25 | name: hostie-linux-x86_64 26 | - target: x86_64-apple-darwin 27 | os: macos-latest 28 | name: hostie-macos-x86_64 29 | - target: aarch64-apple-darwin 30 | os: macos-latest 31 | name: hostie-macos-aarch64 32 | - target: x86_64-pc-windows-msvc 33 | os: windows-latest 34 | name: hostie-windows-x86_64.exe 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | - name: Install Rust toolchain 41 | uses: dtolnay/rust-toolchain@stable 42 | with: 43 | targets: ${{ matrix.target }} 44 | 45 | - name: Cache cargo registry 46 | uses: actions/cache@v4 47 | with: 48 | path: | 49 | ~/.cargo/registry/index/ 50 | ~/.cargo/registry/cache/ 51 | ~/.cargo/git/db/ 52 | target/ 53 | key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} 54 | restore-keys: | 55 | ${{ runner.os }}-${{ matrix.target }}-cargo- 56 | 57 | - name: Build binary 58 | run: cargo build --release --target ${{ matrix.target }} 59 | 60 | - name: Prepare binary 61 | shell: bash 62 | run: | 63 | if [[ "${{ matrix.os }}" == "windows-latest" ]]; then 64 | cp target/${{ matrix.target }}/release/hostie.exe ${{ matrix.name }} 65 | else 66 | cp target/${{ matrix.target }}/release/hostie ${{ matrix.name }} 67 | fi 68 | 69 | - name: Upload artifact 70 | uses: actions/upload-artifact@v4 71 | with: 72 | name: ${{ matrix.name }} 73 | path: ${{ matrix.name }} 74 | 75 | release: 76 | name: Create Release 77 | needs: build 78 | runs-on: ubuntu-latest 79 | if: startsWith(github.ref, 'refs/tags/') 80 | 81 | steps: 82 | - name: Checkout repository 83 | uses: actions/checkout@v4 84 | 85 | - name: Download all artifacts 86 | uses: actions/download-artifact@v4 87 | with: 88 | path: artifacts 89 | 90 | - name: Prepare release assets 91 | run: | 92 | mkdir release 93 | find artifacts -type f -exec cp {} release/ \; 94 | ls -la release/ 95 | 96 | - name: Extract changelog for version 97 | id: changelog 98 | run: | 99 | # Extract the version from the tag (remove 'v' prefix) 100 | VERSION=${GITHUB_REF#refs/tags/v} 101 | echo "Extracting changelog for version: $VERSION" 102 | 103 | # Extract the section for this version from CHANGELOG.md 104 | # Find the line with [VERSION] and extract until the next version or end 105 | if [ -f CHANGELOG.md ]; then 106 | awk -v version="$VERSION" ' 107 | /^## \[/ { 108 | if (found) exit 109 | if ($0 ~ "\\[" version "\\]") { 110 | found = 1 111 | print $0 112 | next 113 | } 114 | } 115 | found && /^## \[/ { exit } 116 | found { print } 117 | ' CHANGELOG.md > release_notes.md 118 | 119 | # Set the content as output (escape for GitHub Actions) 120 | if [ -s release_notes.md ]; then 121 | { 122 | echo 'CHANGELOG<> $GITHUB_OUTPUT 126 | else 127 | echo "CHANGELOG=Release $VERSION" >> $GITHUB_OUTPUT 128 | fi 129 | else 130 | echo "CHANGELOG=Release $VERSION" >> $GITHUB_OUTPUT 131 | fi 132 | 133 | - name: Create GitHub Release 134 | uses: softprops/action-gh-release@v2 135 | with: 136 | files: release/* 137 | body: ${{ steps.changelog.outputs.CHANGELOG }} 138 | draft: false 139 | prerelease: false 140 | env: 141 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: CLI CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ubuntu-latest, macos-latest, windows-latest] 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Install Rust toolchain 26 | uses: dtolnay/rust-toolchain@stable 27 | with: 28 | components: clippy, rustfmt 29 | 30 | - name: Cache cargo registry 31 | uses: actions/cache@v4 32 | with: 33 | path: | 34 | ~/.cargo/registry/index/ 35 | ~/.cargo/registry/cache/ 36 | ~/.cargo/git/db/ 37 | target/ 38 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-cargo- 41 | 42 | - name: Check formatting 43 | if: matrix.os == 'ubuntu-latest' 44 | run: cargo fmt --all -- --check 45 | 46 | - name: Run clippy 47 | run: cargo clippy --all-targets --all-features -- -D warnings 48 | 49 | - name: Build 50 | run: cargo build --verbose 51 | 52 | - name: Build for tests (ensures binary exists for integration tests) 53 | run: cargo build --bin hostie 54 | 55 | - name: Run tests 56 | run: cargo test --verbose 57 | 58 | - name: Run integration tests (tests CLI with mocked hosts files) 59 | run: cargo test --test integration_tests --verbose 60 | 61 | - name: Test summary 62 | run: | 63 | echo "✅ All tests passed successfully on ${{ matrix.os }}!" 64 | echo "📊 Integration tests: 18 tests covering CLI functionality with mocked hosts files" 65 | 66 | - name: Cache cargo-audit 67 | if: matrix.os == 'ubuntu-latest' 68 | uses: actions/cache@v4 69 | with: 70 | path: ~/.cargo/bin/cargo-audit 71 | key: ${{ runner.os }}-cargo-audit 72 | restore-keys: ${{ runner.os }}-cargo-audit 73 | 74 | - name: Install cargo-audit 75 | if: matrix.os == 'ubuntu-latest' 76 | run: which cargo-audit || cargo install cargo-audit 77 | 78 | - name: Security audit 79 | if: matrix.os == 'ubuntu-latest' 80 | run: cargo audit 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.2.0] - 2024-01-15 11 | 12 | ### Fixed 13 | 14 | - **BREAKING**: Fixed hostname collision detection bug that caused false positives 15 | - Previously adding `host` would fail if `localhost` existed 16 | - Now only exact hostname matches prevent additions 17 | - **BREAKING**: Fixed remove function being too aggressive 18 | - Previously removing `host` would also remove `localhost` and `myhost` 19 | - Now only exact IP+hostname combinations are removed 20 | - Fixed file formatting issues that could create double newlines 21 | - Fixed inconsistent async usage throughout codebase 22 | 23 | ### Changed 24 | 25 | - Renamed project from `locdev` to `hostie` 26 | - Updated to Rust edition 2024 27 | - Removed unnecessary tokio dependency (now fully synchronous) 28 | - Improved code quality with clippy fixes 29 | - Modernized GitHub Actions workflows 30 | - Added comprehensive integration tests (24 total tests) 31 | - Improved cross-platform support (Windows hosts file path) 32 | - Enhanced clap CLI configuration following modern best practices 33 | 34 | ### Added 35 | 36 | - Cross-platform hosts file path detection (Windows/Unix) 37 | - Environment variable support for testing (`HOSTIE_HOSTS_FILE`) 38 | - Comprehensive test suite covering all edge cases and bug scenarios 39 | - Better error handling and user feedback 40 | - Whitespace handling for various hosts file formats 41 | 42 | ## [0.1.2] - 2024-01-XX 43 | 44 | ### Added 45 | 46 | - Initial release as `locdev` 47 | - Add entries to hosts file 48 | - Remove entries from hosts file 49 | - List current entries in hosts file 50 | - Protection for system entries (localhost, broadcasthost) 51 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "anstream" 7 | version = "0.6.18" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 10 | dependencies = [ 11 | "anstyle", 12 | "anstyle-parse", 13 | "anstyle-query", 14 | "anstyle-wincon", 15 | "colorchoice", 16 | "is_terminal_polyfill", 17 | "utf8parse", 18 | ] 19 | 20 | [[package]] 21 | name = "anstyle" 22 | version = "1.0.10" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 25 | 26 | [[package]] 27 | name = "anstyle-parse" 28 | version = "0.2.6" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 31 | dependencies = [ 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle-query" 37 | version = "1.1.2" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 40 | dependencies = [ 41 | "windows-sys", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-wincon" 46 | version = "3.0.8" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" 49 | dependencies = [ 50 | "anstyle", 51 | "once_cell_polyfill", 52 | "windows-sys", 53 | ] 54 | 55 | [[package]] 56 | name = "bitflags" 57 | version = "2.9.1" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 60 | 61 | [[package]] 62 | name = "cfg-if" 63 | version = "1.0.0" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 66 | 67 | [[package]] 68 | name = "clap" 69 | version = "4.5.38" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" 72 | dependencies = [ 73 | "clap_builder", 74 | "clap_derive", 75 | ] 76 | 77 | [[package]] 78 | name = "clap_builder" 79 | version = "4.5.38" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" 82 | dependencies = [ 83 | "anstream", 84 | "anstyle", 85 | "clap_lex", 86 | "strsim", 87 | ] 88 | 89 | [[package]] 90 | name = "clap_derive" 91 | version = "4.5.32" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 94 | dependencies = [ 95 | "heck", 96 | "proc-macro2", 97 | "quote", 98 | "syn", 99 | ] 100 | 101 | [[package]] 102 | name = "clap_lex" 103 | version = "0.7.4" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 106 | 107 | [[package]] 108 | name = "colorchoice" 109 | version = "1.0.3" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 112 | 113 | [[package]] 114 | name = "colored" 115 | version = "3.0.0" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" 118 | dependencies = [ 119 | "windows-sys", 120 | ] 121 | 122 | [[package]] 123 | name = "errno" 124 | version = "0.3.12" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" 127 | dependencies = [ 128 | "libc", 129 | "windows-sys", 130 | ] 131 | 132 | [[package]] 133 | name = "fastrand" 134 | version = "2.3.0" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 137 | 138 | [[package]] 139 | name = "getrandom" 140 | version = "0.3.3" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 143 | dependencies = [ 144 | "cfg-if", 145 | "libc", 146 | "r-efi", 147 | "wasi", 148 | ] 149 | 150 | [[package]] 151 | name = "heck" 152 | version = "0.5.0" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 155 | 156 | [[package]] 157 | name = "hostie" 158 | version = "0.2.0" 159 | dependencies = [ 160 | "clap", 161 | "colored", 162 | "tempfile", 163 | "thiserror", 164 | ] 165 | 166 | [[package]] 167 | name = "is_terminal_polyfill" 168 | version = "1.70.1" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 171 | 172 | [[package]] 173 | name = "libc" 174 | version = "0.2.172" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 177 | 178 | [[package]] 179 | name = "linux-raw-sys" 180 | version = "0.9.4" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 183 | 184 | [[package]] 185 | name = "once_cell" 186 | version = "1.21.3" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 189 | 190 | [[package]] 191 | name = "once_cell_polyfill" 192 | version = "1.70.1" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 195 | 196 | [[package]] 197 | name = "proc-macro2" 198 | version = "1.0.95" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 201 | dependencies = [ 202 | "unicode-ident", 203 | ] 204 | 205 | [[package]] 206 | name = "quote" 207 | version = "1.0.40" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 210 | dependencies = [ 211 | "proc-macro2", 212 | ] 213 | 214 | [[package]] 215 | name = "r-efi" 216 | version = "5.2.0" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 219 | 220 | [[package]] 221 | name = "rustix" 222 | version = "1.0.7" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" 225 | dependencies = [ 226 | "bitflags", 227 | "errno", 228 | "libc", 229 | "linux-raw-sys", 230 | "windows-sys", 231 | ] 232 | 233 | [[package]] 234 | name = "strsim" 235 | version = "0.11.1" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 238 | 239 | [[package]] 240 | name = "syn" 241 | version = "2.0.101" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" 244 | dependencies = [ 245 | "proc-macro2", 246 | "quote", 247 | "unicode-ident", 248 | ] 249 | 250 | [[package]] 251 | name = "tempfile" 252 | version = "3.20.0" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" 255 | dependencies = [ 256 | "fastrand", 257 | "getrandom", 258 | "once_cell", 259 | "rustix", 260 | "windows-sys", 261 | ] 262 | 263 | [[package]] 264 | name = "thiserror" 265 | version = "2.0.12" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 268 | dependencies = [ 269 | "thiserror-impl", 270 | ] 271 | 272 | [[package]] 273 | name = "thiserror-impl" 274 | version = "2.0.12" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 277 | dependencies = [ 278 | "proc-macro2", 279 | "quote", 280 | "syn", 281 | ] 282 | 283 | [[package]] 284 | name = "unicode-ident" 285 | version = "1.0.18" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 288 | 289 | [[package]] 290 | name = "utf8parse" 291 | version = "0.2.2" 292 | source = "registry+https://github.com/rust-lang/crates.io-index" 293 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 294 | 295 | [[package]] 296 | name = "wasi" 297 | version = "0.14.2+wasi-0.2.4" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 300 | dependencies = [ 301 | "wit-bindgen-rt", 302 | ] 303 | 304 | [[package]] 305 | name = "windows-sys" 306 | version = "0.59.0" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 309 | dependencies = [ 310 | "windows-targets", 311 | ] 312 | 313 | [[package]] 314 | name = "windows-targets" 315 | version = "0.52.6" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 318 | dependencies = [ 319 | "windows_aarch64_gnullvm", 320 | "windows_aarch64_msvc", 321 | "windows_i686_gnu", 322 | "windows_i686_gnullvm", 323 | "windows_i686_msvc", 324 | "windows_x86_64_gnu", 325 | "windows_x86_64_gnullvm", 326 | "windows_x86_64_msvc", 327 | ] 328 | 329 | [[package]] 330 | name = "windows_aarch64_gnullvm" 331 | version = "0.52.6" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 334 | 335 | [[package]] 336 | name = "windows_aarch64_msvc" 337 | version = "0.52.6" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 340 | 341 | [[package]] 342 | name = "windows_i686_gnu" 343 | version = "0.52.6" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 346 | 347 | [[package]] 348 | name = "windows_i686_gnullvm" 349 | version = "0.52.6" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 352 | 353 | [[package]] 354 | name = "windows_i686_msvc" 355 | version = "0.52.6" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 358 | 359 | [[package]] 360 | name = "windows_x86_64_gnu" 361 | version = "0.52.6" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 364 | 365 | [[package]] 366 | name = "windows_x86_64_gnullvm" 367 | version = "0.52.6" 368 | source = "registry+https://github.com/rust-lang/crates.io-index" 369 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 370 | 371 | [[package]] 372 | name = "windows_x86_64_msvc" 373 | version = "0.52.6" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 376 | 377 | [[package]] 378 | name = "wit-bindgen-rt" 379 | version = "0.39.0" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 382 | dependencies = [ 383 | "bitflags", 384 | ] 385 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hostie" 3 | version = "0.2.0" 4 | edition = "2024" 5 | description = "hostie is a command-line utility for managing your /etc/hosts file." 6 | authors = ["Nicholas Rempel "] 7 | license = "MIT" 8 | keywords = ["hosts", "localhost", "development", "cli"] 9 | categories = ["command-line-utilities", "development-tools"] 10 | repository = "https://github.com/nrempel/hostie" 11 | rust-version = "1.85" 12 | 13 | [dependencies] 14 | clap = { version = "4.5.38", features = ["derive", "cargo"] } 15 | colored = "3.0.0" 16 | thiserror = "2.0.12" 17 | 18 | [dev-dependencies] 19 | tempfile = "3.20.0" 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Nicholas Rempel 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hostie 🛠️ 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/hostie.svg)](https://crates.io/crates/hostie) 4 | [![GitHub Actions](https://github.com/nrempel/hostie/actions/workflows/rust.yml/badge.svg)](https://github.com/nrempel/hostie/actions) 5 | [![GitHub Releases](https://img.shields.io/github/release/nrempel/hostie.svg)](https://github.com/nrempel/hostie/releases) 6 | 7 | hostie is a handy CLI tool that simplifies the process of adding, removing, and 8 | listing entries in your system's hosts file (`/etc/hosts` on Unix, `C:\Windows\System32\drivers\etc\hosts` on Windows). 9 | 10 | Perfect for developers who need to quickly map hostnames to IP addresses for local development, testing, or debugging. You no longer need to deal with manual and error-prone editing. Now, it's as simple as running a command. 11 | 12 | ## Why use hostie? 13 | 14 | Instead of manually editing your hosts file like this: 15 | 16 | ```bash 17 | sudo nano /etc/hosts 18 | # Navigate to the right line, be careful not to break anything... 19 | # Add: 127.0.0.1 myapp.local 20 | # Save and exit 21 | ``` 22 | 23 | Just do this: 24 | 25 | ```bash 26 | sudo hostie add 127.0.0.1 myapp.local 27 | ``` 28 | 29 | ## Features 30 | 31 | - 🚀 **Simple commands**: Add, remove, and list hosts entries with ease 32 | - 🛡️ **Safe operations**: Prevents accidental removal of system entries like `localhost` 33 | - 🎯 **Precise matching**: Only exact IP+hostname combinations are affected 34 | - 🌍 **Cross-platform**: Works on macOS, Linux, and Windows 35 | - 📝 **Preserves formatting**: Keeps your hosts file comments and structure intact 36 | - ✅ **Duplicate prevention**: Won't add the same hostname twice 37 | - 🧪 **Well-tested**: 24 comprehensive tests ensure reliability 38 | 39 | ## Installation 40 | 41 | ### Download Compiled Binaries 42 | 43 | You can download the compiled binaries for hostie from the 44 | [GitHub Releases](https://github.com/nrempel/hostie/releases) page. Choose the 45 | binary that corresponds to your operating system and architecture, and place it 46 | in a directory included in your system's `PATH` environment variable. 47 | 48 | ### Install with Cargo 49 | 50 | To install hostie using Cargo, you'll need to have 51 | [Rust](https://www.rust-lang.org/tools/install) installed on your system. Once 52 | Rust is installed, you can install hostie with Cargo: 53 | 54 | ```bash 55 | cargo install hostie 56 | ``` 57 | 58 | ## Usage 59 | 60 | ```bash 61 | hostie [COMMAND] 62 | ``` 63 | 64 | ### Commands 65 | 66 | - `add `: Add an entry to the hosts file with the specified IP 67 | and hostname 68 | - `remove `: Remove the entry with the specified IP and hostname 69 | from the hosts file 70 | - `list`: Print the current entries in the hosts file 71 | 72 | ## Examples 73 | 74 | ### Basic Usage 75 | 76 | **Add a local development site:** 77 | 78 | ```bash 79 | sudo hostie add 127.0.0.1 myapp.local 80 | ``` 81 | 82 | **Remove an entry when you're done:** 83 | 84 | ```bash 85 | sudo hostie remove 127.0.0.1 myapp.local 86 | ``` 87 | 88 | **List all current entries:** 89 | 90 | ```bash 91 | hostie list 92 | ``` 93 | 94 | ### Common Development Scenarios 95 | 96 | **Set up multiple local services:** 97 | 98 | ```bash 99 | sudo hostie add 127.0.0.1 api.local 100 | sudo hostie add 127.0.0.1 frontend.local 101 | sudo hostie add 127.0.0.1 admin.local 102 | ``` 103 | 104 | **Point to a staging server:** 105 | 106 | ```bash 107 | sudo hostie add 192.168.1.100 staging.mycompany.com 108 | ``` 109 | 110 | **Override a production domain for testing:** 111 | 112 | ```bash 113 | sudo hostie add 127.0.0.1 api.production.com 114 | ``` 115 | 116 | **Block a website (point to localhost):** 117 | 118 | ```bash 119 | sudo hostie add 127.0.0.1 distracting-website.com 120 | ``` 121 | 122 | ### Sample Output 123 | 124 | ```bash 125 | $ hostie list 126 | 127.0.0.1 localhost 127 | 127.0.0.1 myapp.local 128 | 192.168.1.100 staging.mycompany.com 129 | ::1 localhost 130 | ``` 131 | 132 | ### What hostie does for you 133 | 134 | - ✅ **Prevents duplicates**: Won't add the same hostname twice 135 | - ✅ **Protects system entries**: Can't accidentally remove `localhost` 136 | - ✅ **Preserves formatting**: Keeps comments and empty lines intact 137 | - ✅ **Cross-platform**: Works on macOS, Linux, and Windows 138 | - ✅ **Safe operations**: Only modifies exact matches, no false positives 139 | 140 | ## Note 141 | 142 | You need to use `sudo` to execute the `add` and `remove` commands, as the hosts 143 | file requires administrator privileges to modify its contents. 144 | 145 | ## License 146 | 147 | This project is available under the MIT License. 148 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{self, OpenOptions, read_to_string}; 2 | use std::io::prelude::*; 3 | use std::process::ExitCode; 4 | 5 | use clap::Parser; 6 | use colored::{ColoredString, Colorize}; 7 | use thiserror::Error; 8 | 9 | fn main() -> ExitCode { 10 | let opts: Options = Options::parse(); 11 | 12 | let result = match opts.subcmd { 13 | SubCommand::Add(add) => add_hosts_entry(&add), 14 | SubCommand::Remove(remove) => remove_hosts_entry(remove), 15 | SubCommand::List => print_current_entries(), 16 | }; 17 | 18 | match result { 19 | Ok(msg) => { 20 | println!("{msg}"); 21 | ExitCode::SUCCESS 22 | } 23 | Err(err) => { 24 | eprintln!("{err}"); 25 | ExitCode::FAILURE 26 | } 27 | } 28 | } 29 | 30 | fn add_hosts_entry(add: &AddRemove) -> Result { 31 | let new_entry = format!("{} {}", add.ip.cyan().bold(), add.hostname.magenta().bold()); 32 | let new_entry_line = format!("{} {}", add.ip, add.hostname); 33 | 34 | let contents = read_to_string(get_hosts_path())?; 35 | 36 | // Check for exact hostname match (not just ends_with) 37 | let hostname_exists = contents 38 | .lines() 39 | .filter(|line| !line.trim().is_empty() && !line.starts_with('#')) 40 | .any(|line| { 41 | let parts: Vec<&str> = line.split_whitespace().collect(); 42 | parts.get(1).is_some_and(|h| h == &add.hostname) 43 | }); 44 | 45 | if hostname_exists { 46 | return Err(Error::Generic( 47 | format!("Entry already exists: {new_entry}").red(), 48 | )); 49 | } 50 | 51 | let mut file = OpenOptions::new().append(true).open(get_hosts_path())?; 52 | file.write_all(format!("{}\n", new_entry_line).as_bytes())?; 53 | 54 | Ok(format!("Added entry to hosts file: {new_entry}").green()) 55 | } 56 | 57 | fn remove_hosts_entry(remove: AddRemove) -> Result { 58 | let protected_hostnames = ["localhost", "broadcasthost"]; 59 | 60 | if protected_hostnames.contains(&remove.hostname.as_str()) { 61 | return Err(Error::Generic( 62 | format!( 63 | "Cannot remove protected entry: {}", 64 | remove.hostname.magenta().bold() 65 | ) 66 | .red(), 67 | )); 68 | } 69 | 70 | let contents = read_to_string(get_hosts_path())?; 71 | 72 | let entry_to_remove = format!( 73 | "{} {}", 74 | remove.ip.cyan().bold(), 75 | remove.hostname.magenta().bold() 76 | ); 77 | let entry_to_remove_line = format!("{} {}", remove.ip, remove.hostname); 78 | 79 | // Check for exact IP+hostname match 80 | let entry_exists = contents 81 | .lines() 82 | .filter(|line| !line.trim().is_empty() && !line.starts_with('#')) 83 | .any(|line| { 84 | let normalized = line.split_whitespace().collect::>().join(" "); 85 | normalized == entry_to_remove_line 86 | }); 87 | 88 | if !entry_exists { 89 | return Err(Error::Generic( 90 | format!("Entry does not exist: {entry_to_remove}").red(), 91 | )); 92 | } 93 | 94 | // Remove only the exact matching line 95 | let entries: Vec<_> = contents 96 | .lines() 97 | .filter(|line| { 98 | if line.trim().is_empty() || line.starts_with('#') { 99 | true // Keep comments and empty lines 100 | } else { 101 | let normalized = line.split_whitespace().collect::>().join(" "); 102 | normalized != entry_to_remove_line 103 | } 104 | }) 105 | .collect(); 106 | 107 | // Write back with proper formatting (single newline at end) 108 | let new_content = if entries.is_empty() { 109 | String::new() 110 | } else { 111 | format!("{}\n", entries.join("\n")) 112 | }; 113 | 114 | fs::write(get_hosts_path(), new_content)?; 115 | 116 | Ok(format!( 117 | "Removed entry from hosts file: {} {}", 118 | remove.ip.cyan().bold(), 119 | remove.hostname.magenta().bold() 120 | ) 121 | .green()) 122 | } 123 | 124 | fn print_current_entries() -> Result { 125 | let contents = read_to_string(get_hosts_path())?; 126 | 127 | let current_entries = contents 128 | .lines() 129 | .filter(|line| !line.starts_with('#') && !line.is_empty()) 130 | .map(|e| { 131 | let parts: Vec<&str> = e.split_whitespace().collect(); 132 | let ip = parts.first().unwrap_or(&""); 133 | let hostname = parts.get(1).unwrap_or(&""); 134 | format!("{} {}", ip.cyan().bold(), hostname.magenta().bold()) 135 | }) 136 | .collect::>() 137 | .join("\n"); 138 | 139 | Ok(current_entries.green()) 140 | } 141 | 142 | #[derive(Parser)] 143 | #[command(author, version, about, long_about = None)] 144 | struct Options { 145 | #[command(subcommand)] 146 | subcmd: SubCommand, 147 | } 148 | 149 | #[derive(Parser)] 150 | enum SubCommand { 151 | /// Add a new entry to your hosts file 152 | Add(AddRemove), 153 | /// Remove an entry from your hosts file 154 | Remove(AddRemove), 155 | /// List all entries in your hosts file 156 | List, 157 | } 158 | 159 | #[derive(Parser)] 160 | struct AddRemove { 161 | /// The IP address to use 162 | #[arg(value_name = "IP")] 163 | ip: String, 164 | 165 | /// The hostname to associate with the IP address 166 | #[arg(value_name = "HOSTNAME")] 167 | hostname: String, 168 | } 169 | 170 | #[derive(Error, Debug)] 171 | enum Error { 172 | #[error("io error: {0}")] 173 | Io(#[from] std::io::Error), 174 | #[error("{0}")] 175 | Generic(ColoredString), 176 | } 177 | 178 | fn get_hosts_path() -> String { 179 | std::env::var("HOSTIE_HOSTS_FILE").unwrap_or_else(|_| { 180 | if cfg!(windows) { 181 | r"C:\Windows\System32\drivers\etc\hosts".to_string() 182 | } else { 183 | "/etc/hosts".to_string() 184 | } 185 | }) 186 | } 187 | -------------------------------------------------------------------------------- /tests/integration_tests.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::process::Command; 3 | use tempfile::NamedTempFile; 4 | 5 | /// Helper to create a hostie command 6 | fn hostie_command() -> Command { 7 | Command::new(env!("CARGO_BIN_EXE_hostie")) 8 | } 9 | 10 | /// Helper to create a hostie command with a custom hosts file 11 | fn hostie_command_with_hosts_file(hosts_file_path: &str) -> Command { 12 | let mut cmd = hostie_command(); 13 | cmd.env("HOSTIE_HOSTS_FILE", hosts_file_path); 14 | cmd 15 | } 16 | 17 | /// Helper to create a test hosts file with initial content 18 | fn create_test_hosts_file(content: &str) -> NamedTempFile { 19 | let file = NamedTempFile::new().unwrap(); 20 | fs::write(file.path(), content).unwrap(); 21 | file 22 | } 23 | 24 | #[test] 25 | fn test_help_flag() { 26 | let output = hostie_command() 27 | .arg("--help") 28 | .output() 29 | .expect("Failed to execute hostie"); 30 | 31 | assert!(output.status.success()); 32 | let stdout = String::from_utf8(output.stdout).unwrap(); 33 | assert!(stdout.contains("command-line utility for managing your /etc/hosts file")); 34 | assert!(stdout.contains("Usage:")); 35 | } 36 | 37 | #[test] 38 | fn test_version_flag() { 39 | let output = hostie_command() 40 | .arg("--version") 41 | .output() 42 | .expect("Failed to execute hostie"); 43 | 44 | assert!(output.status.success()); 45 | let stdout = String::from_utf8(output.stdout).unwrap(); 46 | assert!(stdout.contains("hostie")); 47 | } 48 | 49 | #[test] 50 | fn test_list_with_real_hosts_file() { 51 | // This test uses the actual hosts file (/etc/hosts on Unix, C:\Windows\System32\drivers\etc\hosts on Windows) 52 | // It should work without sudo/admin since we're only reading 53 | let output = hostie_command() 54 | .arg("list") 55 | .output() 56 | .expect("Failed to execute hostie"); 57 | 58 | // On some systems (especially Windows), the hosts file might not be readable without admin privileges 59 | // So we'll accept either success or a permission error 60 | if !output.status.success() { 61 | let stderr = String::from_utf8(output.stderr).unwrap_or_default(); 62 | // Check if it's a permission error, which is acceptable 63 | assert!( 64 | stderr.contains("Permission denied") 65 | || stderr.contains("Access is denied") 66 | || stderr.contains("io error"), 67 | "Unexpected error: {}", 68 | stderr 69 | ); 70 | } 71 | // If it succeeds, that's great too - just verify it runs 72 | } 73 | 74 | #[test] 75 | fn test_list_empty_hosts_file() { 76 | let hosts_file = create_test_hosts_file(""); 77 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 78 | .arg("list") 79 | .output() 80 | .expect("Failed to execute hostie"); 81 | 82 | assert!(output.status.success()); 83 | let stdout = String::from_utf8(output.stdout).unwrap(); 84 | // Should not contain any actual host entries 85 | assert!(!stdout.contains("127.0.0.1")); 86 | assert!(!stdout.contains("192.168")); 87 | } 88 | 89 | #[test] 90 | fn test_list_hosts_with_entries() { 91 | let initial_content = "127.0.0.1 localhost\n192.168.1.1 router.local\n"; 92 | let hosts_file = create_test_hosts_file(initial_content); 93 | 94 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 95 | .arg("list") 96 | .output() 97 | .expect("Failed to execute hostie"); 98 | 99 | assert!(output.status.success()); 100 | let stdout = String::from_utf8(output.stdout).unwrap(); 101 | assert!(stdout.contains("localhost")); 102 | assert!(stdout.contains("router.local")); 103 | } 104 | 105 | #[test] 106 | fn test_add_new_entry() { 107 | let initial_content = "127.0.0.1 localhost\n"; 108 | let hosts_file = create_test_hosts_file(initial_content); 109 | 110 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 111 | .args(["add", "192.168.1.100", "test.local"]) 112 | .output() 113 | .expect("Failed to execute hostie"); 114 | 115 | assert!(output.status.success()); 116 | 117 | // Verify the entry was added 118 | let content = fs::read_to_string(hosts_file.path()).unwrap(); 119 | assert!(content.contains("192.168.1.100 test.local")); 120 | assert!(content.contains("127.0.0.1 localhost")); // Original entry should remain 121 | } 122 | 123 | #[test] 124 | fn test_add_duplicate_hostname() { 125 | let initial_content = "127.0.0.1 localhost\n192.168.1.100 test.local\n"; 126 | let hosts_file = create_test_hosts_file(initial_content); 127 | 128 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 129 | .args(["add", "192.168.1.200", "test.local"]) // Different IP, same hostname 130 | .output() 131 | .expect("Failed to execute hostie"); 132 | 133 | // Should fail because hostname already exists 134 | assert!(!output.status.success()); 135 | let stderr = String::from_utf8(output.stderr).unwrap(); 136 | assert!(stderr.contains("already exists")); 137 | } 138 | 139 | #[test] 140 | fn test_remove_existing_entry() { 141 | let initial_content = "127.0.0.1 localhost\n192.168.1.100 test.local\n"; 142 | let hosts_file = create_test_hosts_file(initial_content); 143 | 144 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 145 | .args(["remove", "192.168.1.100", "test.local"]) 146 | .output() 147 | .expect("Failed to execute hostie"); 148 | 149 | assert!(output.status.success()); 150 | 151 | // Verify the entry was removed 152 | let content = fs::read_to_string(hosts_file.path()).unwrap(); 153 | assert!(!content.contains("192.168.1.100 test.local")); 154 | assert!(content.contains("127.0.0.1 localhost")); // Other entries should remain 155 | } 156 | 157 | #[test] 158 | fn test_remove_nonexistent_entry() { 159 | let initial_content = "127.0.0.1 localhost\n"; 160 | let hosts_file = create_test_hosts_file(initial_content); 161 | 162 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 163 | .args(["remove", "192.168.1.100", "nonexistent.local"]) 164 | .output() 165 | .expect("Failed to execute hostie"); 166 | 167 | // Should fail because entry doesn't exist 168 | assert!(!output.status.success()); 169 | let stderr = String::from_utf8(output.stderr).unwrap(); 170 | assert!(stderr.contains("does not exist")); 171 | } 172 | 173 | #[test] 174 | fn test_remove_protected_entry() { 175 | let initial_content = "127.0.0.1 localhost\n192.168.1.100 test.local\n"; 176 | let hosts_file = create_test_hosts_file(initial_content); 177 | 178 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 179 | .args(["remove", "127.0.0.1", "localhost"]) 180 | .output() 181 | .expect("Failed to execute hostie"); 182 | 183 | // Should fail because localhost is protected 184 | assert!(!output.status.success()); 185 | let stderr = String::from_utf8(output.stderr).unwrap(); 186 | assert!(stderr.contains("protected")); 187 | } 188 | 189 | #[test] 190 | fn test_handles_comments_and_empty_lines() { 191 | let initial_content = r#"# This is a comment 192 | 127.0.0.1 localhost 193 | 194 | # Another comment 195 | 192.168.1.1 router.local 196 | "#; 197 | let hosts_file = create_test_hosts_file(initial_content); 198 | 199 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 200 | .arg("list") 201 | .output() 202 | .expect("Failed to execute hostie"); 203 | 204 | assert!(output.status.success()); 205 | let stdout = String::from_utf8(output.stdout).unwrap(); 206 | 207 | // Should show entries but not comments 208 | assert!(stdout.contains("localhost")); 209 | assert!(stdout.contains("router.local")); 210 | assert!(!stdout.contains("# This is a comment")); 211 | } 212 | 213 | #[test] 214 | fn test_add_to_empty_file() { 215 | let hosts_file = create_test_hosts_file(""); 216 | 217 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 218 | .args(["add", "192.168.1.100", "test.local"]) 219 | .output() 220 | .expect("Failed to execute hostie"); 221 | 222 | assert!(output.status.success()); 223 | 224 | // Verify the entry was added 225 | let content = fs::read_to_string(hosts_file.path()).unwrap(); 226 | assert!(content.contains("192.168.1.100 test.local")); 227 | } 228 | 229 | #[test] 230 | fn test_multiple_operations() { 231 | let initial_content = "127.0.0.1 localhost\n"; 232 | let hosts_file = create_test_hosts_file(initial_content); 233 | let hosts_path = hosts_file.path().to_str().unwrap(); 234 | 235 | // Add an entry 236 | let output = hostie_command_with_hosts_file(hosts_path) 237 | .args(["add", "192.168.1.100", "test.local"]) 238 | .output() 239 | .expect("Failed to execute hostie"); 240 | assert!(output.status.success()); 241 | 242 | // List entries 243 | let output = hostie_command_with_hosts_file(hosts_path) 244 | .arg("list") 245 | .output() 246 | .expect("Failed to execute hostie"); 247 | assert!(output.status.success()); 248 | let stdout = String::from_utf8(output.stdout).unwrap(); 249 | assert!(stdout.contains("localhost")); 250 | assert!(stdout.contains("test.local")); 251 | 252 | // Remove the entry 253 | let output = hostie_command_with_hosts_file(hosts_path) 254 | .args(["remove", "192.168.1.100", "test.local"]) 255 | .output() 256 | .expect("Failed to execute hostie"); 257 | assert!(output.status.success()); 258 | 259 | // Verify it's gone 260 | let content = fs::read_to_string(hosts_file.path()).unwrap(); 261 | assert!(!content.contains("192.168.1.100 test.local")); 262 | assert!(content.contains("127.0.0.1 localhost")); 263 | } 264 | 265 | // Tests for error cases without sudo (these still apply) 266 | #[test] 267 | fn test_add_without_sudo_fails() { 268 | // Skip this test if running as root (like in CI containers) or on Windows where permissions work differently 269 | if std::env::var("USER").unwrap_or_default() == "root" || cfg!(windows) { 270 | return; 271 | } 272 | 273 | // This should fail because we don't have permission to write to /etc/hosts 274 | let output = hostie_command() 275 | .args(["add", "192.168.1.100", "test.local"]) 276 | .output() 277 | .expect("Failed to execute hostie"); 278 | 279 | assert!(!output.status.success()); 280 | let stderr = String::from_utf8(output.stderr).unwrap(); 281 | // Should get a permission denied error 282 | assert!(stderr.contains("Permission denied") || stderr.contains("io error")); 283 | } 284 | 285 | #[test] 286 | fn test_remove_without_sudo_fails() { 287 | // Skip this test if running as root (like in CI containers) or on Windows where permissions work differently 288 | if std::env::var("USER").unwrap_or_default() == "root" || cfg!(windows) { 289 | return; 290 | } 291 | 292 | // This should fail because we don't have permission to write to /etc/hosts 293 | let output = hostie_command() 294 | .args(["remove", "192.168.1.100", "test.local"]) 295 | .output() 296 | .expect("Failed to execute hostie"); 297 | 298 | assert!(!output.status.success()); 299 | let stderr = String::from_utf8(output.stderr).unwrap(); 300 | // Should get either a permission denied error or entry not found 301 | assert!( 302 | stderr.contains("Permission denied") 303 | || stderr.contains("io error") 304 | || stderr.contains("does not exist") 305 | ); 306 | } 307 | 308 | #[test] 309 | fn test_invalid_command() { 310 | let output = hostie_command() 311 | .arg("invalid-command") 312 | .output() 313 | .expect("Failed to execute hostie"); 314 | 315 | assert!(!output.status.success()); 316 | let stderr = String::from_utf8(output.stderr).unwrap(); 317 | assert!(stderr.contains("error:") || stderr.contains("invalid")); 318 | } 319 | 320 | #[test] 321 | fn test_add_missing_arguments() { 322 | let output = hostie_command() 323 | .args(["add", "192.168.1.100"]) 324 | .output() 325 | .expect("Failed to execute hostie"); 326 | 327 | assert!(!output.status.success()); 328 | let stderr = String::from_utf8(output.stderr).unwrap(); 329 | assert!(stderr.contains("required") || stderr.contains("argument")); 330 | } 331 | 332 | #[test] 333 | fn test_remove_missing_arguments() { 334 | let output = hostie_command() 335 | .args(["remove", "192.168.1.100"]) 336 | .output() 337 | .expect("Failed to execute hostie"); 338 | 339 | assert!(!output.status.success()); 340 | let stderr = String::from_utf8(output.stderr).unwrap(); 341 | assert!(stderr.contains("required") || stderr.contains("argument")); 342 | } 343 | 344 | // NEW TESTS TO DEMONSTRATE BUGS AND SPECIFY CORRECT BEHAVIOR 345 | 346 | #[test] 347 | fn test_add_hostname_collision_false_positive_bug() { 348 | // BUG: Current implementation uses line.ends_with() which causes false positives 349 | let initial_content = "127.0.0.1 localhost\n192.168.1.1 mytest.local\n"; 350 | let hosts_file = create_test_hosts_file(initial_content); 351 | 352 | // This should succeed because "test.local" is different from "mytest.local" 353 | // But current implementation will fail because "mytest.local".ends_with("test.local") is false 354 | // Actually, let me test the real bug case: 355 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 356 | .args(["add", "192.168.1.200", "host"]) // Should fail because "localhost".ends_with("host") is true 357 | .output() 358 | .expect("Failed to execute hostie"); 359 | 360 | // Current buggy behavior: this will fail even though "host" != "localhost" 361 | // Correct behavior: this should succeed because they're different hostnames 362 | assert!( 363 | output.status.success(), 364 | "Should allow adding 'host' when 'localhost' exists" 365 | ); 366 | 367 | let content = fs::read_to_string(hosts_file.path()).unwrap(); 368 | assert!(content.contains("192.168.1.200 host")); 369 | } 370 | 371 | #[test] 372 | fn test_add_exact_hostname_duplicate_should_fail() { 373 | // This test specifies the CORRECT behavior for actual duplicates 374 | let initial_content = "127.0.0.1 localhost\n192.168.1.100 test.local\n"; 375 | let hosts_file = create_test_hosts_file(initial_content); 376 | 377 | // This should fail because "test.local" already exists (exact match) 378 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 379 | .args(["add", "192.168.1.200", "test.local"]) 380 | .output() 381 | .expect("Failed to execute hostie"); 382 | 383 | assert!(!output.status.success()); 384 | let stderr = String::from_utf8(output.stderr).unwrap(); 385 | assert!(stderr.contains("already exists")); 386 | } 387 | 388 | #[test] 389 | fn test_remove_exact_entry_only() { 390 | // BUG: Current implementation removes any line ending with hostname 391 | let initial_content = "127.0.0.1 localhost\n192.168.1.100 myhost\n192.168.1.200 host\n"; 392 | let hosts_file = create_test_hosts_file(initial_content); 393 | 394 | // Remove "host" should only remove the exact "192.168.1.200 host" entry 395 | // It should NOT remove "myhost" even though "myhost".ends_with("host") is true 396 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 397 | .args(["remove", "192.168.1.200", "host"]) 398 | .output() 399 | .expect("Failed to execute hostie"); 400 | 401 | assert!(output.status.success()); 402 | 403 | let content = fs::read_to_string(hosts_file.path()).unwrap(); 404 | assert!( 405 | !content.contains("192.168.1.200 host"), 406 | "Should remove the exact entry" 407 | ); 408 | assert!( 409 | content.contains("192.168.1.100 myhost"), 410 | "Should NOT remove similar hostnames" 411 | ); 412 | assert!( 413 | content.contains("127.0.0.1 localhost"), 414 | "Should preserve other entries" 415 | ); 416 | } 417 | 418 | #[test] 419 | fn test_remove_by_hostname_only_removes_matching_ip() { 420 | // Current implementation checks exact IP+hostname match for existence but removes by hostname only 421 | // This is inconsistent - let's specify the correct behavior 422 | let initial_content = "127.0.0.1 test.local\n192.168.1.100 test.local\n"; 423 | let hosts_file = create_test_hosts_file(initial_content); 424 | 425 | // Remove specific IP+hostname combination 426 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 427 | .args(["remove", "192.168.1.100", "test.local"]) 428 | .output() 429 | .expect("Failed to execute hostie"); 430 | 431 | assert!(output.status.success()); 432 | 433 | let content = fs::read_to_string(hosts_file.path()).unwrap(); 434 | assert!( 435 | !content.contains("192.168.1.100 test.local"), 436 | "Should remove the specific entry" 437 | ); 438 | assert!( 439 | content.contains("127.0.0.1 test.local"), 440 | "Should preserve other IPs with same hostname" 441 | ); 442 | } 443 | 444 | #[test] 445 | fn test_file_format_preservation() { 446 | // Test that we don't add extra newlines or mess up formatting 447 | let initial_content = "127.0.0.1 localhost\n192.168.1.1 router.local\n"; 448 | let hosts_file = create_test_hosts_file(initial_content); 449 | 450 | // Add an entry 451 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 452 | .args(["add", "192.168.1.100", "test.local"]) 453 | .output() 454 | .expect("Failed to execute hostie"); 455 | assert!(output.status.success()); 456 | 457 | let content = fs::read_to_string(hosts_file.path()).unwrap(); 458 | 459 | // Should not have double newlines or extra whitespace 460 | assert!( 461 | !content.contains("\n\n"), 462 | "Should not create double newlines" 463 | ); 464 | assert!(content.ends_with('\n'), "Should end with single newline"); 465 | 466 | // Count lines to ensure proper formatting 467 | let lines: Vec<&str> = content.lines().collect(); 468 | assert_eq!(lines.len(), 3, "Should have exactly 3 lines"); 469 | assert!( 470 | lines[2] == "192.168.1.100 test.local", 471 | "New entry should be properly formatted" 472 | ); 473 | } 474 | 475 | #[test] 476 | fn test_whitespace_handling_in_entries() { 477 | // Test that we handle various whitespace scenarios correctly 478 | let initial_content = "127.0.0.1\tlocalhost\n192.168.1.1 router.local\n"; 479 | let hosts_file = create_test_hosts_file(initial_content); 480 | 481 | let output = hostie_command_with_hosts_file(hosts_file.path().to_str().unwrap()) 482 | .arg("list") 483 | .output() 484 | .expect("Failed to execute hostie"); 485 | 486 | assert!(output.status.success()); 487 | let stdout = String::from_utf8(output.stdout).unwrap(); 488 | 489 | // Should handle both tab and multiple spaces 490 | assert!(stdout.contains("localhost")); 491 | assert!(stdout.contains("router.local")); 492 | } 493 | --------------------------------------------------------------------------------