use marque_engine::LintResult;
use marque_rules::{AppliedFix, Diagnostic};
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Human,
Json,
}
pub fn use_color(no_color_flag: bool) -> bool {
if no_color_flag {
return false;
}
if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) {
return false;
}
if std::env::var("TERM").as_deref() == Ok("dumb") {
return false;
}
use is_terminal::IsTerminal;
std::io::stdout().is_terminal()
}
pub fn default_format() -> Format {
use is_terminal::IsTerminal;
if std::io::stdout().is_terminal() {
Format::Human
} else {
Format::Json
}
}
pub fn render_human(
out: &mut dyn std::io::Write,
path_label: &str,
source: &[u8],
diag: &Diagnostic,
color: bool,
) -> std::io::Result<()> {
let (line, col_start) = byte_to_line_col(source, diag.span.start);
let (end_line, col_end_raw) = byte_to_line_col(source, diag.span.end);
let col_end = if end_line == line {
col_end_raw
} else {
extract_line(source, line)
.map(|l| l.len() + 1)
.unwrap_or(col_start + 1)
};
let level = level_str(diag.severity);
let level_styled = paint(color, AnsiStyle::BoldRed, level);
let rule_styled = paint(color, AnsiStyle::Bold, &format!("[{}]", diag.rule));
writeln!(
out,
"{path_label}:{line}:{col_start} {level_styled}{rule_styled} {}",
diag.message
)?;
let line_num_str = line.to_string();
let gutter_width = line_num_str.len();
let gutter = " ".repeat(gutter_width);
let arrow = paint(color, AnsiStyle::BoldBlue, "-->");
let pipe = paint(color, AnsiStyle::BoldBlue, "|");
let eq = paint(color, AnsiStyle::BoldBlue, "=");
writeln!(
out,
"{gutter} {arrow} {path_label}:{line}:{col_start}-{col_end}"
)?;
if let Some(line_bytes) = extract_line(source, line) {
if let Ok(line_text) = std::str::from_utf8(line_bytes) {
writeln!(out, "{gutter} {pipe}")?;
let line_num_styled = paint(color, AnsiStyle::BoldBlue, &line_num_str);
writeln!(out, "{line_num_styled} {pipe} {line_text}")?;
let caret_pad_width = col_start.saturating_sub(1);
let caret_width = col_end.saturating_sub(col_start).max(1);
let caret_pad = " ".repeat(caret_pad_width);
let carets = "^".repeat(caret_width);
let carets_styled = paint(color, AnsiStyle::BoldRed, &carets);
let hint = diag
.fix
.as_ref()
.map(|f| {
format!(
" replace with {:?} (confidence {:.0}%)",
f.replacement.as_ref(),
f.confidence * 100.0
)
})
.unwrap_or_default();
writeln!(out, "{gutter} {pipe} {caret_pad}{carets_styled}{hint}")?;
writeln!(out, "{gutter} {pipe}")?;
}
}
writeln!(out, "{gutter} {eq} citation: {}", diag.citation)?;
Ok(())
}
fn extract_line(source: &[u8], line_num: usize) -> Option<&[u8]> {
let mut current_line = 1;
let mut line_start = 0;
for (i, &b) in source.iter().enumerate() {
if b == b'\n' {
if current_line == line_num {
let end = if i > line_start && source[i - 1] == b'\r' {
i - 1
} else {
i
};
return Some(&source[line_start..end]);
}
current_line += 1;
line_start = i + 1;
}
}
if current_line == line_num {
return Some(&source[line_start..]);
}
None
}
fn level_str(severity: marque_rules::Severity) -> &'static str {
match severity {
marque_rules::Severity::Error => "error",
marque_rules::Severity::Warn => "warning",
marque_rules::Severity::Fix => "fix",
marque_rules::Severity::Off => "off", }
}
#[derive(Debug, Clone, Copy)]
enum AnsiStyle {
BoldRed,
BoldBlue,
Bold,
}
fn paint(color: bool, style: AnsiStyle, text: &str) -> String {
if !color {
return text.to_owned();
}
let (prefix, suffix) = match style {
AnsiStyle::BoldRed => ("\x1b[31;1m", "\x1b[0m"),
AnsiStyle::BoldBlue => ("\x1b[34;1m", "\x1b[0m"),
AnsiStyle::Bold => ("\x1b[1m", "\x1b[0m"),
};
format!("{prefix}{text}{suffix}")
}
#[derive(Debug, Serialize)]
pub struct DiagnosticJson<'a> {
pub rule: &'a str,
pub severity: &'a str,
pub span: SpanJson,
pub message: &'a str,
pub citation: &'a str,
pub fix: Option<FixJson<'a>>,
}
#[derive(Debug, Serialize)]
pub struct SpanJson {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Serialize)]
pub struct FixJson<'a> {
pub source: &'static str,
pub replacement: &'a str,
pub confidence: f32,
pub migration_ref: Option<&'a str>,
}
pub fn diagnostic_to_json(d: &Diagnostic) -> DiagnosticJson<'_> {
DiagnosticJson {
rule: d.rule.as_str(),
severity: d.severity.as_str(),
span: SpanJson {
start: d.span.start,
end: d.span.end,
},
message: d.message.as_ref(),
citation: d.citation,
fix: d.fix.as_ref().map(|f| FixJson {
source: match f.source {
marque_rules::FixSource::BuiltinRule => "BuiltinRule",
marque_rules::FixSource::CorrectionsMap => "CorrectionsMap",
marque_rules::FixSource::MigrationTable => "MigrationTable",
},
replacement: f.replacement.as_ref(),
confidence: f.confidence,
migration_ref: f.migration_ref,
}),
}
}
pub fn render_ndjson(out: &mut dyn std::io::Write, result: &LintResult) -> std::io::Result<()> {
for d in &result.diagnostics {
let json = serde_json::to_string(&diagnostic_to_json(d)).map_err(std::io::Error::other)?;
out.write_all(json.as_bytes())?;
out.write_all(b"\n")?;
}
Ok(())
}
pub fn render_human_result(
out: &mut dyn std::io::Write,
path_label: &str,
source: &[u8],
result: &LintResult,
color: bool,
) -> std::io::Result<()> {
for d in &result.diagnostics {
render_human(out, path_label, source, d, color)?;
}
Ok(())
}
#[derive(Debug, Serialize)]
pub struct AuditRecordJson {
pub schema: &'static str,
pub rule: String,
pub source: &'static str,
pub span: SpanJson,
pub original: String,
pub replacement: String,
pub confidence: f32,
pub migration_ref: Option<String>,
pub timestamp: String,
pub classifier_id: Option<String>,
pub dry_run: bool,
pub input: Option<String>,
}
const AUDIT_SCHEMA_VERSION: &str = "marque-mvp-1";
fn fix_source_str(source: marque_rules::FixSource) -> &'static str {
match source {
marque_rules::FixSource::BuiltinRule => "BuiltinRule",
marque_rules::FixSource::CorrectionsMap => "CorrectionsMap",
marque_rules::FixSource::MigrationTable => "MigrationTable",
}
}
pub fn applied_fix_to_audit_json(fix: &AppliedFix) -> AuditRecordJson {
AuditRecordJson {
schema: AUDIT_SCHEMA_VERSION,
rule: fix.proposal.rule.as_str().to_owned(),
source: fix_source_str(fix.proposal.source),
span: SpanJson {
start: fix.proposal.span.start,
end: fix.proposal.span.end,
},
original: fix.proposal.original.to_string(),
replacement: fix.proposal.replacement.to_string(),
confidence: fix.proposal.confidence,
migration_ref: fix.proposal.migration_ref.map(|s| s.to_owned()),
timestamp: humantime::format_rfc3339(fix.timestamp).to_string(),
classifier_id: fix.classifier_id.as_ref().map(|s| s.to_string()),
dry_run: fix.dry_run,
input: fix.input.as_ref().map(|s| s.to_string()),
}
}
pub fn render_audit_record(
stderr: &mut dyn std::io::Write,
fix: &AppliedFix,
) -> std::io::Result<()> {
let json = applied_fix_to_audit_json(fix);
match serde_json::to_vec(&json) {
Ok(mut buf) => {
buf.push(b'\n');
stderr.write_all(&buf)
}
Err(e) => {
render_audit_error_frame(stderr, fix.proposal.rule.as_str(), &e.to_string())?;
Err(std::io::Error::other(format!(
"audit record serialization failed for rule {}: {e}",
fix.proposal.rule
)))
}
}
}
pub fn render_audit_error_frame(
stderr: &mut dyn std::io::Write,
rule_id: &str,
error_code: &str,
) -> std::io::Result<()> {
let escaped_error =
serde_json::to_string(error_code).unwrap_or_else(|_| "\"serialization_error\"".to_owned());
let escaped_rule = serde_json::to_string(rule_id).unwrap_or_else(|_| "\"unknown\"".to_owned());
let frame = format!(
"{{\"schema\":\"{AUDIT_SCHEMA_VERSION}\",\"error\":{escaped_error},\"rule\":{escaped_rule}}}\n"
);
stderr.write_all(frame.as_bytes())
}
fn byte_to_line_col(source: &[u8], offset: usize) -> (usize, usize) {
let mut line = 1usize;
let mut col = 1usize;
for &b in &source[..offset.min(source.len())] {
if b == b'\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
pub fn label_for(path: Option<&Path>) -> String {
match path {
Some(p) => p.display().to_string(),
None => "-".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use marque_ism::Span;
use marque_rules::{FixProposal, FixSource, RuleId, Severity};
fn make_diagnostic(
rule: &'static str,
span: Span,
message: &str,
fix: Option<FixProposal>,
) -> Diagnostic {
Diagnostic::new(
RuleId::new(rule),
Severity::Fix,
span,
message,
"CAPCO-ISM-v2022-DEC-§3.2",
fix,
)
}
#[test]
fn extract_line_returns_first_line() {
let src = b"first\nsecond\nthird";
assert_eq!(extract_line(src, 1), Some(&b"first"[..]));
}
#[test]
fn extract_line_returns_middle_line() {
let src = b"first\nsecond\nthird";
assert_eq!(extract_line(src, 2), Some(&b"second"[..]));
}
#[test]
fn extract_line_returns_last_line_without_trailing_newline() {
let src = b"first\nsecond";
assert_eq!(extract_line(src, 2), Some(&b"second"[..]));
}
#[test]
fn extract_line_strips_crlf() {
let src = b"first\r\nsecond\r\n";
assert_eq!(extract_line(src, 1), Some(&b"first"[..]));
assert_eq!(extract_line(src, 2), Some(&b"second"[..]));
}
#[test]
fn extract_line_returns_none_when_out_of_range() {
let src = b"first\nsecond";
assert_eq!(extract_line(src, 5), None);
}
#[test]
fn render_human_produces_rustc_style_shape_with_caret() {
let src = b"TOP SECRET//SI//NF\n";
let span = Span::new(16, 18);
let fix = FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
span,
"NF".to_owned(),
"NOFORN".to_owned(),
1.0,
Some("CAPCO-2023-§3.2"),
);
let diag = make_diagnostic(
"E001",
span,
"banner uses abbreviated dissem control \"NF\"; use \"NOFORN\"",
Some(fix),
);
let mut out = Vec::new();
render_human(&mut out, "banner.txt", src, &diag, false).unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("banner.txt:1:17 fix[E001]"));
assert!(rendered.contains("banner uses abbreviated dissem control"));
assert!(rendered.contains("--> banner.txt:1:17-19"));
assert!(rendered.contains("1 | TOP SECRET//SI//NF"));
assert!(
rendered.contains(" ^^"),
"expected caret at col 17; got:\n{rendered}"
);
assert!(rendered.contains("replace with \"NOFORN\""));
assert!(rendered.contains("(confidence 100%)"));
assert!(rendered.contains("= citation: CAPCO-ISM-v2022-DEC-§3.2"));
}
#[test]
fn render_human_without_color_has_no_ansi_escapes() {
let src = b"TOP SECRET//SI//NF\n";
let span = Span::new(16, 18);
let diag = make_diagnostic("E001", span, "test", None);
let mut out = Vec::new();
render_human(&mut out, "x.txt", src, &diag, false).unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(
!rendered.contains('\x1b'),
"color=false must not emit ANSI escapes, got:\n{rendered:?}"
);
}
#[test]
fn render_human_with_color_emits_ansi_escapes() {
let src = b"TOP SECRET//SI//NF\n";
let span = Span::new(16, 18);
let diag = make_diagnostic("E001", span, "test", None);
let mut out = Vec::new();
render_human(&mut out, "x.txt", src, &diag, true).unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(
rendered.contains('\x1b'),
"color=true must emit ANSI escapes, got:\n{rendered:?}"
);
}
#[test]
fn render_human_diagnostic_without_fix_omits_hint() {
let src = b"SECRET//XYZZY//NOFORN\n";
let span = Span::new(8, 13);
let diag = Diagnostic::new(
RuleId::new("E008"),
Severity::Error,
span,
"unrecognized token",
"CAPCO-ISM-v2022-DEC-§3.1",
None,
);
let mut out = Vec::new();
render_human(&mut out, "x.txt", src, &diag, false).unwrap();
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("^^^^^"));
assert!(!rendered.contains("replace with"));
}
#[test]
fn render_audit_error_frame_produces_valid_json() {
let mut buf = Vec::new();
render_audit_error_frame(&mut buf, "E001", "some error").unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.ends_with('\n'), "must end with newline");
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["schema"], "marque-mvp-1");
assert_eq!(v["error"], "some error");
assert_eq!(v["rule"], "E001");
}
#[test]
fn render_audit_error_frame_escapes_special_characters() {
let mut buf = Vec::new();
render_audit_error_frame(&mut buf, "E001", "key \"foo\\bar\"").unwrap();
let s = String::from_utf8(buf).unwrap();
let v: serde_json::Value =
serde_json::from_str(s.trim()).expect("error frame must be valid JSON");
assert_eq!(v["error"], "key \"foo\\bar\"");
assert_eq!(v["schema"], "marque-mvp-1");
}
#[test]
fn render_audit_record_produces_valid_ndjson() {
use marque_rules::AppliedFix;
use std::sync::Arc;
use std::time::{Duration, UNIX_EPOCH};
let fix = FixProposal::new(
RuleId::new("E001"),
FixSource::BuiltinRule,
Span::new(8, 10),
"NF",
"NOFORN",
1.0,
Some("CAPCO-2023-§3.2"),
);
let applied = AppliedFix::__engine_promote(
fix,
UNIX_EPOCH + Duration::from_secs(1_700_000_000),
Some(Arc::from("classifier-42")),
false,
Some(Arc::from("test.txt")),
);
let mut buf = Vec::new();
render_audit_record(&mut buf, &applied).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.ends_with('\n'));
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["schema"], "marque-mvp-1");
assert_eq!(v["rule"], "E001");
assert_eq!(v["source"], "BuiltinRule");
assert_eq!(v["span"]["start"], 8);
assert_eq!(v["span"]["end"], 10);
assert_eq!(v["original"], "NF");
assert_eq!(v["replacement"], "NOFORN");
assert_eq!(v["confidence"], 1.0);
assert_eq!(v["migration_ref"], "CAPCO-2023-§3.2");
assert_eq!(v["classifier_id"], "classifier-42");
assert_eq!(v["dry_run"], false);
assert_eq!(v["input"], "test.txt");
assert!(v["timestamp"].as_str().unwrap().contains('T'));
}
}