use std::collections::VecDeque;
use std::fmt::Write as _;
use std::io::{IsTerminal, Write as _};
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::thread::JoinHandle;
use std::time::Duration;
use bastyn_core::{Finding, Kind, Observer, Phase, Report, Severity};
use crate::cli::{Format, GlobalArgs};
const SPINNER_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const FRAME_INTERVAL: Duration = Duration::from_millis(80);
const TOTAL_STEPS: u8 = 4;
fn step_number(phase: &Phase) -> Option<u8> {
match phase {
Phase::Walking => Some(1),
Phase::Analysing { .. } => Some(2),
Phase::Cve { .. } => Some(3),
Phase::Reporting => Some(4),
_ => None,
}
}
#[derive(Debug, Clone, Copy)]
#[expect(
clippy::struct_excessive_bools,
reason = "each field is an independent suppression condition; a state machine would obscure which ones are set"
)]
pub(crate) struct ProgressInputs {
pub(crate) quiet: bool,
pub(crate) no_color_flag: bool,
pub(crate) no_color_env: bool,
pub(crate) format: Format,
pub(crate) stderr_is_tty: bool,
}
#[must_use]
pub(crate) fn progress_enabled(inputs: ProgressInputs) -> bool {
inputs.stderr_is_tty
&& inputs.format == Format::Text
&& !inputs.quiet
&& !inputs.no_color_flag
&& !inputs.no_color_env
}
enum Event {
Started(Phase),
Finished(Phase),
Found {
category: String,
title: String,
location: String,
},
}
pub(crate) enum Progress {
Live(Live),
Off,
}
pub(crate) struct Live {
tx: Option<Sender<Event>>,
handle: Option<JoinHandle<()>>,
}
impl Progress {
#[must_use]
pub(crate) fn start(global: &GlobalArgs) -> Self {
let inputs = ProgressInputs {
quiet: global.quiet,
no_color_flag: global.no_color,
no_color_env: std::env::var_os("NO_COLOR").is_some(),
format: global.format,
stderr_is_tty: std::io::stderr().is_terminal(),
};
if !progress_enabled(inputs) {
return Self::Off;
}
let (tx, rx) = mpsc::channel();
let handle = std::thread::spawn(move || render_loop(&rx));
hide_cursor();
Self::Live(Live {
tx: Some(tx),
handle: Some(handle),
})
}
}
pub(crate) fn summary(report: &Report, global: &GlobalArgs) {
let inputs = SummaryInputs {
quiet: global.quiet,
no_color_flag: global.no_color,
no_color_env: std::env::var_os("NO_COLOR").is_some(),
format: global.format,
stdout_is_tty: std::io::stdout().is_terminal(),
stderr_is_tty: std::io::stderr().is_terminal(),
};
if !summary_enabled(inputs) {
return;
}
let text = summary_block(report, summary_color(inputs));
let mut stderr = std::io::stderr().lock();
let _ = stderr.write_all(text.as_bytes());
let _ = stderr.flush();
}
#[derive(Debug, Clone, Copy)]
#[expect(
clippy::struct_excessive_bools,
reason = "each field is an independent condition; a state machine would obscure which ones are set"
)]
pub(crate) struct SummaryInputs {
pub(crate) quiet: bool,
pub(crate) no_color_flag: bool,
pub(crate) no_color_env: bool,
pub(crate) format: Format,
pub(crate) stdout_is_tty: bool,
pub(crate) stderr_is_tty: bool,
}
#[must_use]
pub(crate) fn summary_enabled(inputs: SummaryInputs) -> bool {
if inputs.quiet {
return false;
}
!(inputs.format == Format::Text && inputs.stdout_is_tty)
}
#[must_use]
pub(crate) const fn summary_color(inputs: SummaryInputs) -> bool {
inputs.stderr_is_tty && !inputs.no_color_flag && !inputs.no_color_env
}
impl Observer for Progress {
fn phase_started(&self, phase: &Phase) {
if let Self::Live(live) = self {
live.send(Event::Started(phase.clone()));
}
}
fn phase_finished(&self, phase: &Phase) {
if let Self::Live(live) = self {
live.send(Event::Finished(phase.clone()));
}
}
fn found(&self, finding: &Finding) {
if let Self::Live(live) = self {
let category = finding
.categories
.first()
.map_or("", |category| category.id())
.to_owned();
live.send(Event::Found {
category,
title: finding.title.clone(),
location: format!(
"{}:{}",
finding.location.file.display(),
finding.location.line
),
});
}
}
}
impl Live {
fn send(&self, event: Event) {
if let Some(tx) = &self.tx {
let _ = tx.send(event);
}
}
}
impl Drop for Progress {
fn drop(&mut self) {
let Self::Live(live) = self else { return };
live.tx.take();
if let Some(handle) = live.handle.take() {
let _ = handle.join();
}
show_cursor();
}
}
fn hide_cursor() {
let mut stderr = std::io::stderr();
let _ = stderr.write_all(b"\x1b[?25l");
let _ = stderr.flush();
}
fn show_cursor() {
let mut stderr = std::io::stderr();
let _ = stderr.write_all(b"\x1b[?25h");
let _ = stderr.flush();
}
struct Active {
phase: Phase,
step: u8,
}
fn render_loop(rx: &mpsc::Receiver<Event>) {
let mut queue: VecDeque<Active> = VecDeque::new();
let mut frame = 0usize;
let mut spinner_on_screen = false;
let mut stderr = std::io::stderr();
loop {
match rx.recv_timeout(FRAME_INTERVAL) {
Ok(Event::Started(phase)) => {
if let Some(step) = step_number(&phase) {
let was_idle = queue.is_empty();
queue.push_back(Active { phase, step });
if was_idle {
frame = 0;
if let Some(active) = queue.front() {
draw_spinner(&mut stderr, active, frame, &mut spinner_on_screen);
}
}
}
}
Ok(Event::Finished(phase)) => {
let Some(pos) = queue.iter().position(|active| active.phase == phase) else {
continue;
};
let Some(active) = queue.remove(pos) else {
continue;
};
clear_line(&mut stderr, &mut spinner_on_screen);
let _ = writeln!(
stderr,
"{}",
done_line(active.step, TOTAL_STEPS, &active.phase.label(), true)
);
if let Some(next) = queue.front() {
frame = 0;
draw_spinner(&mut stderr, next, frame, &mut spinner_on_screen);
}
}
Ok(Event::Found {
category,
title,
location,
}) => {
clear_line(&mut stderr, &mut spinner_on_screen);
let _ = writeln!(stderr, "{}", found_line(&category, &title, &location, true));
if let Some(active) = queue.front() {
draw_spinner(&mut stderr, active, frame, &mut spinner_on_screen);
}
}
Err(RecvTimeoutError::Timeout) => {
if let Some(active) = queue.front() {
frame = frame.wrapping_add(1);
draw_spinner(&mut stderr, active, frame, &mut spinner_on_screen);
}
}
Err(RecvTimeoutError::Disconnected) => {
clear_line(&mut stderr, &mut spinner_on_screen);
return;
}
}
}
}
fn clear_line(stderr: &mut std::io::Stderr, spinner_on_screen: &mut bool) {
if *spinner_on_screen {
let _ = write!(stderr, "\r\x1b[2K");
*spinner_on_screen = false;
}
}
fn draw_spinner(
stderr: &mut std::io::Stderr,
active: &Active,
frame: usize,
spinner_on_screen: &mut bool,
) {
let glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()];
let text = spinner_line(glyph, active.step, TOTAL_STEPS, &active.phase.label(), true);
let _ = write!(stderr, "\r\x1b[2K{text}");
let _ = stderr.flush();
*spinner_on_screen = true;
}
mod ansi {
pub(super) const RESET: &str = "\x1b[0m";
pub(super) const CYAN: &str = "\x1b[36m";
pub(super) const GREEN: &str = "\x1b[32m";
pub(super) const RED: &str = "\x1b[1;31m";
pub(super) const DIM: &str = "\x1b[2m";
}
fn paint(text: &str, code: &str, color: bool) -> String {
if color {
format!("{code}{text}{}", ansi::RESET)
} else {
text.to_owned()
}
}
fn spinner_line(frame: char, step: u8, total: u8, label: &str, color: bool) -> String {
format!(
" {} {} {label}",
paint(&frame.to_string(), ansi::CYAN, color),
paint(&format!("[{step}/{total}]"), ansi::DIM, color),
)
}
fn done_line(step: u8, total: u8, label: &str, color: bool) -> String {
format!(
" {} {} {label}",
paint("✓", ansi::GREEN, color),
paint(&format!("[{step}/{total}]"), ansi::DIM, color),
)
}
fn found_line(category: &str, title: &str, location: &str, color: bool) -> String {
format!(
" {} {:<6} {title} {}",
paint("\u{21b3}", ansi::DIM, color),
paint(category, ansi::CYAN, color),
paint(location, ansi::DIM, color),
)
}
fn summary_block(report: &Report, color: bool) -> String {
let defects: Vec<&Finding> = report
.findings
.iter()
.filter(|finding| finding.kind == Kind::Defect)
.collect();
let mut out = String::new();
if defects.is_empty() {
let _ = writeln!(
out,
"{}",
paint("\u{2713} No defects found", ansi::GREEN, color)
);
return out;
}
let mut counts = [0_usize; 4];
for finding in &defects {
let slot = match finding.severity {
Severity::Critical => 0,
Severity::High => 1,
Severity::Medium => 2,
Severity::Low => 3,
};
counts[slot] += 1;
}
let spread: Vec<String> = ["critical", "high", "medium", "low"]
.iter()
.zip(counts)
.filter(|(_, n)| *n > 0)
.map(|(label, n)| format!("{n} {label}"))
.collect();
let heading = format!(
"\u{2716} {} found ({})",
plural(defects.len(), "defect", "defects"),
spread.join(", ")
);
let _ = writeln!(out, "{}", paint(&heading, ansi::RED, color));
out
}
fn plural(count: usize, one: &str, many: &str) -> String {
if count == 1 {
format!("{count} {one}")
} else {
format!("{count} {many}")
}
}
#[cfg(test)]
mod tests {
use bastyn_core::{Category, Confidence, Location, Severity};
use super::{
Format, ProgressInputs, SPINNER_FRAMES, SummaryInputs, done_line, found_line, plural,
progress_enabled, spinner_line, step_number, summary_block, summary_color, summary_enabled,
};
fn baseline() -> ProgressInputs {
ProgressInputs {
quiet: false,
no_color_flag: false,
no_color_env: false,
format: Format::Text,
stderr_is_tty: true,
}
}
#[test]
fn progress_is_enabled_when_every_condition_is_favourable() {
assert!(progress_enabled(baseline()));
}
#[test]
fn quiet_suppresses_progress() {
let inputs = ProgressInputs {
quiet: true,
..baseline()
};
assert!(!progress_enabled(inputs));
}
#[test]
fn no_color_flag_suppresses_progress() {
let inputs = ProgressInputs {
no_color_flag: true,
..baseline()
};
assert!(!progress_enabled(inputs));
}
#[test]
fn no_color_env_suppresses_progress() {
let inputs = ProgressInputs {
no_color_env: true,
..baseline()
};
assert!(!progress_enabled(inputs));
}
#[test]
fn json_format_suppresses_progress() {
let inputs = ProgressInputs {
format: Format::Json,
..baseline()
};
assert!(!progress_enabled(inputs));
}
#[test]
fn sarif_format_suppresses_progress() {
let inputs = ProgressInputs {
format: Format::Sarif,
..baseline()
};
assert!(!progress_enabled(inputs));
}
#[test]
fn no_tty_suppresses_progress() {
let inputs = ProgressInputs {
stderr_is_tty: false,
..baseline()
};
assert!(!progress_enabled(inputs));
}
#[test]
fn spinner_frames_cycle_through_all_ten_and_wrap() {
assert_eq!(SPINNER_FRAMES.len(), 10);
let sequence: Vec<char> = (0..13).map(|tick| SPINNER_FRAMES[tick % 10]).collect();
assert_eq!(sequence[0], SPINNER_FRAMES[0]);
assert_eq!(sequence[9], SPINNER_FRAMES[9]);
assert_eq!(
sequence[10], SPINNER_FRAMES[0],
"the sequence must wrap back to the first frame"
);
assert_eq!(sequence[12], SPINNER_FRAMES[2]);
}
#[test]
fn spinner_line_without_color_carries_no_escape_byte() {
let text = spinner_line(
'\u{280b}',
2,
5,
"Parsing Python (tree-sitter) — 12 files",
false,
);
assert!(!text.contains('\u{1b}'));
assert!(text.contains("[2/5]"));
assert!(text.contains("Parsing Python (tree-sitter) — 12 files"));
}
#[test]
fn done_line_shows_a_checkmark_and_the_label() {
let text = done_line(3, 5, "Matching rules (ast-grep) — 8 rules", false);
assert!(text.contains('\u{2713}'));
assert!(text.contains("[3/5]"));
assert!(text.contains("Matching rules (ast-grep) — 8 rules"));
}
#[test]
fn found_line_carries_category_title_and_location() {
let text = found_line(
"LLM10",
"Model output concatenated into SQL",
"tools.py:214",
false,
);
assert!(text.contains("LLM10"));
assert!(text.contains("Model output concatenated into SQL"));
assert!(text.contains("tools.py:214"));
}
#[test]
fn step_numbers_match_the_documented_sequence() {
assert_eq!(step_number(&bastyn_core::Phase::Walking), Some(1));
assert_eq!(
step_number(&bastyn_core::Phase::Analysing {
files: 1,
rules: 8,
mcp_configs: 0,
}),
Some(2)
);
assert_eq!(
step_number(&bastyn_core::Phase::Cve { dependencies: 0 }),
Some(3)
);
assert_eq!(step_number(&bastyn_core::Phase::Reporting), Some(4));
}
fn text_on_a_terminal() -> SummaryInputs {
SummaryInputs {
quiet: false,
no_color_flag: false,
no_color_env: false,
format: Format::Text,
stdout_is_tty: true,
stderr_is_tty: true,
}
}
#[test]
fn the_summary_is_silent_where_the_report_already_says_it() {
assert!(
!summary_enabled(text_on_a_terminal()),
"a text report on a terminal must not be summarised twice"
);
}
#[test]
fn the_summary_speaks_wherever_stdout_is_not_the_verdict() {
for format in [Format::Json, Format::Sarif] {
assert!(
summary_enabled(SummaryInputs {
format,
..text_on_a_terminal()
}),
"{format:?} renders no report, so stderr is the only thing that can speak"
);
}
assert!(
summary_enabled(SummaryInputs {
stdout_is_tty: false,
..text_on_a_terminal()
}),
"a text report redirected into a file leaves the terminal silent"
);
}
#[test]
fn quiet_silences_the_summary_in_every_format() {
for format in [Format::Text, Format::Json, Format::Sarif] {
for stdout_is_tty in [true, false] {
assert!(
!summary_enabled(SummaryInputs {
quiet: true,
format,
stdout_is_tty,
..text_on_a_terminal()
}),
"--quiet must silence {format:?} (tty: {stdout_is_tty})"
);
}
}
}
#[test]
fn no_color_and_a_piped_stderr_change_the_rendering_not_the_decision() {
let piped = SummaryInputs {
format: Format::Json,
stderr_is_tty: false,
..text_on_a_terminal()
};
assert!(summary_enabled(piped), "a CI log is a place this belongs");
assert!(!summary_color(piped));
for suppressed in [
SummaryInputs {
no_color_flag: true,
format: Format::Json,
..text_on_a_terminal()
},
SummaryInputs {
no_color_env: true,
format: Format::Json,
..text_on_a_terminal()
},
] {
assert!(summary_enabled(suppressed));
assert!(!summary_color(suppressed));
}
assert!(summary_color(SummaryInputs {
format: Format::Json,
..text_on_a_terminal()
}));
}
#[test]
fn the_spinner_and_the_summary_are_not_the_same_question() {
let json = SummaryInputs {
format: Format::Json,
..text_on_a_terminal()
};
assert!(!progress_enabled(ProgressInputs {
quiet: json.quiet,
no_color_flag: json.no_color_flag,
no_color_env: json.no_color_env,
format: json.format,
stderr_is_tty: json.stderr_is_tty,
}));
assert!(summary_enabled(json));
}
#[test]
fn plural_is_regular_except_where_told_otherwise() {
assert_eq!(plural(0, "defect", "defects"), "0 defects");
assert_eq!(plural(1, "defect", "defects"), "1 defect");
assert_eq!(plural(2, "defect", "defects"), "2 defects");
}
fn finding(rule: &str, title: &str, category: Category) -> Finding {
Finding {
rule_id: rule.to_owned(),
title: title.to_owned(),
kind: Kind::Defect,
severity: Severity::Critical,
confidence: Confidence::High,
categories: vec![category],
location: Location {
file: "tools.py".into(),
line: 214,
column: 1,
},
snippet: String::new(),
description: String::new(),
remediation: String::new(),
secondary_rule_ids: Vec::new(),
references: Vec::new(),
}
}
use bastyn_core::CveStatus;
use bastyn_core::{Finding, Kind, Report, Summary};
fn report_with_defects(defects: &[(Severity, &str)]) -> Report {
let findings = defects
.iter()
.map(|(severity, title)| {
let mut f = finding("BAS-TEST-001", title, Category::Llm10);
f.severity = *severity;
f
})
.collect();
report_with(findings)
}
fn report_with(findings: Vec<Finding>) -> Report {
Report {
bastyn_version: "0.0.0".to_owned(),
root: ".".to_owned(),
summary: Summary {
files_scanned: 1,
files_skipped: 0,
defects: findings.len(),
observations: 0,
},
cve: CveStatus::SkippedOffline,
findings,
skipped: Vec::new(),
crosswalks: Vec::new(),
}
}
#[test]
fn summary_block_shows_a_checkmark_when_there_are_no_defects() {
let text = summary_block(&report_with(Vec::new()), false);
assert!(text.contains('\u{2713}'));
assert!(text.contains("No defects found"));
}
#[test]
fn summary_is_one_line_with_the_severity_spread() {
let report = report_with_defects(&[
(Severity::Critical, "One"),
(Severity::High, "Two"),
(Severity::High, "Three"),
]);
let text = summary_block(&report, false);
assert_eq!(text.lines().count(), 1, "must be a single line: {text:?}");
assert!(text.contains("3 defects found"), "{text}");
assert!(text.contains("1 critical"), "{text}");
assert!(text.contains("2 high"), "{text}");
assert!(!text.contains("One"), "titles belong in the report: {text}");
}
#[test]
fn summary_is_singular_for_one_defect() {
let report = report_with_defects(&[(Severity::High, "Only")]);
let text = summary_block(&report, false);
assert!(text.contains("1 defect found"), "{text}");
assert!(!text.contains("1 defects"), "{text}");
}
}