bugfixing and testing

This commit is contained in:
Jana Dönszelmann 2026-03-31 15:06:56 +02:00
parent e490a2ce04
commit d0bc7e952c
No known key found for this signature in database
5 changed files with 411 additions and 109 deletions

View file

@ -20,20 +20,22 @@ pub struct Filters {
undo_pos: usize,
#[serde(skip)]
path: PathBuf,
path: Option<PathBuf>,
#[serde(skip)]
error: Option<LastError>,
}
impl Filters {
pub fn new(path: PathBuf, error: LastError) -> Self {
if path.exists() {
pub fn new(path: Option<PathBuf>, error: LastError) -> Self {
if let Some(path) = &path
&& path.exists()
{
match File::open(&path) {
Ok(f) => match serde_json::from_reader(f) {
Ok(i) => {
return Self {
error: Some(error),
path,
path: Some(path.clone()),
..i
};
}
@ -66,18 +68,19 @@ impl Filters {
fn filters_changed(&self) {
self.direct_children_cache.lock().unwrap().clear();
self.all_children_cache.lock().unwrap().clear();
if let Some(path) = &self.path {
match File::options()
.create(true)
.write(true)
.truncate(true)
.open(&self.path)
.open(&path)
{
Ok(f) => {
if let Err(e) = serde_json::to_writer(f, self) {
self.error.as_ref().inspect(|i| {
i.set(format!(
"failed to write contents of filter file at {}: {e}",
self.path.display()
path.display()
));
});
}
@ -86,12 +89,13 @@ impl Filters {
self.error.as_ref().inspect(|i| {
i.set(format!(
"failed to open filter file at {}: {e}",
self.path.display()
path.display()
));
});
}
}
}
}
pub fn get(&self) -> &[Arc<Filter>] {
&self.filters[0..self.undo_pos]

View file

@ -7,7 +7,7 @@ use crate::tui::{
input::{FieldMatcher, InputState, InputTarget},
view::LogView,
},
model::LogEntry,
model::{LogEntry, id},
processing::Cursor,
widgets::last_error::LastError,
};
@ -19,7 +19,7 @@ pub mod input;
pub mod view;
pub struct LogViewer {
cache: HashMap<Vec<usize>, LogView>,
cache: HashMap<usize, usize>,
pub view: LogView,
@ -35,7 +35,7 @@ pub struct LogViewer {
impl LogViewer {
pub fn new(start: Gc<LogEntry>, filters_path: PathBuf, error: LastError) -> Self {
let filters = Filters::new(filters_path, error);
let filters = Filters::new(Some(filters_path), error);
Self {
view: LogView {
cursor: Cursor::new(start),
@ -200,8 +200,29 @@ impl LogViewer {
}
}
fn update_offset_from_cache(&mut self) {
let cache_key = self.view.cursor.parent().as_ref().map(id).unwrap_or(0);
self.view.selection_offset = 0;
if let Some(offset) = self.cache.get(&cache_key) {
for _ in 0..*offset {
self.view.selection_offset += 1;
self.view.cursor.prev(&self.filters);
}
}
}
fn add_to_cache(&mut self) {
let cache_key = self.view.cursor.parent().as_ref().map(id).unwrap_or(0);
self.cache.insert(cache_key, self.view.selection_offset);
}
pub fn back(&mut self) {
self.view.cursor.exit(&self.filters);
self.add_to_cache();
if self.view.cursor.exit(&self.filters) {
self.update_offset_from_cache();
self.view.cursor.prev(&self.filters);
}
// self.cache.insert(self.path(), self.curr.clone());
self.input_state.reset();
}
@ -217,38 +238,18 @@ impl LogViewer {
pub fn enter(&mut self) {
match self.input_state {
InputState::None => {
self.add_to_cache();
let orig = self.view.cursor.clone();
for _ in 0..(self.view.selection_offset + 1) {
self.view.cursor.next(&self.filters);
}
self.view.cursor.enter(&self.filters);
// let Some((s, _)) = self.selected() else {
// return;
// };
// let Some(i) = s.from_start(0).map(|i| i.with_filters(self.filters.get())) else {
// return;
// };
//
// if i.clone().next(self.filters.get()).is_none() {
// return;
// }
// if i.clone()
// .next(self.filters.get())
// .is_some_and(|(i, _)| i.is_return())
// {
// return;
// }
//
// self.stack.push(mem::replace(
// &mut self.curr,
// LogView {
// cursor: i,
// selection_offset: 0,
// },
// ));
// if let Some(cached_view) = self.cache.get(&self.path()) {
// self.curr = cached_view.clone();
// }
if self.view.cursor.enter(&self.filters) {
self.update_offset_from_cache();
} else {
self.view.cursor = orig;
}
}
InputState::Target(InputTarget::Fields(None)) => {
self.input_state =

View file

@ -2,8 +2,7 @@ use std::{
collections::BTreeMap,
hash::{DefaultHasher, Hash, Hasher},
path::PathBuf,
sync::{Mutex, OnceLock},
thread,
sync::OnceLock,
};
use dumpster::{Trace, TraceWith, Visitor, sync::Gc};
@ -28,7 +27,7 @@ pub fn pretty_print_value(v: &Value) -> String {
}
}
fn id(input: &Gc<LogEntry>) -> usize {
pub fn id(input: &Gc<LogEntry>) -> usize {
Gc::as_ptr(input) as usize
}

View file

@ -1,11 +1,9 @@
use std::{mem, sync::Arc};
pub type FilterList = [Arc<Filter>];
use std::mem;
use dumpster::sync::Gc;
use itertools::Itertools;
use crate::tui::{filter::Filter, log_viewer::filters::Filters, model::LogEntry};
use crate::tui::{log_viewer::filters::Filters, model::LogEntry};
#[derive(Clone)]
pub struct CursorMeta {
@ -62,8 +60,16 @@ impl Cursor {
pub fn update(&mut self, filters: &Filters) -> bool {
// make a backup now
let old = self.clone();
// if the current one is inlined
if self.curr_is_inlined(filters) {
// try going one back
self.prev(filters);
self.next(filters);
}
// if the current one is removed...
if self.curr_is_removed(filters) {
if self.curr_is_removed(filters) || self.curr_is_inlined(filters) {
// try going forwards to the next visible node
if self.next(filters) {
return true;
@ -92,6 +98,10 @@ impl Cursor {
self.curr.entry.clone()
}
pub fn parent(&self) -> Option<Gc<LogEntry>> {
self.parents.last().map(|i| i.entry.clone())
}
fn curr_is_removed(&self, filters: &Filters) -> bool {
self.curr().is_removed(filters)
}
@ -205,7 +215,6 @@ impl Cursor {
}
pub fn prev(&mut self, filters: &Filters) -> bool {
loop {
if !self.prev_cont_in_parent(filters) {
return false;
}
@ -216,11 +225,10 @@ impl Cursor {
self.enter_end_internal();
self.curr.continue_in_parent = true;
}
self.update(filters)
}
pub fn next(&mut self, filters: &Filters) -> bool {
loop {
if !self.next_cont_in_parent(filters) {
return false;
}
@ -231,7 +239,7 @@ impl Cursor {
self.enter_start_internal();
self.curr.continue_in_parent = true;
}
self.update(filters)
}
pub fn enter(&mut self, filters: &Filters) -> bool {
@ -249,6 +257,10 @@ impl Cursor {
}
pub fn exit(&mut self, filters: &Filters) -> bool {
if self.curr.continue_in_parent {
return false;
}
if !self.exit_internal() {
return false;
}

View file

@ -1,15 +1,12 @@
use std::{
cell::RefCell,
fs::File,
io::{self, BufRead, BufReader},
io::{self, BufRead, BufReader, Read},
mem,
path::{Path, PathBuf},
rc::Rc,
sync::{
OnceLock,
mpsc::{Receiver, channel},
},
thread,
sync::OnceLock,
thread::{self, JoinHandle},
};
use dumpster::sync::Gc;
@ -17,7 +14,7 @@ use dumpster::sync::Gc;
use crate::tui::model::{ChildInfo, LogEntry, RawLogEntry};
struct LogFileEntryGenerator {
file: BufReader<File>,
file: BufReader<Box<dyn Read + Send + Sync + 'static>>,
}
impl LogFileEntryGenerator {
@ -38,7 +35,11 @@ impl LogFileEntryGenerator {
match serde_json::from_str(&line) {
Ok(i) => Some(i),
Err(e) => {
#[cfg(test)]
panic!("deserializing: {e:?} in {line}");
#[cfg(not(test))]
eprintln!("deserializing: {e:?} in {line}");
#[cfg(not(test))]
None
}
}
@ -106,7 +107,7 @@ impl LogFileEntryGenerator {
struct Inner {
pub path: PathBuf,
pub jh: Option<JoinHandle<()>>,
first: Option<Gc<LogEntry>>,
}
@ -114,17 +115,21 @@ struct Inner {
pub struct LogfileReader(Rc<RefCell<Inner>>);
impl LogfileReader {
pub fn new(p: &Path) -> io::Result<Self> {
let file = File::open(p)?;
pub fn new(p: impl AsRef<Path>) -> io::Result<Self> {
Ok(Self::new_from_read(File::open(p.as_ref())?, p))
}
pub fn new_from_read(r: impl Read + Send + Sync + 'static, p: impl AsRef<Path>) -> Self {
let mut generator = LogFileEntryGenerator {
file: BufReader::new(file),
file: BufReader::new(Box::new(r)),
};
let first = generator.next_entry(None);
let jh = thread::spawn({
let first = first.clone();
move || {
if let Some(mut curr_last) = first.clone() {
thread::spawn(move || {
while let Some(new) = generator.next_entry(Some(curr_last.clone())) {
assert!(new.prev().is_some());
curr_last.initialize_next().get_or_init({
@ -133,13 +138,15 @@ impl LogfileReader {
});
curr_last = new;
}
});
}
}
});
Ok(Self(Rc::new(RefCell::new(Inner {
path: p.to_path_buf(),
Self(Rc::new(RefCell::new(Inner {
path: p.as_ref().to_path_buf(),
first,
}))))
jh: Some(jh),
})))
}
pub fn path(&self) -> PathBuf {
@ -150,3 +157,282 @@ impl LogfileReader {
self.0.borrow().first.clone()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::tui::{
filter::{Filter, FilterKind, Matcher, MatcherValue},
log_viewer::filters::Filters,
processing::Cursor,
widgets::last_error::LastError,
};
fn parse(data: &str) -> Cursor {
let r = LogfileReader::new_from_read(
std::io::Cursor::new(data.trim().as_bytes().to_vec()),
"test",
);
r.0.borrow_mut().jh.take().unwrap().join().unwrap();
Cursor::new(r.first().unwrap())
}
fn with_fields(fields: &str) -> String {
format!(
r#"{{"timestamp": "2026-03-30 23:47Z", "level": "DEBUG", "filename": "foo", "line_number": 30, "fields": {fields}}}"#
)
}
fn filters() -> Filters {
let le = LastError::new();
Filters::new(None, le)
}
#[test]
fn get_message() {
let c = parse(&[with_fields(r#"{"message": "foo"}"#)].join("\n"));
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
}
#[test]
fn bounds() {
let mut c = parse(&[with_fields(r#"{"message": "foo"}"#)].join("\n"));
let f = filters();
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(!c.next(&f));
assert!(!c.prev(&f));
assert!(!c.enter(&f));
assert!(!c.exit(&f));
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
}
#[test]
fn two_singles() {
let mut c = parse(
&[
with_fields(r#"{"message": "foo"}"#),
with_fields(r#"{"message": "bar"}"#),
]
.join("\n"),
);
let f = filters();
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("bar".to_string()));
assert!(!c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("bar".to_string()));
assert!(c.prev(&f));
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(!c.prev(&f));
assert!(!c.enter(&f));
assert!(!c.exit(&f));
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
}
#[test]
fn enter_exit() {
let mut c = parse(
&[
with_fields(r#"{"message": "foo"}"#),
with_fields(r#"{"message": "enter"}"#),
with_fields(r#"{"message": "baz"}"#),
with_fields(r#"{"message": "meow"}"#),
with_fields(r#"{"message": "exit"}"#),
with_fields(r#"{"message": "bar"}"#),
]
.join("\n"),
);
let f = filters();
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(c.next(&f));
assert!(matches!(Gc::as_ref(&c.curr()), LogEntry::Sub { .. }));
assert!(c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("bar".to_string()));
assert!(c.prev(&f));
assert!(c.enter(&f));
assert_eq!(c.curr().message_or_name(), Some("baz".to_string()));
assert!(!c.enter(&f));
assert!(!c.prev(&f));
assert!(c.exit(&f));
assert!(matches!(Gc::as_ref(&c.curr()), LogEntry::Sub { .. }));
assert!(c.enter(&f));
assert!(c.next(&f));
assert!(!c.enter(&f));
assert!(!c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("meow".to_string()));
assert!(c.exit(&f));
}
#[test]
fn empty_enter() {
let mut c = parse(
&[
with_fields(r#"{"message": "foo"}"#),
with_fields(r#"{"message": "enter"}"#),
with_fields(r#"{"message": "exit"}"#),
with_fields(r#"{"message": "bar"}"#),
]
.join("\n"),
);
let f = filters();
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(c.next(&f));
assert!(
matches!(Gc::as_ref(&c.curr()), LogEntry::Sub { children, .. } if children.first_child.is_none() )
);
assert!(!c.enter(&f));
assert!(!c.exit(&f));
assert!(matches!(Gc::as_ref(&c.curr()), LogEntry::Sub { .. }));
assert!(c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("bar".to_string()));
}
#[test]
fn remove_first() {
let mut c = parse(
&[
with_fields(r#"{"message": "foo"}"#),
with_fields(r#"{"message": "baz"}"#),
with_fields(r#"{"message": "meow"}"#),
with_fields(r#"{"message": "bar"}"#),
]
.join("\n"),
);
let mut f = filters();
f.push(Arc::new(Filter {
matcher: Matcher::Field {
name: "message".to_string(),
value: MatcherValue::Exact("foo".to_string()),
},
kind: FilterKind::Remove,
}));
assert!(c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("baz".to_string()));
assert!(!c.prev(&f));
}
#[test]
fn remove_middle() {
let mut c = parse(
&[
with_fields(r#"{"message": "foo"}"#),
with_fields(r#"{"message": "baz"}"#),
with_fields(r#"{"message": "meow"}"#),
with_fields(r#"{"message": "bar"}"#),
]
.join("\n"),
);
let mut f = filters();
f.push(Arc::new(Filter {
matcher: Matcher::Field {
name: "message".to_string(),
value: MatcherValue::Exact("baz".to_string()),
},
kind: FilterKind::Remove,
}));
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("meow".to_string()));
assert!(c.prev(&f));
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(!c.prev(&f));
}
#[test]
fn inline() {
let mut c = parse(
&[
with_fields(r#"{"message": "foo"}"#),
with_fields(r#"{"message": "enter", "name": "nest"}"#),
with_fields(r#"{"message": "baz"}"#),
with_fields(r#"{"message": "meow"}"#),
with_fields(r#"{"message": "exit"}"#),
with_fields(r#"{"message": "bar"}"#),
]
.join("\n"),
);
let mut f = filters();
f.push(Arc::new(Filter {
matcher: Matcher::Field {
name: "name".to_string(),
value: MatcherValue::Exact("nest".to_string()),
},
kind: FilterKind::Inline,
}));
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("baz".to_string()));
assert!(c.next(&f));
assert!(!c.exit(&f));
assert_eq!(c.curr().message_or_name(), Some("meow".to_string()));
assert!(c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("bar".to_string()));
assert!(c.prev(&f));
assert_eq!(c.curr().message_or_name(), Some("meow".to_string()));
assert!(!c.exit(&f));
assert!(c.prev(&f));
assert_eq!(c.curr().message_or_name(), Some("baz".to_string()));
assert!(c.prev(&f));
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(!c.prev(&f));
}
#[test]
fn inline_while_inside() {
let mut c = parse(
&[
with_fields(r#"{"message": "foo"}"#),
with_fields(r#"{"message": "enter", "name": "nest"}"#),
with_fields(r#"{"message": "baz"}"#),
with_fields(r#"{"message": "meow"}"#),
with_fields(r#"{"message": "exit"}"#),
with_fields(r#"{"message": "bar"}"#),
]
.join("\n"),
);
let mut f = filters();
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(c.next(&f));
assert!(c.enter(&f));
assert_eq!(c.curr().message_or_name(), Some("baz".to_string()));
// inline the current item
f.push(Arc::new(Filter {
matcher: Matcher::Field {
name: "name".to_string(),
value: MatcherValue::Exact("nest".to_string()),
},
kind: FilterKind::Inline,
}));
c.update_with_parents(&f);
assert_eq!(c.curr().message_or_name(), Some("baz".to_string()));
println!("undo");
f.undo();
c.update_with_parents(&f);
assert_eq!(c.curr().message_or_name(), Some("nest".to_string()));
assert!(c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("bar".to_string()));
assert!(!c.next(&f));
assert!(c.prev(&f));
assert!(c.enter(&f));
assert_eq!(c.curr().message_or_name(), Some("baz".to_string()));
assert!(c.next(&f));
assert_eq!(c.curr().message_or_name(), Some("meow".to_string()));
f.redo();
c.update_with_parents(&f);
// redo inlines, and goes to start of inlined part
assert_eq!(c.curr().message_or_name(), Some("baz".to_string()));
assert!(c.prev(&f));
assert_eq!(c.curr().message_or_name(), Some("foo".to_string()));
assert!(!c.prev(&f));
}
}