use std::collections::HashSet;
use unicode_width::UnicodeWidthChar;
use crate::parser::{DisplayParts, LogFormatParser, SpanInfo, format_span_col};
#[derive(Debug, Clone, Default)]
pub struct FieldLayout {
pub columns: Option<Vec<String>>,
}
pub fn line_row_count(bytes: &[u8], inner_width: usize) -> usize {
if inner_width == 0 {
return 1;
}
let text = std::str::from_utf8(bytes).unwrap_or("");
if text.is_empty() {
return 1;
}
let mut rows = 1usize;
let mut col = 0usize; let mut word_w = 0usize;
for ch in text.chars() {
if ch.is_ascii_whitespace() {
if word_w > 0 {
if col > 0 && col + word_w > inner_width {
rows += 1;
col = 0;
}
if col == 0 && word_w > inner_width {
rows += word_w.div_ceil(inner_width) - 1;
col = word_w % inner_width;
} else {
col += word_w;
}
word_w = 0;
}
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if col + cw > inner_width {
rows += 1;
col = cw; } else {
col += cw;
}
} else {
word_w += UnicodeWidthChar::width(ch).unwrap_or(0);
}
}
if word_w > 0 {
if col > 0 && col + word_w > inner_width {
rows += 1;
col = 0;
}
if col == 0 && word_w > inner_width {
rows += word_w.div_ceil(inner_width) - 1;
}
}
rows
}
pub fn count_wrapped_lines(text: &str, width: usize) -> usize {
if width == 0 {
return 1;
}
let mut lines = 1usize;
let mut col = 0usize;
for word in text.split_whitespace() {
let wl = word.len();
if col == 0 {
col = wl;
} else if col + 1 + wl > width {
lines += 1;
col = wl;
} else {
col += 1 + wl;
}
}
lines
}
pub fn effective_row_count(
line_bytes: &[u8],
inner_width: usize,
parser: Option<&dyn LogFormatParser>,
layout: &FieldLayout,
hidden_fields: &HashSet<String>,
show_keys: bool,
) -> usize {
if let Some(p) = parser
&& let Some(parts) = p.parse_line(line_bytes)
{
let cols = apply_field_layout(&parts, layout, hidden_fields, show_keys, None);
if !cols.is_empty() {
let rendered = cols.join(" ");
return line_row_count(rendered.as_bytes(), inner_width);
}
}
line_row_count(line_bytes, inner_width)
}
pub fn get_col(
p: &DisplayParts<'_>,
name: &str,
show_keys: bool,
year_override: Option<i32>,
) -> Option<String> {
match name {
"span" => p.span.as_ref().map(|s| format_span_col(s, show_keys)),
n => {
if let Some(suffix) = n.strip_prefix("span.") {
return p.span.as_ref().and_then(|s| {
if suffix == "name" {
Some(s.name.to_string())
} else {
s.fields
.iter()
.find(|(k, _)| *k == suffix)
.map(|(_, v)| v.to_string())
}
});
}
if let Some(suffix) = n.strip_prefix("fields.") {
return if suffix == "message" {
p.message.map(|s| s.to_string())
} else {
p.extra_fields
.iter()
.find(|(_, k, _)| *k == suffix)
.map(|(_, _, v)| v.to_string())
};
}
match n {
"timestamp" => {
return p.timestamp.map(|s| {
crate::filters::canonical_timestamp(s, year_override)
.unwrap_or_else(|| s.to_string())
});
}
"level" => {
return p.level.map(|l| format!("{:<5}", l));
}
"target" => {
return p.target.map(|s| s.to_string());
}
"message" => {
return p.message.map(|s| s.to_string());
}
_ => {}
}
p.extra_fields
.iter()
.find(|(_, k, _)| *k == n)
.map(|(_, _, v)| v.to_string())
}
}
}
#[cfg(test)]
fn default_cols(p: &DisplayParts<'_>, show_keys: bool) -> Vec<String> {
let mut cols = Vec::new();
if let Some(ts) = p.timestamp {
cols.push(ts.to_string());
}
if let Some(lvl) = p.level {
cols.push(format!("{:<5}", lvl));
}
if let Some(tgt) = p.target {
cols.push(tgt.to_string());
}
if let Some(span) = &p.span {
cols.push(format_span_col(span, show_keys));
}
for (_, key, value) in &p.extra_fields {
if show_keys {
cols.push(format!("{key}={value}"));
} else {
cols.push(value.to_string());
}
}
if let Some(msg) = p.message {
cols.push(msg.to_string());
}
cols
}
fn render_span(s: &SpanInfo<'_>, excluded_keys: &HashSet<&str>, show_keys: bool) -> String {
if excluded_keys.is_empty() {
return format_span_col(s, show_keys);
}
let visible_fields: Vec<(&str, &str)> = s
.fields
.iter()
.filter(|(k, _)| !excluded_keys.contains(k))
.copied()
.collect();
let filtered = SpanInfo {
name: s.name,
fields: visible_fields,
};
format_span_col(&filtered, show_keys)
}
pub fn apply_field_layout(
p: &DisplayParts<'_>,
layout: &FieldLayout,
hidden_fields: &HashSet<String>,
show_keys: bool,
year_override: Option<i32>,
) -> Vec<String> {
let excluded_keys: HashSet<&str> = hidden_fields
.iter()
.filter_map(|h| h.strip_prefix("span."))
.collect();
if let Some(names) = &layout.columns {
names
.iter()
.filter(|name| !hidden_fields.contains(name.as_str()))
.filter_map(|name| {
if name == "span" {
p.span
.as_ref()
.map(|s| render_span(s, &excluded_keys, show_keys))
} else {
get_col(p, name, show_keys, year_override)
}
})
.collect()
} else {
let ts_hidden = hidden_fields.contains("timestamp");
let lvl_hidden = hidden_fields.contains("level");
let tgt_hidden = hidden_fields.contains("target");
let msg_hidden = hidden_fields.contains("message");
let mut cols = Vec::new();
if !ts_hidden && let Some(ts) = p.timestamp {
cols.push(
crate::filters::canonical_timestamp(ts, year_override)
.unwrap_or_else(|| ts.to_string()),
);
}
if !lvl_hidden && let Some(lvl) = p.level {
cols.push(format!("{:<5}", lvl));
}
if !tgt_hidden && let Some(tgt) = p.target {
cols.push(tgt.to_string());
}
if !hidden_fields.contains("span")
&& let Some(span) = &p.span
{
cols.push(render_span(span, &excluded_keys, show_keys));
}
let mut sorted_extras: Vec<_> = p.extra_fields.iter().collect();
sorted_extras.sort_by_key(|(_, k, _)| *k);
for (_, key, value) in sorted_extras {
if !hidden_fields.contains(*key) {
if show_keys {
cols.push(format!("{key}={value}"));
} else {
cols.push(value.to_string());
}
}
}
if !msg_hidden && let Some(msg) = p.message {
cols.push(msg.to_string());
}
cols
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::SpanInfo;
#[test]
fn test_line_row_count_zero_width() {
assert_eq!(line_row_count(b"hello", 0), 1);
}
#[test]
fn test_line_row_count_empty_line() {
assert_eq!(line_row_count(b"", 80), 1);
}
#[test]
fn test_line_row_count_fits_in_one_row() {
assert_eq!(line_row_count(b"hello", 80), 1);
}
#[test]
fn test_line_row_count_wraps_to_two_rows() {
assert_eq!(line_row_count(b"0123456789", 6), 2);
}
#[test]
fn test_line_row_count_word_wrap_exceeds_char_wrap() {
assert_eq!(line_row_count(b"hello world test abc", 7), 4);
}
#[test]
fn test_line_row_count_long_word_spans_many_rows() {
assert_eq!(line_row_count(b"aaaaaaaaaaaaaaa", 5), 3);
}
#[test]
fn test_line_row_count_long_word_plus_short_word() {
assert_eq!(line_row_count(b"aaaaaaaaaa b", 7), 2);
}
#[test]
fn test_line_row_count_exact_width() {
assert_eq!(line_row_count(b"12345", 5), 1);
}
#[test]
fn test_count_wrapped_lines_empty() {
assert_eq!(count_wrapped_lines("", 80), 1);
}
#[test]
fn test_count_wrapped_lines_zero_width() {
assert_eq!(count_wrapped_lines("hello world", 0), 1);
}
#[test]
fn test_count_wrapped_lines_single_word() {
assert_eq!(count_wrapped_lines("hello", 80), 1);
}
#[test]
fn test_count_wrapped_lines_wraps() {
assert!(count_wrapped_lines("hello world", 6) >= 2);
}
#[test]
fn test_count_wrapped_lines_exact_fit() {
assert_eq!(count_wrapped_lines("ab cd", 5), 1);
}
fn make_parts<'a>() -> DisplayParts<'a> {
DisplayParts {
timestamp: Some("2024-01-01T00:00:00Z"),
level: Some("INFO"),
target: Some("myapp"),
span: Some(SpanInfo {
name: "handler",
fields: vec![("method", "GET")],
}),
extra_fields: vec![(crate::parser::FieldSemantic::Extra, "count", "42")],
message: Some("hello world"),
}
}
#[test]
fn test_get_col_timestamp() {
let p = make_parts();
assert_eq!(
get_col(&p, "timestamp", false, None),
Some("2024-01-01 00:00:00.000".to_string())
);
}
#[test]
fn test_get_col_timestamp_nano_epoch_converted() {
let p = DisplayParts {
timestamp: Some("1700046000000000000"),
level: Some("INFO"),
..Default::default()
};
let col = get_col(&p, "timestamp", false, None).unwrap();
assert!(
!col.contains("1700046000000000000"),
"raw nanos should not appear"
);
assert!(col.starts_with("2023-11-15"), "should be canonical date");
}
#[test]
fn test_apply_field_layout_default_nano_epoch_converted() {
let p = DisplayParts {
timestamp: Some("1700046000000000000"),
level: Some("INFO"),
message: Some("server started"),
..Default::default()
};
let hidden = HashSet::new();
let cols = apply_field_layout(&p, &FieldLayout { columns: None }, &hidden, false, None);
assert!(
cols[0].starts_with("2023-11-15"),
"timestamp col should be canonical"
);
}
#[test]
fn test_get_col_level() {
let p = make_parts();
let result = get_col(&p, "level", false, None).unwrap();
assert!(result.starts_with("INFO"));
}
#[test]
fn test_get_col_message() {
let p = make_parts();
assert_eq!(
get_col(&p, "message", false, None),
Some("hello world".to_string())
);
}
#[test]
fn test_get_col_span_name() {
let p = make_parts();
assert_eq!(
get_col(&p, "span.name", false, None),
Some("handler".to_string())
);
}
#[test]
fn test_get_col_dotted_span_field() {
let p = make_parts();
assert_eq!(
get_col(&p, "span.method", false, None),
Some("GET".to_string())
);
}
#[test]
fn test_get_col_dotted_fields_field() {
let p = make_parts();
assert_eq!(
get_col(&p, "fields.message", false, None),
Some("hello world".to_string())
);
}
#[test]
fn test_get_col_extra_field() {
let p = make_parts();
assert_eq!(get_col(&p, "count", false, None), Some("42".to_string()));
}
#[test]
fn test_get_col_unknown_returns_none() {
let p = make_parts();
assert_eq!(get_col(&p, "nonexistent", false, None), None);
}
#[test]
fn test_get_col_alias_resolution() {
use crate::parser::{FieldSemantic, push_field_as};
let mut extra_fields = vec![];
push_field_as(&mut extra_fields, FieldSemantic::Pid, "1234");
push_field_as(&mut extra_fields, FieldSemantic::Hostname, "myhost");
let p = DisplayParts {
extra_fields,
..Default::default()
};
assert_eq!(get_col(&p, "pid", false, None), Some("1234".to_string()));
assert_eq!(
get_col(&p, "hostname", false, None),
Some("myhost".to_string())
);
}
#[test]
fn test_get_col_span_show_keys() {
let p = make_parts();
assert_eq!(
get_col(&p, "span", false, None),
Some("handler: GET".to_string())
); assert_eq!(
get_col(&p, "span", true, None),
Some("handler: method=GET".to_string())
);
}
#[test]
fn test_default_cols_all_fields() {
let p = make_parts();
let cols = default_cols(&p, false);
assert_eq!(cols.len(), 6);
assert!(cols[0].contains("2024"));
assert!(cols[1].starts_with("INFO"));
assert_eq!(cols[2], "myapp");
assert!(cols[5].contains("hello world"));
}
#[test]
fn test_default_cols_minimal() {
let p = DisplayParts {
timestamp: None,
level: None,
target: None,
span: None,
extra_fields: vec![],
message: Some("only message"),
};
let cols = default_cols(&p, false);
assert_eq!(cols.len(), 1);
assert_eq!(cols[0], "only message");
}
#[test]
fn test_apply_field_layout_default_no_hidden() {
let p = make_parts();
let layout = FieldLayout::default();
let hidden = HashSet::new();
let cols = apply_field_layout(&p, &layout, &hidden, false, None);
assert_eq!(cols.len(), 6);
}
#[test]
fn test_apply_field_layout_explicit_columns() {
let p = make_parts();
let layout = FieldLayout {
columns: Some(vec!["level".to_string(), "message".to_string()]),
};
let hidden = HashSet::new();
let cols = apply_field_layout(&p, &layout, &hidden, false, None);
assert_eq!(cols.len(), 2);
}
#[test]
fn test_apply_field_layout_hidden_fields_default() {
let p = make_parts();
let layout = FieldLayout::default();
let mut hidden = HashSet::new();
hidden.insert("timestamp".to_string());
let cols = apply_field_layout(&p, &layout, &hidden, false, None);
assert_eq!(cols.len(), 5);
}
#[test]
fn test_apply_field_layout_hidden_fields_explicit() {
let p = make_parts();
let layout = FieldLayout {
columns: Some(vec![
"timestamp".to_string(),
"level".to_string(),
"message".to_string(),
]),
};
let mut hidden = HashSet::new();
hidden.insert("timestamp".to_string());
let cols = apply_field_layout(&p, &layout, &hidden, false, None);
assert_eq!(cols.len(), 2); }
#[test]
fn test_effective_row_count_no_parser_uses_raw_bytes() {
let hidden = HashSet::new();
let layout = FieldLayout::default();
assert_eq!(
effective_row_count(b"hello world", 80, None, &layout, &hidden, false),
1
);
assert_eq!(
effective_row_count(b"hello world", 5, None, &layout, &hidden, false),
3
);
}
#[test]
fn test_effective_row_count_with_parser_uses_rendered_width() {
let json = br#"{"timestamp":"2024-01-01T00:00:00Z","level":"INFO","target":"app","fields":{"message":"ok"}}"#;
let parser = crate::parser::detect_format(&[br#"{"timestamp":"2024-01-01T00:00:00Z","level":"INFO","target":"app","fields":{"message":"ok"}}"#]).unwrap();
let layout = FieldLayout::default();
let hidden = HashSet::new();
assert_eq!(line_row_count(json, 20), 5);
let result = effective_row_count(json, 20, Some(parser.as_ref()), &layout, &hidden, false);
assert!(
result < 5,
"structured rendering should produce fewer rows than raw bytes"
);
}
#[test]
fn test_effective_row_count_parse_failure_falls_back_to_raw() {
let parser = crate::parser::detect_format(&[br#"{"timestamp":"2024-01-01T00:00:00Z","level":"INFO","target":"app","fields":{"message":"ok"}}"#]).unwrap();
let layout = FieldLayout::default();
let hidden = HashSet::new();
let raw = b"plain text log line that is not json";
assert_eq!(
effective_row_count(raw, 20, Some(parser.as_ref()), &layout, &hidden, false),
line_row_count(raw, 20)
);
}
#[test]
fn test_effective_row_count_all_hidden_falls_back_to_raw() {
let parser = crate::parser::detect_format(&[br#"{"timestamp":"2024-01-01T00:00:00Z","level":"INFO","target":"app","fields":{"message":"ok"}}"#]).unwrap();
let layout = FieldLayout::default();
let mut hidden = HashSet::new();
for key in ["timestamp", "level", "target", "message"] {
hidden.insert(key.to_string());
}
let json = br#"{"timestamp":"2024-01-01T00:00:00Z","level":"INFO","target":"app","fields":{"message":"ok"}}"#;
let raw_rows = line_row_count(json, 20);
assert_eq!(
effective_row_count(json, 20, Some(parser.as_ref()), &layout, &hidden, false),
raw_rows
);
}
#[test]
fn test_hiding_span_subfield_filters_it_from_default_layout() {
let p = DisplayParts {
timestamp: Some("2024-01-01T00:00:00Z"),
level: Some("INFO"),
target: Some("app"),
span: Some(SpanInfo {
name: "request",
fields: vec![("request_id", "abc-123"), ("method", "GET")],
}),
extra_fields: vec![],
message: Some("hello"),
};
let layout = FieldLayout::default();
let mut hidden = HashSet::new();
hidden.insert("span.request_id".to_string());
let cols = apply_field_layout(&p, &layout, &hidden, true, None);
let span_col = cols.iter().find(|c| c.contains("request")).unwrap();
assert!(
!span_col.contains("request_id"),
"hidden span sub-field should not appear: {span_col}"
);
assert!(
span_col.contains("method"),
"non-hidden span sub-field should still appear: {span_col}"
);
}
#[test]
fn test_hiding_span_subfield_via_hidden_fields_explicit_layout() {
let p = DisplayParts {
timestamp: None,
level: None,
target: None,
span: Some(SpanInfo {
name: "request",
fields: vec![("request_id", "abc-123"), ("method", "GET")],
}),
extra_fields: vec![],
message: None,
};
let layout = FieldLayout {
columns: Some(vec!["span".to_string()]),
};
let mut hidden = HashSet::new();
hidden.insert("span.request_id".to_string());
let cols = apply_field_layout(&p, &layout, &hidden, true, None);
assert_eq!(cols.len(), 1);
assert!(
!cols[0].contains("request_id"),
"hidden span sub-field should not appear in explicit layout: {}",
cols[0]
);
assert!(cols[0].contains("method"));
}
#[test]
fn test_hiding_span_subfield_via_select_fields() {
let p = DisplayParts {
timestamp: None,
level: None,
target: None,
span: Some(SpanInfo {
name: "request",
fields: vec![("request_id", "abc-123"), ("method", "GET")],
}),
extra_fields: vec![],
message: None,
};
let layout = FieldLayout {
columns: Some(vec![
"span".to_string(),
"span.request_id".to_string(),
"span.method".to_string(),
]),
};
let mut hidden = HashSet::new();
hidden.insert("span.request_id".to_string());
let cols = apply_field_layout(&p, &layout, &hidden, true, None);
let span_col = cols.iter().find(|c| c.contains("request")).unwrap();
assert!(
!span_col.contains("request_id"),
"disabled span sub-field should be filtered: {span_col}"
);
assert!(span_col.contains("method"));
}
#[test]
fn test_hiding_all_span_subfields_leaves_span_name() {
let p = DisplayParts {
timestamp: None,
level: None,
target: None,
span: Some(SpanInfo {
name: "request",
fields: vec![("request_id", "abc-123")],
}),
extra_fields: vec![],
message: None,
};
let layout = FieldLayout::default();
let mut hidden = HashSet::new();
hidden.insert("span.request_id".to_string());
let cols = apply_field_layout(&p, &layout, &hidden, false, None);
assert!(
cols.iter().any(|c| c == "request"),
"span name should remain when all sub-fields are hidden"
);
}
#[test]
fn test_apply_field_layout_hidden_alias() {
let p = make_parts();
let layout = FieldLayout::default();
let mut hidden = HashSet::new();
hidden.insert("level".to_string());
let cols = apply_field_layout(&p, &layout, &hidden, false, None);
assert_eq!(cols.len(), 5);
}
}