use crate::processor::SearchMatch;
use crate::output::{OutputFormat, OutputFormatterTrait};
use std::path::Path;
pub struct OutputFormatter {
format: OutputFormat,
ndjson: bool,
use_color: bool,
include_metadata: bool,
include_context: bool,
}
impl OutputFormatter {
pub fn new(format: OutputFormat) -> Self {
Self {
format,
ndjson: false,
use_color: is_terminal::is_terminal(&std::io::stdout()),
include_metadata: true,
include_context: true,
}
}
pub fn with_ndjson(mut self, ndjson: bool) -> Self {
self.ndjson = ndjson;
self
}
pub fn with_color(mut self, use_color: bool) -> Self {
self.use_color = use_color;
self
}
pub fn with_metadata(mut self, include: bool) -> Self {
self.include_metadata = include;
self
}
pub fn with_context(mut self, include: bool) -> Self {
self.include_context = include;
self
}
pub fn format_results(&self, matches: &[SearchMatch], query: &str, path: &Path) -> String {
match self.format {
OutputFormat::Text => self.format_text(matches, query, path),
OutputFormat::Json => self.format_json(matches, query, path),
OutputFormat::Xml => self.format_xml(matches, query, path),
OutputFormat::Html => self.format_html(matches, query, path),
OutputFormat::Markdown => self.format_markdown(matches, query, path),
OutputFormat::Csv => self.format_csv(matches, query, path),
OutputFormat::Yaml => self.format_yaml(matches, query, path),
OutputFormat::Junit => self.format_junit(matches, query, path),
}
}
fn format_text(&self, matches: &[SearchMatch], query: &str, path: &Path) -> String {
let mut output = String::new();
if self.include_metadata {
output.push_str(&format!("Query: {query}\n"));
output.push_str(&format!("Path: {}\n", path.display()));
output.push_str(&format!("Total matches: {}\n\n", matches.len()));
}
for m in matches {
let line_len = m.line.len();
let column_start = m.column_start.min(line_len);
let column_end = m.column_end.min(line_len);
let before = if column_start < line_len {
&m.line[..column_start]
} else {
""
};
let matched = &m.matched_text;
let after = if column_end < line_len {
&m.line[column_end..]
} else {
""
};
if self.use_color {
let highlighted = format!("\x1b[33m{matched}\x1b[0m");
output.push_str(&format!(
"{}:{}:{}: {before}{highlighted}{after}\n",
m.path.display(),
m.line_number,
column_start + 1
));
} else {
output.push_str(&format!(
"{}:{}:{}: {before}{matched}{after}\n",
m.path.display(),
m.line_number,
column_start + 1
));
}
if self.include_context && (!m.context_before.is_empty() || !m.context_after.is_empty()) {
output.push_str("-- context --\n");
for (num, line) in &m.context_before {
output.push_str(&format!(" {num} │ {line}\n"));
}
output.push_str(&format!("→ {} │ {before}{matched}{after}\n", m.line_number));
for (num, line) in &m.context_after {
output.push_str(&format!(" {num} │ {line}\n"));
}
output.push('\n');
}
}
output
}
fn format_json(&self, matches: &[SearchMatch], query: &str, path: &Path) -> String {
use serde_json::json;
if self.ndjson {
let mut output = String::new();
for m in matches {
let match_obj = json!({
"query": query,
"path": m.path.to_string_lossy(),
"line_number": m.line_number,
"line": m.line,
"matched_text": m.matched_text,
"column_start": m.column_start,
"column_end": m.column_end,
"context_before": m.context_before,
"context_after": m.context_after,
});
if let Ok(s) = serde_json::to_string(&match_obj) {
output.push_str(&s);
output.push('\n');
}
}
output
} else {
let result = json!({
"query": query,
"path": path.to_string_lossy(),
"total_matches": matches.len(),
"matches": matches.iter().map(|m| json!({
"path": m.path.to_string_lossy(),
"line_number": m.line_number,
"line": m.line,
"matched_text": m.matched_text,
"column_start": m.column_start,
"column_end": m.column_end,
"context_before": m.context_before,
"context_after": m.context_after,
})).collect::<Vec<_>>()
});
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string())
}
}
fn format_xml(&self, matches: &[SearchMatch], query: &str, path: &Path) -> String {
let mut output = String::new();
output.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
output.push_str("<search-results>\n");
output.push_str(" <metadata>\n");
output.push_str(&format!(" <query>{}</query>\n", escape_xml(query)));
output.push_str(&format!(" <path>{}</path>\n", escape_xml(&path.to_string_lossy())));
output.push_str(&format!(" <total-matches>{}</total-matches>\n", matches.len()));
output.push_str(" </metadata>\n");
output.push_str(" <matches>\n");
for (i, m) in matches.iter().enumerate() {
output.push_str(&format!(" <match index=\"{}\">\n", i + 1));
output.push_str(&format!(" <path>{}</path>\n", escape_xml(&m.path.to_string_lossy())));
output.push_str(&format!(" <line-number>{}</line-number>\n", m.line_number));
output.push_str(&format!(" <line>{}</line>\n", escape_xml(&m.line)));
output.push_str(&format!(" <matched-text>{}</matched-text>\n", escape_xml(&m.matched_text)));
output.push_str(&format!(" <column-start>{}</column-start>\n", m.column_start));
output.push_str(&format!(" <column-end>{}</column-end>\n", m.column_end));
output.push_str(" </match>\n");
}
output.push_str(" </matches>\n");
output.push_str("</search-results>\n");
output
}
fn format_html(&self, matches: &[SearchMatch], query: &str, path: &Path) -> String {
let mut output = String::new();
output.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
output.push_str("<meta charset=\"UTF-8\">\n");
output.push_str("<title>rfgrep Search Results</title>\n");
output.push_str("<style>\n");
output.push_str("body { font-family: monospace; margin: 20px; }\n");
output.push_str(".match { margin: 10px 0; padding: 10px; border-left: 3px solid #007acc; }\n");
output.push_str(".line-number { color: #666; }\n");
output.push_str(".matched-text { background-color: #ffff00; font-weight: bold; }\n");
output.push_str(".context { color: #888; }\n");
output.push_str(".metadata { background-color: #f5f5f5; padding: 10px; margin-bottom: 20px; }\n");
output.push_str("</style>\n</head>\n<body>\n");
output.push_str("<div class=\"metadata\">\n");
output.push_str("<h2>Search Results</h2>\n");
output.push_str(&format!("<p><strong>Query:</strong> {}</p>\n", escape_html(query)));
output.push_str(&format!("<p><strong>Path:</strong> {}</p>\n", escape_html(&path.to_string_lossy())));
output.push_str(&format!("<p><strong>Total Matches:</strong> {}</p>\n", matches.len()));
output.push_str("</div>\n");
for (i, m) in matches.iter().enumerate() {
output.push_str("<div class=\"match\">\n");
output.push_str(&format!("<h3>Match {}</h3>\n", i + 1));
let line_len = m.line.len();
let column_start = m.column_start.min(line_len);
let column_end = m.column_end.min(line_len);
let before = if column_start < line_len {
&m.line[..column_start]
} else {
""
};
let matched_text = &m.matched_text;
let after = if column_end < line_len {
&m.line[column_end..]
} else {
""
};
output.push_str("<div>");
let matched_html = format!(
"<span class=\"matched-text\">{}</span>",
escape_html(matched_text)
);
output.push_str(&format!(
"<span class=\"line-number\">→ {:>4}</span> │ {}{}{}",
m.line_number,
escape_html(before),
matched_html,
escape_html(after)
));
output.push_str("</div>\n");
output.push_str("</div>\n");
}
output.push_str("</body>\n</html>\n");
output
}
fn format_markdown(&self, matches: &[SearchMatch], query: &str, path: &Path) -> String {
let mut output = String::new();
output.push_str("# rfgrep Search Results\n\n");
output.push_str(&format!("**Query:** `{query}`\n"));
output.push_str(&format!("**Path:** `{}`\n", path.display()));
output.push_str(&format!("**Total Matches:** {}\n\n", matches.len()));
for (i, m) in matches.iter().enumerate() {
output.push_str(&format!("## Match {}\n\n", i + 1));
let line_len = m.line.len();
let column_start = m.column_start.min(line_len);
let column_end = m.column_end.min(line_len);
let before = if column_start < line_len {
&m.line[..column_start]
} else {
""
};
let matched = &m.matched_text;
let after = if column_end < line_len {
&m.line[column_end..]
} else {
""
};
output.push_str("**Match:**\n");
output.push_str("```\n");
output.push_str(&format!(
"→ {:>4} │ {before}{matched}{after}\n",
m.line_number
));
output.push_str("```\n\n");
}
output
}
fn format_csv(&self, matches: &[SearchMatch], _query: &str, _path: &Path) -> String {
let mut output = String::new();
output.push_str("file,line_number,column_start,column_end,matched_text,line\n");
for m in matches {
let escaped_line = m.line.replace('"', "\"\"");
let escaped_matched = m.matched_text.replace('"', "\"\"");
output.push_str(&format!(
"\"{}\",{},{},{},\"{}\",\"{}\"\n",
m.path.display(),
m.line_number,
m.column_start,
m.column_end,
escaped_matched,
escaped_line
));
}
output
}
fn format_yaml(&self, matches: &[SearchMatch], query: &str, path: &Path) -> String {
use serde_json::json;
let result = json!({
"query": query,
"path": path.to_string_lossy(),
"total_matches": matches.len(),
"matches": matches.iter().map(|m| json!({
"path": m.path.to_string_lossy(),
"line_number": m.line_number,
"line": m.line,
"matched_text": m.matched_text,
"column_start": m.column_start,
"column_end": m.column_end,
"context_before": m.context_before,
"context_after": m.context_after,
})).collect::<Vec<_>>()
});
serde_yaml::to_string(&result).unwrap_or_else(|_| "{}".to_string())
}
fn format_junit(&self, matches: &[SearchMatch], query: &str, _path: &Path) -> String {
let mut output = String::new();
output.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
output.push_str(&format!(
"<testsuite name=\"rfgrep\" tests=\"{}\" failures=\"{}\" time=\"0.0\">\n",
matches.len(),
matches.len()
));
for (i, m) in matches.iter().enumerate() {
output.push_str(&format!(
" <testcase name=\"match_{}\" classname=\"{}\">\n",
i + 1,
escape_xml(&m.path.to_string_lossy())
));
output.push_str(" <failure message=\"Pattern found\">\n");
output.push_str(&format!(
"Query: {}\nFile: {}\nLine {}: {}\nMatched: {}",
escape_xml(query),
escape_xml(&m.path.to_string_lossy()),
m.line_number,
escape_xml(&m.line),
escape_xml(&m.matched_text)
));
output.push_str(" </failure>\n");
output.push_str(" </testcase>\n");
}
output.push_str("</testsuite>\n");
output
}
}
fn escape_xml(s: &str) -> String {
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
}
fn escape_html(s: &str) -> String {
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
}