use crate::adapters::analyzers::iosp::{compute_severity, CallOccurrence, LogicOccurrence};
use crate::adapters::analyzers::iosp::{Classification, FunctionAnalysis};
use crate::adapters::report::github::build::{
build_architecture_view, build_complexity_view, build_coupling_view, build_dry_view,
build_iosp_view, build_srp_view, build_tq_view,
};
use crate::adapters::report::github::format::{
format_architecture, format_complexity, format_coupling, format_dry, format_iosp, format_srp,
format_tq,
};
use crate::ports::Reporter;
fn render_iosp_chunk(findings: &[IospFinding]) -> String {
format_iosp(&build_iosp_view(findings))
}
fn render_architecture_chunk(findings: &[ArchitectureFinding]) -> String {
format_architecture(&build_architecture_view(findings))
}
fn render_complexity_chunk(findings: &[crate::domain::findings::ComplexityFinding]) -> String {
format_complexity(&build_complexity_view(findings))
}
fn render_dry_chunk(findings: &[crate::domain::findings::DryFinding]) -> String {
format_dry(&build_dry_view(findings))
}
fn render_srp_chunk(findings: &[crate::domain::findings::SrpFinding]) -> String {
format_srp(&build_srp_view(findings))
}
fn render_coupling_chunk(findings: &[crate::domain::findings::CouplingFinding]) -> String {
format_coupling(&build_coupling_view(findings))
}
fn render_tq_chunk(findings: &[crate::domain::findings::TqFinding]) -> String {
format_tq(&build_tq_view(findings))
}
use crate::domain::findings::{ArchitectureFinding, IospFinding};
use crate::domain::Finding;
use crate::report::github::*;
use crate::report::{AnalysisResult, Summary};
fn make_result(name: &str, classification: Classification) -> FunctionAnalysis {
let severity = compute_severity(&classification);
FunctionAnalysis {
name: name.to_string(),
file: "test.rs".to_string(),
line: 1,
classification,
parent_type: None,
suppressed: false,
complexity: None,
qualified_name: name.to_string(),
severity,
cognitive_warning: false,
cyclomatic_warning: false,
nesting_depth_warning: false,
function_length_warning: false,
unsafe_warning: false,
error_handling_warning: false,
complexity_suppressed: false,
own_calls: vec![],
parameter_count: 0,
is_trait_impl: false,
is_test: false,
effort_score: None,
}
}
fn make_analysis(results: Vec<FunctionAnalysis>) -> AnalysisResult {
let summary = Summary::from_results(&results);
let data = crate::app::projection::project_data(&results, None);
let findings = crate::domain::AnalysisFindings {
iosp: crate::app::projection::project_iosp(&results),
..Default::default()
};
AnalysisResult {
results,
summary,
findings,
data,
}
}
#[test]
fn test_print_github_emits_quality_notice_when_clean() {
let analysis = make_analysis(vec![make_result("good_fn", Classification::Integration)]);
let reporter = crate::adapters::report::github::GithubReporter {
summary: &analysis.summary,
};
let out = reporter.render(&analysis.findings, &analysis.data);
assert!(
out.contains("::notice::") || out.contains("::error::"),
"github output must include a quality-score annotation prefix; got {out}"
);
assert!(
!out.contains("::warning::"),
"clean analysis must not emit warning annotations; got {out}"
);
}
#[test]
fn test_print_github_emits_warning_for_violation_with_location() {
let analysis = make_analysis(vec![make_result(
"bad_fn",
Classification::Violation {
has_logic: true,
has_own_calls: true,
logic_locations: vec![LogicOccurrence {
kind: "if".into(),
line: 5,
}],
call_locations: vec![CallOccurrence {
name: "helper".into(),
line: 6,
}],
},
)]);
let reporter = crate::adapters::report::github::GithubReporter {
summary: &analysis.summary,
};
let out = reporter.render(&analysis.findings, &analysis.data);
assert!(
out.contains("::warning") || out.contains("::error"),
"violation must emit warning/error annotation; got {out}"
);
assert!(
out.contains("file=test.rs"),
"annotation must include file location; got {out}"
);
}
#[test]
fn iosp_finding_emits_warning_with_file_and_line() {
use crate::domain::findings::{CallLocation, LogicLocation};
let f = IospFinding {
common: Finding {
file: "src/lib.rs".into(),
line: 42,
column: 0,
dimension: crate::findings::Dimension::Iosp,
rule_id: "iosp/violation".into(),
message: "ignored".into(),
severity: crate::domain::Severity::Medium,
suppressed: false,
},
logic_locations: vec![LogicLocation {
kind: "if".into(),
line: 44,
}],
call_locations: vec![CallLocation {
name: "helper".into(),
line: 50,
}],
effort_score: Some(2.5),
};
let out = render_iosp_chunk(&[f]);
assert!(
out.starts_with("::warning"),
"expected ::warning prefix; got {out}"
);
assert!(out.contains("file=src/lib.rs"));
assert!(out.contains("line=42"));
assert!(out.contains("IOSP violation"));
assert!(out.contains("if"));
assert!(out.contains("helper"));
}
#[test]
fn iosp_suppressed_finding_skipped() {
let f = IospFinding {
common: Finding {
file: "src/lib.rs".into(),
line: 42,
column: 0,
dimension: crate::findings::Dimension::Iosp,
rule_id: "iosp/violation".into(),
message: "ignored".into(),
severity: crate::domain::Severity::Medium,
suppressed: true,
},
logic_locations: vec![],
call_locations: vec![],
effort_score: None,
};
let out = render_iosp_chunk(&[f]);
assert!(out.is_empty(), "suppressed finding must produce no output");
}
#[test]
fn architecture_finding_emits_severity_mapped_level() {
let high = ArchitectureFinding {
common: Finding {
file: "src/foo.rs".into(),
line: 17,
column: 0,
dimension: crate::findings::Dimension::Architecture,
rule_id: "architecture/layer/violation".into(),
message: "layer skip".into(),
severity: crate::domain::Severity::High,
suppressed: false,
},
};
let out = render_architecture_chunk(&[high]);
assert!(
out.starts_with("::error"),
"High severity → ::error; got {out}"
);
assert!(out.contains("architecture/layer/violation"));
}
#[test]
fn architecture_finding_low_severity_emits_notice() {
let low = ArchitectureFinding {
common: Finding {
file: "src/foo.rs".into(),
line: 17,
column: 0,
dimension: crate::findings::Dimension::Architecture,
rule_id: "architecture/call_parity/multi_touchpoint".into(),
message: "multi".into(),
severity: crate::domain::Severity::Low,
suppressed: false,
},
};
let out = render_architecture_chunk(&[low]);
assert!(out.starts_with("::notice"));
}
#[test]
fn empty_findings_produce_empty_output() {
assert!(render_iosp_chunk(&[]).is_empty());
assert!(render_complexity_chunk(&[]).is_empty());
assert!(render_dry_chunk(&[]).is_empty());
assert!(render_srp_chunk(&[]).is_empty());
assert!(render_coupling_chunk(&[]).is_empty());
assert!(render_tq_chunk(&[]).is_empty());
assert!(render_architecture_chunk(&[]).is_empty());
}
#[test]
fn summary_annotation_no_violations_emits_notice() {
let summary = Summary {
total: 100,
quality_score: 1.0,
..Default::default()
};
let out = render_summary_annotation(&summary);
assert!(out.contains("::notice"));
assert!(out.contains("100.0%"));
}
#[test]
fn summary_annotation_with_violations_emits_error() {
let summary = Summary {
total: 100,
violations: 3,
quality_score: 0.95,
..Default::default()
};
let out = render_summary_annotation(&summary);
assert!(out.contains("::error"));
assert!(out.contains("3 finding"));
assert!(out.contains("3 IOSP violation"));
}
#[test]
fn summary_annotation_with_only_architecture_findings_emits_error() {
let summary = Summary {
total: 100,
violations: 0,
architecture_warnings: 2,
quality_score: 0.99,
..Default::default()
};
let out = render_summary_annotation(&summary);
assert!(
out.contains("::error"),
"non-IOSP findings must still produce ::error so the GitHub summary \
agrees with the default-fail exit code; got: {out}"
);
}
#[test]
fn summary_annotation_with_suppression_excess_adds_warning() {
let summary = Summary {
total: 100,
suppressed: 50,
suppression_ratio_exceeded: true,
..Default::default()
};
let out = render_summary_annotation(&summary);
assert!(out.contains("::warning"));
assert!(out.contains("Suppression ratio"));
}
#[test]
fn test_github_render_includes_summary_annotation() {
use crate::ports::Reporter;
let summary = Summary {
total: 100,
quality_score: 1.0,
..Default::default()
};
let reporter = GithubReporter { summary: &summary };
let findings = crate::domain::AnalysisFindings::default();
let data = crate::domain::AnalysisData::default();
let out = reporter.render(&findings, &data);
assert!(
out.contains("::notice"),
"render output must include summary annotation, got: {out}",
);
assert!(out.contains("100.0%"));
}
#[test]
fn test_github_render_emits_iosp_annotation_then_summary() {
use crate::domain::findings::{CallLocation, IospFinding, LogicLocation};
use crate::ports::Reporter;
let summary = Summary {
total: 1,
violations: 1,
quality_score: 0.5,
..Default::default()
};
let mut findings = crate::domain::AnalysisFindings::default();
findings.iosp.push(IospFinding {
common: Finding {
file: "src/lib.rs".into(),
line: 17,
column: 0,
dimension: crate::findings::Dimension::Iosp,
rule_id: "iosp/violation".into(),
message: "x".into(),
severity: crate::domain::Severity::Medium,
suppressed: false,
},
logic_locations: vec![LogicLocation {
kind: "if".into(),
line: 18,
}],
call_locations: vec![CallLocation {
name: "h".into(),
line: 19,
}],
effort_score: None,
});
let reporter = GithubReporter { summary: &summary };
let out = reporter.render(&findings, &crate::domain::AnalysisData::default());
let iosp_pos = out
.find("file=src/lib.rs")
.expect("iosp annotation missing");
let summary_pos = out
.find("::error::Quality analysis")
.expect("summary missing");
assert!(
iosp_pos < summary_pos,
"per-dim chunks must come before summary annotation in render output",
);
}
#[test]
fn architecture_message_with_special_chars_is_escaped() {
let mut common = Finding {
file: "src/foo,with,comma.rs".into(),
line: 7,
column: 0,
dimension: crate::findings::Dimension::Architecture,
rule_id: "architecture/custom".into(),
message: "100% bad\nline2".into(),
severity: crate::domain::Severity::Medium,
suppressed: false,
};
common.severity = crate::domain::Severity::Medium;
let arch = vec![ArchitectureFinding { common }];
let out = render_architecture_chunk(&arch);
assert!(
out.contains("100%25 bad%0Aline2"),
"message must escape % and LF; got: {out}"
);
assert!(
out.contains("file=src/foo%2Cwith%2Ccomma.rs"),
"property must escape commas; got: {out}"
);
assert!(
!out.contains("\nline2"),
"no raw LF must remain in the annotation; got: {out}"
);
}
#[test]
fn github_reporter_emits_orphan_annotations_via_snapshot_view() {
use crate::domain::findings::OrphanSuppression;
use crate::ports::Reporter;
let summary = Summary {
total: 1,
quality_score: 1.0,
..Default::default()
};
let mut findings = crate::domain::AnalysisFindings::default();
findings.orphan_suppressions = vec![OrphanSuppression {
file: "src/foo.rs".into(),
line: 42,
dimensions: vec![crate::findings::Dimension::Srp],
reason: Some("legacy".into()),
}];
let reporter = GithubReporter { summary: &summary };
let out = reporter.render(&findings, &crate::domain::AnalysisData::default());
assert!(
out.contains("file=src/foo.rs") && out.contains("line=42"),
"orphan annotation must reach output via snapshot.orphans; got: {out}"
);
}