hostab 0.0.2

Your dev tool to manage /etc/hosts like a pro — written in Rust
Documentation
use std::process::Command;

fn hostab_bin() -> &'static str {
    if let Ok(bin) = std::env::var("HOSTAB_BIN") {
        Box::leak(bin.into_boxed_str())
    } else if cfg!(windows) {
        "target/release/hostab.exe"
    } else {
        "target/release/hostab"
    }
}

fn hostab(args: &[&str]) -> String {
    let output = Command::new(hostab_bin())
        .args(args)
        .output()
        .expect("failed to run hostab");
    String::from_utf8_lossy(&output.stdout).to_string()
}

fn hostab_with_file(hosts_file: &str, args: &[&str]) -> String {
    let mut all_args = vec!["--hosts-file", hosts_file];
    all_args.extend(args);
    let output = Command::new(hostab_bin())
        .args(&all_args)
        .output()
        .expect("failed to run hostab");
    String::from_utf8_lossy(&output.stdout).to_string()
}

#[test]
fn test_entry_list() {
    let out = hostab_with_file("tests/hosts.test", &["e", "list", "-o", "raw"]);
    assert!(out.contains("127.0.0.1"), "should contain localhost IP");
    assert!(out.contains("app.local"), "should contain app.local");
}

#[test]
fn test_ipv4_filter() {
    let out = hostab_with_file("tests/hosts.test", &["e", "list", "--ipv4", "-o", "raw"]);
    assert!(out.contains("127.0.0.1"), "should have IPv4");
    assert!(!out.contains("::1"), "should not have IPv6");
}

#[test]
fn test_ipv6_filter() {
    let out = hostab_with_file("tests/hosts.test", &["e", "list", "--ipv6", "-o", "raw"]);
    assert!(out.contains("::1"), "should have IPv6");
}

#[test]
fn test_json_output() {
    let out = hostab_with_file("tests/hosts.test", &["-o", "json", "e", "list"]);
    assert!(out.starts_with('['), "should be JSON array");
}

#[test]
fn test_add_entry() {
    let tmp = tempfile::NamedTempFile::new().unwrap();
    let path = tmp.path().to_str().unwrap();
    std::fs::write(path, "").unwrap();

    hostab_with_file(path, &["e", "add", "10.99.0.10", "test-add.local", "-q"]);
    let out = hostab_with_file(path, &["e", "list", "-o", "raw"]);
    assert!(out.contains("test-add.local"), "should have added entry");
}

#[test]
fn test_rm_entry() {
    let tmp = tempfile::NamedTempFile::new().unwrap();
    let path = tmp.path().to_str().unwrap();
    std::fs::write(path, "10.99.0.10 test-rm.local\n").unwrap();

    hostab_with_file(path, &["e", "rm", "test-rm.local", "-q"]);
    let out = hostab_with_file(path, &["e", "list", "-o", "raw"]);
    assert!(!out.contains("test-rm.local"), "should have removed entry");
}

#[test]
fn test_rm_by_ip() {
    let tmp = tempfile::NamedTempFile::new().unwrap();
    let path = tmp.path().to_str().unwrap();
    std::fs::write(path, "10.99.0.20 ip-rm.local\n").unwrap();

    hostab_with_file(path, &["e", "rm", "--ip", "10.99.0.20", "-q"]);
    let out = hostab_with_file(path, &["e", "list", "-o", "raw"]);
    assert!(!out.contains("ip-rm.local"), "should have removed by IP");
}

#[test]
fn test_filter() {
    let out = hostab_with_file(
        "tests/hosts.test",
        &["e", "list", "-f", "prod", "-o", "raw"],
    );
    assert!(out.contains("prod.local"), "should match substring");
}

#[test]
fn test_disable_enable() {
    let tmp = tempfile::NamedTempFile::new().unwrap();
    let path = tmp.path().to_str().unwrap();
    std::fs::write(path, "10.0.0.1 app.local api.local\n").unwrap();

    hostab_with_file(path, &["e", "disable", "api.local", "-q"]);
    let raw = std::fs::read_to_string(path).unwrap();
    assert!(
        raw.contains("# 10.0.0.1 api.local"),
        "should comment out api.local"
    );
    assert!(raw.contains("10.0.0.1 app.local"), "should keep app.local");

    hostab_with_file(path, &["e", "enable", "api.local", "-q"]);
    let raw = std::fs::read_to_string(path).unwrap();
    assert!(raw.contains("app.local api.local"), "should merge back");
}

#[test]
fn test_toggle() {
    let tmp = tempfile::NamedTempFile::new().unwrap();
    let path = tmp.path().to_str().unwrap();
    std::fs::write(path, "10.0.0.1 app.local\n").unwrap();

    hostab_with_file(path, &["e", "toggle", "app.local", "-q"]);
    let raw = std::fs::read_to_string(path).unwrap();
    assert!(raw.contains("# 10.0.0.1 app.local"), "toggle off");

    hostab_with_file(path, &["e", "toggle", "app.local", "-q"]);
    let raw = std::fs::read_to_string(path).unwrap();
    assert!(raw.contains("10.0.0.1 app.local"), "toggle on");
}

#[test]
fn test_edit() {
    let tmp = tempfile::NamedTempFile::new().unwrap();
    let path = tmp.path().to_str().unwrap();
    std::fs::write(path, "10.0.0.1 app.local api.local\n").unwrap();

    hostab_with_file(path, &["e", "edit", "api.local", "--ip", "10.0.0.99", "-q"]);
    let raw = std::fs::read_to_string(path).unwrap();
    assert!(
        raw.contains("10.0.0.1 app.local"),
        "old IP should keep app.local"
    );
    assert!(
        raw.contains("10.0.0.99 api.local"),
        "new IP should have api.local"
    );
}

#[test]
fn test_verify() {
    let out = hostab_with_file("tests/hosts.test", &["verify"]);
    assert!(
        out.contains("localhost"),
        "should report duplicate localhost"
    );
    assert!(out.contains("LINE"), "should have table header");
}

#[test]
fn test_merge() {
    let dir = tempfile::tempdir().unwrap();
    let a = dir.path().join("a");
    std::fs::write(&a, "10.99.0.1 merge-a.local\n").unwrap();
    let b = dir.path().join("b");
    std::fs::write(&b, "10.99.0.2 merge-b.local\n").unwrap();
    let tgt = dir.path().join("merged");

    hostab(&[
        "merge",
        "-s",
        a.to_str().unwrap(),
        "-s",
        b.to_str().unwrap(),
        "-t",
        tgt.to_str().unwrap(),
        "-q",
    ]);
    let raw = std::fs::read_to_string(&tgt).unwrap();
    assert!(raw.contains("merge-a.local"), "should have file a content");
    assert!(raw.contains("### source:"), "should have annotation");
}

#[test]
fn test_cat() {
    let out = hostab_with_file("tests/hosts.test", &["cat"]);
    assert!(out.contains("localhost"), "should show raw content");
}

#[test]
fn test_completion() {
    let out = hostab(&["completion", "bash"]);
    assert!(out.contains("complete"), "should generate bash completion");
}

#[test]
fn test_version() {
    let out = hostab(&["version"]);
    assert!(out.contains("hostab"), "should show name");
    assert!(out.contains("commit"), "should show commit info");
}