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, encryption, record::sqlite_store::SqliteStore, settings::Settings,
theme::Theme,
};
use atuin_dotfiles::store::AliasStore;
use atuin_history::stats::{Stats, compute};
#[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],
alias_map: &HashMap<String, String>,
) -> Self {
let expand_alias = |cmd: &str| -> String {
alias_map.get(cmd).map_or_else(
|| cmd.to_string(),
|expanded| {
expanded
.split_whitespace()
.next()
.unwrap_or(cmd)
.to_string()
},
)
};
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",
"pipx",
"uv",
"brew",
"apt",
"apt-get",
"apk",
"pacman",
"yay",
"paru",
"yum",
"dnf",
"dnf5",
"rpm",
"rpm-ostree",
"zypper",
"pkg",
"chocolatey",
"choco",
"scoop",
"winget",
"gem",
"bundle",
"shards",
"composer",
"gradle",
"maven",
"mvn",
"go get",
"nuget",
"dotnet",
"mix",
"hex",
"rebar3",
"nix",
"nix-env",
"cabal",
"opam",
];
let pkg_commands = history
.iter()
.filter(|h| {
let cmd = h.command.clone();
pkg_managers.iter().any(|pm| cmd.starts_with(pm))
})
.count();
let mut command_errors: HashMap<String, (usize, usize)> = HashMap::new(); let midyear = history[0].timestamp + Duration::days(182);
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 raw_cmd = entry
.command
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
let cmd = expand_alias(&raw_cmd);
let (total, errors) = command_errors.entry(cmd.clone()).or_insert((0, 0));
*total += 1;
if entry.exit != 0 {
*errors += 1;
}
if entry.timestamp < midyear {
*first_half_commands.entry(cmd.clone()).or_default() += 1;
} else {
*second_half_commands.entry(cmd).or_default() += 1;
}
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;
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);
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;
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
);
}
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",
);
}
println!(
"{bold}📚 Command Vocabulary{reset}: You know {bold}{}{reset} unique commands\n",
stats.unique_commands
);
println!(
"{bold}📦 Package Management{reset}: You ran {bold}{}{reset} package-related commands\n",
wrapped_stats.pkg_commands
);
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",
);
println!("🔍 Command Evolution:");
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)");
}
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)");
}
}
if let Some((hour, count)) = &wrapped_stats.busiest_hour {
println!("\n🕘 Most Productive Hour: {bold}{hour}{reset} ({count} commands)",);
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,
store: SqliteStore,
theme: &Theme,
) -> Result<()> {
let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
let month = now.month();
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?;
if history.is_empty() {
println!(
"Your history for {year} is empty!\nMaybe 'atuin import' could help you import your previous history 🪄"
);
return Ok(());
}
let alias_map: HashMap<String, String> = if settings.dotfiles.enabled {
if let Ok(encryption_key) = encryption::load_key(settings) {
let encryption_key: [u8; 32] = encryption_key.into();
let host_id = Settings::host_id().await?;
let alias_store = AliasStore::new(store, host_id, encryption_key);
alias_store
.aliases()
.await
.unwrap_or_default()
.into_iter()
.map(|a| (a.name, a.value))
.collect()
} else {
HashMap::new()
}
} else {
HashMap::new()
};
let stats = compute(settings, &history, 10, 1).expect("Failed to compute stats");
let wrapped_stats = WrappedStats::new(settings, &stats, &history, &alias_map);
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(())
}