use clap::{Args, Subcommand};
use serde::Serialize;
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use super::CmdResult;
#[derive(Args, Debug, Clone)]
pub struct ReportArgs {
#[command(subcommand)]
pub command: ReportCommand,
}
#[derive(Subcommand, Debug, Clone)]
pub enum ReportCommand {
FailureDigest(FailureDigestArgs),
}
#[derive(Args, Debug, Clone)]
pub struct FailureDigestArgs {
#[arg(long, value_name = "DIR")]
pub output_dir: String,
#[arg(long, value_name = "JSON")]
pub results: String,
#[arg(long, value_name = "URL")]
pub run_url: Option<String>,
#[arg(long, value_name = "JSON_OR_FILE")]
pub tooling_json: Option<String>,
#[arg(long, value_name = "CSV")]
pub commands: Option<String>,
#[arg(long, value_name = "CSV")]
pub autofix_commands: Option<String>,
#[arg(long)]
pub autofix_enabled: bool,
#[arg(long)]
pub autofix_attempted: bool,
#[arg(long, value_parser = ["markdown"], default_value = "markdown")]
pub format: String,
}
#[derive(Serialize)]
pub struct ReportOutput {
pub command: String,
pub markdown: String,
}
pub fn is_markdown_mode(args: &ReportArgs) -> bool {
matches!(
&args.command,
ReportCommand::FailureDigest(failure_args) if failure_args.format == "markdown"
)
}
pub fn run_markdown(args: ReportArgs) -> CmdResult<String> {
match args.command {
ReportCommand::FailureDigest(failure_args) => {
let markdown = render_failure_digest_from_args(&failure_args)?;
Ok((markdown, 0))
}
}
}
pub fn run(args: ReportArgs, _global: &super::GlobalArgs) -> CmdResult<ReportOutput> {
match args.command {
ReportCommand::FailureDigest(failure_args) => {
let markdown = render_failure_digest_from_args(&failure_args)?;
Ok((
ReportOutput {
command: "report.failure-digest".to_string(),
markdown,
},
0,
))
}
}
}
pub fn render_failure_digest_from_args(args: &FailureDigestArgs) -> homeboy::Result<String> {
let results = read_json_spec_value(&args.results, "results")?;
let tooling = match args.tooling_json.as_deref() {
Some(spec) => read_json_spec_value(spec, "tooling_json")?,
None => Value::Object(Map::new()),
};
let context = FailureDigestContext {
output_dir: PathBuf::from(&args.output_dir),
results: normalize_object(results),
run_url: args.run_url.clone().unwrap_or_default(),
tooling: normalize_object(tooling),
commands_csv: args.commands.clone().unwrap_or_default(),
autofix_enabled: args.autofix_enabled,
autofix_attempted: args.autofix_attempted,
autofix_commands_csv: args.autofix_commands.clone().unwrap_or_default(),
};
Ok(render_failure_digest(&context))
}
pub struct FailureDigestContext {
pub output_dir: PathBuf,
pub results: Map<String, Value>,
pub run_url: String,
pub tooling: Map<String, Value>,
pub commands_csv: String,
pub autofix_enabled: bool,
pub autofix_attempted: bool,
pub autofix_commands_csv: String,
}
pub fn render_failure_digest(context: &FailureDigestContext) -> String {
let mut out = String::new();
out.push_str("## Failure Digest\n\n");
if command_failed(&context.results, "lint") {
render_lint_section(&mut out, &context.output_dir, &context.run_url);
}
if command_failed(&context.results, "test") {
render_test_section(&mut out, &context.output_dir, &context.run_url);
}
if command_failed(&context.results, "audit") {
render_audit_section(&mut out, &context.output_dir, &context.run_url);
}
if command_reported(&context.results, "trace") {
render_trace_section(&mut out, &context.output_dir, &context.run_url);
}
if command_reported(&context.results, "bench") {
render_bench_section(&mut out, &context.output_dir, &context.run_url);
}
render_autofix_section(&mut out, context);
render_tooling_section(&mut out, &context.tooling);
out.push_str("### Machine-readable artifacts\n");
out.push_str("- `{command}.json` — structured output per command (from `homeboy --output`)\n");
out
}
fn read_json_spec_value(spec: &str, context: &str) -> homeboy::Result<Value> {
let raw = if Path::new(spec).exists() {
std::fs::read_to_string(spec).map_err(|e| {
homeboy::Error::internal_unexpected(format!("Failed to read {}: {}", spec, e))
})?
} else {
homeboy::config::read_json_spec_to_string(spec)?
};
serde_json::from_str(&raw).map_err(|e| {
homeboy::Error::validation_invalid_json(e, Some(context.to_string()), Some(raw))
})
}
fn normalize_object(value: Value) -> Map<String, Value> {
match value {
Value::Object(map) => map,
_ => Map::new(),
}
}
fn command_failed(results: &Map<String, Value>, command: &str) -> bool {
results
.get(command)
.and_then(Value::as_str)
.is_some_and(|status| status == "fail")
}
fn command_reported(results: &Map<String, Value>, command: &str) -> bool {
results.contains_key(command)
}
fn command_names_from_csv(raw: &str) -> BTreeSet<String> {
raw.split(',')
.filter_map(|part| part.trim().split(' ').next())
.map(str::trim)
.filter(|part| !part.is_empty())
.map(|part| part.to_lowercase())
.collect()
}
fn failed_commands(results: &Map<String, Value>) -> Vec<String> {
let mut commands = results
.iter()
.filter_map(|(name, status)| {
status
.as_str()
.filter(|value| *value == "fail")
.map(|_| name.clone())
})
.collect::<Vec<_>>();
commands.sort();
commands
}
fn read_command_json(output_dir: &Path, command: &str) -> Option<Value> {
let path = output_dir.join(format!("{command}.json"));
let raw = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
fn envelope_parts(value: Option<Value>) -> (Map<String, Value>, Map<String, Value>) {
let Some(Value::Object(mut root)) = value else {
return (Map::new(), Map::new());
};
if root.contains_key("success") || root.contains_key("data") || root.contains_key("error") {
let take_object = |root: &mut Map<String, Value>, key: &str| {
root.remove(key)
.and_then(|v| match v {
Value::Object(map) => Some(map),
_ => None,
})
.unwrap_or_default()
};
let data = take_object(&mut root, "data");
let error = take_object(&mut root, "error");
return (data, error);
}
(root, Map::new())
}
fn render_lint_section(out: &mut String, output_dir: &Path, run_url: &str) {
out.push_str("### Lint Failure Digest\n");
let (data, error) = envelope_parts(read_command_json(output_dir, "lint"));
if let Some(summary) = string_value(&data, "summary") {
let _ = writeln!(out, "- Lint summary: **{}**", summary);
}
if let Some(summary) = string_value(&data, "phpcs_summary") {
let _ = writeln!(out, "- PHPCS: {}", summary);
}
if let Some(summary) = string_value(&data, "phpstan_summary") {
let _ = writeln!(out, "- PHPStan: {}", summary);
}
if let Some(build_failed) = string_value(&data, "build_failed") {
let _ = writeln!(out, "- Build failed: {}", build_failed);
}
render_error_details(out, &error);
let top_violations = string_array(&data, "top_violations");
append_details_block(out, "Top lint violations", &top_violations, 10);
if !has_any_lint_detail(&data, &error) && top_violations.is_empty() {
out.push_str("- No structured lint details available.\n");
}
render_full_log(out, "lint", run_url);
out.push('\n');
}
fn render_test_section(out: &mut String, output_dir: &Path, run_url: &str) {
out.push_str("### Test Failure Digest\n");
let (data, error) = envelope_parts(read_command_json(output_dir, "test"));
render_error_details(out, &error);
let failed_tests = array_value(&data, "failed_tests");
let failed_count = test_failed_count(&data, failed_tests.len());
let _ = writeln!(out, "- Failed tests: **{}**", failed_count);
let details = failed_tests
.iter()
.take(10)
.enumerate()
.map(|(idx, item)| summarize_test_failure(item, idx + 1))
.collect::<Vec<_>>();
if details.is_empty() {
out.push_str("- No structured test failure details available.\n");
} else {
append_details_block(
out,
&format!("Failed test details ({} shown)", details.len()),
&details,
10,
);
}
render_full_log(out, "test", run_url);
out.push('\n');
}
fn render_audit_section(out: &mut String, output_dir: &Path, run_url: &str) {
out.push_str("### Audit Failure Digest\n");
let (data, error) = envelope_parts(read_command_json(output_dir, "audit"));
render_error_details(out, &error);
let summary = object_value(&data, "summary");
let baseline = object_value(&data, "baseline_comparison");
if let Some(score) = summary.get("alignment_score").and_then(Value::as_f64) {
let _ = writeln!(out, "- Alignment score: **{:.3}**", score);
}
let severity_counts = severity_counts(&data);
if !severity_counts.is_empty() {
let text = severity_counts
.iter()
.map(|(severity, count)| format!("{severity}: {count}"))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(out, "- Severity counts: **{}**", text);
}
if let Some(outliers) = summary.get("outliers_found").and_then(Value::as_i64) {
let _ = writeln!(out, "- Outliers in current run: **{}**", outliers);
}
let outlier_items = collect_outlier_items(&data);
if !outlier_items.is_empty() {
let _ = writeln!(out, "- Parsed outlier entries: **{}**", outlier_items.len());
}
let drift_increased = baseline
.get("drift_increased")
.and_then(Value::as_bool)
.unwrap_or(false);
let _ = writeln!(
out,
"- Drift increased: **{}**",
if drift_increased { "yes" } else { "no" }
);
let new_items = array_from_object(&baseline, "new_items");
if !new_items.is_empty() {
let _ = writeln!(
out,
"- New findings since baseline: **{}**",
new_items.len()
);
for (idx, item) in new_items.iter().take(5).enumerate() {
let context = item_string(item, &["context_label", "file"], "unknown");
let message = item_string(item, &["description", "message"], "(new finding)");
let fingerprint = item_string(item, &["fingerprint"], "");
let _ = write!(out, " {}. **{}**", idx + 1, context);
if !message.is_empty() {
let _ = write!(out, " — {}", message);
}
if !fingerprint.is_empty() {
let _ = write!(out, " (`{}`)", fingerprint);
}
out.push('\n');
}
}
let top_findings = collect_audit_findings(&data, &outlier_items);
if top_findings.is_empty() {
out.push_str("- No structured audit findings available.\n");
} else {
out.push_str("- Top actionable findings:\n");
for (idx, finding) in top_findings.iter().take(5).enumerate() {
let _ = writeln!(out, " {}. {}", idx + 1, format_audit_finding(finding));
}
let detail_lines = top_findings
.iter()
.take(300)
.enumerate()
.map(|(idx, finding)| format!("{}. {}", idx + 1, format_audit_finding(finding)))
.collect::<Vec<_>>();
append_details_block(
out,
&format!("All parsed audit findings ({})", top_findings.len()),
&detail_lines,
300,
);
}
render_full_log(out, "audit", run_url);
out.push('\n');
}
fn render_trace_section(out: &mut String, output_dir: &Path, run_url: &str) {
let (data, error) = envelope_parts(read_command_json(output_dir, "trace"));
let results = object_value(&data, "results");
let failure = object_value(&data, "failure");
let component = string_value(&data, "component")
.or_else(|| string_value(&results, "component_id"))
.or_else(|| string_value(&failure, "component_id"))
.unwrap_or_else(|| "unknown".to_string());
let scenario_id = string_value(&data, "scenario_id")
.or_else(|| string_value(&results, "scenario_id"))
.or_else(|| string_value(&failure, "scenario_id"));
let status = string_value(&data, "status")
.or_else(|| string_value(&results, "status"))
.or_else(|| string_value(&error, "code"))
.unwrap_or_else(|| "unknown".to_string());
let title = scenario_id
.as_ref()
.map(|scenario| format!("{} / {}", component, scenario))
.unwrap_or(component);
let _ = writeln!(out, "### Trace: {}", title);
let _ = writeln!(out, "**Status:** {}\n", status.to_uppercase());
let mut summary_lines = Vec::new();
if let Some(summary) =
string_value(&data, "summary").or_else(|| string_value(&results, "summary"))
{
summary_lines.push(summary);
}
if let Some(failure_message) = string_value(&results, "failure") {
summary_lines.push(failure_message);
}
if let Some(stderr_excerpt) = string_value(&failure, "stderr_excerpt") {
summary_lines.push(stderr_excerpt);
}
if let Some(message) = string_value(&error, "message") {
summary_lines.push(message);
}
if !summary_lines.is_empty() {
out.push_str("**Summary**\n");
for line in summary_lines {
let _ = writeln!(out, "- {}", line);
}
out.push('\n');
}
let artifacts = collect_trace_artifacts(&data, &results);
if !artifacts.is_empty() {
out.push_str("**Artifacts**\n");
for (label, path) in artifacts {
let _ = writeln!(out, "- {}: {}", label, path);
}
} else {
out.push_str("**Artifacts**\n- No structured trace artifacts available.\n");
}
render_full_log(out, "trace", run_url);
out.push('\n');
}
fn render_bench_section(out: &mut String, output_dir: &Path, run_url: &str) {
let (data, error) = envelope_parts(read_command_json(output_dir, "bench"));
let component = string_value(&data, "component")
.or_else(|| string_value(&object_value(&data, "results"), "component_id"))
.unwrap_or_else(|| "unknown".to_string());
let status = string_value(&data, "status")
.or_else(|| string_value(&error, "code"))
.unwrap_or_else(|| "unknown".to_string());
let _ = writeln!(out, "### Bench: {}", component);
let _ = writeln!(out, "**Status:** {}\n", status.to_uppercase());
if let Some(message) = string_value(&error, "message") {
out.push_str("**Summary**\n");
let _ = writeln!(out, "- {}\n", message);
}
let artifacts = collect_bench_artifacts(&data);
if !artifacts.is_empty() {
out.push_str("**Artifacts**\n");
for artifact in artifacts {
let _ = writeln!(out, "- {}", artifact);
}
} else {
out.push_str("**Artifacts**\n- No structured bench artifacts available.\n");
}
render_full_log(out, "bench", run_url);
out.push('\n');
}
fn render_error_details(out: &mut String, error: &Map<String, Value>) {
if let Some(code) = string_value(error, "code") {
let _ = writeln!(out, "- Error code: `{}`", code);
}
if let Some(message) = string_value(error, "message") {
let _ = writeln!(out, "- Error message: {}", message);
}
if let Some(details) = object_value(error, "details")
.get("field")
.and_then(Value::as_str)
{
let _ = writeln!(out, "- Error field: `{}`", details);
}
if let Some(hints) = error.get("hints").and_then(Value::as_array) {
if let Some(first) = hints.first().and_then(Value::as_str) {
let _ = writeln!(out, "- Hint: {}", first);
}
}
}
fn render_autofix_section(out: &mut String, context: &FailureDigestContext) {
let failed = failed_commands(&context.results);
let potential = if context.autofix_commands_csv.trim().is_empty() {
command_names_from_csv(&context.commands_csv)
.into_iter()
.filter(|cmd| matches!(cmd.as_str(), "audit" | "lint" | "test"))
.collect::<BTreeSet<_>>()
} else {
command_names_from_csv(&context.autofix_commands_csv)
};
let fixable = if context.autofix_enabled {
potential.clone()
} else {
BTreeSet::new()
};
let mut auto_fixable_failed = Vec::new();
let mut potential_auto_fixable_failed = Vec::new();
let mut human_needed_failed = Vec::new();
for cmd in &failed {
let normalized = cmd.to_lowercase();
if potential.contains(&normalized) {
potential_auto_fixable_failed.push(cmd.clone());
}
if fixable.contains(&normalized) && !context.autofix_attempted {
auto_fixable_failed.push(cmd.clone());
} else {
human_needed_failed.push(cmd.clone());
}
}
let overall = if failed.is_empty() {
"none"
} else if !auto_fixable_failed.is_empty() && human_needed_failed.is_empty() {
"auto_fixable"
} else if !auto_fixable_failed.is_empty() {
"mixed"
} else {
"human_needed"
};
out.push_str("### Autofixability classification\n");
let _ = writeln!(out, "- Overall: **{}**", overall);
let _ = writeln!(
out,
"- Autofix enabled: **{}**",
if context.autofix_enabled { "yes" } else { "no" }
);
let _ = writeln!(
out,
"- Autofix attempted this run: **{}**",
if context.autofix_attempted {
"yes"
} else {
"no"
}
);
if !auto_fixable_failed.is_empty() {
out.push_str("- Auto-fixable failed commands:\n");
for cmd in &auto_fixable_failed {
let _ = writeln!(out, " - `{}`", cmd);
}
}
if !human_needed_failed.is_empty() {
out.push_str("- Human-needed failed commands:\n");
for cmd in &human_needed_failed {
let _ = writeln!(out, " - `{}`", cmd);
}
}
if auto_fixable_failed.is_empty() && human_needed_failed.is_empty() {
out.push_str("- No failed commands to classify.\n");
}
if !potential_auto_fixable_failed.is_empty() {
out.push_str("- Failed commands with available automated fixes:\n");
for cmd in &potential_auto_fixable_failed {
let _ = writeln!(out, " - `{}`", cmd);
}
}
if !context.autofix_enabled {
if potential.is_empty() {
out.push_str(
"- Automated fixes are **disabled for this step** and no fix-capable commands were detected.\n",
);
} else {
let candidates = potential
.iter()
.map(|cmd| format!("`{cmd}`"))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
out,
"- Automated fixes are **disabled for this step**. Commands with available fix support in this run: {}",
candidates
);
}
}
out.push('\n');
}
fn render_tooling_section(out: &mut String, tooling: &Map<String, Value>) {
if tooling.is_empty() {
return;
}
out.push_str("### Tooling metadata\n");
for (key, value) in BTreeMap::from_iter(tooling.iter()) {
let rendered = value
.as_str()
.map_or_else(|| value.to_string(), str::to_string);
let _ = writeln!(out, "- {}: `{}`", key, rendered);
}
out.push('\n');
}
fn render_full_log(out: &mut String, command: &str, run_url: &str) {
if run_url.is_empty() {
let _ = writeln!(
out,
"- Full {} log: structured job link unavailable",
command
);
} else {
let _ = writeln!(out, "- Full {} log: {}", command, run_url);
}
}
fn has_any_lint_detail(data: &Map<String, Value>, error: &Map<String, Value>) -> bool {
[
"summary",
"phpcs_summary",
"phpstan_summary",
"build_failed",
]
.iter()
.any(|key| string_value(data, key).is_some())
|| ["code", "message"]
.iter()
.any(|key| string_value(error, key).is_some())
}
fn string_value(map: &Map<String, Value>, key: &str) -> Option<String> {
match map.get(key)? {
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn object_value(map: &Map<String, Value>, key: &str) -> Map<String, Value> {
map.get(key)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default()
}
fn array_value<'a>(map: &'a Map<String, Value>, key: &str) -> Vec<&'a Value> {
map.get(key)
.and_then(Value::as_array)
.map(|items| items.iter().collect())
.unwrap_or_default()
}
fn array_from_object(map: &Map<String, Value>, key: &str) -> Vec<Value> {
map.get(key)
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
fn collect_trace_artifacts(
data: &Map<String, Value>,
results: &Map<String, Value>,
) -> Vec<(String, String)> {
let mut seen = BTreeSet::new();
[
array_value(data, "artifacts"),
array_value(results, "artifacts"),
]
.into_iter()
.flatten()
.filter_map(|artifact| {
let obj = artifact.as_object()?;
let label = string_value(obj, "label").or_else(|| string_value(obj, "name"))?;
let path = string_value(obj, "path")?;
if !seen.insert((label.clone(), path.clone())) {
return None;
}
Some((label, path))
})
.collect()
}
fn collect_bench_artifacts(data: &Map<String, Value>) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut rendered = Vec::new();
for artifact in array_value(data, "artifacts") {
push_bench_artifact(&mut seen, &mut rendered, None, artifact);
}
for rig in array_value(data, "rigs") {
let Some(rig_obj) = rig.as_object() else {
continue;
};
let rig_id = string_value(rig_obj, "rig_id");
for artifact in array_value(rig_obj, "artifacts") {
push_bench_artifact(&mut seen, &mut rendered, rig_id.as_deref(), artifact);
}
}
rendered
}
fn push_bench_artifact(
seen: &mut BTreeSet<(Option<String>, String)>,
rendered: &mut Vec<String>,
rig_id: Option<&str>,
artifact: &Value,
) {
let Some(obj) = artifact.as_object() else {
return;
};
let Some(path) = string_value(obj, "path") else {
return;
};
let key = (rig_id.map(str::to_string), path.clone());
if !seen.insert(key) {
return;
}
let label = string_value(obj, "label")
.or_else(|| string_value(obj, "name"))
.unwrap_or_else(|| "artifact".to_string());
let scenario = string_value(obj, "scenario_id");
let run_index = string_value(obj, "run_index");
let kind = string_value(obj, "kind");
let mut prefix = Vec::new();
if let Some(rig) = rig_id {
prefix.push(format!("rig `{}`", rig));
}
if let Some(scenario) = scenario {
prefix.push(format!("scenario `{}`", scenario));
}
if let Some(run_index) = run_index {
prefix.push(format!("run {}", run_index));
}
let mut line = if prefix.is_empty() {
label
} else {
format!("{} — {}", prefix.join(" / "), label)
};
if let Some(kind) = kind {
let _ = write!(line, " ({})", kind);
}
let _ = write!(line, ": {}", path);
rendered.push(line);
}
fn string_array(map: &Map<String, Value>, key: &str) -> Vec<String> {
map.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|value| match value {
Value::String(s) => Some(s.clone()),
Value::Object(obj) => Some(Value::Object(obj.clone()).to_string()),
other if !other.is_null() => Some(other.to_string()),
_ => None,
})
.collect()
})
.unwrap_or_default()
}
fn test_failed_count(data: &Map<String, Value>, fallback: usize) -> usize {
let counts = object_value(data, "test_counts");
let failed = counts.get("failed").and_then(Value::as_u64).unwrap_or(0);
let errors = counts.get("errors").and_then(Value::as_u64).unwrap_or(0);
let total = failed + errors;
if total > 0 {
total as usize
} else {
fallback
}
}
fn summarize_test_failure(item: &Value, idx: usize) -> String {
let Some(obj) = item.as_object() else {
return format!("{}. {}", idx, item.as_str().unwrap_or("unknown"));
};
let name = string_value(obj, "name").unwrap_or_else(|| "unknown".to_string());
let detail = string_value(obj, "detail").or_else(|| string_value(obj, "message"));
let location = string_value(obj, "location").or_else(|| string_value(obj, "file"));
let mut parts = vec![format!("{}. {}", idx, name)];
if let Some(detail) = detail {
parts.push(detail);
}
if let Some(location) = location {
parts.push(location);
}
parts.join(" — ")
}
fn severity_counts(data: &Map<String, Value>) -> BTreeMap<String, usize> {
let mut counts = BTreeMap::new();
let outliers = collect_outlier_items(data);
for finding in collect_audit_findings(data, &outliers) {
let severity = item_string(&finding, &["severity", "level"], "unknown").to_lowercase();
*counts.entry(severity).or_insert(0) += 1;
}
counts
}
fn collect_outlier_items(data: &Map<String, Value>) -> Vec<Value> {
let mut outliers = Vec::new();
for convention in array_value(data, "conventions") {
let Some(obj) = convention.as_object() else {
continue;
};
let label = item_string(
convention,
&["context_label", "name", "rule", "pattern"],
"unknown",
);
for outlier in array_value(obj, "outliers") {
let mut item = outlier.clone();
if let Value::Object(ref mut map) = item {
map.entry("context_label".to_string())
.or_insert_with(|| Value::String(label.clone()));
}
outliers.push(item);
}
}
outliers
}
fn collect_audit_findings(data: &Map<String, Value>, outliers: &[Value]) -> Vec<Value> {
let mut findings = array_value(data, "findings")
.into_iter()
.cloned()
.collect::<Vec<_>>();
findings.extend(outliers.iter().cloned());
findings
}
fn item_string(item: &Value, keys: &[&str], fallback: &str) -> String {
let Some(obj) = item.as_object() else {
return fallback.to_string();
};
for key in keys {
if let Some(value) = obj.get(*key) {
if let Some(s) = value.as_str() {
if !s.is_empty() {
return s.to_string();
}
} else if !value.is_null() {
return value.to_string();
}
}
}
fallback.to_string()
}
fn format_audit_finding(finding: &Value) -> String {
let file = item_string(finding, &["file", "path", "context_label"], "unknown");
let rule = item_string(finding, &["rule", "kind", "category"], "outlier");
let message = item_string(finding, &["description", "message"], "");
if message.is_empty() {
format!("**{}** — {}", file, rule)
} else {
format!("**{}** — {} — {}", file, rule, message)
}
}
fn append_details_block(out: &mut String, summary: &str, lines: &[String], limit: usize) {
let content = lines
.iter()
.filter(|line| !line.trim().is_empty())
.take(limit)
.collect::<Vec<_>>();
if content.is_empty() {
return;
}
let _ = writeln!(out, "\n<details><summary>{}</summary>\n", summary);
out.push_str("```text\n");
for line in content {
out.push_str(line);
out.push('\n');
}
out.push_str("```\n\n</details>\n");
}