hen 0.20.1

Run protocol-aware API request collections from the command line or through MCP.
Documentation
mod support;

use std::{
    io::{Read, Write},
    net::TcpListener,
    sync::{
        atomic::{AtomicUsize, Ordering},
        Arc,
    },
    thread,
};

use support::TestWorkspace;

#[test]
fn exports_all_requests_non_interactively_when_selector_is_all() {
    let workspace = TestWorkspace::new();
    workspace.copy_fixture("non_interactive/multi-request.hen", "collection.hen");

    let output = workspace.run_hen([
        "run",
        "collection.hen",
        "all",
        "--non-interactive",
        "--export",
    ]);

    assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
    assert!(output
        .stdout
        .contains("curl -X GET 'https://example.com/one'"));
    assert!(output
        .stdout
        .contains("curl -X GET 'https://example.com/two'"));
    assert!(output.stderr.is_empty(), "stderr: {}", output.stderr);
}

#[test]
fn explicit_all_selector_bypasses_request_prompt_in_default_mode() {
    let hits = Arc::new(AtomicUsize::new(0));
    let server_url = spawn_counting_http_server(Arc::clone(&hits));
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        &format!(
            r#"Selector Fixture

Verifies that an explicit `all` selector skips the interactive request picker.

---

First request

GET {server_url}/one

---

Second request

GET {server_url}/two
"#
        ),
    );

    let output = workspace.run_hen(["collection.hen", "all"]);

    assert_eq!(output.status_code, 0, "stderr: {}", output.stderr);
    assert!(!output.stdout.contains("Select a request"), "stdout: {}", output.stdout);
    assert_eq!(hits.load(Ordering::SeqCst), 2, "both requests should run");
    assert!(output.stderr.is_empty(), "stderr: {}", output.stderr);
}

#[test]
fn requires_selector_for_multi_request_collection_in_non_interactive_mode() {
    let workspace = TestWorkspace::new();
    workspace.copy_fixture("non_interactive/multi-request.hen", "collection.hen");

    let output = workspace.run_hen(["run", "collection.hen", "--non-interactive"]);

    assert_eq!(output.status_code, 2, "stderr: {}", output.stderr);
    assert!(output.stdout.is_empty(), "stdout: {}", output.stdout);
    assert!(output
        .stderr
        .contains("A selector is required when a collection contains multiple requests"));
}

#[test]
fn rejects_invalid_selector_in_non_interactive_mode() {
    let workspace = TestWorkspace::new();
    workspace.copy_fixture("non_interactive/multi-request.hen", "collection.hen");

    let output = workspace.run_hen(["run", "collection.hen", "bogus", "--non-interactive"]);

    assert_eq!(output.status_code, 2, "stderr: {}", output.stderr);
    assert!(output.stdout.is_empty(), "stdout: {}", output.stdout);
    assert!(output
        .stderr
        .contains("Selector must be an integer or 'all'"));
}

#[test]
fn rejects_missing_prompt_inputs_in_non_interactive_mode() {
    let workspace = TestWorkspace::new();
    workspace.copy_fixture(
        "non_interactive/single-request-with-prompt.hen",
        "collection.hen",
    );

    let output = workspace.run_hen(["run", "collection.hen", "--non-interactive"]);

    assert_eq!(output.status_code, 2, "stderr: {}", output.stderr);
    assert!(output.stdout.is_empty(), "stdout: {}", output.stdout);
    assert!(output
        .stderr
        .contains("One or more required prompt inputs were not supplied"));
    assert!(output.stderr.contains("Missing value for prompt 'item_id'"));
}

#[test]
fn rejects_missing_prompt_inputs_by_default() {
    let workspace = TestWorkspace::new();
    workspace.copy_fixture(
        "non_interactive/single-request-with-prompt.hen",
        "collection.hen",
    );

    let output = workspace.run_hen(["run", "collection.hen"]);

    assert_eq!(output.status_code, 2, "stderr: {}", output.stderr);
    assert!(output.stdout.is_empty(), "stdout: {}", output.stdout);
    assert!(output
        .stderr
        .contains("One or more required prompt inputs were not supplied"));
    assert!(output.stderr.contains("Missing value for prompt 'item_id'"));
}

