From aa5c7e7d1a0787dd07ff4f901ed0321c7005debb Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Fri, 27 Dec 2024 15:21:45 +0000 Subject: [PATCH] feat: add `atuin wrapped` (#2493) * wip * wip * final * fix clippy * do not hard code the year * support tz properly, allow specifying the year --- crates/atuin-client/src/database.rs | 80 ++--- crates/atuin-history/src/stats.rs | 2 +- crates/atuin/src/command/client.rs | 6 + .../src/command/client/search/interactive.rs | 34 +- crates/atuin/src/command/client/wrapped.rs | 304 ++++++++++++++++++ 5 files changed, 368 insertions(+), 58 deletions(-) create mode 100644 crates/atuin/src/command/client/wrapped.rs diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs index 4f126030f63..5bdbb75ce9c 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( + &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( + &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( - &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( - &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 6312f518214..310891e4a4e 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 ce10120150d..2637b691b30 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 }, + /// *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 5288a0ee9b2..a2d32ee8d48 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 00000000000..7c5ca0585e8 --- /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 = HashMap::new(); // (total_uses, errors) + let midyear = history[0].timestamp + Duration::days(182); // Split year in half + + let mut first_half_commands: HashMap = HashMap::new(); + let mut second_half_commands: HashMap = HashMap::new(); + let mut hours: HashMap = 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::() + .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, + 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(()) +}