hen 0.15.0

Run protocol-aware API request collections from the command line or through MCP.
Documentation
use std::time::{Duration, UNIX_EPOCH};

use serde_json::{json, Value};

use crate::{
    request::{
        ArtifactTimingPhase, ArtifactTranscript, ArtifactTranscriptDirection,
        ExecutionArtifact, ExecutionRecord, RequestFailure, RetainedArtifact,
    },
};

use super::{common::assertion_results_json, text::describe_text, BodyReportOptions};

pub(crate) fn request_failure_json(
    failure: &RequestFailure,
    body_options: BodyReportOptions,
) -> Value {
    let protocol_context = failure
        .artifact()
        .and_then(artifact_protocol_context_json)
        .or_else(|| failure.protocol_context().cloned());
    let started_at_unix_ms = failure
        .started_at()
        .and_then(|started_at| started_at.duration_since(UNIX_EPOCH).ok())
        .map(|value| value.as_millis() as u64);
    let duration_ms = failure.duration().map(|duration| duration.as_millis() as u64);
    let transcripts = failure
        .artifact()
        .map(|artifact| {
            artifact
                .transcripts
                .iter()
                .map(|transcript| artifact_transcript_json(transcript, body_options))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let retained_artifacts = failure
        .artifact()
        .map(|artifact| {
            artifact
                .retained_artifacts
                .iter()
                .map(|artifact| retained_artifact_json(artifact, body_options))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let timing = failure
        .duration()
        .map(|duration| artifact_timing_json(duration, failure.artifact()));

    json!({
        "index": failure.index(),
        "request": failure.request(),
        "protocol": failure.protocol().map(|protocol| protocol.as_str()),
        "protocolContext": protocol_context,
        "kind": format!("{:?}", failure.kind()),
        "message": failure.to_string(),
        "startedAtUnixMs": started_at_unix_ms,
        "durationMs": duration_ms,
        "timing": timing,
        "transcripts": transcripts,
        "retainedArtifacts": retained_artifacts,
        "assertions": assertion_results_json(failure.assertions()),
    })
}

pub(crate) fn run_record_json(record: &ExecutionRecord, body_options: BodyReportOptions) -> Value {
    let body = describe_text(&record.execution.output, body_options);

    json!({
        "index": record.index,
        "description": record.description,
        "method": record.method.as_str(),
        "url": record.url,
        "protocol": record.execution.artifact.protocol.as_str(),
        "protocolContext": artifact_protocol_context_json(&record.execution.artifact),
        "status": record.execution.artifact.http_status().map(|status| status.as_u16()),
        "statusText": record.execution.artifact.http_status().and_then(|status| status.canonical_reason()),
        "startedAtUnixMs": record.started_at.duration_since(UNIX_EPOCH).ok().map(|value| value.as_millis() as u64),
        "durationMs": record.duration.as_millis() as u64,
        "timing": artifact_timing_json(record.duration, Some(&record.execution.artifact)),
        "body": body.value,
        "bodyChars": body.char_count as u64,
        "bodyCharLimit": body.char_limit.map(|value| value as u64),
        "bodyTruncated": body.truncated,
        "transcripts": record.execution.artifact.transcripts.iter().map(|transcript| artifact_transcript_json(transcript, body_options)).collect::<Vec<_>>(),
        "retainedArtifacts": record.execution.artifact.retained_artifacts.iter().map(|artifact| retained_artifact_json(artifact, body_options)).collect::<Vec<_>>(),
        "assertions": assertion_results_json(&record.execution.assertions),
    })
}

fn artifact_transcript_json(transcript: &ArtifactTranscript, body_options: BodyReportOptions) -> Value {
    let body = describe_text(&transcript.body, body_options);

    json!({
        "direction": artifact_transcript_direction_label(transcript.direction),
        "label": transcript.label,
        "body": body.value,
        "bodyChars": body.char_count as u64,
        "bodyCharLimit": body.char_limit.map(|value| value as u64),
        "bodyTruncated": body.truncated,
        "attributes": transcript.attributes,
    })
}

fn retained_artifact_json(artifact: &RetainedArtifact, body_options: BodyReportOptions) -> Value {
    let body = describe_text(&artifact.body, body_options);

    json!({
        "name": artifact.name,
        "contentType": artifact.content_type,
        "body": body.value,
        "bodyChars": body.char_count as u64,
        "bodyCharLimit": body.char_limit.map(|value| value as u64),
        "bodyTruncated": body.truncated,
    })
}

fn artifact_timing_json(total_duration: Duration, artifact: Option<&ExecutionArtifact>) -> Value {
    let phases = artifact
        .map(|artifact| {
            artifact
                .timing_phases
                .iter()
                .map(timing_phase_json)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();

    json!({
        "totalMs": total_duration.as_millis() as u64,
        "phases": phases,
    })
}

fn timing_phase_json(phase: &ArtifactTimingPhase) -> Value {
    json!({
        "name": phase.name,
        "durationMs": phase.duration.as_millis() as u64,
    })
}

fn artifact_transcript_direction_label(direction: ArtifactTranscriptDirection) -> &'static str {
    match direction {
        ArtifactTranscriptDirection::Outgoing => "outgoing",
        ArtifactTranscriptDirection::Incoming => "incoming",
    }
}

fn artifact_protocol_context_json(artifact: &ExecutionArtifact) -> Option<Value> {
    let outgoing = artifact
        .transcripts
        .iter()
        .find(|transcript| transcript.direction == crate::request::ArtifactTranscriptDirection::Outgoing)?;

    match artifact.protocol {
        crate::request::RequestProtocol::Http => None,
        crate::request::RequestProtocol::Graphql => {
            let mut context = serde_json::Map::new();

            if let Some(operation_name) = outgoing.attributes.get("operationName") {
                context.insert(
                    "operationName".to_string(),
                    Value::String(operation_name.to_string()),
                );
            }

            if let Some(variables) = outgoing.attributes.get("variables") {
                match serde_json::from_str::<Value>(variables) {
                    Ok(value) => {
                        context.insert("variables".to_string(), value);
                    }
                    Err(_) => {
                        context.insert(
                            "variablesText".to_string(),
                            Value::String(variables.to_string()),
                        );
                    }
                }
            }

            if context.is_empty() {
                None
            } else {
                Some(Value::Object(context))
            }
        }
        crate::request::RequestProtocol::Sse => {
            let mut context = serde_json::Map::new();

            if let Some(action) = outgoing.attributes.get("action") {
                context.insert("action".to_string(), Value::String(action.to_string()));
            }

            if let Some(session_name) = outgoing.attributes.get("sessionName") {
                context.insert(
                    "sessionName".to_string(),
                    Value::String(session_name.to_string()),
                );
            }

            if let Some(within) = outgoing.attributes.get("within") {
                context.insert("within".to_string(), Value::String(within.to_string()));
            }

            if let Some(incoming) = artifact
                .transcripts
                .iter()
                .find(|transcript| transcript.direction == crate::request::ArtifactTranscriptDirection::Incoming)
            {
                if let Some(event) = incoming.attributes.get("event") {
                    context.insert("event".to_string(), Value::String(event.to_string()));
                }

                if let Some(id) = incoming.attributes.get("id") {
                    context.insert("id".to_string(), Value::String(id.to_string()));
                }
            }

            if context.is_empty() {
                None
            } else {
                Some(Value::Object(context))
            }
        }
        crate::request::RequestProtocol::Ws => {
            let mut context = serde_json::Map::new();

            if let Some(action) = outgoing.attributes.get("action") {
                context.insert("action".to_string(), Value::String(action.to_string()));
            }

            if let Some(session_name) = outgoing.attributes.get("sessionName") {
                context.insert(
                    "sessionName".to_string(),
                    Value::String(session_name.to_string()),
                );
            }

            if let Some(kind) = outgoing.attributes.get("kind") {
                context.insert("kind".to_string(), Value::String(kind.to_string()));
            }

            if let Some(within) = outgoing.attributes.get("within") {
                context.insert("within".to_string(), Value::String(within.to_string()));
            }

            if let Some(incoming) = artifact
                .transcripts
                .iter()
                .find(|transcript| {
                    transcript.direction == crate::request::ArtifactTranscriptDirection::Incoming
                })
            {
                if let Some(kind) = incoming.attributes.get("kind") {
                    context.insert("kind".to_string(), Value::String(kind.to_string()));
                }
            }

            if context.is_empty() {
                None
            } else {
                Some(Value::Object(context))
            }
        }
        crate::request::RequestProtocol::Mcp => {
            let mut context = serde_json::Map::new();

            for (attribute, key) in [
                ("call", "call"),
                ("tool", "tool"),
                ("sessionName", "sessionName"),
                ("protocolVersion", "protocolVersion"),
                ("clientName", "clientName"),
                ("clientVersion", "clientVersion"),
            ] {
                if let Some(value) = outgoing.attributes.get(attribute) {
                    context.insert(key.to_string(), Value::String(value.to_string()));
                }
            }

            for (attribute, json_key, text_key) in [
                ("arguments", "arguments", "argumentsText"),
                ("capabilities", "capabilities", "capabilitiesText"),
            ] {
                if let Some(value) = outgoing.attributes.get(attribute) {
                    match serde_json::from_str::<Value>(value) {
                        Ok(parsed) => {
                            context.insert(json_key.to_string(), parsed);
                        }
                        Err(_) => {
                            context.insert(text_key.to_string(), Value::String(value.to_string()));
                        }
                    }
                }
            }

            if context.is_empty() {
                None
            } else {
                Some(Value::Object(context))
            }
        }
    }
}