diff options
Diffstat (limited to 'crates/client/src/command')
| -rw-r--r-- | crates/client/src/command/client.rs | 28 | ||||
| -rw-r--r-- | crates/client/src/command/client/daemon.rs | 44 | ||||
| -rw-r--r-- | crates/client/src/command/client/history.rs | 1241 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/end.rs | 38 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/mod.rs | 753 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/start.rs | 76 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/tail.rs | 321 | ||||
| -rw-r--r-- | crates/client/src/command/client/info.rs | 24 | ||||
| -rw-r--r-- | crates/client/src/command/client/stats.rs | 18 | ||||
| -rw-r--r-- | crates/client/src/command/client/store/mod.rs (renamed from crates/client/src/command/client/store.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/sync.rs | 149 | ||||
| -rw-r--r-- | crates/client/src/command/client/sync/status.rs | 25 | ||||
| -rw-r--r-- | crates/client/src/command/client/wrapped.rs | 17 | ||||
| -rw-r--r-- | crates/client/src/command/mod.rs | 86 |
14 files changed, 1352 insertions, 1468 deletions
diff --git a/crates/client/src/command/client.rs b/crates/client/src/command/client.rs index 42e72e21..9f45f53b 100644 --- a/crates/client/src/command/client.rs +++ b/crates/client/src/command/client.rs @@ -37,11 +37,12 @@ fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) { } mod config; +mod daemon; mod default_config; mod history; mod info; mod stats; -mod store; +// mod store; mod sync; mod wrapped; @@ -52,14 +53,17 @@ pub(crate) enum Cmd { #[command(subcommand)] History(history::Cmd), + /// Interact with the daemon #[command(subcommand)] - /// Request a sync or view sync status - Sync(sync::Cmd), + Daemon(daemon::Cmd), - /// Manage the atuin data store #[command(subcommand)] - Store(store::Cmd), + /// Request a sync or view sync status + Sync(sync::Cmd), + // /// Manage the atuin data store + // #[command(subcommand)] + // Store(store::Cmd), /// Information about dotfiles locations and ENV vars #[command()] Info, @@ -97,7 +101,7 @@ impl Cmd { res } - async fn run_inner(self, mut settings: Settings) -> Result<()> { + async fn run_inner(self, settings: Settings) -> Result<()> { // ATUIN_LOG env var overrides config file level settings let env_log_set = std::env::var("ATUIN_LOG").is_ok(); @@ -117,14 +121,14 @@ impl Cmd { } match self { - Self::Stats(stats) => stats.run(&db, &settings).await, - Self::Wrapped { year } => wrapped::run(year, &db, &settings).await, - - Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await, + Self::Daemon(cmd) => cmd.run(&settings).await, + Self::Sync(sync) => sync.run(settings).await, - Self::Store(store) => store.run(&settings, &db, sqlite_store).await, + Self::Stats(stats) => stats.run(&settings).await, + Self::Wrapped { year } => wrapped::run(year, &settings).await, - Self::Info => info::run(&settings), + // Self::Store(store) => store.run(&settings, &db, sqlite_store).await, + Self::Info => info::run(&settings).await, Self::DefaultConfig => { default_config::run(); diff --git a/crates/client/src/command/client/daemon.rs b/crates/client/src/command/client/daemon.rs new file mode 100644 index 00000000..83877cfd --- /dev/null +++ b/crates/client/src/command/client/daemon.rs @@ -0,0 +1,44 @@ +use clap::Subcommand; +use eyre::Result; + +use turtle_daemon::api::client::{Probe, probe}; + +use crate::atuin_client::settings::Settings; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Show the daemon's current status + Status, +} + +impl Cmd { + pub(crate) async fn run(self, settings: &Settings) -> Result<()> { + match self { + Cmd::Status => status_cmd(&settings).await, + } + } +} + +async fn status_cmd(settings: &Settings) -> Result<()> { + match probe(settings.daemon.socket_path.clone()).await { + Probe::Ready(mut client) => { + let status = client.status().await?; + println!("Daemon running"); + println!(" PID: {}", status.pid); + println!(" Version: {}", status.version); + println!(" Protocol: {}", status.protocol); + println!(" Healthy: {}", status.healthy); + println!(" Socket: {}", settings.daemon.socket_path); + } + Probe::NeedsRestart(reason) => { + println!("Daemon running (needs restart)"); + println!(" Reason: {reason}"); + } + Probe::Unreachable(_) => { + println!("Daemon is not running"); + } + } + + Ok(()) +} diff --git a/crates/client/src/command/client/history.rs b/crates/client/src/command/client/history.rs deleted file mode 100644 index 4d633b7d..00000000 --- a/crates/client/src/command/client/history.rs +++ /dev/null @@ -1,1241 +0,0 @@ -use std::{ - fmt::{self, Display}, - io::{self, IsTerminal, Write}, - path::PathBuf, - time::Duration, -}; - -use crate::{ - atuin_common::utils::{self, Escapable as _}, - command::client::daemon, -}; -use clap::Subcommand; -use eyre::{Context, Result, bail}; -use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; - -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::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<String>, - - /// Optional intent/rationale for running this command - #[arg(long)] - intent: Option<String>, - - command: Vec<String>, - }, - - /// Finishes a new command in the history (adds time, exit code) - End { - id: String, - - #[arg(long, short)] - exit: i64, - - #[arg(long, short)] - duration: Option<u64>, - }, - - /// 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<Timezone>, - - /// 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<String>, - }, - - /// 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<Timezone>, - - /// 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<String>, - }, - - 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(), - }; - - #[expect(trivial_casts)] - 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); - - // 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()); - } -} - -async fn connect_client(settings: &Settings) -> Result<HistoryClient> { - HistoryClient::new( - #[cfg(unix)] - settings.daemon.socket_path.clone(), - ) - .await -} - -async fn probe(settings: &Settings) -> Probe { - let mut client = match connect_client(settings).await { - Ok(client) => client, - Err(err) => return Probe::Unreachable(err), - }; - - match client.status().await { - Ok(status) => { - if daemon_matches_expected(&status.version, status.protocol) { - Probe::Ready(client) - } else { - Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol)) - } - } - Err(err) => Probe::Unreachable(err), - } -} - -pub(crate) async fn start_history(settings: &Settings, history: History) -> Result<String> { - match async { - connect_client(settings) - .await? - .start_history(history.clone()) - .await - } - .await - { - Ok(resp) => { - if daemon_matches_expected(&resp.version, resp.protocol) { - return Ok(resp.id); - } - - Err(eyre!( - "{}. Restart the daemon manually", - daemon_mismatch_message(&resp.version, resp.protocol) - )) - } - Err(err) => Err(err), - } -} - -pub(crate) async fn end_history( - settings: &Settings, - id: String, - duration: u64, - exit: i64, -) -> Result<()> { - match async { - connect_client(settings) - .await? - .end_history(id.clone(), duration, exit) - .await - } - .await - { - Ok(resp) => { - if daemon_matches_expected(&resp.version, resp.protocol) { - return Ok(()); - } - - Err(eyre!( - "{}. Restart the daemon manually", - daemon_mismatch_message(&resp.version, resp.protocol) - )) - } - Err(err) => Err(err), - } -} - -/// Emit a daemon event. -pub(crate) async fn emit_event(settings: &Settings, event: DaemonEvent) { - // Try to connect and send - match ControlClient::from_settings(settings).await { - Ok(mut client) => { - if let Err(e) = client.send_event(event).await { - tracing::debug!(?e, "failed to send event to daemon"); - } - } - Err(e) => { - tracing::debug!(?e, "daemon not available, skipping event emission"); - } - } -} - -pub(crate) async fn tail_client(settings: &Settings) -> Result<HistoryClient> { - match probe(settings).await { - Probe::Ready(client) => Ok(client), - Probe::NeedsRestart(reason) => { - bail!("{reason}. Restart the daemon manually"); - } - Probe::Unreachable(err) if is_legacy_daemon_error(&err) => { - Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE)) - } - Probe::Unreachable(err) => Err(err), - } -} - -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<O: IsTerminal>(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<Option<String>> { - // 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<u64>, -) -> 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<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>, -} - -impl TailEvent { - fn from_proto(reply: TailHistoryReply) -> Result<Self> { - 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<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: Timezone) -> Result<String> { - 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<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 => 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<String> { - 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<String> { - 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<String>, - 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<History> = 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::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<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: [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::<Vec<_>>(); - - for entry in matches { - eprintln!("deleting {}", entry.id); - let (id, _) = history_store.delete(entry.id).await?; - history_store.incremental_build(db, &[id]).await?; - } - - daemon::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::{ - History, Settings, TailEvent, TailKind, Timezone, normalize_command_for_storage, parse_fmt, - }; - - #[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")); - } -} diff --git a/crates/client/src/command/client/history/end.rs b/crates/client/src/command/client/history/end.rs new file mode 100644 index 00000000..bfec0aab --- /dev/null +++ b/crates/client/src/command/client/history/end.rs @@ -0,0 +1,38 @@ +use crate::atuin_client::settings::Settings; + +use eyre::{Result, eyre}; +use turtle_daemon::api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}; + +pub(super) async fn handle( + settings: &Settings, + id: &str, + exit: i64, + duration: Option<u64>, +) -> Result<()> { + end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?; + + Ok(()) +} + +async fn end_history(settings: &Settings, id: String, duration: u64, exit: i64) -> Result<()> { + match async { + HistoryClient::new(settings.daemon.socket_path.clone()) + .await? + .end_history(id.clone(), duration, exit) + .await + } + .await + { + Ok(resp) => { + if daemon_matches_expected(&resp.version, resp.protocol) { + return Ok(()); + } + + Err(eyre!( + "{}. Restart the daemon manually", + daemon_mismatch_message(&resp.version, resp.protocol) + )) + } + Err(err) => Err(err), + } +} diff --git a/crates/client/src/command/client/history/mod.rs b/crates/client/src/command/client/history/mod.rs new file mode 100644 index 00000000..d71c653d --- /dev/null +++ b/crates/client/src/command/client/history/mod.rs @@ -0,0 +1,753 @@ +use std::{ + fmt::{self, Display}, + io::{self, IsTerminal, Write}, + ops::ControlFlow, + time::Duration, +}; + +use clap::Subcommand; +use eyre::Result; +use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; +use time::{OffsetDateTime, macros::format_description}; +use turtle_common::utils::Escapable; +use turtle_daemon::aclient::history::History; + +use crate::atuin_client::settings::{Settings, Timezone}; + +mod end; +mod start; +mod tail; + +#[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<String>, + + /// Optional intent/rationale for running this command + #[arg(long)] + intent: Option<String>, + + command: Vec<String>, + }, + + /// Finishes a new command in the history (adds time, exit code) + End { + id: String, + + #[arg(long, short)] + exit: i64, + + #[arg(long, short)] + duration: Option<u64>, + }, + + /// 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<Timezone>, + + /// 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<String>, + }, + + /// 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<Timezone>, + + /// 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<String>, + }, + + 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, + }, +} + +impl Cmd { + // #[expect(clippy::too_many_arguments)] + // #[expect(clippy::fn_params_excessive_bools)] + // async fn handle_list( + // settings: &Settings, + // session: bool, + // cwd: bool, + // mode: ListMode, + // format: Option<String>, + // 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<History> = 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::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<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: [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::<Vec<_>>(); + // + // for entry in matches { + // eprintln!("deleting {}", entry.id); + // let (id, _) = history_store.delete(entry.id).await?; + // history_store.incremental_build(db, &[id]).await?; + // } + // + // daemon::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) = + start::handle(settings, &command, author.as_deref(), intent.as_deref()).await? + { + println!("{id}"); + } + + Ok(()) + } + Self::End { id, exit, duration } => end::handle(settings, &id, exit, duration).await, + Self::Tail => { + return tail::handle(settings).await; + } + cmd => { + todo!() + // 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!(), + // } + } + } + } +} + +#[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(), + }; + + #[expect(trivial_casts)] + 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); + + // 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, +} +/// 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 format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> { + if value > 0 { + ControlFlow::Break((unit, value)) + } else { + ControlFlow::Continue(()) + } + } + + // impl taken and modified from + // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331 + // Copyright (c) 2016 The humantime Developers + fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> { + let secs = f.as_secs(); + let nanos = f.subsec_nanos(); + + let years = secs / 31_557_600; // 365.25d + let year_days = secs % 31_557_600; + let months = year_days / 2_630_016; // 30.44d + let month_days = year_days % 2_630_016; + let days = month_days / 86400; + let day_secs = month_days % 86400; + let hours = day_secs / 3600; + let minutes = day_secs % 3600 / 60; + let seconds = day_secs % 60; + + let millis = nanos / 1_000_000; + let micros = nanos / 1_000; + + // a difference from our impl than the original is that + // we only care about the most-significant segment of the duration. + // If the item call returns `Break`, then the `?` will early-return. + // This allows for a very consise impl + item("y", years)?; + item("mo", months)?; + item("d", days)?; + item("h", hours)?; + item("m", minutes)?; + item("s", seconds)?; + item("ms", u64::from(millis))?; + item("us", u64::from(micros))?; + item("ns", u64::from(nanos))?; + ControlFlow::Continue(()) + } + + match fmt(dur) { + ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"), + ControlFlow::Continue(()) => write!(f, "0s"), + } +} + +#[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 + } + } +} + +static TIME_FMT: &[time::format_description::FormatItem<'static>] = + format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]"); + +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 + } +} + +#[cfg(test)] +mod tests { + use super::{Settings, normalize_command_for_storage, parse_fmt}; + + #[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()); + } +} diff --git a/crates/client/src/command/client/history/start.rs b/crates/client/src/command/client/history/start.rs new file mode 100644 index 00000000..81aaa904 --- /dev/null +++ b/crates/client/src/command/client/history/start.rs @@ -0,0 +1,76 @@ +use crate::{ + atuin_client::settings::Settings, + command::client::history::{apply_start_metadata, normalize_command_for_storage}, +}; + +use eyre::{Result, eyre}; +use time::OffsetDateTime; +use tracing::debug; +use turtle_common::utils; +use turtle_daemon::{ + aclient::history::{History, SettingsFilter}, + api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}, +}; + +pub(super) async fn handle( + settings: &Settings, + command: &str, + author: Option<&str>, + intent: Option<&str>, +) -> Result<Option<String>> { + // 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(SettingsFilter { + history: &settings.history_filter, + cwd: &settings.cwd_filter, + secrets: settings.secrets_filter, + }) { + 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 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 start_history(settings: &Settings, history: History) -> Result<String> { + match async { + HistoryClient::new(settings.daemon.socket_path.clone()) + .await? + .start_history(history.clone()) + .await + } + .await + { + Ok(resp) => { + if daemon_matches_expected(&resp.version, resp.protocol) { + return Ok(resp.id); + } + + Err(eyre!( + "{}. Restart the daemon manually", + daemon_mismatch_message(&resp.version, resp.protocol) + )) + } + Err(err) => Err(err), + } +} diff --git a/crates/client/src/command/client/history/tail.rs b/crates/client/src/command/client/history/tail.rs new file mode 100644 index 00000000..595fe3a0 --- /dev/null +++ b/crates/client/src/command/client/history/tail.rs @@ -0,0 +1,321 @@ +use crate::{ + atuin_client::settings::{Settings, Timezone}, + command::client::history::{TIME_FMT, format_duration_into}, +}; + +use colored::Colorize; +use eyre::{Context, Result, bail}; +use serde::Serialize; +use time::OffsetDateTime; +use turtle_common::utils::Escapable; +use turtle_daemon::{ + aclient::history::History, + api::client::{ + HistoryClient, HistoryEventKind, Probe, TailHistoryReply, history_entry_to_history, probe, + }, +}; + +use std::{ + fmt::{self, Display}, + io::{self, IsTerminal, Write}, + time::Duration, +}; + +#[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<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>, +} + +impl TailEvent { + fn from_proto(reply: TailHistoryReply) -> Result<Self> { + let history = reply + .history + .ok_or_else(|| eyre::eyre!("daemon sent a history tail event without history"))?; + 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_entry_to_history(history), + }) + } + + fn render(&self, tty: bool, tz: Timezone) -> 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: Timezone) -> Result<String> { + 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<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 => 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 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 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 format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> { + Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?) +} + +async fn tail_client(settings: &Settings) -> Result<HistoryClient> { + match probe(settings.daemon.socket_path.clone()).await { + Probe::Ready(_) => HistoryClient::new(settings.daemon.socket_path.clone()).await, + Probe::NeedsRestart(reason) => { + bail!("{reason}. Restart the daemon manually"); + } + Probe::Unreachable(err) => Err(err), + } +} + +pub(super) async fn handle(settings: &Settings) -> Result<()> { + let tty = io::stdout().is_terminal(); + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).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(()) +} diff --git a/crates/client/src/command/client/info.rs b/crates/client/src/command/client/info.rs index 49c92193..77c7064c 100644 --- a/crates/client/src/command/client/info.rs +++ b/crates/client/src/command/client/info.rs @@ -2,21 +2,33 @@ use crate::atuin_client::settings::Settings; use crate::{SHA, VERSION};
use eyre::Result;
+use turtle_daemon::api::client::ControlClient;
+
+pub(crate) async fn run(settings: &Settings) -> Result<()> {
+ let config = turtle_common::utils::config_dir();
+
+ let mut client = ControlClient::new(settings.daemon.socket_path.clone()).await?;
+ let paths = client.paths().await?;
-pub(crate) fn run(settings: &Settings) -> Result<()> {
- let config = crate::atuin_common::utils::config_dir();
let mut config_file = config.clone();
config_file.push("config.toml");
let mut sever_config = config;
sever_config.push("server.toml");
let config_paths = format!(
- "Config files:\nclient config: {:?}\nserver config: {:?}\nclient db path: {:?}\nkey path: {:?}\nmeta db path: {:?}",
+ "\
+ Config files:
+ client config: {:?}
+ server config: {:?}
+ deamon config: {:?}
+ deamon db path: {:?}
+ deamon socket path: {:?}\
+ ",
config_file.to_string_lossy(),
sever_config.to_string_lossy(),
- settings.db_path,
- settings.sync.encryption_key()?,
- settings.meta.db_path
+ paths.config,
+ paths.db,
+ paths.socket,
);
let env_vars = format!(
diff --git a/crates/client/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs index 9b8ebdff..ec235f36 100644 --- a/crates/client/src/command/client/stats.rs +++ b/crates/client/src/command/client/stats.rs @@ -2,10 +2,12 @@ use clap::Parser; use eyre::Result; use interim::parse_date_string; use time::{Duration, OffsetDateTime, Time}; +use turtle_daemon::api::client::{HistoryClient, Range}; use crate::atuin_client::settings::Settings; use crate::atuin_history::stats::{compute, pretty_print}; +use crate::command::current_session; fn parse_ngram_size(s: &str) -> Result<usize, String> { let value = s @@ -36,7 +38,9 @@ pub(crate) struct Cmd { impl Cmd { pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { - let context = current_context().await?; + let session = current_session()?; + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + let words = if self.period.is_empty() { String::from("all") } else { @@ -47,27 +51,27 @@ impl Cmd { let last_night = now.replace_time(Time::MIDNIGHT); let history = if words.as_str() == "all" { - db.list(&[], &context, None, false, false).await? + client.history(session, None).await? } else if words.trim() == "today" { let start = last_night; let end = start + Duration::days(1); - db.range(start, end).await? + client.history(session, Some(Range { start, end })).await? } else if words.trim() == "month" { let end = last_night; let start = end - Duration::days(31); - db.range(start, end).await? + client.history(session, Some(Range { start, end })).await? } else if words.trim() == "week" { let end = last_night; let start = end - Duration::days(7); - db.range(start, end).await? + client.history(session, Some(Range { start, end })).await? } else if words.trim() == "year" { let end = last_night; let start = end - Duration::days(365); - db.range(start, end).await? + client.history(session, Some(Range { start, end })).await? } else { let start = parse_date_string(&words, now, settings.dialect.into())?; let end = start + Duration::days(1); - db.range(start, end).await? + client.history(session, Some(Range { start, end })).await? }; let stats = compute(settings, &history, self.count, self.ngram_size); diff --git a/crates/client/src/command/client/store.rs b/crates/client/src/command/client/store/mod.rs index bc57488d..bc57488d 100644 --- a/crates/client/src/command/client/store.rs +++ b/crates/client/src/command/client/store/mod.rs diff --git a/crates/client/src/command/client/sync.rs b/crates/client/src/command/client/sync.rs index c29a82fc..86228e47 100644 --- a/crates/client/src/command/client/sync.rs +++ b/crates/client/src/command/client/sync.rs @@ -1,29 +1,18 @@ use clap::Subcommand; -use eyre::{Result, WrapErr}; +use colored::Colorize; +use eyre::{Result, WrapErr, bail}; use serde_json::json; -use crate::{ - atuin_client::{ - database::ClientSqlite, - encryption, - history::store::HistoryStore, - record::{sqlite_store::SqliteStore, sync}, - settings::Settings, - }, - atuin_common::utils, -}; +use turtle_common::utils; +use turtle_daemon::api::client::{Probe, probe}; -mod status; +use crate::{SHA, VERSION, atuin_client::settings::Settings}; #[derive(Subcommand, Debug)] #[command(infer_subcommands = true)] pub(crate) enum Cmd { /// Sync with the configured server - Perform { - /// Force re-download everything - #[arg(long, short)] - force: bool, - }, + Perform {}, /// Print (or generate) the encryption key and user id for transfer to another machine KeyAndId {}, @@ -33,88 +22,72 @@ pub(crate) enum Cmd { } impl Cmd { - pub(crate) async fn run( - self, - settings: Settings, - db: &ClientSqlite, - store: SqliteStore, - ) -> Result<()> { + pub(crate) async fn run(self, settings: Settings) -> Result<()> { match self { - Self::Perform { force } => run(&settings, force, db, store).await, - Self::Status => status::run(&settings).await, + Self::Perform {} => perform_cmd(&settings).await, + Self::Status => status_cmd(&settings).await, Self::KeyAndId {} => { - use crate::atuin_client::encryption::{encode_key, load_key}; - - let key = load_key(&settings).wrap_err("could not load encryption key")?; - let user_id = settings - .sync - .user_id() - .wrap_err("Failed to load user-id")? - .unwrap_or_else(utils::uuid_v7); - - let key = encode_key(&key).wrap_err("could not encode encryption key")?; - - let json = serde_json::to_string_pretty(&json!({ "key": key, "user_id": user_id })) - .expect("Will always be formattable"); - - println!("{json}"); - - Ok(()) + todo!() + // use crate::atuin_client::encryption::{encode_key, load_key}; + // + // let key = load_key(&settings).wrap_err("could not load encryption key")?; + // let user_id = settings + // .sync + // .user_id() + // .wrap_err("Failed to load user-id")? + // .unwrap_or_else(utils::uuid_v7); + // + // let key = encode_key(&key).wrap_err("could not encode encryption key")?; + // + // let json = serde_json::to_string_pretty(&json!({ "key": key, "user_id": user_id })) + // .expect("Will always be formattable"); + // + // println!("{json}"); + // + // Ok(()) } } } } -async fn run( - settings: &Settings, - force: bool, - db: &ClientSqlite, - store: SqliteStore, -) -> Result<()> { - 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 (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key) - .await - .map_err(crate::print_error::format_sync_error)?; - - crate::sync::build(settings, &store, db, Some(&downloaded)).await?; - - println!("{uploaded}/{} up/down to record store", downloaded.len()); - - let history_length = db.history_count(true).await?; - let store_history_length = store.len_tag("history").await?; +async fn status_cmd(settings: &Settings) -> Result<()> { + todo!(); - #[expect(clippy::cast_sign_loss)] - if history_length as u64 > store_history_length { - println!("{history_length} in history index, but {store_history_length} in history store"); - println!("Running automatic history store init..."); + // if let Some(me) = settings.sync.user_id()? { + // let last_sync = Settings::last_sync().await?; + // + // println!("Atuin v{VERSION} - Build rev {SHA}\n"); + // + // println!("{}", "[Local]".green()); + // println!("Sync frequency: {}", settings.sync.frequency); + // println!("Last sync: {}", last_sync.to_offset(settings.timezone.0)); + // println!("Auto sync: {}", settings.sync.auto); + // + // println!("{}", "[Remote]".green()); + // println!("Address: {}", settings.sync.address); + // println!("User id: {me}"); + // } else { + // bail!("You are not logged in to a sync server - cannot show sync status"); + // } - // Internally we use the global filter mode, so this context is ignored. - // don't recurse or loop here. - history_store.init_store(db).await?; - - println!("Re-running sync due to new records locally"); - - // we'll want to run sync once more, as there will now be stuff to upload - let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key) - .await - .map_err(crate::print_error::format_sync_error)?; - - crate::sync::build(settings, &store, db, Some(&downloaded)).await?; + Ok(()) +} - println!("{uploaded}/{} up/down to record store", downloaded.len()); +async fn perform_cmd(settings: &Settings) -> Result<()> { + match probe(settings.daemon.socket_path.clone()).await { + Probe::Ready(mut control_client) => { + let reply = control_client.force_sync().await?; + if !reply.accepted { + bail!("Daemon refused to accept sync request"); + } + } + Probe::NeedsRestart(msg) => { + bail!("Daemon version mis-match, needs restart: {msg}"); + } + Probe::Unreachable(report) => { + bail!("Daemon unreachable: {report}"); + } } - println!( - "Sync complete! {} items in history database, force: {}", - db.history_count(true).await?, - force - ); - Ok(()) } diff --git a/crates/client/src/command/client/sync/status.rs b/crates/client/src/command/client/sync/status.rs deleted file mode 100644 index caf3b90f..00000000 --- a/crates/client/src/command/client/sync/status.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crate::atuin_client::settings::Settings; -use crate::{SHA, VERSION}; -use colored::Colorize; -use eyre::{Result, bail}; - -pub(crate) async fn run(settings: &Settings) -> Result<()> { - if let Some(me) = settings.sync.user_id()? { - let last_sync = Settings::last_sync().await?; - - println!("Atuin v{VERSION} - Build rev {SHA}\n"); - - println!("{}", "[Local]".green()); - println!("Sync frequency: {}", settings.sync.frequency); - println!("Last sync: {}", last_sync.to_offset(settings.timezone.0)); - println!("Auto sync: {}", settings.sync.auto); - - println!("{}", "[Remote]".green()); - println!("Address: {}", settings.sync.address); - println!("User id: {me}"); - } else { - bail!("You are not logged in to a sync server - cannot show sync status"); - } - - Ok(()) -} diff --git a/crates/client/src/command/client/wrapped.rs b/crates/client/src/command/client/wrapped.rs index 2ce19bf7..64e5b718 100644 --- a/crates/client/src/command/client/wrapped.rs +++ b/crates/client/src/command/client/wrapped.rs @@ -2,11 +2,13 @@ use crossterm::style::{ResetColor, SetAttribute}; use eyre::Result; use std::collections::{HashMap, HashSet}; use time::{Date, Duration, Month, OffsetDateTime, Time}; +use turtle_daemon::aclient::history::History; +use turtle_daemon::api::client::{HistoryClient, Range}; -use crate::atuin_client::database::ClientSqlite; use crate::atuin_client::settings::Settings; use crate::atuin_history::stats::{Stats, compute}; +use crate::command::current_session; #[derive(Debug)] struct WrappedStats { @@ -21,11 +23,7 @@ struct WrappedStats { impl WrappedStats { #[expect(clippy::too_many_lines, clippy::cast_precision_loss)] - fn new( - settings: &Settings, - stats: &Stats, - history: &[crate::atuin_client::history::History], - ) -> Self { + fn new(settings: &Settings, stats: &Stats, history: &[History]) -> Self { let nav_commands = stats .top .iter() @@ -272,7 +270,10 @@ fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) { println!(); } -pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Settings) -> Result<()> { +pub(crate) async fn run(year: Option<i32>, settings: &Settings) -> Result<()> { + let session = current_session()?; + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0); let month = now.month(); @@ -296,7 +297,7 @@ pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Setting now.offset(), ); - let history = db.range(start, end).await?; + let history = client.history(session, Some(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 🪄" diff --git a/crates/client/src/command/mod.rs b/crates/client/src/command/mod.rs index 9a648254..2e51a1e2 100644 --- a/crates/client/src/command/mod.rs +++ b/crates/client/src/command/mod.rs @@ -15,10 +15,6 @@ pub(crate) enum AtuinCmd { #[command(flatten)] Client(client::Cmd), - /// PTY proxy for atuin - #[command(alias = "hex")] - PtyProxy(crate::atuin_pty_proxy::PtyProxy), - /// Generate a UUID Uuid, @@ -41,17 +37,12 @@ impl AtuinCmd { match self { Self::Client(client) => client.run(), - Self::PtyProxy(proxy) => { - run_pty_proxy(proxy); - Ok(()) - } - Self::Contributors => { contributors::run(); Ok(()) } Self::Uuid => { - println!("{}", crate::atuin_common::utils::uuid_v7().as_simple()); + println!("{}", turtle_common::utils::uuid_v7().as_simple()); Ok(()) } Self::GenCompletions(gen_completions) => gen_completions.run(), @@ -60,52 +51,6 @@ impl AtuinCmd { } #[cfg(unix)] -fn run_pty_proxy(proxy: crate::atuin_pty_proxy::PtyProxy) { - proxy.run(semantic_command_capture_sink()); -} - -#[cfg(unix)] -fn semantic_command_capture_sink() -> Option<crate::atuin_pty_proxy::CommandCaptureSink> { - use std::sync::mpsc; - use std::time::Duration; - - if is_truthy_env("ATUIN_TERMINAL") { - return None; - } - - let settings = crate::atuin_client::settings::Settings::new().ok()?; - let (tx, rx) = mpsc::sync_channel::<crate::atuin_pty_proxy::CommandCapture>(128); - - std::thread::spawn(move || { - let Ok(runtime) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - else { - return; - }; - - while let Ok(first) = rx.recv() { - let mut batch = vec![first]; - - while batch.len() < 64 { - match rx.recv_timeout(Duration::from_millis(25)) { - Ok(capture) => batch.push(capture), - Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => { - break; - } - } - } - - runtime.block_on(send_semantic_command_captures(&settings, batch)); - } - }); - - Some(Box::new(move |capture| { - drop(tx.try_send(capture)); - })) -} - -#[cfg(unix)] #[inline] fn is_truthy_env(name: &str) -> bool { std::env::var(name) @@ -113,29 +58,8 @@ fn is_truthy_env(name: &str) -> bool { .as_ref() .is_some_and(|value| !value.trim().is_empty() && value.trim() != "false") } - -#[cfg(unix)] -async fn send_semantic_command_captures( - settings: &crate::atuin_client::settings::Settings, - batch: Vec<crate::atuin_pty_proxy::CommandCapture>, -) { - use crate::atuin_daemon::generated; - - let captures = batch - .into_iter() - .map(|capture| generated::semantic::CommandCapture { - prompt: capture.prompt, - command: capture.command, - output: capture.output, - exit_code: capture.exit_code, - history_id: capture.history_id, - session_id: capture.session_id, - output_truncated: capture.output_truncated, - output_observed_bytes: capture.output_observed_bytes, - }) - .collect(); - - if let Ok(mut client) = crate::atuin_daemon::SemanticClient::from_settings(settings).await { - drop(client.record_commands(captures).await); - } +pub(crate) fn current_session() -> Result<String> { + std::env::var("ATUIN_SESSION").map_err(|_| { + eyre::eyre!("Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set up your shell.") + }) } |
