aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2024-12-27 15:21:45 +0000
committerGitHub <noreply@github.com>2024-12-27 15:21:45 +0000
commitaa5c7e7d1a0787dd07ff4f901ed0321c7005debb (patch)
tree1488254dfd47287ab7d5443f4deabce8b206e7c6
parentchore(release): prepare for release 18.4.0-beta.5 (#2472) (diff)
downloadatuin-aa5c7e7d1a0787dd07ff4f901ed0321c7005debb.zip
feat: add `atuin wrapped` (#2493)
* wip * wip * final * fix clippy * do not hard code the year * support tz properly, allow specifying the year
-rw-r--r--crates/atuin-client/src/database.rs80
-rw-r--r--crates/atuin-history/src/stats.rs2
-rw-r--r--crates/atuin/src/command/client.rs6
-rw-r--r--crates/atuin/src/command/client/search/interactive.rs34
-rw-r--r--crates/atuin/src/command/client/wrapped.rs304
5 files changed, 368 insertions, 58 deletions
diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs
index 4f126030..5bdbb75c 100644
--- a/crates/atuin-client/src/database.rs
+++ b/crates/atuin-client/src/database.rs
@@ -760,6 +760,46 @@ impl Database for Sqlite {
}
}
+trait SqlBuilderExt {
+ fn fuzzy_condition<S: ToString, T: ToString>(
+ &mut self,
+ field: S,
+ mask: T,
+ inverse: bool,
+ glob: bool,
+ is_or: bool,
+ ) -> &mut Self;
+}
+
+impl SqlBuilderExt for SqlBuilder {
+ /// adapted from the sql-builder *like functions
+ fn fuzzy_condition<S: ToString, T: ToString>(
+ &mut self,
+ field: S,
+ mask: T,
+ inverse: bool,
+ glob: bool,
+ is_or: bool,
+ ) -> &mut Self {
+ let mut cond = field.to_string();
+ if inverse {
+ cond.push_str(" NOT");
+ }
+ if glob {
+ cond.push_str(" GLOB '");
+ } else {
+ cond.push_str(" LIKE '");
+ }
+ cond.push_str(&esc(mask.to_string()));
+ cond.push('\'');
+ if is_or {
+ self.or_where(cond)
+ } else {
+ self.and_where(cond)
+ }
+ }
+}
+
#[cfg(test)]
mod test {
use crate::settings::test_local_timeout;
@@ -1105,43 +1145,3 @@ mod test {
assert!(duration < Duration::from_secs(15));
}
}
-
-trait SqlBuilderExt {
- fn fuzzy_condition<S: ToString, T: ToString>(
- &mut self,
- field: S,
- mask: T,
- inverse: bool,
- glob: bool,
- is_or: bool,
- ) -> &mut Self;
-}
-
-impl SqlBuilderExt for SqlBuilder {
- /// adapted from the sql-builder *like functions
- fn fuzzy_condition<S: ToString, T: ToString>(
- &mut self,
- field: S,
- mask: T,
- inverse: bool,
- glob: bool,
- is_or: bool,
- ) -> &mut Self {
- let mut cond = field.to_string();
- if inverse {
- cond.push_str(" NOT");
- }
- if glob {
- cond.push_str(" GLOB '");
- } else {
- cond.push_str(" LIKE '");
- }
- cond.push_str(&esc(mask.to_string()));
- cond.push('\'');
- if is_or {
- self.or_where(cond)
- } else {
- self.and_where(cond)
- }
- }
-}
diff --git a/crates/atuin-history/src/stats.rs b/crates/atuin-history/src/stats.rs
index 6312f518..310891e4 100644
--- a/crates/atuin-history/src/stats.rs
+++ b/crates/atuin-history/src/stats.rs
@@ -6,7 +6,7 @@ use unicode_segmentation::UnicodeSegmentation;
use atuin_client::{history::History, settings::Settings, theme::Meaning, theme::Theme};
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stats {
pub total_commands: usize,
pub unique_commands: usize,
diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs
index ce101201..2637b691 100644
--- a/crates/atuin/src/command/client.rs
+++ b/crates/atuin/src/command/client.rs
@@ -28,6 +28,7 @@ mod kv;
mod search;
mod stats;
mod store;
+mod wrapped;
#[derive(Subcommand, Debug)]
#[command(infer_subcommands = true)]
@@ -78,6 +79,9 @@ pub enum Cmd {
#[command()]
Doctor,
+ #[command()]
+ Wrapped { year: Option<i32> },
+
/// *Experimental* Start the background daemon
#[cfg(feature = "daemon")]
#[command()]
@@ -166,6 +170,8 @@ impl Cmd {
Ok(())
}
+ Self::Wrapped { year } => wrapped::run(year, &db, &settings, theme).await,
+
#[cfg(feature = "daemon")]
Self::Daemon => daemon::run(settings, sqlite_store, db).await,
diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs
index 5288a0ee..a2d32ee8 100644
--- a/crates/atuin/src/command/client/search/interactive.rs
+++ b/crates/atuin/src/command/client/search/interactive.rs
@@ -1304,8 +1304,8 @@ mod tests {
let no_preview = State::calc_preview_height(
&settings_preview_auto,
&results,
- 0 as usize,
- 0 as usize,
+ 0_usize,
+ 0_usize,
false,
1,
80,
@@ -1314,8 +1314,8 @@ mod tests {
let preview_h2 = State::calc_preview_height(
&settings_preview_auto,
&results,
- 1 as usize,
- 0 as usize,
+ 1_usize,
+ 0_usize,
false,
1,
80,
@@ -1324,8 +1324,8 @@ mod tests {
let preview_h3 = State::calc_preview_height(
&settings_preview_auto,
&results,
- 2 as usize,
- 0 as usize,
+ 2_usize,
+ 0_usize,
false,
1,
80,
@@ -1334,8 +1334,8 @@ mod tests {
let preview_one_line = State::calc_preview_height(
&settings_preview_auto,
&results,
- 0 as usize,
- 0 as usize,
+ 0_usize,
+ 0_usize,
false,
1,
66,
@@ -1344,8 +1344,8 @@ mod tests {
let preview_limit_at_2 = State::calc_preview_height(
&settings_preview_auto_h2,
&results,
- 2 as usize,
- 0 as usize,
+ 2_usize,
+ 0_usize,
false,
1,
80,
@@ -1354,8 +1354,8 @@ mod tests {
let preview_static_h3 = State::calc_preview_height(
&settings_preview_h4,
&results,
- 1 as usize,
- 0 as usize,
+ 1_usize,
+ 0_usize,
false,
1,
80,
@@ -1364,8 +1364,8 @@ mod tests {
let preview_static_limit_at_4 = State::calc_preview_height(
&settings_preview_h4,
&results,
- 1 as usize,
- 0 as usize,
+ 1_usize,
+ 0_usize,
false,
1,
20,
@@ -1374,8 +1374,8 @@ mod tests {
let settings_preview_fixed = State::calc_preview_height(
&settings_preview_fixed,
&results,
- 1 as usize,
- 0 as usize,
+ 1_usize,
+ 0_usize,
false,
1,
20,
@@ -1383,7 +1383,7 @@ mod tests {
assert_eq!(no_preview, 1);
// 1 * 2 is the space for the border
- let border_space = 1 * 2;
+ let border_space = 2;
assert_eq!(preview_h2, 2 + border_space);
assert_eq!(preview_h3, 3 + border_space);
assert_eq!(preview_one_line, 1 + border_space);
diff --git a/crates/atuin/src/command/client/wrapped.rs b/crates/atuin/src/command/client/wrapped.rs
new file mode 100644
index 00000000..7c5ca058
--- /dev/null
+++ b/crates/atuin/src/command/client/wrapped.rs
@@ -0,0 +1,304 @@
+use crossterm::style::{ResetColor, SetAttribute};
+use eyre::Result;
+use std::collections::{HashMap, HashSet};
+use time::{Date, Duration, Month, OffsetDateTime, Time};
+
+use atuin_client::{database::Database, settings::Settings, theme::Theme};
+
+use atuin_history::stats::{compute, Stats};
+
+#[derive(Debug)]
+struct WrappedStats {
+ nav_commands: usize,
+ pkg_commands: usize,
+ error_rate: f64,
+ first_half_commands: Vec<(String, usize)>,
+ second_half_commands: Vec<(String, usize)>,
+ git_percentage: f64,
+ busiest_hour: Option<(String, usize)>,
+}
+
+impl WrappedStats {
+ #[allow(clippy::too_many_lines, clippy::cast_precision_loss)]
+ fn new(settings: &Settings, stats: &Stats, history: &[atuin_client::history::History]) -> Self {
+ let nav_commands = stats
+ .top
+ .iter()
+ .filter(|(cmd, _)| {
+ let cmd = &cmd[0];
+ cmd == "cd" || cmd == "ls" || cmd == "pwd" || cmd == "pushd" || cmd == "popd"
+ })
+ .map(|(_, count)| count)
+ .sum();
+
+ let pkg_managers = [
+ "cargo",
+ "npm",
+ "pnpm",
+ "yarn",
+ "pip",
+ "pip3",
+ "pipenv",
+ "poetry",
+ "brew",
+ "apt",
+ "apt-get",
+ "apk",
+ "pacman",
+ "yum",
+ "dnf",
+ "zypper",
+ "pkg",
+ "chocolatey",
+ "choco",
+ "scoop",
+ "winget",
+ "gem",
+ "bundle",
+ "composer",
+ "gradle",
+ "maven",
+ "mvn",
+ "go get",
+ "nuget",
+ "dotnet",
+ "mix",
+ "hex",
+ "rebar3",
+ ];
+
+ let pkg_commands = history
+ .iter()
+ .filter(|h| {
+ let cmd = h.command.clone();
+ pkg_managers.iter().any(|pm| cmd.starts_with(pm))
+ })
+ .count();
+
+ // Error analysis
+ let mut command_errors: HashMap<String, (usize, usize)> = HashMap::new(); // (total_uses, errors)
+ let midyear = history[0].timestamp + Duration::days(182); // Split year in half
+
+ let mut first_half_commands: HashMap<String, usize> = HashMap::new();
+ let mut second_half_commands: HashMap<String, usize> = HashMap::new();
+ let mut hours: HashMap<String, usize> = HashMap::new();
+
+ for entry in history {
+ let cmd = entry
+ .command
+ .split_whitespace()
+ .next()
+ .unwrap_or("")
+ .to_string();
+ let (total, errors) = command_errors.entry(cmd.clone()).or_insert((0, 0));
+ *total += 1;
+ if entry.exit != 0 {
+ *errors += 1;
+ }
+
+ // Track command evolution
+ if entry.timestamp < midyear {
+ *first_half_commands.entry(cmd.clone()).or_default() += 1;
+ } else {
+ *second_half_commands.entry(cmd).or_default() += 1;
+ }
+
+ // Track hourly distribution
+ let local_time = entry
+ .timestamp
+ .to_offset(time::UtcOffset::current_local_offset().unwrap_or(settings.timezone.0));
+ let hour = format!("{:02}:00", local_time.time().hour());
+ *hours.entry(hour).or_default() += 1;
+ }
+
+ let total_errors: usize = command_errors.values().map(|(_, errors)| errors).sum();
+ let total_commands: usize = command_errors.values().map(|(total, _)| total).sum();
+ let error_rate = total_errors as f64 / total_commands as f64;
+
+ // Process command evolution data
+ let mut first_half: Vec<_> = first_half_commands.into_iter().collect();
+ let mut second_half: Vec<_> = second_half_commands.into_iter().collect();
+ first_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
+ second_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
+ first_half.truncate(5);
+ second_half.truncate(5);
+
+ // Calculate git percentage
+ let git_commands: usize = stats
+ .top
+ .iter()
+ .filter(|(cmd, _)| cmd[0].starts_with("git"))
+ .map(|(_, count)| count)
+ .sum();
+ let git_percentage = git_commands as f64 / stats.total_commands as f64;
+
+ // Find busiest hour
+ let busiest_hour = hours.into_iter().max_by_key(|(_, count)| *count);
+
+ Self {
+ nav_commands,
+ pkg_commands,
+ error_rate,
+ first_half_commands: first_half,
+ second_half_commands: second_half,
+ git_percentage,
+ busiest_hour,
+ }
+ }
+}
+
+pub fn print_wrapped_header(year: i32) {
+ let reset = ResetColor;
+ let bold = SetAttribute(crossterm::style::Attribute::Bold);
+
+ println!("{bold}╭────────────────────────────────────╮{reset}");
+ println!("{bold}│ ATUIN WRAPPED {year} │{reset}");
+ println!("{bold}│ Your Year in Shell History │{reset}");
+ println!("{bold}╰────────────────────────────────────╯{reset}");
+ println!();
+}
+
+#[allow(clippy::cast_precision_loss)]
+fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) {
+ let reset = ResetColor;
+ let bold = SetAttribute(crossterm::style::Attribute::Bold);
+
+ if wrapped_stats.git_percentage > 0.05 {
+ println!(
+ "{bold}🌟 You're a Git Power User!{reset} {bold}{:.1}%{reset} of your commands were Git operations\n",
+ wrapped_stats.git_percentage * 100.0
+ );
+ }
+ // Navigation patterns
+ let nav_percentage = wrapped_stats.nav_commands as f64 / stats.total_commands as f64 * 100.0;
+ if nav_percentage > 0.05 {
+ println!(
+ "{bold}🚀 You're a Navigator!{reset} {bold}{nav_percentage:.1}%{reset} of your time was spent navigating directories\n",
+ );
+ }
+
+ // Command vocabulary
+ println!(
+ "{bold}📚 Command Vocabulary{reset}: You know {bold}{}{reset} unique commands\n",
+ stats.unique_commands
+ );
+
+ // Package management
+ println!(
+ "{bold}📦 Package Management{reset}: You ran {bold}{}{reset} package-related commands\n",
+ wrapped_stats.pkg_commands
+ );
+
+ // Error patterns
+ let error_percentage = wrapped_stats.error_rate * 100.0;
+ println!(
+ "{bold}🚨 Error Analysis{reset}: Your commands failed {bold}{error_percentage:.1}%{reset} of the time\n",
+ );
+
+ // Command evolution
+ println!("🔍 Command Evolution:");
+
+ // print stats for each half and compare
+ println!(" {bold}Top Commands{reset} in the first half of {year}:");
+ for (cmd, count) in wrapped_stats.first_half_commands.iter().take(3) {
+ println!(" {bold}{cmd}{reset} ({count} times)");
+ }
+
+ println!(" {bold}Top Commands{reset} in the second half of {year}:");
+ for (cmd, count) in wrapped_stats.second_half_commands.iter().take(3) {
+ println!(" {bold}{cmd}{reset} ({count} times)");
+ }
+
+ // Find new favorite commands (in top 5 of second half but not in first half)
+ let first_half_set: HashSet<_> = wrapped_stats
+ .first_half_commands
+ .iter()
+ .map(|(cmd, _)| cmd)
+ .collect();
+ let new_favorites: Vec<_> = wrapped_stats
+ .second_half_commands
+ .iter()
+ .filter(|(cmd, _)| !first_half_set.contains(cmd))
+ .take(2)
+ .collect();
+
+ if !new_favorites.is_empty() {
+ println!(" {bold}New favorites{reset} in the second half:");
+ for (cmd, count) in new_favorites {
+ println!(" {bold}{cmd}{reset} ({count} times)");
+ }
+ }
+
+ // Time patterns
+ if let Some((hour, count)) = &wrapped_stats.busiest_hour {
+ println!("\n🕘 Most Productive Hour: {bold}{hour}{reset} ({count} commands)",);
+
+ // Night owl or early bird
+ let hour_num = hour
+ .split(':')
+ .next()
+ .unwrap_or("0")
+ .parse::<u32>()
+ .unwrap_or(0);
+ if hour_num >= 22 || hour_num <= 4 {
+ println!(" You're quite the night owl! 🦉");
+ } else if (5..=7).contains(&hour_num) {
+ println!(" Early bird gets the worm! 🐦");
+ }
+ }
+
+ println!();
+}
+
+pub async fn run(
+ year: Option<i32>,
+ db: &impl Database,
+ settings: &Settings,
+ theme: &Theme,
+) -> Result<()> {
+ let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
+ let month = now.month();
+
+ // If we're in December, then wrapped is for the current year. If not, it's for the previous year
+ let year = year.unwrap_or_else(|| {
+ if month == Month::December {
+ now.year()
+ } else {
+ now.year() - 1
+ }
+ });
+
+ let start = OffsetDateTime::new_in_offset(
+ Date::from_calendar_date(year, Month::January, 1).unwrap(),
+ Time::MIDNIGHT,
+ now.offset(),
+ );
+ let end = OffsetDateTime::new_in_offset(
+ Date::from_calendar_date(year, Month::December, 31).unwrap(),
+ Time::MIDNIGHT + Duration::days(1) - Duration::nanoseconds(1),
+ now.offset(),
+ );
+
+ let history = db.range(start, end).await?;
+
+ // Compute overall stats using existing functionality
+ let stats = compute(settings, &history, 10, 1).expect("Failed to compute stats");
+ let wrapped_stats = WrappedStats::new(settings, &stats, &history);
+
+ // Print wrapped format
+ print_wrapped_header(year);
+
+ println!("🎉 In {year}, you typed {} commands!", stats.total_commands);
+ println!(
+ " That's ~{} commands every day\n",
+ stats.total_commands / 365
+ );
+
+ println!("Your Top Commands:");
+ atuin_history::stats::pretty_print(stats.clone(), 1, theme);
+ println!();
+
+ print_fun_facts(&wrapped_stats, &stats, year);
+
+ Ok(())
+}