diff --git a/bread-launcher/src/gtk.rs b/bread-launcher/src/gtk.rs index e270511..d365353 100644 --- a/bread-launcher/src/gtk.rs +++ b/bread-launcher/src/gtk.rs @@ -17,7 +17,7 @@ use gtk4::{ use crate::desktop::DesktopEntry; use crate::history::LaunchHistory; -use crate::matching::{fuzzy_matches, fuzzy_score}; +use crate::matching::{fuzzy_matches, fuzzy_score, split_sections}; fn make_icon(icon_name: &str, icon_path: Option<&Path>, icon_px: i32) -> Image { // Try loading from resolved cached path via gio::File @@ -71,6 +71,24 @@ fn build_row(entry: &DesktopEntry, idx: u32, icon_px: i32) -> ListBoxRow { row } +/// A non-selectable, non-activatable "Recent"/"Apps" label row (plan phase +/// 6c, `[launcher].sections`) — deliberately carries no `"entry"` row data, +/// which is exactly what [`row_entry`] (and everything downstream of it: +/// `set_query`'s filter, `select_next`/`select_prev`'s traversal) already +/// uses to tell a header apart from a real app row. +fn build_header_row(label: &str, idx: u32) -> ListBoxRow { + let row = ListBoxRow::new(); + row.set_selectable(false); + row.set_activatable(false); + row.add_css_class("bread-drawer-section-header"); + let lbl = Label::new(Some(label)); + lbl.add_css_class("section-header-label"); + lbl.set_xalign(0.0); + row.set_child(Some(&lbl)); + unsafe { row.set_data("initial_order", idx) }; + row +} + /// Reads the [`DesktopEntry`] a row was built from — e.g. from a /// `ListBox::connect_row_activated` handler, which hands back a row /// reference rather than going through [`ResultsList::selected_entry`]. @@ -99,12 +117,48 @@ impl ResultsList { /// Builds one row per entry (in `entries`' given order — that order is /// also the fallback sort when the query is empty) and wires up sorting /// against `history`'s launch counts. - pub fn new(entries: &[DesktopEntry], icon_px: i32, history: Rc>) -> Self { + /// + /// `sections` (`[launcher].sections`, plan phase 6c): when true, the + /// idle (empty-query) view groups `entries` into "Recent"/"Apps" + /// [`build_header_row`]s via [`split_sections`] instead of one flat + /// list. Sections disappear the moment a query is typed — `set_query` + /// falls back to the same flat fuzzy-ranked list either way — so this + /// only changes the initial build order and the header rows' presence, + /// never the (unchanged) search behaviour. `false` reproduces the + /// exact pre-phase-6c flat list breadbox's own overlay still uses. + pub fn new( + entries: &[DesktopEntry], + icon_px: i32, + history: Rc>, + sections: bool, + ) -> Self { let list = ListBox::new(); list.set_selection_mode(SelectionMode::Browse); - for (idx, entry) in entries.iter().enumerate() { - list.append(&build_row(entry, idx as u32, icon_px)); + let mut idx = 0u32; + if sections { + let (recent, apps) = split_sections(entries.to_vec(), &history.borrow()); + if !recent.is_empty() { + list.append(&build_header_row("Recent", idx)); + idx += 1; + for entry in &recent { + list.append(&build_row(entry, idx, icon_px)); + idx += 1; + } + } + if !apps.is_empty() { + list.append(&build_header_row("Apps", idx)); + idx += 1; + for entry in &apps { + list.append(&build_row(entry, idx, icon_px)); + idx += 1; + } + } + } else { + for entry in entries { + list.append(&build_row(entry, idx, icon_px)); + idx += 1; + } } let query: Rc> = Rc::new(RefCell::new(String::new())); @@ -122,22 +176,34 @@ impl ResultsList { }; return oa.cmp(&ob).into(); } - let (Some(ea), Some(eb)) = (row_entry(row_a), row_entry(row_b)) else { - return std::cmp::Ordering::Equal.into(); - }; - let sa = fuzzy_score(&query, &ea); - let sb = fuzzy_score(&query, &eb); - let history = history.borrow(); - let ca = history.count(&ea.name); - let cb = history.count(&eb.name); - sa.cmp(&sb) - .then(cb.cmp(&ca)) - .then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase())) - .into() + // A header row carries no "entry" data — sort it after any + // real row rather than treating the comparison as `Equal`, + // though `set_query` also hides every header outright once + // a query is non-empty, so this only matters for the + // underlying (invisible) list order, never what's shown. + match (row_entry(row_a), row_entry(row_b)) { + (Some(ea), Some(eb)) => { + let sa = fuzzy_score(&query, &ea); + let sb = fuzzy_score(&query, &eb); + let history = history.borrow(); + let ca = history.count(&ea.name); + let cb = history.count(&eb.name); + sa.cmp(&sb) + .then(cb.cmp(&ca)) + .then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase())) + .into() + } + (None, Some(_)) => std::cmp::Ordering::Greater.into(), + (Some(_), None) => std::cmp::Ordering::Less.into(), + (None, None) => std::cmp::Ordering::Equal.into(), + } }); } - if let Some(first) = list.row_at_index(0) { + let first_real = (0i32..) + .map_while(|i| list.row_at_index(i)) + .find(|r| row_entry(r).is_some()); + if let Some(first) = first_real { list.select_row(Some(&first)); } @@ -151,23 +217,29 @@ impl ResultsList { } /// Re-filters (fuzzy match against name, `wm_class`, and `exec`) and - /// re-sorts by `query`, then selects the first visible row. + /// re-sorts by `query`, then selects the first visible row. A header + /// row (see [`build_header_row`]) only ever shows in the idle + /// (empty-query) browse view — it has no name/`wm_class`/`exec` of its + /// own to filter against. pub fn set_query(&self, query: &str) { *self.query.borrow_mut() = query.to_string(); let mut i = 0i32; while let Some(row) = self.list.row_at_index(i) { - let vis = row_entry(&row) - .map(|e| { + let vis = match row_entry(&row) { + Some(e) => { fuzzy_matches(query, &e.name) || e.wm_class.as_deref().is_some_and(|w| fuzzy_matches(query, w)) || fuzzy_matches(query, &e.exec) - }) - .unwrap_or(false); + } + None => query.is_empty(), + }; row.set_visible(vis); i += 1; } self.list.invalidate_sort(); - let first_vis = (0i32..).find_map(|j| self.list.row_at_index(j).filter(|r| r.is_visible())); + let first_vis = (0i32..) + .map_while(|j| self.list.row_at_index(j)) + .find(|r| r.is_visible() && row_entry(r).is_some()); self.list.select_row(first_vis.as_ref()); } @@ -175,13 +247,16 @@ impl ResultsList { self.list.selected_row().and_then(|r| row_entry(&r)) } - /// Moves the selection to the next visible row, if any. + /// Moves the selection to the next visible row, if any. Skips header + /// rows even though they may be visible (the idle browse view) — + /// `set_selectable(false)` alone doesn't stop a programmatic + /// `select_row` call from landing on one. pub fn select_next(&self) { let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(-1); let mut i = cur + 1; loop { match self.list.row_at_index(i) { - Some(r) if r.is_visible() => { + Some(r) if r.is_visible() && row_entry(&r).is_some() => { self.list.select_row(Some(&r)); break; } @@ -191,7 +266,8 @@ impl ResultsList { } } - /// Moves the selection to the previous visible row, if any. + /// Moves the selection to the previous visible row, if any. See + /// [`select_next`](Self::select_next) on skipping header rows. pub fn select_prev(&self) { let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(0); let mut i = cur - 1; @@ -200,7 +276,7 @@ impl ResultsList { break; } match self.list.row_at_index(i) { - Some(r) if r.is_visible() => { + Some(r) if r.is_visible() && row_entry(&r).is_some() => { self.list.select_row(Some(&r)); break; } diff --git a/bread-launcher/src/history.rs b/bread-launcher/src/history.rs index 816fd66..2d476cf 100644 --- a/bread-launcher/src/history.rs +++ b/bread-launcher/src/history.rs @@ -30,4 +30,16 @@ impl LaunchHistory { let _ = fs::write(&self.path, json); } } + + /// In-memory history with no backing file — [`save`](Self::save) is a + /// silent no-op (an empty `path`). Lets a test (or a future in-memory + /// host) control counts directly instead of writing through + /// `~/.cache//history.json`. + #[cfg(test)] + pub(crate) fn from_counts(counts: HashMap) -> Self { + LaunchHistory { + counts, + path: PathBuf::new(), + } + } } diff --git a/bread-launcher/src/lib.rs b/bread-launcher/src/lib.rs index 9f7fe31..1c91f49 100644 --- a/bread-launcher/src/lib.rs +++ b/bread-launcher/src/lib.rs @@ -20,6 +20,7 @@ mod icon; mod launch; mod matching; mod paths; +mod query; #[cfg(feature = "gtk")] pub mod gtk; @@ -28,8 +29,11 @@ pub use desktop::{load_all_desktop_entries, parse_desktop, strip_exec_codes, Des pub use history::LaunchHistory; pub use icon::IconCache; pub use launch::{do_launch, emit_launched}; -pub use matching::{fuzzy_matches, fuzzy_score, load_sorted_entries, matches_term, priority_rank}; +pub use matching::{ + fuzzy_matches, fuzzy_score, load_sorted_entries, matches_term, priority_rank, split_sections, +}; pub use paths::{app_dirs, cache_dir, config_dir, home_dir}; +pub use query::{builtin_commands, eval_calc, filter_commands, parse_query, Command, ParsedQuery, QueryKind}; /// The launcher's one shared identity, passed to [`cache_dir`]/[`config_dir`]/ /// [`IconCache::new`]/[`LaunchHistory::load`] by every host that embeds this diff --git a/bread-launcher/src/matching.rs b/bread-launcher/src/matching.rs index 7fde970..c7f95d2 100644 --- a/bread-launcher/src/matching.rs +++ b/bread-launcher/src/matching.rs @@ -132,6 +132,39 @@ pub fn load_sorted_entries( entries } +/// The demo's own cap (`BOS.pushRecent`'s `.slice(0, 4)`) on how many +/// entries the "Recent" section shows. +pub const MAX_RECENT: usize = 4; + +/// Splits `entries` (already ordered by [`load_sorted_entries`]) into a +/// "recent" section — the entries `history` has any launch count for, most- +/// launched first, capped at [`MAX_RECENT`] — and an "apps" section: every +/// other entry, in its existing relative order. No new tracking beyond +/// `LaunchHistory`'s existing counts (THEME_SYSTEM_PLAN.md phase 6c task +/// notes: "`LaunchHistory` already tracks counts for a recents list"). +/// +/// Only meaningful when `entries` has no priority-ranked prefix (breadbar's +/// capsule calls [`load_sorted_entries`] with an empty `priority` list) — +/// with a non-empty priority list, priority-ranked entries still sort first +/// and would be treated as "apps" here even if launched often, since this +/// function has no way to tell "sorted first because launched a lot" from +/// "sorted first because priority-ranked" apart from the count itself. +pub fn split_sections( + entries: Vec, + history: &LaunchHistory, +) -> (Vec, Vec) { + let mut recent = Vec::new(); + let mut apps = Vec::new(); + for entry in entries { + if recent.len() < MAX_RECENT && history.count(&entry.name) > 0 { + recent.push(entry); + } else { + apps.push(entry); + } + } + (recent, apps) +} + #[cfg(test)] mod tests { use super::*; @@ -269,4 +302,63 @@ mod tests { let priority = vec!["code".to_string()]; assert_eq!(priority_rank(&e, &priority), None); } + + // ---- split_sections -------------------------------------------------- + + #[test] + fn split_sections_no_history_is_all_apps() { + let entries = vec![entry("Firefox", None), entry("GoLand", None)]; + let history = LaunchHistory::from_counts(HashMap::new()); + let (recent, apps) = split_sections(entries, &history); + assert!(recent.is_empty()); + assert_eq!(apps.len(), 2); + } + + #[test] + fn split_sections_launched_entries_go_to_recent() { + let entries = vec![ + entry("Firefox", None), + entry("GoLand", None), + entry("Steam", None), + ]; + let mut counts = HashMap::new(); + counts.insert("Firefox".to_string(), 5); + let history = LaunchHistory::from_counts(counts); + let (recent, apps) = split_sections(entries, &history); + assert_eq!(recent.iter().map(|e| &e.name).collect::>(), vec!["Firefox"]); + assert_eq!( + apps.iter().map(|e| &e.name).collect::>(), + vec!["GoLand", "Steam"] + ); + } + + #[test] + fn split_sections_caps_recent_at_max() { + let entries: Vec = (0..(MAX_RECENT + 2)) + .map(|i| entry(&format!("App{i}"), None)) + .collect(); + let counts = entries + .iter() + .map(|e| (e.name.clone(), 1)) + .collect::>(); + let history = LaunchHistory::from_counts(counts); + let (recent, apps) = split_sections(entries, &history); + assert_eq!(recent.len(), MAX_RECENT); + assert_eq!(apps.len(), 2); + } + + #[test] + fn split_sections_preserves_relative_order_within_each_group() { + let mut counts = HashMap::new(); + counts.insert("A".to_string(), 1); + counts.insert("C".to_string(), 3); + let history = LaunchHistory::from_counts(counts); + // load_sorted_entries would already have ordered these by count + // desc before calling split_sections; split_sections itself just + // partitions in whatever order it's handed, so feed it pre-sorted. + let pre_sorted = vec![entry("C", None), entry("A", None), entry("B", None)]; + let (recent, apps) = split_sections(pre_sorted, &history); + assert_eq!(recent.iter().map(|e| &e.name).collect::>(), vec!["C", "A"]); + assert_eq!(apps.iter().map(|e| &e.name).collect::>(), vec!["B"]); + } } diff --git a/bread-launcher/src/query.rs b/bread-launcher/src/query.rs new file mode 100644 index 0000000..e6a696b --- /dev/null +++ b/bread-launcher/src/query.rs @@ -0,0 +1,391 @@ +//! Query modes (`04-spotlight.html`'s `BOS.parseQuery`/`BOS.evalCalc`/ +//! `BOS.COMMANDS`, THEME_SYSTEM_PLAN.md phase 6c): a leading `=`/`>`/`.` +//! switches the launcher from filtering apps to evaluating an arithmetic +//! expression, listing bread commands, or treating the rest of the query as +//! a URL to open. Pure/headless — no GTK here — so both breadbar's embedded +//! capsule and (per the module doc comment on `crate`) breadbox's own +//! overlay window can adopt the same parsing/eval/filter logic. A host is +//! expected to gate which prefixes it actually acts on against its theme's +//! `[launcher].modes` list — this module recognizes all four kinds +//! unconditionally and leaves that gating to the caller. + +use crate::matching::fuzzy_matches; + +/// Which of the four query modes a raw entry-text string names, per its +/// leading character (mirrors `BOS.parseQuery`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueryKind { + /// No recognized prefix — the ordinary app-filter query. + Apps, + /// Leading `=` — the rest is an arithmetic expression for [`eval_calc`]. + Calc, + /// Leading `>` — the rest filters [`builtin_commands`]. + Cmd, + /// Leading `.` — the rest is a URL to open. + Url, +} + +/// A parsed query: which mode it names, and the text after the prefix +/// character (empty string for a bare `=`/`>`/`.` with nothing typed yet). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedQuery { + pub kind: QueryKind, + pub value: String, +} + +/// Splits `raw` on its leading mode character, if any. Mirrors +/// `BOS.parseQuery` exactly: only the FIRST character is checked, and it is +/// stripped along with any immediately-following whitespace. +pub fn parse_query(raw: &str) -> ParsedQuery { + let (kind, rest) = if let Some(rest) = raw.strip_prefix('=') { + (QueryKind::Calc, rest) + } else if let Some(rest) = raw.strip_prefix('>') { + (QueryKind::Cmd, rest) + } else if let Some(rest) = raw.strip_prefix('.') { + (QueryKind::Url, rest) + } else { + (QueryKind::Apps, raw) + }; + ParsedQuery { + kind, + value: rest.trim().to_string(), + } +} + +// ---- Calc --------------------------------------------------------------- + +/// Evaluates `expr` as a small four-function arithmetic expression +/// (`+ - * / ( )`, decimal literals, unary minus) and formats the result +/// the same way `BOS.evalCalc` does: rounded to 8 decimal places, an +/// integer result prints with no trailing `.0`, a non-finite result prints +/// "∞", an expression containing anything outside `[0-9.\s+\-*/()]` +/// returns "bad expr", and anything else that fails to parse or evaluate +/// (unbalanced parens, division producing NaN, trailing garbage) returns +/// "err". `None` only for an empty/whitespace-only expression — the +/// caller's "nothing typed after `=` yet" case, which has no result to +/// show at all (the demo's own `if (!expr) return null;`). +pub fn eval_calc(expr: &str) -> Option { + let expr = expr.trim(); + if expr.is_empty() { + return None; + } + if !expr.chars().all(|c| c.is_ascii_digit() || " .+-*/()".contains(c)) { + return Some("bad expr".to_string()); + } + let mut p = CalcParser { + bytes: expr.as_bytes(), + pos: 0, + }; + let result = p.parse_expr().filter(|_| p.skip_ws() == p.bytes.len()); + Some(match result { + Some(n) if !n.is_finite() => "∞".to_string(), + Some(n) => format_calc_result(n), + None => "err".to_string(), + }) +} + +/// Rounds to 8 decimal places and formats without a trailing `.0` for whole +/// numbers — `String(Math.round(n * 1e8) / 1e8)` in JS, `{}` on `f64` +/// already behaves the same way in Rust (`format!("{}", 4.0_f64)` == "4"). +fn format_calc_result(n: f64) -> String { + let rounded = (n * 1e8).round() / 1e8; + // Avoid printing "-0" for a result that rounds to negative zero. + let rounded = if rounded == 0.0 { 0.0 } else { rounded }; + format!("{rounded}") +} + +/// Minimal recursive-descent parser: `expr := term (('+'|'-') term)*`, +/// `term := factor (('*'|'/') factor)*`, `factor := '-' factor | number | +/// '(' expr ')'`. Byte-indexed since the character set is already +/// restricted to ASCII by [`eval_calc`]'s pre-check. +struct CalcParser<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> CalcParser<'a> { + fn skip_ws(&mut self) -> usize { + while self.pos < self.bytes.len() && self.bytes[self.pos] == b' ' { + self.pos += 1; + } + self.pos + } + + fn peek(&mut self) -> Option { + self.skip_ws(); + self.bytes.get(self.pos).copied() + } + + fn parse_expr(&mut self) -> Option { + let mut val = self.parse_term()?; + loop { + match self.peek() { + Some(b'+') => { + self.pos += 1; + val += self.parse_term()?; + } + Some(b'-') => { + self.pos += 1; + val -= self.parse_term()?; + } + _ => break, + } + } + Some(val) + } + + fn parse_term(&mut self) -> Option { + let mut val = self.parse_factor()?; + loop { + match self.peek() { + Some(b'*') => { + self.pos += 1; + val *= self.parse_factor()?; + } + Some(b'/') => { + self.pos += 1; + val /= self.parse_factor()?; + } + _ => break, + } + } + Some(val) + } + + fn parse_factor(&mut self) -> Option { + match self.peek()? { + b'-' => { + self.pos += 1; + Some(-self.parse_factor()?) + } + b'+' => { + self.pos += 1; + self.parse_factor() + } + b'(' => { + self.pos += 1; + let val = self.parse_expr()?; + if self.peek() == Some(b')') { + self.pos += 1; + Some(val) + } else { + None + } + } + c if c.is_ascii_digit() || c == b'.' => self.parse_number(), + _ => None, + } + } + + fn parse_number(&mut self) -> Option { + self.skip_ws(); + let start = self.pos; + let mut seen_dot = false; + let mut seen_digit = false; + while let Some(&c) = self.bytes.get(self.pos) { + if c.is_ascii_digit() { + seen_digit = true; + self.pos += 1; + } else if c == b'.' && !seen_dot { + seen_dot = true; + self.pos += 1; + } else { + break; + } + } + if !seen_digit { + return None; + } + std::str::from_utf8(&self.bytes[start..self.pos]) + .ok() + .and_then(|s| s.parse::().ok()) + } +} + +// ---- Commands ------------------------------------------------------------- + +/// One `>`-mode command palette entry: a display `name` and the shell +/// command it runs (via `bash -c`, same spawn convention [`crate::do_launch`] +/// already uses for a desktop entry's `Exec=` line). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Command { + pub id: &'static str, + pub name: &'static str, + pub exec: &'static str, +} + +/// A small, real bread-ecosystem command palette — deliberately not the +/// demo's placeholder set (`Lock session`/`Open settings`/`Test +/// notification` map to nothing real in this codebase). `loginctl +/// lock-session` and `bread reload` are both already-documented, safe, +/// no-argument commands (see the bread CLI reference / systemd-logind). +pub fn builtin_commands() -> &'static [Command] { + &[ + Command { + id: "lock", + name: "Lock session", + exec: "loginctl lock-session", + }, + Command { + id: "reload-breadd", + name: "Reload breadd", + exec: "bread reload", + }, + ] +} + +/// Fuzzy-filters `commands` by `query` against each command's `name` (same +/// subsequence match [`crate::fuzzy_matches`] uses for app rows) — an empty +/// query matches everything, same as the app list's own empty-query case. +pub fn filter_commands(query: &str, commands: &[Command]) -> Vec { + commands + .iter() + .filter(|c| fuzzy_matches(query, c.name)) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- parse_query ------------------------------------------------- + + #[test] + fn parse_query_bare_text_is_apps() { + let p = parse_query("firefox"); + assert_eq!(p.kind, QueryKind::Apps); + assert_eq!(p.value, "firefox"); + } + + #[test] + fn parse_query_empty_is_apps() { + let p = parse_query(""); + assert_eq!(p.kind, QueryKind::Apps); + assert_eq!(p.value, ""); + } + + #[test] + fn parse_query_equals_is_calc() { + let p = parse_query("=2+2"); + assert_eq!(p.kind, QueryKind::Calc); + assert_eq!(p.value, "2+2"); + } + + #[test] + fn parse_query_gt_is_cmd() { + let p = parse_query(">lock"); + assert_eq!(p.kind, QueryKind::Cmd); + assert_eq!(p.value, "lock"); + } + + #[test] + fn parse_query_dot_is_url() { + let p = parse_query(".breadway.dev"); + assert_eq!(p.kind, QueryKind::Url); + assert_eq!(p.value, "breadway.dev"); + } + + #[test] + fn parse_query_strips_leading_whitespace_after_prefix() { + let p = parse_query("= 2 + 2 "); + assert_eq!(p.kind, QueryKind::Calc); + assert_eq!(p.value, "2 + 2"); + } + + #[test] + fn parse_query_bare_prefix_has_empty_value() { + assert_eq!(parse_query("=").value, ""); + assert_eq!(parse_query(">").value, ""); + assert_eq!(parse_query(".").value, ""); + } + + // ---- eval_calc ----------------------------------------------------- + + #[test] + fn eval_calc_empty_expr_is_none() { + assert_eq!(eval_calc(""), None); + assert_eq!(eval_calc(" "), None); + } + + #[test] + fn eval_calc_simple_addition() { + assert_eq!(eval_calc("2+2"), Some("4".to_string())); + } + + #[test] + fn eval_calc_precedence() { + assert_eq!(eval_calc("2+3*4"), Some("14".to_string())); + } + + #[test] + fn eval_calc_parens() { + assert_eq!(eval_calc("(2+3)*4"), Some("20".to_string())); + } + + #[test] + fn eval_calc_unary_minus() { + assert_eq!(eval_calc("-5+2"), Some("-3".to_string())); + } + + #[test] + fn eval_calc_decimals_round_to_8_places() { + assert_eq!(eval_calc("0.1+0.2"), Some("0.3".to_string())); + } + + #[test] + fn eval_calc_division_by_zero_is_infinity_symbol() { + assert_eq!(eval_calc("1/0"), Some("∞".to_string())); + } + + #[test] + fn eval_calc_bad_chars_is_bad_expr() { + assert_eq!(eval_calc("2+alert(1)"), Some("bad expr".to_string())); + assert_eq!(eval_calc("rm -rf /"), Some("bad expr".to_string())); + } + + #[test] + fn eval_calc_unbalanced_parens_is_err() { + assert_eq!(eval_calc("(2+3"), Some("err".to_string())); + } + + #[test] + fn eval_calc_trailing_garbage_is_err() { + assert_eq!(eval_calc("2+3)"), Some("err".to_string())); + } + + #[test] + fn eval_calc_double_operator_is_err() { + assert_eq!(eval_calc("2++"), Some("err".to_string())); + } + + #[test] + fn eval_calc_whitespace_is_tolerated() { + assert_eq!(eval_calc(" 2 + 2 "), Some("4".to_string())); + } + + // ---- commands -------------------------------------------------------- + + #[test] + fn builtin_commands_are_non_empty() { + assert!(!builtin_commands().is_empty()); + } + + #[test] + fn filter_commands_empty_query_matches_all() { + let all = builtin_commands(); + assert_eq!(filter_commands("", all).len(), all.len()); + } + + #[test] + fn filter_commands_filters_by_name_subsequence() { + let matches = filter_commands("lock", builtin_commands()); + assert!(matches.iter().any(|c| c.id == "lock")); + assert!(!matches.iter().any(|c| c.id == "reload-breadd")); + } + + #[test] + fn filter_commands_no_match_is_empty() { + assert!(filter_commands("zzzznotacommand", builtin_commands()).is_empty()); + } +}