atuin_history/
stats.rs

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 the first non whitespace char
19        .find(|(_, c)| !c.is_ascii_whitespace())
20        // return the index of that char
21        .map(|(i, _)| i)
22}
23
24fn first_whitespace(s: &str) -> usize {
25    s.char_indices()
26        // find the first whitespace char
27        .find(|(_, c)| c.is_ascii_whitespace())
28        // return the index of that char, (or the max length of the string)
29        .map_or(s.len(), |(i, _)| i)
30}
31
32fn interesting_command<'a>(settings: &Settings, mut command: &'a str) -> &'a str {
33    // Sort by length so that we match the longest prefix first
34    let mut common_prefix = settings.stats.common_prefix.clone();
35    common_prefix.sort_by_key(|b| std::cmp::Reverse(b.len()));
36
37    // Trim off the common prefix, if it exists
38    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                // no commands following, just use the prefix
45                return prefix;
46            }
47            break;
48        }
49    }
50
51    // Sort the common_subcommands by length so that we match the longest subcommand first
52    let mut common_subcommands = settings.stats.common_subcommands.clone();
53    common_subcommands.sort_by_key(|b| std::cmp::Reverse(b.len()));
54
55    // Check for a common subcommand
56    for p in &common_subcommands {
57        if command.starts_with(p) {
58            // if the subcommand is the same length as the command, then we just use the subcommand
59            if p.len() == command.len() {
60                return command;
61            }
62            // otherwise we need to use the subcommand + the next word
63            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    // Return the first word if there is no subcommand
70    &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    // Find the length of the longest command name for each column
117    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        // just in case it somehow has a leading tab or space or something (legacy atuin didn't ignore space prefixes)
199        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 with spaces in the common_prefix
294    #[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        // Works with a common_subcommand as well
308        assert_eq!(
309            interesting_command(&settings, "sudo test cargo build foo bar"),
310            "cargo build"
311        );
312
313        // We still match on just the sudo prefix
314        assert_eq!(interesting_command(&settings, "sudo"), "sudo");
315        assert_eq!(interesting_command(&settings, "sudo foo"), "foo");
316    }
317
318    // Test with spaces in the common_subcommand
319    #[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        // Works with a common_prefix as well
338        assert_eq!(
339            interesting_command(&settings, "sudo cargo build foo bar"),
340            "cargo build foo"
341        );
342
343        // We still match on just cargo as a subcommand
344        assert_eq!(interesting_command(&settings, "cargo"), "cargo");
345        assert_eq!(interesting_command(&settings, "cargo foo"), "cargo foo");
346    }
347
348    // Test with spaces in the common_prefix and common_subcommand
349    #[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}