use crate::domain::{Finding, MissingDiscriminatorFact, ValueFact, context_packet::ContextPacket};
use super::{array_field, escape, field, number_field};
use crate::output::json::report::{related_test_json, stop_reason_values};
const CONTEXT_PACKET_VERSION_CONTRACT: &str = "1.0";
pub fn render_context_packet(finding: &Finding, max_related_tests: usize) -> String {
let stop_reasons = stop_reason_values(finding);
let packet = ContextPacket::from_finding(finding, max_related_tests, stop_reasons);
render_context_packet_dto(&packet)
}
pub(crate) fn render_context_packet_dto(packet: &ContextPacket) -> String {
let _context_packet_version_contract = CONTEXT_PACKET_VERSION_CONTRACT;
let mut out = String::new();
out.push_str("{\n");
field(&mut out, 1, "version", packet.version, true);
field(&mut out, 1, "tool", packet.tool, true);
if let Some(canonical_gap_id) = &packet.canonical_gap_id {
field(&mut out, 1, "canonical_gap_id", canonical_gap_id, true);
}
out.push_str(" \"probe\": {\n");
field(&mut out, 2, "id", &packet.probe.id, true);
field(&mut out, 2, "family", &packet.probe.family, true);
field(&mut out, 2, "delta", &packet.probe.delta, true);
field(&mut out, 2, "file", &packet.probe.file, true);
number_field(&mut out, 2, "line", packet.probe.line, true);
field(
&mut out,
2,
"changed_expression",
&packet.probe.changed_expression,
false,
);
out.push_str(" },\n");
out.push_str(" \"ripr\": {\n");
field(&mut out, 2, "reach", &packet.ripr.reach, true);
field(&mut out, 2, "infect", &packet.ripr.infect, true);
field(&mut out, 2, "propagate", &packet.ripr.propagate, true);
field(&mut out, 2, "observe", &packet.ripr.observe, true);
field(
&mut out,
2,
"discriminate",
&packet.ripr.discriminate,
false,
);
out.push_str(" },\n");
let related_test_count = packet.related_tests.len();
out.push_str(" \"related_tests\": [\n");
for (idx, test) in packet.related_tests.iter().enumerate() {
related_test_json(&mut out, test, 2);
if idx + 1 != related_test_count {
out.push(',');
}
out.push('\n');
}
out.push_str(" ],\n");
value_array(&mut out, 1, "observed_values", &packet.observed_values);
out.push_str(",\n");
discriminator_array(
&mut out,
1,
"missing_discriminators",
&packet.missing_discriminators,
);
out.push_str(",\n");
array_field(&mut out, 1, "missing", &packet.missing, true);
array_field(&mut out, 1, "stop_reasons", &packet.stop_reasons, true);
field(
&mut out,
1,
"recommended_next_step",
packet.recommended_next_step.as_deref().unwrap_or(""),
false,
);
out.push_str("}\n");
out
}
fn value_array(out: &mut String, indent: usize, name: &str, values: &[ValueFact]) {
out.push_str(&format!("{}\"{name}\": [", " ".repeat(indent)));
for (idx, value) in values.iter().enumerate() {
if idx > 0 {
out.push_str(", ");
}
out.push_str(&format!(
"{{\"value\":\"{}\",\"context\":\"{}\",\"line\":{}}}",
escape(&value.value),
value.context.as_str(),
value.line
));
}
out.push(']');
}
fn discriminator_array(
out: &mut String,
indent: usize,
name: &str,
values: &[MissingDiscriminatorFact],
) {
out.push_str(&format!("{}\"{name}\": [", " ".repeat(indent)));
for (idx, value) in values.iter().enumerate() {
if idx > 0 {
out.push_str(", ");
}
out.push_str(&format!(
"{{\"value\":\"{}\",\"reason\":\"{}\"}}",
escape(&value.value),
escape(&value.reason)
));
}
out.push(']');
}
#[cfg(test)]
mod tests {
use super::render_context_packet;
use crate::domain::{
ActivationEvidence, Confidence, DeltaKind, ExposureClass, Finding, FindingCanonicalGap,
MissingDiscriminatorFact, OracleKind, OracleStrength, Probe, ProbeFamily, ProbeId,
RelatedTest, RevealEvidence, RiprEvidence, SourceLocation, StageEvidence, StageState,
SymbolId, ValueContext, ValueFact,
};
use std::path::PathBuf;
#[test]
fn context_packet_limits_related_tests_to_max() {
let mut finding = sample_finding();
finding.related_tests = vec![related("t1"), related("t2"), related("t3")];
let packet = render_context_packet(&finding, 2);
assert!(packet.contains("\"name\": \"t1\""));
assert!(packet.contains("\"name\": \"t2\""));
assert!(!packet.contains("\"name\": \"t3\""));
}
#[test]
fn context_packet_escapes_observed_values_and_discriminator_reasons() {
let mut finding = sample_finding();
finding.activation.observed_values.push(ValueFact {
line: 11,
text: "assert!(x)".to_string(),
value: "line \"a\"\nnext".to_string(),
context: ValueContext::AssertionArgument,
});
finding
.activation
.missing_discriminators
.push(MissingDiscriminatorFact {
value: "v == \"boundary\"".to_string(),
reason: "No test checks \"boundary\"\ncase".to_string(),
flow_sink: None,
});
let packet = render_context_packet(&finding, 5);
assert!(packet.contains("line \\\"a\\\"\\nnext"));
assert!(packet.contains("No test checks \\\"boundary\\\"\\ncase"));
}
#[test]
fn context_packet_renders_canonical_gap_id_when_present() {
let mut finding = sample_finding();
finding.canonical_gap = Some(FindingCanonicalGap {
id: "gap:python:src/pricing.py:discount:predicate_boundary:predicate:amount>=threshold"
.to_string(),
language: "python".to_string(),
file: "src/pricing.py".to_string(),
owner: "discount".to_string(),
behavior_kind: "predicate_boundary".to_string(),
probe_kind: "predicate".to_string(),
normalized_discriminator: "amount>=threshold".to_string(),
});
let packet = render_context_packet(&finding, 5);
assert!(packet.contains(
"\"canonical_gap_id\": \"gap:python:src/pricing.py:discount:predicate_boundary:predicate:amount>=threshold\""
));
}
fn sample_finding() -> Finding {
Finding {
id: "probe:src_lib_rs:9:error_path".to_string(),
canonical_gap: None,
probe: Probe {
id: ProbeId("probe:src_lib_rs:9:error_path".to_string()),
location: SourceLocation::new("src/lib.rs", 9, 1),
owner: Some(SymbolId("crate::sample".to_string())),
family: ProbeFamily::Predicate,
delta: DeltaKind::Control,
before: Some("x > 0".to_string()),
after: Some("x >= 0".to_string()),
expression: "x >= 0".to_string(),
expected_sinks: vec![],
required_oracles: vec![],
},
class: ExposureClass::WeaklyExposed,
ripr: RiprEvidence {
reach: stage("Reachable"),
infect: stage("Possible infection"),
propagate: stage("Possible propagation"),
reveal: RevealEvidence {
observe: stage("Observed"),
discriminate: stage("Weak discriminator"),
},
},
confidence: 0.5,
evidence: vec![],
missing: vec![],
flow_sinks: vec![],
activation: ActivationEvidence::default(),
stop_reasons: vec![],
related_tests: vec![],
recommended_next_step: None,
language: None,
language_status: None,
owner_kind: None,
static_limit_kind: None,
}
}
fn related(name: &str) -> RelatedTest {
RelatedTest {
name: name.to_string(),
file: PathBuf::from("tests/sample.rs"),
line: 7,
oracle: Some("assert_eq!(value, 1)".to_string()),
oracle_kind: OracleKind::ExactValue,
oracle_strength: OracleStrength::Strong,
}
}
fn stage(summary: &str) -> StageEvidence {
StageEvidence::new(StageState::Yes, Confidence::Medium, summary)
}
}