diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..cd5ce8f --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,21 @@ +# ref: https://docs.codecov.com/docs/codecovyml-reference +coverage: + # Hold ourselves to a high bar + range: 85..100 + round: down + precision: 1 + status: + # ref: https://docs.codecov.com/docs/commit-status + project: + default: + # Avoid false negatives + threshold: 1% + +# Test files aren't important for coverage +ignore: + - "tests" + +# Make comments less noisy +comment: + layout: "files" + require_changes: true diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3e0ec4b..cc903c4 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -66,3 +66,33 @@ jobs: toolchain: stable - name: cargo test --locked run: cargo test --locked --all-features + + coverage: + runs-on: ubuntu-latest + name: ubuntu / stable / coverage + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # tag=v6.0.2 + with: + submodules: true + - name: Install stable + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # branch=master + with: + toolchain: stable + components: llvm-tools-preview + - name: cargo install cargo-llvm-cov + uses: taiki-e/install-action@68675c5a5f1a6950c3975d33f3ae0ef155e5bf3d # tag=v2.68.15 + with: + tool: cargo-llvm-cov + - name: cargo generate-lockfile + if: hashFiles('Cargo.lock') == '' + run: cargo generate-lockfile + - name: cargo llvm-cov + run: cargo llvm-cov --locked --all-features --lcov --output-path lcov.info + - name: Record Rust version + run: echo "RUST=$(rustc --version)" >> "$GITHUB_ENV" + - name: Upload to codecov.io + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # tag=v5.5.2 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + env_vars: OS,RUST diff --git a/src/event.rs b/src/event.rs index acd22d9..a86e421 100644 --- a/src/event.rs +++ b/src/event.rs @@ -9,13 +9,18 @@ use crate::formats::rmp::BinaryWriter; #[derive(Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "kebab-case")] +/// Event format for entries in the history file. pub struct Event { + /// time when execution of the command began pub timestamp_millis: i64, pub command: String, + /// records time when the command ended (can be used to calculate duration) pub endtime: i64, pub exit_code: i16, pub folder: String, + /// a special machine id to filter by machine pub machine: String, + /// a special session id to filter by session pub session: String, } @@ -38,6 +43,8 @@ impl Arbitrary<'_> for Event { #[allow(deprecated)] impl From for Event { + /// Converts a `JsonLineEvent` to the new binary format. The `JsonLineEven` format is deprecated + /// and this is only used to convert old history files to the binary format. fn from(event: JsonLineEvent) -> Self { let timestamp = event.timestamp.timestamp_millis(); Self { diff --git a/src/formats.rs b/src/formats.rs index e7b505a..80c89da 100644 --- a/src/formats.rs +++ b/src/formats.rs @@ -1,12 +1,15 @@ +//! Supported history file formats: [`json_lines`] (deprecated) and [`rmp`] (current binary). pub mod json_lines; pub mod rmp; +/// all formats we support pub enum Kind { JsonLines, Rmp, } impl Kind { + /// get file extension for a format kind pub fn extension(&self) -> String { match self { Kind::JsonLines => "osh".to_string(), diff --git a/src/formats/json_lines.rs b/src/formats/json_lines.rs index 5a0c2cb..3d2b172 100644 --- a/src/formats/json_lines.rs +++ b/src/formats/json_lines.rs @@ -1,3 +1,5 @@ +//! Deprecated JSON-per-line format with a header. Only used to migrate old `.osh` files to the +//! current binary format. #![allow(deprecated)] use chrono::{DateTime, Local}; @@ -69,6 +71,7 @@ impl Entry { } } +/// parse and collect all [`JsonLineEvent`]s in the slice pub fn load_osh_events(data: &[u8]) -> std::io::Result> { let mut events = Vec::new(); for line in data.split(|c| *c == b'\n') { diff --git a/src/formats/rmp.rs b/src/formats/rmp.rs index 9b5b5af..96c752d 100644 --- a/src/formats/rmp.rs +++ b/src/formats/rmp.rs @@ -1,3 +1,6 @@ +//! Binary format using `rmp_serde`. Wire layout: each record is an 8-byte LE length prefix +//! followed by a msgpack-encoded [`Event`]. This allows O(1) appends without deserialising the +//! whole file. use std::io::Write; use rmp_serde::{decode, encode::to_vec}; @@ -29,6 +32,7 @@ impl BinaryWriter { } } +/// parse and collect all [`Event`]s in the slice pub fn load_osh_events(data: &[u8]) -> std::io::Result> { let mut events = Vec::new(); let mut cursor = 0; diff --git a/src/lib.rs b/src/lib.rs index bc9b782..499e035 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,16 +18,17 @@ pub mod formats; pub mod matcher; pub mod ui; -pub fn mmap(f: &File) -> &'_ [u8] { +/// memory map `file` +pub fn mmap(file: &File) -> &'_ [u8] { #[allow(clippy::unwrap_used)] - let len = f.metadata().unwrap().len(); + let len = file.metadata().unwrap().len(); unsafe { let ptr = libc::mmap( std::ptr::null_mut(), len as libc::size_t, libc::PROT_READ, libc::MAP_SHARED, - f.as_raw_fd(), + file.as_raw_fd(), 0, ); if ptr == libc::MAP_FAILED { @@ -41,6 +42,7 @@ pub fn mmap(f: &File) -> &'_ [u8] { } } +/// discover all parsable osh files under `~/.osh` for a specific format pub fn osh_files(kind: formats::Kind) -> anyhow::Result> { // TODO when can this really fail? let home_dir = home::home_dir().ok_or(anyhow!("no home directory"))?; @@ -60,6 +62,7 @@ pub fn osh_files(kind: formats::Kind) -> anyhow::Result> { Ok(files) } +/// load all binary osh files in `~/.osh` and return a merged and sorted vector of all events pub fn load_sorted() -> anyhow::Result> { let oshs = osh_files(Kind::Rmp)?; let osh_files: Vec = oshs diff --git a/src/ui.rs b/src/ui.rs index 56dfab4..88babc0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,4 @@ +//! Ratatui-based TUI. Entry point is [`Tui::start`]. use std::{ collections::HashSet, fmt::Display, @@ -111,6 +112,8 @@ impl FromStr for EventFilter { pub struct Tui; impl Tui { + /// Set up the terminal and run the TUI. `receiver` is fed [`Event`]s by the caller. + /// Returns the selected event, if any. pub fn start( receiver: Receiver>, query: &str, @@ -155,26 +158,25 @@ impl Tui { } } -/// App holds the state of the application +/// app holds the state of the application struct App { - /// Current value of the input box + /// current value of the input box input: String, - /// Position of cursor in the editor area. + /// position of cursor in the editor area. character_index: usize, - /// History of recorded messages + /// display entries: (time-ago label, command) pairs derived from `events` history: Vec<(String, String)>, /// indices into history sorted according to fuzzer score if we have a query indexer: FuzzyIndex, - /// Reader for collecting events from background thread + /// reader for collecting events from background thread reader: EventReader, - /// Accumulated events pool for filtering and matching + /// accumulated events pool for filtering and matching events: Vec>, - /// Currently selected index in the history widget (0 = bottom-most) + /// currently selected index in the history widget (0 = bottom-most) selected_index: usize, /// currently active event filter filters: HashSet, folder: String, - /// Current session id session_id: Option, show_score: bool, } @@ -291,10 +293,7 @@ impl App { self.run_matcher(); } - /// Returns the byte index based on the character position. - /// - /// Since each character in a string can contain multiple bytes, it's necessary to calculate - /// the byte index based on the index of the character. + /// returns the byte index for `character_index`, which counts Unicode scalar values not bytes. fn byte_index(&self) -> usize { self.input .char_indices() @@ -306,20 +305,11 @@ impl App { fn delete_char(&mut self) { let is_not_cursor_leftmost = self.character_index != 0; if is_not_cursor_leftmost { - // Method "remove" is not used on the saved text for deleting the selected char. - // Reason: Using remove on String works on bytes instead of the chars. - // Using remove would require special care because of char boundaries. - + // String::remove works on bytes, not chars; reconstruct around the character instead. let current_index = self.character_index; let from_left_to_current_index = current_index - 1; - - // Getting all characters before the selected character. let before_char_to_delete = self.input.chars().take(from_left_to_current_index); - // Getting all characters after selected character. let after_char_to_delete = self.input.chars().skip(current_index); - - // Put all characters together except the selected one. - // By leaving the selected one out, it is forgotten and therefore deleted. self.input = before_char_to_delete.chain(after_char_to_delete).collect(); self.move_cursor_left(); }