use std::fmt::{self, Display};
use std::io::{self, IsTerminal, Write};
use std::time::Duration;
use atuin_client::database::{Sqlite, current_context};
use atuin_client::history::store::HistoryStore;
use atuin_client::history::{AuthorKind, History, HistoryId, probe_author};
#[cfg(feature = "sync")]
use atuin_client::record;
use atuin_client::record::sqlite_store::SqliteStore;
use atuin_client::settings::FilterMode::{Directory, Global, Session};
use atuin_client::settings::Settings;
use atuin_common::encryption::paseto_v4;
use atuin_common::logs::LogConfig;
use atuin_common::string::{EscapeNonPrintablePosixExt as _, NonNulStr};
use atuin_common::time::{DurationExt, OffsetDateTimeExt, UtcOffsetSpec};
use atuin_common::utils;
use atuin_common::utils::normalize_optional_string;
#[cfg(feature = "daemon")]
use atuin_daemon::grpc::history::pb::{
TailHistoryReply, tail_history_reply::Event as TailEventProto,
};
use atuin_domain::record::CmdOrigin;
use clap::Subcommand;
#[cfg(feature = "daemon")]
use colored::Colorize;
use eyre::{Context, Result, bail};
use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt};
#[cfg(feature = "daemon")]
use serde::Serialize;
use time::OffsetDateTime;
use tracing::{debug, instrument, warn};
#[cfg(feature = "daemon")]
use super::daemon;
#[derive(Subcommand, Debug)]
#[command(infer_subcommands = true)]
pub enum Cmd {
Start {
#[arg(long = "command-from-env", hide = true)]
cmd_env: bool,
#[arg(long)]
author: Option<String>,
#[arg(long, value_enum)]
author_kind: Option<AuthorKind>,
#[arg(long)]
intent: Option<String>,
command: Vec<String>,
#[arg(long, hide = true)]
hook: bool,
},
End {
id: HistoryId,
#[arg(long, short)]
exit: i64,
#[arg(long, short)]
duration: Option<u64>,
#[arg(long, hide = true)]
hook: bool,
},
Tail,
List {
#[arg(long, short)]
cwd: bool,
#[arg(long, short)]
session: bool,
#[arg(long)]
human: bool,
#[arg(long)]
cmd_only: bool,
#[arg(long)]
print0: bool,
#[arg(long, short, default_value = "true")]
#[arg(num_args(0..=1), default_missing_value("true"))]
#[arg(action = clap::ArgAction::Set)]
reverse: bool,
#[arg(long, visible_alias = "tz", verbatim_doc_comment)]
timezone: Option<UtcOffsetSpec>,
#[arg(long, short)]
format: Option<String>,
},
Last {
#[arg(long)]
human: bool,
#[arg(long)]
cmd_only: bool,
#[arg(long, visible_alias = "tz", verbatim_doc_comment)]
timezone: Option<UtcOffsetSpec>,
#[arg(long, short)]
format: Option<String>,
},
InitStore,
Prune {
#[arg(short = 'n', long)]
dry_run: bool,
},
Dedup {
#[arg(short = 'n', long)]
dry_run: bool,
#[arg(long, short)]
before: String,
#[arg(long)]
dupkeep: u32,
},
}
#[derive(Clone, Copy, Debug)]
pub enum ListMode {
Human,
CmdOnly,
Regular,
}
impl ListMode {
pub const fn from_flags(human: bool, cmd_only: bool) -> Self {
if human {
ListMode::Human
} else if cmd_only {
ListMode::CmdOnly
} else {
ListMode::Regular
}
}
}
#[instrument(level = "trace", skip_all, fields(count = h.len()))]
pub fn print_list(
h: &[History],
list_mode: ListMode,
format: Option<&str>,
print0: bool,
reverse: bool,
tz: UtcOffsetSpec,
) {
let w = std::io::stdout();
let mut w = w.lock();
let fmt_str = match list_mode {
ListMode::Human => format.unwrap_or("{time} · {duration}\t{command}").replace("\\t", "\t"),
ListMode::Regular => format.unwrap_or("{time}\t{command}\t{duration}").replace("\\t", "\t"),
ListMode::CmdOnly => String::new(),
};
let parsed_fmt = match list_mode {
ListMode::Human | ListMode::Regular => parse_fmt(&fmt_str),
ListMode::CmdOnly => std::iter::once(ParseSegment::Key("command")).collect(),
};
let iterator = if reverse {
Box::new(h.iter().rev()) as Box<dyn Iterator<Item = &History>>
} else {
Box::new(h.iter()) as Box<dyn Iterator<Item = &History>>
};
let entry_terminator = if print0 {
"\0"
} else {
"\n"
};
let flush_each_line = print0;
for history in iterator {
let fh = FmtHistory {
history,
cmd_format: CmdFormat::for_output(&w),
tz: &tz,
};
let args = parsed_fmt.with_args(&fh);
if let Err(err) = args.status() {
eprintln!("ERROR: history output failed with: {err}");
std::process::exit(1);
}
let write_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
write!(w, "{args}{entry_terminator}")
}));
match write_result {
Ok(Ok(())) => {
}
Ok(Err(err)) => {
if err.kind() != io::ErrorKind::BrokenPipe {
eprintln!("ERROR: Failed to write history output: {err}");
std::process::exit(1);
}
}
Err(_) => {
eprintln!("ERROR: Format string caused a formatting error.");
eprintln!(
"This may be due to an unsupported format string containing special \
characters."
);
eprintln!(
"Please check your format string syntax and ensure literal braces are \
properly escaped."
);
std::process::exit(1);
}
}
if flush_each_line {
check_for_write_errors(w.flush());
}
}
if !flush_each_line {
check_for_write_errors(w.flush());
}
}
fn check_for_write_errors(write: Result<(), io::Error>) {
if let Err(err) = write {
if err.kind() != io::ErrorKind::BrokenPipe {
eprintln!("ERROR: History output failed with the following error: {err}");
std::process::exit(1);
}
}
}
#[derive(Clone, Copy, Debug)]
struct FmtHistory<'a> {
history: &'a History,
cmd_format: CmdFormat,
tz: &'a UtcOffsetSpec,
}
#[derive(Clone, Copy, Debug)]
enum CmdFormat {
Literal,
Escaped,
}
impl CmdFormat {
fn for_output<O: IsTerminal>(out: &O) -> Self {
if out.is_terminal() {
Self::Escaped
} else {
Self::Literal
}
}
}
impl FormatKey for FmtHistory<'_> {
fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> {
match key {
"command" => match self.cmd_format {
CmdFormat::Literal => f.write_str(self.history.command.trim()),
CmdFormat::Escaped => {
f.write_str(&self.history.command.trim().escape_non_printable())
}
}?,
"directory" => f.write_str(self.history.cwd.trim())?,
"exit" => f.write_str(&self.history.exit.to_string())?,
"duration" => {
let dur = Duration::saturating_from_nanos_i64(self.history.duration);
write!(f, "{}", dur.display().largest_unit())?;
}
"time" => {
self.history.timestamp.to_offset(self.tz.0).display().ymd_hms().fmt(f)?;
}
"relativetime" => {
let d = OffsetDateTime::now_utc().saturating_duration_since(self.history.timestamp);
write!(f, "{}", d.display().largest_unit())?;
}
"host" => f.write_str(self.history.cmd_origin.host().into_inner())?,
"author" => f.write_str(&self.history.author)?,
"intent" => f.write_str(self.history.intent.as_deref().unwrap_or_default())?,
"user" => f.write_str(self.history.cmd_origin.user().into_inner())?,
"session" => f.write_str(&self.history.session)?,
"uuid" => write!(f, "{}", self.history.id)?,
_ => return Err(FormatKeyError::UnknownKey),
}
Ok(())
}
}
fn parse_fmt(format: &str) -> ParsedFmt<'_> {
match ParsedFmt::new(format) {
Ok(fmt) => fmt,
Err(err) => {
eprintln!("ERROR: History formatting failed with the following error: {err}");
if format.contains('"') && (format.contains(":{") || format.contains(",{")) {
eprintln!("It looks like you're trying to create JSON output.");
eprintln!("For JSON, you need to escape literal braces by doubling them:");
eprintln!("Example: '{{\"command\":\"{{command}}\",\"time\":\"{{time}}\"}}'");
} else {
eprintln!(
"If your formatting string contains literal curly braces, you need to escape \
them by doubling:"
);
eprintln!("Use {{{{ for literal {{ and }}}} for literal }}");
}
std::process::exit(1)
}
}
}
fn normalize_command_for_storage<'a>(command: &'a str, settings: &Settings) -> &'a str {
if !settings.strip_trailing_whitespace {
return command;
}
let trimmed = command.trim_end_matches([' ', '\t']);
if trimmed.len() == command.len() {
return command;
}
let trailing_backslashes =
trimmed.as_bytes().iter().rev().take_while(|&&byte| byte == b'\\').count();
if trailing_backslashes % 2 == 1 {
command
} else {
trimmed
}
}
fn make_starting_history(
settings: &Settings,
command: &str,
author: Option<&str>,
author_kind: Option<AuthorKind>,
intent: Option<&str>,
) -> Option<History> {
let cwd = utils::get_current_dir();
let command = normalize_command_for_storage(command, settings);
if let Err(err) = NonNulStr::new(command) {
debug!("dropping command containing a NUL byte at index {}", err.index);
return None;
}
let author = normalize_optional_string(author.map(String::from)).or_else(probe_author);
let author_kind =
author_kind.or_else(|| author.is_some().then(AuthorKind::probe_current).flatten());
let h: History = History::capture()
.timestamp(OffsetDateTime::now_utc())
.command(command)
.cwd(cwd)
.author_opt(author)
.author_kind_opt(author_kind)
.intent_opt(intent.map(String::from))
.shell_opt(std::env::var("ATUIN_SHELL").ok())
.build()
.into();
h.should_save(settings).then_some(h)
}
#[instrument(level = "trace", skip_all, err)]
async fn handle_start(
db: &Sqlite,
settings: &Settings,
command: &str,
author: Option<&str>,
author_kind: Option<AuthorKind>,
intent: Option<&str>,
) -> Result<Option<HistoryId>> {
let Some(h) = make_starting_history(settings, command, author, author_kind, intent) else {
return Ok(None);
};
if let Err(e) = db.save(&h).await {
debug!("failed to save history: {e}");
}
Ok(Some(h.id))
}
#[cfg(feature = "daemon")]
#[instrument(level = "trace", skip_all, err)]
async fn handle_daemon_start(
settings: &Settings,
command: &str,
author: Option<&str>,
author_kind: Option<AuthorKind>,
intent: Option<&str>,
) -> Result<Option<HistoryId>> {
let Some(h) = make_starting_history(settings, command, author, author_kind, intent) else {
return Ok(None);
};
let local_id = h.id;
let resp = match daemon::start_history(settings, h).await {
Ok(id) => id,
Err(e) => {
debug!("failed to start history via daemon: {e}");
local_id
}
};
Ok(Some(resp))
}
#[allow(unused_variables)]
#[instrument(level = "trace", skip_all, fields(id = %id, exit, duration = ?duration), err)]
async fn handle_end(
db: &Sqlite,
store: SqliteStore,
history_store: HistoryStore,
settings: &Settings,
id: HistoryId,
exit: i64,
duration: Option<u64>,
) -> Result<()> {
let Some(mut h) = db.load(id).await? else {
warn!("history entry is missing");
return Ok(());
};
if h.duration > 0 {
debug!("cannot end history - already has duration");
return Ok(());
}
if !settings.store_failed && exit > 0 {
debug!("history has non-zero exit code, and store_failed is false");
db.delete(h).await?;
return Ok(());
}
h.exit = exit;
h.duration = match duration {
Some(value) => i64::try_from(value).context("command took over 292 years")?,
None => i64::try_from((OffsetDateTime::now_utc() - h.timestamp).whole_nanoseconds())
.context("command took over 292 years")?,
};
db.update(&h).await?;
history_store.push(h).await?;
if settings.should_sync().await? {
#[cfg(feature = "sync")]
{
let engine = record::sync::SyncEngine::builder()
.store(store.clone())
.client_source(record::sync::ClientSource::FromSettings {
settings,
caps: None,
})
.build()
.connect()
.await?;
let (_, downloaded) = engine.keyed(&history_store.encryption_key).sync().await?;
Settings::save_sync_time().await?;
crate::sync::build(settings, &store, db, Some(&downloaded)).await?;
}
#[cfg(not(feature = "sync"))]
debug!("not compiled with sync support");
} else {
debug!("sync disabled! not syncing");
}
Ok(())
}
#[cfg(feature = "daemon")]
#[instrument(level = "trace", skip_all, fields(id = %id, exit, duration = ?duration), err)]
async fn handle_daemon_end(
settings: &Settings,
id: HistoryId,
exit: i64,
duration: Option<u64>,
) -> Result<()> {
if !settings.store_failed && exit > 0 {
debug!("history has non-zero exit code, and store_failed is false");
daemon::cancel_history(settings, id).await?;
} else {
let duration = duration.map(Duration::from_nanos);
daemon::end_history(settings, id, duration, exit).await?;
}
Ok(())
}
#[instrument(level = "trace", skip_all, err)]
pub(super) async fn start_history_entry(
settings: &Settings,
command: &str,
author: Option<&str>,
author_kind: Option<AuthorKind>,
intent: Option<&str>,
) -> Result<Option<HistoryId>> {
#[cfg(feature = "daemon")]
if settings.daemon.enabled {
return handle_daemon_start(settings, command, author, author_kind, intent).await;
}
let db_path = &settings.db_path;
let db = Sqlite::new(db_path, Duration::try_from_secs_f64(settings.local_timeout)?).await?;
handle_start(&db, settings, command, author, author_kind, intent).await
}
#[instrument(level = "trace", skip_all, fields(id = %id, exit, duration = ?duration), err)]
pub(super) async fn end_history_entry(
settings: &Settings,
id: HistoryId,
exit: i64,
duration: Option<u64>,
) -> Result<()> {
#[cfg(feature = "daemon")]
if settings.daemon.enabled {
return handle_daemon_end(settings, id, exit, duration).await;
}
let db_path = &settings.db_path;
let record_store_path = &settings.record_store_path;
let db = Sqlite::new(db_path, Duration::try_from_secs_f64(settings.local_timeout)?).await?;
let store =
SqliteStore::new(record_store_path, Duration::try_from_secs_f64(settings.local_timeout)?)
.await?;
let encryption_key = paseto_v4::Key::try_load_or_generate(&settings.key_path)
.context("could not load or generate encryption key")?;
let host_id = Settings::host_id().await?;
let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
handle_end(&db, store, history_store, settings, id, exit, duration).await
}
#[cfg_attr(not(feature = "daemon"), allow(unused_variables))]
pub(super) async fn delete_history_entries(
settings: &Settings,
history_store: &HistoryStore,
db: &Sqlite,
entries: impl IntoIterator<Item = History>,
) -> Result<()> {
#[cfg(feature = "daemon")]
if settings.daemon.enabled {
let ids: Vec<HistoryId> = entries.into_iter().map(|h| h.id).collect();
daemon::delete_history(settings, ids).await?;
return Ok(());
}
let ids = history_store.delete_entries(entries).await?;
history_store.build_all(db, &ids).await?;
Ok(())
}
#[cfg(feature = "daemon")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TailKind {
Started,
Ended,
Cancelled,
}
#[cfg(feature = "daemon")]
#[derive(Clone, Debug, Eq, PartialEq)]
struct TailEvent {
kind: TailKind,
history: History,
}
#[cfg(feature = "daemon")]
#[derive(Serialize)]
struct TailJsonEvent<'a> {
event: &'static str,
history: TailJsonHistory<'a>,
}
#[cfg(feature = "daemon")]
#[derive(Serialize)]
struct TailJsonHistory<'a> {
id: HistoryId,
timestamp: String,
timestamp_unix_ns: u64,
command: &'a str,
cwd: &'a str,
session: &'a str,
hostname: &'a str,
host: &'a str,
user: &'a str,
author: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
intent: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
exit: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_ns: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
duration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
success: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
finished_at: Option<String>,
}
#[cfg(feature = "daemon")]
impl TailEvent {
fn from_proto(reply: TailHistoryReply) -> Result<Self> {
let (kind, history) = match reply.event {
Some(TailEventProto::Started(history)) => (TailKind::Started, history),
Some(TailEventProto::Ended(history)) => (TailKind::Ended, history),
Some(TailEventProto::Cancelled(history)) => (TailKind::Cancelled, history),
Some(TailEventProto::Lagged(_)) => {
bail!("daemon sent a lag notice as a history event")
}
None => bail!("daemon sent an unspecified history tail event"),
};
let timestamp = OffsetDateTime::from_unix_nanos_i64(history.timestamp);
let author_kind = history.author_kind();
Ok(Self {
kind,
history: History {
id: history
.id
.ok_or_else(|| eyre::eyre!("daemon tail event is missing the history id"))?
.try_into()?,
timestamp,
duration: history.duration,
exit: history.exit,
command: history.command,
cwd: history.cwd,
session: history.session,
#[allow(deprecated)]
cmd_origin: CmdOrigin::parse_lenient(history.hostname),
author: history.author,
intent: normalize_optional_string(history.intent),
shell: normalize_optional_string(history.shell),
deleted_at: None,
author_kind: author_kind.into(),
},
})
}
fn render(&self, tty: bool, tz: UtcOffsetSpec) -> Result<String> {
if tty {
Ok(self.render_pretty(tz))
} else {
let mut json = self.render_json(tz)?;
json.push('\n');
Ok(json)
}
}
fn render_json(&self, tz: UtcOffsetSpec) -> Result<String> {
let payload = TailJsonEvent {
event: self.kind.as_str(),
history: TailJsonHistory {
id: self.history.id,
timestamp: self.history.timestamp.to_offset(tz.0).display().ymd_hms().to_string(),
timestamp_unix_ns: u64::try_from(self.history.timestamp.unix_timestamp_nanos())
.context("history timestamp predates unix epoch")?,
command: &self.history.command,
cwd: &self.history.cwd,
session: &self.history.session,
hostname: self.history.cmd_origin.as_str(),
host: self.host(),
user: self.user(),
author: &self.history.author,
intent: self.history.intent.as_deref(),
exit: self.exit_value(),
duration_ns: self.duration_value(),
duration: self.duration_value().map(|d| {
Duration::saturating_from_nanos_i64(d).display().largest_unit().to_string()
}),
success: self.success_value(),
finished_at: self
.finished_at()
.map(|time| time.to_offset(tz.0).display().ymd_hms().to_string()),
},
};
Ok(serde_json::to_string(&payload)?)
}
fn render_pretty(&self, tz: UtcOffsetSpec) -> String {
let mut out = String::new();
let border = match self.kind {
TailKind::Started => "-".repeat(72).bright_blue().to_string(),
TailKind::Ended if self.history.exit == 0 => "-".repeat(72).bright_green().to_string(),
TailKind::Ended => "-".repeat(72).bright_red().to_string(),
TailKind::Cancelled => "-".repeat(72).bright_yellow().to_string(),
};
out.push_str(&border);
out.push('\n');
let command = self.history.command.trim();
let escaped_command = command.escape_non_printable();
let mut command_lines = escaped_command.lines();
let header = format!(
"{} {}",
self.kind.badge(self.history.exit),
command_lines.next().unwrap_or_default().bold()
);
out.push_str(&header);
out.push('\n');
for line in command_lines {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
push_pretty_field(
&mut out,
"start",
&self.history.timestamp.to_offset(tz.0).display().ymd_hms().to_string(),
);
push_pretty_field(&mut out, "history", &self.history.id.to_string());
push_pretty_field(&mut out, "session", &self.history.session);
push_pretty_field(&mut out, "exit", &self.exit_display());
push_pretty_field(&mut out, "duration", &self.duration_display());
out.push('\n');
push_pretty_field(&mut out, "cwd", &self.history.cwd);
push_pretty_field(&mut out, "hostname", self.history.cmd_origin.as_str());
push_pretty_field(&mut out, "host", self.host());
push_pretty_field(&mut out, "user", self.user());
push_pretty_field(&mut out, "author", &self.history.author);
if let Some(intent) = self.history.intent.as_deref() {
push_pretty_field(&mut out, "intent", intent);
}
if let Some(finished) = self.finished_at() {
let finished = finished.to_offset(tz.0).display().ymd_hms().to_string();
push_pretty_field(&mut out, "finished", &finished);
}
out.push_str(&border);
out.push_str("\n\n");
out
}
fn host(&self) -> &str {
self.history.cmd_origin.host().into_inner()
}
fn user(&self) -> &str {
self.history.cmd_origin.user().into_inner()
}
fn exit_value(&self) -> Option<i64> {
matches!(self.kind, TailKind::Ended).then_some(self.history.exit)
}
fn duration_value(&self) -> Option<i64> {
matches!(self.kind, TailKind::Ended).then_some(self.history.duration)
}
fn success_value(&self) -> Option<bool> {
matches!(self.kind, TailKind::Ended).then_some(self.history.exit == 0)
}
fn finished_at(&self) -> Option<OffsetDateTime> {
self.duration_value()
.filter(|duration| *duration >= 0)
.map(time::Duration::nanoseconds)
.and_then(|duration| self.history.timestamp.checked_add(duration))
}
fn exit_display(&self) -> String {
match self.exit_value() {
Some(0) => "0 (success)".bright_green().to_string(),
Some(code) => format!("{code} (failure)").bright_red().to_string(),
None => "pending".bright_yellow().to_string(),
}
}
fn duration_display(&self) -> String {
match self.duration_value() {
Some(duration) if duration >= 0 => {
Duration::saturating_from_nanos_i64(duration).display().largest_unit().to_string()
}
Some(_) => "unknown".bright_yellow().to_string(),
None => "running".bright_yellow().to_string(),
}
}
}
#[cfg(feature = "daemon")]
impl TailKind {
const fn as_str(self) -> &'static str {
match self {
Self::Started => "started",
Self::Ended => "ended",
Self::Cancelled => "cancelled",
}
}
fn badge(self, exit: i64) -> colored::ColoredString {
match self {
Self::Started => "STARTED".bold().bright_blue(),
Self::Ended if exit == 0 => "ENDED".bold().bright_green(),
Self::Ended => "ENDED".bold().bright_red(),
Self::Cancelled => "CANCELLED".bold().bright_yellow(),
}
}
}
#[cfg(feature = "daemon")]
fn push_pretty_field(out: &mut String, label: &str, value: &str) {
out.push_str(" ");
let label = format!("{label}:");
out.push_str(&label.bright_cyan().bold().to_string());
if label.len() < 10 {
out.push_str(&" ".repeat(10 - label.len()));
}
let mut lines = value.lines();
if let Some(first) = lines.next() {
out.push_str(first);
}
out.push('\n');
for line in lines {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
impl Cmd {
#[cfg(feature = "daemon")]
#[instrument(level = "trace", skip_all, err)]
async fn handle_tail(settings: &Settings) -> Result<()> {
let tty = std::io::stdout().is_terminal();
let mut client = daemon::ready_client(settings).await?;
let mut stream = client.tail_history().await?;
let stdout = std::io::stdout();
while let Some(reply) = stream.message().await? {
if let Some(TailEventProto::Lagged(lagged)) = &reply.event {
eprintln!(
"WARNING: atuin daemon dropped {} history events; tail fell behind",
lagged.dropped
);
continue;
}
let event = TailEvent::from_proto(reply)?;
let rendered = event.render(tty, settings.timezone)?;
let mut out = stdout.lock();
match out.write_all(rendered.as_bytes()) {
Ok(()) => out.flush()?,
Err(err) if err.kind() == io::ErrorKind::BrokenPipe => break,
Err(err) => return Err(err.into()),
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)]
#[allow(clippy::fn_params_excessive_bools)]
#[instrument(level = "trace", skip_all, err)]
async fn handle_list(
db: &Sqlite,
settings: &Settings,
context: atuin_client::database::Context,
session: bool,
cwd: bool,
mode: ListMode,
format: Option<String>,
include_deleted: bool,
print0: bool,
reverse: bool,
tz: UtcOffsetSpec,
) -> Result<()> {
let filters = match (session, cwd) {
(true, true) => [Session, Directory],
(true, false) => [Session, Global],
(false, true) => [Global, Directory],
(false, false) => [settings.default_filter_mode(context.git_root.is_some()), Global],
};
let history = db.list(filters, &context, None, false, include_deleted, None).await?;
print_list(
&history,
mode,
match format {
None => Some(settings.history_format.as_str()),
_ => format.as_deref(),
},
print0,
reverse,
tz,
);
Ok(())
}
#[instrument(level = "trace", skip_all, fields(dry_run), err)]
async fn handle_prune(
db: &Sqlite,
settings: &Settings,
store: SqliteStore,
context: atuin_client::database::Context,
dry_run: bool,
) -> Result<()> {
let matches: Vec<History> = db
.list([Global], &context, None, false, false, None)
.await?
.into_iter()
.filter(|h| !h.should_save(settings))
.collect();
match matches.len() {
0 => {
println!("No entries to prune.");
return Ok(());
}
1 => println!("Found 1 entry to prune."),
n => println!("Found {n} entries to prune."),
}
if dry_run {
print_list(
&matches,
ListMode::Human,
Some(settings.history_format.as_str()),
false,
false,
settings.timezone,
);
} else {
let encryption_key = paseto_v4::Key::try_load_or_generate(&settings.key_path)
.context("could not load or generate encryption key")?;
let host_id = Settings::host_id().await?;
let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
for entry in &matches {
eprintln!("deleting {}", entry.id);
}
delete_history_entries(settings, &history_store, db, matches).await?;
}
Ok(())
}
#[instrument(level = "trace", skip_all, fields(dry_run, dupkeep), err)]
async fn handle_dedup(
db: &Sqlite,
settings: &Settings,
store: SqliteStore,
before: i64,
dupkeep: u32,
dry_run: bool,
) -> Result<()> {
if dupkeep == 0 {
eprintln!(
"\"--dupkeep 0\" would keep 0 copies of duplicate commands and thus delete all of \
them! Use \"atuin search --delete ...\" if you really want that."
);
std::process::exit(1);
}
let matches: Vec<History> = db.get_dups(before, dupkeep).await?;
match matches.len() {
0 => {
println!("No duplicates to delete.");
return Ok(());
}
1 => println!("Found 1 duplicate to delete."),
n => println!("Found {n} duplicates to delete."),
}
if dry_run {
print_list(
&matches,
ListMode::Human,
Some(settings.history_format.as_str()),
false,
false,
settings.timezone,
);
} else {
let encryption_key = paseto_v4::Key::try_load_or_generate(&settings.key_path)
.context("could not load or generate encryption key")?;
let host_id = Settings::host_id().await?;
let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
for entry in &matches {
eprintln!("deleting {}", entry.id);
}
delete_history_entries(settings, &history_store, db, matches).await?;
}
Ok(())
}
#[allow(clippy::too_many_lines)]
#[instrument(level = "trace", skip_all, err)]
pub async fn run(self, settings: &Settings) -> Result<()> {
match self {
Self::Start {
cmd_env,
author,
author_kind,
intent,
command,
..
} => {
let command = if cmd_env {
std::env::var("ATUIN_COMMAND_LINE").unwrap_or_default()
} else {
command.join(" ")
};
if let Some(id) = start_history_entry(
settings,
&command,
author.as_deref(),
author_kind,
intent.as_deref(),
)
.await?
{
println!("{id}");
}
Ok(())
}
Self::End {
id, exit, duration, ..
} => end_history_entry(settings, id, exit, duration).await,
Self::Tail => {
#[cfg(feature = "daemon")]
{
return Self::handle_tail(settings).await;
}
#[cfg(not(feature = "daemon"))]
bail!("`atuin history tail` requires Atuin to be built with the `daemon` feature");
}
cmd => {
let context = current_context().await?;
let db_path = &settings.db_path;
let record_store_path = &settings.record_store_path;
let db = Sqlite::new(db_path, Duration::try_from_secs_f64(settings.local_timeout)?)
.await?;
let store = SqliteStore::new(
record_store_path,
Duration::try_from_secs_f64(settings.local_timeout)?,
)
.await?;
let encryption_key = paseto_v4::Key::try_load_or_generate(&settings.key_path)
.context("could not load or generate encryption key")?;
let host_id = Settings::host_id().await?;
let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
match cmd {
Self::List {
session,
cwd,
human,
cmd_only,
print0,
reverse,
timezone,
format,
} => {
let mode = ListMode::from_flags(human, cmd_only);
let tz = timezone.unwrap_or(settings.timezone);
Self::handle_list(
&db, settings, context, session, cwd, mode, format, false, print0,
reverse, tz,
)
.await
}
Self::Last {
human,
cmd_only,
timezone,
format,
} => {
let last = db.last().await?;
let last = last.as_slice();
let tz = timezone.unwrap_or(settings.timezone);
print_list(
last,
ListMode::from_flags(human, cmd_only),
match format {
None => Some(settings.history_format.as_str()),
_ => format.as_deref(),
},
false,
true,
tz,
);
Ok(())
}
Self::InitStore => history_store.init_store(&db).await,
Self::Prune { dry_run } => {
Self::handle_prune(&db, settings, store, context, dry_run).await
}
Self::Dedup {
dry_run,
before,
dupkeep,
} => {
let before = i64::try_from(
interim::parse_date_string(
before.as_str(),
OffsetDateTime::now_utc(),
interim::Dialect::Uk,
)?
.unix_timestamp_nanos(),
)?;
Self::handle_dedup(&db, settings, store, before, dupkeep, dry_run).await
}
Self::Start { .. } | Self::End { .. } | Self::Tail => unreachable!(),
}
}
}
}
fn logs_enabled(&self) -> bool {
match self {
Self::Start { hook, .. } | Self::End { hook, .. } => !*hook,
_ => true,
}
}
pub fn log_config(&self) -> Option<LogConfig> {
self.logs_enabled().then(LogConfig::stderr_only)
}
}
#[cfg(test)]
mod tests {
use rstest::{fixture, rstest};
#[cfg(feature = "daemon")]
use time::macros::datetime;
use super::*;
#[fixture]
async fn db() -> Sqlite {
Sqlite::in_memory(Duration::from_secs(2)).await.unwrap()
}
#[fixture]
fn settings() -> Settings {
Settings::utc()
}
#[rstest]
fn utc_settings_strip_trailing_whitespace_by_default() {
assert!(Settings::utc().strip_trailing_whitespace);
}
#[rstest]
#[case::strips_spaces_and_tabs("ls \t", "ls")]
#[case::preserves_single_escaped_trailing_space("printf foo\\ ", "printf foo\\ ")]
#[case::even_backslashes_still_trim("printf foo\\\\ ", "printf foo\\\\")]
fn normalize_command_cases(settings: Settings, #[case] input: &str, #[case] expected: &str) {
assert_eq!(normalize_command_for_storage(input, &settings), expected);
}
#[rstest]
#[case::strips_by_default(true, "ls")]
#[case::keeps_when_disabled(false, "ls \t")]
#[tokio::test]
async fn handle_start_respects_strip_setting(
#[future] db: Sqlite,
#[case] strip: bool,
#[case] expected: &str,
) {
let db = db.await;
let settings = Settings {
strip_trailing_whitespace: strip,
..Settings::utc()
};
handle_start(&db, &settings, "ls \t", None, None, None).await.unwrap();
let history = db
.before(OffsetDateTime::now_utc() + time::Duration::SECOND, 1)
.await
.unwrap()
.pop()
.unwrap();
assert_eq!(history.command, expected);
}
#[rstest]
#[tokio::test]
async fn handle_start_drops_command_with_nul_byte(#[future] db: Sqlite, settings: Settings) {
let db = db.await;
let id = handle_start(&db, &settings, "hello\0world", None, None, None).await.unwrap();
assert!(id.is_none());
let stored =
db.before(OffsetDateTime::now_utc() + time::Duration::SECOND, 1).await.unwrap();
assert!(stored.is_empty());
}
#[rstest]
#[case::malformed_named_placeholder(r#"{"command":"{command}","key":"value"}"#)]
#[case::single_command("{command}")]
#[case::time_and_command("{time} - {command}")]
fn parse_fmt_never_panics(#[case] fmt: &str) {
assert!(std::panic::catch_unwind(|| parse_fmt(fmt)).is_ok());
}
#[cfg(feature = "daemon")]
#[fixture]
fn tail_event(#[default(TailKind::Ended)] kind: TailKind) -> TailEvent {
TailEvent {
kind,
history: History {
id: "018f011c-9a0a-7000-8000-000000000001".parse().unwrap(),
timestamp: datetime!(2026-04-09 17:18:19 UTC),
duration: 12_345_678,
exit: 0,
command: "git status".to_owned(),
cwd: "/tmp/repo".to_owned(),
session: "session-id".to_owned(),
cmd_origin: CmdOrigin::try_from("host:ellie").unwrap(),
author: "claude".to_owned(),
intent: Some("inspect repository state".to_owned()),
deleted_at: None,
shell: Some("zsh".into()),
author_kind: Some(AuthorKind::Agent),
},
}
}
#[cfg(feature = "daemon")]
#[rstest]
fn test_tail_json_output_contains_history_fields(tail_event: TailEvent) {
let json = tail_event.render(false, UtcOffsetSpec(time::UtcOffset::UTC)).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["event"], "ended");
assert_eq!(value["history"]["id"], "018f011c9a0a70008000000000000001");
assert_eq!(value["history"]["duration_ns"], 12_345_678);
assert_eq!(value["history"]["success"], true);
assert!(value.get("record").is_none());
}
#[cfg(feature = "daemon")]
#[rstest]
fn test_tail_pretty_output_shows_pending_fields_for_started_events(
#[with(TailKind::Started)] tail_event: TailEvent,
) {
let rendered = tail_event.render(true, UtcOffsetSpec(time::UtcOffset::UTC)).unwrap();
let plain = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap().replace_all(&rendered, "");
assert!(plain.contains("STARTED git status"));
assert!(plain.contains("exit:"));
assert!(plain.contains("pending"));
assert!(plain.contains("duration:"));
assert!(plain.contains("running"));
}
#[cfg(feature = "daemon")]
#[rstest]
#[case::daemon_enabled_but_unreachable(true, true, 2)]
#[case::daemon_disabled(false, false, 0)]
#[tokio::test]
async fn delete_routes_through_the_daemon_only_when_enabled(
#[future] db: Sqlite,
#[case] daemon_enabled: bool,
#[case] expect_error: bool,
#[case] rows_left: i64,
) {
use atuin_client::record::sqlite_store::SqliteStore;
use atuin_common::utils::uuid_v7;
use atuin_domain::record::HostId;
let db = db.await;
let tmp = tempfile::tempdir().unwrap();
let store = SqliteStore::in_memory(Duration::from_secs(2)).await.unwrap();
let history_store = HistoryStore::new(store, HostId(uuid_v7()), paseto_v4::Key::generate());
let settings = Settings {
daemon: atuin_client::settings::Daemon {
enabled: daemon_enabled,
autostart: false,
socket_path: Some(tmp.path().join("no-daemon-here.sock")),
..Default::default()
},
..Settings::utc()
};
let entries: Vec<History> = ["echo one", "echo two"]
.into_iter()
.map(|cmd| {
History::capture()
.timestamp(time::OffsetDateTime::now_utc())
.command(cmd)
.cwd("/")
.build()
.into()
})
.collect();
for entry in &entries {
db.save(entry).await.unwrap();
}
let result = delete_history_entries(&settings, &history_store, &db, entries).await;
assert_eq!(result.is_err(), expect_error, "{result:?}");
assert_eq!(db.history_count(false).await.unwrap(), rows_left);
}
}