set envvars

This commit is contained in:
Jana Dönszelmann 2026-02-20 12:40:07 +01:00
commit 3963fc50c3
No known key found for this signature in database
9 changed files with 3248 additions and 0 deletions

107
src/main.rs Normal file
View file

@ -0,0 +1,107 @@
use std::{
env::temp_dir,
ffi::OsString,
fs::{self, File},
path::PathBuf,
process::{Command, exit},
};
mod tui;
use clap::{Parser, Subcommand};
use jiff::Zoned;
#[derive(Subcommand, Debug)]
enum Preset {
/// Explore logs
Show,
/// Get all the typesystem related logs
Types,
/// Get all logs
All,
Crates {
#[arg(short, long)]
crates: Vec<String>,
},
}
fn default_tempdir() -> PathBuf {
temp_dir().join("rustc-logviz")
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[command(subcommand)]
preset: Preset,
#[arg(default_value_os_t = default_tempdir())]
#[arg(global = true)]
#[arg(long = "logs-dir")]
logs_dir: PathBuf,
#[arg(trailing_var_arg = true)]
#[arg(allow_hyphen_values = true)]
#[arg(global = true)]
rest: Vec<OsString>,
}
fn main() {
let Args {
preset,
logs_dir,
rest,
} = Args::parse();
let rustc_log = match preset {
Preset::Show => {
tui::run(logs_dir);
exit(0);
}
Preset::Types => {
"rustc_hir_typeck,rustc_infer,rustc_next_trait_solver,rustc_middle,rustc_traits,rustc_trait_selection,rustc_type_ir,rustc_ty_utils".to_string()
}
Preset::All => "debug".to_string(),
Preset::Crates { crates } => format!("{}", crates.join(",")),
};
let (first, rest) = {
let mut rest = rest.into_iter();
let Some(first) = rest.next() else {
eprintln!("no command given, exiting");
exit(0);
};
(first, rest.collect::<Vec<_>>())
};
if let Err(e) = fs::create_dir_all(&logs_dir) {
eprintln!("failed to create logs dir at {}: {e:?}", logs_dir.display());
exit(1)
}
let now = Zoned::now().strftime("%b %e %H:%M:%S");
let log_file_path = logs_dir.join(format!("{now}.log"));
let log_file = match File::create(&log_file_path) {
Ok(i) => i,
Err(e) => {
eprintln!(
"failed to create logfile at {}: {e:?}",
log_file_path.display()
);
exit(1)
}
};
eprintln!("outputting json logs to {}", log_file_path.display());
if let Err(e) = Command::new(first)
.args(rest)
.env("RUSTC_LOG", rustc_log)
.env("RUSTC_LOG_FORMAT_JSON", "1")
.env("RUSTC_LOG_OUTPUT_TARGET", log_file_path)
.status()
{
eprintln!("failed to spawn command: {e:?}, exiting");
exit(1);
}
}

158
src/tui/filter.rs Normal file
View file

