use std::io::{self, Write};
use console::style;
use crate::cli::OutputContext;
use crate::commands::types::{
BulkPrReviewResult, PrLabelResult, PrReviewResult, SinglePrReviewOutcome,
};
use crate::output::Renderable;
use aptu_core::PrCreateResult;
fn render_security_findings_text(
w: &mut dyn Write,
findings: &[aptu_core::Finding],
ctx: &OutputContext,
) -> io::Result<()> {
if findings.is_empty() {
writeln!(
w,
"{}: {}",
style("Security Scan").green().bold(),
style("No issues found").green()
)?;
writeln!(w)?;
} else if ctx.verbose {
writeln!(w, "{}", style("Security Findings").red().bold())?;
for finding in findings {
let severity_style = match finding.severity {
aptu_core::Severity::Critical => style("CRITICAL").red().bold(),
aptu_core::Severity::High => style("HIGH").red(),
aptu_core::Severity::Medium => style("MEDIUM").yellow(),
aptu_core::Severity::Low => style("LOW").dim(),
};
writeln!(
w,
" [{}] {}:{}",
severity_style,
style(&finding.file_path).cyan(),
finding.line_number
)?;
writeln!(w, " {}", finding.description)?;
if let Some(cwe) = &finding.cwe {
writeln!(w, " {}", style(cwe).dim())?;
}
}
writeln!(w)?;
} else {
let count = findings.len();
let critical_count = findings
.iter()
.filter(|f| matches!(f.severity, aptu_core::Severity::Critical))
.count();
let high_count = findings
.iter()
.filter(|f| matches!(f.severity, aptu_core::Severity::High))
.count();
let summary = if critical_count > 0 || high_count > 0 {
let mut parts = vec![];
if critical_count > 0 {
parts.push(format!("{critical_count} CRITICAL"));
}
if high_count > 0 {
parts.push(format!("{high_count} HIGH"));
}
format!(
"{} finding{} ({})",
count,
if count == 1 { "" } else { "s" },
parts.join(", ")
)
} else {
format!("{} finding{}", count, if count == 1 { "" } else { "s" })
};
writeln!(
w,
"{}: {} {}",
style("Security Scan").red().bold(),
style(summary).red(),
style("(use --verbose for details)").dim()
)?;
writeln!(w)?;
}
Ok(())
}
fn render_comments_text(
w: &mut dyn Write,
comments: &[aptu_core::ai::types::PrReviewComment],
) -> io::Result<()> {
for comment in comments {
let severity_style = match comment.severity.as_str() {
"issue" => style(&comment.severity).red(),
"warning" => style(&comment.severity).yellow(),
"suggestion" => style(&comment.severity).blue(),
_ => style(&comment.severity).dim(),
};
let line_info = comment.line.map_or(String::new(), |l| format!(":{l}"));
writeln!(
w,
" [{}] {}{}",
severity_style,
style(&comment.file).cyan(),
line_info
)?;
writeln!(w, " {}", comment.comment)?;
}
Ok(())
}
impl Renderable for PrReviewResult {
fn render_text(&self, w: &mut dyn Write, ctx: &OutputContext) -> io::Result<()> {
let verdict_style = match self.review.verdict.as_str() {
"approve" => style(&self.review.verdict).green().bold(),
"request_changes" => style(&self.review.verdict).red().bold(),
_ => style(&self.review.verdict).yellow().bold(),
};
writeln!(w, "{}: {}", style("Verdict").bold(), verdict_style)?;
writeln!(w)?;
if let Some(findings) = &self.security_findings {
render_security_findings_text(w, findings, ctx)?;
writeln!(w)?;
}
writeln!(w, "{}", style("Summary").cyan().bold())?;
writeln!(w, "{}", self.review.summary)?;
writeln!(w)?;
if let Some(disclaimer) = &self.review.disclaimer {
writeln!(w, "{}", style("Disclaimer").yellow().bold())?;
writeln!(w, "{disclaimer}")?;
writeln!(w)?;
}
if !self.review.strengths.is_empty() {
writeln!(w, "{}", style("Strengths").green().bold())?;
for strength in &self.review.strengths {
writeln!(w, " + {strength}")?;
}
writeln!(w)?;
}
if !self.review.concerns.is_empty() {
writeln!(w, "{}", style("Concerns").red().bold())?;
for concern in &self.review.concerns {
writeln!(w, " - {concern}")?;
}
writeln!(w)?;
}
if !self.review.comments.is_empty() {
writeln!(w, "{}", style("Comments").yellow().bold())?;
render_comments_text(w, &self.review.comments)?;
writeln!(w)?;
}
if !self.review.suggestions.is_empty() {
writeln!(w, "{}", style("Suggestions").blue().bold())?;
for suggestion in &self.review.suggestions {
writeln!(w, " * {suggestion}")?;
}
writeln!(w)?;
}
if ctx.is_verbose() {
writeln!(w, "{}", style("AI Stats").dim().bold())?;
writeln!(
w,
" Model: {} | Tokens: {} in, {} out | Duration: {}ms",
self.ai_stats.model,
self.ai_stats.input_tokens,
self.ai_stats.output_tokens,
self.ai_stats.duration_ms
)?;
if let Some(cost) = self.ai_stats.cost_usd {
writeln!(w, " Cost: ${cost:.6}")?;
}
writeln!(w, " Prompt: {} chars", self.ai_stats.prompt_chars)?;
writeln!(w)?;
}
if self.dry_run {
crate::output::common::show_dry_run_message(w, "DRY RUN MODE")?;
}
Ok(())
}
fn render_markdown(&self, w: &mut dyn Write, _ctx: &OutputContext) -> io::Result<()> {
writeln!(w, "## PR Review: #{} - {}", self.pr_number, self.pr_title)?;
writeln!(w)?;
writeln!(w, "**Verdict:** {}", self.review.verdict)?;
writeln!(w)?;
writeln!(w, "### Summary")?;
writeln!(w, "{}", self.review.summary)?;
writeln!(w)?;
if let Some(findings) = &self.security_findings {
if findings.is_empty() {
writeln!(w, "### Security Scan")?;
writeln!(w, "No issues found")?;
writeln!(w)?;
} else {
writeln!(w, "### Security Findings")?;
for finding in findings {
writeln!(
w,
"- **[{}]** `{}:{}`",
match finding.severity {
aptu_core::Severity::Critical => "CRITICAL",
aptu_core::Severity::High => "HIGH",
aptu_core::Severity::Medium => "MEDIUM",
aptu_core::Severity::Low => "LOW",
},
finding.file_path,
finding.line_number
)?;
writeln!(w, " {}", finding.description)?;
if let Some(cwe) = &finding.cwe {
writeln!(w, " {cwe}")?;
}
}
writeln!(w)?;
}
}
if let Some(disclaimer) = &self.review.disclaimer {
writeln!(w, "### Disclaimer")?;
writeln!(w, "> {disclaimer}")?;
writeln!(w)?;
}
if !self.review.strengths.is_empty() {
writeln!(w, "### Strengths")?;
for strength in &self.review.strengths {
writeln!(w, "- {strength}")?;
}
writeln!(w)?;
}
if !self.review.concerns.is_empty() {
writeln!(w, "### Concerns")?;
for concern in &self.review.concerns {
writeln!(w, "- {concern}")?;
}
writeln!(w)?;
}
if !self.review.comments.is_empty() {
writeln!(w, "### Comments")?;
for comment in &self.review.comments {
let line_info = comment.line.map_or(String::new(), |l| format!(":{l}"));
writeln!(
w,
"- **[{}]** `{}{}`",
comment.severity, comment.file, line_info
)?;
writeln!(w, " {}", comment.comment)?;
}
writeln!(w)?;
}
if !self.review.suggestions.is_empty() {
writeln!(w, "### Suggestions")?;
for suggestion in &self.review.suggestions {
writeln!(w, "- {suggestion}")?;
}
writeln!(w)?;
}
Ok(())
}
}
impl Renderable for BulkPrReviewResult {
fn render_text(&self, w: &mut dyn Write, _ctx: &OutputContext) -> io::Result<()> {
writeln!(w)?;
writeln!(w, "{}", style("PR Review Summary").cyan().bold())?;
writeln!(w)?;
writeln!(
w,
" {} succeeded | {} failed | {} skipped",
style(self.succeeded).green().bold(),
style(self.failed).red().bold(),
style(self.skipped).yellow().bold(),
)?;
writeln!(w)?;
if !self.outcomes.is_empty() {
writeln!(w, "{}", style("Outcomes").dim().bold())?;
for (pr_ref, outcome) in &self.outcomes {
match outcome {
SinglePrReviewOutcome::Success(result) => {
writeln!(
w,
" {} {} ({})",
style("✓").green(),
pr_ref,
style(&result.review.verdict).green()
)?;
}
SinglePrReviewOutcome::Skipped(reason) => {
writeln!(
w,
" {} {} ({})",
style("⊘").yellow(),
pr_ref,
style(reason).yellow()
)?;
}
SinglePrReviewOutcome::Failed(error) => {
writeln!(
w,
" {} {} ({})",
style("✗").red(),
pr_ref,
style(error).red()
)?;
}
}
}
writeln!(w)?;
}
if self.has_dry_run() {
crate::output::common::show_dry_run_message(w, "DRY RUN MODE")?;
}
Ok(())
}
fn render_markdown(&self, w: &mut dyn Write, _ctx: &OutputContext) -> io::Result<()> {
writeln!(w, "## PR Review Summary")?;
writeln!(w)?;
writeln!(
w,
"- **Succeeded:** {}\n- **Failed:** {}\n- **Skipped:** {}",
self.succeeded, self.failed, self.skipped
)?;
writeln!(w)?;
if !self.outcomes.is_empty() {
writeln!(w, "### Outcomes")?;
for (pr_ref, outcome) in &self.outcomes {
match outcome {
SinglePrReviewOutcome::Success(result) => {
writeln!(w, "- ✓ `{pr_ref}` ({})", result.review.verdict)?;
}
SinglePrReviewOutcome::Skipped(reason) => {
writeln!(w, "- ⊘ `{pr_ref}` ({reason})")?;
}
SinglePrReviewOutcome::Failed(error) => {
writeln!(w, "- ✗ `{pr_ref}` ({error})")?;
}
}
}
writeln!(w)?;
}
Ok(())
}
}
impl Renderable for PrLabelResult {
fn render_text(&self, w: &mut dyn Write, _ctx: &OutputContext) -> io::Result<()> {
writeln!(w)?;
writeln!(
w,
"{} #{}: {}",
style("PR").cyan().bold(),
self.pr_number,
style(&self.pr_title).bold()
)?;
writeln!(w, "{}", style(&self.pr_url).dim())?;
writeln!(w)?;
if self.dry_run {
writeln!(w, "{}", style("DRY RUN MODE").yellow().bold())?;
writeln!(w)?;
}
if self.labels.is_empty() {
writeln!(w, "{}", style("No labels extracted").dim())?;
} else {
writeln!(w, "{}", style("Labels").cyan().bold())?;
for label in &self.labels {
writeln!(w, " - {}", style(label).green())?;
}
}
writeln!(w)?;
Ok(())
}
fn render_markdown(&self, w: &mut dyn Write, _ctx: &OutputContext) -> io::Result<()> {
writeln!(w, "## PR Labels: #{} - {}", self.pr_number, self.pr_title)?;
writeln!(w)?;
if self.dry_run {
writeln!(w, "**DRY RUN MODE**")?;
writeln!(w)?;
}
if self.labels.is_empty() {
writeln!(w, "No labels extracted")?;
} else {
writeln!(w, "### Labels")?;
for label in &self.labels {
writeln!(w, "- `{label}`")?;
}
}
writeln!(w)?;
Ok(())
}
}
impl Renderable for PrCreateResult {
fn render_text(&self, w: &mut dyn Write, _ctx: &OutputContext) -> io::Result<()> {
writeln!(
w,
"PR #{} created: {}",
self.pr_number,
style(&self.url).cyan().underlined()
)?;
writeln!(
w,
" {} -> {}",
style(&self.branch).green(),
style(&self.base).cyan()
)?;
Ok(())
}
fn render_markdown(&self, w: &mut dyn Write, _ctx: &OutputContext) -> io::Result<()> {
writeln!(w, "PR #{} created: {}", self.pr_number, self.url)?;
writeln!(w, " {} -> {}", self.branch, self.base)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_test_result(security_findings: Option<Vec<aptu_core::Finding>>) -> PrReviewResult {
PrReviewResult {
pr_title: "Test PR".to_string(),
pr_number: 42,
pr_url: "https://github.com/test/repo/pull/42".to_string(),
review: aptu_core::ai::types::PrReviewResponse {
verdict: "approve".to_string(),
summary: "Test summary".to_string(),
strengths: vec![],
concerns: vec![],
comments: vec![],
suggestions: vec![],
disclaimer: None,
},
verdict: "approve".to_string(),
ai_stats: aptu_core::history::AiStats {
provider: "openrouter".to_string(),
model: "test-model".to_string(),
input_tokens: 100,
output_tokens: 50,
duration_ms: 1000,
cost_usd: None,
fallback_provider: None,
prompt_chars: 0,
},
security_findings,
dry_run: false,
labels: vec![],
}
}
#[test]
fn test_render_markdown_security_findings_none() {
let result = build_test_result(None);
let mut output = Vec::new();
let ctx = OutputContext::from_cli(crate::cli::OutputFormat::Markdown, false);
result.render_markdown(&mut output, &ctx).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("Security Scan"));
assert!(!text.contains("Security Findings"));
}
#[test]
fn test_render_markdown_security_findings_empty() {
let result = build_test_result(Some(vec![]));
let mut output = Vec::new();
let ctx = OutputContext::from_cli(crate::cli::OutputFormat::Markdown, false);
result.render_markdown(&mut output, &ctx).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("### Security Scan"));
assert!(text.contains("No issues found"));
}
#[test]
fn test_render_markdown_security_findings_populated() {
let finding = aptu_core::Finding {
pattern_id: "test-pattern".to_string(),
description: "Test vulnerability".to_string(),
severity: aptu_core::Severity::High,
confidence: aptu_core::Confidence::High,
file_path: "src/main.rs".to_string(),
line_number: 42,
matched_text: "unsafe { }".to_string(),
cwe: Some("CWE-123".to_string()),
};
let result = build_test_result(Some(vec![finding]));
let mut output = Vec::new();
let ctx = OutputContext::from_cli(crate::cli::OutputFormat::Markdown, false);
result.render_markdown(&mut output, &ctx).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("### Security Findings"));
assert!(text.contains("[HIGH]"));
assert!(text.contains("src/main.rs:42"));
assert!(text.contains("Test vulnerability"));
assert!(text.contains("CWE-123"));
}
#[test]
fn test_render_text_security_findings_hint_in_normal_mode() {
let finding = aptu_core::Finding {
pattern_id: "test-pattern".to_string(),
description: "Test vulnerability".to_string(),
severity: aptu_core::Severity::High,
confidence: aptu_core::Confidence::High,
file_path: "src/main.rs".to_string(),
line_number: 42,
matched_text: "unsafe { }".to_string(),
cwe: Some("CWE-123".to_string()),
};
let result = build_test_result(Some(vec![finding]));
let mut output = Vec::new();
let ctx = OutputContext::from_cli(crate::cli::OutputFormat::Text, false);
result.render_text(&mut output, &ctx).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Security Scan"));
assert!(text.contains("(use --verbose for details)"));
}
#[test]
fn test_render_text_security_findings_no_hint_in_verbose_mode() {
let finding = aptu_core::Finding {
pattern_id: "test-pattern".to_string(),
description: "Test vulnerability".to_string(),
severity: aptu_core::Severity::High,
confidence: aptu_core::Confidence::High,
file_path: "src/main.rs".to_string(),
line_number: 42,
matched_text: "unsafe { }".to_string(),
cwe: Some("CWE-123".to_string()),
};
let result = build_test_result(Some(vec![finding]));
let mut output = Vec::new();
let ctx = OutputContext::from_cli(crate::cli::OutputFormat::Text, true);
result.render_text(&mut output, &ctx).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Security Findings"));
assert!(!text.contains("(use --verbose for details)"));
}
}