use std::collections::BTreeMap;
use std::io::Write;
use std::path::Path;
use anstream::AutoStream;
use anstyle::{AnsiColor, Color, Reset, Style};
use crate::lang::BootstrapSummary;
use crate::scan_phase_peaks::PhasePeak;
use crate::scanner::{FileResult, FileStatus, ScanObserver, ScanStats};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verbosity {
Quiet,
Default,
Verbose,
}
impl Verbosity {
pub fn from_flags(quiet: bool, verbose: bool) -> Self {
match (quiet, verbose) {
(true, _) => Verbosity::Quiet,
(_, true) => Verbosity::Verbose,
_ => Verbosity::Default,
}
}
}
pub fn stdout(force_off: bool) -> AutoStream<std::io::Stdout> {
if force_off {
AutoStream::never(std::io::stdout())
} else {
AutoStream::auto(std::io::stdout())
}
}
const COL_PATH: usize = 8;
#[derive(Debug, Default)]
pub struct WarnTally {
by_grammar: BTreeMap<&'static str, (usize, u64)>,
}
impl WarnTally {
pub fn absorb(&mut self, res: &FileResult) -> bool {
let FileStatus::Updated {
had_errors: true,
error_count,
..
} = &res.status
else {
return false;
};
let grammar = crate::lang::detect(Path::new(&res.path)).unwrap_or("unknown");
let entry = self.by_grammar.entry(grammar).or_default();
entry.0 += 1;
entry.1 += u64::from(*error_count);
true
}
pub fn summary_lines(&self) -> Vec<String> {
self.by_grammar
.iter()
.map(|(grammar, (files, errors))| {
format!(
"{grammar}: {files} file{fs}, {errors} parse error{es}",
fs = if *files == 1 { "" } else { "s" },
es = if *errors == 1 { "" } else { "s" },
)
})
.collect()
}
pub fn flush(&mut self, w: &mut AutoStream<std::io::Stdout>) {
let style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow))).render();
let reset = Reset.render();
let dim = Style::new().dimmed().render();
for line in self.summary_lines() {
let _ = writeln!(
w,
"{style}⚠ warn {reset} {line} {dim}(partial maps; -v lists each file){reset}"
);
}
self.by_grammar.clear();
}
}
fn render_or_tally(w: &mut AutoStream<std::io::Stdout>, tally: &mut WarnTally, res: &FileResult, verbosity: Verbosity) {
if verbosity == Verbosity::Default && tally.absorb(res) {
return;
}
render_file(w, res, verbosity);
}
pub fn render_files(w: &mut AutoStream<std::io::Stdout>, results: &[FileResult], verbosity: Verbosity) {
let mut tally = WarnTally::default();
for r in results {
render_or_tally(w, &mut tally, r, verbosity);
}
tally.flush(w);
}
pub struct ScanLineObserver {
out: AutoStream<std::io::Stdout>,
verbosity: Verbosity,
tally: WarnTally,
}
impl ScanLineObserver {
pub fn new(force_off: bool, verbosity: Verbosity) -> Self {
Self {
out: stdout(force_off),
verbosity,
tally: WarnTally::default(),
}
}
pub fn finish(&mut self) {
self.tally.flush(&mut self.out);
}
}
impl ScanObserver for ScanLineObserver {
fn on_file(&mut self, result: FileResult) {
render_or_tally(&mut self.out, &mut self.tally, &result, self.verbosity);
}
}
pub fn render_file(w: &mut AutoStream<std::io::Stdout>, res: &FileResult, verbosity: Verbosity) {
let Some(line) = format_line(res, verbosity) else {
return;
};
let _ = writeln!(w, "{line}");
}
pub fn render_summary(w: &mut AutoStream<std::io::Stdout>, stats: &ScanStats, verbosity: Verbosity) {
if verbosity == Verbosity::Quiet
&& stats.read_failed == 0
&& stats.extract_failed == 0
&& stats.updated_with_warnings == 0
{
return;
}
let style_label = Style::new().dimmed();
let style_zero = Style::new().dimmed();
let style_ok = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)));
let style_warn = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow)));
let style_fail = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)));
let pair = |label: &str, n: usize, important: Style| -> String {
let s = if n == 0 { style_zero } else { important };
format!(
"{label_style}{label}{label_off} {n_style}{n}{n_off}",
label_style = style_label.render(),
label_off = Reset.render(),
label = label,
n_style = s.render(),
n_off = Reset.render(),
n = n,
)
};
let line = format!(
"{scanned} {updated} {reused} {warn} {unchanged} {failed} {skipped} {removed}",
scanned = pair("scanned", stats.scanned, Style::new()),
updated = pair("updated", stats.updated, style_ok),
reused = pair("reused", stats.reused_extraction, Style::new()),
warn = pair("warn", stats.updated_with_warnings, style_warn),
unchanged = pair("unchanged", stats.skipped_unchanged, Style::new()),
failed = pair("failed", stats.read_failed + stats.extract_failed, style_fail),
skipped = pair(
"skipped",
stats.skipped_too_large + stats.skipped_non_utf8 + stats.skipped_no_lang,
Style::new(),
),
removed = pair("removed", stats.removed, style_warn),
);
let _ = writeln!(w, "{line}");
if stats.docs_indexed > 0 {
let docs_line = pair("docs_indexed", stats.docs_indexed, style_ok);
let _ = writeln!(w, "{docs_line}");
}
if stats.docs_degraded > 0 || stats.doc_timeouts > 0 {
let _ = writeln!(
w,
"{degraded} {timeouts} (large text documents use linear chunking; see [documents] markdown_chunk_max_bytes)",
degraded = pair("docs_degraded", stats.docs_degraded, style_warn),
timeouts = pair("doc_timeouts", stats.doc_timeouts, style_fail),
);
}
if stats.docs_text_fallback > 0 {
let _ = writeln!(
w,
"{fallback} (of docs_degraded: structured extraction failed, so the file was read as plain text)",
fallback = pair("docs_text_fallback", stats.docs_text_fallback, style_warn),
);
}
if stats.docs_tombstoned > 0 || stats.docs_deferred > 0 || stats.abandoned_extractions > 0 {
let _ = writeln!(
w,
"{tombstoned} {deferred} {abandoned} (see [documents] phase_budget_secs, max_abandoned_extractions)",
tombstoned = pair("docs_tombstoned", stats.docs_tombstoned, style_warn),
deferred = pair("docs_deferred", stats.docs_deferred, style_warn),
abandoned = pair("abandoned_extractions", stats.abandoned_extractions, style_warn),
);
}
if stats.tier_migrated > 0 || stats.refreshed_extraction > 0 {
let _ = writeln!(
w,
"{migrated} {refreshed}",
migrated = pair("tier_migrated", stats.tier_migrated, style_warn),
refreshed = pair("refreshed", stats.refreshed_extraction, style_warn),
);
}
if stats.skipped_too_large > 0 {
let too_large = pair("too_large", stats.skipped_too_large, style_warn);
let _ = writeln!(
w,
"{too_large} (over [scan] max_file_bytes, or [documents] max_file_bytes for documents)"
);
}
}
pub fn render_phase_peaks(w: &mut AutoStream<std::io::Stdout>, peaks: &[PhasePeak], verbosity: Verbosity) {
let Some(line) = phase_peaks_line(peaks) else {
return;
};
if verbosity == Verbosity::Quiet {
return;
}
let dim = Style::new().dimmed().render();
let _ = writeln!(w, "{dim}{line}{reset}", reset = Reset.render());
}
fn phase_peaks_line(peaks: &[PhasePeak]) -> Option<String> {
if peaks.is_empty() {
return None;
}
let cells: Vec<String> = peaks
.iter()
.map(|p| format!("{} {}", p.phase, human_bytes(p.peak_bytes)))
.collect();
Some(format!("peak memory {}", cells.join(" ")))
}
pub fn render_cleanup(w: &mut AutoStream<std::io::Stdout>, report: &crate::store_gc::GcReport) {
if report.removed == 0 {
return;
}
let _ = writeln!(
w,
"cleanup: reclaimed {} orphaned blob(s), {} bytes",
report.removed, report.bytes_freed
);
}
pub fn render_grammar_bootstrap(w: &mut AutoStream<std::io::Stdout>, summary: &BootstrapSummary, verbosity: Verbosity) {
if !summary.did_download() && verbosity != Verbosity::Verbose {
return;
}
let dim = Style::new().dimmed();
let ok = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)));
let info = Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightBlue)));
if summary.did_download() {
let names = summary.downloaded.join(", ");
let _ = writeln!(
w,
"{s}▼ downloaded {n} grammar{plural}{r} {d}({names}){dr}",
s = info.render(),
r = Reset.render(),
d = dim.render(),
dr = Reset.render(),
n = summary.downloaded.len(),
plural = if summary.downloaded.len() == 1 { "" } else { "s" },
names = names,
);
}
if verbosity == Verbosity::Verbose {
let cached_count = summary.already_cached.len();
let _ = writeln!(
w,
"{s}✓ grammars ready{r} {d}({cached_count} cached, {dl} fresh){dr}",
s = ok.render(),
r = Reset.render(),
d = dim.render(),
dr = Reset.render(),
cached_count = cached_count,
dl = summary.downloaded.len(),
);
if let Some(dir) = &summary.cache_dir {
let _ = writeln!(
w,
" {d}cache: {dir}{dr}",
d = dim.render(),
dr = Reset.render(),
dir = dir.display(),
);
}
}
}
pub fn render_scan_header(w: &mut AutoStream<std::io::Stdout>, label: &str, verbosity: Verbosity) {
if verbosity == Verbosity::Quiet {
return;
}
let style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightBlue)));
let _ = writeln!(
w,
"{s}▶ scanning {label}{r}",
s = style.render(),
r = Reset.render(),
label = label,
);
}
pub fn render_batch_header(w: &mut AutoStream<std::io::Stdout>, paths: usize, verbosity: Verbosity) {
if verbosity == Verbosity::Quiet {
return;
}
let style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightBlue)));
let _ = writeln!(
w,
"{s}▶ batch — {paths} {label}{r}",
s = style.render(),
r = Reset.render(),
paths = paths,
label = if paths == 1 { "path" } else { "paths" },
);
}
fn sanitize_path(path: &str) -> String {
path.chars()
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.filter(|c| !c.is_control())
.collect()
}
fn format_line(res: &FileResult, verbosity: Verbosity) -> Option<String> {
let row = row_for(res, verbosity)?;
let style = row.style.render();
let reset = Reset.render();
let symbol = row.symbol;
let label = row.label;
let path = sanitize_path(&res.path);
let detail = row.detail;
let detail_block = if detail.is_empty() {
String::new()
} else {
let dim = Style::new().dimmed().render();
format!(" {dim}{detail}{reset}")
};
Some(format!("{style}{symbol} {label:<5}{reset} {path}{detail_block}",))
}
struct Row<'a> {
symbol: char,
label: &'a str,
style: Style,
detail: String,
}
fn row_for(res: &FileResult, verbosity: Verbosity) -> Option<Row<'_>> {
let v = verbosity;
match &res.status {
FileStatus::Updated { had_errors: false, .. } => {
if v == Verbosity::Verbose {
Some(Row {
symbol: '✓',
label: "ok",
style: Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green))),
detail: String::new(),
})
} else {
None
}
}
FileStatus::Updated {
had_errors: true,
error_count,
..
} => {
if v == Verbosity::Quiet {
None
} else {
Some(Row {
symbol: '⚠',
label: "warn",
style: Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow))),
detail: format!(
"({n} parse error{s}, partial map)",
n = error_count,
s = if *error_count == 1 { "" } else { "s" }
),
})
}
}
#[cfg(feature = "documents")]
FileStatus::DocUnchanged => {
if v == Verbosity::Verbose {
Some(Row {
symbol: '·',
label: "same",
style: Style::new().dimmed(),
detail: String::new(),
})
} else {
None
}
}
FileStatus::Unchanged => {
if v == Verbosity::Verbose {
Some(Row {
symbol: '·',
label: "same",
style: Style::new().dimmed(),
detail: String::new(),
})
} else {
None
}
}
FileStatus::Removed => {
if v == Verbosity::Quiet {
None
} else {
Some(Row {
symbol: '×',
label: "gone",
style: Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red))),
detail: String::new(),
})
}
}
FileStatus::SkippedTooLarge { size } => {
if v == Verbosity::Verbose {
Some(Row {
symbol: '·',
label: "skip",
style: Style::new().dimmed(),
detail: format!("({} too large)", human_bytes(*size)),
})
} else {
None
}
}
FileStatus::SkippedNonUtf8 => {
if v == Verbosity::Verbose {
Some(Row {
symbol: '·',
label: "skip",
style: Style::new().dimmed(),
detail: "(non-UTF8)".to_string(),
})
} else {
None
}
}
FileStatus::SkippedNoLang => {
if v == Verbosity::Verbose {
Some(Row {
symbol: '·',
label: "skip",
style: Style::new().dimmed(),
detail: "(no language)".to_string(),
})
} else {
None
}
}
FileStatus::SkippedBinary => {
if v == Verbosity::Verbose {
Some(Row {
symbol: '·',
label: "skip",
style: Style::new().dimmed(),
detail: "(binary)".to_string(),
})
} else {
None
}
}
FileStatus::ParseTimedOut => Some(Row {
symbol: '✗',
label: "fail",
style: Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red))),
detail: "(parse timed out — file likely pathological)".to_string(),
}),
FileStatus::ReadFailed { msg, .. } => Some(Row {
symbol: '✗',
label: "fail",
style: Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red))),
detail: format!("(read failed: {msg})"),
}),
#[cfg(feature = "documents")]
FileStatus::DocTimedOut => Some(Row {
symbol: '✗',
label: "fail",
style: Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red))),
detail: "(document extraction timed out; see [documents] extraction_timeout_secs)".to_string(),
}),
#[cfg(feature = "documents")]
FileStatus::DocTombstoned => {
if v == Verbosity::Verbose {
Some(Row {
symbol: '·',
label: "skip",
style: Style::new().dimmed(),
detail: "(timed out before, unchanged; edit the file or raise [documents] extraction_timeout_secs to retry)".to_string(),
})
} else {
None
}
}
#[cfg(feature = "documents")]
FileStatus::DocDeferred => Some(Row {
symbol: '·',
label: "defer",
style: Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow))),
detail: "(document deferred to the next scan: phase budget spent or too many stuck extractions)"
.to_string(),
}),
FileStatus::ExtractFailed { msg } => Some(Row {
symbol: '✗',
label: "fail",
style: Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red))),
detail: format!("(extract failed: {msg})"),
}),
#[cfg(feature = "documents")]
FileStatus::DocIndexed {
chunk_count,
embedding_dim,
reused,
degraded,
text_fallback,
elapsed_ms,
} => {
if v == Verbosity::Quiet {
None
} else {
let cached = if *reused { ", cached" } else { "" };
let cached = if *text_fallback {
format!("{cached}, read as plain text after a parse failure")
} else if *degraded {
format!("{cached}, linear chunking")
} else {
cached.to_string()
};
Some(Row {
symbol: '✓',
label: "doc",
style: Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightBlue))),
detail: format!(
"({chunk_count} chunks, dim={embedding_dim}{cached}, {})",
format_elapsed(*elapsed_ms)
),
})
}
}
}
}
#[cfg(any(feature = "documents", test))]
fn format_elapsed(ms: u64) -> String {
match ms {
0..1000 => format!("{ms}ms"),
1000..60_000 => format!("{:.1}s", ms as f64 / 1000.0),
_ => format!("{}m{:02}s", ms / 60_000, ms / 1000 % 60),
}
}
fn human_bytes(b: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if b >= GB {
format!("{:.1}GB", b as f64 / GB as f64)
} else if b >= MB {
format!("{:.1}MB", b as f64 / MB as f64)
} else if b >= KB {
format!("{:.1}KB", b as f64 / KB as f64)
} else {
format!("{b}B")
}
}
const _: usize = COL_PATH;
#[cfg(test)]
mod tests {
use super::{WarnTally, format_elapsed, phase_peaks_line, sanitize_path};
use crate::scan_phase_peaks::PhasePeak;
use crate::scanner::{FileResult, FileStatus};
fn result(path: &str, status: FileStatus) -> FileResult {
FileResult::bare(path.to_string(), status)
}
fn warned(path: &str, error_count: u32) -> FileResult {
result(
path,
FileStatus::Updated {
had_errors: true,
error_count,
reused: false,
},
)
}
#[test]
fn warn_tally_aggregates_files_and_errors_per_grammar() {
let mut tally = WarnTally::default();
assert!(tally.absorb(&warned("a/one.scss", 3)));
assert!(tally.absorb(&warned("a/two.scss", 2)));
assert!(tally.absorb(&warned("db/x.sql", 1)));
assert_eq!(
tally.summary_lines(),
vec!["scss: 2 files, 5 parse errors", "sql: 1 file, 1 parse error"]
);
}
#[test]
fn phase_peaks_line_lists_each_phase_and_is_absent_when_empty() {
assert_eq!(phase_peaks_line(&[]), None);
let peaks = [
PhasePeak {
phase: "extract",
peak_bytes: 512 * 1024 * 1024,
},
PhasePeak {
phase: "resolve",
peak_bytes: 3 * 1024 * 1024 * 1024,
},
];
assert_eq!(
phase_peaks_line(&peaks).as_deref(),
Some("peak memory extract 512.0MB resolve 3.0GB")
);
}
#[test]
fn warn_tally_ignores_clean_and_unchanged_files() {
let mut tally = WarnTally::default();
let clean = result(
"a.rs",
FileStatus::Updated {
had_errors: false,
error_count: 0,
reused: false,
},
);
assert!(!tally.absorb(&clean));
assert!(!tally.absorb(&result("b.rs", FileStatus::Unchanged)));
assert!(tally.summary_lines().is_empty());
}
#[test]
fn format_elapsed_scales_from_millis_to_minutes() {
assert_eq!(format_elapsed(0), "0ms");
assert_eq!(format_elapsed(850), "850ms");
assert_eq!(format_elapsed(1_200), "1.2s");
assert_eq!(format_elapsed(59_000), "59.0s");
assert_eq!(format_elapsed(185_000), "3m05s");
}
#[test]
fn sanitize_path_flattens_newlines_to_spaces() {
assert_eq!(sanitize_path("src/a\nb.rs"), "src/a b.rs");
assert_eq!(sanitize_path("src/a\r\nb.rs"), "src/a b.rs");
}
#[test]
fn sanitize_path_strips_ansi_and_control_chars() {
let crafted = "src/\x1b[31mevil\x07\tname.rs";
assert_eq!(sanitize_path(crafted), "src/[31mevilname.rs");
}
#[test]
fn sanitize_path_leaves_plain_paths_unchanged() {
assert_eq!(sanitize_path("src/render.rs"), "src/render.rs");
}
}