Repository: cantino/mcfly (language: rust)

Issue:
Feature: quick stats
It would be neat to add something like `mcfly stats` to the cli for the curious - it could be roughly equivalent to 

    sqlite3 .local/share/mcfly/history.db "select substr(cmd,1,instr(cmd,' ')-1), count(1) as n from commands group by 1 order by 2 desc limit 40;"

Inspired by https://www.miskatonic.org/2023/02/21/commands/


Discussion / hints:
I'd be happy to accept a pull request!
Sure, I can add it to my todo list. 

Fix patch:
diff --git a/src/cli.rs b/src/cli.rs
index b7ea31b..f202a2a 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -139,6 +139,28 @@ pub enum SubCommand {
         #[arg(long, short, value_enum, default_value_t)]
         format: DumpFormat,
     },
+
+    /// Prints stats
+    Stats {
+        /// The minimum command size to be listed in the "top-n" commands
+        #[arg(
+            value_name = "TOP_COMMAND_MIN_SIZE",
+            short,
+            long,
+            value_name = "top_cmd_min_size"
+        )]
+        command_limit: Option<i16>,
+
+        /// The number of "top-n" commands
+        #[arg(
+            value_name = "TOP_COMMAND_SIZE",
+            short,
+            long,
+            value_name = "top_cmd_size",
+            default_value_t = 10
+        )]
+        limit: i16,
+    },
 }
 
 #[derive(Clone, Copy, ValueEnum, Default)]
diff --git a/src/history/history.rs b/src/history/history.rs
index 5073973..25f2baa 100644
--- a/src/history/history.rs
+++ b/src/history/history.rs
@@ -677,7 +677,7 @@ impl History {
         }
     }
 
-    fn run_query<T, F>(&self, query: &str, params: &[(&str, &dyn ToSql)], f: F) -> Vec<T>
+    pub fn run_query<T, F>(&self, query: &str, params: &[(&str, &dyn ToSql)], f: F) -> Vec<T>
     where
         F: FnMut(&Row<'_>) -> rusqlite::Result<T>,
     {
diff --git a/src/lib.rs b/src/lib.rs
index 1635ae3..64d0fa5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -13,6 +13,7 @@ pub mod path_update_helpers;
 pub mod settings;
 pub mod shell_history;
 pub mod simplified_command;
+pub mod stats_generator;
 pub mod time;
 pub mod trainer;
 pub mod training_cache;
diff --git a/src/main.rs b/src/main.rs
index ebef438..70f624f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,7 @@
+use std::fs;
+use std::path::PathBuf;
+use std::time::{SystemTime, UNIX_EPOCH};
+
 use mcfly::dumper::Dumper;
 use mcfly::fake_typer;
 use mcfly::history::History;
@@ -6,10 +10,8 @@ use mcfly::interface::Interface;
 use mcfly::settings::Mode;
 use mcfly::settings::Settings;
 use mcfly::shell_history;
+use mcfly::stats_generator::StatsGenerator;
 use mcfly::trainer::Trainer;
-use std::fs;
-use std::path::PathBuf;
-use std::time::{SystemTime, UNIX_EPOCH};
 
 fn handle_addition(settings: &Settings) {
     let history = History::load(settings.history_format);
@@ -100,6 +102,15 @@ fn handle_dump(settings: &Settings) {
     Dumper::new(settings, &history).dump();
 }
 
+fn handle_stats(settings: &Settings) {
+    let history = History::load(settings.history_format);
+    let stats = StatsGenerator::new(&history).generate_stats(
+        settings.stats_top_command_limit,
+        settings.stats_command_limit,
+    );
+    println!("{}", stats);
+}
+
 fn main() {
     let mut settings = Settings::parse_args();
 
@@ -124,5 +135,6 @@ fn main() {
         Mode::Dump => {
             handle_dump(&settings);
         }
+        Mode::Stats => handle_stats(&settings),
     }
 }
diff --git a/src/settings.rs b/src/settings.rs
index ad22f92..2e952f1 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -22,6 +22,7 @@ pub enum Mode {
     Move,
     Init,
     Dump,
+    Stats,
 }
 
 #[derive(Debug)]
@@ -151,6 +152,8 @@ pub struct Settings {
     pub pattern: Option<Regex>,
     pub dump_format: DumpFormat,
     pub colors: Colors,
+    pub stats_command_limit: Option<i16>,
+    pub stats_top_command_limit: i16,
 }
 
 impl Default for Settings {
@@ -211,6 +214,8 @@ impl Default for Settings {
                     results_selection_hl: Color::Grey,
                 },
             },
+            stats_command_limit: None,
+            stats_top_command_limit: 10,
         }
     }
 }