#[test]
fn rejects_missing_prompt_inputs_in_multipart_file_paths() {
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        r#"Multipart Prompt

Ensures file-part prompts are rejected before execution.

---

Upload file

POST https://example.com/upload

~ file = @[[ file_path ]]
"#,
    );

    let output = workspace.run_hen(["run", "collection.hen"]);

    assert_eq!(output.status_code, 2, "stderr: {}", output.stderr);
    assert!(output.stdout.is_empty(), "stdout: {}", output.stdout);
    assert!(output
        .stderr
        .contains("One or more required prompt inputs were not supplied"));
    assert!(output.stderr.contains("Missing value for prompt 'file_path'"));
}

#[test]
fn fails_before_making_http_requests_when_later_request_has_missing_prompt() {
    let hits = Arc::new(AtomicUsize::new(0));
    let server_url = spawn_counting_http_server(Arc::clone(&hits));
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        &format!(
            r#"Prompt Preflight

Should reject unresolved prompt inputs before any request runs.

---

Ready request

GET {server_url}/ready

---

Blocked request

GET {server_url}/items/[[ item_id ]]
"#
        ),
    );

    let output = workspace.run_hen(["run", "collection.hen", "all"]);

    assert_eq!(output.status_code, 2, "stderr: {}", output.stderr);
    assert!(output.stdout.is_empty(), "stdout: {}", output.stdout);
    assert!(output
        .stderr
        .contains("One or more required prompt inputs were not supplied"));
    assert!(output.stderr.contains("Missing value for prompt 'item_id'"));
    assert_eq!(hits.load(Ordering::SeqCst), 0, "no requests should be sent");
}

#[test]
fn missing_oauth_prompts_only_block_selected_auth_requests() {
    let hits = Arc::new(AtomicUsize::new(0));
    let server_url = spawn_counting_http_server(Arc::clone(&hits));
    let workspace = TestWorkspace::new();
    workspace.write_file(
        "collection.hen",
        &format!(
                        r#"name = OAuth Prompt Preflight

oauth api
  grant = client_credentials
  token_url = [[ TOKEN_URL ]]
  client_id = hen-client
  client_secret = hen-secret

---

Public request

GET {server_url}/public

---

Authenticated request

auth = api
GET https://example.com/private
"#
        ),
    );

    let public_output = workspace.run_hen(["run", "collection.hen", "0", "--non-interactive"]);

    assert_eq!(public_output.status_code, 0, "stderr: {}", public_output.stderr);
    assert!(public_output.stderr.is_empty(), "stderr: {}", public_output.stderr);
    assert_eq!(hits.load(Ordering::SeqCst), 1, "public request should run");

    let auth_output = workspace.run_hen(["run", "collection.hen", "1", "--non-interactive"]);

    assert_eq!(auth_output.status_code, 2, "stderr: {}", auth_output.stderr);
    assert!(auth_output.stdout.is_empty(), "stdout: {}", auth_output.stdout);
    assert!(auth_output
        .stderr
        .contains("One or more required prompt inputs were not supplied"));
    assert!(auth_output.stderr.contains("Missing value for prompt 'TOKEN_URL'"));
    assert_eq!(hits.load(Ordering::SeqCst), 1, "auth request should fail before sending HTTP");
}

#[test]
fn rejects_ambiguous_directory_selection_in_non_interactive_mode() {
    let workspace = TestWorkspace::new();
    workspace.copy_fixture(
        "non_interactive/single-request-with-prompt.hen",
        "collections/one.hen",
    );
    workspace.copy_fixture("non_interactive/multi-request.hen", "collections/two.hen");

    let output = workspace.run_hen(["run", "collections", "--non-interactive"]);

    assert_eq!(output.status_code, 2, "stderr: {}", output.stderr);
    assert!(output.stdout.is_empty(), "stdout: {}", output.stdout);
    assert!(output
        .stderr
        .contains("cannot be resolved non-interactively"));
}

fn spawn_counting_http_server(hits: Arc<AtomicUsize>) -> String {
    let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
    let address = listener.local_addr().expect("address should be available");

    thread::spawn(move || {
        while let Ok((mut stream, _)) = listener.accept() {
            hits.fetch_add(1, Ordering::SeqCst);
            let mut buffer = [0_u8; 1024];
            let _ = stream.read(&mut buffer);
            let body = "{}";
            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                body.len()
            );
            stream
                .write_all(response.as_bytes())
                .expect("response should be written");
        }
    });

    format!("http://{}", address)
}