@ -0,0 +1,158 @@
use crate::tui::model::LogEntry;
pub enum WipMatcher {
Field {
name: Option<String>,
value: Option<serde_json::Value>,
},
Specific {
path: Option<Vec<usize>>,
},
}
impl WipMatcher {
fn validate(&self) -> Option<Matcher> {
match self {
WipMatcher::Field {
name: Some(name),
value: Some(value),
} => Some(Matcher::Field {
name: name.clone(),
value: value.clone(),
}),
WipMatcher::Specific { path: Some(path) } => {
Some(Matcher::Specific { path: path.clone() })
}
_ => None,
}
}
}
pub enum Matcher {
Field {
name: String,
value: serde_json::Value,
},
Specific {
path: Vec<usize>,
},
}
impl Matcher {
pub fn matches(&self, entry: &LogEntry) -> bool {
match self {
Matcher::Field { name, value } => entry
.all_fields()
.fields
.get(name)
.is_some_and(|v| v == value),
Matcher::Specific { path } => false,
}
}
}
#[derive(Clone)]
pub enum FilterKind {
Inline,
Remove,
}
pub struct Filter {
pub matcher: Matcher,
pub kind: FilterKind,
}
pub struct WipFilter {
pub matcher: Option<WipMatcher>,
pub kind: Option<FilterKind>,
pub selection: FilterSelection,
}
impl WipFilter {
pub fn validate(&self) -> Option<Filter> {
let Self {
matcher,
kind,
selection: _,
} = self;
let Some(matcher) = matcher else { return None };
Some(Filter {
matcher: matcher.validate()?,
kind: kind.clone()?,
})
}
pub fn clear(&mut self) {
match self.selection {
FilterSelection::Kind => self.kind = None,
FilterSelection::MatcherKind => {}
FilterSelection::Matcher => {}
FilterSelection::Confirm => {}
}
}
pub fn right(&mut self) {
match self.selection {
FilterSelection::Kind => {
self.kind = Some(match self.kind {
None => FilterKind::Inline,
Some(FilterKind::Inline) => FilterKind::Remove,
Some(FilterKind::Remove) => FilterKind::Inline,
})
}
FilterSelection::MatcherKind => {}
FilterSelection::Matcher => {}
FilterSelection::Confirm => {}
}
}
pub fn left(&mut self) {
match self.selection {
FilterSelection::Kind => {
self.kind = Some(match self.kind {
None => FilterKind::Remove,
Some(FilterKind::Remove) => FilterKind::Inline,
Some(FilterKind::Inline) => FilterKind::Inline,
})
}
FilterSelection::MatcherKind => {}
FilterSelection::Matcher => {}
FilterSelection::Confirm => {}
}
}
}
#[derive(Clone, Copy)]
pub enum FilterSelection {
Kind,
MatcherKind,
Matcher,
Confirm,
}
impl FilterSelection {
pub fn next(&mut self) {
*self = match *self {
Self::Kind => Self::MatcherKind,
Self::MatcherKind => Self::Matcher,
Self::Matcher => Self::Confirm,
Self::Confirm => Self::Confirm,
};
}
pub fn prev(&mut self) {
*self = match self {
Self::Kind => Self::Kind,
Self::MatcherKind => Self::Kind,
Self::Matcher => Self::MatcherKind,
Self::Confirm => Self::Matcher,
};
}
}
#[derive(Clone, Copy)]
pub enum FieldMatcherSelection {
Field,
Value,
}
impl FieldMatcherSelection {}

166
src/tui/log_viewer.rs Normal file
View file