@@ -451,6 +456,15 @@ impl Settings {
                 settings.pattern = regex;
                 settings.dump_format = format;
             }
+
+            SubCommand::Stats {
+                command_limit,
+                limit,
+            } => {
+                settings.mode = Mode::Stats;
+                settings.stats_command_limit = command_limit;
+                settings.stats_top_command_limit = limit;
+            }
         }
 
         settings.lightmode = is_env_var_truthy("MCFLY_LIGHT");
diff --git a/src/stats_generator.rs b/src/stats_generator.rs
new file mode 100644
index 0000000..e7d176c
--- /dev/null
+++ b/src/stats_generator.rs
@@ -0,0 +1,230 @@
+use std::cmp::min;
+use std::ops::Add;
+
+use regex::Regex;
+use serde::Serialize;
+
+use crate::history::History;
+
+#[derive(Debug)]
+pub struct StatsGenerator<'a> {
+    history: &'a History,
+}
+
+#[derive(Debug, Clone, Serialize)]
+struct StatItem {
+    cmd: String,
+    count: i64,
+}
+
+impl<'a> StatsGenerator<'a> {
+    pub fn generate_stats(&self, limit: i16, command_limit: Option<i16>) -> String {
+        let mut lines = "".to_owned();
+        let count_history = Self::count_commands_from_db_history(self);
+        if count_history == 0 {
+            return "No history, no stats!".to_string();
+        }
+        lines.push_str("📊 Quick stats:\n");
+        lines.push_str(format!("  - history has {:?} items ;\n", count_history).as_mut_str());
+        let most_used_commands = self.most_used_commands(&limit, command_limit.unwrap_or(1));
+        lines.push_str(&Self::generate_command_stats(
+            self,
+            limit,
+            most_used_commands,
+        ));
+        lines
+    }
+
+    fn generate_command_stats(&self, limit: i16, stats: Vec<StatItem>) -> String {
+        let mut lines = "".to_owned();
+        if !stats.is_empty() {
+            lines.push_str(
+                format!(
+                    "  - {:?} first commands, sorted by occurrences:\n",
+                    min(limit, stats.len() as i16)
+                )
+                .as_mut_str(),
+            );
+            let re = Regex::new("^(.*)/(.*)$").unwrap();
+            for item in &stats[..min(limit as usize, stats.len())] {
+                let cmd = item.clone().cmd;
+                if !cmd.trim().is_empty() {
+                    let relative_cmd = re.captures(&cmd).map(|dir_and_cmd| {
+                        format!(
+                            "{0} ({1})",
+                            dir_and_cmd.get(2).unwrap().as_str(),
+                            dir_and_cmd.get(1).unwrap().as_str()
+                        )
+                    });
+                    lines = lines.add(&*format!(
+                        "    {:#} ({:?})\n",
+                        relative_cmd.unwrap_or(cmd),
+                        item.count
+                    ));
+                }
+            }
+        }
+        lines
+    }
+
+    fn most_used_commands(&self, limit: &i16, command_limit: i16) -> Vec<StatItem> {
+        self.history.run_query(
+            "select substr(cmd,1,instr(cmd,' ')-1), count(1) as n \
+                from commands \
+                where length(substr(cmd,1,instr(cmd,' ')-1)) >= :command_limit \
+                group by 1 \
+                order by 2 \
+                desc limit :limit",
+            &[
+                (":command_limit", &command_limit.to_owned()),
+                (":limit", &limit.to_owned()),
+            ],
+            |row| {
+                Ok(StatItem {
+                    cmd: row.get(0)?,
+                    count: row.get(1)?,
+                })
+            },
+        )
+    }
+
+    #[inline]
+    pub fn new(history: &'a History) -> Self {
+        Self { history }
+    }
+    fn count_commands_from_db_history(&self) -> i32 {
+        struct Count {
+            count: i32,
+        }
+        let vec = self
+            .history
+            .run_query("select count(1) as n from commands", &[], |row| {
+                Ok(Count { count: row.get(0)? })
+            });
+        vec.first().unwrap().count
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use rusqlite::Connection;
+
+    use crate::history::History;
+    use crate::network::Network;
+    use crate::stats_generator::StatItem;
+
+    #[test]
+    fn empty_history() {
+        let history = History {
+            connection: Connection::open_in_memory().unwrap(),
+            network: Network::random(),
+        };
+        let stats_generator = crate::stats_generator::StatsGenerator::new(&history);
+        let lines = stats_generator.generate_command_stats(10, Vec::new());
+        assert_eq!(lines, "");
+    }
+
+    #[test]
+    fn partial_history() {
+        let history = History {
+            connection: Connection::open_in_memory().unwrap(),
+            network: Network::random(),
+        };
+        let stats_generator = crate::stats_generator::StatsGenerator::new(&history);
+        let lines = stats_generator.generate_command_stats(
+            3,
+            Vec::from([
+                StatItem {
+                    cmd: "git".to_string(),
+                    count: 10,
+                },
+                StatItem {
+                    cmd: "cargo".to_string(),
+                    count: 9,
+                },
+            ]),
+        );
+        assert_eq!(
+            lines,
+            "  - 2 first commands, sorted by occurrences:\n    git (10)\n    cargo (9)\n"
+        );
+    }
+
+    #[test]
+    fn full_history_with_simple_commands() {
+        let history = History {
+            connection: Connection::open_in_memory().unwrap(),
+            network: Network::random(),
+        };
+        let stats_generator = crate::stats_generator::StatsGenerator::new(&history);
+        let lines = stats_generator.generate_command_stats(
+            10,
+            Vec::from([
+                StatItem {
+                    cmd: "git".to_string(),
+                    count: 10,
+                },
+                StatItem {
+                    cmd: "cargo".to_string(),
+                    count: 9,
+                },
+            ]),
+        );
+        assert_eq!(
+            lines,
+            "  - 2 first commands, sorted by occurrences:\n    git (10)\n    cargo (9)\n"
+        );
+    }
+
+    #[test]
+    fn history_with_relative_and_full_path_commands() {
+        let history = History {
+            connection: Connection::open_in_memory().unwrap(),
+            network: Network::random(),
+        };
+        let stats_generator = crate::stats_generator::StatsGenerator::new(&history);
+        let lines = stats_generator.generate_command_stats(
+            10,
+            Vec::from([
+                StatItem {
+                    cmd: "./bin/docker".to_string(),
+                    count: 10,
+                },
+                StatItem {
+                    cmd: "/opt/local/share/docker".to_string(),
+                    count: 9,
+                },
+            ]),
+        );
+        assert_eq!(
+            lines,
+            "  - 2 first commands, sorted by occurrences:\n    docker (./bin) (10)\n    docker (/opt/local/share) (9)\n"
+        );
+    }
+
+    #[test]
+    fn command_stats_can_be_limited() {
+        let history = History {
+            connection: Connection::open_in_memory().unwrap(),
+            network: Network::random(),
+        };
+        let stats_generator = crate::stats_generator::StatsGenerator::new(&history);
+        let lines = stats_generator.generate_command_stats(
+            2,
+            Vec::from([
+                StatItem {
+                    cmd: "a".to_string(),
+                    count: 1,
+                },
+                StatItem {
+                    cmd: "be".to_string(),
+                    count: 2,
+                },
+            ]),
+        );
+        assert_eq!(
+            lines,
+            "  - 2 first commands, sorted by occurrences:\n    a (1)\n    be (2)\n"
+        );
+    }
+}


Test patch: