netform_cli 0.3.0

CLI for diffing lossless network configuration IR documents
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

fn temp_file_path(prefix: &str) -> PathBuf {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock should be after epoch")
        .as_nanos();
    std::env::temp_dir().join(format!("netform-{prefix}-{nonce}.cfg"))
}

#[test]
fn config_diff_cli_prints_markdown_report() {
    let left = temp_file_path("left-markdown");
    let right = temp_file_path("right-markdown");
    fs::write(&left, "hostname old\n").expect("write left");
    fs::write(&right, "hostname new\n").expect("write right");

    let output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg("--no-exit-code")
        .arg(&left)
        .arg(&right)
        .output()
        .expect("run config-diff");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("# Config Diff Report"));
    assert!(stdout.contains("Replaces:"));
}

#[test]
fn config_diff_cli_emits_json_and_plan_json() {
    let left = temp_file_path("left-json");
    let right = temp_file_path("right-json");
    fs::write(&left, "interface Ethernet1\n  description old\n").expect("write left");
    fs::write(&right, "interface Ethernet1\n  description new\n").expect("write right");

    let diff_output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg("--no-exit-code")
        .arg("--json")
        .arg(&left)
        .arg(&right)
        .output()
        .expect("run config-diff --json");
    assert!(diff_output.status.success());
    let diff_json: serde_json::Value =
        serde_json::from_slice(&diff_output.stdout).expect("valid diff json");
    assert_eq!(diff_json["has_changes"], true);
    assert!(diff_json.get("edits").is_some());

    let plan_output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg("--no-exit-code")
        .arg("--plan-json")
        .arg(&left)
        .arg(&right)
        .output()
        .expect("run config-diff --plan-json");
    assert!(plan_output.status.success());
    let plan_json: serde_json::Value =
        serde_json::from_slice(&plan_output.stdout).expect("valid plan json");
    assert!(plan_json.get("actions").is_some());
    assert!(plan_json.get("version").is_some());
}

#[test]
fn config_diff_cli_accepts_dialect_flag() {
    let left = temp_file_path("left-dialect");
    let right = temp_file_path("right-dialect");
    fs::write(
        &left,
        "interfaces {\n    ge-0/0/0 {\n        description \"a\";\n    }\n}\n",
    )
    .expect("write left");
    fs::write(
        &right,
        "interfaces {\n    ge-0/0/0 {\n        description \"b\";\n    }\n}\n",
    )
    .expect("write right");

    let output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg("--no-exit-code")
        .arg("--dialect")
        .arg("junos")
        .arg("--json")
        .arg(&left)
        .arg(&right)
        .output()
        .expect("run config-diff --dialect junos");

    assert!(output.status.success());
    let diff_json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid json");
    assert_eq!(diff_json["has_changes"], true);
}

#[test]
fn config_diff_cli_fails_for_missing_file() {
    let output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg("/definitely/missing-left.cfg")
        .arg("/definitely/missing-right.cfg")
        .output()
        .expect("run config-diff");

    assert!(!output.status.success());
}

#[test]
fn config_diff_exits_zero_when_no_changes() {
    let path = temp_file_path("exit-code-same");
    fs::write(&path, "hostname router\n").expect("write file");

    let output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg(&path)
        .arg(&path)
        .output()
        .expect("run config-diff");

    assert_eq!(output.status.code(), Some(0), "identical files → exit 0");
}

#[test]
fn config_diff_exits_one_when_changes_detected() {
    let left = temp_file_path("exit-code-left");
    let right = temp_file_path("exit-code-right");
    fs::write(&left, "hostname old\n").expect("write left");
    fs::write(&right, "hostname new\n").expect("write right");

    let output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg(&left)
        .arg(&right)
        .output()
        .expect("run config-diff");

    assert_eq!(output.status.code(), Some(1), "differing files → exit 1");
}

#[test]
fn config_diff_no_exit_code_suppresses_exit_one() {
    let left = temp_file_path("no-exit-code-left");
    let right = temp_file_path("no-exit-code-right");
    fs::write(&left, "hostname old\n").expect("write left");
    fs::write(&right, "hostname new\n").expect("write right");

    let output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg("--no-exit-code")
        .arg(&left)
        .arg(&right)
        .output()
        .expect("run config-diff --no-exit-code");

    assert_eq!(
        output.status.code(),
        Some(0),
        "--no-exit-code suppresses exit 1"
    );
}

#[test]
fn config_diff_unordered_policy_ignores_reordered_siblings() {
    let left = temp_file_path("left-unordered");
    let right = temp_file_path("right-unordered");
    fs::write(
        &left,
        "router bgp 65000\n  neighbor 10.0.0.1\n  neighbor 10.0.0.2\n",
    )
    .expect("write left");
    fs::write(
        &right,
        "router bgp 65000\n  neighbor 10.0.0.2\n  neighbor 10.0.0.1\n",
    )
    .expect("write right");

    let output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg("--order-policy")
        .arg("unordered")
        .arg("--json")
        .arg(&left)
        .arg(&right)
        .output()
        .expect("run config-diff --order-policy unordered");

    assert!(output.status.success());
    let diff_json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid json");
    assert_eq!(
        diff_json["has_changes"], false,
        "unordered policy should ignore sibling reordering"
    );
}

#[test]
fn config_diff_keyed_stable_policy_ignores_reordered_children() {
    let left = temp_file_path("left-keyed-stable");
    let right = temp_file_path("right-keyed-stable");
    fs::write(
        &left,
        "interface Ethernet1\n  description uplink\n  mtu 9000\n",
    )
    .expect("write left");
    fs::write(
        &right,
        "interface Ethernet1\n  mtu 9000\n  description uplink\n",
    )
    .expect("write right");

    let output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg("--order-policy")
        .arg("keyed-stable")
        .arg("--json")
        .arg(&left)
        .arg(&right)
        .output()
        .expect("run config-diff --order-policy keyed-stable");

    assert!(output.status.success());
    let diff_json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid json");
    assert_eq!(
        diff_json["has_changes"], false,
        "keyed-stable policy should ignore reordered block children"
    );
}

#[test]
fn config_diff_junos_dialect_with_keyed_stable_policy() {
    let left = temp_file_path("left-junos-keyed");
    let right = temp_file_path("right-junos-keyed");
    fs::write(
        &left,
        "interfaces {\n    ge-0/0/0 {\n        description \"uplink\";\n        mtu 9000;\n    }\n}\n",
    )
    .expect("write left");
    fs::write(
        &right,
        "interfaces {\n    ge-0/0/0 {\n        mtu 9000;\n        description \"uplink\";\n    }\n}\n",
    )
    .expect("write right");

    let output = Command::new(env!("CARGO_BIN_EXE_config-diff"))
        .arg("--dialect")
        .arg("junos")
        .arg("--order-policy")
        .arg("keyed-stable")
        .arg("--json")
        .arg(&left)
        .arg(&right)
        .output()
        .expect("run config-diff --dialect junos --order-policy keyed-stable");

    assert!(output.status.success());
    let diff_json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid json");
    assert_eq!(
        diff_json["has_changes"], false,
        "junos + keyed-stable should ignore reordered children within a keyed block"
    );
}

#[test]
fn replay_fixtures_cli_runs_successfully() {
    let output = Command::new(env!("CARGO_BIN_EXE_netform-replay-fixtures"))
        .output()
        .expect("run replay binary");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("replayed"));
    assert!(stdout.contains("fixture"));
}