diff --git a/.github/codecov.yml b/.github/codecov.yml index cd5ce8f..7c108bf 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -11,9 +11,9 @@ coverage: # Avoid false negatives threshold: 1% -# Test files aren't important for coverage ignore: - - "tests" + - "tests" # test fixtures + - "src/formats/json_lines.rs" # deprecated, migration only # Make comments less noisy comment: diff --git a/src/event.rs b/src/event.rs index a86e421..572de89 100644 --- a/src/event.rs +++ b/src/event.rs @@ -47,10 +47,11 @@ impl From for Event { /// 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(); + let endtime = timestamp + (event.duration * 1000.) as i64; Self { timestamp_millis: timestamp, command: event.command, - endtime: (timestamp + (event.duration as i64)), + endtime, exit_code: event.exit_code, folder: event.folder, machine: event.machine, @@ -78,3 +79,78 @@ impl Event { writer.write(self) } } + +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use super::*; + + fn event_with_endtime(endtime: i64) -> Event { + Event { + timestamp_millis: 0, + command: String::new(), + endtime, + exit_code: 0, + folder: String::new(), + machine: String::new(), + session: String::new(), + } + } + + #[test] + fn cmp_orders_by_endtime() { + let earlier = event_with_endtime(100); + let later = event_with_endtime(200); + assert_eq!(earlier.cmp(&later), Ordering::Less); + assert_eq!(later.cmp(&earlier), Ordering::Greater); + assert_eq!(earlier.cmp(&earlier), Ordering::Equal); + } + + #[test] + fn partial_cmp_always_some() { + let a = event_with_endtime(100); + let b = event_with_endtime(200); + assert_eq!(a.partial_cmp(&b), Some(Ordering::Less)); + assert_eq!(b.partial_cmp(&a), Some(Ordering::Greater)); + assert_eq!(a.partial_cmp(&a), Some(Ordering::Equal)); + } + + #[allow(deprecated)] + #[test] + fn from_json_line_event() { + let timestamp = chrono::DateTime::from_timestamp_millis(1_000_000_000_000).unwrap(); + let timestamp = timestamp.with_timezone(&chrono::Local); + let json_event = crate::formats::json_lines::JsonLineEvent { + timestamp, + command: "sleep 5".to_string(), + duration: 5.0, + exit_code: 0, + folder: "/".to_string(), + machine: "m".to_string(), + session: "s".to_string(), + }; + let event = Event::from(json_event); + assert_eq!(event.timestamp_millis, 1_000_000_000_000); + assert_eq!(event.endtime, 1_000_000_000_000_i64 + 5 * 1000); + assert_eq!(event.command, "sleep 5"); + assert_eq!(event.exit_code, 0); + assert_eq!(event.folder, "/"); + assert_eq!(event.machine, "m"); + assert_eq!(event.session, "s"); + } + + #[test] + fn sort_by_endtime() { + let mut events = vec![ + event_with_endtime(300), + event_with_endtime(100), + event_with_endtime(200), + ]; + events.sort(); + assert_eq!( + events.iter().map(|e| e.endtime).collect::>(), + vec![100, 200, 300] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 499e035..d10ac13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,9 @@ -use std::{collections::HashSet, fs::File, os::fd::AsRawFd, path::PathBuf}; +use std::{ + collections::HashSet, + fs::File, + os::fd::AsRawFd, + path::{Path, PathBuf}, +}; use anyhow::anyhow; use glob::glob; @@ -42,15 +47,14 @@ pub fn mmap(file: &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"))?; - let home = home_dir - .to_str() - .ok_or(anyhow!("home directory contains invalid chars"))?; - let pattern = format!("{home}/.osh/**/*.{}", kind.extension()); - +/// discover all osh files of `kind` under `root`, recursively. +fn discover_files(root: &Path, kind: &formats::Kind) -> anyhow::Result> { + let pattern = format!( + "{}/**/*.{}", + root.to_str() + .ok_or(anyhow!("root path contains invalid chars"))?, + kind.extension() + ); let files = match glob(&pattern) { Err(_) => unreachable!("pattern is valid"), Ok(matches) => matches @@ -58,10 +62,15 @@ pub fn osh_files(kind: formats::Kind) -> anyhow::Result> { .filter_map(|path| path.canonicalize().ok()) .collect(), }; - Ok(files) } +/// discover all parsable osh files under `~/.osh` for a specific format +pub fn osh_files(kind: formats::Kind) -> anyhow::Result> { + let home_dir = home::home_dir().ok_or(anyhow!("no home directory"))?; + discover_files(&home_dir.join(".osh"), &kind) +} + /// 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)?; @@ -79,3 +88,51 @@ pub fn load_sorted() -> anyhow::Result> { all_items.par_sort_unstable_by(|a, b| b.cmp(a)); Ok(all_items) } + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn mmap_reads_file_contents() { + let mut file = tempfile::tempfile().unwrap(); + let data = b"hello mmap"; + file.write_all(data).unwrap(); + let mapped = mmap(&file); + assert_eq!(mapped, data); + } + + #[test] + fn discover_mixed_extensions() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("machine1")).unwrap(); + std::fs::File::create(dir.path().join("machine1/history.bosh")).unwrap(); + std::fs::File::create(dir.path().join("machine1/history.osh")).unwrap(); + + let found = discover_files(dir.path(), &Kind::Rmp).unwrap(); + assert_eq!(found.len(), 1); + assert!(found.iter().all(|p| p.extension().unwrap() == "bosh")); + } + + #[test] + fn discover_empty_dir() { + let dir = TempDir::new().unwrap(); + let found = discover_files(dir.path(), &Kind::Rmp).unwrap(); + assert!(found.is_empty()); + } + + #[test] + fn discover_nested() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("a/b")).unwrap(); + std::fs::File::create(dir.path().join("a/one.bosh")).unwrap(); + std::fs::File::create(dir.path().join("a/b/two.bosh")).unwrap(); + + let found = discover_files(dir.path(), &Kind::Rmp).unwrap(); + assert_eq!(found.len(), 2); + } +} diff --git a/src/matcher.rs b/src/matcher.rs index 789794e..1040745 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -313,7 +313,7 @@ impl FuzzyIndex { } } - /// crates an index from a matcher result + /// crates an index from a sorted matcher result pub fn new(scored_indices: Vec<(usize, i64)>, highlight_indices: Vec>) -> Self { let (indices, scores) = scored_indices.into_iter().unzip(); Self { @@ -374,6 +374,77 @@ impl FuzzyIndex { mod tests { use super::*; + #[test] + fn fuzzy_index_identity_get() { + let index = FuzzyIndex::identity(); + assert_eq!(index.get(0), Some(0)); + assert_eq!(index.get(42), Some(42)); + } + + #[test] + fn fuzzy_index_identity_len_and_empty() { + let index = FuzzyIndex::identity(); + assert_eq!(index.len(), None); + assert!(index.is_empty()); + } + + #[test] + fn fuzzy_index_identity_first_n() { + let index = FuzzyIndex::identity(); + let result: Vec = index.first_n(3).collect(); + assert_eq!(result, vec![0, 1, 2]); + } + + #[test] + fn fuzzy_index_identity_no_highlights_or_scores() { + let index = FuzzyIndex::identity(); + assert_eq!(index.highlight_indices(0), None); + assert_eq!(index.matcher_score(0), None); + } + + #[test] + fn fuzzy_index_filtered_get() { + let index = FuzzyIndex::new(vec![(5, 100), (2, 50)], vec![vec![0, 1], vec![3]]); + assert_eq!(index.get(0), Some(5)); + assert_eq!(index.get(1), Some(2)); + assert_eq!(index.get(2), None); + } + + #[test] + fn fuzzy_index_filtered_len() { + let index = FuzzyIndex::new(vec![(5, 100), (2, 50)], vec![vec![], vec![]]); + assert_eq!(index.len(), Some(2)); + assert!(!index.is_empty()); + } + + #[test] + fn fuzzy_index_filtered_first_n() { + let index = FuzzyIndex::new( + vec![(5, 100), (2, 50), (8, 10)], + vec![vec![], vec![], vec![]], + ); + let result: Vec = index.first_n(2).collect(); + assert_eq!(result, vec![5, 2]); + } + + #[test] + fn fuzzy_index_filtered_first_n_clamps_to_available() { + let index = FuzzyIndex::new(vec![(1, 10)], vec![vec![]]); + let result: Vec = index.first_n(10).collect(); + assert_eq!(result, vec![1]); + } + + #[test] + fn fuzzy_index_filtered_scores_and_highlights() { + let index = FuzzyIndex::new(vec![(5, 100), (2, 50)], vec![vec![0, 1], vec![3, 4]]); + assert_eq!(index.matcher_score(0), Some(100)); + assert_eq!(index.matcher_score(1), Some(50)); + assert_eq!(index.matcher_score(2), None); + assert_eq!(index.highlight_indices(0), Some(&vec![0, 1])); + assert_eq!(index.highlight_indices(1), Some(&vec![3, 4])); + assert_eq!(index.highlight_indices(2), None); + } + #[test] fn single_term_matches() { let engine = FuzzyEngine::new("git".to_string()); diff --git a/src/ui.rs b/src/ui.rs index 88babc0..5ed3ba1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -164,9 +164,7 @@ struct App { input: String, /// position of cursor in the editor area. character_index: usize, - /// 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 + /// indices into events sorted according to fuzzy score if we have a query indexer: FuzzyIndex, /// reader for collecting events from background thread reader: EventReader, @@ -193,7 +191,6 @@ impl App { let character_index = query.len(); Self { input: query, - history: Vec::new(), indexer: FuzzyIndex::identity(), character_index, reader, @@ -355,18 +352,6 @@ impl App { new_cursor_pos.clamp(0, self.input.chars().count()) } - fn update_display(&mut self) { - self.history.clear(); - let f = timeago::Formatter::new(); - let now = Utc::now().timestamp_millis(); - for event in &self.events { - let d = std::time::Duration::from_millis((now - event.endtime) as u64); - let ago = f.convert(d); - // TODO clone - self.history.push((ago, event.command.clone())); - } - } - fn toggle_filter(&mut self, event: EventFilter) { if self.filters.contains(&event) { self.filters.remove(&event); @@ -394,7 +379,6 @@ impl App { terminal: &mut Terminal>, ) -> anyhow::Result> { self.collect_new_events(); - self.update_display(); terminal.draw(|frame| self.render(frame))?; loop { @@ -469,7 +453,6 @@ impl App { self.collect_new_events(); if self.events.len() != events_before { self.run_matcher(); - self.update_display(); terminal.draw(|frame| self.render(frame))?; } } @@ -487,14 +470,20 @@ impl App { let available_height = history_area.height.saturating_sub(1) as usize; + let now = Utc::now().timestamp_millis(); + let timeago_fmt = timeago::Formatter::new(); let history: Vec = self .indexer - .first_n(available_height.min(self.history.len())) + .first_n(available_height.min(self.events.len())) .enumerate() .rev() .filter_map(|(i, idx)| { // TODO should always be Some(...): skip, report, log otherwise? - let (ago, command) = self.history.get(idx)?; + let event = self.events.get(idx)?; + let ago = timeago_fmt.convert(std::time::Duration::from_millis( + (now - event.endtime) as u64, + )); + let command = &event.command; let mut spans = Vec::new(); spans.push(Span::raw(format!("{ago} -- "))); if let Some(hl_indides) = self.indexer.highlight_indices(idx) { @@ -524,7 +513,7 @@ impl App { spans.push(Span::raw(text)); } } else { - spans.push(Span::raw(command.clone())); + spans.push(Span::raw(command.as_str())); } if self.show_score @@ -546,7 +535,7 @@ impl App { frame.render_widget(history_widget, history_area); let filtered = self.indexer.len().unwrap_or(self.events.len()); - let status_text = format!("{filtered}/{}", self.history.len()); + let status_text = format!("{filtered}/{}", self.events.len()); let status_line = Line::from(vec![Span::raw(" "), Span::raw(status_text)]); let filters = format!("[{}] ", self.active_filters()); let status_line_chunks = Layout::default() @@ -591,3 +580,278 @@ impl App { frame.render_widget(preview, preview_area); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_app(input: &str) -> App { + let character_index = input.chars().count(); + App { + input: input.to_string(), + character_index, + indexer: crate::matcher::FuzzyIndex::identity(), + reader: EventReader::new(), + events: Vec::new(), + selected_index: 0, + filters: HashSet::new(), + folder: String::new(), + session_id: None, + show_score: false, + } + } + + #[test] + fn delete_word_basic() { + let mut app = make_app("hello world"); + app.delete_word(); + assert_eq!(app.input, "hello "); + assert_eq!(app.character_index, 6); + } + + #[test] + fn delete_word_trailing_spaces() { + let mut app = make_app("hello "); + app.delete_word(); + assert_eq!(app.input, ""); + assert_eq!(app.character_index, 0); + } + + #[test] + fn delete_word_single_word() { + let mut app = make_app("hello"); + app.delete_word(); + assert_eq!(app.input, ""); + assert_eq!(app.character_index, 0); + } + + #[test] + fn delete_word_at_start_is_noop() { + let mut app = make_app("hello"); + app.character_index = 0; + app.delete_word(); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 0); + } + + #[test] + fn delete_word_mid_word() { + let mut app = make_app("hello world"); + app.character_index = 7; // cursor between 'w' and 'o' in "world" + app.delete_word(); + assert_eq!(app.input, "hello orld"); + assert_eq!(app.character_index, 6); + } + + #[test] + fn byte_index_ascii() { + let app = make_app("hello"); + assert_eq!(app.byte_index(), 5); + } + + #[test] + fn byte_index_multibyte() { + // "é" is 2 bytes; cursor after it should give byte index 2 + let mut app = make_app("é"); + app.character_index = 1; + assert_eq!(app.byte_index(), 2); + } + + #[test] + fn byte_index_at_start() { + let mut app = make_app("hello"); + app.character_index = 0; + assert_eq!(app.byte_index(), 0); + } + + #[test] + fn toggle_filter_adds_and_removes() { + let mut app = make_app(""); + app.toggle_filter(EventFilter::Duplicates); + assert!(app.filters.contains(&EventFilter::Duplicates)); + app.toggle_filter(EventFilter::Duplicates); + assert!(!app.filters.contains(&EventFilter::Duplicates)); + } + + #[test] + fn active_filters_empty() { + let app = make_app(""); + assert_eq!(app.active_filters(), ""); + } + + #[test] + fn active_filters_shows_abbreviations() { + let mut app = make_app(""); + app.toggle_filter(EventFilter::Duplicates); + app.toggle_filter(EventFilter::ExitCodeSuccess); + app.toggle_filter(EventFilter::Folder); + app.toggle_filter(EventFilter::SessionId); + let filters = app.active_filters(); + assert!(filters.contains('U')); + assert!(filters.contains('E')); + assert!(filters.contains('F')); + assert!(filters.contains('S')); + } + + #[test] + fn move_selection_up_increments() { + let mut app = make_app(""); + app.move_selection_up(10); + assert_eq!(app.selected_index, 1); + } + + #[test] + fn move_selection_up_clamps_to_height() { + let mut app = make_app(""); + app.selected_index = 6; + app.move_selection_up(8); // max = 8 - 3 = 5 + assert_eq!(app.selected_index, 5); + } + + #[test] + fn move_selection_down_decrements() { + let mut app = make_app(""); + app.selected_index = 3; + app.move_selection_down(); + assert_eq!(app.selected_index, 2); + } + + #[test] + fn move_selection_down_clamps_at_zero() { + let mut app = make_app(""); + app.move_selection_down(); + assert_eq!(app.selected_index, 0); + } + + #[test] + fn enter_char_appends_at_end() { + let mut app = make_app("hell"); + app.enter_char('o'); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 5); + } + + #[test] + fn enter_char_inserts_at_start() { + let mut app = make_app("ello"); + app.character_index = 0; + app.enter_char('h'); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 1); + } + + #[test] + fn enter_char_inserts_in_middle() { + let mut app = make_app("hllo"); + app.character_index = 1; + app.enter_char('e'); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 2); + } + + #[test] + fn enter_char_multibyte() { + let mut app = make_app("h"); + app.enter_char('é'); + assert_eq!(app.input, "hé"); + assert_eq!(app.character_index, 2); + } + + #[test] + fn collect_new_events_drains_channel() { + let (sender, receiver) = crossbeam_channel::unbounded(); + let mut app = make_app(""); + app.reader = EventReader::new().start(receiver); + + let event = Arc::new(Event { + timestamp_millis: 0, + command: "git status".to_string(), + endtime: 1000, + exit_code: 0, + folder: "/".to_string(), + machine: "m".to_string(), + session: "s".to_string(), + }); + sender.send(event).unwrap(); + drop(sender); // closing the channel lets us wait for the thread to drain it + std::thread::sleep(std::time::Duration::from_millis(10)); + + app.collect_new_events(); + assert_eq!(app.events.len(), 1); + assert_eq!(app.events[0].command, "git status"); + } + + #[test] + fn move_cursor_left_decrements() { + let mut app = make_app("hello"); + app.move_cursor_left(); + assert_eq!(app.character_index, 4); + } + + #[test] + fn move_cursor_left_clamps_at_zero() { + let mut app = make_app("hello"); + app.character_index = 0; + app.move_cursor_left(); + assert_eq!(app.character_index, 0); + } + + #[test] + fn move_cursor_right_increments() { + let mut app = make_app("hello"); + app.character_index = 0; + app.move_cursor_right(); + assert_eq!(app.character_index, 1); + } + + #[test] + fn move_cursor_right_clamps_at_end() { + let mut app = make_app("hello"); + app.move_cursor_right(); + assert_eq!(app.character_index, 5); + } + + #[test] + fn delete_char_basic() { + let mut app = make_app("hello"); + app.delete_char(); + assert_eq!(app.input, "hell"); + assert_eq!(app.character_index, 4); + } + + #[test] + fn delete_char_at_start_is_noop() { + let mut app = make_app("hello"); + app.character_index = 0; + app.delete_char(); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 0); + } + + #[test] + fn delete_char_multibyte() { + let mut app = make_app("héllo"); + app.character_index = 2; // cursor after 'é' + app.delete_char(); + assert_eq!(app.input, "hllo"); + assert_eq!(app.character_index, 1); + } + + #[test] + fn event_filter_from_str_roundtrip() { + let cases = [ + ("duplicates", EventFilter::Duplicates), + ("session_id", EventFilter::SessionId), + ("folder", EventFilter::Folder), + ("exit_code_success", EventFilter::ExitCodeSuccess), + ]; + for (input, expected) in cases { + assert_eq!(input.parse::().unwrap(), expected); + } + } + + #[test] + fn event_filter_from_str_unknown() { + assert!("unknown".parse::().is_err()); + } +}