hen 0.18.1

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, ExecutionReliabilityMetadata,
        RequestFailure, RetainedArtifact,
    },
};

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

pub(crate) fn request_failure_json(
    failure: &RequestFailure,
    body_options: BodyReportOptions,
) -> Value {
    let redactor = OutputRedactor::with_header_names(
        failure.sensitive_values(),
        failure.sensitive_header_names(),
    );
    let protocol_context = failure
        .artifact()
        .and_then(artifact_protocol_context_json)
        .or_else(|| failure.protocol_context().cloned())
        .map(|value| redactor.redact_json_value(&value));
    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, &redactor))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let retained_artifacts = failure
        .artifact()
        .map(|artifact| {
            artifact
                .retained_artifacts
                .iter()
                .map(|artifact| retained_artifact_json(artifact, body_options, &redactor))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let timing = failure
        .duration()
        .map(|duration| artifact_timing_json(duration, failure.artifact()));

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

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

    json!({
        "index": record.index,
        "description": redactor.redact_text(&record.description),
        "method": record.method.as_str(),
        "url": redactor.redact_text(&record.url),
        "protocol": record.execution.artifact.protocol.as_str(),
        "protocolContext": artifact_protocol_context_json(&record.execution.artifact)
            .map(|value| redactor.redact_json_value(&value)),
        "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,
        "reliability": reliability_json(&record.execution.reliability),
        "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, &redactor)).collect::<Vec<_>>(),
        "retainedArtifacts": record.execution.artifact.retained_artifacts.iter().map(|artifact| retained_artifact_json(artifact, body_options, &redactor)).collect::<Vec<_>>(),
        "assertions": assertion_results_json(&record.execution.assertions, &redactor),
    })
}

fn reliability_json(metadata: &ExecutionReliabilityMetadata) -> Value {
    json!({
        "attempts": metadata.attempts as u64,
        "polled": metadata.attempts > 1,
        "timeout": metadata.timeout.as_str(),
        "pollUntil": metadata.poll_until.as_deref(),
        "pollEvery": metadata.poll_every.as_deref(),
        "failureClass": metadata.failure_class.map(|class| class.as_str()),
    })
}

fn artifact_transcript_json(
    transcript: &ArtifactTranscript,
    body_options: BodyReportOptions,
    redactor: &OutputRedactor,
) -> Value {
    let body = describe_text(&redactor.redact_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": redactor.redact_string_map(&transcript.attributes),
    })
}

fn retained_artifact_json(
    artifact: &RetainedArtifact,
    body_options: BodyReportOptions,
    redactor: &OutputRedactor,
) -> Value {
    let body = describe_text(&redactor.redact_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 => outgoing
            .attributes
            .get("sessionName")
            .map(|session_name| {
                json!({
                    "sessionName": session_name,
                })
            }),
        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))
            }
        }
    }
}