use std::{ fmt::{self, Display}, io::{self, IsTerminal, Write}, path::PathBuf, time::Duration, }; use crate::atuin_common::utils::{self, Escapable as _}; use clap::Subcommand; use eyre::{Context, Result, bail}; use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; use super::daemon as daemon_cmd; use colored::Colorize; use serde::Serialize; use crate::atuin_daemon::generated::history::{HistoryEventKind, TailHistoryReply}; use crate::atuin_client::{ database::{ClientSqlite, current_context}, encryption, history::{History, store::HistoryStore}, record::sqlite_store::SqliteStore, settings::{ FilterMode::{Directory, Global, Session}, Settings, Timezone, }, }; use log::debug; use time::{OffsetDateTime, macros::format_description}; use super::daemon; use super::search::format_duration_into; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub(crate) enum Cmd { /// Begins a new command in the history Start { /// Collects the command from the `ATUIN_COMMAND_LINE` environment variable, /// which does not need escaping and is more compatible between OS and shells #[arg(long = "command-from-env", hide = true)] cmd_env: bool, /// Author of this command, eg `ellie`, `claude`, or `copilot` #[arg(long)] author: Option, /// Optional intent/rationale for running this command #[arg(long)] intent: Option, command: Vec, }, /// Finishes a new command in the history (adds time, exit code) End { id: String, #[arg(long, short)] exit: i64, #[arg(long, short)] duration: Option, }, /// Stream history events from the daemon as they are received Tail, /// List all items in history List { #[arg(long, short)] cwd: bool, #[arg(long, short)] session: bool, #[arg(long)] human: bool, /// Show only the text of the command #[arg(long)] cmd_only: bool, /// Terminate the output with a null, for better multiline support #[arg(long)] print0: bool, #[arg(long, short, default_value = "true")] // accept no value #[arg(num_args(0..=1), default_missing_value("true"))] // accept a value #[arg(action = clap::ArgAction::Set)] reverse: bool, /// Display the command time in another timezone other than the configured default. /// /// This option takes one of the following kinds of values: /// - the special value "local" (or "l") which refers to the system time zone /// - an offset from UTC (e.g. "+9", "-2:30") #[arg(long, visible_alias = "tz")] timezone: Option, /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {exit}, {time}, {session}, and {uuid} /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] format: Option, }, /// Get the last command ran Last { #[arg(long)] human: bool, /// Show only the text of the command #[arg(long)] cmd_only: bool, /// Display the command time in another timezone other than the configured default. /// /// This option takes one of the following kinds of values: /// - the special value "local" (or "l") which refers to the system time zone /// - an offset from UTC (e.g. "+9", "-2:30") #[arg(long, visible_alias = "tz")] timezone: Option, /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {time}, {session}, {uuid} and {relativetime}. /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] format: Option, }, InitStore, /// Delete history entries matching the configured exclusion filters Prune { /// List matching history lines without performing the actual deletion. #[arg(short = 'n', long)] dry_run: bool, }, /// Delete duplicate history entries (that have the same command, cwd and hostname) Dedup { /// List matching history lines without performing the actual deletion. #[arg(short = 'n', long)] dry_run: bool, /// Only delete results added before this date #[arg(long, short)] before: String, /// How many recent duplicates to keep #[arg(long)] dupkeep: u32, }, } #[derive(Clone, Copy, Debug)] pub(crate) enum ListMode { Human, CmdOnly, Regular, } impl ListMode { pub(crate) const fn from_flags(human: bool, cmd_only: bool) -> Self { if human { Self::Human } else if cmd_only { Self::CmdOnly } else { Self::Regular } } } pub(crate) fn print_list( h: &[History], list_mode: ListMode, format: Option<&str>, print0: bool, reverse: bool, tz: Timezone, ) { let w = 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"), // not used 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(), }; #[allow(trivial_casts)] let iterator = if reverse { Box::new(h.iter().rev()) as Box> } else { Box::new(h.iter()) as Box> }; 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); // Check for formatting errors before attempting to write 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(())) => { // Write succeeded } 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 { // Ignore broken pipe (issue #626) if err.kind() != io::ErrorKind::BrokenPipe { eprintln!("ERROR: History output failed with the following error: {err}"); std::process::exit(1); } } } /// Type wrapper around `History` with formatting settings. #[derive(Clone, Copy, Debug)] struct FmtHistory<'a> { history: &'a History, cmd_format: CmdFormat, tz: &'a Timezone, } #[derive(Clone, Copy, Debug)] enum CmdFormat { Literal, Escaped, } impl CmdFormat { fn for_output(out: &O) -> Self { if out.is_terminal() { Self::Escaped } else { Self::Literal } } } static TIME_FMT: &[time::format_description::FormatItem<'static>] = format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]"); /// defines how to format the history impl FormatKey for FmtHistory<'_> { #[expect(clippy::cast_sign_loss)] 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_control()), }?, "directory" => f.write_str(self.history.cwd.trim())?, "exit" => f.write_str(&self.history.exit.to_string())?, "duration" => { let dur = Duration::from_nanos(std::cmp::max(self.history.duration, 0) as u64); format_duration_into(dur, f)?; } "time" => { self.history .timestamp .to_offset(self.tz.0) .format(TIME_FMT) .map_err(|_| fmt::Error)? .fmt(f)?; } "relativetime" => { let since = OffsetDateTime::now_utc() - self.history.timestamp; let d = Duration::try_from(since).unwrap_or_default(); format_duration_into(d, f)?; } "host" => f.write_str( self.history .hostname .split_once(':') .map_or(&self.history.hostname, |(host, _)| host), )?, "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 .hostname .split_once(':') .map_or("", |(_, user)| user), )?, "session" => f.write_str(&self.history.session)?, "uuid" => f.write_str(&self.history.id.0)?, _ => 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 apply_start_metadata(history: &mut History, author: Option<&str>, intent: Option<&str>) { if let Some(author) = author.map(str::trim).filter(|author| !author.is_empty()) { author.clone_into(&mut history.author); } if let Some(intent) = intent.map(str::trim).filter(|intent| !intent.is_empty()) { history.intent = Some(intent.to_owned()); } else if intent.is_some() { history.intent = None; } } 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 } } async fn handle_daemon_start( settings: &Settings, command: &str, author: Option<&str>, intent: Option<&str>, ) -> Result> { // It's better for atuin to silently fail here and attempt to // store whatever is ran, than to throw an error to the terminal let cwd = utils::get_current_dir(); let command = normalize_command_for_storage(command, settings); let mut h: History = History::capture() .timestamp(OffsetDateTime::now_utc()) .command(command) .cwd(cwd) .build() .into(); apply_start_metadata(&mut h, author, intent); if !h.should_save(settings) { return Ok(None); } // Attempt to start history via daemon, but silently ignore errors // to avoid breaking the shell when the daemon is unavailable or disk is full let resp = match daemon::start_history(settings, h.clone()).await { Ok(id) => id, Err(e) => { debug!("failed to start history via daemon: {e}"); h.id.0.clone() } }; Ok(Some(resp)) } async fn handle_daemon_end( settings: &Settings, id: &str, exit: i64, duration: Option, ) -> Result<()> { daemon::end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?; Ok(()) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum TailKind { Started, Ended, } #[derive(Clone, Debug, Eq, PartialEq)] struct TailEvent { kind: TailKind, history: History, } #[derive(Serialize)] struct TailJsonEvent<'a> { event: &'static str, history: TailJsonHistory<'a>, } #[derive(Serialize)] struct TailJsonHistory<'a> { id: &'a str, 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, #[serde(skip_serializing_if = "Option::is_none")] duration_ns: Option, #[serde(skip_serializing_if = "Option::is_none")] duration: Option, #[serde(skip_serializing_if = "Option::is_none")] success: Option, #[serde(skip_serializing_if = "Option::is_none")] finished_at: Option, } impl TailEvent { fn from_proto(reply: TailHistoryReply) -> Result { let history = reply .history .ok_or_else(|| eyre::eyre!("daemon sent a history tail event without history"))?; let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(history.timestamp)) .context("invalid daemon history timestamp")?; let kind = match HistoryEventKind::try_from(reply.kind) .unwrap_or(HistoryEventKind::Unspecified) { HistoryEventKind::Started => TailKind::Started, HistoryEventKind::Ended => TailKind::Ended, HistoryEventKind::Unspecified => bail!("daemon sent an unspecified history tail event"), }; Ok(Self { kind, history: History { id: history.id.into(), timestamp, duration: history.duration, exit: history.exit, command: history.command, cwd: history.cwd, session: history.session, hostname: history.hostname, author: history.author, intent: normalize_optional_field(&history.intent), deleted_at: None, }, }) } fn render(&self, tty: bool, tz: Timezone) -> Result { 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: Timezone) -> Result { let payload = TailJsonEvent { event: self.kind.as_str(), history: TailJsonHistory { id: &self.history.id.0, timestamp: format_history_time(self.history.timestamp, tz)?, 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.hostname, 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(format_duration_ns), success: self.success_value(), finished_at: self .finished_at() .map(|time| format_history_time(time, tz)) .transpose()?, }, }; Ok(serde_json::to_string(&payload)?) } fn render_pretty(&self, tz: Timezone) -> 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(), }; out.push_str(&border); out.push('\n'); let command = self.history.command.trim(); let escaped_command = command.escape_control(); 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", &format_history_time(self.history.timestamp, tz) .unwrap_or_else(|_| "invalid".to_owned()), ); push_pretty_field(&mut out, "history", &self.history.id.0); 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.hostname); 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 = format_history_time(finished, tz).unwrap_or_else(|_| "invalid".to_owned()); push_pretty_field(&mut out, "finished", &finished); } out.push_str(&border); out.push_str("\n\n"); out } fn host(&self) -> &str { self.history .hostname .split_once(':') .map_or(self.history.hostname.as_str(), |(host, _)| host) } fn user(&self) -> &str { self.history .hostname .split_once(':') .map_or("", |(_, user)| user) } fn exit_value(&self) -> Option { matches!(self.kind, TailKind::Ended).then_some(self.history.exit) } fn duration_value(&self) -> Option { matches!(self.kind, TailKind::Ended).then_some(self.history.duration) } fn success_value(&self) -> Option { matches!(self.kind, TailKind::Ended).then_some(self.history.exit == 0) } fn finished_at(&self) -> Option { 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 => format_duration_ns(duration), Some(_) => "unknown".bright_yellow().to_string(), None => "running".bright_yellow().to_string(), } } } impl TailKind { const fn as_str(self) -> &'static str { match self { Self::Started => "started", Self::Ended => "ended", } } 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(), } } } fn format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result { Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?) } fn format_duration_ns(duration_ns: i64) -> String { struct F(Duration); impl Display for F { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { format_duration_into(self.0, f) } } F(Duration::from_nanos(duration_ns.max(0).cast_unsigned())).to_string() } 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'); } } fn normalize_optional_field(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } } impl Cmd { async fn handle_tail(settings: &Settings) -> Result<()> { let tty = io::stdout().is_terminal(); let mut client = daemon::tail_client(settings).await?; let mut stream = client.tail_history().await?; let stdout = io::stdout(); while let Some(reply) = stream.message().await? { 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(()) } #[expect(clippy::too_many_arguments)] #[expect(clippy::fn_params_excessive_bools)] async fn handle_list( db: &ClientSqlite, settings: &Settings, context: crate::atuin_client::database::Context, session: bool, cwd: bool, mode: ListMode, format: Option, include_deleted: bool, print0: bool, reverse: bool, tz: Timezone, ) -> 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) .await?; print_list( &history, mode, match format { None => Some(settings.history_format.as_str()), _ => format.as_deref(), }, print0, reverse, tz, ); Ok(()) } async fn handle_prune( db: &ClientSqlite, settings: &Settings, store: SqliteStore, context: crate::atuin_client::database::Context, dry_run: bool, ) -> Result<()> { // Grab all executed commands and filter them using History::should_save. // We could iterate or paginate here if memory usage becomes an issue. let matches: Vec = db .list(&[Global], &context, None, false, false) .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: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); 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); let (id, _) = history_store.delete(entry.id.clone()).await?; history_store.incremental_build(db, &[id]).await?; } daemon_cmd::emit_event(settings, crate::atuin_daemon::DaemonEvent::HistoryPruned).await; } Ok(()) } async fn handle_dedup( db: &ClientSqlite, 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 = 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: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); let host_id = Settings::host_id().await?; let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); let ids = matches.iter().map(|h| h.id.clone()).collect::>(); for entry in matches { eprintln!("deleting {}", entry.id); let (id, _) = history_store.delete(entry.id).await?; history_store.incremental_build(db, &[id]).await?; } daemon_cmd::emit_event( settings, crate::atuin_daemon::DaemonEvent::HistoryDeleted { ids }, ) .await; } Ok(()) } #[expect(clippy::too_many_lines)] pub(crate) async fn run(self, settings: &Settings) -> Result<()> { match self { Self::Start { cmd_env, author, intent, command, } => { let command = if cmd_env { std::env::var("ATUIN_COMMAND_LINE").unwrap_or_default() } else { command.join(" ") }; if let Some(id) = handle_daemon_start(settings, &command, author.as_deref(), intent.as_deref()) .await? { println!("{id}"); } Ok(()) } Self::End { id, exit, duration } => { handle_daemon_end(settings, &id, exit, duration).await } Self::Tail => { return Self::handle_tail(settings).await; } cmd => { let context = current_context().await?; let db_path = PathBuf::from(settings.db_path.as_str()); let record_store_path = PathBuf::from(settings.record_store_path.as_str()); let db = ClientSqlite::new(db_path, settings.local_timeout).await?; let store = SqliteStore::new(record_store_path, settings.local_timeout).await?; let encryption_key: [u8; 32] = encryption::load_key(settings) .context("could not load encryption key")? .into(); 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!(), } } } } } #[cfg(test)] mod tests { use time::macros::datetime; use super::*; #[test] fn normalize_command_strips_trailing_spaces_and_tabs() { let settings = Settings::new().unwrap(); assert!(settings.strip_trailing_whitespace); assert_eq!(normalize_command_for_storage("ls \t", &settings), "ls"); } #[test] fn normalize_command_preserves_escaped_trailing_space() { let settings = Settings::new().unwrap(); assert_eq!( normalize_command_for_storage("printf foo\\ ", &settings), "printf foo\\ " ); assert_eq!( normalize_command_for_storage("printf foo\\\\ ", &settings), "printf foo\\\\" ); } #[test] fn test_format_string_no_panic() { // Don't panic but provide helpful output (issue #2776) let malformed_json = r#"{"command":"{command}","key":"value"}"#; let result = std::panic::catch_unwind(|| parse_fmt(malformed_json)); assert!(result.is_ok()); } #[test] fn test_valid_formats_still_work() { assert!(std::panic::catch_unwind(|| parse_fmt("{command}")).is_ok()); assert!(std::panic::catch_unwind(|| parse_fmt("{time} - {command}")).is_ok()); } fn sample_tail_event(kind: TailKind) -> TailEvent { TailEvent { kind, history: History { id: "history-id".to_owned().into(), 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(), hostname: "host:ellie".to_owned(), author: "claude".to_owned(), intent: Some("inspect repository state".to_owned()), deleted_at: None, }, } } #[test] fn test_tail_json_output_contains_history_fields() { let json = sample_tail_event(TailKind::Ended) .render(false, Timezone(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"], "history-id"); assert_eq!(value["history"]["duration_ns"], 12_345_678); assert_eq!(value["history"]["success"], true); assert!(value.get("record").is_none()); } #[test] fn test_tail_pretty_output_shows_pending_fields_for_started_events() { let rendered = sample_tail_event(TailKind::Started) .render(true, Timezone(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")); } }