use std::collections::{HashMap, HashSet};
use atuin_client::database::Sqlite;
use atuin_client::record::sqlite_store::SqliteStore;
use atuin_client::settings::Settings;
use atuin_client::theme::Theme;
use atuin_history::stats::{Stats, compute, split_common_prefix};
use crossterm::style::{ResetColor, SetAttribute};
use eyre::Result;
use time::{Date, Duration, Month, OffsetDateTime, Time};
use tracing::instrument;
#[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 command = entry.command.trim_start();
let command =
split_common_prefix(settings, command).map_or(command, |(prefix, rest)| {
if rest.is_empty() {
prefix
} else {
rest
}
});
let raw_cmd = command.split_whitespace().next().unwrap_or("");
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!();
}
#[instrument(level = "trace", skip_all, err)]
pub async fn run(
year: Option<i32>,
db: &Sqlite,
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 Some(stats) = compute(settings, &history, 10, 1) else {
println!(
"No commands found in your {year} history. Run a command or check your config for for \
commands ignored by stats.ignored_commands."
);
return Ok(());
};
let alias_map = HashMap::new();
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(())
}
#[cfg(test)]
mod tests {
use atuin_client::history::History;
use rstest::rstest;
use time::macros::datetime;
use super::*;
#[rstest]
#[case::default_prefix(None, "sudo echo hello", "echo")]
#[case::default_doas(None, "doas echo hello", "echo")]
#[case::custom_prefix(Some(vec!["please"]), "please echo hello", "echo")]
#[case::longest_prefix(Some(vec!["sudo", "sudo -u root"]), "sudo -u root echo hello", "echo")]
#[case::empty_prefixes(Some(vec![]), "sudo echo hello", "sudo")]
#[case::unconfigured_prefix(Some(vec!["please"]), "sudo echo hello", "sudo")]
#[case::prefix_only(None, "sudo", "sudo")]
#[case::whitespace(None, " \t sudo \t echo hello", "echo")]
#[case::empty(None, " \t ", "")]
#[case::subcommand(None, "sudo git status", "git")]
#[case::alias(None, "ll /tmp", "ls")]
#[case::prefixed_alias(None, "sudo ll /tmp", "ls")]
#[case::multiword_prefix_only(Some(vec!["sudo -u root"]), "sudo -u root ", "sudo")]
#[case::one_removal(None, "sudo doas echo hello", "doas")]
#[case::partial_word(None, "sudoedit file", "edit")]
fn evolution_respects_common_prefix(
#[case] prefixes: Option<Vec<&str>>,
#[case] command: &str,
#[case] expected: &str,
) {
let mut settings = Settings::utc();
if let Some(prefixes) = prefixes {
settings.stats.common_prefix = prefixes.into_iter().map(str::to_owned).collect();
}
let history: Vec<History> =
[datetime!(2024-01-01 0:00 UTC), datetime!(2024-10-01 0:00 UTC)]
.into_iter()
.flat_map(|timestamp| {
[(command, 0), (expected, 1)].map(|(command, exit)| {
History::import()
.timestamp(timestamp)
.command(command)
.exit(exit)
.build()
.into()
})
})
.collect();
let stats = Stats {
total_commands: 4,
unique_commands: 2,
top: vec![],
};
let aliases = HashMap::from([("ll".to_string(), "ls -l".to_string())]);
let wrapped = WrappedStats::new(&settings, &stats, &history, &aliases);
assert_eq!(wrapped.first_half_commands, vec![(expected.to_string(), 2)]);
assert_eq!(wrapped.second_half_commands, vec![(expected.to_string(), 2)]);
assert!((wrapped.error_rate - 0.5).abs() < f64::EPSILON);
}
}