@ -0,0 +1,166 @@
use std::{collections::HashMap, mem, rc::Rc};
use crate::tui::{
model::LogEntry,
reader::{FilterAdapter, LogfileReader},
};
use tui_widget_list::ListState;
#[derive(Default, Clone)]
pub struct LogView {
first_item: usize,
selected: usize,
}
pub struct LogViewer {
stack: Vec<LogView>,
curr: LogView,
cache: HashMap<Vec<usize>, LogView>,
pub last_height: usize,
pub footer_selected: bool,
pub footer_list: ListState,
}
impl LogViewer {
pub fn new() -> Self {
Self {
stack: Vec::new(),
curr: LogView::default(),
cache: HashMap::new(),
footer_list: ListState::default(),
footer_selected: false,
last_height: 0,
}
}
pub fn update_num_items(&mut self, num_visible_items: usize) {
while self.curr.selected >= self.curr.first_item + num_visible_items {
self.curr.first_item += 1;
}
self.last_height = num_visible_items;
}
pub fn footer_fields(&self, file: &mut LogfileReader) -> Vec<(String, serde_json::Value)> {
if let Some(selected) = self.selected(file) {
selected.all_fields().fields.into_iter().collect::<Vec<_>>()
} else {
Vec::new()
}
}
pub fn items(
&self,
file: &mut LogfileReader,
max: usize,
) -> Option<(Vec<Rc<LogEntry>>, usize, usize)> {
let items: Vec<_> = if self.stack.is_empty() {
file.iter_from(self.curr.first_item).take(max).collect()
} else {
let mut stack = self.stack.iter();
let first = stack.next().unwrap();
let mut curr_log_entry = file.iter_from(first.selected).next()?;
for elem in stack {
curr_log_entry = curr_log_entry.get(elem.selected)?;
}
match curr_log_entry.as_ref() {
LogEntry::Single { .. } => return None,
LogEntry::Sub { sub_entries, .. } => {
FilterAdapter::new(file, &sub_entries[self.curr.first_item..])
.take(max)
.cloned()
.collect()
}
}
};
Some((items, self.curr.first_item, self.curr.selected))
}
pub fn selected(&self, file: &mut LogfileReader) -> Option<Rc<LogEntry>> {
self.items(file, self.curr.selected - self.curr.first_item + 1)?
.0
.get(self.curr.selected - self.curr.first_item)
.cloned()
}
fn update_footer_select(&mut self) {
self.footer_list.select(Some(0));
}
pub fn prev(&mut self) {
if self.footer_selected {
self.footer_list.previous();
} else {
self.curr.selected = self.curr.selected.saturating_sub(1);
self.curr.first_item = self.curr.first_item.min(self.curr.selected);
self.update_footer_select();
}
}
pub fn next(&mut self) {
if self.footer_selected {
self.footer_list.next();
} else {
self.curr.selected += 1;
self.update_footer_select();
}
}
pub fn page_down(&mut self) {
self.curr.selected += self.last_height;
self.footer_selected = false;
self.update_footer_select();
}
pub fn page_up(&mut self) {
self.curr.selected = self.curr.selected.saturating_sub(self.last_height);
self.curr.first_item = self.curr.first_item.min(self.curr.selected);
self.footer_selected = false;
self.update_footer_select();
}
pub fn home(&mut self) {
if self.footer_selected {
self.footer_list.select(Some(0));
} else {
self.curr.selected = 0;
self.curr.first_item = 0;
self.update_footer_select();
}
}
pub fn path(&self) -> Vec<usize> {
self.stack.iter().map(|i| i.selected).collect()
}
pub fn back(&mut self) {
self.cache.insert(self.path(), self.curr);
self.curr = self.stack.pop().unwrap_or_default();
self.footer_selected = false;
self.update_footer_select();
}
pub fn switch_focus(&mut self) {
self.footer_selected = !self.footer_selected;
}
pub fn enter(&mut self, file: &mut LogfileReader) {
if !self.footer_selected {
let Some(s) = self.selected(file) else {
return;
};
if let LogEntry::Single { .. } = s.as_ref() {
return;
}
self.stack
.push(mem::replace(&mut self.curr, LogView::default()));
if let Some(cached_view) = self.cache.get(&self.path()) {
self.curr = *cached_view;
}
self.update_footer_select();
}
}
}

594
src/tui/mod.rs Normal file
View file

