req-cli 0.5.0-rc.6

Managed requirements CLI for LLM agents and humans
// Tests for REQ-0016 (local read-only web server). Spawns `req serve`,
// hits each route over raw TCP (avoids adding an HTTP client dep), kills
// the child, asserts status codes + response shape.
mod common;
use common::Sandbox;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

const HOST: &str = "127.0.0.1";

/// Pick a free port by binding ephemerally and immediately dropping. Race
/// window is tiny; tests serialise via --test-threads=1 anyway.
fn pick_free_port() -> u16 {
    let listener = std::net::TcpListener::bind(format!("{}:0", HOST)).expect("bind ephemeral");
    listener.local_addr().expect("local_addr").port()
}

fn spawn_server(s: &Sandbox, port: u16) -> Child {
    Command::new(env!("CARGO_BIN_EXE_req"))
        .args([
            "--file",
            s.path().to_str().unwrap(),
            "serve",
            "--host",
            HOST,
            "--port",
            &port.to_string(),
            "--read-only",
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn req serve")
}

fn wait_for_bind(port: u16, timeout: Duration) -> bool {
    let deadline = Instant::now() + timeout;
    while Instant::now() < deadline {
        if TcpStream::connect_timeout(
            &format!("{}:{}", HOST, port).parse().unwrap(),
            Duration::from_millis(200),
        )
        .is_ok()
        {
            return true;
        }
        std::thread::sleep(Duration::from_millis(75));
    }
    false
}

/// Minimal HTTP/1.1 GET: returns (status_code, body).
fn http_get(port: u16, path: &str) -> (u16, String) {
    let mut stream = TcpStream::connect(format!("{}:{}", HOST, port)).expect("connect to server");
    stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
    write!(
        stream,
        "GET {} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\n\r\n",
        path, HOST, port
    )
    .expect("write request");
    let mut buf = String::new();
    stream.read_to_string(&mut buf).ok();
    // Parse the status line and split off the body.
    let mut lines = buf.splitn(2, "\r\n");
    let status_line = lines.next().unwrap_or("");
    let rest = lines.next().unwrap_or("");
    let body = rest
        .split_once("\r\n\r\n")
        .map(|x| x.1)
        .unwrap_or("")
        .to_string();
    // Status line shape: HTTP/1.1 200 OK
    let code = status_line
        .split_whitespace()
        .nth(1)
        .and_then(|s| s.parse::<u16>().ok())
        .unwrap_or(0);
    (code, body)
}

/// Helper that always kills the child even on panic.
struct GuardedChild(Option<Child>);
impl Drop for GuardedChild {
    fn drop(&mut self) {
        if let Some(mut c) = self.0.take() {
            let _ = c.kill();
            let _ = c.wait();
        }
    }
}

fn fixture() -> (Sandbox, GuardedChild, u16) {
    let s = Sandbox::new();
    s.init("p");
    // Stage one known requirement so route /r/REQ-0001 has something to return.
    let _ = s.run(&[
        "add",
        "--title",
        "Hosted on the local web server for inspection",
        "--statement",
        "The system shall render this requirement at GET /r/REQ-0001.",
        "--rationale",
        "Fixture for serve smoke tests.",
        "--kind",
        "constraint",
        "--priority",
        "could",
    ]);
    let port = pick_free_port();
    let child = spawn_server(&s, port);
    let bound = wait_for_bind(port, Duration::from_secs(10));
    assert!(
        bound,
        "req serve did not bind to {}:{} within 10s",
        HOST, port
    );
    (s, GuardedChild(Some(child)), port)
}

// ---------- REQ-0016 ----------

#[test]
fn req_0016_serve_root_returns_html_index() {
    let (_s, _child, port) = fixture();
    let (code, body) = http_get(port, "/");
    assert_eq!(code, 200, "index should return 200, got {}", code);
    assert!(
        body.contains("<html"),
        "body should be HTML: {}",
        &body[..body.len().min(200)]
    );
    assert!(body.contains("REQ-0001"), "index should list REQ-0001");
}

#[test]
fn req_0016_serve_show_route_returns_html_detail() {
    let (_s, _child, port) = fixture();
    let (code, body) = http_get(port, "/r/REQ-0001");
    assert_eq!(code, 200);
    assert!(body.contains("REQ-0001"));
    assert!(body.contains("Hosted on the local web server"));
    assert!(body.contains("Statement") || body.contains("statement"));
}

#[test]
fn req_0016_serve_api_list_returns_json_array() {
    let (_s, _child, port) = fixture();
    let (code, body) = http_get(port, "/api/list");
    assert_eq!(code, 200);
    let v: serde_json::Value = serde_json::from_str(&body)
        .unwrap_or_else(|_| panic!("/api/list should return JSON, got: {}", body));
    let arr = v.as_array().expect("array of requirements");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["id"].as_str().unwrap(), "REQ-0001");
}

#[test]
fn req_0016_serve_api_show_returns_json_object() {
    let (_s, _child, port) = fixture();
    let (code, body) = http_get(port, "/api/r/REQ-0001");
    assert_eq!(code, 200);
    let v: serde_json::Value = serde_json::from_str(&body).expect("json object");
    assert_eq!(v["id"].as_str().unwrap(), "REQ-0001");
}

#[test]
fn req_0016_serve_unknown_id_returns_404() {
    let (_s, _child, port) = fixture();
    // Construct via format! so the four-digit literal never appears in
    // this source (project-wide coverage scan would otherwise pick it
    // up as a ghost marker).
    let bogus = format!("REQ-{:04}", 9999);
    let url = format!("/api/r/{}", bogus);
    let (code, _body) = http_get(port, &url);
    assert_eq!(code, 404);
}

#[test]
fn req_0016_serve_html_escapes_user_supplied_strings() {
    // Stage a requirement whose title contains characters the HTML
    // renderer must escape; assert they don't appear raw in the body.
    let s = Sandbox::new();
    s.init("p");
    let _ = s.run(&[
        "add",
        "--title",
        "Has <script>tag and \"quotes\" in title",
        "--statement",
        "The system shall escape these characters on render.",
        "--rationale",
        "Fixture for HTML-escape behaviour in serve.",
        "--kind",
        "constraint",
        "--priority",
        "could",
    ]);
    let port = pick_free_port();
    let child = spawn_server(&s, port);
    let _guard = GuardedChild(Some(child));
    assert!(wait_for_bind(port, Duration::from_secs(10)));
    let (code, body) = http_get(port, "/");
    assert_eq!(code, 200);
    assert!(
        !body.contains("<script>tag"),
        "raw < entity leaked through escape: {}",
        &body[..body.len().min(400)]
    );
    assert!(
        body.contains("&lt;script&gt;tag") || body.contains("&lt;script&gt;"),
        "expected &lt; entity in escaped output"
    );
}

// ---------- REQ-0134: functional-safety web view ----------

#[test]
fn req_0134_serve_safety_view_and_api() {
    let s = Sandbox::new();
    s.init("p");
    s.enable_safety();
    let _ = s.run(&[
        "hazard",
        "add",
        "-t",
        "Hazardous mode",
        "--harm",
        "operator could be hurt",
        "-C",
        "C_C",
        "-F",
        "F_B",
        "-P",
        "P_B",
        "-W",
        "W3",
    ]);
    let _ = s.run(&["sf", "add", "-t", "Interlock", "--mitigates", "HAZ-0001"]);
    let port = pick_free_port();
    let child = spawn_server(&s, port);
    assert!(
        wait_for_bind(port, Duration::from_secs(10)),
        "serve did not bind"
    );
    let _guard = GuardedChild(Some(child));

    // The index links to the safety view when hazards exist.
    let (code, body) = http_get(port, "/");
    assert_eq!(code, 200);
    assert!(body.contains("/safety"), "index should link to /safety");

    // The HARA view renders the hazard, its SIL, and the disclaimer.
    let (code, body) = http_get(port, "/safety");
    assert_eq!(code, 200, "/safety should return 200");
    assert!(body.contains("HAZ-0001"), "/safety should list the hazard");
    assert!(body.contains("SIL3"), "/safety should show the derived SIL");
    assert!(
        body.contains("not qualified per IEC 61508-3"),
        "/safety must carry the disclaimer"
    );

    // The JSON API returns the safety artifacts.
    let (code, body) = http_get(port, "/api/safety");
    assert_eq!(code, 200, "/api/safety should return 200");
    assert!(body.contains("\"hazards\""), "api should include hazards");
    assert!(body.contains("HAZ-0001"));
}

// ---------- REQ-0147: web relationship navigation across safety + verification ----------

#[test]
fn req_0147_web_navigates_safety_chain_and_verification() {
    let s = Sandbox::new();
    s.init("p");
    s.enable_safety();
    s.run(&[
        "hazard", "add", "-t", "H", "--harm", "hurt", "-C", "C_C", "-F", "F_B", "-P", "P_B", "-W",
        "W3",
    ]);
    s.run(&["sf", "add", "-t", "Stop fn", "--mitigates", "HAZ-0001"]);
    s.run(&[
        "sreq",
        "add",
        "-t",
        "Stop the blade",
        "-s",
        "The system shall stop the blade on demand.",
        "-r",
        "operator safety",
        "-a",
        "stops",
        "--realizes",
        "SF-0001",
    ]);
    s.run(&[
        "sreq", "update", "SR-0001", "--status", "approved", "--reason", "r",
    ]);
    s.run(&[
        "sreq",
        "update",
        "SR-0001",
        "--status",
        "implemented",
        "--reason",
        "r",
    ]);
    s.run(&[
        "sreq",
        "verify",
        "SR-0001",
        "--by",
        "automated",
        "--notes",
        "bench",
    ]);
    s.run(&["verification", "plan", "SR-0001", "--plan", "p"]);
    s.run(&[
        "verification",
        "analysis",
        "SR-0001",
        "--findings",
        "reviewed",
        "--result",
        "pass",
    ]);
    s.run(&[
        "verification",
        "test",
        "SR-0001",
        "--findings",
        "bench",
        "--result",
        "pass",
    ]);
    s.run(&[
        "verification",
        "conclude",
        "SR-0001",
        "--statement",
        "meets",
        "--promote",
    ]);
    s.run(&["verification", "confirm", "SR-0001"]); // human (REQ_ACTOR_KIND unset in tests)

    let port = pick_free_port();
    let _child = GuardedChild(Some(spawn_server(&s, port)));
    assert!(
        wait_for_bind(port, Duration::from_secs(10)),
        "req serve did not bind"
    );

    // /safety links each hazard into its detail page.
    let (c0, safety) = http_get(port, "/safety");
    assert_eq!(c0, 200);
    assert!(
        safety.contains("/s/HAZ-0001"),
        "safety page must link the hazard to its detail page:\n{safety}"
    );

    // The hazard page renders the full SF → SR chain (both navigable).
    let (c1, haz) = http_get(port, "/s/HAZ-0001");
    assert_eq!(c1, 200);
    assert!(
        haz.contains("/s/SF-0001") && haz.contains("/s/SR-0001"),
        "hazard page must render the SF→SR chain as links:\n{haz}"
    );

    // The safety-function page links back to the hazard and down to the SR.
    let (_c2, sf) = http_get(port, "/s/SF-0001");
    assert!(
        sf.contains("/s/HAZ-0001") && sf.contains("/s/SR-0001"),
        "SF page must link the hazard it mitigates and the SR that realizes it:\n{sf}"
    );

    // The safety-requirement page links to its function AND shows the
    // verification dossier with the human confirmation.
    let (c3, sr) = http_get(port, "/s/SR-0001");
    assert_eq!(c3, 200);
    assert!(
        sr.contains("/s/SF-0001"),
        "SR page must link the safety function it realizes:\n{sr}"
    );
    assert!(
        sr.contains("Verification dossier") && sr.contains("human-confirmed"),
        "SR page must show the verification dossier with the human confirmation:\n{sr}"
    );
}