use super::shared::{convert_location, report_safe, ReportLocation, MAX_REPORTED_CLUSTER_MEMBERS};
use super::{
CliReportMetadata, CorpusCensus, ExecutionMetrics, ReportContext, Summary, JSONL_SCHEMA_VERSION,
};
use crate::analysis::AnalysisCensus;
use crate::model::{Finding, Rule, Severity};
use anyhow::{Context, Result};
use serde::Serialize;
use std::io::{BufWriter, Write};
pub(super) const JSONL_TEXT_LIMIT_CHARS: usize = 2_000;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonlFinding {
schema_version: &'static str,
#[serde(rename = "type")]
record_type: &'static str,
tool_version: &'static str,
fingerprint: String,
active: bool,
contributes_to_threshold: bool,
rule: Rule,
severity: Severity,
message: String,
primary: ReportLocation,
related: Vec<ReportLocation>,
related_locations_truncated: bool,
evidence: JsonlEvidence,
suggested_action: String,
suppression: Option<JsonlSuppression>,
truncated_fields: Vec<&'static str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonlEvidence {
matcher_similarity: Option<f64>,
handler_similarity: Option<f64>,
matcher_difference: String,
handler_evidence: String,
#[serde(skip_serializing_if = "Option::is_none")]
comparison: Option<JsonlComparison>,
#[serde(skip_serializing_if = "Option::is_none")]
cluster: Option<JsonlCluster>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonlCluster {
member_count: usize,
definition_fingerprints: Vec<String>,
pair_findings_collapsed: usize,
members_truncated: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonlComparison {
left_fingerprint: String,
right_fingerprint: String,
left_matcher: String,
right_matcher: String,
left_handler: String,
right_handler: String,
matcher_diff: JsonlMatcherDiff,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonlMatcherDiff {
prefix: String,
left_change: String,
right_change: String,
suffix: String,
}
#[derive(Serialize)]
struct JsonlSuppression {
reason: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonlSummary<'a> {
schema_version: &'static str,
#[serde(rename = "type")]
record_type: &'static str,
tool_version: &'static str,
summary: &'a Summary,
#[serde(skip_serializing_if = "Option::is_none")]
metrics: Option<&'a ExecutionMetrics>,
#[serde(skip_serializing_if = "Option::is_none")]
corpus: Option<CorpusCensus>,
#[serde(skip_serializing_if = "Option::is_none")]
analysis: Option<AnalysisCensus>,
record_count: usize,
truncated: bool,
}
pub fn render_jsonl(context: &ReportContext<'_>) -> Result<String> {
render_jsonl_context(context)
}
pub(super) fn write_jsonl_context(
context: &ReportContext<'_>,
metadata: Option<&CliReportMetadata<'_>>,
writer: &mut dyn Write,
) -> Result<()> {
let mut writer = BufWriter::new(writer);
for finding in &context.result.findings {
let bytes = serde_json::to_vec(&jsonl_finding(finding, context))
.context("failed to serialize JSONL finding")?;
writer.write_all(&bytes)?;
writer.write_all(b"\n")?;
}
let summary = JsonlSummary {
schema_version: JSONL_SCHEMA_VERSION,
record_type: "summary",
tool_version: context.tool_version,
summary: &context.summary,
metrics: context.metrics,
corpus: metadata.map(|metadata| metadata.corpus.clone()),
analysis: metadata.map(|metadata| metadata.analysis.clone()),
record_count: context.result.findings.len() + 1,
truncated: false,
};
let bytes = serde_json::to_vec(&summary).context("failed to serialize JSONL summary")?;
writer.write_all(&bytes)?;
writer.write_all(b"\n")?;
writer.flush()?;
Ok(())
}
fn render_jsonl_context(context: &ReportContext<'_>) -> Result<String> {
let mut output = String::new();
for finding in &context.result.findings {
let record = jsonl_finding(finding, context);
output.push_str(
&serde_json::to_string(&record).context("failed to serialize JSONL finding")?,
);
output.push('\n');
}
let summary = JsonlSummary {
schema_version: JSONL_SCHEMA_VERSION,
record_type: "summary",
tool_version: context.tool_version,
summary: &context.summary,
metrics: context.metrics,
corpus: None,
analysis: None,
record_count: context.result.findings.len() + 1,
truncated: false,
};
output.push_str(&serde_json::to_string(&summary).context("failed to serialize JSONL summary")?);
output.push('\n');
Ok(output)
}
fn jsonl_finding(finding: &Finding, context: &ReportContext<'_>) -> JsonlFinding {
let mut truncated_fields = Vec::new();
let message = bounded_jsonl_text(&finding.message, "message", &mut truncated_fields);
let matcher_difference = bounded_jsonl_text(
&finding.evidence.matcher_difference,
"evidence.matcherDifference",
&mut truncated_fields,
);
let handler_evidence = bounded_jsonl_text(
&finding.evidence.handler_evidence,
"evidence.handlerEvidence",
&mut truncated_fields,
);
let comparison = finding
.evidence
.comparison
.as_ref()
.map(|comparison| JsonlComparison {
left_fingerprint: comparison.left_fingerprint.clone(),
right_fingerprint: comparison.right_fingerprint.clone(),
left_matcher: bounded_jsonl_text(
&comparison.left_matcher,
"evidence.comparison.leftMatcher",
&mut truncated_fields,
),
right_matcher: bounded_jsonl_text(
&comparison.right_matcher,
"evidence.comparison.rightMatcher",
&mut truncated_fields,
),
left_handler: bounded_jsonl_text(
&comparison.left_handler,
"evidence.comparison.leftHandler",
&mut truncated_fields,
),
right_handler: bounded_jsonl_text(
&comparison.right_handler,
"evidence.comparison.rightHandler",
&mut truncated_fields,
),
matcher_diff: JsonlMatcherDiff {
prefix: bounded_jsonl_text(
&comparison.matcher_diff.prefix,
"evidence.comparison.matcherDiff.prefix",
&mut truncated_fields,
),
left_change: bounded_jsonl_text(
&comparison.matcher_diff.left_change,
"evidence.comparison.matcherDiff.leftChange",
&mut truncated_fields,
),
right_change: bounded_jsonl_text(
&comparison.matcher_diff.right_change,
"evidence.comparison.matcherDiff.rightChange",
&mut truncated_fields,
),
suffix: bounded_jsonl_text(
&comparison.matcher_diff.suffix,
"evidence.comparison.matcherDiff.suffix",
&mut truncated_fields,
),
},
});
let cluster = finding
.evidence
.cluster
.as_ref()
.map(|cluster| JsonlCluster {
member_count: cluster.member_count,
definition_fingerprints: cluster
.definition_fingerprints
.iter()
.take(MAX_REPORTED_CLUSTER_MEMBERS)
.cloned()
.collect(),
pair_findings_collapsed: cluster.pair_findings_collapsed,
members_truncated: cluster.members_truncated
|| cluster.definition_fingerprints.len() > MAX_REPORTED_CLUSTER_MEMBERS,
});
let retained_related = MAX_REPORTED_CLUSTER_MEMBERS.saturating_sub(1);
let suggested_action = bounded_jsonl_text(
&finding.suggested_action,
"suggestedAction",
&mut truncated_fields,
);
let suppression = finding
.suppression
.as_ref()
.map(|suppression| JsonlSuppression {
reason: bounded_jsonl_text(
&suppression.reason,
"suppression.reason",
&mut truncated_fields,
),
});
JsonlFinding {
schema_version: JSONL_SCHEMA_VERSION,
record_type: "finding",
tool_version: context.tool_version,
fingerprint: crate::modes::finding_fingerprint(finding),
active: finding.is_active(),
contributes_to_threshold: finding.is_active()
&& finding.severity == Severity::Error
&& finding.rule.contributes_to_duplication_threshold(),
rule: finding.rule,
severity: finding.severity,
message,
primary: convert_location(&finding.primary, context.root),
related: finding
.related
.iter()
.take(retained_related)
.map(|location| convert_location(location, context.root))
.collect(),
related_locations_truncated: finding.related.len() > retained_related,
evidence: JsonlEvidence {
matcher_similarity: finding.evidence.matcher_similarity,
handler_similarity: finding.evidence.handler_similarity,
matcher_difference,
handler_evidence,
comparison,
cluster,
},
suggested_action,
suppression,
truncated_fields,
}
}
fn bounded_jsonl_text(
value: &str,
field: &'static str,
truncated_fields: &mut Vec<&'static str>,
) -> String {
let value = report_safe(value);
if value.chars().count() <= JSONL_TEXT_LIMIT_CHARS {
return value;
}
truncated_fields.push(field);
value
.chars()
.take(JSONL_TEXT_LIMIT_CHARS.saturating_sub(1))
.chain(std::iter::once('\u{2026}'))
.collect()
}