1use std::collections::{HashMap, HashSet};
2
3use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor};
4use serde::{Deserialize, Serialize};
5use unicode_segmentation::UnicodeSegmentation;
6
7use atuin_client::{history::History, settings::Settings, theme::Meaning, theme::Theme};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Stats {
11 pub total_commands: usize,
12 pub unique_commands: usize,
13 pub top: Vec<(Vec<String>, usize)>,
14}
15
16fn first_non_whitespace(s: &str) -> Option<usize> {
17 s.char_indices()
18 .find(|(_, c)| !c.is_ascii_whitespace())
20 .map(|(i, _)| i)
22}
23
24fn first_whitespace(s: &str) -> usize {
25 s.char_indices()
26 .find(|(_, c)| c.is_ascii_whitespace())
28 .map_or(s.len(), |(i, _)| i)
30}
31
32fn interesting_command<'a>(settings: &Settings, mut command: &'a str) -> &'a str {
33 let mut common_prefix = settings.stats.common_prefix.clone();
35 common_prefix.sort_by_key(|b| std::cmp::Reverse(b.len()));
36
37 for p in &common_prefix {
39 if command.starts_with(p) {
40 let i = p.len();
41 let prefix = &command[..i];
42 command = command[i..].trim_start();
43 if command.is_empty() {
44 return prefix;
46 }
47 break;
48 }
49 }
50
51 let mut common_subcommands = settings.stats.common_subcommands.clone();
53 common_subcommands.sort_by_key(|b| std::cmp::Reverse(b.len()));
54
55 for p in &common_subcommands {
57 if command.starts_with(p) {
58 if p.len() == command.len() {
60 return command;
61 }
62 let non_whitespace = first_non_whitespace(&command[p.len()..]).unwrap_or(0);
64 let j =
65 p.len() + non_whitespace + first_whitespace(&command[p.len() + non_whitespace..]);
66 return &command[..j];
67 }
68 }
69 &command[..first_whitespace(command)]
71}
72
73fn split_at_pipe(command: &str) -> Vec<&str> {
74 let mut result = vec![];
75 let mut quoted = false;
76 let mut start = 0;
77 let mut graphemes = UnicodeSegmentation::grapheme_indices(command, true);
78
79 while let Some((i, c)) = graphemes.next() {
80 let current = i;
81 match c {
82 "\"" => {
83 if command[start..current] != *"\"" {
84 quoted = !quoted;
85 }
86 }
87 "'" => {
88 if command[start..current] != *"'" {
89 quoted = !quoted;
90 }
91 }
92 "\\" => if graphemes.next().is_some() {},
93 "|" => {
94 if !quoted {
95 if current > start && command[start..].starts_with('|') {
96 start += 1;
97 }
98 result.push(&command[start..current]);
99 start = current;
100 }
101 }
102 _ => {}
103 }
104 }
105 if command[start..].starts_with('|') {
106 start += 1;
107 }
108 result.push(&command[start..]);
109 result
110}
111
112pub fn pretty_print(stats: Stats, ngram_size: usize, theme: &Theme) {
113 let max = stats.top.iter().map(|x| x.1).max().unwrap();
114 let num_pad = max.ilog10() as usize + 1;
115
116 let column_widths = stats
118 .top
119 .iter()
120 .map(|(commands, _)| commands.iter().map(|c| c.len()).collect::<Vec<usize>>())
121 .fold(vec![0; ngram_size], |acc, item| {
122 acc.iter()
123 .zip(item.iter())
124 .map(|(a, i)| *std::cmp::max(a, i))
125 .collect()
126 });
127
128 for (command, count) in stats.top {
129 let gray = SetForegroundColor(match theme.as_style(Meaning::Muted).foreground_color {
130 Some(color) => color,
131 None => Color::Grey,
132 });
133 let bold = SetAttribute(crossterm::style::Attribute::Bold);
134
135 let in_ten = 10 * count / max;
136
137 print!("[");
138 print!(
139 "{}",
140 SetForegroundColor(match theme.get_error().foreground_color {
141 Some(color) => color,
142 None => Color::Red,
143 })
144 );
145
146 for i in 0..in_ten {
147 if i == 2 {
148 print!(
149 "{}",
150 SetForegroundColor(match theme.get_warning().foreground_color {
151 Some(color) => color,
152 None => Color::Yellow,
153 })
154 );
155 }
156
157 if i == 5 {
158 print!(
159 "{}",
160 SetForegroundColor(match theme.get_info().foreground_color {
161 Some(color) => color,
162 None => Color::Green,
163 })
164 );
165 }
166
167 print!("▮");
168 }
169
170 for _ in in_ten..10 {
171 print!(" ");
172 }
173
174 let formatted_command = command
175 .iter()
176 .zip(column_widths.iter())
177 .map(|(cmd, width)| format!("{cmd:width$}"))
178 .collect::<Vec<_>>()
179 .join(" | ");
180
181 println!("{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{formatted_command}{ResetColor}");
182 }
183 println!("Total commands: {}", stats.total_commands);
184 println!("Unique commands: {}", stats.unique_commands);
185}
186
187pub fn compute(
188 settings: &Settings,
189 history: &[History],
190 count: usize,
191 ngram_size: usize,
192) -> Option<Stats> {
193 let mut commands = HashSet::<&str>::with_capacity(history.len());
194 let mut total_unignored = 0;
195 let mut prefixes = HashMap::<Vec<&str>, usize>::with_capacity(history.len());
196
197 for i in history {
198 let command = i.command.trim();
200 let prefix = interesting_command(settings, command);
201
202 if settings.stats.ignored_commands.iter().any(|c| c == prefix) {
203 continue;
204 }
205
206 total_unignored += 1;
207 commands.insert(command);
208
209 split_at_pipe(i.command.trim())
210 .iter()
211 .map(|l| {
212 let command = l.trim();
213 commands.insert(command);
214 command
215 })
216 .collect::<Vec<_>>()
217 .windows(ngram_size)
218 .for_each(|w| {
219 *prefixes
220 .entry(w.iter().map(|c| interesting_command(settings, c)).collect())
221 .or_default() += 1;
222 });
223 }
224
225 let unique = commands.len();
226 let mut top = prefixes.into_iter().collect::<Vec<_>>();
227
228 top.sort_unstable_by_key(|x| std::cmp::Reverse(x.1));
229 top.truncate(count);
230
231 if top.is_empty() {
232 return None;
233 }
234
235 Some(Stats {
236 unique_commands: unique,
237 total_commands: total_unignored,
238 top: top
239 .into_iter()
240 .map(|t| (t.0.into_iter().map(|s| s.to_string()).collect(), t.1))
241 .collect(),
242 })
243}
244
245#[cfg(test)]
246mod tests {
247 use atuin_client::history::History;
248 use atuin_client::settings::Settings;
249 use time::OffsetDateTime;
250
251 use super::compute;
252 use super::{interesting_command, split_at_pipe};
253
254 #[test]
255 fn ignored_commands() {
256 let mut settings = Settings::utc();
257 settings.stats.ignored_commands.push("cd".to_string());
258
259 let history = [
260 History::import()
261 .timestamp(OffsetDateTime::now_utc())
262 .command("cd foo")
263 .build()
264 .into(),
265 History::import()
266 .timestamp(OffsetDateTime::now_utc())
267 .command("cargo build stuff")
268 .build()
269 .into(),
270 ];
271
272 let stats = compute(&settings, &history, 10, 1).expect("failed to compute stats");
273 assert_eq!(stats.total_commands, 1);
274 assert_eq!(stats.unique_commands, 1);
275 }
276
277 #[test]
278 fn interesting_commands() {
279 let settings = Settings::utc();
280
281 assert_eq!(interesting_command(&settings, "cargo"), "cargo");
282 assert_eq!(
283 interesting_command(&settings, "cargo build foo bar"),
284 "cargo build"
285 );
286 assert_eq!(
287 interesting_command(&settings, "sudo cargo build foo bar"),
288 "cargo build"
289 );
290 assert_eq!(interesting_command(&settings, "sudo"), "sudo");
291 }
292
293 #[test]
295 fn interesting_commands_spaces() {
296 let mut settings = Settings::utc();
297 settings.stats.common_prefix.push("sudo test".to_string());
298
299 assert_eq!(interesting_command(&settings, "sudo test"), "sudo test");
300 assert_eq!(interesting_command(&settings, "sudo test "), "sudo test");
301 assert_eq!(interesting_command(&settings, "sudo test foo bar"), "foo");
302 assert_eq!(
303 interesting_command(&settings, "sudo test foo bar"),
304 "foo"
305 );
306
307 assert_eq!(
309 interesting_command(&settings, "sudo test cargo build foo bar"),
310 "cargo build"
311 );
312
313 assert_eq!(interesting_command(&settings, "sudo"), "sudo");
315 assert_eq!(interesting_command(&settings, "sudo foo"), "foo");
316 }
317
318 #[test]
320 fn interesting_commands_spaces_subcommand() {
321 let mut settings = Settings::utc();
322 settings
323 .stats
324 .common_subcommands
325 .push("cargo build".to_string());
326
327 assert_eq!(interesting_command(&settings, "cargo build"), "cargo build");
328 assert_eq!(
329 interesting_command(&settings, "cargo build "),
330 "cargo build"
331 );
332 assert_eq!(
333 interesting_command(&settings, "cargo build foo bar"),
334 "cargo build foo"
335 );
336
337 assert_eq!(
339 interesting_command(&settings, "sudo cargo build foo bar"),
340 "cargo build foo"
341 );
342
343 assert_eq!(interesting_command(&settings, "cargo"), "cargo");
345 assert_eq!(interesting_command(&settings, "cargo foo"), "cargo foo");
346 }
347
348 #[test]
350 fn interesting_commands_spaces_both() {
351 let mut settings = Settings::utc();
352 settings.stats.common_prefix.push("sudo test".to_string());
353 settings
354 .stats
355 .common_subcommands
356 .push("cargo build".to_string());
357
358 assert_eq!(
359 interesting_command(&settings, "sudo test cargo build"),
360 "cargo build"
361 );
362 assert_eq!(
363 interesting_command(&settings, "sudo test cargo build"),
364 "cargo build"
365 );
366 assert_eq!(
367 interesting_command(&settings, "sudo test cargo build "),
368 "cargo build"
369 );
370 assert_eq!(
371 interesting_command(&settings, "sudo test cargo build foo bar"),
372 "cargo build foo"
373 );
374 }
375
376 #[test]
377 fn split_simple() {
378 assert_eq!(split_at_pipe("fd | rg"), ["fd ", " rg"]);
379 }
380
381 #[test]
382 fn split_multi() {
383 assert_eq!(
384 split_at_pipe("kubectl | jq | rg"),
385 ["kubectl ", " jq ", " rg"]
386 );
387 }
388
389 #[test]
390 fn split_simple_quoted() {
391 assert_eq!(
392 split_at_pipe("foo | bar 'baz {} | quux' | xyzzy"),
393 ["foo ", " bar 'baz {} | quux' ", " xyzzy"]
394 );
395 }
396
397 #[test]
398 fn split_multi_quoted() {
399 assert_eq!(
400 split_at_pipe("foo | bar 'baz \"{}\" | quux' | xyzzy"),
401 ["foo ", " bar 'baz \"{}\" | quux' ", " xyzzy"]
402 );
403 }
404
405 #[test]
406 fn escaped_pipes() {
407 assert_eq!(
408 split_at_pipe("foo | bar baz \\| quux"),
409 ["foo ", " bar baz \\| quux"]
410 );
411 }
412
413 #[test]
414 fn emoji() {
415 assert_eq!(
416 split_at_pipe("git commit -m \"🚀\""),
417 ["git commit -m \"🚀\""]
418 );
419 }
420
421 #[test]
422 fn starts_with_pipe() {
423 assert_eq!(
424 split_at_pipe("| sed 's/[0-9a-f]//g'"),
425 ["", " sed 's/[0-9a-f]//g'"]
426 );
427 }
428
429 #[test]
430 fn starts_with_spaces_and_pipe() {
431 assert_eq!(
432 split_at_pipe(" | sed 's/[0-9a-f]//g'"),
433 [" ", " sed 's/[0-9a-f]//g'"]
434 );
435 }
436}