├── src ├── ui │ ├── mod.rs │ ├── interface.rs │ └── scroll.rs ├── communication │ ├── mod.rs │ ├── handlers │ │ ├── mod.rs │ │ ├── handler.rs │ │ ├── processor.rs │ │ ├── normal.rs │ │ ├── multiple_choice.rs │ │ ├── user_input.rs │ │ ├── startup.rs │ │ ├── regex.rs │ │ └── command.rs │ └── input.rs ├── extensions │ ├── mod.rs │ ├── extension.rs │ └── session.rs ├── constants │ ├── mod.rs │ ├── resolver.rs │ ├── directories.rs │ ├── app.rs │ └── cli.rs ├── util │ ├── aggregators │ │ ├── mod.rs │ │ ├── none.rs │ │ ├── sum.rs │ │ ├── mean.rs │ │ ├── aggregator.rs │ │ ├── counter.rs │ │ └── date.rs │ ├── types.rs │ ├── credits.rs │ ├── mod.rs │ ├── options.rs │ ├── error.rs │ ├── binary_search.rs │ ├── sanitizers.rs │ ├── poll.rs │ └── history.rs └── main.rs ├── resources ├── img │ ├── 333745.png │ └── e63462.png ├── branding │ ├── logria.ai │ ├── logria.png │ ├── logria.svg │ ├── logria.txt │ └── logria.svg.txt └── screenshots │ ├── regex.png │ ├── logria.png │ ├── parser.png │ └── aggregation.png ├── .gitignore ├── docs ├── examples │ ├── sessions │ │ ├── File - readme │ │ ├── File - Sample Access Log │ │ └── Cmd - Generate Test Logs │ └── patterns │ │ ├── Hyphen Separated │ │ ├── Color + Hyphen Separated │ │ └── Common Log Format ├── input_handler.md ├── commands.md ├── sessions.md ├── README.md └── parsers.md ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── Cargo.toml └── README.md /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod interface; 2 | pub mod scroll; 3 | -------------------------------------------------------------------------------- /src/communication/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod handlers; 2 | pub mod input; 3 | pub mod reader; 4 | -------------------------------------------------------------------------------- /src/extensions/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod extension; 2 | pub mod parser; 3 | pub mod session; 4 | -------------------------------------------------------------------------------- /resources/img/333745.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReagentX/Logria/HEAD/resources/img/333745.png -------------------------------------------------------------------------------- /resources/img/e63462.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReagentX/Logria/HEAD/resources/img/e63462.png -------------------------------------------------------------------------------- /src/constants/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod cli; 3 | pub mod directories; 4 | pub mod resolver; 5 | -------------------------------------------------------------------------------- /resources/branding/logria.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReagentX/Logria/HEAD/resources/branding/logria.ai -------------------------------------------------------------------------------- /resources/branding/logria.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReagentX/Logria/HEAD/resources/branding/logria.png -------------------------------------------------------------------------------- /resources/screenshots/regex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReagentX/Logria/HEAD/resources/screenshots/regex.png -------------------------------------------------------------------------------- /resources/screenshots/logria.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReagentX/Logria/HEAD/resources/screenshots/logria.png -------------------------------------------------------------------------------- /resources/screenshots/parser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReagentX/Logria/HEAD/resources/screenshots/parser.png -------------------------------------------------------------------------------- /resources/screenshots/aggregation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReagentX/Logria/HEAD/resources/screenshots/aggregation.png -------------------------------------------------------------------------------- /src/util/aggregators/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod aggregator; 2 | pub mod counter; 3 | pub mod date; 4 | pub mod mean; 5 | pub mod none; 6 | pub mod sum; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment 2 | .vscode/ 3 | .gitattributes 4 | 5 | # Build directories 6 | /target 7 | /output 8 | 9 | # OS Files 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /docs/examples/sessions/File - readme: -------------------------------------------------------------------------------- 1 | { 2 | "commands": [ 3 | "~/Code/Rust/logria/README.md" 4 | ], 5 | "stream_type": "File" 6 | } 7 | -------------------------------------------------------------------------------- /src/util/types.rs: -------------------------------------------------------------------------------- 1 | use crate::util::error::LogriaError; 2 | use std::result::Result; 3 | 4 | pub type Del = Option Result<(), LogriaError>>; 5 | -------------------------------------------------------------------------------- /docs/examples/sessions/File - Sample Access Log: -------------------------------------------------------------------------------- 1 | { 2 | "commands": [ 3 | "/var/nginx/logs/accesslog" 4 | ], 5 | "stream_type": "File" 6 | } 7 | -------------------------------------------------------------------------------- /src/util/credits.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::app::LOGRIA; 2 | 3 | pub fn gen_credits() -> Vec { 4 | LOGRIA 5 | .into_iter() 6 | .map(std::borrow::ToOwned::to_owned) 7 | .collect() 8 | } 9 | -------------------------------------------------------------------------------- /src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod aggregators; 2 | pub mod binary_search; 3 | pub mod credits; 4 | pub mod error; 5 | pub mod history; 6 | pub mod options; 7 | pub mod poll; 8 | pub mod sanitizers; 9 | pub mod types; 10 | -------------------------------------------------------------------------------- /src/communication/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod command; 2 | pub mod handler; 3 | pub mod highlight; 4 | pub mod multiple_choice; 5 | pub mod normal; 6 | pub mod parser; 7 | pub mod processor; 8 | pub mod regex; 9 | pub mod startup; 10 | pub mod user_input; 11 | -------------------------------------------------------------------------------- /docs/examples/sessions/Cmd - Generate Test Logs: -------------------------------------------------------------------------------- 1 | { 2 | "commands": [ 3 | "/bin/python3 ~/example/sample_streams/generate_test_logs.py", 4 | "/bin/python3 ~/example/sample_streams/generate_test_logs_2.py" 5 | ], 6 | "stream_type": "Command" 7 | } 8 | -------------------------------------------------------------------------------- /src/communication/handlers/handler.rs: -------------------------------------------------------------------------------- 1 | use std::io::Result; 2 | 3 | use crossterm::event::KeyCode; 4 | 5 | use crate::communication::reader::MainWindow; 6 | 7 | pub trait Handler { 8 | fn new() -> Self; 9 | fn receive_input(&mut self, window: &mut MainWindow, key: KeyCode) -> Result<()>; 10 | } 11 | -------------------------------------------------------------------------------- /src/extensions/extension.rs: -------------------------------------------------------------------------------- 1 | use crate::util::error::LogriaError; 2 | use std::result::Result; 3 | 4 | pub trait ExtensionMethods { 5 | fn verify_path(); 6 | fn save(self, file_name: &str) -> Result<(), LogriaError>; 7 | fn del(items: &[usize]) -> Result<(), LogriaError>; 8 | fn list_full() -> Vec; 9 | fn list_clean() -> Vec; 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - release 7 | - develop 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | Test: 14 | name: Logria Tests 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - run: rustup update stable && rustup default stable 20 | - run: cargo clippy 21 | - run: cargo test --verbose 22 | -------------------------------------------------------------------------------- /src/util/aggregators/none.rs: -------------------------------------------------------------------------------- 1 | use crate::util::{aggregators::aggregator::Aggregator, error::LogriaError}; 2 | 3 | pub struct NoneAg {} 4 | 5 | impl Aggregator for NoneAg { 6 | fn update(&mut self, _: &str) -> Result<(), LogriaError> { 7 | Ok(()) 8 | } 9 | 10 | fn messages(&self, _: &usize) -> Vec { 11 | vec![" Disabled".to_owned()] 12 | } 13 | 14 | fn reset(&mut self) {} 15 | } 16 | 17 | impl NoneAg { 18 | pub fn new() -> Self { 19 | NoneAg {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/examples/patterns/Hyphen Separated: -------------------------------------------------------------------------------- 1 | { 2 | "pattern": " - ", 3 | "pattern_type": "Split", 4 | "example": "2005-03-19 15:10:26,773 - simple_example - CRITICAL - critical message", 5 | "order": [ 6 | "Timestamp", 7 | "Method", 8 | "Level", 9 | "Message" 10 | ], 11 | "aggregation_methods": { 12 | "Timestamp": { 13 | "Date": "[year]-[month]-[day]" 14 | }, 15 | "Method": "Count", 16 | "Level": "Count", 17 | "Message": "Sum" 18 | } 19 | } -------------------------------------------------------------------------------- /src/constants/resolver.rs: -------------------------------------------------------------------------------- 1 | use dirs::config_dir; 2 | use std::env; 3 | 4 | pub fn get_home_dir() -> String { 5 | match env::var("LOGRIA_USER_HOME") { 6 | Ok(val) => val, 7 | Err(_) => config_dir() 8 | .expect("Unable to start application: home directory not resolved!") 9 | .to_str() 10 | .expect("Home directory path is badly malformed!") 11 | .to_string(), 12 | } 13 | } 14 | 15 | pub fn get_env_var_or_default(var_name: &str, default: &'static str) -> String { 16 | match env::var(var_name) { 17 | Ok(val) => val, 18 | Err(_) => default.to_owned(), 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/examples/patterns/Color + Hyphen Separated: -------------------------------------------------------------------------------- 1 | { 2 | "pattern": "- ", 3 | "pattern_type": "Split", 4 | "example": "\u001b[33m2020-02-04 19:06:52,852 \u001b[0m- \u001b[34m__main__. \u001b[0m- \u001b[32mMainProcess \u001b[0m- \u001b[36mINFO \u001b[0m- I am a log! 91", 5 | "order": [ 6 | "Timestamp", 7 | "Method", 8 | "Level", 9 | "Message" 10 | ], 11 | "aggregation_methods": { 12 | "Timestamp": { 13 | "Date": "[year]-[month]-[day]" 14 | }, 15 | "Method": "Count", 16 | "Process": "Count", 17 | "Level": "Count", 18 | "Message": "Mean" 19 | } 20 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | Release: 12 | name: Logria Release 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - run: rustup update stable && rustup default stable 18 | - run: cargo test --verbose 19 | - run: | 20 | export VERSION=${{ github.event.release.tag_name }} 21 | sed -i "s/0.0.0/$VERSION/g" Cargo.toml 22 | cargo publish --allow-dirty 23 | env: 24 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 25 | -------------------------------------------------------------------------------- /docs/examples/patterns/Common Log Format: -------------------------------------------------------------------------------- 1 | { 2 | "pattern": "([^ ]*) ([^ ]*) ([^ ]*) \\[([^]]*)\\] \"([^\"]*)\" ([^ ]*) ([^ ]*)", 3 | "pattern_type": "Regex", 4 | "example": "127.0.0.1 user-identifier user-name [10/Oct/2000:13:55:36 -0700] \"GET /apache_pb.gif HTTP/1.0\" 200 2326", 5 | "order": [ 6 | "Remote Host", 7 | "User ID", 8 | "Username", 9 | "Date", 10 | "Request", 11 | "Status", 12 | "Size" 13 | ], 14 | "aggregation_methods": { 15 | "Remote Host": "Count", 16 | "User ID": "Count", 17 | "Username": "Count", 18 | "Date": "Count", 19 | "Request": "Count", 20 | "Status": "Count", 21 | "Size": "Count" 22 | } 23 | } -------------------------------------------------------------------------------- /docs/input_handler.md: -------------------------------------------------------------------------------- 1 | # Input Handler Documentation 2 | 3 | Input handlers run in processes parallel to the main process and communicate using Rust's `mpsc` module. Each struct that implements the `Input` trait has a method that creates two sets of `mpsc` channels, one for `stdin` and one for `stdout`. The data sent through these channels are stored until the main process can read from them. 4 | 5 | ## `CommandInput` 6 | 7 | Given a command, use `tokio`'s `command` module to open process and read its `stderr` and `stdout` into their respective `mpsc` channels. 8 | 9 | ## `FileInput` 10 | 11 | Given a file path, read the file and send the output to the `stdout` queue. 12 | 13 | Creating a `FileInput()` with `"sample_streams/accesslog"` will read in the contents of `sample_streams/accesslog` to the `stdout` queue. The path is parsed relative to the current directory when starting `logria`. 14 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | 3 | use std::io::Result; 4 | 5 | mod communication; 6 | mod constants; 7 | mod extensions; 8 | mod ui; 9 | mod util; 10 | 11 | use communication::reader::MainWindow; 12 | use constants::{cli::messages::DOCS, directories::print_paths}; 13 | use util::options::from_command_line; 14 | 15 | fn main() -> Result<()> { 16 | // Get options from command line 17 | let options = from_command_line(); 18 | if options.get_flag("docs") { 19 | println!("{DOCS}"); 20 | } else if options.get_flag("paths") { 21 | print_paths(); 22 | } else { 23 | let history = !options.get_flag("tape"); 24 | let smart_poll_rate = !options.get_flag("mindless"); 25 | let exec: Option> = match options.try_get_one("exec") { 26 | Ok(cmd) => cmd.map(|text: &String| vec![text.to_string()]), 27 | Err(_) => None, 28 | }; 29 | 30 | // Start app 31 | let mut app = MainWindow::new(history, smart_poll_rate); 32 | app.start(exec)?; 33 | } 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Christopher Sardegna "] 3 | categories = ["command-line-interface", "command-line-utilities"] 4 | description = "A powerful CLI tool that puts log analytics at your fingertips." 5 | edition = "2024" 6 | exclude = ["/resources", ".github", "docs", "build.sh"] 7 | keywords = ["cli", "tui", "logs", "log-parsing", "log-analytics"] 8 | license = "GPL-3.0-or-later" 9 | name = "logria" 10 | readme = "README.md" 11 | repository = "https://github.com/ReagentX/Logria" 12 | version = "0.0.0" 13 | 14 | [dependencies] 15 | clap = { version = "=4.5.40", features = ["cargo"] } 16 | crossterm = "=0.29.0" 17 | dirs = "=6.0.0" 18 | is_executable = "=1.0.4" 19 | regex = "=1.11.1" 20 | serde = { version = "=1.0.219", features = ["derive"] } 21 | serde_json = "=1.0.140" 22 | time = { version = "=0.3.41", features = ["formatting", "parsing"] } 23 | 24 | [profile.release] 25 | # Perform Link Time Optimization 26 | lto = true 27 | # Use a single codegen unit for the entire crate 28 | codegen-units = 1 29 | # Do not unwind stack on crash 30 | panic = "abort" 31 | -------------------------------------------------------------------------------- /src/communication/handlers/processor.rs: -------------------------------------------------------------------------------- 1 | use std::io::Result; 2 | 3 | use crate::communication::reader::MainWindow; 4 | 5 | pub trait ProcessorMethods { 6 | fn return_to_normal(&mut self, window: &mut MainWindow) -> Result<()>; 7 | fn clear_matches(&mut self, window: &mut MainWindow) -> Result<()>; 8 | fn process_matches(&mut self, window: &mut MainWindow) -> Result<()>; 9 | } 10 | 11 | /// The step size for progress indicator updates. 12 | const STEP: usize = 999; 13 | /// Threshold for when to print progress updates in the aggregator. 14 | const THRESHOLD: usize = 25_000; 15 | 16 | #[inline(always)] 17 | pub fn update_progress( 18 | window: &mut MainWindow, 19 | start: usize, 20 | end: usize, 21 | index: usize, 22 | ) -> Result { 23 | // Update the user interface with the current state 24 | if end - start > THRESHOLD && (index % STEP == 0 || index == end - 1) { 25 | let word = if index == end - 1 { 26 | "Processed" 27 | } else { 28 | "Processing" 29 | }; 30 | window.write_to_command_line(&format!( 31 | "{word} messages: {}/{} ({}%)", 32 | (index + 1) - start, 33 | end - start, 34 | ((index + 1 - start) * 100) / (end - start) 35 | ))?; 36 | return Ok(true); 37 | } 38 | Ok(false) 39 | } 40 | -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | | Key | Command | 4 | |--|--| 5 | | `:` | enter command mode | 6 | | `:q` | exit Logria | 7 | | `:poll #` | update [poll rate](#poll-rate) to #, where # is an integer | 8 | | `:r #` | when launching logria or viewing sessions, this will delete item # | 9 | | `:agg #` | set the limit for aggregation counters be `top #`, i.e. `top 5` or `top 1` | 10 | | `:history on` | enable command history disk cache | 11 | | `:history off` | disable command history disk cache | 12 | 13 | ## Notes 14 | 15 | To use a command, simply type `:` and enter a command. To exit without running the command, press `esc`. 16 | 17 | ### Poll Rate 18 | 19 | This is the rate at which Logria checks the queues for new messages. 20 | 21 | The poll rate defaults to `smart` mode, where Logria will calculate a rate at which to poll the message queues based on the speed of incoming messages. To disable this feature, pass `-m` when starting Logria. When "mindless" mode is enabled, the app falls back to the default value of polling once every `50` milliseconds. 22 | 23 | ### Remove Command 24 | 25 | The command `:r` is applicable only when the user is loading either sessions or parsers. `:r 2` will remove item 2, `:r 0-4` will remove items 0 through 4 inclusively. Any combination of those two patterns will work: for example, `:r 2,4-6,8` will remove 2, 4, 5, 6, and 8. 26 | -------------------------------------------------------------------------------- /resources/branding/logria.svg: -------------------------------------------------------------------------------- 1 | logria -------------------------------------------------------------------------------- /src/util/options.rs: -------------------------------------------------------------------------------- 1 | use clap::{Arg, ArgAction, ArgMatches, command, crate_version}; 2 | 3 | use crate::constants::app::NAME; 4 | use crate::constants::cli::messages; 5 | 6 | pub fn from_command_line() -> ArgMatches { 7 | command!(NAME) 8 | .version(crate_version!()) 9 | .about(messages::APP_DESCRIPTION) 10 | .arg( 11 | Arg::new("tape") 12 | .short('t') 13 | .long("no-history-tape") 14 | .required(false) 15 | .action(ArgAction::SetTrue) 16 | .help(messages::HISTORY_HELP), 17 | ) 18 | .arg( 19 | Arg::new("mindless") 20 | .short('m') 21 | .long("mindless") 22 | .required(false) 23 | .action(ArgAction::SetTrue) 24 | .help(messages::SMART_POLL_RATE_HELP), 25 | ) 26 | .arg( 27 | Arg::new("docs") 28 | .short('d') 29 | .long("docs") 30 | .required(false) 31 | .action(ArgAction::SetTrue) 32 | .help(messages::DOCS_HELP), 33 | ) 34 | .arg( 35 | Arg::new("paths") 36 | .short('p') 37 | .long("paths") 38 | .required(false) 39 | .action(ArgAction::SetTrue) 40 | .help(messages::PATHS_HELP), 41 | ) 42 | .arg( 43 | Arg::new("exec") 44 | .short('e') 45 | .long("exec") 46 | .help(messages::EXEC_HELP) 47 | .value_name("stream"), 48 | ) 49 | .get_matches() 50 | } 51 | -------------------------------------------------------------------------------- /src/ui/interface.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Result, Stdout, Write, stdin, stdout}; 2 | 3 | use crossterm::{cursor, execute, queue, style, terminal, tty::IsTty}; 4 | 5 | use crate::communication::reader::MainWindow; 6 | 7 | fn rect(stdout: &mut Stdout, start: u16, height: u16, width: u16) -> Result<()> { 8 | for y in start..height { 9 | for x in 0..width { 10 | if y == start || y == height - 1 { 11 | queue!(stdout, cursor::MoveTo(x, y), style::Print("─"))?; // left side 12 | } else if x == 0 || x == width - 1 { 13 | queue!(stdout, cursor::MoveTo(x, y), style::Print("│"))?; // right side 14 | } 15 | queue!(stdout, cursor::MoveTo(width - 1, start), style::Print("┐"))?; // top right 16 | queue!(stdout, cursor::MoveTo(0, start), style::Print("┌"))?; // top left 17 | queue!(stdout, cursor::MoveTo(width - 1, height), style::Print("┘"))?; // bottom right 18 | queue!(stdout, cursor::MoveTo(0, height), style::Print("└"))?; // bottom left 19 | } 20 | } 21 | Ok(()) 22 | } 23 | 24 | pub fn build(app: &mut MainWindow) -> Result<()> { 25 | let mut stdout = stdout(); 26 | execute!(stdout, terminal::Clear(terminal::ClearType::All))?; 27 | execute!(stdout, cursor::Hide)?; 28 | terminal::enable_raw_mode()?; 29 | rect( 30 | &mut stdout, 31 | app.config.last_row, 32 | app.config.height, 33 | app.config.width, 34 | )?; 35 | stdout.flush()?; 36 | Ok(()) 37 | } 38 | 39 | /// Ensure both stdin and stdout are controlled by the terminal emulator 40 | pub fn valid_tty() -> bool { 41 | stdin().is_tty() && stdout().is_tty() 42 | } 43 | -------------------------------------------------------------------------------- /resources/branding/logria.txt: -------------------------------------------------------------------------------- 1 | ██ ██ ██ ██ ██░░░░ ██ 2 | ██ ██ ██ ██ ██░░░░ ██ 3 | ██ ██ ██ ██ ██ ██ 4 | ██ ██ ██ ██ ██ ██ 5 | ██░░░░ ██░░░░░░░░░░░ ██░░░░░░░░░░░░ ██░░░░░░░░░░░░░ ██░░░░ ░░░░░░░░░░░░██ 6 | ██░░░░ ██░░░░░░░░░░░ ██░░░░░░░░░░░░ ██░░░░░░░░░░░░░ ██░░░░ ░░░░░░░░░░░░██ 7 | ██░░░░ ██░░░░ ░░░░ ██░░░░ ░░░░ ██░░░░ ░░░░░ ██░░░░ ░░░░██ 8 | ██░░░░ ██░░░░ ░░░░ ██░░░░ ░░░░ ██░░░░ ░░░░░ ██░░░░ ░░░░██ 9 | ██░░░░ ██░░░░ ░░░░ ██░░░░ ░░░░ ██░░░░ ██░░░░ ░░░░░░░░░░░░██ 10 | ██░░░░ ██░░░░ ░░░░ ██░░░░ ░░░░ ██░░░░ ██░░░░ ░░░░░░░░░░░░██ 11 | ██░░░░ ██░░░░ ░░░░ ██░░░░ ░░░░ ██░░░░ ██░░░░ ░░░░ ░░░░██ 12 | ██░░░░ ░░░░ ██░░░░ ░░░░ ██░░░░ ░░░░ ██░░░░ ██░░░░ ░░░░ ░░░░██ 13 | ██░░░░ ░░░░ ██░░░░ ░░░░ ██░░░░ ░░░░ ██░░░░ ██░░░░ ░░░░ ░░░░██ 14 | ██░░░░░░░░░░░░ ██░░░░░░░░░░░ ██░░░░░░░░░░░░ ██░░░░ ██░░░░ ░░░░░░░░░░░░██ 15 | ██░░░░░░░░░░░░ ██░░░░░░░░░░░ ██░░░░░░░░░░░░ ██░░░░ ██░░░░ ░░░░░░░░░░░░██ 16 | ██ ██ ██ ░░░░ ██ ██ ██ 17 | ██ ██ ██ ░░░░ ██ ██ ██ 18 | ██ ██ ██░░░░░░░░░░░░ ██ ██ ██ 19 | ██ ██ ██░░░░░░░░░░░░ ██ ██ ██ 20 | -------------------------------------------------------------------------------- /resources/branding/logria.svg.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | logria 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/util/error.rs: -------------------------------------------------------------------------------- 1 | use regex::Error; 2 | use std::fmt::{Display, Formatter, Result}; 3 | 4 | #[derive(Debug)] 5 | pub enum LogriaError { 6 | InvalidRegex(Error, String), 7 | WrongParserType, 8 | InvalidExampleRegex(String), 9 | InvalidExampleSplit(usize, usize), 10 | CannotRead(String, String), 11 | CannotWrite(String, String), 12 | CannotRemove(String, String), 13 | CannotParseDate(String), 14 | InvalidCommand(String), 15 | CannotParseMessage(String), 16 | InvalidParserState(String), 17 | } 18 | 19 | impl Display for LogriaError { 20 | fn fmt(&self, fmt: &mut Formatter<'_>) -> Result { 21 | match self { 22 | LogriaError::InvalidRegex(why, msg) => write!(fmt, "{why}: {msg}"), 23 | LogriaError::WrongParserType => { 24 | write!(fmt, "Cannot construct regex for a Split type parser") 25 | } 26 | LogriaError::InvalidExampleRegex(msg) => { 27 | write!(fmt, "Invalid example: /{msg}/ has no captures") 28 | } 29 | LogriaError::InvalidExampleSplit(msg, count) => write!( 30 | fmt, 31 | "Invalid example: {msg:?} matches for {count:?} methods" 32 | ), 33 | LogriaError::CannotRead(path, why) => write!(fmt, "Couldn't open {path:?}: {why}"), 34 | LogriaError::CannotWrite(path, why) => { 35 | write!(fmt, "Couldn't write {path:?}: {why}") 36 | } 37 | LogriaError::CannotRemove(path, why) => { 38 | write!(fmt, "Couldn't remove {path:?}: {why}") 39 | } 40 | LogriaError::CannotParseDate(msg) => { 41 | write!(fmt, "Invalid format description: {msg}") 42 | } 43 | LogriaError::InvalidCommand(msg) => { 44 | write!(fmt, "Invalid command: {msg}") 45 | } 46 | LogriaError::CannotParseMessage(msg) => { 47 | write!(fmt, "Unable to parse message: {msg}") 48 | } 49 | LogriaError::InvalidParserState(msg) => { 50 | write!(fmt, "Invalid parser state: {msg}") 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/util/binary_search.rs: -------------------------------------------------------------------------------- 1 | /// Returns the index of the element in `values` that is numerically closest to `target`. On a tie (two values equally close) the lower index is chosen. 2 | pub fn closest_index(values: &[usize], target: usize) -> Option { 3 | if values.is_empty() { 4 | return None; 5 | } 6 | 7 | match values.binary_search(&target) { 8 | // Exact hit ─ best possible. 9 | Ok(idx) => Some(idx), 10 | 11 | // `Err(pos)` gives the index where `target` could be inserted to keep the order. 12 | Err(pos) => { 13 | match pos { 14 | // target < first element 15 | 0 => Some(0), 16 | // target > last element 17 | n if n == values.len() => Some(values.len() - 1), 18 | // Compare distances to the two neighbors: values[pos-1] and values[pos]. 19 | _ => { 20 | let diff_low = target - values[pos - 1]; 21 | let diff_high = values[pos] - target; 22 | if diff_low <= diff_high { 23 | Some(pos - 1) 24 | } else { 25 | Some(pos) 26 | } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | 33 | #[cfg(test)] 34 | mod tests { 35 | use crate::util::binary_search::closest_index; 36 | 37 | #[test] 38 | fn closest_within_range() { 39 | // 8 is the closest value to 10 → index 2 40 | let v = vec![0, 3, 8, 12, 20]; 41 | assert_eq!(closest_index(&v, 10), Some(2)); 42 | } 43 | 44 | #[test] 45 | fn closest_exact_match() { 46 | // Exact hit: 12 is at index 3 47 | let v = vec![0, 3, 8, 12, 20]; 48 | assert_eq!(closest_index(&v, 12), Some(3)); 49 | } 50 | 51 | #[test] 52 | fn closest_below_range() { 53 | // Target is smaller than the first element → choose index 0 54 | let v = vec![0, 3, 8, 12, 20]; 55 | assert_eq!(closest_index(&v, 1), Some(0)); 56 | } 57 | 58 | #[test] 59 | fn closest_above_range() { 60 | // Target is larger than the last element → choose last index (4) 61 | let v = vec![0, 3, 8, 12, 20]; 62 | assert_eq!(closest_index(&v, 25), Some(4)); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/util/aggregators/sum.rs: -------------------------------------------------------------------------------- 1 | use crate::util::{ 2 | aggregators::aggregator::{Aggregator, extract_number, format_float}, 3 | error::LogriaError, 4 | }; 5 | 6 | /// Aggregator that accumulates numeric values from messages into a running total. 7 | pub struct Sum { 8 | /// Running total of numeric messages. 9 | total: f64, 10 | } 11 | 12 | impl Aggregator for Sum { 13 | /// Parses `message`, extracts a number if present, and adds it to the total. 14 | /// Saturates at `f64::MAX` on overflow. 15 | fn update(&mut self, message: &str) -> Result<(), LogriaError> { 16 | if self.total >= f64::MAX { 17 | self.total = f64::MAX; 18 | } else if let Some(number) = self.parse(message) { 19 | self.total += number; 20 | } 21 | Ok(()) 22 | } 23 | 24 | /// Returns the current total formatted as a string message. 25 | fn messages(&self, _: &usize) -> Vec { 26 | vec![format!(" Total: {}", format_float(self.total))] 27 | } 28 | 29 | /// Resets the running total back to zero. 30 | fn reset(&mut self) { 31 | self.total = 0.; 32 | } 33 | } 34 | 35 | impl Sum { 36 | /// Creates a new `Sum` aggregator with an initial total of zero. 37 | pub fn new() -> Self { 38 | Sum { total: 0. } 39 | } 40 | 41 | /// Attempts to parse a numeric value from `message`, returning `None` if parsing fails. 42 | fn parse(&self, message: &str) -> Option { 43 | extract_number(message) 44 | } 45 | } 46 | 47 | #[cfg(test)] 48 | mod float_tests { 49 | use crate::util::aggregators::{aggregator::Aggregator, sum::Sum}; 50 | 51 | #[test] 52 | fn sum() { 53 | let mut sum: Sum = Sum::new(); 54 | sum.update("1_f32").unwrap(); 55 | sum.update("2_f32").unwrap(); 56 | sum.update("3_f32").unwrap(); 57 | 58 | assert!(sum.total - 6. == 0.); 59 | } 60 | 61 | #[test] 62 | fn messages() { 63 | let mut sum: Sum = Sum::new(); 64 | sum.update("1_f32").unwrap(); 65 | sum.update("2_f32").unwrap(); 66 | sum.update("3_f32").unwrap(); 67 | 68 | assert_eq!(sum.messages(&1), vec![" Total: 6"]); 69 | } 70 | 71 | #[test] 72 | fn sum_empty() { 73 | let mean: Sum = Sum::new(); 74 | 75 | assert!(mean.total - 0_f64 == 0_f64); 76 | } 77 | 78 | #[test] 79 | fn sum_overflow() { 80 | let mut sum: Sum = Sum::new(); 81 | sum.update(&format!("{}test", f64::MAX)).unwrap(); 82 | sum.update(&format!("{} test", f64::MAX)).unwrap(); 83 | 84 | assert!(sum.total - f64::MAX == 0_f64); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /docs/sessions.md: -------------------------------------------------------------------------------- 1 | # Sessions Documentation 2 | 3 | A session is a collection of commands that result in input streams. 4 | 5 | ## Storage 6 | 7 | Sessions are stored as `JSON` in [`$LOGRIA_USER_HOME/$LOGRIA_ROOT/sessions`](README.md#directory-configuration) and do not have file extensions. A session is defined like so: 8 | 9 | ```json 10 | { 11 | "commands": [ 12 | [ 13 | "/bin/python", 14 | "sample_streams/generate_test_logs.py" 15 | ], 16 | [ 17 | "/bin/python", 18 | "sample_streams/generate_test_logs_2.py" 19 | ] 20 | ], 21 | "stream_type": "Command" 22 | } 23 | ``` 24 | 25 | If [`$LOGRIA_USER_HOME/$LOGRIA_ROOT/sessions`](README.md#directory-configuration) does not exist, Logria will attempt to create it. 26 | 27 | ## Elements 28 | 29 | All sessions have two keys: 30 | 31 | - `commands` 32 | - Contains a list of commands to listen on 33 | - `stream_type` 34 | - Contains a string of the type of input handler to use, either `File` or `Command` 35 | - `File` creates a `FileInputHandler` and `Command` creates a `CommandInputHandler` 36 | 37 | ## Interpreting Sessions at Runtime 38 | 39 | If Logria is launched without `-e`, it will default to listing the contents of `$LOGRIA_ROOT/sessions` and allow the user to select one. Users can also enter a new command to listen to; that command will be saved as a new session if the user has write permissions to the sessions directory. 40 | 41 | ```zsh 42 | Enter a new command to open and save a new stream, 43 | or enter a number to choose a saved session from the list. 44 | 45 | Enter `:r #` to remove session #. 46 | Enter `:q` to quit. 47 | 48 | 0: File - README.md 49 | 1: File - Sample Access Log 50 | 2: Cmd - Generate Test Logs 51 | ``` 52 | 53 | Once a selection has been made, Logria will open pipes to the new processes and begin streaming. 54 | 55 | ```zsh 56 | 2020-02-08 19:00:02,317 - __main__. - MainProcess - INFO - I am a first log! 80 57 | 2020-02-08 19:00:02,317 - __main__. - MainProcess - INFO - I am a second log! 43 58 | 2020-02-08 19:00:02,327 - __main__. - MainProcess - INFO - I am a first log! 80 59 | 2020-02-08 19:00:02,327 - __main__. - MainProcess - INFO - I am a second log! 58 60 | 2020-02-08 19:00:02,337 - __main__. - MainProcess - INFO - I am a second log! 54 61 | 2020-02-08 19:00:02,337 - __main__. - MainProcess - INFO - I am a first log! 92 62 | 2020-02-08 19:00:02,347 - __main__. - MainProcess - INFO - I am a second log! 68 63 | 2020-02-08 19:00:02,350 - __main__. - MainProcess - INFO - I am a first log! 26 64 | ┌─────────────────────────────────────────────────────────────────────────────────────────┐ 65 | │ │ 66 | └─────────────────────────────────────────────────────────────────────────────────────────┘ 67 | ``` 68 | -------------------------------------------------------------------------------- /src/constants/directories.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::{ 2 | app::NAME, 3 | resolver::{get_env_var_or_default, get_home_dir}, 4 | }; 5 | use std::env; 6 | 7 | // Paths 8 | pub fn home() -> String { 9 | get_home_dir() 10 | } 11 | 12 | pub fn app_root() -> String { 13 | let mut root = home(); 14 | root.push('/'); 15 | root.push_str(&get_env_var_or_default("LOGRIA_ROOT", NAME)); 16 | root 17 | } 18 | 19 | pub fn patterns() -> String { 20 | let mut root = app_root(); 21 | root.push_str("/parsers"); 22 | root 23 | } 24 | 25 | pub fn sessions() -> String { 26 | let mut root = app_root(); 27 | root.push_str("/sessions"); 28 | root 29 | } 30 | 31 | pub fn history() -> String { 32 | let mut root = app_root(); 33 | root.push_str("/history"); 34 | root 35 | } 36 | 37 | pub fn history_tape() -> String { 38 | let mut root = app_root(); 39 | root.push_str("/history/tape"); 40 | root 41 | } 42 | 43 | pub fn print_paths() { 44 | let mut result = String::new(); 45 | result.push_str("Environment variables:\n"); 46 | match env::var("LOGRIA_USER_HOME") { 47 | Ok(home) => { 48 | result.push_str(&format!("LOGRIA_USER_HOME: {home}\n")); 49 | } 50 | Err(_) => { 51 | result.push_str("LOGRIA_USER_HOME: Not Set\n"); 52 | } 53 | } 54 | match env::var("LOGRIA_ROOT") { 55 | Ok(root) => { 56 | result.push_str(&format!("LOGRIA_ROOT: {root}\n")); 57 | } 58 | Err(_) => { 59 | result.push_str("LOGRIA_ROOT: Not Set\n"); 60 | } 61 | } 62 | 63 | result.push_str("\nExpanded paths:\n"); 64 | result.push_str(&format!("Config root: {}\n", home())); 65 | result.push_str(&format!("Logria root: {}\n", app_root())); 66 | result.push_str(&format!("Patterns: {}\n", patterns())); 67 | result.push_str(&format!("Sessions: {}\n", sessions())); 68 | result.push_str(&format!("History: {}", history())); 69 | println!("{result}"); 70 | } 71 | 72 | #[cfg(test)] 73 | mod tests { 74 | use crate::constants::directories; 75 | use dirs::config_dir; 76 | 77 | #[test] 78 | fn test_app_root() { 79 | let t = directories::app_root(); 80 | let mut root = config_dir().unwrap().to_str().unwrap().to_string(); 81 | root.push_str("/Logria"); 82 | assert_eq!(t, root); 83 | } 84 | 85 | #[test] 86 | fn test_patterns() { 87 | let t = directories::patterns(); 88 | let mut root = config_dir().unwrap().to_str().unwrap().to_string(); 89 | root.push_str("/Logria/parsers"); 90 | assert_eq!(t, root); 91 | } 92 | 93 | #[test] 94 | fn test_sessions() { 95 | let t = directories::sessions(); 96 | let mut root = config_dir().expect("").to_str().expect("").to_string(); 97 | root.push_str("/Logria/sessions"); 98 | assert_eq!(t, root); 99 | } 100 | 101 | #[test] 102 | fn test_history() { 103 | let t = directories::history(); 104 | let mut root = config_dir().expect("").to_str().expect("").to_string(); 105 | root.push_str("/Logria/history"); 106 | assert_eq!(t, root); 107 | } 108 | 109 | #[test] 110 | fn test_history_tape() { 111 | let t = directories::history_tape(); 112 | let mut root = config_dir().expect("").to_str().expect("").to_string(); 113 | root.push_str("/Logria/history/tape"); 114 | assert_eq!(t, root); 115 | } 116 | 117 | #[test] 118 | fn test_print_paths() { 119 | // Ensure no weird crashes here 120 | directories::print_paths(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/constants/app.rs: -------------------------------------------------------------------------------- 1 | pub const NAME: &str = "Logria"; 2 | pub const LOGRIA: [&str; 33] = [ 3 | "██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m░░░░ \x1b[0m██", 4 | "██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m░░░░ \x1b[0m██", 5 | "██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██", 6 | "██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██", 7 | "██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░░░░░░░░░\x1b[0m██", 8 | "██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░░░░░░░░░\x1b[0m██", 9 | "██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░\x1b[0m██", 10 | "██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░\x1b[0m██", 11 | "██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░░░░░░░░░\x1b[0m██", 12 | "██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░░░░░░░░░\x1b[0m██", 13 | "██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ ░░░░\x1b[0m██", 14 | "██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ ░░░░\x1b[0m██", 15 | "██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ \x1b[0m██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░ ░░░░\x1b[0m██", 16 | "██\x1b[35m░░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░░░░░░░░░\x1b[0m██", 17 | "██\x1b[35m░░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░░░░░░░░░ \x1b[0m██\x1b[35m░░░░ \x1b[0m██\x1b[35m░░░░ ░░░░░░░░░░░░\x1b[0m██", 18 | "██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m ░░░░ \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██", 19 | "██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m ░░░░ \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██", 20 | "██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m░░░░░░░░░░░░ \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██", 21 | "██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m░░░░░░░░░░░░ \x1b[0m██\x1b[35m \x1b[0m██\x1b[35m \x1b[0m██", 22 | "", 23 | "", 24 | "Author", 25 | "Christopher Sardegna", 26 | "https://chrissardegna.com", 27 | "", 28 | "", 29 | "Special Thanks", 30 | "Voidsphere https://voidsphere.bandcamp.com/music", 31 | "Julian Coleman https://github.com/juliancoleman/", 32 | "@rhamorim https://twitter.com/rhamorim", 33 | "@javasux0 https://twitter.com/javasux0", 34 | "yonkeltron https://github.com/yonkeltron", 35 | "Simone Vittori https://www.simonewebdesign.it", 36 | ]; 37 | -------------------------------------------------------------------------------- /src/communication/handlers/normal.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Result, stdout}; 2 | 3 | use crossterm::{cursor, event::KeyCode, queue}; 4 | 5 | use super::handler::Handler; 6 | use crate::{ 7 | communication::{ 8 | input::{InputType, StreamType}, 9 | reader::MainWindow, 10 | }, 11 | constants::cli::cli_chars::{COMMAND_CHAR, HIGHLIGHT_CHAR, PARSER_CHAR, REGEX_CHAR, SWAP_CHAR}, 12 | ui::scroll, 13 | }; 14 | 15 | pub struct NormalHandler {} 16 | 17 | impl NormalHandler { 18 | fn set_parser_mode(&self, window: &mut MainWindow) -> Result<()> { 19 | window.update_input_type(InputType::Parser)?; 20 | window.reset_command_line()?; 21 | window.set_cli_cursor(None)?; 22 | window.config.previous_stream_type = window.config.stream_type; 23 | window.config.stream_type = StreamType::Auxiliary; 24 | // Send 2 new refresh ticks from the main app loop when this method returns 25 | window.config.did_switch = true; 26 | Ok(()) 27 | } 28 | 29 | fn set_regex_mode(&self, window: &mut MainWindow) -> Result<()> { 30 | window.go_to_cli()?; 31 | window.update_input_type(InputType::Regex)?; 32 | window.config.highlight_match = true; 33 | window.reset_command_line()?; 34 | window.set_cli_cursor(None)?; 35 | queue!(stdout(), cursor::Show)?; 36 | // Send 2 new refresh ticks from the main app loop when this method returns 37 | window.config.did_switch = true; 38 | Ok(()) 39 | } 40 | 41 | fn set_highlight_mode(&self, window: &mut MainWindow) -> Result<()> { 42 | window.go_to_cli()?; 43 | window.update_input_type(InputType::Highlight)?; 44 | window.config.highlight_match = true; 45 | window.reset_command_line()?; 46 | window.set_cli_cursor(None)?; 47 | queue!(stdout(), cursor::Show)?; 48 | // Send 2 new refresh ticks from the main app loop when this method returns 49 | window.config.did_switch = true; 50 | Ok(()) 51 | } 52 | 53 | fn swap_streams(&self, window: &mut MainWindow) -> Result<()> { 54 | window.config.previous_stream_type = window.config.stream_type; 55 | window.config.stream_type = match window.config.stream_type { 56 | StreamType::StdOut => StreamType::StdErr, 57 | StreamType::StdErr => StreamType::StdOut, 58 | // Do not swap from auxiliary stream 59 | StreamType::Auxiliary => StreamType::Auxiliary, 60 | }; 61 | window.update_input_type(InputType::Normal)?; 62 | window.set_cli_cursor(None)?; 63 | window.reset_command_line()?; 64 | window.reset_output()?; 65 | window.redraw()?; 66 | Ok(()) 67 | } 68 | } 69 | 70 | impl Handler for NormalHandler { 71 | fn new() -> NormalHandler { 72 | NormalHandler {} 73 | } 74 | 75 | fn receive_input(&mut self, window: &mut MainWindow, key: KeyCode) -> Result<()> { 76 | match key { 77 | // Scroll 78 | KeyCode::Down => scroll::down(window), 79 | KeyCode::Up => scroll::up(window), 80 | KeyCode::Left => scroll::top(window), 81 | KeyCode::Right => scroll::bottom(window), 82 | KeyCode::Home => scroll::top(window), 83 | KeyCode::End => scroll::bottom(window), 84 | KeyCode::PageUp => scroll::pg_up(window), 85 | KeyCode::PageDown => scroll::pg_down(window), 86 | 87 | // Modes 88 | KeyCode::Char(COMMAND_CHAR) => window.set_command_mode(None)?, 89 | KeyCode::Char(REGEX_CHAR) => self.set_regex_mode(window)?, 90 | KeyCode::Char(HIGHLIGHT_CHAR) => self.set_highlight_mode(window)?, 91 | KeyCode::Char(PARSER_CHAR) => self.set_parser_mode(window)?, 92 | KeyCode::Char(SWAP_CHAR) => self.swap_streams(window)?, 93 | _ => {} 94 | } 95 | window.redraw()?; 96 | Ok(()) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logria Logo](/resources/branding/logria.svg) 2 | 3 | # Logria 4 | 5 | A powerful CLI tool that puts log aggregation at your fingertips. 6 | 7 | ## tl;dr 8 | 9 | - Live filtering/parsing of data from other processes 10 | - Use shell commands or files as input, save sessions and come back later 11 | - Replace regex/filter without killing the process or losing the stream's history 12 | - Parse logs using user-defined rules, apply aggregation methods on top 13 | 14 | ## Installation 15 | 16 | There are several options to install this app. 17 | 18 | ### Cargo (recommended) 19 | 20 | This binary is available on [crates.io](https://crates.io/crates/logria). 21 | 22 | `cargo install logria` is the best way to install the app for normal use. 23 | 24 | ### Development 25 | 26 | See [Advanced Installation](docs/README.md#advanced-installation). 27 | 28 | ## Usage 29 | 30 | There are a few ways to invoke Logria: 31 | 32 | - Directly: 33 | - `logria` 34 | - Opens to the setup screen 35 | - With args: 36 | - `logria -e 'tail -f log.txt'` 37 | - Opens a process for `tail -f log.txt` and skips setup 38 | - `logria -h` will show the help page with all possible options 39 | 40 | For more details, see [Sample Usage Session](docs/README.md#sample-usage-session). 41 | 42 | ## Key Commands 43 | 44 | | Key | Command | 45 | |--|--| 46 | | `:` | [command mode](docs/commands.md) | 47 | | `/` | highlight search | 48 | | `r` | regex filter | 49 | | `h` | toggle highlighting of search/regex matches | 50 | | `s` | swap reading `stderr` and `stdout` | 51 | | `p` | activate parser | 52 | | `a` | toggle aggregation mode when parser is active | 53 | | `z` | deactivate parser | 54 | | ↑ | scroll buffer up one line | 55 | | ↓ | scroll buffer down one line | 56 | | → | skip and stick to end of buffer | 57 | | ← | skip and stick to beginning of buffer | 58 | 59 | ## Features 60 | 61 | Here are some of the ways you can leverage Logria: 62 | 63 | ### Live stream of log data 64 | 65 | ![logria](/resources/screenshots/logria.png) 66 | 67 | ### Interactive, live, editable regex search 68 | 69 | ![regex](/resources/screenshots/regex.png) 70 | 71 | ### Live log message parsing 72 | 73 | ![parser](/resources/screenshots/parser.png) 74 | 75 | ### Live aggregation/statistics tracking 76 | 77 | ![aggregation](/resources/screenshots/aggregation.png) 78 | 79 | ### User-defined saved sessions 80 | 81 | See [session](/docs/sessions.md) docs. 82 | 83 | ### User-defined saved log parsing methods 84 | 85 | See [parser](/docs/parsers.md) docs. 86 | 87 | ## Notes 88 | 89 | This is a Rust implementation of my [Python](https://github.com/ReagentX/Logria-py) proof-of-concept. 90 | 91 | ### When to use Logria 92 | 93 | Logria is best leveraged to watch live logs from multiple processes and filter them for events you want to see. My most common use case is watching logs from multiple Linode/EC2 instances via `ssh` or multiple CloudWatch streams using [`aws logs`](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/logs/index.html). 94 | 95 | I also use it to analyze the logs from my Apache web servers that print logs in the common log format. 96 | 97 | ### When to avoid Logria 98 | 99 | Logria is not a tool for detailed log analytics. [`lnav`](https://lnav.org/features) or [`angle-grinder`](https://github.com/rcoh/angle-grinder/) will both do the job better. 100 | 101 | ## Special Thanks 102 | 103 | - [Voidsphere](https://voidsphere.bandcamp.com/music), for providing all the hacking music I could want. 104 | - [Julian Coleman](https://github.com/juliancoleman/), for lots of code review and general Rust advice. 105 | - [yonkeltron](https://github.com/yonkeltron), for advice and help learning Rust. 106 | - [Simone Vittori](https://www.simonewebdesign.it), for a great [blog post](https://www.simonewebdesign.it/rust-hashmap-insert-values-multiple-types/) on storing multiple value types in a `HashMap`. 107 | -------------------------------------------------------------------------------- /src/util/aggregators/mean.rs: -------------------------------------------------------------------------------- 1 | use crate::util::{ 2 | aggregators::aggregator::{Aggregator, extract_number, format_float}, 3 | error::LogriaError, 4 | }; 5 | 6 | /// Aggregator that computes the running mean of numeric messages. 7 | pub struct Mean { 8 | /// Number of numeric messages processed. 9 | count: f64, 10 | /// Sum of parsed numeric message values. 11 | total: f64, 12 | } 13 | 14 | /// Float implementation of Mean 15 | impl Aggregator for Mean { 16 | /// Parses `message`, updates the running count, and accumulates the total. 17 | /// Saturates `count` and `total` at `f64::MAX` to prevent overflow. 18 | fn update(&mut self, message: &str) -> Result<(), LogriaError> { 19 | if self.count >= f64::MAX { 20 | self.count = f64::MAX; 21 | } else { 22 | self.count += 1.; 23 | } 24 | 25 | if self.total >= f64::MAX { 26 | self.total = f64::MAX; 27 | } else { 28 | match self.parse(message) { 29 | Some(number) => { 30 | self.total += number; 31 | } 32 | None => { 33 | self.count -= 1.; 34 | } 35 | } 36 | } 37 | 38 | Ok(()) 39 | } 40 | 41 | /// Returns formatted output: current mean (two decimals), count, and total. 42 | fn messages(&self, _: &usize) -> Vec { 43 | vec![ 44 | format!(" Mean: {:.2}", self.mean()), 45 | format!(" Count: {}", format_float(self.count)), 46 | format!(" Total: {}", format_float(self.total)), 47 | ] 48 | } 49 | 50 | /// Resets both `count` and `total` back to zero. 51 | fn reset(&mut self) { 52 | self.count = 0.; 53 | self.total = 0.; 54 | } 55 | } 56 | 57 | impl Mean { 58 | /// Creates a new `Mean` aggregator with zero count and total. 59 | pub fn new() -> Mean { 60 | Mean { 61 | count: 0., 62 | total: 0., 63 | } 64 | } 65 | 66 | /// Attempts to parse a floating-point number from `message`. 67 | /// Returns `None` if parsing fails. 68 | fn parse(&self, message: &str) -> Option { 69 | extract_number(message) 70 | } 71 | 72 | /// Computes the current average; returns `total` if no values have been aggregated. 73 | fn mean(&self) -> f64 { 74 | if self.count == 0. { 75 | self.total 76 | } else { 77 | self.total / self.count 78 | } 79 | } 80 | } 81 | 82 | #[cfg(test)] 83 | mod float_tests { 84 | use crate::util::aggregators::{aggregator::Aggregator, mean::Mean}; 85 | 86 | #[test] 87 | fn mean() { 88 | let mut mean: Mean = Mean::new(); 89 | mean.update("1_f64").unwrap(); 90 | mean.update("2_f64").unwrap(); 91 | mean.update("3_f64").unwrap(); 92 | 93 | assert!((mean.mean() - 2_f64).abs() == 0_f64); 94 | assert!((mean.total - 6_f64).abs() == 0_f64); 95 | assert!((mean.count - 3_f64).abs() == 0_f64); 96 | } 97 | 98 | #[test] 99 | fn display() { 100 | let mut mean: Mean = Mean::new(); 101 | mean.update("1_f64").unwrap(); 102 | mean.update("2_f64").unwrap(); 103 | mean.update("3_f64").unwrap(); 104 | 105 | assert_eq!( 106 | mean.messages(&1), 107 | vec![ 108 | " Mean: 2.00".to_string(), 109 | " Count: 3".to_string(), 110 | " Total: 6".to_string(), 111 | ] 112 | ); 113 | } 114 | 115 | #[test] 116 | fn empty_mean() { 117 | let mean: Mean = Mean::new(); 118 | 119 | assert!(mean.mean() == 0_f64); 120 | assert!(mean.total == 0_f64); 121 | assert!(mean.count == 0_f64); 122 | } 123 | 124 | #[test] 125 | fn mean_overflow() { 126 | let mut mean: Mean = Mean::new(); 127 | mean.update(&format!("{}test", f64::MAX - 1_f64)).unwrap(); 128 | mean.update(&format!("{} test", f64::MAX - 1_f64)).unwrap(); 129 | 130 | assert!((mean.mean() - f64::MAX / 2_f64).abs() == 0_f64); 131 | assert!((mean.total - f64::MAX).abs() == 0_f64); 132 | assert!((mean.count - 2_f64).abs() == 0_f64); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/util/sanitizers.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp::max, collections::HashSet, str::from_utf8, sync::LazyLock}; 2 | 3 | use regex::bytes::Regex; 4 | 5 | use crate::constants::cli::patterns::ANSI_COLOR_PATTERN; 6 | 7 | /// Characters disallowed in a filename 8 | static FILENAME_DISALLOWED_CHARS: LazyLock> = 9 | LazyLock::new(|| HashSet::from(['*', '"', '/', '\\', '<', '>', ':', '|', '?', '.'])); 10 | /// The character to replace disallowed chars with 11 | const FILENAME_REPLACEMENT_CHAR: char = '_'; 12 | 13 | /// Remove unsafe chars in [this list](FILENAME_DISALLOWED_CHARS). 14 | /// 15 | /// Does not need to use a `Cow` for optimization because the source is always generated based on chat data 16 | /// so there is no opportunity for the original input to be passed in from another borrow. 17 | pub fn sanitize_filename(filename: &str) -> String { 18 | filename 19 | .trim() 20 | .chars() 21 | .map(|letter| { 22 | if letter.is_control() || FILENAME_DISALLOWED_CHARS.contains(&letter) { 23 | FILENAME_REPLACEMENT_CHAR 24 | } else { 25 | letter 26 | } 27 | }) 28 | .take(255) 29 | .collect() 30 | } 31 | 32 | pub struct LengthFinder { 33 | color_pattern: Regex, 34 | } 35 | 36 | impl LengthFinder { 37 | pub fn new() -> LengthFinder { 38 | LengthFinder { 39 | color_pattern: Regex::new(ANSI_COLOR_PATTERN).unwrap(), 40 | } 41 | } 42 | 43 | /// Returns the length of the string without ANSI color codes, i.e. the 44 | /// number of visible characters in a string when rendered in a terminal. 45 | fn get_real_length(&self, content: &str) -> usize { 46 | self.color_pattern 47 | .split(content.as_bytes()) 48 | .filter_map(|s| from_utf8(s).ok()) 49 | .map(|s| s.chars().count()) 50 | .sum() 51 | } 52 | 53 | /// Given a string to render and the terminal width, return a tuple of the number of rows it 54 | /// would take to display the string and the real length of the string. 55 | pub fn get_rows_and_length(&self, content: &str, terminal_width: usize) -> (usize, usize) { 56 | let length = self.get_real_length(content); 57 | (max(1, (length).div_ceil(terminal_width)), length) 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use crate::util::sanitizers::{LengthFinder, sanitize_filename}; 64 | 65 | #[test] 66 | fn test_length_clean() { 67 | let l = LengthFinder::new(); 68 | assert_eq!(l.get_real_length("word"), 4); 69 | } 70 | 71 | #[test] 72 | fn test_length_dirty() { 73 | let l = LengthFinder::new(); 74 | let content = "\x1b[0m word \x1b[32m"; 75 | assert_eq!(l.get_real_length(content), 6); 76 | } 77 | 78 | #[test] 79 | fn test_length_wide_chars() { 80 | let l = LengthFinder::new(); 81 | let content = "\x1b[0m█四░\x1b[32m█四░"; 82 | assert_eq!(l.get_real_length(content), 6); 83 | } 84 | 85 | #[test] 86 | fn test_row_length_clean() { 87 | let l = LengthFinder::new(); 88 | let (rows, length) = l.get_rows_and_length("word", 10); 89 | assert_eq!(rows, 1); 90 | assert_eq!(length, 4); 91 | } 92 | 93 | #[test] 94 | fn test_row_length_dirty() { 95 | let l = LengthFinder::new(); 96 | let content = "\x1b[0m word \x1b[32m"; 97 | let (rows, length) = l.get_rows_and_length(content, 4); 98 | assert_eq!(rows, 2); 99 | assert_eq!(length, 6); 100 | } 101 | 102 | #[test] 103 | fn test_row_length_wide_chars() { 104 | let l = LengthFinder::new(); 105 | let content = "\x1b[0m█四░\x1b[32m█四░"; 106 | let (rows, length) = l.get_rows_and_length(content, 10); 107 | assert_eq!(rows, 1); 108 | assert_eq!(length, 6); 109 | } 110 | 111 | #[test] 112 | fn test_sanitize_filename_clean() { 113 | assert_eq!(sanitize_filename("normal_filename"), "normal_filename"); 114 | assert_eq!(sanitize_filename("file.txt"), "file_txt"); 115 | assert_eq!(sanitize_filename("file_123"), "file_123"); 116 | } 117 | 118 | #[test] 119 | fn test_sanitize_filename_invalid_chars() { 120 | assert_eq!(sanitize_filename("file<>name"), "file__name"); 121 | assert_eq!(sanitize_filename("file:name"), "file_name"); 122 | assert_eq!(sanitize_filename("file\"name"), "file_name"); 123 | assert_eq!(sanitize_filename("file|name"), "file_name"); 124 | assert_eq!(sanitize_filename("file?name"), "file_name"); 125 | assert_eq!(sanitize_filename("file*name"), "file_name"); 126 | assert_eq!(sanitize_filename("file\\name"), "file_name"); 127 | assert_eq!(sanitize_filename("file/name"), "file_name"); 128 | } 129 | 130 | #[test] 131 | fn test_sanitize_filename_control_chars() { 132 | assert_eq!(sanitize_filename("file\x00name"), "file_name"); 133 | assert_eq!(sanitize_filename("file\x1fname"), "file_name"); 134 | assert_eq!(sanitize_filename("file\x7fname"), "file_name"); 135 | } 136 | 137 | #[test] 138 | fn test_sanitize_filename_trim() { 139 | assert_eq!(sanitize_filename(" filename "), "filename"); 140 | assert_eq!(sanitize_filename("..filename.."), "__filename__"); 141 | assert_eq!(sanitize_filename("\tfilename\t"), "filename"); 142 | } 143 | 144 | #[test] 145 | fn test_sanitize_filename_long() { 146 | let long_name = "a".repeat(300); 147 | let sanitized = sanitize_filename(&long_name); 148 | assert_eq!(sanitized.len(), 255); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/communication/handlers/multiple_choice.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, io::Result}; 2 | 3 | use crossterm::event::KeyCode; 4 | 5 | use crate::{ 6 | communication::{ 7 | handlers::{handler::Handler, user_input::UserInputHandler}, 8 | reader::MainWindow, 9 | }, 10 | ui::scroll, 11 | }; 12 | 13 | pub struct MultipleChoiceHandler { 14 | choices_map: HashMap, 15 | input_handler: UserInputHandler, 16 | pub result: Option, 17 | } 18 | 19 | impl MultipleChoiceHandler { 20 | /// Set internal choices map 21 | pub fn set_choices(&mut self, choices: &[String]) { 22 | self.choices_map.clear(); 23 | choices.iter().enumerate().for_each(|(index, choice)| { 24 | self.choices_map.insert(index, choice.to_owned()); 25 | }); 26 | } 27 | 28 | /// Build body text for a set of choices 29 | pub fn get_body_text(&self) -> Vec { 30 | let mut body_text: Vec = vec![]; 31 | (0..self.choices_map.len()).for_each(|key| { 32 | body_text.push(format!("{}: {}", key, self.choices_map.get(&key).unwrap())); 33 | }); 34 | body_text 35 | } 36 | 37 | /// Determine if the choice is valid 38 | pub fn validate_choice(&mut self, window: &mut MainWindow, choice: &str) -> Result<()> { 39 | match choice.parse::() { 40 | Ok(res) => { 41 | if self.choices_map.contains_key(&res) { 42 | self.result = Some(res.to_owned()); 43 | } else { 44 | window.write_to_command_line(&format!("Invalid item: {choice}"))?; 45 | } 46 | } 47 | Err(why) => { 48 | window.write_to_command_line(&format!("Invalid selection: {choice} ({why:?})"))?; 49 | } 50 | } 51 | Ok(()) 52 | } 53 | 54 | /// Extract the choice value from the hashmap 55 | pub fn get_choice(&mut self) -> Option<&String> { 56 | match self.result { 57 | Some(index) => { 58 | self.result = None; 59 | self.choices_map.get(&index) 60 | } 61 | None => None, 62 | } 63 | } 64 | } 65 | 66 | impl Handler for MultipleChoiceHandler { 67 | fn new() -> MultipleChoiceHandler { 68 | MultipleChoiceHandler { 69 | choices_map: HashMap::new(), 70 | input_handler: UserInputHandler::new(), 71 | result: None, 72 | } 73 | } 74 | 75 | fn receive_input(&mut self, window: &mut MainWindow, key: KeyCode) -> Result<()> { 76 | match key { 77 | // Scroll 78 | KeyCode::Down => scroll::down(window), 79 | KeyCode::Up => scroll::up(window), 80 | KeyCode::Left => scroll::top(window), 81 | KeyCode::Right => scroll::bottom(window), 82 | KeyCode::Home => scroll::top(window), 83 | KeyCode::End => scroll::bottom(window), 84 | KeyCode::PageUp => scroll::pg_up(window), 85 | KeyCode::PageDown => scroll::pg_down(window), 86 | 87 | // Handle user input selection 88 | KeyCode::Enter => { 89 | let choice = match self.input_handler.gather(window) { 90 | Ok(pattern) => pattern, 91 | Err(why) => panic!("Unable to gather text: {why:?}"), 92 | }; 93 | self.validate_choice(window, &choice)?; 94 | // Send 2 new refresh ticks from the main app loop when this method returns 95 | window.config.did_switch = true; 96 | } 97 | 98 | // User text input 99 | key => self.input_handler.receive_input(window, key)?, 100 | } 101 | window.redraw()?; 102 | Ok(()) 103 | } 104 | } 105 | 106 | #[cfg(test)] 107 | mod kc_tests { 108 | use std::collections::HashMap; 109 | 110 | use super::MultipleChoiceHandler; 111 | use crate::communication::{handlers::handler::Handler, reader::MainWindow}; 112 | 113 | #[test] 114 | fn can_create() { 115 | MultipleChoiceHandler::new(); 116 | } 117 | 118 | #[test] 119 | fn can_set_choices() { 120 | // Setup handler 121 | let mut mc = MultipleChoiceHandler::new(); 122 | mc.set_choices(&["a".to_string(), "b".to_string(), "c".to_string()]); 123 | 124 | // Generate expected result 125 | let mut expected: HashMap = HashMap::new(); 126 | expected.insert(0, String::from("a")); 127 | expected.insert(1, String::from("b")); 128 | expected.insert(2, String::from("c")); 129 | 130 | assert_eq!(mc.choices_map, expected); 131 | } 132 | 133 | #[test] 134 | fn can_get_body_text_no_desc() { 135 | // Setup handler 136 | let mut mc = MultipleChoiceHandler::new(); 137 | mc.set_choices(&["a".to_string(), "b".to_string(), "c".to_string()]); 138 | 139 | // Generate expected result 140 | let expected = vec!["0: a", "1: b", "2: c"]; 141 | 142 | assert_eq!(mc.get_body_text(), expected); 143 | } 144 | 145 | #[test] 146 | fn can_validate_choice() { 147 | // Setup Logria 148 | let mut logria = MainWindow::_new_dummy(); 149 | 150 | // Setup handler 151 | let mut mc = MultipleChoiceHandler::new(); 152 | mc.set_choices(&["a".to_string(), "b".to_string(), "c".to_string()]); 153 | 154 | // Generate expected result 155 | mc.validate_choice(&mut logria, "1").unwrap(); 156 | 157 | assert_eq!(Some(1), mc.result); 158 | } 159 | 160 | #[test] 161 | fn can_get_choice() { 162 | // Setup Logria 163 | let mut logria = MainWindow::_new_dummy(); 164 | 165 | // Setup handler 166 | let mut mc = MultipleChoiceHandler::new(); 167 | mc.set_choices(&["a".to_string(), "b".to_string(), "c".to_string()]); 168 | 169 | // Generate expected result 170 | mc.validate_choice(&mut logria, "1").unwrap(); 171 | 172 | assert_eq!("b", mc.get_choice().unwrap()); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/util/poll.rs: -------------------------------------------------------------------------------- 1 | use std::collections::vec_deque::VecDeque; 2 | 3 | use std::cmp::{max, min}; 4 | use std::time::Duration; 5 | 6 | use crate::constants::cli::poll_rate::{DEFAULT, FASTEST, SLOWEST}; 7 | 8 | pub fn ms_per_message(timestamp: Duration, messages: u64) -> u64 { 9 | (timestamp.as_millis() as u64) 10 | .checked_div(messages) 11 | .unwrap_or(SLOWEST) 12 | .clamp(FASTEST, SLOWEST) 13 | } 14 | 15 | #[derive(Debug)] 16 | pub struct Backoff { 17 | num_cycles: u64, // The number of times we have increased the poll rate 18 | previous_base: u64, // The previous amount we increased the poll rate by 19 | } 20 | 21 | impl Backoff { 22 | pub fn new() -> Backoff { 23 | Backoff { 24 | num_cycles: 1, 25 | previous_base: DEFAULT, 26 | } 27 | } 28 | 29 | pub fn determine_poll_rate(&mut self, poll_rate: u64) -> u64 { 30 | // Poll rate is capped to SLOWEST in the reader 31 | if poll_rate > self.previous_base || poll_rate == SLOWEST { 32 | let increase = self.previous_base * self.num_cycles; 33 | self.num_cycles = self.num_cycles.checked_add(1).unwrap_or(1); 34 | self.previous_base = min(min(self.previous_base + increase, poll_rate), SLOWEST); 35 | self.previous_base 36 | } else { 37 | self.num_cycles = 1; 38 | self.previous_base = max(poll_rate, 1); // Ensure we are always positive 39 | poll_rate 40 | } 41 | } 42 | } 43 | 44 | #[derive(Debug)] 45 | pub struct RollingMean { 46 | pub deque: VecDeque, 47 | sum: u64, 48 | size: u64, 49 | max_size: usize, 50 | tracker: Backoff, 51 | } 52 | 53 | impl RollingMean { 54 | pub fn new(max_size: usize) -> RollingMean { 55 | RollingMean { 56 | deque: VecDeque::with_capacity(max_size), 57 | sum: 0, 58 | size: 0, 59 | max_size, 60 | tracker: Backoff::new(), 61 | } 62 | } 63 | 64 | pub fn update(&mut self, item: u64) { 65 | if self.deque.len() >= self.max_size { 66 | self.sum -= self.deque.pop_back().unwrap_or(0); 67 | } else { 68 | self.size += 1; 69 | } 70 | let adjusted_item = self.tracker.determine_poll_rate(item); 71 | self.deque.push_front(adjusted_item); 72 | self.sum += adjusted_item; 73 | } 74 | 75 | pub fn mean(&self) -> u64 { 76 | self.sum.checked_div(self.size).unwrap_or(0) 77 | } 78 | 79 | pub fn reset(&mut self) { 80 | self.sum = 0; 81 | self.size = 0; 82 | self.deque.clear(); 83 | } 84 | } 85 | 86 | #[cfg(test)] 87 | mod mean_track_tests { 88 | use crate::util::poll::RollingMean; 89 | 90 | #[test] 91 | fn can_create() { 92 | let tracker = RollingMean::new(5); 93 | assert_eq!(tracker.sum, 0); 94 | assert_eq!(tracker.max_size, 5); 95 | assert_eq!(tracker.size, 0); 96 | } 97 | 98 | #[test] 99 | fn cant_exceed_capacity() { 100 | let mut tracker = RollingMean::new(2); 101 | tracker.update(1); 102 | tracker.update(2); 103 | tracker.update(3); 104 | tracker.update(4); 105 | assert_eq!(tracker.sum, 7); 106 | assert_eq!(tracker.deque.len(), 2); 107 | assert_eq!(tracker.size, 2); 108 | } 109 | 110 | #[test] 111 | fn can_get_mean_full() { 112 | let mut tracker = RollingMean::new(5); 113 | tracker.update(1); 114 | tracker.update(2); 115 | tracker.update(3); 116 | tracker.update(4); 117 | tracker.update(5); 118 | assert_eq!(tracker.sum, 15); 119 | assert_eq!(tracker.mean(), 3); 120 | assert_eq!(tracker.size, 5); 121 | } 122 | 123 | #[test] 124 | fn can_get_mean_under() { 125 | let mut tracker = RollingMean::new(5); 126 | tracker.update(1); 127 | tracker.update(2); 128 | tracker.update(3); 129 | assert_eq!(tracker.sum, 6); 130 | assert_eq!(tracker.mean(), 2); 131 | assert_eq!(tracker.size, 3); 132 | } 133 | 134 | #[test] 135 | fn can_get_mean_over() { 136 | let mut tracker = RollingMean::new(5); 137 | tracker.update(1); 138 | tracker.update(2); 139 | tracker.update(3); 140 | tracker.update(4); 141 | tracker.update(5); 142 | tracker.update(6); 143 | tracker.update(7); 144 | assert_eq!(tracker.sum, 25); 145 | assert_eq!(tracker.mean(), 5); 146 | assert_eq!(tracker.size, 5); 147 | } 148 | } 149 | 150 | #[cfg(test)] 151 | mod tracker_tests { 152 | use crate::util::poll::Backoff; 153 | 154 | #[test] 155 | fn can_create() { 156 | let tracker = Backoff::new(); 157 | assert_eq!(tracker.num_cycles, 1); 158 | } 159 | 160 | #[test] 161 | fn stays_low_no_slowest() { 162 | let mut tracker = Backoff::new(); 163 | tracker.determine_poll_rate(25); 164 | tracker.determine_poll_rate(900); 165 | tracker.determine_poll_rate(34); 166 | assert_eq!(tracker.num_cycles, 1); 167 | } 168 | 169 | #[test] 170 | fn stays_low_when_less_than_max() { 171 | let mut tracker = Backoff::new(); 172 | let result = tracker.determine_poll_rate(25); 173 | assert_eq!(result, 25); 174 | assert_eq!(tracker.num_cycles, 1); 175 | 176 | let result = tracker.determine_poll_rate(1000); 177 | assert_eq!(result, 50); 178 | assert_eq!(tracker.num_cycles, 2); 179 | 180 | let result = tracker.determine_poll_rate(900); 181 | assert_eq!(result, 150); 182 | assert_eq!(tracker.num_cycles, 3); 183 | } 184 | 185 | #[test] 186 | fn expands_slowly() { 187 | let mut tracker = Backoff::new(); 188 | tracker.determine_poll_rate(34); 189 | assert_eq!(tracker.previous_base, 34); 190 | 191 | let mut result = tracker.determine_poll_rate(1000); 192 | assert_eq!(tracker.num_cycles, 2); 193 | assert_eq!(tracker.previous_base, 34 * 2); 194 | assert_eq!(result, 68); 195 | 196 | result = tracker.determine_poll_rate(1000); 197 | assert_eq!(tracker.num_cycles, 3); 198 | assert_eq!(tracker.previous_base, 68 + (68 * 2)); 199 | assert_eq!(result, 68 + (68 * 2)); 200 | 201 | result = tracker.determine_poll_rate(1000); 202 | assert_eq!(tracker.num_cycles, 4); 203 | assert_eq!(tracker.previous_base, 204 + (204 * 3)); 204 | assert_eq!(result, 204 + (204 * 3)); 205 | 206 | result = tracker.determine_poll_rate(1000); 207 | assert_eq!(tracker.num_cycles, 5); 208 | assert_eq!(tracker.previous_base, 1000); 209 | assert_eq!(result, 1000); 210 | 211 | result = tracker.determine_poll_rate(34); 212 | assert_eq!(tracker.num_cycles, 1); 213 | assert_eq!(tracker.previous_base, 34); 214 | assert_eq!(result, 34); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/communication/handlers/user_input.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::{max, min}, 3 | io::{Result, Write, stdout}, 4 | }; 5 | 6 | use crossterm::{cursor, event::KeyCode, queue, style, terminal::size}; 7 | 8 | use crate::{ 9 | communication::{handlers::handler::Handler, reader::MainWindow}, 10 | util::history::Tape, 11 | }; 12 | 13 | // Used in Command and Regex handler to capture user typing 14 | pub struct UserInputHandler { 15 | x: u16, 16 | y: u16, 17 | last_write: u16, 18 | content: Vec, 19 | history: Tape, 20 | } 21 | 22 | impl UserInputHandler { 23 | /// Get the useable area of the textbox container 24 | fn update_dimensions(&mut self) { 25 | let (w, h) = size().unwrap_or((0, 0)); 26 | self.y = h; 27 | self.x = w; 28 | } 29 | 30 | fn y(&self) -> u16 { 31 | self.y - 2 32 | } 33 | 34 | fn x(&self) -> u16 { 35 | self.x - 3 36 | } 37 | 38 | fn get_content(&self) -> String { 39 | self.content.iter().collect() 40 | } 41 | 42 | fn write(&self, window: &mut MainWindow) -> Result<()> { 43 | // Remove the existing content 44 | window.reset_command_line()?; 45 | 46 | // Insert the word to the screen 47 | queue!( 48 | stdout(), 49 | cursor::MoveTo(1, self.y()), 50 | style::Print(self.get_content()), 51 | cursor::MoveTo(self.last_write, self.y()), 52 | cursor::Show 53 | )?; 54 | stdout().flush()?; 55 | Ok(()) 56 | } 57 | 58 | /// Insert character to the input window 59 | /// TODO: Support insert vs normal typing mode 60 | fn insert_char(&mut self, window: &mut MainWindow, character: KeyCode) -> Result<()> { 61 | match character { 62 | KeyCode::Char(c) => { 63 | // Ensure we are using the current screen size 64 | self.update_dimensions(); 65 | 66 | // Handle movement 67 | if self.last_write < self.x() { 68 | // Add the char to our data 69 | self.content.insert(self.position_as_index(), c); 70 | 71 | // Increment the last written position 72 | self.last_write += 1; 73 | 74 | // Insert the word to the screen 75 | self.write(window)?; 76 | } 77 | Ok(()) 78 | } 79 | _ => Ok(()), 80 | } 81 | } 82 | 83 | fn position_as_index(&self) -> usize { 84 | (self.last_write - 1) as usize 85 | } 86 | 87 | /// Remove char 1 to the left of the cursor 88 | fn backspace(&mut self, window: &mut MainWindow) -> Result<()> { 89 | if self.last_write >= 1 && !self.content.is_empty() { 90 | self.content.remove(self.position_as_index() - 1); 91 | self.move_left()?; 92 | self.write(window)?; 93 | } 94 | Ok(()) 95 | } 96 | 97 | /// Remove char 1 to the right of the cursor 98 | fn delete(&mut self, window: &mut MainWindow) -> Result<()> { 99 | if self.last_write < self.x() && !self.content.is_empty() { 100 | self.content.remove(self.position_as_index()); 101 | self.write(window)?; 102 | } 103 | Ok(()) 104 | } 105 | 106 | /// Move the cursor left 107 | fn move_left(&mut self) -> Result<()> { 108 | self.last_write = max(1, self.last_write.checked_sub(1).unwrap_or(1)); 109 | queue!(stdout(), cursor::MoveTo(self.last_write, self.y()),)?; 110 | Ok(()) 111 | } 112 | 113 | /// Move the cursor right 114 | fn move_right(&mut self) -> Result<()> { 115 | // TODO: possible index errors here 116 | self.last_write = min(self.content.len() as u16 + 1, self.last_write + 1); 117 | queue!(stdout(), cursor::MoveTo(self.last_write, self.y()))?; 118 | Ok(()) 119 | } 120 | 121 | /// Get the next item in the history tape if it exists 122 | fn tape_forward(&mut self, window: &mut MainWindow) -> Result<()> { 123 | let content = self.history.scroll_forward(); 124 | self.tape_render(window, &content)?; 125 | Ok(()) 126 | } 127 | 128 | /// Get the previous item in the history tape if it exists 129 | fn tape_back(&mut self, window: &mut MainWindow) -> Result<()> { 130 | let content = self.history.scroll_back(); 131 | self.tape_render(window, &content)?; 132 | Ok(()) 133 | } 134 | 135 | /// Render the new choice 136 | fn tape_render(&mut self, window: &mut MainWindow, content: &str) -> Result<()> { 137 | self.last_write = content.len() as u16 + 1; 138 | window.write_to_command_line(content)?; 139 | self.content = content.chars().collect(); 140 | queue!( 141 | stdout(), 142 | cursor::MoveTo(self.last_write, self.y()), 143 | cursor::Show 144 | )?; 145 | Ok(()) 146 | } 147 | 148 | /// Get the contents of the command line as a String 149 | pub fn gather(&mut self, window: &mut MainWindow) -> Result { 150 | // Copy the result to a new place so we can clear out the existing one and reuse the struct 151 | let result = self.get_content(); 152 | self.content.clear(); 153 | 154 | // Hide the cursor 155 | queue!(stdout(), cursor::Hide)?; 156 | 157 | // Reset the last written spot 158 | self.last_write = 1; 159 | window.reset_command_line()?; 160 | 161 | // Write to the history tape 162 | if window.config.use_history { 163 | match self.history.add_item(&result) { 164 | Ok(()) => {} 165 | Err(why) => window.write_to_command_line(&why.to_string())?, 166 | } 167 | } 168 | 169 | Ok(result) 170 | } 171 | } 172 | 173 | impl Handler for UserInputHandler { 174 | fn new() -> UserInputHandler { 175 | let mut handler = UserInputHandler { 176 | x: 0, 177 | y: 0, 178 | last_write: 1, 179 | content: vec![], 180 | history: Tape::new(), 181 | }; 182 | handler.update_dimensions(); 183 | handler 184 | } 185 | 186 | fn receive_input(&mut self, window: &mut MainWindow, key: KeyCode) -> Result<()> { 187 | queue!(stdout(), cursor::Show)?; 188 | match key { 189 | // Remove data 190 | KeyCode::Delete => self.delete(window)?, 191 | KeyCode::Backspace => self.backspace(window)?, 192 | 193 | // Move cursor 194 | // TODO: Possibly opt+left to skip words/symbols 195 | KeyCode::Left => self.move_left()?, 196 | KeyCode::Right => self.move_right()?, 197 | 198 | KeyCode::Up => self.tape_back(window)?, 199 | KeyCode::Down => self.tape_forward(window)?, 200 | 201 | // Insert char 202 | command => self.insert_char(window, command)?, 203 | } 204 | stdout().flush()?; 205 | Ok(()) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/extensions/session.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashSet, 3 | error::Error, 4 | fs::{create_dir_all, read_dir, read_to_string, remove_file, write}, 5 | path::Path, 6 | result::Result, 7 | }; 8 | 9 | use serde::{Deserialize, Serialize}; 10 | 11 | use crate::{ 12 | constants::{cli::excludes::SESSION_FILE_EXCLUDES, directories::sessions}, 13 | extensions::extension::ExtensionMethods, 14 | util::{error::LogriaError, sanitizers::sanitize_filename}, 15 | }; 16 | 17 | #[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Debug)] 18 | pub enum SessionType { 19 | File, 20 | Command, 21 | Mixed, 22 | } 23 | 24 | #[derive(Serialize, Deserialize, Debug)] 25 | pub struct Session { 26 | pub commands: Vec, 27 | pub stream_type: SessionType, // Cannot use `type` for the name as it is reserved 28 | } 29 | 30 | impl ExtensionMethods for Session { 31 | /// Ensure the proper paths exist 32 | fn verify_path() { 33 | let tape_path = sessions(); 34 | if !Path::new(&tape_path).exists() { 35 | create_dir_all(tape_path).unwrap(); 36 | } 37 | } 38 | 39 | /// Create session file from a Session struct 40 | fn save(self, file_name: &str) -> Result<(), LogriaError> { 41 | let session_json = serde_json::to_string_pretty(&self).unwrap(); 42 | let sanitized_filename = sanitize_filename(file_name); 43 | let path = format!("{}/{}", sessions(), sanitized_filename); 44 | match write(&path, session_json) { 45 | Ok(()) => Ok(()), 46 | Err(why) => Err(LogriaError::CannotWrite(path, ::to_string(&why))), 47 | } 48 | } 49 | 50 | /// Delete the path for a fully qualified session filename 51 | fn del(items: &[usize]) -> Result<(), LogriaError> { 52 | // Iterate through each `i` in `items` and remove the item at list index `i` 53 | let files = Session::list_full(); 54 | for i in items { 55 | if i >= &files.len() { 56 | break; 57 | } 58 | let file_name = &files[*i]; 59 | match remove_file(file_name) { 60 | Ok(()) => {} 61 | Err(why) => { 62 | return Err(LogriaError::CannotRemove( 63 | file_name.to_owned(), 64 | ::to_string(&why), 65 | )); 66 | } 67 | } 68 | } 69 | Ok(()) 70 | } 71 | 72 | /// Get a list of all available session configurations with fully qualified paths 73 | fn list_full() -> Vec { 74 | Session::verify_path(); 75 | // Files to exclude from the session list 76 | let mut excluded = HashSet::new(); 77 | for &item in &SESSION_FILE_EXCLUDES { 78 | excluded.insert(format!("{}/{}", sessions(), item)); 79 | } 80 | 81 | let mut sessions: Vec = read_dir(sessions()) 82 | .unwrap() 83 | .map(|session| String::from(session.unwrap().path().to_str().unwrap())) 84 | .filter(|item| !excluded.contains(item)) 85 | .collect(); 86 | sessions.sort(); 87 | sessions 88 | } 89 | 90 | /// Get a list of all available session configurations for display purposes 91 | fn list_clean() -> Vec { 92 | Session::verify_path(); 93 | // Files to exclude from the session list 94 | let mut excluded = HashSet::new(); 95 | for &item in &SESSION_FILE_EXCLUDES { 96 | excluded.insert(item.to_owned()); 97 | } 98 | 99 | let mut sessions: Vec = read_dir(sessions()) 100 | .unwrap() 101 | .map(|session| { 102 | String::from( 103 | session 104 | .unwrap() 105 | .path() 106 | .file_name() 107 | .unwrap() 108 | .to_str() 109 | .unwrap(), 110 | ) 111 | }) 112 | .filter(|item| !excluded.contains(item)) 113 | .collect(); 114 | sessions.sort(); 115 | sessions 116 | } 117 | } 118 | 119 | impl Session { 120 | /// Create a Session struct 121 | pub fn new(commands: &[String], session_type: SessionType) -> Session { 122 | Session::verify_path(); 123 | Session { 124 | commands: commands.to_owned(), 125 | stream_type: session_type, 126 | } 127 | } 128 | 129 | /// Create Session struct from a session file 130 | pub fn load(file_name: &str) -> Result { 131 | // Read file 132 | let session_json = match read_to_string(file_name) { 133 | Ok(json) => json, 134 | Err(why) => panic!( 135 | "Couldn't open {:?}: {}", 136 | file_name, 137 | ::to_string(&why) 138 | ), 139 | }; 140 | serde_json::from_str(&session_json) 141 | } 142 | } 143 | 144 | #[cfg(test)] 145 | mod tests { 146 | use crate::{ 147 | constants::directories::sessions, 148 | extensions::{ 149 | extension::ExtensionMethods, 150 | session::{Session, SessionType}, 151 | }, 152 | }; 153 | use std::path::Path; 154 | 155 | #[test] 156 | fn test_list_full() { 157 | let list = Session::list_full(); 158 | assert!( 159 | list.iter() 160 | .any(|i| i == &format!("{}/{}", sessions(), "ls -la")) 161 | ); 162 | } 163 | 164 | #[test] 165 | fn test_list_clean() { 166 | let list = Session::list_clean(); 167 | assert!(list.iter().any(|i| i == "ls -la")); 168 | } 169 | 170 | #[test] 171 | fn serialize_session() { 172 | let session = Session::new(&[String::from("ls -la")], SessionType::Command); 173 | session.save("ls -la").unwrap(); 174 | 175 | assert!(Path::new(&format!("{}/{}", sessions(), "ls -la")).exists()); 176 | } 177 | 178 | #[test] 179 | fn deserialize_session() { 180 | let session = Session::new(&[String::from("ls -la")], SessionType::Command); 181 | session.save("ls -la copy").unwrap(); 182 | assert!(Path::new(&format!("{}/{}", sessions(), "ls -la copy")).exists()); 183 | 184 | let file_name = format!("{}/{}", sessions(), "ls -la copy"); 185 | let read_session: Session = Session::load(&file_name).unwrap(); 186 | let expected_session = Session { 187 | commands: vec![String::from("ls -la")], 188 | stream_type: SessionType::Command, 189 | }; 190 | assert_eq!(read_session.commands, expected_session.commands); 191 | assert_eq!(read_session.stream_type, expected_session.stream_type); 192 | } 193 | 194 | #[test] 195 | fn delete_session() { 196 | let session = Session::new(&[String::from("ls -la")], SessionType::Command); 197 | session.save("zzzfake_file_name").unwrap(); 198 | Session::del(&[Session::list_full().len() - 1]).unwrap(); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/constants/cli.rs: -------------------------------------------------------------------------------- 1 | pub mod poll_rate { 2 | // Numerical limits in milliseconds 3 | // Fast enough for smooth typing, 1000Hz 4 | pub const FASTEST: u64 = 1; 5 | // Poll once per second, 1Hz 6 | pub const SLOWEST: u64 = 1000; 7 | // Default rate, 500 hz 8 | pub const DEFAULT: u64 = 50; 9 | } 10 | 11 | pub mod patterns { 12 | pub const ANSI_COLOR_PATTERN: &str = r"(?-u)(\x9b|\x1b\[)[0-?]*[ -/]*[@-~]"; 13 | } 14 | 15 | pub mod colors { 16 | pub const RESET_COLOR: &str = "\x1b[0m"; 17 | pub const HIGHLIGHT_COLOR: &str = "\x1b[35m"; 18 | } 19 | 20 | pub mod excludes { 21 | // Text to exclude from message history 22 | pub const HISTORY_EXCLUDES: [&str; 2] = [":history", ":history off"]; 23 | pub const SESSION_FILE_EXCLUDES: [&str; 1] = [".DS_Store"]; 24 | } 25 | 26 | pub mod cli_chars { 27 | // For the command line interface 28 | pub const NORMAL_STR: &str = "│"; 29 | pub const COMMAND_STR: &str = ":"; 30 | pub const REGEX_STR: &str = "r"; 31 | pub const HIGHLIGHT_STR: &str = "/"; 32 | pub const PARSER_STR: &str = "+"; 33 | 34 | // For matching on keycodes 35 | pub const COMMAND_CHAR: char = ':'; 36 | pub const REGEX_CHAR: char = 'r'; 37 | pub const HIGHLIGHT_CHAR: char = '/'; 38 | pub const PARSER_CHAR: char = 'p'; 39 | pub const AGGREGATION_CHAR: char = 'a'; 40 | pub const SWAP_CHAR: char = 's'; 41 | pub const TOGGLE_HIGHLIGHT_CHAR: char = 'h'; 42 | } 43 | 44 | #[allow(dead_code)] 45 | pub mod messages { 46 | 47 | // Startup messages 48 | pub const START_MESSAGE: [&str; 6] = [ 49 | "Enter a new command to open and save a new stream,", 50 | "or enter a number to choose a saved session from the list.", 51 | " ", // Blank line for printout 52 | "Enter `:r #` to remove session #.", 53 | "Enter `:q` to quit.", 54 | " ", // Blank line for printout 55 | ]; 56 | 57 | // Error messages 58 | pub const NO_MESSAGE_IN_BUFFER_NORMAL: &str = 59 | "No messages in current buffer; press s to swap buffers."; 60 | pub const NO_MESSAGE_IN_BUFFER_PARSER: &str = 61 | "No messages match current parser rules. Press z to exit parsing mode."; 62 | 63 | // Config messages 64 | pub const CONFIG_START_MESSAGES: [&str; 2] = [ 65 | "Saved data paths:", 66 | "To configure new parameters, enter `session` or `parser`", 67 | ]; 68 | pub const CREATE_SESSION_START_MESSAGES: [&str; 1] = 69 | ["To create a session, enter a type, either `command` or `file`:"]; 70 | pub const CREATE_PARSER_MESSAGES: [&str; 1] = 71 | ["To create a parser, enter a type, either `regex` or `split`:"]; 72 | 73 | // Session Strings 74 | pub const SESSION_ADD_COMMAND: &str = "Enter a command to open pipes to:"; 75 | pub const SESSION_SHOULD_CONTINUE_COMMAND: &str = 76 | "Enter :s to save or press enter to add another command"; 77 | pub const SESSION_ADD_FILE: &str = "Enter a path to a file:"; 78 | pub const SESSION_SHOULD_CONTINUE_FILE: &str = 79 | "Enter :s to save or press enter to add another file"; 80 | pub const SAVE_CURRENT_SESSION: &str = "Enter a name to save the session:"; 81 | 82 | // Parser Strings 83 | pub const PARSER_SET_NAME: &str = "Enter a name for the parser:"; 84 | pub const PARSER_SET_EXAMPLE: &str = "Enter an example string to match against:"; 85 | pub const PARSER_SET_PATTERN: &str = "Enter a regex pattern:"; 86 | pub const SAVE_CURRENT_PATTERN: &str = "Press enter to save or type `:q` to quit:"; 87 | 88 | // Startup messages 89 | pub const APP_DESCRIPTION: &str = 90 | "A powerful CLI tool that puts log aggregation at your fingertips."; 91 | pub const EXEC_HELP: &str = "Command to listen to, ex: logria -e \"tail -f log.txt\""; 92 | pub const HISTORY_HELP: &str = "Disable command history disk cache"; 93 | pub const SMART_POLL_RATE_HELP: &str = 94 | "Disable variable polling rate based on incoming message rate"; 95 | pub const DOCS_HELP: &str = "Prints documentation"; 96 | pub const PATHS_HELP: &str = "Prints current configuration paths"; 97 | pub const DOCS: &str = concat!( 98 | "CONTROLS:\n", 99 | " +------+---------------------------------------------------+\n", 100 | " | Key | Command |\n", 101 | " +======+===================================================+\n", 102 | " | : | command mode |\n", 103 | " | / | highlight search |\n", 104 | " | r | regex filter |\n", 105 | " | h | toggle highlighting of search/regex matches |\n", 106 | " | s | swap reading `stderr` and `stdout` |\n", 107 | " | p | activate parser |\n", 108 | " | a | toggle aggregation mode when parser is active |\n", 109 | " | z | deactivate parser |\n", 110 | " | ↑ | scroll buffer up one line |\n", 111 | " | ↓ | scroll buffer down one line |\n", 112 | " | → | skip and stick to end of buffer |\n", 113 | " | ← | skip and stick to beginning of buffer |\n", 114 | " +------+---------------------------------------------------+\n\n", 115 | "COMMANDS:\n", 116 | " +-----------------+----------------------------------------+\n", 117 | " | Key | Command |\n", 118 | " +=================+========================================+\n", 119 | " | :q | exit Logria |\n", 120 | " | :poll # | update poll rate to #, where # is an |\n", 121 | " | | integer (in milliseconds) |\n", 122 | " | :r # | when launching logria or viewing |\n", 123 | " | | sessions, this will delete item # |\n", 124 | " | :agg # | set the limit for aggregation counters |\n", 125 | " | | be top #, i.e. top 5 or top 1 |\n", 126 | " | :history on | enable command history disk cache |\n", 127 | " | :history off | disable command history disk cache |\n", 128 | " +-----------------+----------------------------------------|\n" 129 | ); 130 | pub const PIPE_INPUT_ERROR: &str = concat!( 131 | "Piping to Logria is not supported as it cannot\n", 132 | "both listen to stdin as well as get user input\n", 133 | "from your tty. Process substitution is also not\n", 134 | "allowed, as Logria is unable to read from the\n", 135 | "file descriptor created by the shell.\n", 136 | "\n", 137 | "Piping from Logria is also not supported because\n", 138 | "the interface is fundamentally interactive and\n", 139 | "thus requires a tty.\n", 140 | "\n", 141 | "To capture command output, start Logria and\n", 142 | "enter the command during the setup process,\n", 143 | "invoke Logria with `logria -e \"command\", or\n", 144 | "create a valid session file." 145 | ); 146 | } 147 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Logria Documentation 2 | 3 | This folder contains the documentation on how to interact with Logria programmatically as well as how to leverage its feature-set as a user. 4 | 5 | ## Index 6 | 7 | - [Parsers](parsers.md) 8 | - Details on how to configure parsers for log parsing 9 | - [Sessions](sessions.md) 10 | - Details on how to configure sessions when launching the app 11 | - [Input Handler](input_handler.md) 12 | - Details on how input handler classes open subprocesses 13 | - [Commands](commands.md) 14 | - Details on commands available in the app 15 | 16 | ## Advanced Installation 17 | 18 | `cargo install logria` is the best way to install the app for normal use. 19 | 20 | ### Installing as a standalone app 21 | 22 | - `clone` the repository 23 | - `cd` to the repository 24 | - `cargo test` to make sure everything works 25 | - `cargo run --release` to compile 26 | 27 | ### Directory Configuration 28 | 29 | By default, Logria will create a `/Logria/` directory to store [parsers](parsers.md), [sessions](sessions.md), and an input history tape in. The location is [platform dependent](https://docs.rs/dirs/latest/dirs/fn.config_dir.html). 30 | 31 | #### Platform-Specific Directory Locations 32 | 33 | | Platform | Value | Example | 34 | | --- | --- | --- | 35 | | Linux | `$XDG_DATA_HOME` or `$HOME/.config` | `~/.config/Logria` | 36 | | macOS | `$HOME/Library/Application Support` | `~/Library/Application Support/Logria` | 37 | | Windows | `{FOLDERID_RoamingAppData}` | `%homedrive%%homepath%\AppData\Roaming\Logria` | 38 | 39 | If you want to specify a different path, either set `LOGRIA_ROOT` to replace the `/Logria/` directory or set `LOGRIA_USER_HOME` to move the directory away from the default `$HOME`. Setting both means the app looks in `$LOGRIA_USER_HOME/$LOGRIA_ROOT`. 40 | 41 | #### Example Exports 42 | 43 | | Environment Variable | Value | Result | 44 | |---|---|---| 45 | | `LOGRIA_ROOT` | `.conf/.logria` | `~/.conf/.logria/` | 46 | | `LOGRIA_USER_HOME` | `/usr/local/` | `/usr/local/Logria` | 47 | | both of the above | | `/usr/local/.conf/.logria/` | 48 | 49 | ## Sample Usage Session 50 | 51 | To see available commands, invoke Logria with `-h`: 52 | 53 | ```zsh 54 | chris@home ~ % logria -h 55 | A powerful CLI tool that puts log analytics at your fingertips. 56 | 57 | USAGE: 58 | logria [FLAGS] [OPTIONS] 59 | 60 | Usage: logria [OPTIONS] 61 | 62 | Options: 63 | -t, --no-history-tape Disable command history disk cache 64 | -m, --mindless Disable variable polling rate based on incoming message rate 65 | -d, --docs Prints documentation 66 | -p, --paths Prints current configuration paths 67 | -e, --exec Command to listen to, ex: logria -e "tail -f log.txt" 68 | -h, --help Print help information 69 | -V, --version Print version information 70 | ``` 71 | 72 | Start Logria by invoking it as a command line application: 73 | 74 | ```zsh 75 | chris@home ~ % logria 76 | ``` 77 | 78 | This will launch the app and show us the splash screen: 79 | 80 | ```log 81 | Enter a new command to open and save a new stream, 82 | or enter a number to choose a saved session from the list. 83 | 84 | Enter `:r #` to remove session #. 85 | Enter `:q` to quit. 86 | 87 | 0: File - readme 88 | 1: File - Sample Access Log 89 | 2: Cmd - Generate Test Logs 90 | ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ 91 | │ │ 92 | └────────────────────────────────────────────────────────────────────────────────────────────────┘ 93 | ``` 94 | 95 | Entering `2` will load and open handles to the commands in `Cmd - Generate Test Logs`: 96 | 97 | ```log 98 | 2020-02-23 16:56:10,786 - __main__. - MainProcess - INFO - I am the first log in the list 99 | 2020-02-23 16:56:10,997 - __main__. - MainProcess - INFO - I am a first log! 21 100 | 2020-02-23 16:56:10,997 - __main__. - MainProcess - INFO - I am a second log! 71 101 | 2020-02-23 16:56:11,100 - __main__. - MainProcess - INFO - I am a first log! 43 102 | 2020-02-23 16:56:11,100 - __main__. - MainProcess - INFO - I am a second log! 87 103 | ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ 104 | │ │ 105 | └────────────────────────────────────────────────────────────────────────────────────────────────┘ 106 | ``` 107 | 108 | Typing `r` and entering `100` will filter our stream down to only lines that match that pattern: 109 | 110 | ```log 111 | 2020-02-23 16:56:11,100 - __main__. - MainProcess - INFO - I am a first log! 43 112 | 2020-02-23 16:56:11,100 - __main__. - MainProcess - INFO - I am a second log! 87 113 | ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ 114 | │Regex with pattern /100/ │ 115 | └────────────────────────────────────────────────────────────────────────────────────────────────┘ 116 | ``` 117 | 118 | Pressing `esc` will reset the filter: 119 | 120 | ```log 121 | 2020-02-23 16:56:10,786 - __main__. - MainProcess - INFO - I am the first log in the list 122 | 2020-02-23 16:56:10,997 - __main__. - MainProcess - INFO - I am a first log! 21 123 | 2020-02-23 16:56:10,997 - __main__. - MainProcess - INFO - I am a second log! 71 124 | 2020-02-23 16:56:11,100 - __main__. - MainProcess - INFO - I am a first log! 43 125 | 2020-02-23 16:56:11,100 - __main__. - MainProcess - INFO - I am a second log! 87 126 | ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ 127 | │ │ 128 | └────────────────────────────────────────────────────────────────────────────────────────────────┘ 129 | ``` 130 | 131 | Typing `/` and entering `100` will show all logs, but add a highlight color to matched lines: 132 | 133 | ```log 134 | 2020-02-23 16:56:10,786 - __main__. - MainProcess - INFO - I am the first log in the list 135 | 2020-02-23 16:56:10,997 - __main__. - MainProcess - INFO - I am a first log! 21 136 | 2020-02-23 16:56:10,997 - __main__. - MainProcess - INFO - I am a second log! 71 137 | 2020-02-23 16:56:11,100 - __main__. - MainProcess - INFO - I am a first log! 43 // will be highlighted 138 | 2020-02-23 16:56:11,100 - __main__. - MainProcess - INFO - I am a second log! 87 // will be highlighted 139 | ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ 140 | │Highlight with pattern /100/ │ 141 | └────────────────────────────────────────────────────────────────────────────────────────────────┘ 142 | ``` 143 | 144 | Using `Page Up` and `Page Down` will scroll to the previous and next match, respectively; centering the current match on the screen. 145 | 146 | Typing `:` and entering `q` will exit the app. 147 | 148 | ## Contributing Guidelines 149 | 150 | - No pull request shall be behind develop 151 | - First come, first served 152 | - If anything breaks, the pull request will be queued again when the issue is resolved 153 | - Pull request comments will be resolved by the person who created them 154 | 155 | ## Logo Colors 156 | 157 | - Letters: ![#e63462](../resources/img/e63462.png) `#e63462` 158 | - Accent: ![#333745](../resources/img/333745.png) `#333745` 159 | 160 | ## Notes / Caveats 161 | 162 | - When using `tmux` or other emulators that change the `$TERM` environment variable, you must set the default terminal to something that supports color. In `tmux`, this is as simple as adding `set -g default-terminal "screen-256color"` to `.tmux.conf`. 163 | - The package version in `Cargo.toml` is `0.0.0`. This is because during release that value gets [replaced](/.github/workflows/release.yml) with the current release tag name. 164 | -------------------------------------------------------------------------------- /src/util/aggregators/aggregator.rs: -------------------------------------------------------------------------------- 1 | use crate::util::error::LogriaError; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | /// Attempts to quickly extract a float from a string; may have weird effects 5 | /// if numbers are poorly formatted or are immediately next to each other. 6 | /// 7 | /// This function requires allocation because `parse::()` fails 8 | /// for strings that contain digit separators. 9 | /// 10 | /// Simply selecting the number range from `message` can fail for cases 11 | /// like `"-83,234.34".parse::();` 12 | pub fn extract_number(message: &str) -> Option { 13 | // Result float to parse 14 | let mut result = String::new(); 15 | 16 | // If we have started compiling a float 17 | let mut in_float = false; 18 | 19 | // For each char, check if it is a sign, digit, or digit separator 20 | // If it is, flip the float switch, and build the float string 21 | for (_, char) in message.char_indices() { 22 | if char.is_ascii_digit() || char == '.' || char == ',' || char == '-' { 23 | if !in_float { 24 | in_float = !in_float; 25 | } 26 | // Exclude digit separators; this is the part that requires allocation 27 | if char != ',' { 28 | result.push(char); 29 | } 30 | } else if in_float { 31 | break; 32 | } 33 | } 34 | result.parse::().ok() 35 | } 36 | 37 | /// Formats a float as a string with commas separating thousands 38 | pub fn format_float(n: f64) -> String { 39 | let int_part = n.trunc() as i64; 40 | let abs_str = int_part.abs().to_string(); 41 | let mut result = String::new(); 42 | 43 | let chars: Vec = abs_str.chars().collect(); 44 | let len = chars.len(); 45 | 46 | for (i, c) in chars.iter().enumerate() { 47 | if i != 0 && (len - i) % 3 == 0 { 48 | result.push(','); 49 | } 50 | result.push(*c); 51 | } 52 | 53 | if int_part < 0 { 54 | format!("-{}", result) 55 | } else { 56 | result 57 | } 58 | } 59 | 60 | /// Formats an integer as a string with commas separating thousands 61 | pub fn format_int(n: usize) -> String { 62 | let digits = n.to_string(); 63 | let mut result = String::new(); 64 | 65 | let chars: Vec = digits.chars().collect(); 66 | let len = chars.len(); 67 | 68 | for (i, c) in chars.iter().enumerate() { 69 | if i != 0 && (len - i) % 3 == 0 { 70 | result.push(','); 71 | } 72 | result.push(*c); 73 | } 74 | 75 | result 76 | } 77 | 78 | pub trait Aggregator { 79 | /// Insert an item into the aggregator, updating it's internal tracking data 80 | fn update(&mut self, message: &str) -> Result<(), LogriaError>; 81 | /// Expensive function that generates messages to render 82 | fn messages(&self, n: &usize) -> Vec; 83 | /// Reset the aggregator, clearing all internal data 84 | fn reset(&mut self); 85 | } 86 | 87 | #[derive(Eq, PartialEq, Serialize, Deserialize, Debug)] 88 | pub enum AggregationMethod { 89 | Mean, 90 | Mode, // Special case of Count, for most_common(1) 91 | Sum, 92 | Count, 93 | Date(String), // Format string provided by user 94 | Time(String), // Format string provided by user 95 | DateTime(String), // Format string provided by user 96 | None, 97 | } 98 | 99 | #[cfg(test)] 100 | mod extract_tests { 101 | use super::extract_number; 102 | 103 | #[test] 104 | fn no_number() { 105 | let result = extract_number("this is a test"); 106 | assert!(result.is_none()); 107 | } 108 | 109 | #[test] 110 | fn only_number() { 111 | let result = extract_number("834234.34"); 112 | assert!(result.unwrap() - 834234.34 == 0.); 113 | } 114 | 115 | #[test] 116 | fn only_number_comma() { 117 | let result = extract_number("834,234.34"); 118 | assert!(result.unwrap() - 834234.34 == 0.); 119 | } 120 | 121 | #[test] 122 | fn only_number_multiple_commas() { 123 | let result = extract_number("834,789,234.34"); 124 | assert!(result.unwrap() - 834789234.34 == 0.); 125 | } 126 | 127 | #[test] 128 | fn negative_number() { 129 | let result = extract_number("test -83,234.34 this is"); 130 | assert!(result.unwrap() + 83234.34 == 0.); 131 | } 132 | 133 | #[test] 134 | fn double_negative_number() { 135 | let result = extract_number("test --83,234.34 this is"); 136 | assert!(result.is_none()); 137 | } 138 | 139 | #[test] 140 | fn trailing_negative_number() { 141 | let result = extract_number("test 83,234.34-- this is"); 142 | assert!(result.is_none()); 143 | } 144 | 145 | #[test] 146 | fn number_period_extra() { 147 | let result = extract_number("this is a 123.123.123 test"); 148 | assert!(result.is_none()); 149 | } 150 | 151 | #[test] 152 | fn number_trailing_comma() { 153 | let result = extract_number("this is a 123.123,123 test"); 154 | // This is actually a bad edge case 155 | assert!(result.unwrap() - 123.123123 == 0.); 156 | } 157 | 158 | #[test] 159 | fn number_trailing_decimal() { 160 | let result = extract_number("this is a 123.123. test"); 161 | assert!(result.is_none()); 162 | } 163 | 164 | #[test] 165 | fn one_number_end() { 166 | let result = extract_number("this is a test 123.4"); 167 | assert!(result.unwrap() - 123.4 == 0.); 168 | } 169 | 170 | #[test] 171 | fn one_number_middle() { 172 | let result = extract_number("this is 123.46 a test"); 173 | assert!(result.unwrap() - 123.46 == 0.); 174 | } 175 | 176 | #[test] 177 | fn one_number_start() { 178 | let result = extract_number("653.12 this is a test"); 179 | assert!(result.unwrap() - 653.12 == 0.); 180 | } 181 | 182 | #[test] 183 | fn no_spaces() { 184 | let result = extract_number("thisis983.12a test"); 185 | assert!(result.unwrap() - 983.12 == 0.); 186 | } 187 | 188 | #[test] 189 | fn two_numbers_start_end() { 190 | let result = extract_number("4.123 this is a test 123.4"); 191 | assert!(result.unwrap() - 4.123 == 0.); 192 | } 193 | 194 | #[test] 195 | fn two_numbers_middle() { 196 | let result = extract_number("this 1337 is 5543 a test"); 197 | assert!(result.unwrap() - 1337. == 0.); 198 | } 199 | } 200 | 201 | #[cfg(test)] 202 | mod format_float_tests { 203 | use crate::util::aggregators::aggregator::format_float; 204 | 205 | #[test] 206 | fn test_basic_positive() { 207 | assert_eq!(format_float(1234.56), "1,234"); 208 | assert_eq!(format_float(1000000.0), "1,000,000"); 209 | assert_eq!(format_float(0.0), "0"); 210 | } 211 | 212 | #[test] 213 | fn test_basic_negative() { 214 | assert_eq!(format_float(-1234.56), "-1,234"); 215 | assert_eq!(format_float(-1000000.99), "-1,000,000"); 216 | } 217 | 218 | #[test] 219 | fn test_truncation() { 220 | assert_eq!(format_float(999.999), "999"); 221 | assert_eq!(format_float(-999.999), "-999"); 222 | } 223 | 224 | #[test] 225 | fn test_small_numbers() { 226 | assert_eq!(format_float(9.99), "9"); 227 | assert_eq!(format_float(-9.99), "-9"); 228 | assert_eq!(format_float(0.99), "0"); 229 | } 230 | 231 | #[test] 232 | fn test_large_number() { 233 | assert_eq!(format_float(1234567890.123), "1,234,567,890"); 234 | } 235 | } 236 | 237 | #[cfg(test)] 238 | mod tests { 239 | use crate::util::aggregators::aggregator::format_int; 240 | 241 | #[test] 242 | fn test_format_usize_basic() { 243 | assert_eq!(format_int(0), "0"); 244 | assert_eq!(format_int(12), "12"); 245 | assert_eq!(format_int(123), "123"); 246 | } 247 | 248 | #[test] 249 | fn test_format_usize_commas() { 250 | assert_eq!(format_int(1234), "1,234"); 251 | assert_eq!(format_int(12345), "12,345"); 252 | assert_eq!(format_int(1234567), "1,234,567"); 253 | assert_eq!(format_int(1234567890), "1,234,567,890"); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/util/history.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::min, 3 | error::Error, 4 | fs::{File, OpenOptions, create_dir_all}, 5 | io::{BufRead, BufReader, Write}, 6 | path::Path, 7 | result::Result, 8 | }; 9 | 10 | use crate::{ 11 | constants::{ 12 | cli::excludes::HISTORY_EXCLUDES, 13 | directories::{history, history_tape}, 14 | }, 15 | util::error::LogriaError, 16 | }; 17 | 18 | pub struct Tape { 19 | history_tape: Vec, 20 | current_index: usize, 21 | should_scroll_back: bool, 22 | } 23 | 24 | impl Tape { 25 | /// Ensure the proper paths exist 26 | pub fn verify_path() { 27 | let history_path = history(); 28 | if !Path::new(&history_path).exists() { 29 | create_dir_all(history_path).unwrap(); 30 | } 31 | let tape_path = history_tape(); 32 | if !Path::new(&tape_path).exists() { 33 | File::create(&tape_path).unwrap(); 34 | } 35 | } 36 | 37 | pub fn new() -> Tape { 38 | Tape::verify_path(); 39 | let mut tape = Tape { 40 | history_tape: vec![], 41 | current_index: 0, 42 | should_scroll_back: false, 43 | }; 44 | match tape.read_from_disk() { 45 | Ok(()) => {} 46 | Err(why) => panic!("{:?}", &why.to_string()), 47 | } 48 | tape 49 | } 50 | 51 | /// Read the history file from the disk to the current history buffer 52 | fn read_from_disk(&mut self) -> Result<(), LogriaError> { 53 | match OpenOptions::new().read(true).open(history_tape()) { 54 | // The `description` method of `io::Error` returns a string that describes the error 55 | Err(why) => Err(LogriaError::CannotRead( 56 | history_tape(), 57 | ::to_string(&why), 58 | )), 59 | Ok(file) => { 60 | // Create a buffer and read from it 61 | let reader = BufReader::new(file); 62 | for line in reader.lines() { 63 | if let Ok(item) = line { 64 | self.history_tape.push(item); 65 | } else { 66 | break; 67 | } 68 | } 69 | 70 | self.current_index = self.history_tape.len().checked_sub(1).unwrap_or_default(); 71 | Ok(()) 72 | } 73 | } 74 | } 75 | 76 | /// Add an item to the history tape 77 | pub fn add_item(&mut self, item: &str) -> Result<(), LogriaError> { 78 | let clean_item = item.trim(); 79 | if HISTORY_EXCLUDES.contains(&clean_item) { 80 | return Ok(()); 81 | } 82 | // Write to internal buffer 83 | self.history_tape.push(String::from(clean_item)); 84 | 85 | // Reset tape to end 86 | self.should_scroll_back = false; 87 | self.current_index = self.history_tape.len().checked_sub(1).unwrap_or_default(); 88 | 89 | // Write to file 90 | match OpenOptions::new() 91 | .read(true) 92 | .append(true) 93 | .open(history_tape()) 94 | { 95 | // The `description` method of `io::Error` returns a string that describes the error 96 | Err(why) => Err(LogriaError::CannotRead( 97 | history_tape(), 98 | ::to_string(&why), 99 | )), 100 | Ok(mut file) => match writeln!(file, "{clean_item}") { 101 | Ok(()) => Ok(()), 102 | Err(why) => Err(LogriaError::CannotWrite( 103 | history_tape(), 104 | ::to_string(&why), 105 | )), 106 | }, 107 | } 108 | } 109 | 110 | /// Rewind the tape if possible 111 | fn scroll_back_n(&mut self, num_to_scroll: usize) { 112 | if !self.history_tape.is_empty() { 113 | if self.should_scroll_back { 114 | self.current_index = self 115 | .current_index 116 | .checked_sub(num_to_scroll) 117 | .unwrap_or_default(); 118 | } else { 119 | self.should_scroll_back = true; 120 | } 121 | } 122 | } 123 | 124 | /// Scroll the tape forward if possible 125 | fn scroll_forward_n(&mut self, num_to_scroll: usize) { 126 | if self.current_index != self.history_tape.len().checked_sub(1).unwrap_or_default() 127 | && !self.history_tape.is_empty() 128 | { 129 | self.current_index = min( 130 | self.history_tape.len().checked_sub(1).unwrap_or_default(), 131 | self.current_index 132 | .checked_add(num_to_scroll) 133 | .unwrap_or_else(|| self.history_tape.len().checked_sub(1).unwrap_or_default()), 134 | ); 135 | } 136 | } 137 | 138 | /// Common case where we scroll back a single item 139 | pub fn scroll_back(&mut self) -> String { 140 | self.scroll_back_n(1); 141 | self.get_current_item() 142 | } 143 | 144 | /// Common case where we scroll up a single item 145 | pub fn scroll_forward(&mut self) -> String { 146 | self.scroll_forward_n(1); 147 | self.get_current_item() 148 | } 149 | 150 | pub fn get_current_item(&self) -> String { 151 | self.history_tape[self.current_index].clone() 152 | } 153 | } 154 | 155 | #[cfg(test)] 156 | mod tests { 157 | use super::Tape; 158 | 159 | #[test] 160 | fn can_construct() { 161 | Tape::new(); 162 | } 163 | 164 | #[test] 165 | fn can_add_item() { 166 | let mut tape = Tape::new(); 167 | tape.add_item("test").unwrap(); 168 | assert_eq!(String::from("test"), tape.get_current_item()); 169 | } 170 | 171 | #[test] 172 | fn scroll_back_n_good() { 173 | let mut tape = Tape::new(); 174 | 175 | // Create some dummy data 176 | (0..10).for_each(|_| tape.history_tape.push(String::new())); 177 | tape.current_index = tape.history_tape.len().checked_sub(1).unwrap_or_default(); 178 | tape.should_scroll_back = true; 179 | 180 | tape.scroll_back_n(5); 181 | assert_eq!(tape.current_index, tape.history_tape.len() - 5 - 1); 182 | } 183 | 184 | #[test] 185 | fn scroll_back_n_too_many() { 186 | let mut tape = Tape::new(); 187 | 188 | // Create some dummy data 189 | (0..5).for_each(|_| tape.history_tape.push(String::new())); 190 | tape.current_index = tape.history_tape.len().checked_sub(1).unwrap_or_default(); 191 | tape.should_scroll_back = true; 192 | 193 | tape.scroll_back_n(tape.history_tape.len() * 2); 194 | assert_eq!(tape.current_index, 0); 195 | } 196 | 197 | #[test] 198 | fn scroll_back_one() { 199 | let mut tape = Tape::new(); 200 | 201 | // Create some dummy data 202 | (0..5).for_each(|_| tape.history_tape.push(String::new())); 203 | tape.current_index = tape.history_tape.len().checked_sub(1).unwrap_or_default(); 204 | tape.should_scroll_back = true; 205 | 206 | tape.scroll_back(); 207 | assert_eq!(tape.current_index, tape.history_tape.len() - 1 - 1); 208 | } 209 | 210 | #[test] 211 | fn scroll_forward_n_good() { 212 | let mut tape = Tape::new(); 213 | 214 | // Create some dummy data 215 | (0..25).for_each(|_| tape.history_tape.push(String::new())); 216 | tape.current_index = tape.history_tape.len().checked_sub(1).unwrap_or_default(); 217 | tape.should_scroll_back = true; 218 | 219 | tape.scroll_back_n(10); 220 | tape.scroll_forward_n(5); 221 | assert_eq!(tape.current_index, tape.history_tape.len() - 5 - 1); 222 | } 223 | 224 | #[test] 225 | fn scroll_forward_n_too_many() { 226 | let mut tape = Tape::new(); 227 | 228 | // Create some dummy data 229 | (0..5).for_each(|_| tape.history_tape.push(String::new())); 230 | tape.current_index = tape.history_tape.len().checked_sub(1).unwrap_or_default(); 231 | tape.should_scroll_back = true; 232 | 233 | tape.scroll_back_n(10); 234 | tape.scroll_forward_n(25); 235 | assert_eq!(tape.current_index, tape.history_tape.len() - 1); 236 | } 237 | 238 | #[test] 239 | fn scroll_forward_one() { 240 | let mut tape = Tape::new(); 241 | 242 | // Create some dummy data 243 | (0..5).for_each(|_| tape.history_tape.push(String::new())); 244 | tape.current_index = tape.history_tape.len().checked_sub(1).unwrap_or_default(); 245 | tape.should_scroll_back = true; 246 | 247 | tape.scroll_back(); 248 | tape.scroll_forward(); 249 | assert_eq!(tape.current_index, tape.history_tape.len() - 1); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /docs/parsers.md: -------------------------------------------------------------------------------- 1 | # Parser Documentation 2 | 3 | A parser includes a pattern with associated metadata that Logria uses to parse and aggregate log messages. 4 | 5 | ## Storage 6 | 7 | Parsers are stored as `JSON` in [`$LOGRIA_USER_HOME/$LOGRIA_ROOT/parsers`](README.md#directory-configuration) and do not have file extensions. A parser is defined like so: 8 | 9 | ```json 10 | { 11 | "pattern": " - ", 12 | "pattern_type": "Split", 13 | "example": "2005-03-19 15:10:26,773 - simple_example - CRITICAL - critical message", 14 | "order": [ 15 | "Timestamp", 16 | "Method", 17 | "Level", 18 | "Message" 19 | ], 20 | "aggregation_methods": { 21 | "Timestamp": { 22 | "DateTime": "[year]-[month]-[day] [hour]:[minute]:[second],[subsecond]" 23 | }, 24 | "Method": "Count", 25 | "Level": "Count", 26 | "Message": "Sum" 27 | } 28 | } 29 | ``` 30 | 31 | If [`$LOGRIA_USER_HOME/$LOGRIA_ROOT/parsers`](README.md#directory-configuration) does not exist, Logria will create it. 32 | 33 | ## Anatomy 34 | 35 | All parsers have the following keys: 36 | 37 | - `pattern` 38 | - The pattern to apply 39 | - `pattern_type` 40 | - The method we intend to apply the pattern with, one of {`regex`, `split`}, detailed in [Types of Parsers](#types-of-parsers) 41 | - `name` 42 | - The name of the parser 43 | - Displayed to the user when selecting parsers 44 | - `example` 45 | - An example message to match with the parser 46 | - Displayed to the user when selecting which part of a message to render 47 | - `order` 48 | - The order the message parts occur in for aggregation 49 | - `aggregation_methods` 50 | - Can be `Mean`, `Sum`, `Count`, `Mode`, `Date`, `Time`, `DateTime`, and `None` 51 | - See [Aggregation Methods](#aggregation-methods) below for details 52 | 53 | ## Types of Parsers 54 | 55 | There are two types of parsers: `regex` and `split`. 56 | 57 | ### Regex Parser 58 | 59 | A `regex` parser uses a regex expression to match parts of a log and looks like this: 60 | 61 | ```json 62 | { 63 | "pattern": "([^ ]*) ([^ ]*) ([^ ]*) \\[([^]]*)\\] \"([^\"]*)\" ([^ ]*) ([^ ]*)", 64 | "pattern_type": "Regex", 65 | "example": "127.0.0.1 user-identifier user-name [10/Oct/2000:13:55:36 -0700] \"GET /apache_pb.gif HTTP/1.0\" 200 2326", 66 | "order": [ 67 | "Remote Host", 68 | "User ID", 69 | "Username", 70 | "Date", 71 | "Request", 72 | "Status", 73 | "Size", 74 | ], 75 | "aggregation_methods": { 76 | "Remote Host": "Count", 77 | "User ID": "Count", 78 | "Username": "Count", 79 | "Date": "Count", 80 | "Request": "Count", 81 | "Status": "Count", 82 | "Size": "Count" 83 | } 84 | } 85 | ``` 86 | 87 | ### Split Patterns 88 | 89 | A `split` parser uses [str::split](https://doc.rust-lang.org/std/primitive.str.html#method.split) to split a message on a delimiter: 90 | 91 | ```json 92 | { 93 | "pattern": " - ", 94 | "pattern_type": "Split", 95 | "example": "2005-03-19 15:10:26,773 - simple_example - CRITICAL - critical message", 96 | "order": [ 97 | "Timestamp", 98 | "Method", 99 | "Level", 100 | "Message" 101 | ], 102 | "aggregation_methods": { 103 | "Timestamp": { 104 | "DateTime": "[year]-[month]-[day] [hour]:[minute]:[second],[subsecond]" 105 | }, 106 | "Method": "Count", 107 | "Level": "Count", 108 | "Message": "Sum" 109 | } 110 | } 111 | ``` 112 | 113 | ## Aggregation Methods 114 | 115 | The `aggregation_methods` key stores a `HashMap` of the name of the parsed message to a method to handle message aggregation. Since `HashMap`s are unordered, a list called `order` must also be present. This list contains strings that match the key names in `aggregation_methods`. 116 | 117 | ### Included Methods 118 | 119 | Methods currently include [`Mean`](#mean-and-sum), [`Sum`](#mean-and-sum), [`Count`](#count-and-mode), [`Mode`](#count-and-mode), [`Date`](#date-time-and-datetime), [`Time`](#date-time-and-datetime), [`DateTime`](#date-time-and-datetime), and [`None`](#none). These all have different behaviors. 120 | 121 | #### Mean and Sum 122 | 123 | Both of these methods search for the first occurrence of a number in the parsed message. 124 | 125 | `Mean` will display the mean, the count, and the sum of the parsed floats: 126 | 127 | ```txt 128 | Message 129 | Mean: 51.32 130 | Count: 5.113 131 | Total: 262,417 132 | ``` 133 | 134 | `Sum` will only display the sum: 135 | 136 | ```txt 137 | Message 138 | Total: 262,417 139 | ``` 140 | 141 | Float parsing logic is defined and tested in [aggregators.rs](../src/util/aggregators/aggregator.rs). Some examples include: 142 | 143 | ```rust 144 | extract_number("653.12 this is a test"); // 653.12 145 | extract_number("4.123 this is a test 123.4"); // 4.123 146 | extract_number("this is a 123.123. test"); // None, invalid 147 | ``` 148 | 149 | #### Count and Mode 150 | 151 | This uses a data structure similar to Python's [`collections.Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) to keep track of messages. Each message is hashed, so identical messages will get incremented. It defaults to displaying the top 5 results; this can be adjusted using the `:agg` [command](commands.md#commands). 152 | 153 | When activated, it will display the ordinal count of each occurrence as well as its ratio to the total amount of messages counted: 154 | 155 | ```txt 156 | Level 157 | INFO: 2,794 (55%) 158 | WARNING: 1,433 (28%) 159 | ERROR: 886 (17%) 160 | ``` 161 | 162 | `Mode` is a special case of `Counter` where the top `n` is frozen to `1`. 163 | 164 | #### Date, Time, and DateTime 165 | 166 | `Date`, `Time`, or `DateTime` methods require a format description as outlined in the [`time` book](https://time-rs.github.io/book/api/format-description.html) or [`time` docs](https://docs.rs/time/0.3.3/time/struct.Date.html#method.parse). 167 | 168 | `Date` will default all messages to [midnight](https://docs.rs/time/latest/time/struct.Time.html#associatedconstant.MIDNIGHT) and `Time` will default all messages to [min](https://docs.rs/time/latest/time/struct.Date.html#associatedconstant.MIN). 169 | 170 | When activated, these methods display the rate at which messages are received, the total number of messages, and the earliest and latest timestamps. 171 | 172 | ```txt 173 | Timestamp 174 | Rate: 196 per second 175 | Count: 5,113 176 | Earliest: 2021-11-15 22:28:42.21 177 | Latest: 2021-11-15 22:29:08.389 178 | ``` 179 | 180 | #### None 181 | 182 | `None` disables parsing for that field. It displays like this when activated: 183 | 184 | ```txt 185 | Timestamp 186 | Disabled 187 | ``` 188 | 189 | ### Example Aggregation Data 190 | 191 | Given an `order` and `aggregation_map` with methods like this: 192 | 193 | ```json 194 | "order": [ 195 | "Timestamp", 196 | "Method", 197 | "Level", 198 | "Message" 199 | ], 200 | "aggregation_methods": { 201 | "Timestamp": { 202 | "DateTime": "[year]-[month]-[day] [hour]:[minute]:[second],[subsecond]" 203 | }, 204 | "Method": "Count", 205 | "Level": "Count", 206 | "Message": "Mean" 207 | } 208 | ``` 209 | 210 | The resultant aggregation data will render like so: 211 | 212 | ```txt 213 | Timestamp 214 | Rate: 196 per second 215 | Count: 5,113 216 | Earliest: 2021-11-15 22:28:42.21 217 | Latest: 2021-11-15 22:29:08.389 218 | Method 219 | __main__.‹module>: 2,215 (43%) 220 | __main__.first: 1,433 (28%) 221 | __main__.second: 886 (17%) 222 | __main__.third: 579 (11%) 223 | Process 224 | MainProcess: 5,113 (100%) 225 | Level 226 | INFO: 2,794 (55%) 227 | WARNING: 1,433 (28%) 228 | ERROR: 886 (17%) 229 | Message 230 | Mean: 51.32 231 | Count: 5.113 232 | Total: 262,417 233 | ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ 234 | |Parsing with Color + Hyphen Separated, aggregation mode │ 235 | └────────────────────────────────────────────────────────────────────────────────────────────────┘ 236 | ``` 237 | 238 | ## Activating Parsers 239 | 240 | When invoked, Logria will list the parsers defined in the parsers directory for the user to select based on the index of the filename: 241 | 242 | ```zsh 243 | 0: Common Log Format 244 | 1: Hyphen Separated 245 | 2: Color + Hyphen Separated 246 | ``` 247 | 248 | Once the first selection has been made, the user will be able to select which part of the matched log we will use when streaming: 249 | 250 | ```zsh 251 | 0: 2020-02-04 19:06:52,852 252 | 1: __main__. 253 | 2: MainProcess 254 | 3: INFO 255 | 4: I am a log! 91 256 | ``` 257 | 258 | This text is generated by the `example` key in the parser's `JSON`. 259 | -------------------------------------------------------------------------------- /src/communication/handlers/startup.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, io::Result}; 2 | 3 | use crossterm::event::KeyCode; 4 | 5 | use super::{handler::Handler, user_input::UserInputHandler}; 6 | use crate::{ 7 | communication::{ 8 | input::{ 9 | InputType, StreamType::StdErr, build_streams_from_input, build_streams_from_session, 10 | }, 11 | reader::MainWindow, 12 | }, 13 | constants::cli::{cli_chars::COMMAND_CHAR, messages::START_MESSAGE}, 14 | extensions::{extension::ExtensionMethods, session::Session}, 15 | ui::scroll, 16 | }; 17 | 18 | pub struct StartupHandler { 19 | input_handler: UserInputHandler, 20 | session_data: HashMap, 21 | } 22 | 23 | impl StartupHandler { 24 | /// Generate the startup message with available session configurations 25 | pub fn get_startup_text() -> Vec { 26 | let mut text: Vec = Vec::new(); 27 | let sessions = Session::list_clean(); 28 | START_MESSAGE.iter().for_each(|&s| text.push(s.to_string())); 29 | sessions.iter().enumerate().for_each(|(i, s)| { 30 | let value = s.to_string(); 31 | text.push(format!("{i}: {value}")); 32 | }); 33 | text 34 | } 35 | 36 | /// Load the `session_data` hashmap internally 37 | fn initialize(&mut self) { 38 | let sessions = Session::list_full(); 39 | sessions.iter().enumerate().for_each(|(i, s)| { 40 | let value = s.to_string(); 41 | self.session_data.insert(i, value); 42 | }); 43 | } 44 | 45 | fn process_command(&mut self, window: &mut MainWindow, command: &str) -> Result<()> { 46 | let selection = command.parse::(); 47 | if let Ok(item) = selection { 48 | match self.session_data.get(&item) { 49 | Some(file_path) => { 50 | let session = Session::load(file_path); 51 | match session { 52 | // Successfully start the app 53 | Ok(session) => { 54 | window.config.streams = match build_streams_from_session(session) { 55 | Ok(streams) => streams, 56 | Err(why) => { 57 | window.write_to_command_line(&why.to_string())?; 58 | return Ok(()); 59 | } 60 | }; 61 | window.config.stream_type = StdErr; 62 | window.update_input_type(InputType::Normal)?; 63 | window.config.generate_auxiliary_messages = None; 64 | window.config.message_speed_tracker.reset(); 65 | window.reset_output()?; 66 | window.redraw()?; 67 | } 68 | Err(why) => { 69 | window.write_to_command_line(&format!( 70 | "Unable to parse session: {why:?}" 71 | ))?; 72 | } 73 | } 74 | } 75 | None => { 76 | window.write_to_command_line("Invalid selection!")?; 77 | } 78 | } 79 | Ok(()) 80 | } else { 81 | window.config.streams = match build_streams_from_input(&[command.to_owned()], true) { 82 | Ok(streams) => streams, 83 | Err(why) => { 84 | window.write_to_command_line(&why.to_string())?; 85 | match build_streams_from_input(&[command.to_owned()], false) { 86 | Ok(streams) => streams, 87 | Err(why) => { 88 | window.write_to_command_line(&why.to_string())?; 89 | return Ok(()); 90 | } 91 | } 92 | } 93 | }; 94 | window.config.stream_type = StdErr; 95 | window.update_input_type(InputType::Normal)?; 96 | window.reset_output()?; 97 | window.redraw()?; 98 | Ok(()) 99 | } 100 | } 101 | } 102 | 103 | impl Handler for StartupHandler { 104 | fn new() -> StartupHandler { 105 | StartupHandler { 106 | input_handler: UserInputHandler::new(), 107 | session_data: HashMap::new(), 108 | } 109 | } 110 | 111 | fn receive_input(&mut self, window: &mut MainWindow, key: KeyCode) -> Result<()> { 112 | match key { 113 | // Scroll 114 | KeyCode::Down => scroll::down(window), 115 | KeyCode::Up => scroll::up(window), 116 | KeyCode::Left => scroll::top(window), 117 | KeyCode::Right => scroll::bottom(window), 118 | KeyCode::Home => scroll::top(window), 119 | KeyCode::End => scroll::bottom(window), 120 | KeyCode::PageUp => scroll::pg_up(window), 121 | KeyCode::PageDown => scroll::pg_down(window), 122 | 123 | // Mode change for remove or config commands 124 | KeyCode::Char(COMMAND_CHAR) => window.set_command_mode(Some(Session::del))?, 125 | 126 | // Handle user input selection 127 | KeyCode::Enter => { 128 | // Ensure the hashmap of files is updated 129 | self.initialize(); 130 | let command = match self.input_handler.gather(window) { 131 | Ok(command) => command, 132 | Err(why) => panic!("Unable to gather text: {why:?}"), 133 | }; 134 | if !command.is_empty() { 135 | self.process_command(window, &command)?; 136 | } 137 | } 138 | 139 | // User input 140 | key => self.input_handler.receive_input(window, key)?, 141 | } 142 | window.redraw()?; 143 | Ok(()) 144 | } 145 | } 146 | 147 | #[cfg(test)] 148 | mod startup_tests { 149 | use crate::{ 150 | communication::{ 151 | handlers::handler::Handler, 152 | input::{InputType, StreamType}, 153 | reader::MainWindow, 154 | }, 155 | constants::cli::messages::START_MESSAGE, 156 | extensions::{ 157 | extension::ExtensionMethods, 158 | session::{Session, SessionType::Command}, 159 | }, 160 | }; 161 | 162 | use super::StartupHandler; 163 | 164 | #[test] 165 | fn can_initialize() { 166 | let mut handler = StartupHandler::new(); 167 | handler.initialize(); 168 | } 169 | 170 | #[test] 171 | fn can_get_startup_text() { 172 | let text = StartupHandler::get_startup_text(); 173 | let sessions = Session::list_full(); 174 | assert_eq!(text.len(), sessions.len() + START_MESSAGE.len()); 175 | } 176 | 177 | #[test] 178 | fn can_load_session() { 179 | // Create a new dummy session 180 | let session = Session::new(&[String::from("ls -la")], Command); 181 | session.save("ls -la").unwrap(); 182 | 183 | // Setup dummy window 184 | let mut window = MainWindow::_new_dummy(); 185 | 186 | // Setup handler 187 | let mut handler = StartupHandler::new(); 188 | handler.initialize(); 189 | 190 | // Tests 191 | assert!(handler.process_command(&mut window, "0").is_ok()); 192 | assert!(matches!(window.input_type, InputType::Normal)); 193 | assert!(matches!(window.config.stream_type, StreamType::StdErr)); 194 | } 195 | 196 | #[test] 197 | fn doesnt_crash_bad_index() { 198 | // Setup dummy window 199 | let mut window = MainWindow::_new_dummy(); 200 | window.config.stream_type = StreamType::Auxiliary; 201 | 202 | // Setup handler 203 | let mut handler = StartupHandler::new(); 204 | handler.initialize(); 205 | 206 | // Tests 207 | assert!(handler.process_command(&mut window, "999").is_ok()); 208 | assert!(matches!(window.input_type, InputType::Startup)); 209 | assert!(matches!(window.config.stream_type, StreamType::Auxiliary)); 210 | } 211 | 212 | #[test] 213 | fn doesnt_crash_invalid_command_startup() { 214 | // Setup dummy window 215 | let mut window = MainWindow::_new_dummy(); 216 | window.config.stream_type = StreamType::Auxiliary; 217 | 218 | // Setup handler 219 | let mut handler = StartupHandler::new(); 220 | handler.initialize(); 221 | 222 | // Tests 223 | assert!( 224 | handler 225 | .process_command(&mut window, "zzzfake_file_name") 226 | .is_ok() 227 | ); 228 | assert!(matches!(window.input_type, InputType::Startup)); 229 | assert!(matches!(window.config.stream_type, StreamType::Auxiliary)); 230 | Session::del(&[Session::list_full().len() - 1]).unwrap(); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/util/aggregators/counter.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::Reverse, 3 | collections::{BinaryHeap, HashMap}, 4 | }; 5 | 6 | use crate::{ 7 | constants::cli::colors::RESET_COLOR, 8 | util::{ 9 | aggregators::aggregator::{Aggregator, format_int}, 10 | error::LogriaError, 11 | }, 12 | }; 13 | 14 | /// A counter for tracking occurrences of messages, similar to Python's `Counter`. 15 | pub struct Counter { 16 | /// Map of message strings to their occurrence counts. 17 | counts: HashMap, 18 | /// Total number of messages processed. 19 | total_count: u64, 20 | /// Optional limit on the number of top messages to display. 21 | frozen_n: Option, 22 | } 23 | 24 | impl Aggregator for Counter { 25 | /// Implements [`Aggregator::update`]: increments the count for the given message. 26 | fn update(&mut self, message: &str) -> Result<(), LogriaError> { 27 | self.increment(message); 28 | Ok(()) 29 | } 30 | 31 | /// Implements [`Aggregator::messages`]: returns the top `n` messages. 32 | fn messages(&self, n: &usize) -> Vec { 33 | self.get_top_messages(*n) 34 | } 35 | 36 | /// Implements [`Aggregator::reset`]: clears all message counts. 37 | fn reset(&mut self) { 38 | self.counts.clear(); 39 | self.total_count = 0; 40 | } 41 | } 42 | 43 | impl Counter { 44 | /// Creates a new `Counter` with no messages counted and no limits. 45 | pub fn new() -> Counter { 46 | Counter { 47 | counts: HashMap::new(), 48 | total_count: 0, 49 | frozen_n: None, 50 | } 51 | } 52 | 53 | /// Creates a new `Counter` configured to return only the top message. 54 | pub fn mean() -> Counter { 55 | Counter { 56 | counts: HashMap::new(), 57 | total_count: 0, 58 | frozen_n: Some(1), 59 | } 60 | } 61 | 62 | /// Increments the count for `item`, adding it if not already present. 63 | pub fn increment(&mut self, item: &str) { 64 | let count = self.counts.entry(item.to_string()).or_insert(0); 65 | *count += 1; 66 | self.total_count += 1; 67 | } 68 | 69 | /// Decrements the count for `item`, removing it if its count reaches zero. 70 | pub fn decrement(&mut self, item: &str) { 71 | if let Some(count) = self.counts.get_mut(item) { 72 | if *count > 1 { 73 | *count -= 1; 74 | self.total_count -= 1; 75 | } else { 76 | self.counts.remove(item); 77 | self.total_count -= 1; 78 | } 79 | } 80 | } 81 | 82 | /// Removes `item` entirely from the counter, subtracting its count from the total. 83 | pub fn delete(&mut self, item: &str) { 84 | if let Some(count) = self.counts.remove(item) { 85 | self.total_count -= count; 86 | } 87 | } 88 | 89 | /// Retrieves the top `n` messages, respecting `frozen_n` if set. 90 | fn get_top_messages(&self, n: usize) -> Vec { 91 | let actual_num = self.frozen_n.unwrap_or(n).min(self.counts.len()); 92 | 93 | if actual_num == 0 || self.total_count == 0 { 94 | return Vec::new(); 95 | } 96 | 97 | self.compute_top_messages(actual_num) 98 | } 99 | 100 | /// Computes the top `n` messages sorted by count (descending) and message text. 101 | fn compute_top_messages(&self, n: usize) -> Vec { 102 | // A min-heap that only ever holds the top n entries. 103 | let total = self.total_count as f64; 104 | let mut heap = BinaryHeap::with_capacity(n + 1); 105 | 106 | for (item, &count) in &self.counts { 107 | // Reverse so that smallest count is at the top—and will get popped when > n 108 | heap.push(Reverse((count, item.as_str()))); 109 | if heap.len() > n { 110 | heap.pop(); 111 | } 112 | } 113 | 114 | // Drain heap into a Vec, sort descending by count then key 115 | let mut top: Vec<(u64, &str)> = heap 116 | .into_iter() 117 | .map(|Reverse((count, item))| (count, item)) 118 | .collect(); 119 | 120 | top.sort_unstable_by(|(ca, a), (cb, b)| cb.cmp(ca).then_with(|| a.cmp(b))); 121 | 122 | // Format output 123 | top.into_iter() 124 | .map(|(count, item)| { 125 | let pct = (count as f64 / total) * 100.0; 126 | format!( 127 | " {}{}: {} ({:.0}%)", 128 | item.trim(), 129 | RESET_COLOR, 130 | format_int(count as usize), 131 | pct 132 | ) 133 | }) 134 | .collect() 135 | } 136 | } 137 | 138 | #[cfg(test)] 139 | mod behavior_tests { 140 | use crate::util::aggregators::{aggregator::Aggregator, counter::Counter}; 141 | use std::collections::HashMap; 142 | 143 | static A: &str = "a"; 144 | static B: &str = "b"; 145 | 146 | #[test] 147 | fn can_construct_counter() { 148 | Counter::new(); 149 | } 150 | 151 | #[test] 152 | fn can_count_int() { 153 | let mut c: Counter = Counter::new(); 154 | c.increment("1"); 155 | c.increment("1"); 156 | c.increment("1"); 157 | c.increment("2"); 158 | c.increment("2"); 159 | 160 | let mut expected_count = HashMap::new(); 161 | expected_count.insert("1".to_string(), 3); 162 | expected_count.insert("2".to_string(), 2); 163 | 164 | assert_eq!(c.counts.get("1"), Some(&3)); 165 | assert_eq!(c.counts.get("2"), Some(&2)); 166 | assert_eq!(c.total_count, 5); 167 | } 168 | 169 | #[test] 170 | fn can_count() { 171 | let mut c: Counter = Counter::new(); 172 | c.increment(A); 173 | c.increment(A); 174 | c.increment(A); 175 | c.increment(B); 176 | c.increment(B); 177 | 178 | let mut expected_count = HashMap::new(); 179 | expected_count.insert(A.to_owned(), 3); 180 | expected_count.insert(B.to_owned(), 2); 181 | 182 | assert_eq!(c.counts.get(A), Some(&3)); 183 | assert_eq!(c.counts.get(B), Some(&2)); 184 | assert_eq!(c.total_count, 5); 185 | } 186 | 187 | #[test] 188 | fn can_sum() { 189 | let mut c: Counter = Counter::new(); 190 | c.update(A).unwrap(); 191 | c.update(A).unwrap(); 192 | c.update(A).unwrap(); 193 | c.update(B).unwrap(); 194 | c.update(B).unwrap(); 195 | 196 | let mut expected = HashMap::new(); 197 | expected.insert(A.to_owned(), 3); 198 | expected.insert(B.to_owned(), 2); 199 | 200 | assert_eq!(c.total_count, 5); 201 | } 202 | 203 | #[test] 204 | fn can_decrement() { 205 | let mut c: Counter = Counter::new(); 206 | c.increment(A); 207 | c.increment(A); 208 | c.increment(A); 209 | c.increment(B); 210 | c.increment(B); 211 | c.decrement(A); 212 | 213 | let mut expected_count = HashMap::new(); 214 | expected_count.insert(A.to_owned(), 2); 215 | expected_count.insert(B.to_owned(), 2); 216 | 217 | assert_eq!(c.counts.get(A), Some(&2)); 218 | assert_eq!(c.counts.get(B), Some(&2)); 219 | assert_eq!(c.total_count, 4); 220 | } 221 | 222 | #[test] 223 | fn can_decrement_auto_remove() { 224 | let mut c: Counter = Counter::new(); 225 | c.increment(A); 226 | c.increment(B); 227 | c.increment(B); 228 | c.decrement(A); 229 | 230 | let mut expected_count = HashMap::new(); 231 | expected_count.insert(B.to_owned(), 2); 232 | 233 | assert_eq!(c.counts.get(B), Some(&2)); 234 | assert_eq!(c.counts.get(A), None); 235 | assert_eq!(c.total_count, 2); 236 | } 237 | 238 | #[test] 239 | fn can_delete() { 240 | let mut c: Counter = Counter::new(); 241 | c.increment(A); 242 | c.increment(A); 243 | c.increment(A); 244 | c.increment(B); 245 | c.increment(B); 246 | c.delete(A); 247 | 248 | assert_eq!(c.counts.get(B), Some(&2)); 249 | assert_eq!(c.counts.get(A), None); 250 | assert_eq!(c.total_count, 2); 251 | } 252 | } 253 | 254 | #[cfg(test)] 255 | mod message_tests { 256 | use crate::util::aggregators::{aggregator::Aggregator, counter::Counter}; 257 | 258 | static A: &str = "a"; 259 | static B: &str = "b"; 260 | static C: &str = "c"; 261 | static D: &str = "d"; 262 | 263 | #[test] 264 | fn can_get_top_0() { 265 | let mut c: Counter = Counter::new(); 266 | c.increment(A); 267 | c.increment(A); 268 | c.increment(A); 269 | c.increment(B); 270 | c.increment(B); 271 | c.increment(B); 272 | c.increment(C); 273 | c.increment(C); 274 | c.increment(D); 275 | 276 | let expected: Vec = vec![]; 277 | 278 | assert_eq!(c.messages(&0), expected); 279 | } 280 | 281 | #[test] 282 | fn can_get_top_1() { 283 | let mut c: Counter = Counter::new(); 284 | c.increment(A); 285 | c.increment(A); 286 | c.increment(A); 287 | c.increment(A); 288 | c.increment(B); 289 | c.increment(B); 290 | c.increment(B); 291 | c.increment(C); 292 | c.increment(C); 293 | c.increment(D); 294 | 295 | let expected = vec![String::from(" a\u{1b}[0m: 4 (40%)")]; 296 | 297 | assert_eq!(c.messages(&1), expected); 298 | } 299 | 300 | #[test] 301 | fn can_get_top_2() { 302 | let mut c: Counter = Counter::new(); 303 | c.increment(A); 304 | c.increment(A); 305 | c.increment(A); 306 | c.increment(B); 307 | c.increment(B); 308 | c.increment(B); 309 | c.increment(C); 310 | c.increment(C); 311 | c.increment(D); 312 | 313 | let expected = vec![ 314 | String::from(" a\u{1b}[0m: 3 (33%)"), 315 | String::from(" b\u{1b}[0m: 3 (33%)"), 316 | ]; 317 | 318 | assert_eq!(c.messages(&2), expected); 319 | } 320 | 321 | #[test] 322 | fn can_get_top_3() { 323 | let mut c: Counter = Counter::new(); 324 | c.increment(A); 325 | c.increment(A); 326 | c.increment(A); 327 | c.increment(B); 328 | c.increment(B); 329 | c.increment(B); 330 | c.increment(C); 331 | c.increment(C); 332 | c.increment(D); 333 | 334 | let expected = vec![ 335 | String::from(" a\u{1b}[0m: 3 (33%)"), 336 | String::from(" b\u{1b}[0m: 3 (33%)"), 337 | String::from(" c\u{1b}[0m: 2 (22%)"), 338 | ]; 339 | 340 | assert_eq!(c.messages(&3), expected); 341 | } 342 | 343 | #[test] 344 | fn can_get_top_4() { 345 | let mut c: Counter = Counter::new(); 346 | c.increment(A); 347 | c.increment(A); 348 | c.increment(A); 349 | c.increment(B); 350 | c.increment(B); 351 | c.increment(B); 352 | c.increment(C); 353 | c.increment(C); 354 | c.increment(D); 355 | 356 | let expected = vec![ 357 | String::from(" a\u{1b}[0m: 3 (33%)"), 358 | String::from(" b\u{1b}[0m: 3 (33%)"), 359 | String::from(" c\u{1b}[0m: 2 (22%)"), 360 | String::from(" d\u{1b}[0m: 1 (11%)"), 361 | ]; 362 | 363 | assert_eq!(c.messages(&4), expected); 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/communication/handlers/regex.rs: -------------------------------------------------------------------------------- 1 | use std::io::Result; 2 | 3 | use crossterm::event::KeyCode; 4 | use regex::bytes::Regex; 5 | 6 | use super::{handler::Handler, processor::ProcessorMethods}; 7 | use crate::{ 8 | communication::{ 9 | handlers::{processor::update_progress, user_input::UserInputHandler}, 10 | input::InputType::Normal, 11 | reader::MainWindow, 12 | }, 13 | constants::cli::{ 14 | cli_chars::{COMMAND_CHAR, NORMAL_STR, REGEX_CHAR, TOGGLE_HIGHLIGHT_CHAR}, 15 | patterns::ANSI_COLOR_PATTERN, 16 | }, 17 | ui::scroll, 18 | }; 19 | 20 | pub struct RegexHandler { 21 | color_pattern: Regex, 22 | current_pattern: Option, 23 | input_handler: UserInputHandler, 24 | } 25 | 26 | impl RegexHandler { 27 | /// Test a message to see if it matches the pattern while also escaping the color code 28 | fn test(&self, message: &str) -> bool { 29 | // TODO: Possibly without the extra allocation here? 30 | let clean_message = self 31 | .color_pattern 32 | .replace_all(message.as_bytes(), "".as_bytes()); 33 | match &self.current_pattern { 34 | Some(pattern) => pattern.is_match(&clean_message), 35 | None => panic!("Match called with no pattern!"), 36 | } 37 | } 38 | 39 | /// Save the user input pattern to the main window config 40 | fn set_pattern(&mut self, window: &mut MainWindow) -> Result<()> { 41 | let pattern = match self.input_handler.gather(window) { 42 | Ok(pattern) => pattern, 43 | Err(why) => panic!("Unable to gather text: {why:?}"), 44 | }; 45 | 46 | self.current_pattern = match Regex::new(&pattern) { 47 | Ok(regex) => { 48 | window.config.current_status = Some(format!("Regex with pattern /{pattern}/")); 49 | window.write_status()?; 50 | 51 | // Update the main window's regex 52 | window.config.regex_pattern = Some(regex.clone()); 53 | Some(regex) 54 | } 55 | Err(e) => { 56 | window.write_to_command_line(&format!("Invalid regex: /{pattern}/ ({e})"))?; 57 | None 58 | } 59 | }; 60 | window.set_cli_cursor(Some(NORMAL_STR))?; 61 | window.config.highlight_match = true; 62 | Ok(()) 63 | } 64 | } 65 | 66 | impl ProcessorMethods for RegexHandler { 67 | /// Process matches, loading the buffer of indexes to matched messages in the main buffer 68 | fn process_matches(&mut self, window: &mut MainWindow) -> Result<()> { 69 | let mut wrote_progress = false; 70 | if self.current_pattern.is_some() { 71 | // Start from where we left off to the most recent message 72 | // Start from where we left off to the most recent message 73 | let start = window.config.last_index_regexed; 74 | let end = window.messages().len(); 75 | 76 | for index in start..end { 77 | if self.test(&window.messages()[index]) { 78 | window.config.matched_rows.push(index); 79 | } 80 | 81 | // Update the user interface with the current state 82 | wrote_progress = update_progress(window, start, end, index)?; 83 | 84 | // Update the last spot so we know where to start next time 85 | window.config.last_index_regexed = index + 1; 86 | } 87 | if wrote_progress { 88 | window.write_status()?; 89 | } 90 | } 91 | Ok(()) 92 | } 93 | 94 | /// Return the app to a normal input state 95 | fn return_to_normal(&mut self, window: &mut MainWindow) -> Result<()> { 96 | self.clear_matches(window)?; 97 | window.config.current_status = None; 98 | window.update_input_type(Normal)?; 99 | window.set_cli_cursor(None)?; 100 | self.input_handler.gather(window)?; 101 | window.redraw()?; 102 | Ok(()) 103 | } 104 | 105 | /// Clear the matched messages from the message buffer 106 | fn clear_matches(&mut self, window: &mut MainWindow) -> Result<()> { 107 | self.current_pattern = None; 108 | window.config.regex_pattern = None; 109 | window.config.matched_rows.clear(); 110 | window.config.last_index_regexed = 0; 111 | window.config.highlight_match = false; 112 | window.reset_command_line()?; 113 | Ok(()) 114 | } 115 | } 116 | 117 | impl Handler for RegexHandler { 118 | fn new() -> RegexHandler { 119 | RegexHandler { 120 | color_pattern: Regex::new(ANSI_COLOR_PATTERN).unwrap(), 121 | current_pattern: None, 122 | input_handler: UserInputHandler::new(), 123 | } 124 | } 125 | 126 | fn receive_input(&mut self, window: &mut MainWindow, key: KeyCode) -> Result<()> { 127 | match &self.current_pattern { 128 | Some(_) => match key { 129 | // Scroll 130 | KeyCode::Down => scroll::down(window), 131 | KeyCode::Up => scroll::up(window), 132 | KeyCode::Left => scroll::top(window), 133 | KeyCode::Right => scroll::bottom(window), 134 | KeyCode::Home => scroll::top(window), 135 | KeyCode::End => scroll::bottom(window), 136 | KeyCode::PageUp => scroll::pg_up(window), 137 | KeyCode::PageDown => scroll::pg_down(window), 138 | 139 | // Build new regex 140 | KeyCode::Char(REGEX_CHAR) => { 141 | self.clear_matches(window)?; 142 | window.redraw()?; 143 | window.set_cli_cursor(None)?; 144 | } 145 | 146 | // Toggle match highlight 147 | KeyCode::Char(TOGGLE_HIGHLIGHT_CHAR) => { 148 | window.config.highlight_match = !window.config.highlight_match; 149 | window.redraw()?; 150 | } 151 | 152 | // Enter command mode 153 | KeyCode::Char(COMMAND_CHAR) => window.set_command_mode(None)?, 154 | 155 | // Return to normal 156 | KeyCode::Esc => self.return_to_normal(window)?, 157 | _ => {} 158 | }, 159 | None => match key { 160 | KeyCode::Enter => { 161 | self.set_pattern(window)?; 162 | if self.current_pattern.is_some() { 163 | window.reset_output()?; 164 | self.process_matches(window)?; 165 | } 166 | window.redraw()?; 167 | } 168 | KeyCode::Esc => self.return_to_normal(window)?, 169 | key => self.input_handler.receive_input(window, key)?, 170 | }, 171 | } 172 | window.redraw()?; 173 | Ok(()) 174 | } 175 | } 176 | 177 | #[cfg(test)] 178 | mod tests { 179 | use crossterm::event::KeyCode; 180 | use regex::bytes::Regex; 181 | 182 | use crate::{ 183 | communication::{ 184 | handlers::{handler::Handler, processor::ProcessorMethods, regex::RegexHandler}, 185 | input::InputType, 186 | reader::MainWindow, 187 | }, 188 | constants::cli::cli_chars::COMMAND_CHAR, 189 | }; 190 | 191 | #[test] 192 | fn test_can_filter() { 193 | let mut logria = MainWindow::_new_dummy(); 194 | let mut handler = RegexHandler::new(); 195 | 196 | // Set state to regex mode 197 | logria.input_type = InputType::Regex; 198 | 199 | // Set regex pattern 200 | let pattern = "0"; 201 | handler.current_pattern = Some(Regex::new(pattern).unwrap()); 202 | handler.process_matches(&mut logria).unwrap(); 203 | assert_eq!( 204 | vec![0, 10, 20, 30, 40, 50, 60, 70, 80, 90], 205 | logria.config.matched_rows 206 | ); 207 | } 208 | 209 | #[test] 210 | fn test_can_filter_no_matches() { 211 | let mut logria = MainWindow::_new_dummy(); 212 | let mut handler = RegexHandler::new(); 213 | 214 | // Set state to regex mode 215 | logria.input_type = InputType::Regex; 216 | 217 | // Set regex pattern 218 | let pattern = "a"; 219 | handler.current_pattern = Some(Regex::new(pattern).unwrap()); 220 | logria.config.regex_pattern = Some(Regex::new(pattern).unwrap()); 221 | handler.process_matches(&mut logria).unwrap(); 222 | assert_eq!(0, logria.config.matched_rows.len()); 223 | } 224 | 225 | #[test] 226 | fn test_can_return_normal() { 227 | let mut logria = MainWindow::_new_dummy(); 228 | let mut handler = RegexHandler::new(); 229 | 230 | // Set state to regex mode 231 | logria.input_type = InputType::Regex; 232 | 233 | // Set regex pattern 234 | let pattern = "0"; 235 | handler.current_pattern = Some(Regex::new(pattern).unwrap()); 236 | handler.process_matches(&mut logria).unwrap(); 237 | handler.return_to_normal(&mut logria).unwrap(); 238 | 239 | assert!(handler.current_pattern.is_none()); 240 | assert!(logria.config.regex_pattern.is_none()); 241 | assert_eq!(logria.config.matched_rows.len(), 0); 242 | assert_eq!(logria.config.last_index_regexed, 0); 243 | } 244 | 245 | #[test] 246 | fn test_can_process() { 247 | let mut logria = MainWindow::_new_dummy(); 248 | let mut handler = RegexHandler::new(); 249 | 250 | // Set state to regex mode 251 | logria.input_type = InputType::Regex; 252 | 253 | // Set regex pattern 254 | let pattern = "0"; 255 | handler.current_pattern = Some(Regex::new(pattern).unwrap()); 256 | handler.process_matches(&mut logria).unwrap(); 257 | assert_eq!(100, logria.config.last_index_regexed); 258 | } 259 | 260 | #[test] 261 | fn test_can_process_no_pattern() { 262 | let mut logria = MainWindow::_new_dummy(); 263 | let mut handler = RegexHandler::new(); 264 | 265 | // Set state to regex mode 266 | logria.input_type = InputType::Regex; 267 | handler.process_matches(&mut logria).unwrap(); 268 | 269 | assert_eq!(logria.config.matched_rows, Vec::::new()); 270 | } 271 | 272 | #[test] 273 | #[should_panic] 274 | fn test_test_no_pattern() { 275 | let mut logria = MainWindow::_new_dummy(); 276 | let handler = RegexHandler::new(); 277 | 278 | // Set state to regex mode 279 | logria.input_type = InputType::Regex; 280 | handler.test("test"); 281 | } 282 | 283 | #[test] 284 | fn test_can_enter_command_mode() { 285 | let mut logria = MainWindow::_new_dummy(); 286 | let mut handler = RegexHandler::new(); 287 | 288 | // Set state to regex mode 289 | logria.input_type = InputType::Regex; 290 | 291 | // Set regex pattern 292 | let pattern = "0"; 293 | handler.current_pattern = Some(Regex::new(pattern).unwrap()); 294 | 295 | // Normally this is set by `set_pattern()` but that requires user input 296 | logria.config.regex_pattern = Some(Regex::new(pattern).unwrap()); 297 | handler.process_matches(&mut logria).unwrap(); 298 | 299 | // Simulate keystroke for command mode 300 | handler 301 | .receive_input(&mut logria, KeyCode::Char(COMMAND_CHAR)) 302 | .unwrap(); 303 | 304 | // Ensure we have the same amount of messages as when the regex was active 305 | assert_eq!(logria.config.matched_rows.len(), 10); 306 | 307 | // Ensure we are in command mode 308 | assert_eq!(logria.input_type, InputType::Command); 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/communication/handlers/command.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Result, Write, stdout}; 2 | 3 | use crossterm::event::KeyCode; 4 | 5 | use super::handler::Handler; 6 | use crate::{ 7 | communication::{ 8 | handlers::user_input::UserInputHandler, 9 | input::{InputType, StreamType}, 10 | reader::MainWindow, 11 | }, 12 | ui::scroll::ScrollState, 13 | util::{credits::gen_credits, error::LogriaError}, 14 | }; 15 | 16 | pub struct CommandHandler { 17 | input_handler: UserInputHandler, 18 | } 19 | 20 | impl CommandHandler { 21 | fn return_to_prev_state(&mut self, window: &mut MainWindow) -> Result<()> { 22 | // If we are in auxiliary mode, go back to that, otherwise go to normal mode 23 | window.update_input_type(window.previous_input_type)?; 24 | window.write_status()?; 25 | window.config.delete_func = None; 26 | window.set_cli_cursor(None)?; 27 | stdout().flush()?; 28 | Ok(()) 29 | } 30 | 31 | fn resolve_poll_rate(&self, command: &str) -> std::result::Result { 32 | let parts: Vec<&str> = command.split(' ').collect(); // ["poll", "42", ...] 33 | if parts.len() < 2 { 34 | return Err(LogriaError::InvalidCommand(format!( 35 | "No poll delay provided {parts:?}" 36 | ))); 37 | } 38 | match parts[1].parse::() { 39 | Ok(parsed) => Ok(parsed), 40 | Err(why) => Err(LogriaError::InvalidCommand(format!("{why:?}"))), 41 | } 42 | } 43 | 44 | fn resolve_aggregation_count(&self, command: &str) -> std::result::Result { 45 | let parts: Vec<&str> = command.split(' ').collect(); // ["agg", "42", ...] 46 | if parts.len() < 2 { 47 | return Err(LogriaError::InvalidCommand(format!( 48 | "No aggregation count provided: {parts:?}" 49 | ))); 50 | } 51 | match parts[1].parse::() { 52 | Ok(parsed) => Ok(parsed), 53 | Err(why) => Err(LogriaError::InvalidCommand(format!("{why:?}"))), 54 | } 55 | } 56 | 57 | fn resolve_delete_command( 58 | &self, 59 | command: &str, 60 | ) -> std::result::Result, LogriaError> { 61 | // Validate length 62 | if command.len() < 3 { 63 | return Err(LogriaError::InvalidCommand(format!("{command:?}"))); 64 | } 65 | 66 | // Remove "r " from the string 67 | let parts = command[2..].split(','); 68 | let mut out_l: Vec = vec![]; 69 | 70 | // Not for_each because we may need to bail early 71 | for part in parts { 72 | if part.contains('-') { 73 | // Create range 74 | let range: Vec<&str> = part.split('-').collect(); 75 | if range.len() != 2 { 76 | continue; 77 | } 78 | 79 | // Parse range 80 | // This code is repeated because we cannot break from the loop if we use a closure 81 | match (range[0].parse::(), range[1].parse::()) { 82 | (Ok(start), Ok(end)) => { 83 | (start..end + 1).for_each(|step| out_l.push(step)); 84 | } 85 | (_, _) => { 86 | return Err(LogriaError::InvalidCommand(format!( 87 | "range invalid: {:?}", 88 | &range 89 | ))); 90 | } 91 | } 92 | 93 | // Add all items to the range 94 | } else { 95 | // Parse the value 96 | if !part.is_empty() { 97 | match part.parse::() { 98 | Ok(num) => { 99 | out_l.push(num); 100 | } 101 | Err(why) => return Err(LogriaError::InvalidCommand(format!("{why:?}"))), 102 | } 103 | } 104 | } 105 | } 106 | out_l.sort_unstable(); 107 | Ok(out_l) 108 | } 109 | 110 | fn process_command(&mut self, window: &mut MainWindow, command: &str) -> Result<()> { 111 | if command == "q" { 112 | window.quit()?; 113 | } 114 | // Update poll rate 115 | else if command.starts_with("poll ") { 116 | match self.resolve_poll_rate(command) { 117 | Ok(val) => { 118 | window.config.smart_poll_rate = false; 119 | window.config.poll_rate = val; 120 | window.write_to_command_line(&format!( 121 | "Smart polling disabled, polling every {val}ms" 122 | ))?; 123 | } 124 | Err(why) => { 125 | window.write_to_command_line(&format!( 126 | "Failed to parse remove command: {why:?}" 127 | ))?; 128 | } 129 | } 130 | } 131 | // Enter history mode 132 | else if command.starts_with("history on") { 133 | if window.config.use_history { 134 | window.write_to_command_line("History tape already enabled!")?; 135 | } else { 136 | window.config.use_history = true; 137 | window.write_to_command_line("History tape enabled!")?; 138 | } 139 | } 140 | // Exit history mode 141 | else if command.starts_with("history off") { 142 | if window.config.use_history { 143 | window.config.use_history = false; 144 | window.write_to_command_line("History tape disabled!")?; 145 | } else { 146 | window.write_to_command_line("History tape already disabled!")?; 147 | } 148 | } 149 | // Remove saved sessions from the main screen 150 | else if command.starts_with('r') { 151 | if let StreamType::Auxiliary = window.config.stream_type { 152 | if let Ok(items) = self.resolve_delete_command(command) { 153 | if let Some(del) = window.config.delete_func { 154 | match del(&items) { 155 | Ok(()) => {} 156 | Err(why) => window.write_to_command_line(&why.to_string())?, 157 | } 158 | window.render_auxiliary_text()?; 159 | } else { 160 | { 161 | window.write_to_command_line( 162 | "Delete command is valid, but there is nothing to delete.", 163 | )?; 164 | } 165 | } 166 | } else { 167 | { 168 | window.write_to_command_line(&format!( 169 | "Failed to parse remove command: {command:?} is invalid." 170 | ))?; 171 | } 172 | } 173 | } else { 174 | { 175 | window.write_to_command_line("Cannot remove files outside of startup mode.")?; 176 | } 177 | } 178 | } 179 | // Credits! Only accessible from the startup window 180 | else if command.starts_with("credits") { 181 | // Since getting here implies that we are now in command mode, check if the previous input type was startup 182 | if let InputType::Startup = window.previous_input_type { 183 | window.config.generate_auxiliary_messages = Some(gen_credits); 184 | window.config.stream_type = StreamType::Auxiliary; 185 | window.config.scroll_state = ScrollState::Top; 186 | window.render_auxiliary_text()?; 187 | window.write_to_command_line("You've reached the credits! C-c or :q to exit.")?; 188 | } 189 | } else if command.starts_with("agg") { 190 | match self.resolve_aggregation_count(command) { 191 | Ok(val) => { 192 | window.config.num_to_aggregate = val; 193 | // TODO: This wont cause the screen to re-render until there is a new message to get parsed 194 | } 195 | Err(why) => { 196 | window.write_to_command_line(&format!( 197 | "Failed to parse aggregation count command: {why:?}" 198 | ))?; 199 | } 200 | } 201 | } else { 202 | window.write_to_command_line(&format!("Invalid command: {command:?}"))?; 203 | } 204 | self.return_to_prev_state(window)?; 205 | Ok(()) 206 | } 207 | } 208 | 209 | impl Handler for CommandHandler { 210 | fn new() -> CommandHandler { 211 | CommandHandler { 212 | input_handler: UserInputHandler::new(), 213 | } 214 | } 215 | 216 | fn receive_input(&mut self, window: &mut MainWindow, key: KeyCode) -> Result<()> { 217 | match key { 218 | // Execute the command 219 | KeyCode::Enter => { 220 | let command = match self.input_handler.gather(window) { 221 | Ok(command) => command, 222 | Err(why) => panic!("Unable to gather text: {why:?}"), 223 | }; 224 | self.process_command(window, &command)?; 225 | } 226 | // Go back to the previous state 227 | KeyCode::Esc => self.return_to_prev_state(window)?, 228 | key => self.input_handler.receive_input(window, key)?, 229 | } 230 | Ok(()) 231 | } 232 | } 233 | 234 | #[cfg(test)] 235 | mod poll_rate_tests { 236 | use super::CommandHandler; 237 | use crate::communication::handlers::handler::Handler; 238 | 239 | #[test] 240 | fn test_can_set_poll_rate() { 241 | let handler = CommandHandler::new(); 242 | let result = handler.resolve_poll_rate("poll 1"); 243 | assert!(result.is_ok()); 244 | assert_eq!(result.unwrap(), 1); 245 | } 246 | 247 | #[test] 248 | fn test_do_not_set_bad_poll_rate() { 249 | let handler = CommandHandler::new(); 250 | let result = handler.resolve_poll_rate("poll v"); 251 | assert!(result.is_err()); 252 | } 253 | 254 | #[test] 255 | fn test_do_no_poll_rate() { 256 | let handler = CommandHandler::new(); 257 | let result = handler.resolve_poll_rate("poll"); 258 | assert!(result.is_err()); 259 | } 260 | } 261 | 262 | #[cfg(test)] 263 | mod remove_tests { 264 | use super::CommandHandler; 265 | use crate::communication::handlers::handler::Handler; 266 | 267 | #[test] 268 | fn test_resolve_single_num() { 269 | let handler = CommandHandler::new(); 270 | let resolved = handler.resolve_delete_command("r 1").unwrap_or_default(); 271 | assert_eq!(resolved, [1]); 272 | } 273 | 274 | #[test] 275 | fn test_resolve_double_num() { 276 | let handler = CommandHandler::new(); 277 | let resolved = handler.resolve_delete_command("r 1,2").unwrap_or_default(); 278 | assert_eq!(resolved, [1, 2]); 279 | } 280 | 281 | #[test] 282 | fn test_resolve_triple_num() { 283 | let handler = CommandHandler::new(); 284 | let resolved = handler 285 | .resolve_delete_command("r 1,2,3") 286 | .unwrap_or_default(); 287 | assert_eq!(resolved, [1, 2, 3]); 288 | } 289 | 290 | #[test] 291 | fn test_resolve_triple_num_trailing_comma() { 292 | let handler = CommandHandler::new(); 293 | let resolved = handler 294 | .resolve_delete_command("r 1,2,3,") 295 | .unwrap_or_default(); 296 | assert_eq!(resolved, [1, 2, 3]); 297 | } 298 | 299 | #[test] 300 | fn test_resolve_range() { 301 | let handler = CommandHandler::new(); 302 | let resolved = handler.resolve_delete_command("r 1-5").unwrap_or_default(); 303 | assert_eq!(resolved, [1, 2, 3, 4, 5]); 304 | } 305 | 306 | #[test] 307 | fn test_resolve_double_range() { 308 | let handler = CommandHandler::new(); 309 | let resolved = handler 310 | .resolve_delete_command("r 1-3,5-7") 311 | .unwrap_or_default(); 312 | assert_eq!(resolved, [1, 2, 3, 5, 6, 7]); 313 | } 314 | 315 | #[test] 316 | fn test_resolve_triple_range() { 317 | let handler = CommandHandler::new(); 318 | let resolved = handler 319 | .resolve_delete_command("r 1-3,5-7,9-11") 320 | .unwrap_or_default(); 321 | assert_eq!(resolved, [1, 2, 3, 5, 6, 7, 9, 10, 11]); 322 | } 323 | 324 | #[test] 325 | fn test_resolve_ranges_with_singletons() { 326 | let handler = CommandHandler::new(); 327 | let resolved = handler 328 | .resolve_delete_command("r 1-3,5,9-11,15") 329 | .unwrap_or_default(); 330 | assert_eq!(resolved, [1, 2, 3, 5, 9, 10, 11, 15]); 331 | } 332 | 333 | #[test] 334 | fn test_resolve_ranges_multiple_dash() { 335 | let handler = CommandHandler::new(); 336 | let resolved = handler 337 | .resolve_delete_command("r 1--3,4") 338 | .unwrap_or_default(); 339 | assert_eq!(resolved, [4]); 340 | } 341 | 342 | #[test] 343 | fn test_resolve_ranges_with_string() { 344 | let handler = CommandHandler::new(); 345 | let resolved = handler 346 | .resolve_delete_command("r a-b,4") 347 | .unwrap_or_default(); 348 | assert_eq!(resolved.len(), 0); 349 | } 350 | 351 | #[test] 352 | fn test_resolve_no_num() { 353 | let handler = CommandHandler::new(); 354 | let resolved = handler.resolve_delete_command("r").unwrap_or_default(); 355 | assert_eq!(resolved.len(), 0); 356 | } 357 | 358 | #[test] 359 | fn test_resolve_no_num_space() { 360 | let handler = CommandHandler::new(); 361 | let resolved = handler.resolve_delete_command("r ").unwrap_or_default(); 362 | assert_eq!(resolved.len(), 0); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/ui/scroll.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::min; 2 | 3 | use crate::{communication::reader::MainWindow, util::binary_search::closest_index}; 4 | 5 | #[derive(Debug)] 6 | pub enum ScrollState { 7 | Top, 8 | Free, 9 | Bottom, 10 | Centered, 11 | } 12 | 13 | pub fn up(window: &mut MainWindow) { 14 | window.config.scroll_state = ScrollState::Free; 15 | 16 | window.config.current_end = window.config.current_end.saturating_sub(1).max(1); 17 | } 18 | 19 | pub fn down(window: &mut MainWindow) { 20 | window.config.scroll_state = ScrollState::Free; 21 | 22 | // Get number of messages we can scroll 23 | let num_messages = window.number_of_messages(); 24 | 25 | // No scrolling past the last message 26 | window.config.current_end = min(num_messages, window.config.current_end + 1); 27 | } 28 | 29 | pub fn pg_up(window: &mut MainWindow) { 30 | (0..window.config.last_row).for_each(|_| up(window)); 31 | } 32 | 33 | pub fn pg_down(window: &mut MainWindow) { 34 | (0..window.config.last_row).for_each(|_| down(window)); 35 | } 36 | 37 | pub fn bottom(window: &mut MainWindow) { 38 | window.config.scroll_state = ScrollState::Bottom; 39 | } 40 | 41 | pub fn top(window: &mut MainWindow) { 42 | window.config.scroll_state = ScrollState::Top; 43 | } 44 | 45 | /// Scroll to the current matched row in the matched rows vector, used by the highlight search 46 | pub fn update_current_match_index(window: &mut MainWindow, scroll_up: bool) { 47 | match window.config.scroll_state { 48 | ScrollState::Free => { 49 | let (start, end) = window.config.previous_render; 50 | // The midpoint index of the current render 51 | let render_midpoint = (start + end) / 2; 52 | // Find the closest match to the render midpoint in the matched rows 53 | window.config.current_matched_row = 54 | closest_index(&window.config.matched_rows, render_midpoint) 55 | .unwrap_or(window.config.current_matched_row); 56 | // Only change the scroll state if there is a match to render 57 | if !window.config.matched_rows.is_empty() { 58 | window.config.scroll_state = ScrollState::Centered; 59 | } 60 | } 61 | ScrollState::Top => { 62 | // If we are at the top, we can just return to the first match 63 | window.config.current_matched_row = 0; 64 | // Only change the scroll state if there is a match to render 65 | if !window.config.matched_rows.is_empty() { 66 | window.config.scroll_state = ScrollState::Centered; 67 | } 68 | } 69 | ScrollState::Bottom => { 70 | // If we are at the bottom, we can just return to the last match 71 | window.config.current_matched_row = window.config.matched_rows.len().saturating_sub(1); 72 | // Only change the scroll state if there is a match to render 73 | if !window.config.matched_rows.is_empty() { 74 | window.config.scroll_state = ScrollState::Centered; 75 | } 76 | } 77 | ScrollState::Centered => { 78 | if scroll_up { 79 | // Scrolling back one match, up to the first index in the list 80 | window.config.current_matched_row = 81 | window.config.current_matched_row.saturating_sub(1); 82 | } else { 83 | // Scrolling forward one match, up to the last index in the list 84 | window.config.current_matched_row = min( 85 | window.config.current_matched_row + 1, 86 | window.config.matched_rows.len().saturating_sub(1), 87 | ); 88 | } 89 | } 90 | } 91 | } 92 | 93 | #[cfg(test)] 94 | mod tests { 95 | use crate::{ 96 | communication::{input::InputType::Regex, reader::MainWindow}, 97 | ui::scroll::{self, ScrollState}, 98 | }; 99 | 100 | #[test] 101 | fn test_render_final_items_scroll_down() { 102 | let mut logria = MainWindow::_new_dummy(); 103 | 104 | // Set scroll state 105 | logria.config.scroll_state = ScrollState::Bottom; 106 | 107 | // Set existing status 108 | logria.determine_render_position(); 109 | 110 | // Scroll action 111 | scroll::down(&mut logria); 112 | 113 | let (start, end) = logria.determine_render_position(); 114 | assert_eq!(start, 93); 115 | assert_eq!(end, 100); 116 | } 117 | 118 | #[test] 119 | fn test_render_first_items_scroll_up() { 120 | let mut logria = MainWindow::_new_dummy(); 121 | 122 | // Set scroll state 123 | logria.config.scroll_state = ScrollState::Top; 124 | 125 | // Set existing status 126 | logria.determine_render_position(); 127 | 128 | // Scroll action 129 | scroll::up(&mut logria); 130 | 131 | let (start, end) = logria.determine_render_position(); 132 | assert_eq!(start, 0); 133 | assert_eq!(end, 6); 134 | } 135 | 136 | #[test] 137 | fn test_render_final_items_scroll_bottom() { 138 | let mut logria = MainWindow::_new_dummy(); 139 | 140 | // Set scroll state 141 | logria.config.scroll_state = ScrollState::Bottom; 142 | 143 | // Set existing status 144 | logria.determine_render_position(); 145 | 146 | // Scroll action 147 | scroll::bottom(&mut logria); 148 | 149 | let (start, end) = logria.determine_render_position(); 150 | assert_eq!(start, 93); 151 | assert_eq!(end, 100); 152 | } 153 | 154 | #[test] 155 | fn test_render_first_items_scroll_top() { 156 | let mut logria = MainWindow::_new_dummy(); 157 | 158 | // Set scroll state 159 | logria.config.scroll_state = ScrollState::Top; 160 | 161 | // Set existing status 162 | logria.determine_render_position(); 163 | 164 | // Scroll action 165 | scroll::top(&mut logria); 166 | 167 | let (start, end) = logria.determine_render_position(); 168 | assert_eq!(start, 0); 169 | assert_eq!(end, 7); 170 | } 171 | 172 | #[test] 173 | fn test_render_final_items_scroll_pgup() { 174 | let mut logria = MainWindow::_new_dummy(); 175 | 176 | // Set scroll state 177 | logria.config.scroll_state = ScrollState::Bottom; 178 | 179 | // Set existing status 180 | logria.determine_render_position(); 181 | 182 | // Scroll action 183 | scroll::pg_up(&mut logria); 184 | 185 | let (start, end) = logria.determine_render_position(); 186 | assert_eq!(start, 86); 187 | assert_eq!(end, 93); 188 | } 189 | 190 | #[test] 191 | fn test_render_first_items_scroll_pgdn() { 192 | let mut logria = MainWindow::_new_dummy(); 193 | 194 | // Set scroll state 195 | logria.config.scroll_state = ScrollState::Top; 196 | 197 | // Set existing status 198 | logria.determine_render_position(); 199 | 200 | // Scroll action 201 | scroll::pg_down(&mut logria); 202 | 203 | let (start, end) = logria.determine_render_position(); 204 | assert_eq!(start, 7); 205 | assert_eq!(end, 14); 206 | } 207 | 208 | #[test] 209 | fn test_render_scroll_past_end() { 210 | let mut logria = MainWindow::_new_dummy(); 211 | 212 | // Set scroll state 213 | logria.config.scroll_state = ScrollState::Bottom; 214 | 215 | // Set existing status 216 | logria.config.current_end = 101; // somehow longer than the messages buffer 217 | 218 | // Scroll action 219 | scroll::down(&mut logria); 220 | 221 | let (start, end) = logria.determine_render_position(); 222 | assert_eq!(start, 93); 223 | assert_eq!(end, 100); 224 | } 225 | 226 | #[test] 227 | fn test_render_scroll_past_end_small() { 228 | let mut logria = MainWindow::_new_dummy(); 229 | 230 | // Set scroll state 231 | logria.config.scroll_state = ScrollState::Bottom; 232 | 233 | // Set existing status 234 | logria.config.current_end = 10; 235 | 236 | // Set state to regex mode 237 | logria.config.matched_rows = (0..5).collect(); 238 | logria.config.regex_pattern = Some(regex::bytes::Regex::new("fa.ke").unwrap()); 239 | logria.input_type = Regex; 240 | 241 | // Scroll action 242 | scroll::down(&mut logria); 243 | 244 | let (start, end) = logria.determine_render_position(); 245 | assert_eq!(start, 0); 246 | assert_eq!(end, 5); 247 | } 248 | 249 | #[test] 250 | fn test_render_final_items_scroll_down_matched() { 251 | let mut logria = MainWindow::_new_dummy(); 252 | 253 | // Set scroll state 254 | logria.config.scroll_state = ScrollState::Bottom; 255 | 256 | // Set existing status 257 | logria.determine_render_position(); 258 | 259 | // Set state to regex mode 260 | logria.input_type = Regex; 261 | logria.config.regex_pattern = Some(regex::bytes::Regex::new("fa.ke").unwrap()); 262 | logria.config.matched_rows = (0..20).collect(); 263 | 264 | // Scroll action 265 | scroll::down(&mut logria); 266 | 267 | let (start, end) = logria.determine_render_position(); 268 | assert_eq!(start, 13); 269 | assert_eq!(end, 20); 270 | } 271 | 272 | #[test] 273 | fn test_render_first_items_scroll_up_matched() { 274 | let mut logria = MainWindow::_new_dummy(); 275 | 276 | // Set scroll state 277 | logria.config.scroll_state = ScrollState::Top; 278 | 279 | // Set existing status 280 | logria.determine_render_position(); 281 | 282 | // Set state to regex mode 283 | logria.input_type = Regex; 284 | logria.config.matched_rows = (0..20).collect(); 285 | 286 | // Scroll action 287 | scroll::up(&mut logria); 288 | 289 | let (start, end) = logria.determine_render_position(); 290 | assert_eq!(start, 0); 291 | assert_eq!(end, 6); 292 | } 293 | 294 | #[test] 295 | fn test_update_current_match_index_free_closest_high() { 296 | let mut window = MainWindow::_new_dummy(); 297 | window.config.matched_rows = vec![5, 15, 25]; 298 | window.config.current_matched_row = 99; 299 | window.config.previous_render = (0, 22); // midpoint is 11 300 | window.config.scroll_state = ScrollState::Free; 301 | 302 | scroll::update_current_match_index(&mut window, false); 303 | 304 | assert_eq!(window.config.current_matched_row, 1); 305 | assert!(matches!(window.config.scroll_state, ScrollState::Centered)); 306 | } 307 | 308 | #[test] 309 | fn test_update_current_match_index_free_closest_low() { 310 | let mut window = MainWindow::_new_dummy(); 311 | window.config.matched_rows = vec![5, 15, 25]; 312 | window.config.current_matched_row = 99; 313 | window.config.previous_render = (0, 21); // midpoint is 10 314 | window.config.scroll_state = ScrollState::Free; 315 | 316 | scroll::update_current_match_index(&mut window, false); 317 | 318 | assert_eq!(window.config.current_matched_row, 0); 319 | assert!(matches!(window.config.scroll_state, ScrollState::Centered)); 320 | } 321 | 322 | #[test] 323 | fn test_update_current_match_index_free_empty() { 324 | let mut window = MainWindow::_new_dummy(); 325 | window.config.matched_rows.clear(); 326 | window.config.current_matched_row = 0; 327 | window.config.previous_render = (0, 50); 328 | window.config.scroll_state = ScrollState::Free; 329 | 330 | scroll::update_current_match_index(&mut window, true); 331 | 332 | assert_eq!(window.config.current_matched_row, 0); 333 | assert!(matches!(window.config.scroll_state, ScrollState::Free)); 334 | } 335 | 336 | #[test] 337 | fn test_update_current_match_index_free() { 338 | let mut window = MainWindow::_new_dummy(); 339 | window.config.matched_rows.clear(); 340 | window.config.matched_rows = vec![0, 1, 2, 3]; 341 | window.config.current_matched_row = 3; 342 | window.config.previous_render = (0, 50); 343 | window.config.scroll_state = ScrollState::Free; 344 | 345 | scroll::update_current_match_index(&mut window, true); 346 | 347 | assert_eq!(window.config.current_matched_row, 3); 348 | assert!(matches!(window.config.scroll_state, ScrollState::Centered)); 349 | } 350 | 351 | #[test] 352 | fn test_update_current_match_index_top() { 353 | let mut window = MainWindow::_new_dummy(); 354 | window.config.matched_rows = vec![1, 2, 3]; 355 | window.config.current_matched_row = 5; 356 | window.config.scroll_state = ScrollState::Top; 357 | 358 | scroll::update_current_match_index(&mut window, false); 359 | 360 | assert_eq!(window.config.current_matched_row, 0); 361 | assert!(matches!(window.config.scroll_state, ScrollState::Centered)); 362 | } 363 | 364 | #[test] 365 | fn test_update_current_match_index_bottom() { 366 | let mut window = MainWindow::_new_dummy(); 367 | window.config.matched_rows = vec![1, 2, 3, 4]; 368 | window.config.current_matched_row = 0; 369 | window.config.scroll_state = ScrollState::Bottom; 370 | 371 | scroll::update_current_match_index(&mut window, false); 372 | 373 | assert_eq!(window.config.current_matched_row, 3); 374 | assert!(matches!(window.config.scroll_state, ScrollState::Centered)); 375 | } 376 | 377 | #[test] 378 | fn test_update_current_match_index_centered_down() { 379 | let mut window = MainWindow::_new_dummy(); 380 | window.config.matched_rows = vec![0, 1, 2]; 381 | window.config.current_matched_row = 1; 382 | window.config.scroll_state = ScrollState::Centered; 383 | 384 | scroll::update_current_match_index(&mut window, false); 385 | 386 | assert_eq!(window.config.current_matched_row, 2); 387 | } 388 | 389 | #[test] 390 | fn test_update_current_match_index_centered_up() { 391 | let mut window = MainWindow::_new_dummy(); 392 | window.config.matched_rows = vec![0, 1, 2]; 393 | window.config.current_matched_row = 1; 394 | window.config.scroll_state = ScrollState::Centered; 395 | 396 | scroll::update_current_match_index(&mut window, true); 397 | 398 | assert_eq!(window.config.current_matched_row, 0); 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /src/util/aggregators/date.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::{max, min}; 2 | 3 | use time::{ 4 | Date as Dt, PrimitiveDateTime as DateTime, Time as Tm, 5 | format_description::{OwnedFormatItem, parse_owned}, 6 | }; 7 | 8 | use crate::util::{ 9 | aggregators::aggregator::{Aggregator, format_int}, 10 | error::LogriaError, 11 | }; 12 | 13 | #[derive(Clone, Debug)] 14 | pub enum DateParserType { 15 | Date, 16 | Time, 17 | DateTime, 18 | } 19 | 20 | /// Aggregator for tracking temporal data: records earliest and latest timestamps, 21 | /// counts entries, and computes rate over a chosen unit (day, hour, etc.). 22 | pub struct Date { 23 | /// Parsed format items for the date/time format. 24 | format: Option, 25 | /// The minimum timestamp observed. 26 | earliest: DateTime, 27 | /// The maximum timestamp observed. 28 | latest: DateTime, 29 | /// Number of parsed timestamps. 30 | count: i64, 31 | /// Specifies whether to parse as Date, Time, or DateTime. 32 | parser_type: DateParserType, 33 | } 34 | 35 | impl Aggregator for Date { 36 | /// Parses and ingests a new timestamp from `message`, updating internal state. 37 | fn update(&mut self, message: &str) -> Result<(), LogriaError> { 38 | match &self.format { 39 | Some(format) => match self.parser_type { 40 | DateParserType::Date => match Dt::parse(message, format) { 41 | Ok(date) => { 42 | self.upsert(DateTime::new(date, Tm::MIDNIGHT)); 43 | Ok(()) 44 | } 45 | Err(why) => Err(LogriaError::CannotParseDate(why.to_string())), 46 | }, 47 | DateParserType::Time => match Tm::parse(message, format) { 48 | Ok(time) => { 49 | self.upsert(DateTime::new(Dt::MIN, time)); 50 | Ok(()) 51 | } 52 | Err(why) => Err(LogriaError::CannotParseDate(why.to_string())), 53 | }, 54 | DateParserType::DateTime => match DateTime::parse(message, format) { 55 | Ok(date) => { 56 | self.upsert(date); 57 | Ok(()) 58 | } 59 | Err(why) => Err(LogriaError::CannotParseDate(why.to_string())), 60 | }, 61 | }, 62 | None => Err(LogriaError::CannotParseDate( 63 | "No date format string specified!".to_string(), 64 | )), 65 | } 66 | } 67 | 68 | /// Returns vector of formatted output lines: rate, count, earliest, and latest. 69 | fn messages(&self, _: &usize) -> Vec { 70 | let (rate, unit) = self.determine_rate(); 71 | let mut out_v = vec![ 72 | format!(" Rate: {} {}", format_int(rate as usize), unit), 73 | format!(" Count: {}", format_int(self.count as usize)), 74 | ]; 75 | match self.parser_type { 76 | DateParserType::Date => { 77 | out_v.push(format!(" Earliest: {}", self.earliest.date())); 78 | out_v.push(format!(" Latest: {}", self.latest.date())); 79 | } 80 | DateParserType::Time => { 81 | out_v.push(format!(" Earliest: {}", self.earliest.time())); 82 | out_v.push(format!(" Latest: {}", self.latest.time())); 83 | } 84 | DateParserType::DateTime => { 85 | out_v.push(format!(" Earliest: {}", self.earliest)); 86 | out_v.push(format!(" Latest: {}", self.latest)); 87 | } 88 | } 89 | out_v 90 | } 91 | 92 | /// Resets the aggregator to its initial state, preserving `format` and `parser_type`. 93 | fn reset(&mut self) { 94 | self.count = 0; 95 | let (earliest, latest) = Self::default_datetimes(&self.parser_type); 96 | self.earliest = earliest; 97 | self.latest = latest; 98 | } 99 | } 100 | 101 | impl Date { 102 | /// Constructs a new `Date` aggregator with the given `format` and `parser_type`. 103 | pub fn new(format: &str, parser_type: DateParserType) -> Self { 104 | let (earliest, latest) = Self::default_datetimes(&parser_type); 105 | 106 | Self { 107 | format: parse_owned::<2>(format).ok(), 108 | earliest, 109 | latest, 110 | count: 0, 111 | parser_type, 112 | } 113 | } 114 | 115 | fn default_datetimes(parser_type: &DateParserType) -> (DateTime, DateTime) { 116 | match parser_type { 117 | DateParserType::Date => ( 118 | DateTime::new(Dt::MAX, Tm::MIDNIGHT), 119 | DateTime::new(Dt::MIN, Tm::MIDNIGHT), 120 | ), 121 | DateParserType::Time => ( 122 | DateTime::new(Dt::MIN, Tm::from_hms(23, 59, 59).unwrap()), 123 | DateTime::new(Dt::MIN, Tm::MIDNIGHT), 124 | ), 125 | DateParserType::DateTime => ( 126 | DateTime::new(Dt::MAX, Tm::MIDNIGHT), 127 | DateTime::new(Dt::MIN, Tm::MIDNIGHT), 128 | ), 129 | } 130 | } 131 | 132 | /// Inserts `new_date` into the aggregator, adjusting earliest/latest and recalculating rate. 133 | fn upsert(&mut self, new_date: DateTime) { 134 | self.earliest = min(new_date, self.earliest); 135 | self.latest = max(new_date, self.latest); 136 | self.count += 1; 137 | } 138 | 139 | /// Calculates the entry rate based on the span between earliest and latest timestamps. 140 | fn determine_rate(&self) -> (i64, String) { 141 | let difference = self.latest - self.earliest; 142 | let mut denominator = difference.whole_weeks(); 143 | let mut unit = "week"; 144 | if difference.whole_days() < self.count { 145 | denominator = difference.whole_days(); 146 | unit = "day"; 147 | } 148 | if difference.whole_hours() < self.count { 149 | denominator = difference.whole_hours(); 150 | unit = "hour"; 151 | } 152 | if difference.whole_minutes() < self.count { 153 | denominator = difference.whole_minutes(); 154 | unit = "minute"; 155 | } 156 | if difference.whole_seconds() < self.count { 157 | denominator = difference.whole_seconds(); 158 | unit = "second"; 159 | } 160 | let mut per_unit = String::from("per "); 161 | per_unit.push_str(unit); 162 | ( 163 | max(self.count.checked_div(denominator).unwrap_or(self.count), 1), 164 | per_unit, 165 | ) 166 | } 167 | } 168 | 169 | #[cfg(test)] 170 | mod use_tests { 171 | use crate::util::aggregators::{ 172 | aggregator::Aggregator, 173 | date::{Date, DateParserType}, 174 | }; 175 | use time::{ 176 | Date as Dt, PrimitiveDateTime as DateTime, Time as Tm, format_description::parse_owned, 177 | }; 178 | 179 | #[test] 180 | fn can_construct() { 181 | Date::new("[month]/[day]/[year]", DateParserType::Date); 182 | Date::new("[hour]:[minute]:[second]", DateParserType::Time); 183 | Date::new( 184 | "[month]/[day]/[year] [hour]:[minute]:[second]", 185 | DateParserType::DateTime, 186 | ); 187 | } 188 | 189 | #[test] 190 | fn can_update_date() { 191 | let mut d: Date = Date::new("[month]/[day]/[year]", DateParserType::Date); 192 | d.update("01/01/2021").unwrap(); 193 | d.update("01/02/2021").unwrap(); 194 | d.update("01/03/2021").unwrap(); 195 | d.update("01/04/2021").unwrap(); 196 | 197 | let expected = Date { 198 | earliest: DateTime::new(Dt::from_ordinal_date(2021, 1).unwrap(), Tm::MIDNIGHT), 199 | latest: DateTime::new(Dt::from_ordinal_date(2021, 4).unwrap(), Tm::MIDNIGHT), 200 | count: 4, 201 | parser_type: DateParserType::Date, 202 | format: parse_owned::<2>("[month]/[day]/[year]").ok(), 203 | }; 204 | 205 | assert_eq!(d.format, expected.format); 206 | assert_eq!(d.earliest, expected.earliest); 207 | assert_eq!(d.latest, expected.latest); 208 | assert_eq!(d.count, expected.count); 209 | let (rate, unit) = d.determine_rate(); 210 | assert_eq!(unit, "per day"); 211 | assert_eq!(rate, 1); 212 | } 213 | 214 | #[test] 215 | fn can_update_time() { 216 | let mut d: Date = Date::new("[hour]:[minute]:[second]", DateParserType::Time); 217 | d.update("01:01:00").unwrap(); 218 | d.update("02:01:00").unwrap(); 219 | d.update("03:01:00").unwrap(); 220 | d.update("04:01:00").unwrap(); 221 | 222 | let expected = Date { 223 | earliest: DateTime::new(Dt::MIN, Tm::from_hms(1, 1, 0).unwrap()), 224 | latest: DateTime::new(Dt::MIN, Tm::from_hms(4, 1, 0).unwrap()), 225 | count: 4, 226 | parser_type: DateParserType::Time, 227 | format: parse_owned::<2>("[hour]:[minute]:[second]").ok(), 228 | }; 229 | 230 | assert_eq!(d.format, expected.format); 231 | assert_eq!(d.earliest, expected.earliest); 232 | assert_eq!(d.latest, expected.latest); 233 | assert_eq!(d.count, expected.count); 234 | 235 | let (rate, unit) = d.determine_rate(); 236 | assert_eq!(unit, "per hour"); 237 | assert_eq!(rate, 1); 238 | } 239 | 240 | #[test] 241 | fn can_update_date_time() { 242 | let mut d: Date = Date::new( 243 | "[month]/[day]/[year] [hour]:[minute]:[second]", 244 | DateParserType::DateTime, 245 | ); 246 | 247 | d.update("01/01/2021 01:01:00").unwrap(); 248 | d.update("01/02/2021 02:01:00").unwrap(); 249 | d.update("01/03/2021 03:01:00").unwrap(); 250 | d.update("01/04/2021 04:01:00").unwrap(); 251 | 252 | let expected = Date { 253 | earliest: DateTime::new( 254 | Dt::from_ordinal_date(2021, 1).unwrap(), 255 | Tm::from_hms(1, 1, 0).unwrap(), 256 | ), 257 | latest: DateTime::new( 258 | Dt::from_ordinal_date(2021, 4).unwrap(), 259 | Tm::from_hms(4, 1, 0).unwrap(), 260 | ), 261 | count: 4, 262 | parser_type: DateParserType::DateTime, 263 | format: parse_owned::<2>("[month]/[day]/[year] [hour]:[minute]:[second]").ok(), 264 | }; 265 | 266 | assert_eq!(d.format, expected.format); 267 | assert_eq!(d.earliest, expected.earliest); 268 | assert_eq!(d.latest, expected.latest); 269 | assert_eq!(d.count, expected.count); 270 | 271 | let (rate, unit) = d.determine_rate(); 272 | assert_eq!(unit, "per day"); 273 | assert_eq!(rate, 1); 274 | } 275 | } 276 | 277 | #[cfg(test)] 278 | mod message_tests { 279 | use crate::util::aggregators::{ 280 | aggregator::Aggregator, 281 | date::{Date, DateParserType}, 282 | }; 283 | 284 | #[test] 285 | fn can_update_date() { 286 | let mut d: Date = Date::new("[month]/[day]/[year]", DateParserType::Date); 287 | d.update("01/01/2021").unwrap(); 288 | d.update("01/02/2021").unwrap(); 289 | d.update("01/03/2021").unwrap(); 290 | d.update("01/04/2021").unwrap(); 291 | 292 | let expected = vec![ 293 | " Rate: 1 per day".to_string(), 294 | " Count: 4".to_string(), 295 | " Earliest: 2021-01-01".to_string(), 296 | " Latest: 2021-01-04".to_string(), 297 | ]; 298 | let messages = d.messages(&1); 299 | 300 | assert_eq!(messages, expected); 301 | } 302 | 303 | #[test] 304 | fn can_update_time() { 305 | let mut d: Date = Date::new("[hour]:[minute]:[second]", DateParserType::Time); 306 | d.update("01:01:00").unwrap(); 307 | d.update("02:01:00").unwrap(); 308 | d.update("03:01:00").unwrap(); 309 | d.update("04:01:00").unwrap(); 310 | 311 | let expected = vec![ 312 | " Rate: 1 per hour".to_string(), 313 | " Count: 4".to_string(), 314 | " Earliest: 1:01:00.0".to_string(), 315 | " Latest: 4:01:00.0".to_string(), 316 | ]; 317 | let messages = d.messages(&1); 318 | 319 | assert_eq!(messages, expected); 320 | } 321 | 322 | #[test] 323 | fn can_update_date_time() { 324 | let mut d: Date = Date::new( 325 | "[month]/[day]/[year] [hour]:[minute]:[second]", 326 | DateParserType::DateTime, 327 | ); 328 | d.update("01/01/2021 01:01:00").unwrap(); 329 | d.update("01/02/2021 02:01:00").unwrap(); 330 | d.update("01/03/2021 03:01:00").unwrap(); 331 | d.update("01/04/2021 04:01:00").unwrap(); 332 | 333 | let expected = vec![ 334 | " Rate: 1 per day".to_string(), 335 | " Count: 4".to_string(), 336 | " Earliest: 2021-01-01 1:01:00.0".to_string(), 337 | " Latest: 2021-01-04 4:01:00.0".to_string(), 338 | ]; 339 | let messages = d.messages(&1); 340 | 341 | assert_eq!(messages, expected); 342 | } 343 | } 344 | 345 | #[cfg(test)] 346 | mod rate_tests { 347 | use crate::util::aggregators::date::{Date, DateParserType}; 348 | use time::{Date as Dt, PrimitiveDateTime as DateTime, Time as Tm}; 349 | 350 | #[test] 351 | fn weekly() { 352 | let d = Date { 353 | earliest: DateTime::new(Dt::from_ordinal_date(2021, 1).unwrap(), Tm::MIDNIGHT), 354 | latest: DateTime::new(Dt::from_ordinal_date(2021, 15).unwrap(), Tm::MIDNIGHT), 355 | count: 10, 356 | parser_type: DateParserType::Date, 357 | format: None, 358 | }; 359 | assert_eq!(d.determine_rate(), (5, "per week".to_string())); 360 | } 361 | 362 | #[test] 363 | fn daily() { 364 | let d = Date { 365 | earliest: DateTime::new(Dt::from_ordinal_date(2021, 1).unwrap(), Tm::MIDNIGHT), 366 | latest: DateTime::new(Dt::from_ordinal_date(2021, 15).unwrap(), Tm::MIDNIGHT), 367 | count: 15, 368 | parser_type: DateParserType::Date, 369 | format: None, 370 | }; 371 | assert_eq!(d.determine_rate(), (1, "per day".to_string())); 372 | } 373 | 374 | #[test] 375 | fn hourly() { 376 | let d = Date { 377 | earliest: DateTime::new(Dt::from_ordinal_date(2021, 1).unwrap(), Tm::MIDNIGHT), 378 | latest: DateTime::new(Dt::from_ordinal_date(2021, 3).unwrap(), Tm::MIDNIGHT), 379 | count: 150, 380 | parser_type: DateParserType::Date, 381 | format: None, 382 | }; 383 | assert_eq!(d.determine_rate(), (3, "per hour".to_string())); 384 | } 385 | 386 | #[test] 387 | fn minutely() { 388 | let d = Date { 389 | earliest: DateTime::new(Dt::from_ordinal_date(2021, 1).unwrap(), Tm::MIDNIGHT), 390 | latest: DateTime::new(Dt::from_ordinal_date(2021, 2).unwrap(), Tm::MIDNIGHT), 391 | count: 1500, 392 | parser_type: DateParserType::Date, 393 | format: None, 394 | }; 395 | assert_eq!(d.determine_rate(), (1, "per minute".to_string())); 396 | } 397 | 398 | #[test] 399 | fn secondly() { 400 | let d = Date { 401 | earliest: DateTime::new(Dt::from_ordinal_date(2021, 1).unwrap(), Tm::MIDNIGHT), 402 | latest: DateTime::new(Dt::from_ordinal_date(2021, 2).unwrap(), Tm::MIDNIGHT), 403 | count: 100000, 404 | parser_type: DateParserType::Date, 405 | format: None, 406 | }; 407 | assert_eq!(d.determine_rate(), (1, "per second".to_string())); 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /src/communication/input.rs: -------------------------------------------------------------------------------- 1 | use is_executable::is_executable; 2 | 3 | use crate::{ 4 | extensions::{ 5 | extension::ExtensionMethods, 6 | session::{Session, SessionType}, 7 | }, 8 | util::{ 9 | error::LogriaError, 10 | poll::{RollingMean, ms_per_message}, 11 | }, 12 | }; 13 | 14 | use std::{ 15 | collections::HashSet, 16 | env::current_dir, 17 | error::Error, 18 | fs::File, 19 | io::{BufRead, BufReader}, 20 | path::Path, 21 | process::{Child, Command, Stdio}, 22 | result::Result, 23 | sync::{ 24 | Arc, Mutex, 25 | atomic::{AtomicBool, Ordering}, 26 | mpsc::{Receiver, channel}, 27 | }, 28 | thread, time, 29 | }; 30 | 31 | #[derive(Debug)] 32 | pub struct InputStream { 33 | pub stdout: Receiver, 34 | pub stderr: Receiver, 35 | pub should_die: Arc, 36 | pub _type: String, 37 | pub handle: Option>, 38 | pub child: Option, 39 | } 40 | 41 | pub trait Input { 42 | fn build(name: String, command: String) -> Result; 43 | } 44 | 45 | #[derive(Debug)] 46 | pub struct FileInput {} 47 | 48 | impl Input for FileInput { 49 | /// Create a file input 50 | /// `poll_rate` is unused since the file will be read all at once 51 | fn build(name: String, command: String) -> Result { 52 | // Setup multiprocessing queues 53 | let (_, err_rx) = channel(); 54 | let (out_tx, out_rx) = channel(); 55 | 56 | // Try and open a handle to the file 57 | // Remove, as file input should be immediately buffered... 58 | let path = Path::new(&command); 59 | // Ensure file exists 60 | let file = match File::open(path) { 61 | // The `description` method of `io::Error` returns a string that describes the error 62 | Err(why) => { 63 | return Err(LogriaError::CannotRead( 64 | command, 65 | ::to_string(&why), 66 | )); 67 | } 68 | Ok(file) => file, 69 | }; 70 | 71 | // Start process 72 | let _ = thread::Builder::new() 73 | .name(format!("FileInput: {name}")) 74 | .spawn(move || { 75 | // Create a buffer and read from it 76 | let reader = BufReader::new(file); 77 | for line in reader.lines() { 78 | if line.is_ok() { 79 | out_tx 80 | .send(match line { 81 | Ok(a) => a, 82 | _ => unreachable!(), 83 | }) 84 | .unwrap(); 85 | } 86 | } 87 | }); 88 | 89 | Ok(InputStream { 90 | stdout: out_rx, 91 | stderr: err_rx, 92 | should_die: Arc::new(AtomicBool::new(false)), 93 | _type: String::from("FileInput"), 94 | handle: None, // No handle needed for file input 95 | child: None, // No child process for file input 96 | }) 97 | } 98 | } 99 | 100 | #[derive(Debug)] 101 | pub struct CommandInput {} 102 | 103 | impl CommandInput { 104 | /// Parse a command string to a list of parts for `subprocess` 105 | fn parse_command(command: &str) -> Vec<&str> { 106 | command.split(' ').collect() 107 | } 108 | } 109 | 110 | impl Input for CommandInput { 111 | /// Create a command input 112 | fn build(name: String, command: String) -> Result { 113 | let command_to_run = CommandInput::parse_command(&command); 114 | let mut child = match Command::new(command_to_run[0]) 115 | .args(&command_to_run[1..]) 116 | .current_dir(current_dir().unwrap()) 117 | .stdout(Stdio::piped()) 118 | .stderr(Stdio::piped()) 119 | .stdin(Stdio::null()) 120 | .spawn() 121 | { 122 | Ok(child) => child, 123 | Err(why) => { 124 | return Err(LogriaError::InvalidCommand(format!( 125 | "Unable to connect to process: {why}" 126 | ))); 127 | } 128 | }; 129 | 130 | // Get stdout and stderr handles 131 | let stdout = child.stdout.take().unwrap(); 132 | let stderr = child.stderr.take().unwrap(); 133 | 134 | // Setup multiprocessing queues 135 | let (err_tx, err_rx) = channel(); 136 | let (out_tx, out_rx) = channel(); 137 | 138 | // Provide check for termination outside of the thread 139 | let should_die = Arc::new(AtomicBool::new(false)); 140 | let should_die_clone = Arc::clone(&should_die); 141 | 142 | // Handle poll rate for each stream 143 | let poll_rate_stdout = Arc::new(Mutex::new(RollingMean::new(5))); 144 | let poll_rate_stderr = Arc::new(Mutex::new(RollingMean::new(5))); 145 | 146 | // Start reading from the queues 147 | let handle = thread::Builder::new() 148 | .name(format!("CommandInput: {name}")) 149 | .spawn(move || { 150 | // Create readers 151 | let mut stdout_reader = BufReader::new(stdout); 152 | let mut stderr_reader = BufReader::new(stderr); 153 | 154 | // Create threads to read stdout and stderr independently 155 | let die_clone = Arc::clone(&should_die_clone); 156 | let poll_stdout = poll_rate_stdout.clone(); 157 | let stdout_handle = thread::spawn(move || { 158 | loop { 159 | thread::sleep(time::Duration::from_millis( 160 | poll_stdout.lock().unwrap().mean(), 161 | )); 162 | 163 | // Exit if the process is requested to die 164 | if die_clone.load(Ordering::Relaxed) { 165 | break; 166 | } 167 | 168 | let mut buf_stdout = String::new(); 169 | let timestamp = time::Instant::now(); 170 | stdout_reader.read_line(&mut buf_stdout).unwrap(); 171 | 172 | if buf_stdout.is_empty() { 173 | poll_stdout 174 | .lock() 175 | .unwrap() 176 | .update(ms_per_message(timestamp.elapsed(), 0)); 177 | continue; 178 | } 179 | 180 | if out_tx.send(buf_stdout).is_err() { 181 | break; 182 | } 183 | 184 | poll_stdout 185 | .lock() 186 | .unwrap() 187 | .update(ms_per_message(timestamp.elapsed(), 1)); 188 | } 189 | }); 190 | 191 | let die_clone = Arc::clone(&should_die_clone); 192 | let poll_stderr = poll_rate_stderr.clone(); 193 | let stderr_handle = thread::spawn(move || { 194 | loop { 195 | thread::sleep(time::Duration::from_millis( 196 | poll_stderr.lock().unwrap().mean(), 197 | )); 198 | 199 | // Exit if the process is requested to die 200 | if die_clone.load(Ordering::Relaxed) { 201 | break; 202 | } 203 | 204 | let mut buf_stderr = String::new(); 205 | let timestamp = time::Instant::now(); 206 | stderr_reader.read_line(&mut buf_stderr).unwrap(); 207 | 208 | if buf_stderr.is_empty() { 209 | poll_stderr 210 | .lock() 211 | .unwrap() 212 | .update(ms_per_message(timestamp.elapsed(), 0)); 213 | continue; 214 | } 215 | 216 | if err_tx.send(buf_stderr).is_err() { 217 | break; 218 | } 219 | 220 | poll_stderr 221 | .lock() 222 | .unwrap() 223 | .update(ms_per_message(timestamp.elapsed(), 1)); 224 | } 225 | }); 226 | 227 | // Wait for both readers to complete 228 | stdout_handle.join().unwrap(); 229 | stderr_handle.join().unwrap(); 230 | }) 231 | .unwrap(); 232 | 233 | Ok(InputStream { 234 | stdout: out_rx, 235 | stderr: err_rx, 236 | should_die, 237 | _type: String::from("CommandInput"), 238 | handle: Some(handle), 239 | child: Some(child), 240 | }) 241 | } 242 | } 243 | 244 | fn determine_stream_type(command: &str) -> SessionType { 245 | let path = Path::new(command); 246 | if path.exists() { 247 | if is_executable(path) { 248 | SessionType::Command 249 | } else { 250 | SessionType::File 251 | } 252 | } else { 253 | SessionType::Command 254 | } 255 | } 256 | 257 | /// Build app streams from user input, i.e. command text or a filepath 258 | pub fn build_streams_from_input( 259 | commands: &[String], 260 | save: bool, 261 | ) -> Result, LogriaError> { 262 | let mut streams: Vec = vec![]; 263 | let mut stream_types: HashSet = HashSet::new(); 264 | for command in commands { 265 | // Determine if command is a file, create FileInput if it is, CommandInput if not 266 | match determine_stream_type(command) { 267 | SessionType::Command => { 268 | // None indicates default poll rate 269 | match CommandInput::build(command.to_owned(), command.to_owned()) { 270 | Ok(stream) => streams.push(stream), 271 | Err(why) => return Err(why), 272 | } 273 | stream_types.insert(SessionType::Command); 274 | } 275 | SessionType::File => { 276 | // None indicates default poll rate 277 | let path = Path::new(command); 278 | let name = path.file_name().unwrap().to_str().unwrap().to_string(); 279 | match FileInput::build(name, command.to_owned()) { 280 | Ok(stream) => streams.push(stream), 281 | Err(why) => return Err(why), 282 | } 283 | stream_types.insert(SessionType::File); 284 | } 285 | _ => {} 286 | } 287 | } 288 | if save { 289 | let stream_type = match stream_types.len() { 290 | 1 => { 291 | if stream_types.contains(&SessionType::File) { 292 | SessionType::File 293 | } else if stream_types.contains(&SessionType::Command) { 294 | SessionType::Command 295 | } else { 296 | SessionType::Mixed 297 | } 298 | } 299 | _ => SessionType::Mixed, 300 | }; 301 | return match Session::new(commands, stream_type).save(&commands[0]) { 302 | Ok(()) => Ok(streams), 303 | Err(why) => Err(why), 304 | }; 305 | } 306 | Ok(streams) 307 | } 308 | 309 | /// Build app streams from a session struct 310 | pub fn build_streams_from_session(session: Session) -> Result, LogriaError> { 311 | match session.stream_type { 312 | SessionType::Command => { 313 | let mut streams: Vec = vec![]; 314 | for command in session.commands { 315 | match CommandInput::build(command.clone(), command.clone()) { 316 | Ok(stream) => streams.push(stream), 317 | Err(why) => return Err(why), 318 | } 319 | } 320 | Ok(streams) 321 | } 322 | SessionType::File => { 323 | let mut streams: Vec = vec![]; 324 | for command in session.commands { 325 | match FileInput::build(command.clone(), command.clone()) { 326 | Ok(stream) => streams.push(stream), 327 | Err(why) => return Err(why), 328 | } 329 | } 330 | Ok(streams) 331 | } 332 | SessionType::Mixed => build_streams_from_input(&session.commands, false), 333 | } 334 | } 335 | 336 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 337 | pub enum InputType { 338 | Normal, 339 | Command, 340 | Regex, 341 | Highlight, 342 | Parser, 343 | Startup, 344 | } 345 | 346 | #[derive(Debug, Clone, Copy)] 347 | pub enum StreamType { 348 | StdErr, 349 | StdOut, 350 | Auxiliary, 351 | } 352 | 353 | #[cfg(test)] 354 | mod session_type_tests { 355 | use crate::{communication::input::determine_stream_type, extensions::session::SessionType}; 356 | 357 | #[test] 358 | fn can_build_command_simple() { 359 | assert_eq!(determine_stream_type("ls"), SessionType::Command); 360 | } 361 | 362 | #[test] 363 | fn can_build_command_simple_arg() { 364 | assert_eq!(determine_stream_type("ls -lga"), SessionType::Command); 365 | } 366 | 367 | #[test] 368 | fn can_build_command_simple_pipe() { 369 | assert_eq!( 370 | determine_stream_type("echo 'thing' | cat"), 371 | SessionType::Command 372 | ); 373 | } 374 | 375 | #[test] 376 | fn can_build_command_awk_file() { 377 | assert_eq!( 378 | determine_stream_type( 379 | "awk '{ if (length($0) > max) max = length($0) } END { print max }' fake.txt" 380 | ), 381 | SessionType::Command 382 | ); 383 | } 384 | 385 | #[test] 386 | fn can_build_command_qualified_path_args() { 387 | assert_eq!( 388 | determine_stream_type("/bin/cp fake.txt fake2.txt"), 389 | SessionType::Command 390 | ); 391 | } 392 | 393 | #[test] 394 | fn can_build_command_qualified_path_no_args() { 395 | assert_eq!(determine_stream_type("/bin/pwd"), SessionType::Command); 396 | } 397 | 398 | #[test] 399 | fn can_build_file_simple() { 400 | assert_eq!(determine_stream_type("/"), SessionType::File); 401 | } 402 | } 403 | 404 | #[cfg(test)] 405 | mod stream_tests { 406 | use crate::{ 407 | communication::input::{build_streams_from_input, build_streams_from_session}, 408 | extensions::session::{Session, SessionType}, 409 | }; 410 | 411 | #[test] 412 | fn test_build_file_stream() { 413 | let commands = vec![String::from("README.md")]; 414 | let streams = build_streams_from_input(&commands, false).unwrap(); 415 | assert_eq!(streams[0]._type, "FileInput"); 416 | } 417 | 418 | #[test] 419 | fn test_build_command_stream() { 420 | let commands = vec![String::from("ls -la ~")]; 421 | let streams = build_streams_from_input(&commands, false).unwrap(); 422 | assert_eq!(streams[0]._type, "CommandInput"); 423 | } 424 | 425 | #[test] 426 | fn test_build_command_and_file_streams() { 427 | let commands = vec![String::from("ls -la ~"), String::from("README.md")]; 428 | let streams = build_streams_from_input(&commands, false).unwrap(); 429 | assert_eq!(streams[0]._type, "CommandInput"); 430 | assert_eq!(streams[1]._type, "FileInput"); 431 | } 432 | 433 | #[test] 434 | fn test_build_multiple_command_streams() { 435 | let commands = vec![String::from("ls -la ~"), String::from("ls /")]; 436 | let streams = build_streams_from_input(&commands, false).unwrap(); 437 | assert_eq!(streams[0]._type, "CommandInput"); 438 | assert_eq!(streams[1]._type, "CommandInput"); 439 | } 440 | 441 | #[test] 442 | fn test_build_multiple_file_streams() { 443 | let commands = vec![String::from("README.md"), String::from("Cargo.toml")]; 444 | let streams = build_streams_from_input(&commands, false).unwrap(); 445 | assert_eq!(streams[0]._type, "FileInput"); 446 | assert_eq!(streams[1]._type, "FileInput"); 447 | } 448 | 449 | #[test] 450 | fn test_build_file_stream_from_session() { 451 | let session = Session::new(&[String::from("README.md")], SessionType::File); 452 | let streams = build_streams_from_session(session).unwrap(); 453 | assert_eq!(streams[0]._type, "FileInput"); 454 | } 455 | 456 | #[test] 457 | fn test_build_command_stream_from_session() { 458 | let session = Session::new(&[String::from("ls -l")], SessionType::Command); 459 | let streams = build_streams_from_session(session).unwrap(); 460 | assert_eq!(streams[0]._type, "CommandInput"); 461 | } 462 | 463 | #[test] 464 | fn test_build_mixed_stream_from_session() { 465 | let session = Session::new( 466 | &[String::from("ls -l"), String::from("README.md")], 467 | SessionType::Mixed, 468 | ); 469 | let streams = build_streams_from_session(session).unwrap(); 470 | assert_eq!(streams[0]._type, "CommandInput"); 471 | assert_eq!(streams[1]._type, "FileInput"); 472 | } 473 | } 474 | --------------------------------------------------------------------------------