diff --git a/crates/atuin-client/config.toml b/crates/atuin-client/config.toml index 6e67a4e1eb9..a81a365233e 100644 --- a/crates/atuin-client/config.toml +++ b/crates/atuin-client/config.toml @@ -308,6 +308,12 @@ records = true ## Default filter mode can be overridden with the filter_mode setting. # filters = [ "global", "host", "session", "session-preload", "workspace", "directory" ] +## Use smart-case matching for search queries. +## When true (default), all-lowercase queries match case-insensitively, while queries +## containing uppercase characters match case-sensitively. Similar to fzf's --smart-case. +## Set to false to always match case-insensitively, regardless of query casing. +# smart_case = true + [tmux] ## Enable using atuin with tmux popup (requires tmux >= 3.2) ## When enabled and running inside tmux, Atuin will use a popup window for interactive search. diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs index 7c63368d016..17ae4b2e2d7 100644 --- a/crates/atuin-client/src/database.rs +++ b/crates/atuin-client/src/database.rs @@ -132,6 +132,7 @@ pub trait Database: Send + Sync + 'static { context: &Context, query: &str, filter_options: OptFilters, + smart_case: bool, ) -> Result>; async fn query_history(&self, query: &str) -> Result>; @@ -456,6 +457,7 @@ impl Database for Sqlite { context: &Context, query: &str, filter_options: OptFilters, + smart_case: bool, ) -> Result> { let mut sql = SqlBuilder::select_from("history"); @@ -510,8 +512,9 @@ impl Database for Sqlite { _ => { let mut is_or = false; for token in QueryTokenizer::new(query) { - // TODO smart case mode could be made configurable like in fzf - let (is_glob, glob) = if token.has_uppercase() { + // Use GLOB (case-sensitive) when smart_case is enabled and the + // token contains uppercase chars, otherwise LIKE (case-insensitive). + let (is_glob, glob) = if smart_case && token.has_uppercase() { (true, "*") } else { (false, "%") @@ -959,6 +962,7 @@ mod test { OptFilters { ..Default::default() }, + true, // smart_case: use default behavior in tests ) .await?; @@ -1237,6 +1241,91 @@ mod test { .unwrap(); } + #[tokio::test(flavor = "multi_thread")] + async fn test_search_smart_case_disabled() { + let mut db = Sqlite::new("sqlite::memory:", test_local_timeout()) + .await + .unwrap(); + new_history_item(&mut db, "ls /home/ellie").await.unwrap(); + new_history_item(&mut db, "cd /home/Ellie").await.unwrap(); + new_history_item(&mut db, "/home/ellie/.bin/rustup") + .await + .unwrap(); + + let context = Context { + hostname: "test:host".to_string(), + session: "beepboopiamasession".to_string(), + cwd: "/home/ellie".to_string(), + host_id: "test-host".to_string(), + git_root: None, + }; + + // With smart_case=true (default), "Ellie" should only match the one with uppercase E + let results = db + .search( + SearchMode::Fuzzy, + FilterMode::Global, + &context, + "Ellie", + OptFilters::default(), + true, + ) + .await + .unwrap(); + assert_eq!( + results.len(), + 1, + "smart_case=true: uppercase query should match case-sensitively" + ); + + // With smart_case=false, "Ellie" should match all entries containing ellie/Ellie + let results = db + .search( + SearchMode::Fuzzy, + FilterMode::Global, + &context, + "Ellie", + OptFilters::default(), + false, + ) + .await + .unwrap(); + assert_eq!( + results.len(), + 3, + "smart_case=false: uppercase query should still match case-insensitively" + ); + + // Lowercase query should always match case-insensitively regardless of smart_case + let results_smart = db + .search( + SearchMode::Fuzzy, + FilterMode::Global, + &context, + "ellie", + OptFilters::default(), + true, + ) + .await + .unwrap(); + let results_no_smart = db + .search( + SearchMode::Fuzzy, + FilterMode::Global, + &context, + "ellie", + OptFilters::default(), + false, + ) + .await + .unwrap(); + assert_eq!( + results_smart.len(), + results_no_smart.len(), + "lowercase queries should behave identically regardless of smart_case" + ); + } + #[tokio::test(flavor = "multi_thread")] async fn test_paged_basic() { let mut db = Sqlite::new("sqlite::memory:", test_local_timeout()) @@ -1385,6 +1474,7 @@ mod test { OptFilters { ..Default::default() }, + true, ) .await .unwrap(); diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 5b18d9ead5d..7c83dfbe1ef 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -17,6 +17,11 @@ use serde_with::DeserializeFromStr; use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description}; pub const HISTORY_PAGE_SIZE: i64 = 100; + +/// Serde default helper that returns `true`. +fn default_true() -> bool { + true +} static EXAMPLE_CONFIG: &str = include_str!("../config.toml"); static DATA_DIR: OnceLock = OnceLock::new(); @@ -565,6 +570,14 @@ pub struct Search { /// The overall frecency score multiplier for the search index (default: 1.0). /// Applied after combining recency and frequency scores. pub frecency_score_multiplier: f64, + + /// Use smart-case matching for search queries (default: true). + /// When true, queries with all lowercase characters match case-insensitively, + /// while queries containing uppercase characters match case-sensitively. + /// When false, all queries match case-insensitively regardless of casing. + /// Similar to fzf's --smart-case / --no-smart-case options. + #[serde(default = "default_true")] + pub smart_case: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -820,6 +833,7 @@ impl Default for Search { recency_score_multiplier: 1.0, frequency_score_multiplier: 1.0, frecency_score_multiplier: 1.0, + smart_case: true, } } } diff --git a/crates/atuin-daemon/src/components/search.rs b/crates/atuin-daemon/src/components/search.rs index 9fc87faecf7..de9b32bcb1c 100644 --- a/crates/atuin-daemon/src/components/search.rs +++ b/crates/atuin-daemon/src/components/search.rs @@ -39,6 +39,8 @@ pub struct SearchComponent { handle: tokio::sync::RwLock>, loader_handle: Option>, frecency_handle: Option>, + /// Shared smart_case setting — updated on SettingsReloaded, read by the gRPC service. + smart_case: Arc>, } impl SearchComponent { @@ -49,6 +51,8 @@ impl SearchComponent { handle: tokio::sync::RwLock::new(None), loader_handle: None, frecency_handle: None, + // Default true (smart-case on) — overwritten when settings load. + smart_case: Arc::new(tokio::sync::RwLock::new(true)), } } @@ -56,6 +60,7 @@ impl SearchComponent { pub fn grpc_service(&self) -> SearchServer { SearchServer::new(SearchGrpcService { index: self.index.clone(), + smart_case: self.smart_case.clone(), }) } @@ -117,10 +122,16 @@ impl Component for SearchComponent { async fn start(&mut self, handle: DaemonHandle) -> Result<()> { *self.handle.write().await = Some(handle.clone()); + // Apply user's smart_case setting immediately so searches before + // the history loader finishes use the correct case mode. + let settings = handle.settings().await; + *self.smart_case.write().await = settings.search.smart_case; + // Spawn background task to load history into index let index = self.index.clone(); let db = handle.history_db().clone(); let handle_for_loader = handle.clone(); + let smart_case_for_loader = self.smart_case.clone(); self.loader_handle = Some(tokio::spawn(async move { info!( @@ -144,6 +155,7 @@ impl Component for SearchComponent { ); // Build initial frecency map with current settings let settings = handle_for_loader.settings().await; + *smart_case_for_loader.write().await = settings.search.smart_case; index.read().await.rebuild_frecency(&settings.search).await; info!("Initial frecency map built"); break; @@ -244,6 +256,7 @@ impl Component for SearchComponent { let handle_guard = self.handle.read().await; if let Some(handle) = handle_guard.as_ref() { let settings = handle.settings().await; + *self.smart_case.write().await = settings.search.smart_case; self.index .read() .await @@ -275,6 +288,7 @@ impl Component for SearchComponent { /// The gRPC service implementation. pub struct SearchGrpcService { index: Arc>, + smart_case: Arc>, } #[tonic::async_trait] @@ -288,6 +302,7 @@ impl SearchSvc for SearchGrpcService { ) -> Result, Status> { let mut in_stream = request.into_inner(); let index = self.index.clone(); + let smart_case = self.smart_case.clone(); // Create output channel let (tx, rx) = tokio::sync::mpsc::channel::>(128); @@ -332,7 +347,13 @@ impl SearchSvc for SearchGrpcService { .in_scope(|| async { let index = index.read().await; index - .search(&query, index_filter, &query_context, RESULTS_LIMIT) + .search( + &query, + index_filter, + &query_context, + RESULTS_LIMIT, + *smart_case.read().await, + ) .await }) .await; diff --git a/crates/atuin-daemon/src/search/index.rs b/crates/atuin-daemon/src/search/index.rs index 3328c5b555b..3eb1898c41b 100644 --- a/crates/atuin-daemon/src/search/index.rs +++ b/crates/atuin-daemon/src/search/index.rs @@ -337,6 +337,7 @@ impl SearchIndex { filter_mode: IndexFilterMode, _context: &QueryContext, limit: u32, + smart_case: bool, ) -> Vec { let mut nucleo = self.nucleo.write().await; @@ -352,10 +353,15 @@ impl SearchIndex { nucleo.set_scorer(scorer); // Update pattern + let case_matching = if smart_case { + pattern::CaseMatching::Smart + } else { + pattern::CaseMatching::Ignore + }; nucleo.pattern.reparse( 0, query, - pattern::CaseMatching::Smart, + case_matching, pattern::Normalization::Smart, false, ); @@ -661,7 +667,13 @@ mod tests { // Search for "git" - should match 2 commands let results = index - .search("git", IndexFilterMode::Global, &QueryContext::default(), 10) + .search( + "git", + IndexFilterMode::Global, + &QueryContext::default(), + 10, + true, + ) .await; assert_eq!(results.len(), 2); @@ -672,8 +684,82 @@ mod tests { IndexFilterMode::Directory(with_trailing_slash("/home/user/project")), &QueryContext::default(), 10, + true, ) .await; assert_eq!(results.len(), 2); // git status and git commit } + + #[tokio::test] + async fn search_index_smart_case() { + let index = SearchIndex::new(); + + let h1 = make_history( + "Git status", + "/home/user/project", + datetime!(2024-01-01 10:00 UTC), + ); + let h2 = make_history( + "git commit -m 'test'", + "/home/user/project", + datetime!(2024-01-01 10:05 UTC), + ); + let h3 = make_history( + "GIT PUSH", + "/home/user/project", + datetime!(2024-01-01 10:10 UTC), + ); + + index.add_history(&h1); + index.add_history(&h2); + index.add_history(&h3); + + // smart_case=true: uppercase query "Git" should be case-sensitive, + // matching only "Git status" (not "git commit" or "GIT PUSH") + let results = index + .search( + "Git", + IndexFilterMode::Global, + &QueryContext::default(), + 10, + true, + ) + .await; + assert_eq!(results.len(), 1); + + // smart_case=false: same uppercase query "Git" should match + // case-insensitively, finding all three commands + let results = index + .search( + "Git", + IndexFilterMode::Global, + &QueryContext::default(), + 10, + false, + ) + .await; + assert_eq!(results.len(), 3); + + // lowercase query matches all regardless of smart_case setting + let results_smart = index + .search( + "git", + IndexFilterMode::Global, + &QueryContext::default(), + 10, + true, + ) + .await; + let results_ignore = index + .search( + "git", + IndexFilterMode::Global, + &QueryContext::default(), + 10, + false, + ) + .await; + assert_eq!(results_smart.len(), 3); + assert_eq!(results_ignore.len(), 3); + } } diff --git a/crates/atuin/src/command/client/search.rs b/crates/atuin/src/command/client/search.rs index 7c72e13dacb..8f6e534b2ff 100644 --- a/crates/atuin/src/command/client/search.rs +++ b/crates/atuin/src/command/client/search.rs @@ -332,6 +332,7 @@ async fn run_non_interactive( &context, query.join(" ").as_str(), opt_filter, + settings.search.smart_case, ) .await?; diff --git a/crates/atuin/src/command/client/search/engines.rs b/crates/atuin/src/command/client/search/engines.rs index 8cbee0c3d07..d98cb339fc2 100644 --- a/crates/atuin/src/command/client/search/engines.rs +++ b/crates/atuin/src/command/client/search/engines.rs @@ -22,9 +22,9 @@ pub fn engine(search_mode: SearchMode, settings: &Settings) -> Box { // Fall back to fuzzy mode if daemon feature is not enabled - Box::new(db::Search(SearchMode::Fuzzy)) as Box<_> + Box::new(db::Search(SearchMode::Fuzzy, settings.search.smart_case)) as Box<_> } - mode => Box::new(db::Search(mode)) as Box<_>, + mode => Box::new(db::Search(mode, settings.search.smart_case)) as Box<_>, } } diff --git a/crates/atuin/src/command/client/search/engines/daemon.rs b/crates/atuin/src/command/client/search/engines/daemon.rs index c5de39abbb0..2529e5c3ab7 100644 --- a/crates/atuin/src/command/client/search/engines/daemon.rs +++ b/crates/atuin/src/command/client/search/engines/daemon.rs @@ -18,6 +18,7 @@ use super::{SearchEngine, SearchState}; pub struct Search { client: Option, query_id: u64, + smart_case: bool, #[cfg(unix)] socket_path: String, #[cfg(not(unix))] @@ -29,6 +30,7 @@ impl Search { Search { client: None, query_id: 0, + smart_case: settings.search.smart_case, #[cfg(unix)] socket_path: settings.daemon.socket_path.clone(), #[cfg(not(unix))] @@ -77,6 +79,7 @@ impl Search { limit: Some(200), ..Default::default() }, + self.smart_case, ) .await .map_or(Vec::new(), |r| r.into_iter().collect()); @@ -190,11 +193,20 @@ impl SearchEngine for Search { fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec { // Use fulltext highlighting for regex queries if Self::contains_regex_pattern(search_input) { - return super::db::get_highlight_indices_fulltext(command, search_input); + return super::db::get_highlight_indices_fulltext( + command, + search_input, + self.smart_case, + ); } let mut matcher = Matcher::new(Config::DEFAULT); - let pattern = Pattern::parse(search_input, CaseMatching::Smart, Normalization::Smart); + let case_matching = if self.smart_case { + CaseMatching::Smart + } else { + CaseMatching::Ignore + }; + let pattern = Pattern::parse(search_input, case_matching, Normalization::Smart); let mut indices: Vec = Vec::new(); let mut haystack_buf = Vec::new(); diff --git a/crates/atuin/src/command/client/search/engines/db.rs b/crates/atuin/src/command/client/search/engines/db.rs index 476462f5484..50e08211072 100644 --- a/crates/atuin/src/command/client/search/engines/db.rs +++ b/crates/atuin/src/command/client/search/engines/db.rs @@ -8,12 +8,13 @@ use atuin_client::{ settings::SearchMode, }; use eyre::Result; +use norm::CaseSensitivity; use norm::Metric; use norm::fzf::{FzfParser, FzfV2}; use std::ops::Range; use tracing::{Level, instrument}; -pub struct Search(pub SearchMode); +pub struct Search(pub SearchMode, pub bool); #[async_trait] impl SearchEngine for Search { @@ -33,6 +34,7 @@ impl SearchEngine for Search { limit: Some(200), ..Default::default() }, + self.1, ) .await // ignore errors as it may be caused by incomplete regex @@ -45,9 +47,12 @@ impl SearchEngine for Search { if self.0 == SearchMode::Prefix { return vec![]; } else if self.0 == SearchMode::FullText { - return get_highlight_indices_fulltext(command, search_input); + return get_highlight_indices_fulltext(command, search_input, self.1); } let mut fzf = FzfV2::new(); + if !self.1 { + fzf.set_case_sensitivity(CaseSensitivity::Insensitive); + } let mut parser = FzfParser::new(); let query = parser.parse(search_input); let mut ranges: Vec> = Vec::new(); @@ -59,12 +64,19 @@ impl SearchEngine for Search { } #[instrument(skip_all, level = Level::TRACE, name = "db_highlight_fulltext")] -pub fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec { +pub fn get_highlight_indices_fulltext( + command: &str, + search_input: &str, + smart_case: bool, +) -> Vec { let mut ranges = vec![]; let lower_command = command.to_ascii_lowercase(); for token in QueryTokenizer::new(search_input) { - let matchee = if token.has_uppercase() { + // Match case-sensitively only when smart_case is enabled and the token + // contains uppercase characters, mirroring the SQL query logic. + let case_sensitive = smart_case && token.has_uppercase(); + let matchee = if case_sensitive { command } else { &lower_command @@ -84,18 +96,33 @@ pub fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec< } } QueryToken::MatchStart(term, _) => { - if matchee.starts_with(term) { + let term = if case_sensitive { + term.to_string() + } else { + term.to_ascii_lowercase() + }; + if matchee.starts_with(&term) { ranges.push(0..term.len()); } } QueryToken::MatchEnd(term, _) => { - if matchee.ends_with(term) { + let term = if case_sensitive { + term.to_string() + } else { + term.to_ascii_lowercase() + }; + if matchee.ends_with(&term) { let l = matchee.len(); ranges.push((l - term.len())..l); } } QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => { - for (idx, m) in matchee.match_indices(term) { + let term = if case_sensitive { + term.to_string() + } else { + term.to_ascii_lowercase() + }; + for (idx, m) in matchee.match_indices(&*term) { ranges.push(idx..(idx + m.len())); } }