use crate::domain::delta::{DeltaSummary, DeltaView, DeltaViewSpec, FunctionChange};
use crate::domain::types::{
AnalysisDiagnostics, AnalysisResult, AnalysisSummary, ComplexityMetric, FunctionVerdict,
};
use crate::domain::view::{AnalysisView, GroupedView, ViewSpec};
use crate::ports::ParseDiagnostic;
use serde::Serialize;
#[derive(Debug)]
pub struct JsonConfig<'a, P: ParseDiagnostic> {
pub tool_version: String,
pub metric: ComplexityMetric,
pub threshold: f64,
pub timestamp: String,
pub diagnostics: Option<&'a AnalysisDiagnostics<P>>,
pub diff_ref: Option<&'a str>,
pub minimal_view: bool,
pub delta: Option<DeltaContext<'a, P>>,
}
#[derive(Debug)]
pub struct DeltaContext<'a, P: ParseDiagnostic> {
pub view: &'a DeltaView<'a>,
pub baseline_tool_version: &'a str,
pub baseline_timestamp: &'a str,
pub baseline_diagnostics: Option<&'a AnalysisDiagnostics<P>>,
}
#[derive(Serialize)]
#[serde(bound = "")]
struct JsonEnvelope<'a, P: ParseDiagnostic> {
schema_version: u32,
tool_version: &'a str,
language: &'static str,
timestamp: &'a str,
metric: &'a ComplexityMetric,
threshold: f64,
diff_ref: Option<&'a str>,
result: &'a AnalysisResult,
view: ViewWire<'a>,
#[serde(skip_serializing_if = "Option::is_none")]
delta: Option<DeltaWire<'a, P>>,
#[serde(skip_serializing_if = "Option::is_none")]
diagnostics: Option<&'a AnalysisDiagnostics<P>>,
}
#[derive(Serialize)]
#[serde(bound = "")]
struct DeltaWire<'a, P: ParseDiagnostic> {
summary: &'a DeltaSummary,
spec: &'a DeltaViewSpec,
eligible_count: usize,
truncated: bool,
baseline_ref: Option<&'a str>,
baseline_tool_version: &'a str,
baseline_timestamp: &'a str,
shown: Vec<&'a FunctionChange>,
#[serde(skip_serializing_if = "Option::is_none")]
baseline_diagnostics: Option<&'a AnalysisDiagnostics<P>>,
}
impl<'a, P: ParseDiagnostic> DeltaWire<'a, P> {
fn from_context(ctx: &'a DeltaContext<'a, P>) -> Self {
DeltaWire {
summary: &ctx.view.full.summary,
spec: &ctx.view.spec,
eligible_count: ctx.view.eligible_count,
truncated: ctx.view.truncated,
baseline_ref: None,
baseline_tool_version: ctx.baseline_tool_version,
baseline_timestamp: ctx.baseline_timestamp,
shown: ctx.view.shown.clone(),
baseline_diagnostics: ctx.baseline_diagnostics,
}
}
}
#[derive(Serialize)]
struct ViewWire<'a> {
spec: &'a ViewSpec,
eligible_count: usize,
truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
shown: Option<&'a [&'a FunctionVerdict]>,
shown_summary: &'a AnalysisSummary,
grouped: Option<&'a GroupedView>,
}
impl<'a> ViewWire<'a> {
fn from_view(view: &'a AnalysisView<'a>, minimal: bool) -> Self {
ViewWire {
spec: &view.spec,
eligible_count: view.eligible_count,
truncated: view.truncated,
shown: if minimal {
None
} else {
Some(view.shown.as_slice())
},
shown_summary: &view.shown_summary,
grouped: view.grouped.as_ref(),
}
}
}
pub fn format_json<P: ParseDiagnostic>(
view: &AnalysisView<'_>,
config: &JsonConfig<'_, P>,
) -> Result<String, serde_json::Error> {
let delta_wire: Option<DeltaWire<P>> = config.delta.as_ref().map(DeltaWire::from_context);
let envelope = JsonEnvelope {
schema_version: 2,
tool_version: &config.tool_version,
language: "rust",
timestamp: &config.timestamp,
metric: &config.metric,
threshold: config.threshold,
diff_ref: config.diff_ref,
result: view.full,
view: ViewWire::from_view(view, config.minimal_view),
delta: delta_wire,
diagnostics: config.diagnostics,
};
serde_json::to_string_pretty(&envelope)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::reporters::test_fixtures::*;
use crate::domain::types::{ComplexityMetric, RiskLevel};
use crate::test_strategies::DummyParseDiagnostic;
type TestJsonConfig = JsonConfig<'static, DummyParseDiagnostic>;
fn default_config() -> TestJsonConfig {
JsonConfig {
tool_version: "0.1.0".to_string(),
metric: ComplexityMetric::Cognitive,
threshold: 8.0,
timestamp: "2026-03-28T12:00:00Z".to_string(),
diagnostics: None,
diff_ref: None,
minimal_view: false,
delta: None,
}
}
fn parse_json<'a>(
result: &AnalysisResult,
config: &JsonConfig<'a, DummyParseDiagnostic>,
) -> serde_json::Value {
let view = make_view_default(result);
let json_str = format_json(&view, config).expect("format_json should succeed");
serde_json::from_str(&json_str).expect("output should be valid JSON")
}
#[test]
fn test_envelope_contains_all_fields() {
let result = make_empty_result();
let v = parse_json(&result, &default_config());
assert!(v.get("schema_version").is_some());
assert!(v.get("tool_version").is_some());
assert!(v.get("language").is_some());
assert!(v.get("timestamp").is_some());
assert!(v.get("metric").is_some());
assert!(v.get("threshold").is_some());
assert!(v.get("result").is_some());
}
#[test]
fn test_result_nested_correctly() {
let result = make_empty_result();
let v = parse_json(&result, &default_config());
let r = v.get("result").expect("should have result key");
assert!(r.get("functions").is_some());
assert!(r.get("summary").is_some());
assert!(r.get("passed").is_some());
}
#[test]
fn test_schema_version_is_integer() {
let result = make_empty_result();
let v = parse_json(&result, &default_config());
let sv = v.get("schema_version").unwrap();
assert!(sv.is_number());
assert_eq!(sv.as_u64(), Some(2));
}
#[test]
fn test_function_all_scored_fields() {
let result = make_single_function_result(
"compute_crap",
"src/domain/crap.rs",
5,
80.0,
5.16,
RiskLevel::Acceptable,
8.0,
);
let v = parse_json(&result, &default_config());
let func = &v["result"]["functions"][0];
assert_eq!(func["scored"]["identity"]["qualified_name"], "compute_crap");
assert_eq!(
func["scored"]["identity"]["file_path"],
"src/domain/crap.rs"
);
assert_eq!(func["scored"]["complexity"], 5);
assert_eq!(func["scored"]["coverage_percent"], 80.0);
assert_eq!(func["scored"]["crap"]["value"], 5.16);
assert_eq!(func["scored"]["crap"]["risk_level"], "acceptable");
assert_eq!(func["exceeds"], false);
assert_eq!(func["threshold"], 8.0);
}
#[test]
fn test_summary_aggregate_stats() {
let result = make_multi_function_result();
let v = parse_json(&result, &default_config());
let s = &v["result"]["summary"];
assert_eq!(s["total_functions"], 3);
assert_eq!(s["exceeding_threshold"], 2);
assert!(s["average_crap"].is_number());
assert!(s["median_crap"].is_number());
}
#[test]
fn test_summary_distribution() {
let result = make_multi_function_result();
let v = parse_json(&result, &default_config());
let d = &v["result"]["summary"]["distribution"];
assert_eq!(d["low"], 1);
assert_eq!(d["acceptable"], 0);
assert_eq!(d["moderate"], 1);
assert_eq!(d["high"], 1);
}
#[test]
fn test_passed_true() {
let result =
make_single_function_result("f", "src/lib.rs", 1, 100.0, 1.0, RiskLevel::Low, 8.0);
let v = parse_json(&result, &default_config());
assert_eq!(v["result"]["passed"], true);
}
#[test]
fn test_passed_false() {
let result = make_multi_function_result();
let v = parse_json(&result, &default_config());
assert_eq!(v["result"]["passed"], false);
}
#[test]
fn test_empty_valid_json() {
let result = make_empty_result();
let v = parse_json(&result, &default_config());
let funcs = v["result"]["functions"].as_array().unwrap();
assert!(funcs.is_empty());
assert_eq!(v["result"]["summary"]["total_functions"], 0);
assert_eq!(v["result"]["passed"], true);
}
#[test]
fn test_metric_from_config() {
let result = make_empty_result();
let config = JsonConfig {
metric: ComplexityMetric::Cognitive,
..default_config()
};
let v = parse_json(&result, &config);
assert_eq!(v["metric"], "cognitive");
let config2 = JsonConfig {
metric: ComplexityMetric::Cyclomatic,
..default_config()
};
let v2 = parse_json(&result, &config2);
assert_eq!(v2["metric"], "cyclomatic");
}
#[test]
fn test_threshold_from_config() {
let result = make_empty_result();
let config = JsonConfig {
threshold: 12.5,
..default_config()
};
let v = parse_json(&result, &config);
assert_eq!(v["threshold"], 12.5);
}
#[test]
fn test_timestamp_passthrough() {
let result = make_empty_result();
let config = JsonConfig {
timestamp: "2026-01-15T09:30:00Z".to_string(),
..default_config()
};
let v = parse_json(&result, &config);
assert_eq!(v["timestamp"], "2026-01-15T09:30:00Z");
}
#[test]
fn test_full_json_snapshot() {
let result = make_single_function_result(
"compute_crap",
"src/domain/crap.rs",
5,
80.0,
5.16,
RiskLevel::Acceptable,
8.0,
);
let view = make_view_default(&result);
let json_str = format_json(&view, &default_config()).unwrap();
let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
insta::assert_json_snapshot!(v);
}
#[test]
fn test_envelope_field_declaration_order() {
let result = make_empty_result();
let view = make_view_default(&result);
let json_str = format_json(&view, &default_config()).unwrap();
let keys = [
"schema_version",
"tool_version",
"language",
"timestamp",
"metric",
"threshold",
"diff_ref",
"result",
"view",
];
let positions: Vec<usize> = keys
.iter()
.map(|k| {
json_str
.find(&format!("\n \"{k}\""))
.unwrap_or_else(|| panic!("missing top-level key {k} in:\n{json_str}"))
})
.collect();
for (k_prev, w) in keys.windows(2).zip(positions.windows(2)) {
assert!(
w[0] < w[1],
"envelope key order: expected {} before {}, got positions {} and {}",
k_prev[0],
k_prev[1],
w[0],
w[1],
);
}
}
#[test]
fn test_view_block_present_in_envelope() {
let result = make_multi_function_result();
let view = make_view_default(&result);
let json_str = format_json(&view, &default_config()).unwrap();
let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let view_block = v.get("view").expect("envelope missing view block");
assert!(view_block.get("spec").is_some());
assert!(view_block.get("eligible_count").is_some());
assert!(view_block.get("truncated").is_some());
assert!(view_block.get("shown").is_some());
assert!(view_block.get("shown_summary").is_some());
assert!(
view_block.get("full").is_none(),
"view.full should be elided from JSON (envelope's `result` already serializes it)"
);
let spec = view_block.get("spec").unwrap();
assert_eq!(spec["sort"], "crap");
assert_eq!(spec["limit"], serde_json::Value::Null);
assert_eq!(spec["filters"]["only_failing"], false);
}
#[test]
fn test_view_grouped_null_by_default() {
let result = make_multi_function_result();
let v = parse_json(&result, &default_config());
let view_block = &v["view"];
assert_eq!(view_block["grouped"], serde_json::Value::Null);
assert_eq!(view_block["spec"]["group_by"], serde_json::Value::Null);
}
#[test]
fn test_view_grouped_populated_under_group_by_file() {
use crate::domain::view::{self, GroupKey, ViewSpec};
let result = make_multi_function_result();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
..Default::default()
};
let view = view::apply(&result, spec);
let json_str = format_json(&view, &default_config()).unwrap();
let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let grouped = &v["view"]["grouped"];
assert!(grouped.is_object(), "view.grouped must be populated");
assert_eq!(grouped["key"], "file");
assert!(grouped["files"].is_array());
let files = grouped["files"].as_array().unwrap();
assert_eq!(files.len(), 3); let f0 = &files[0];
assert!(f0["file_path"].is_string());
assert!(f0["function_count"].is_number());
assert!(f0["exceeding_count"].is_number());
assert!(f0["average_crap"].is_number());
assert!(f0["average_coverage"].is_number());
assert!(f0["max_complexity"].is_number());
assert!(f0["distribution"].is_object());
assert_eq!(v["view"]["spec"]["group_by"], "file");
}
#[test]
fn test_diff_ref_present_in_json() {
let result = make_empty_result();
let config = JsonConfig {
diff_ref: Some("main"),
..default_config()
};
let v = parse_json(&result, &config);
assert_eq!(v["diff_ref"], "main");
}
#[test]
fn test_diff_ref_null_when_none() {
let result = make_empty_result();
let v = parse_json(&result, &default_config());
assert!(
v.get("diff_ref").is_some(),
"diff_ref key should be present"
);
assert!(v["diff_ref"].is_null(), "diff_ref should be null");
}
#[test]
fn test_diagnostics_omitted_when_none() {
let result = make_empty_result();
let v = parse_json(&result, &default_config());
assert!(
v.get("diagnostics").is_none(),
"diagnostics should be absent without --verbose"
);
}
#[test]
fn test_diagnostics_included_when_present_p_agnostic_top_level() {
use crate::domain::types::AnalysisDiagnostics;
let diag: AnalysisDiagnostics<DummyParseDiagnostic> = AnalysisDiagnostics {
parse_diagnostics: vec![],
files_found: 10,
files_unparseable: 1,
functions_extracted: 42,
functions_matched: 40,
functions_no_coverage: 2,
files_analyzed: 8,
files_zero_coverage: 2,
};
let result = make_empty_result();
let config = JsonConfig {
diagnostics: Some(&diag),
..default_config()
};
let v = parse_json(&result, &config);
let d = v.get("diagnostics").expect("should have diagnostics key");
assert_eq!(d["files_found"], 10);
assert_eq!(d["files_unparseable"], 1);
assert_eq!(d["functions_extracted"], 42);
assert_eq!(d["functions_matched"], 40);
assert_eq!(d["functions_no_coverage"], 2);
let parse_diags = d["parse_diagnostics"].as_array().unwrap();
assert!(parse_diags.is_empty());
}
}
#[cfg(test)]
mod proptests {
use super::*;
use crate::adapters::reporters::test_fixtures::make_view_default;
use crate::test_strategies::{DummyParseDiagnostic, arb_analysis_result};
use proptest::prelude::*;
fn arb_config() -> impl Strategy<Value = JsonConfig<'static, DummyParseDiagnostic>> {
(1.0..100.0f64,).prop_map(|(threshold,)| JsonConfig {
tool_version: "0.1.0".to_string(),
metric: ComplexityMetric::Cognitive,
threshold,
timestamp: "2026-01-01T00:00:00Z".to_string(),
diagnostics: None,
diff_ref: None,
minimal_view: false,
delta: None,
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn prop_format_json_always_valid(
result in arb_analysis_result(),
config in arb_config(),
) {
let view = make_view_default(&result);
let json_str = format_json(&view, &config)
.expect("format_json should never fail on valid input");
let _: serde_json::Value = serde_json::from_str(&json_str)
.expect("output should be valid JSON");
}
#[test]
fn prop_format_json_functions_count(
result in arb_analysis_result(),
config in arb_config(),
) {
let view = make_view_default(&result);
let json_str = format_json(&view, &config).unwrap();
let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let funcs = v["result"]["functions"].as_array().unwrap();
prop_assert_eq!(funcs.len(), result.functions.len());
}
}
}