@ -0,0 +1,594 @@
use ratatui_themes::{Theme, ThemeName};
use std::{
fs::{self, DirEntry},
io,
path::{Path, PathBuf},
process::exit,
};
use tui_widget_list::{ListBuilder, ListView};
use crate::tui::{
filter::{FilterKind, WipMatcher},
log_viewer::LogViewer,
};
use crate::tui::{
filter::{FilterSelection, WipFilter},
reader::LogfileReader,
};
use ratatui::{
DefaultTerminal,
buffer::Buffer,
crossterm::event::{self, Event, KeyCode, KeyModifiers},
layout::{Constraint, HorizontalAlignment, Layout, Rect},
style::Style,
widgets::{
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
},
};
pub mod filter;
pub mod log_viewer;
pub mod model;
pub mod reader;
pub fn run(logs_dir: PathBuf) {
let terminal = ratatui::init();
let theme = Theme::new(ThemeName::OneDarkPro);
let app_result = App::new(logs_dir, theme).run(terminal);
ratatui::restore();
if let Err(e) = app_result {
eprintln!("error in tui: {e:?}");
exit(1);
}
}
enum Tab {
FileChooser {
files: Vec<DirEntry>,
state: ListState,
last_height: usize,
},
LogViewer(LogViewer),
CreateFilter {
filter: WipFilter,
},
Empty,
}
impl Tab {
fn name(&self, path: Option<&Path>) -> String {
match (self, path) {
(Tab::Empty, _) => "dummy".to_string(),
(Tab::FileChooser { .. }, _) => "choose a file".to_string(),
(Tab::LogViewer(_), Some(path)) => format!("logs of {}", path.display()),
(Tab::LogViewer(_), None) => "logs".to_string(),
(Tab::CreateFilter { .. }, _) => "create filter".to_string(),
}
}
}
fn initialize_filter(
lv: &mut LogViewer,
file: &mut LogfileReader,
kind: Option<FilterKind>,
) -> WipFilter {
let matcher = if lv.footer_selected {
let footer_fields = lv.footer_fields(file);
let (key, value) = footer_fields
.get(lv.footer_list.selected.unwrap_or(0))
.map_or((None, None), |(k, v)| (Some(k), Some(v)));
Some(WipMatcher::Field {
name: key.cloned(),
value: value.cloned(),
})
} else {
Some(WipMatcher::Specific {
path: Some(lv.path().clone()),
})
};
WipFilter {
matcher,
kind,
selection: filter::FilterSelection::Kind,
}
}
struct App {
tabs: Vec<Tab>,
logs_dir: PathBuf,
current_file: Option<LogfileReader>,
theme: Theme,
}
impl App {
fn new(logs_dir: PathBuf, theme: Theme) -> Self {
let mut res = Self {
tabs: Vec::new(),
current_file: None,
logs_dir,
theme,
};
res.replace_tab(res.choose_file());
res
}
fn current_file_path(&self) -> Option<PathBuf> {
self.current_file.as_ref().map(|i| i.path.to_path_buf())
}
fn replace_tab(&mut self, tab: Tab) {
if let Some(last) = self.tabs.last_mut() {
*last = tab;
} else {
self.tabs = vec![tab];
}
}
fn push_tab(&mut self, tab: Tab) {
self.tabs.push(tab);
}
fn pop_tab(&mut self) {
let _ = self.tabs.pop();
}
fn choose_file(&self) -> Tab {
fn init(logs_dir: &Path) -> io::Result<Vec<DirEntry>> {
let mut files = Vec::new();
for file in fs::read_dir(logs_dir)? {
let file = file?;
files.push(file);
}
Ok(files)
}
match init(&self.logs_dir) {
Ok(files) => Tab::FileChooser {
files,
state: ListState::default(),
last_height: 0,
},
Err(_) => Tab::Empty,
}
}
fn current_tab(&mut self) -> &mut Tab {
if self.tabs.is_empty() {
self.tabs.push(Tab::Empty);
}
self.tabs.last_mut().unwrap()
}
fn run(mut self, mut terminal: DefaultTerminal) -> io::Result<()> {
loop {
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
if let Event::Key(key) = event::read()? {
// to initialize, but we then do get manually for borrow reasons
self.current_tab();
let num_tabs = self.tabs.len();
match (key.code, self.tabs.last_mut().unwrap()) {
(KeyCode::Char('q'), _) => return Ok(()),
(KeyCode::Char('c'), _) if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(());
}
(KeyCode::Esc, _) if num_tabs > 1 => {
self.pop_tab();
}
(KeyCode::Char('j') | KeyCode::Down, tab) => match tab {
Tab::FileChooser { state, .. } => state.select_next(),
Tab::LogViewer(lv) => lv.next(),
Tab::Empty => {}
Tab::CreateFilter { filter } => {
filter.selection.next();
}
},
(KeyCode::Char('k') | KeyCode::Up, tab) => match tab {
Tab::FileChooser { state, .. } => state.select_previous(),
Tab::LogViewer(lv) => {
lv.prev();
}
Tab::Empty => {}
Tab::CreateFilter { filter } => {
filter.selection.prev();
}
},
(KeyCode::PageDown, tab) => match tab {
Tab::FileChooser {
state, last_height, ..
} => state.scroll_down_by(*last_height as u16),
Tab::LogViewer(lv) => {
lv.page_down();
}
Tab::Empty => {}
Tab::CreateFilter { .. } => {}
},
(KeyCode::PageUp, tab) => match tab {
Tab::FileChooser {
state, last_height, ..
} => state.scroll_up_by(*last_height as u16),
Tab::LogViewer(lv) => {
lv.page_up();
}
Tab::Empty => {}
Tab::CreateFilter { .. } => {}
},
(KeyCode::Char('G') | KeyCode::Home, tab) => match tab {
Tab::FileChooser { state, .. } => state.select_first(),
Tab::LogViewer(lv) => {
lv.home();
}
Tab::Empty => {}
Tab::CreateFilter { .. } => {}
},
(KeyCode::Char('g') | KeyCode::End, tab) => match tab {
Tab::FileChooser { state, .. } => state.select_last(),
Tab::LogViewer(lv) => {}
Tab::Empty => {}
Tab::CreateFilter { .. } => {}
},
(KeyCode::Backspace | KeyCode::Left | KeyCode::Esc, Tab::LogViewer(lv)) => {
lv.back();
}
(KeyCode::Backspace, Tab::CreateFilter { filter }) => {
filter.clear();
}
(KeyCode::Right, Tab::CreateFilter { filter }) => {
filter.right();
}
(KeyCode::Left, Tab::CreateFilter { filter }) => {
filter.left();
}
(KeyCode::Right, Tab::LogViewer(lv)) => {
if let Some(file) = &mut self.current_file {
lv.enter(file)
}
}
(KeyCode::Tab, Tab::LogViewer(lv)) => {
lv.switch_focus();
}
(KeyCode::Char('r'), Tab::LogViewer(lv)) => {
if let Some(file) = &mut self.current_file {
let filter = initialize_filter(lv, file, Some(FilterKind::Remove));
self.push_tab(Tab::CreateFilter { filter });
}
}
(KeyCode::Char('i'), Tab::LogViewer(lv)) => {
if let Some(file) = &mut self.current_file {
let filter = initialize_filter(lv, file, Some(FilterKind::Inline));
self.push_tab(Tab::CreateFilter { filter });
}
}
(KeyCode::Enter, tab) => match tab {
Tab::FileChooser { files, state, .. } => {
if let Some(selected) = state.selected()
&& let Some(selected) = files.get(selected)
{
match LogfileReader::new(&selected.path()) {
Ok(i) => {
self.current_file = Some(i);
self.replace_tab(Tab::LogViewer(LogViewer::new()));
}
Err(e) => {
panic!()
}
}
}
}
Tab::LogViewer(lv) => {
if let Some(file) = &mut self.current_file {
if lv.footer_selected {
let filter = initialize_filter(lv, file, None);
self.push_tab(Tab::CreateFilter { filter });
} else {
lv.enter(file)
}
}
}
Tab::Empty => {}
Tab::CreateFilter { filter } => {
if let FilterSelection::Confirm = filter.selection
&& let Some(file) = &mut self.current_file
{
if let Some(filter) = filter.validate() {
file.add_filter(filter);
self.pop_tab();
if let Tab::LogViewer(lv) = self.current_tab() {
lv.footer_selected = false;
}
}
} else {
filter.selection.next();
}
}
},
_ => {}
}
}
}
}
}
impl Widget for &mut App {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let palette = self.theme.palette();
let default = Style::new().fg(palette.fg).bg(palette.bg);
let highlighted = Style::new().fg(palette.accent).bg(palette.selection);
let border = Style::new().fg(palette.fg).bg(palette.bg);
let border_selected = Style::new().fg(palette.secondary).bg(palette.bg);
let [header_area, main_area, footer_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Ratio(1, 4),
])
.areas(area);
let [_, popup_area, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Min(40),
Constraint::Fill(1),
])
.areas(area);
let [_, popup_area, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Min(40),
Constraint::Fill(1),
])
.areas(popup_area);
let (footer_focused, header_focused) = match self.current_tab() {
Tab::FileChooser { .. } => (false, true),
Tab::LogViewer(lv) => (lv.footer_selected, !lv.footer_selected),
Tab::Empty => (false, false),
Tab::CreateFilter { .. } => (false, false),
};
let main_area = {
let block = Block::bordered()
.style(default)
.border_style(if header_focused {
border_selected
} else {
border
});
let inner = block.inner(main_area);
block.render(main_area, buf);
inner
};
let footer_area = {
let block = Block::bordered()
.style(default)
.border_style(if footer_focused {
border_selected
} else {
border
});
let inner = block.inner(footer_area);
block.render(footer_area, buf);
inner
};
let [left, middle, right] = Layout::horizontal([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Fill(1),
])
.areas(header_area);
let current_file_path = self.current_file_path();
let breadcrumbs = self.tabs[..self.tabs.len() - 1]
.iter()
.map(|i| i.name(current_file_path.as_deref()))
.collect::<Vec<_>>()
.join("");
Paragraph::new(breadcrumbs)
.wrap(Wrap { trim: false })
.render(left, buf);
Paragraph::new(self.current_tab().name(current_file_path.as_deref()))
.alignment(HorizontalAlignment::Center)
.wrap(Wrap { trim: false })
.render(middle, buf);
for tab in &mut self.tabs {
match tab {
Tab::FileChooser {
files,
state,
last_height,
} => {
let list = List::new(files.iter().map(|file| {
ListItem::new(file.file_name().to_string_lossy().into_owned())
}))
.style(default)
.highlight_style(highlighted);
*last_height = main_area.height as usize;
StatefulWidget::render(list, main_area, buf, state);
}
Tab::LogViewer(lv) => {
let Some(file) = &mut self.current_file else {
continue;
};
lv.update_num_items(main_area.height as usize);
let (items, start, selected) = lv
.items(file, main_area.height as usize)
.unwrap_or_else(|| (Vec::new(), 0, 0));
let list = List::new(items.into_iter().enumerate().map(|(idx, i)| {
let line = i.line_text(false);
let mut list_item = ListItem::new(line);
if idx + start == selected {
list_item = list_item.style(highlighted);
}
list_item
}));
Widget::render(list, main_area, buf);
let items = lv.footer_fields(file);
let width = 20;
let builder = ListBuilder::new(|cx| {
let Some((k, v)) = &items.get(cx.index) else {
return (Paragraph::new(""), 1);
};
let contents = serde_json::to_string_pretty(&v)
.unwrap_or(String::new())
.replace("\n", "\n{:width$}");
let mut res = Paragraph::new(format!("{k:width$} {contents}"))
.wrap(Wrap { trim: false });
if cx.is_selected {
res = res.style(highlighted);
}
let height = res.line_count(footer_area.width) as u16;
(res, height)
});
let list = ListView::new(builder, items.len());
StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list);
}
Tab::Empty => {}
Tab::CreateFilter { filter } => {
Clear.render(popup_area, buf);
let popup_area = {
let block = Block::bordered()
.title_top("create filter")
.style(default)
.padding(Padding::symmetric(3, 1))
.border_style(border_selected);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
inner
};
let [kind, matcher_kind, matcher_area, confirm] = Layout::vertical([
Constraint::Length(5),
Constraint::Length(5),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(popup_area);
let text = match &filter.kind {
None => "<unset>",
Some(FilterKind::Inline) => "inline items",
Some(FilterKind::Remove) => "remove item and sub-items",
};
Paragraph::new(format!("{text}"))
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::Kind) {
highlighted
} else {
default
},
)
.block(
Block::bordered()
.title_top("transformation")
.padding(Padding::uniform(1)),
)
.render(kind, buf);
let text = match filter.matcher.as_ref() {
None => "<unset>",
Some(WipMatcher::Field { .. }) => "all logs where field matches",
Some(WipMatcher::Specific { .. }) => "this specific log",
};
Paragraph::new(format!("{text}"))
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::MatcherKind) {
highlighted
} else {
default
},
)
.block(
Block::bordered()
.title_top("matcher")
.padding(Padding::uniform(1)),
)
.render(matcher_kind, buf);
match &filter.matcher {
Some(WipMatcher::Field { name, value }) => {
let [field_area, value_area, _] = Layout::vertical([
Constraint::Length(5),
Constraint::Length(5),
Constraint::Fill(1),
])
.areas(matcher_area);
Paragraph::new(format!("{}", name.clone().unwrap_or_default()))
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::Matcher)
{
highlighted
} else {
default
},
)
.block(
Block::bordered()
.title_top("field name")
.padding(Padding::uniform(1)),
)
.render(field_area, buf);
Paragraph::new(format!("{}", value.clone().unwrap_or_default()))
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::Matcher)
{
highlighted
} else {
default
},
)
.block(
Block::bordered()
.title_top("value")
.padding(Padding::uniform(1)),
)
.render(value_area, buf);
}
Some(WipMatcher::Specific { .. }) => {}
None => {}
}
Paragraph::new("confirm")
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::Confirm) {
highlighted
} else {
default
},
)
.render(confirm, buf);
}
}
}
}
}

