use forge::signal::compactor;
pub fn compress_buf(subcmd: &str, raw: &str) -> String {
let sub = subcmd.trim();
match sub {
"lint" => compress_lint(raw),
"breaking" => compress_breaking(raw),
"generate" => compress_generate(raw),
"build" => compress_build(raw),
_ => compactor::normalise(raw),
}
}
fn compress_lint(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
if cleaned.trim().is_empty() {
return "buf lint: no issues".to_string();
}
let mut by_file: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for line in cleaned.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
let file = t
.split(':')
.next()
.filter(|s| s.ends_with(".proto"))
.unwrap_or("unknown")
.to_string();
by_file.entry(file).or_default().push(t.to_string());
}
if by_file.is_empty() {
return compactor::collapse_blanks(&cleaned);
}
let mut result: Vec<String> = Vec::new();
let mut files: Vec<&String> = by_file.keys().collect();
files.sort();
let total: usize = by_file.values().map(|v| v.len()).sum();
for file in &files {
let findings = &by_file[*file];
result.push(format!("{file} — {} issue(s)", findings.len()));
for f in findings.iter().take(4) {
result.push(format!(" {f}"));
}
if findings.len() > 4 {
result.push(format!(" … {} more", findings.len() - 4));
}
}
result.push(format!(
"buf lint: {total} issues across {} files",
files.len()
));
result.join("\n")
}
fn compress_breaking(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
if cleaned.trim().is_empty() {
return "buf breaking: no breaking changes".to_string();
}
let mut changes: Vec<String> = Vec::new();
for line in cleaned.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
let msg = if let Some(colon_pos) = t.find(':') {
let prefix = &t[..colon_pos];
if prefix.chars().all(|c| c.is_uppercase() || c == '_') {
t[colon_pos + 1..].trim()
} else {
t
}
} else {
t
};
changes.push(msg.to_string());
}
let n = changes.len();
let mut result = changes;
result.push(format!("buf breaking: {n} breaking change(s)"));
result.join("\n")
}
fn compress_generate(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let errors: Vec<&str> = cleaned
.lines()
.filter(|l| l.to_lowercase().contains("error") || l.to_lowercase().contains("failed"))
.collect();
if errors.is_empty() {
if cleaned.trim().is_empty() {
return "buf generate: completed".to_string();
}
let kept: Vec<&str> = cleaned
.lines()
.filter(|l| !l.trim().starts_with("Generated") || !l.contains("ms"))
.filter(|l| !l.trim().is_empty())
.collect();
return if kept.is_empty() {
"buf generate: completed".to_string()
} else {
kept.join("\n")
};
}
errors.join("\n")
}
fn compress_build(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
if cleaned.trim().is_empty() {
return "buf build: succeeded".to_string();
}
compactor::collapse_blanks(&cleaned)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lint_groups_by_file() {
let raw = "proto/foo.proto:10:5:FIELD_NAMES_LOWER_SNAKE_CASE: Field name \"myField\" should be lower_snake_case.\nproto/foo.proto:20:1:ENUM_VALUE_PREFIX: Enum value should be prefixed.\nproto/bar.proto:5:3:PACKAGE_SAME_DIRECTORY: Files in package must be in same directory.\n";
let out = compress_buf("lint", raw);
assert!(out.contains("foo.proto"), "{out}");
assert!(out.contains("bar.proto"), "{out}");
assert!(out.contains("3 issues") || out.contains("issue"), "{out}");
}
#[test]
fn lint_no_issues_clean() {
let out = compress_buf("lint", "");
assert!(out.contains("no issues"), "{out}");
}
#[test]
fn breaking_strips_rule_codes() {
let raw = "FILE_NO_DELETE:proto/foo.proto:1:1:File \"foo.proto\" was deleted.\nFIELD_SAME_TYPE:proto/bar.proto:5:3:Field \"id\" changed type.\n";
let out = compress_buf("breaking", raw);
assert!(
out.contains("deleted") || out.contains("foo.proto"),
"{out}"
);
assert!(out.contains("breaking"), "{out}");
}
}