1pub mod actions;
15pub mod app;
16pub mod clipboard;
17pub mod events;
18pub mod format;
19pub mod help;
20pub mod theme;
21pub mod ui;
22
23use anyhow::Result;
24use crossterm::event::{self, Event, KeyEventKind};
25use crossterm::execute;
26use crossterm::terminal::{
27 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
28};
29use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
30use nucleo_matcher::{Config as NucleoConfig, Matcher, Utf32Str};
31use ratatui::backend::CrosstermBackend;
32use ratatui::{Frame, Terminal};
33use std::fs::{File, OpenOptions};
34
35use crate::db::CommandRecord;
36use actions::{DbBackend, TuiBackend};
37use app::{TuiApp, TuiFilters};
38
39pub fn run_interactive(
45 records: Vec<CommandRecord>,
46 filters: TuiFilters,
47 initial_query: String,
48 limit: usize,
49) -> Result<Option<String>> {
50 let mut backend = DbBackend::open(limit)?;
51 let mut app = TuiApp::new(records, filters, initial_query);
52
53 if let Ok(cwd) = std::env::current_dir() {
56 let ctx = crate::gitctx::detect(&cwd);
57 let project = ctx
58 .root
59 .as_deref()
60 .map(|r| app::last_segment(r).to_string());
61 app.set_current_context(project, ctx.branch);
62 }
63
64 let mut terminal = setup_terminal()?;
65 let result = event_loop(&mut terminal, &mut app, &mut backend);
66 restore_terminal(&mut terminal)?;
67 result?;
68
69 Ok(app.outcome.clone())
70}
71
72fn setup_terminal() -> Result<Terminal<CrosstermBackend<File>>> {
75 enable_raw_mode()?;
76 let mut tty = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
77 execute!(tty, EnterAlternateScreen)?;
78 let backend = CrosstermBackend::new(tty);
79 Ok(Terminal::new(backend)?)
80}
81
82fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<File>>) -> Result<()> {
83 disable_raw_mode()?;
84 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
85 terminal.show_cursor()?;
86 Ok(())
87}
88
89fn event_loop<B: TuiBackend>(
90 terminal: &mut Terminal<CrosstermBackend<File>>,
91 app: &mut TuiApp,
92 backend: &mut B,
93) -> Result<()> {
94 loop {
95 terminal.draw(|f: &mut Frame| ui::render(f, app))?;
96
97 if let Event::Key(key) = event::read()? {
98 if key.kind != KeyEventKind::Press {
99 continue;
100 }
101 let action = events::map_key(app.mode, key.code, key.modifiers);
102 app.dispatch(action, backend);
103 if app.should_quit {
104 break;
105 }
106 }
107 }
108 Ok(())
109}
110
111pub fn fuzzy_filter(records: &[CommandRecord], query: &str, matcher: &mut Matcher) -> Vec<usize> {
116 if query.trim().is_empty() {
117 return (0..records.len()).collect();
118 }
119 let pattern = Pattern::parse(query.trim(), CaseMatching::Ignore, Normalization::Smart);
120 let mut buf: Vec<char> = Vec::new();
121 let mut scored: Vec<(usize, u32)> = records
122 .iter()
123 .enumerate()
124 .filter_map(|(i, r)| {
125 let haystack = Utf32Str::new(&r.command, &mut buf);
126 pattern.score(haystack, matcher).map(|score| (i, score))
127 })
128 .collect();
129 scored.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
130 scored.into_iter().map(|(i, _)| i).collect()
131}
132
133pub fn search_print(records: &[CommandRecord], query: &str, limit: usize) -> Vec<String> {
136 let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
137 fuzzy_filter(records, query, &mut matcher)
138 .into_iter()
139 .take(limit)
140 .map(|i| records[i].command.clone())
141 .collect()
142}
143
144pub fn search_records<'a>(
147 records: &'a [CommandRecord],
148 query: &str,
149 limit: usize,
150) -> Vec<&'a CommandRecord> {
151 let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
152 fuzzy_filter(records, query, &mut matcher)
153 .into_iter()
154 .take(limit)
155 .map(|i| &records[i])
156 .collect()
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::db::CommandRecord;
163
164 fn rec(id: i64, command: &str) -> CommandRecord {
165 CommandRecord {
166 id,
167 command: command.to_string(),
168 cwd: None,
169 shell: None,
170 hostname: None,
171 exit_code: None,
172 created_at: "2026-06-13 10:00:00".to_string(),
173 git_root: None,
174 git_branch: None,
175 git_remote: None,
176 session_id: None,
177 }
178 }
179
180 fn sample() -> Vec<CommandRecord> {
181 vec![
182 rec(1, "cargo build --release"),
183 rec(2, "git status"),
184 rec(3, "cargo test"),
185 rec(4, "ls -la"),
186 ]
187 }
188
189 #[test]
190 fn print_filtre_par_requete() {
191 let records = sample();
192 let out = search_print(&records, "cargo", 10);
193 assert_eq!(out.len(), 2);
194 assert!(out.iter().all(|c| c.contains("cargo")));
195 }
196
197 #[test]
198 fn print_respecte_la_limite() {
199 let records = sample();
200 let out = search_print(&records, "", 2);
201 assert_eq!(out.len(), 2);
202 }
203
204 #[test]
205 fn requete_vide_renvoie_tout() {
206 let records = sample();
207 let out = search_print(&records, " ", 100);
208 assert_eq!(out.len(), records.len());
209 }
210}