134
src/tui/model.rs Normal file
View file

@ -0,0 +1,134 @@
use std::{collections::BTreeMap, rc::Rc, sync::OnceLock};
use jiff::Timestamp;
use ratatui::text::Line;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub enum Level {
#[serde(rename = "TRACE")]
Trace,
#[serde(rename = "DEBUG")]
Debug,
#[serde(rename = "INFO")]
Info,
#[serde(rename = "Warn")]
Warn,
#[serde(rename = "Error")]
Error,
}
#[derive(Debug)]
pub enum LogEntry {
Single {
raw: RawLogEntry,
},
Sub {
enter: RawLogEntry,
sub_entries: Vec<Rc<LogEntry>>,
exit: RawLogEntry,
count_sub: OnceLock<usize>,
},
}
impl LogEntry {
pub fn get(&self, index: usize) -> Option<Rc<LogEntry>> {
match self {
LogEntry::Single { .. } => None,
LogEntry::Sub { sub_entries, .. } => sub_entries.get(index).cloned(),
}
}
pub fn all_fields(&self) -> LogFields {
match self {
LogEntry::Single { raw } => raw.all_fields(),
LogEntry::Sub { enter, exit, .. } => enter.all_fields().merge(&exit.all_fields()),
}
}
pub fn count(&self) -> usize {
match self {
LogEntry::Single { .. } => 1,
LogEntry::Sub {
sub_entries,
count_sub,
..
} => {
*count_sub.get_or_init(|| sub_entries.iter().map(|i| i.count()).sum::<usize>() + 1)
}
}
}
pub fn line_text(&self, accessed: bool) -> Line<'static> {
const NO_MESSAGE: &str = "<no message>";
match self {
LogEntry::Single { raw } => {
format!("{}", raw.fields.message().unwrap_or(NO_MESSAGE)).into()
}
LogEntry::Sub {
enter, sub_entries, ..
} => {
if let Some(val) = enter.all_fields().fields.get("name")
&& let Some(s) = val.as_str()
{
Line::from(format!(
"{:3}⭣{:3}⇊ ┃↪ {s}",
sub_entries.len(),
self.count().wrapping_sub(1)
))
} else {
format!(
" ┃{}",
enter.fields.message().unwrap_or(NO_MESSAGE)
)
.into()
}
}
}
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct LogFields {
#[serde(flatten)]
pub fields: BTreeMap<String, serde_json::Value>,
}
impl LogFields {
pub fn message(&self) -> Option<&str> {
self.fields.get("message").and_then(|i| i.as_str())
}
pub fn merge(&self, other: &Self) -> Self {
Self {
fields: self
.fields
.iter()
.chain(other.fields.iter())
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
}
}
}
#[derive(Deserialize, Debug)]
pub struct RawLogEntry {
pub timestamp: Timestamp,
pub level: Level,
pub filename: String,
pub line_number: usize,
pub fields: LogFields,
#[serde(default)]
pub spans: Vec<LogFields>,
}
impl RawLogEntry {
pub fn all_fields(&self) -> LogFields {
let mut res = self.fields.clone();
for i in &self.spans {
res = res.merge(i);
}
res
}
}

163
src/tui/reader.rs Normal file
View file

@ -0,0 +1,163 @@
use std::{
fs::File,
io::{self, BufRead, BufReader},
mem,
path::{Path, PathBuf},
rc::Rc,
sync::OnceLock,
};
use crate::tui::{
filter::{Filter, FilterKind},
model::{LogEntry, RawLogEntry},
};
pub struct LogfileReader {
pub path: PathBuf,
file: BufReader<File>,
entries: Vec<Rc<LogEntry>>,
filters: Vec<Rc<Filter>>,
}
impl LogfileReader {
pub fn new(p: &Path) -> io::Result<Self> {
Ok(Self {
file: BufReader::new(File::open(p)?),
path: p.to_path_buf(),
entries: Vec::new(),
filters: Vec::new(),
})
}
pub fn add_filter(&mut self, filter: Filter) {
self.filters.push(Rc::new(filter));
}
fn next_line(&mut self) -> Option<String> {
let mut res = String::new();
match self.file.read_line(&mut res) {
Err(e) => {
eprintln!("error: {e:?}");
None
}
Ok(0) => None,
Ok(_) => Some(res),
}
}
fn next_raw_entry(&mut self) -> Option<RawLogEntry> {
let line = self.next_line()?;
match serde_json::from_str(&line) {
Ok(i) => Some(i),
Err(e) => {
eprintln!("deserializing: {e:?} in {line}");
None
}
}
}
fn next_entry(&mut self) -> Option<Rc<LogEntry>> {
let mut stack = Vec::<(RawLogEntry, Vec<Rc<LogEntry>>)>::new();
let mut curr = Vec::<Rc<LogEntry>>::new();
loop {
let entry = self.next_raw_entry()?;
let new_entry = Rc::new(match entry.fields.message() {
Some("enter") => {
stack.push((entry, mem::take(&mut curr)));
continue;
}
Some("exit") => {
// TODO: does it match?
let Some((enter, prev)) = stack.pop() else {
panic!("exit before entry");
};
let sub_entries = mem::replace(&mut curr, prev);
LogEntry::Sub {
enter: enter,
sub_entries,
exit: entry,
count_sub: OnceLock::new(),
}
}
_ => LogEntry::Single { raw: entry },
});
if stack.is_empty() {
return Some(new_entry);
} else {
curr.push(new_entry);
}
}
}
pub fn iter_from(&mut self, start: usize) -> FilterAdapter<EntryIterator<'_>> {
FilterAdapter {
filters: self.filters.clone(),
inner: EntryIterator {
curr: start,
reader: self,
},
}
}
fn add_next_entry(&mut self) -> Option<()> {
let entry = self.next_entry()?;
self.entries.push(entry);
Some(())
}
}
pub struct EntryIterator<'a> {
curr: usize,
reader: &'a mut LogfileReader,
}
impl<'a> Iterator for EntryIterator<'a> {
type Item = Rc<LogEntry>;
fn next(&mut self) -> Option<Self::Item> {
while self.reader.entries.len() <= self.curr {
self.reader.add_next_entry()?;
}
let res = Rc::clone(&self.reader.entries[self.curr]);
self.curr += 1;
Some(res)
}
}
pub struct FilterAdapter<I> {
filters: Vec<Rc<Filter>>,
inner: I,
}
impl<I: IntoIterator> FilterAdapter<I> {
pub fn new(file: &LogfileReader, inner: I) -> FilterAdapter<I::IntoIter> {
Self {
filters: file.filters.clone(),
inner: inner.into_iter(),
}
}
}
impl<I: Iterator> Iterator for FilterAdapter<I> {
type Item = I::Item;
fn next(&mut self) -> Option<Self::Item> {
'next_entry: loop {
let res = self.inner.next()?;
for filter in &self.reader.filters {
if let FilterKind::Remove = filter.kind
&& filter.matcher.matches(&res)
{
continue 'next_entry;
}
}
break Some(res);
}
}
}