binocular-cli 0.2.3

Not exactly a telescope, but it's useful sometimes. TUI to search/navigate through files and workspaces.
Documentation
pub mod actions;
mod detect;
mod filter;
mod format;
mod parse;
pub mod reducer;
mod types;
pub(crate) mod ui;
pub mod watcher;

use crate::app::{App, AppAction};
use crate::preview::structured_log::actions::LogViewerOutcome;
use crate::preview::{PreviewContent, PreviewSource};
use crossterm::event::KeyEvent;

pub const DEFAULT_MAX_ENTRIES: usize = 100_000;
pub const STDIN_STREAM_PATH: &str = "<stdin>";
pub use detect::detect_structured_log;
pub use filter::{parse_epoch_secs, parse_filters};
pub use format::format_entry_visible;
pub use parse::{parse_initial, parse_line};
pub use types::{
    ColModal, ColumnConfig, FilterOp, LogEntry, LogFilter, LogFilterState, LogFormat, StructuredLog,
};

pub fn preview_content(log: StructuredLog) -> PreviewContent {
    reducer::preview_content(log)
}

pub fn initialize_empty_stream(app: &mut App, path: String, format: LogFormat) {
    app.preview_session.preview.source = Some(PreviewSource::LogStream(path));
    app.preview_session.preview.content = Some(preview_content(StructuredLog {
        entries: vec![],
        total_lines: 0,
        all_fields: vec![],
        format,
    }));
}

pub fn handle_input(app: &mut App, key: KeyEvent) {
    let Some(PreviewContent::StructuredLog(lp)) = &mut app.preview_session.preview.content else {
        return;
    };

    let Some(action) = actions::action_for_key(lp, key) else {
        return;
    };

    match reducer::apply_action(lp, action, app.runtime.run.log) {
        LogViewerOutcome::None => {}
        LogViewerOutcome::ExitApp => app.apply_action(AppAction::Quit),
        LogViewerOutcome::FocusSearch => {
            app.ui.layout.preview_fullscreen = false;
            app.apply_action(AppAction::FocusSearch);
        }
    }
}

pub fn apply_append(app: &mut App, path: &str, entries: Vec<LogEntry>, max_entries: usize) {
    let Some(PreviewSource::LogStream(active_path)) = app.preview_session.preview.source.as_ref()
    else {
        return;
    };

    let is_valid_source = path == active_path
        || app
            .runtime
            .run
            .log_files
            .iter()
            .any(|f| f.to_str() == Some(path));
    if !is_valid_source {
        return;
    }

    let Some(PreviewContent::StructuredLog(lp)) = &mut app.preview_session.preview.content else {
        return;
    };

    reducer::append_entries(lp, entries, max_entries);
}

pub fn init_visible_cols(fields: &[String], entries: &[LogEntry]) -> Vec<ColumnConfig> {
    const WIDTH_SAMPLE: usize = 200;
    const MAX_COL_WIDTH: usize = 40;
    const MIN_COL_WIDTH: usize = 6;
    const MSG_FIELDS: &[&str] = &["msg", "message", "text", "body", "log"];

    let mut widths: Vec<usize> = fields
        .iter()
        .map(|f| f.chars().count().max(MIN_COL_WIDTH))
        .collect();

    for entry in entries.iter().take(WIDTH_SAMPLE) {
        for (col_i, field) in fields.iter().enumerate() {
            if let Some((_, v)) = entry.fields.iter().find(|(k, _)| k == field) {
                let len = v.chars().count().min(MAX_COL_WIDTH);
                if len > widths[col_i] {
                    widths[col_i] = len;
                }
            }
        }
    }

    for w in &mut widths {
        *w = (*w).min(MAX_COL_WIDTH);
    }

    for (i, field) in fields.iter().enumerate() {
        let lower = field.to_ascii_lowercase();
        if MSG_FIELDS.iter().any(|m| *m == lower.as_str()) {
            widths[i] = widths[i].max(40).min(60);
        }
    }

    fields
        .iter()
        .zip(widths.iter())
        .map(|(f, &w)| ColumnConfig {
            field: f.clone(),
            width: w,
        })
        .collect()
}

pub fn update_fields(all_fields: &mut Vec<String>, entry: &LogEntry) {
    for (k, _) in &entry.fields {
        if !all_fields.iter().any(|f| f == k) {
            all_fields.push(k.clone());
        }
    }
}