diff options
Diffstat (limited to '')
| l--------- | crates/client/src/command/CONTRIBUTORS (renamed from crates/turtle/src/command/CONTRIBUTORS) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client.rs | 121 | ||||
| -rw-r--r-- | crates/client/src/command/client/config.rs (renamed from crates/turtle/src/command/client/config.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/daemon.rs | 46 | ||||
| -rw-r--r-- | crates/client/src/command/client/default_config.rs (renamed from crates/turtle/src/command/client/default_config.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/end.rs | 44 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/list.rs | 283 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/mod.rs | 261 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/start.rs | 80 | ||||
| -rw-r--r-- | crates/client/src/command/client/history/tail.rs | 322 | ||||
| -rw-r--r-- | crates/client/src/command/client/info.rs (renamed from crates/turtle/src/command/client/info.rs) | 24 | ||||
| -rw-r--r-- | crates/client/src/command/client/stats.rs (renamed from crates/turtle/src/command/client/stats.rs) | 29 | ||||
| -rw-r--r-- | crates/client/src/command/client/store/mod.rs (renamed from crates/turtle/src/command/client/store.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/store/pull.rs (renamed from crates/turtle/src/command/client/store/pull.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/store/purge.rs (renamed from crates/turtle/src/command/client/store/purge.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/store/push.rs (renamed from crates/turtle/src/command/client/store/push.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/store/rebuild.rs (renamed from crates/turtle/src/command/client/store/rebuild.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/store/rekey.rs (renamed from crates/turtle/src/command/client/store/rekey.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/store/verify.rs (renamed from crates/turtle/src/command/client/store/verify.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/client/sync.rs | 101 | ||||
| -rw-r--r-- | crates/client/src/command/client/wrapped.rs (renamed from crates/turtle/src/command/client/wrapped.rs) | 24 | ||||
| -rw-r--r-- | crates/client/src/command/contributors.rs (renamed from crates/turtle/src/command/contributors.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/gen_completions.rs (renamed from crates/turtle/src/command/gen_completions.rs) | 0 | ||||
| -rw-r--r-- | crates/client/src/command/mod.rs | 56 |
24 files changed, 1361 insertions, 30 deletions
diff --git a/crates/turtle/src/command/CONTRIBUTORS b/crates/client/src/command/CONTRIBUTORS index 1ca4115a..1ca4115a 120000 --- a/crates/turtle/src/command/CONTRIBUTORS +++ b/crates/client/src/command/CONTRIBUTORS diff --git a/crates/client/src/command/client.rs b/crates/client/src/command/client.rs new file mode 100644 index 00000000..0ecb4573 --- /dev/null +++ b/crates/client/src/command/client.rs @@ -0,0 +1,121 @@ +use clap::Subcommand; +use eyre::{Result, WrapErr}; + +use tracing_subscriber::filter::EnvFilter; + +use crate::atuin_client::settings::Settings; + +mod config; +mod daemon; +mod default_config; +mod history; +mod info; +mod stats; +// mod store; +mod sync; +mod wrapped; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Manipulate shell history + #[command(subcommand)] + History(history::Cmd), + + /// Interact with the daemon + #[command(subcommand)] + Daemon(daemon::Cmd), + + #[command(subcommand)] + /// Request a sync or view sync status + Sync(sync::Cmd), + + /// Information about dotfiles locations and ENV vars + #[command()] + Info, + + /// Calculate statistics for your history + Stats(stats::Cmd), + + #[command()] + /// Display a recap of your last year's history + Wrapped { year: Option<i32> }, + + /// Print the default atuin configuration (config.toml) + #[command()] + DefaultConfig, + + #[command(subcommand)] + /// Manage your configuration + Config(config::Cmd), +} + +impl Cmd { + pub(crate) fn run(self) -> Result<()> { + let mut runtime = tokio::runtime::Builder::new_current_thread(); + + let runtime = runtime.enable_all().build().unwrap(); + + let res = { + let settings = Settings::new().wrap_err("could not load client settings")?; + + runtime.block_on(self.run_inner(settings)) + }; + + runtime.shutdown_timeout(std::time::Duration::from_millis(50)); + + res + } + + 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(); + + // Base filter from env var (or empty if not set) + let base_filter = + EnvFilter::from_env("ATUIN_LOG").add_directive("sqlx_sqlite::regexp=off".parse()?); + + if env_log_set + && let Err(e) = tracing_subscriber::fmt() + .with_file(true) + .with_line_number(true) + .with_level(true) + .without_time() + .with_env_filter(base_filter) + .try_init() + { + eprintln!("failed to initialize logging: {e}"); + } + + tracing::trace!(command = ?self, "client command"); + + // Skip initializing any databases for history + // This is a pretty hot path, as it runs before and after every single command the user + // runs + match self { + Self::History(history) => return history.run(&settings).await, + Self::Config(config) => return config.run(&settings).await, + _ => {} + } + + match self { + Self::Daemon(cmd) => cmd.run(&settings).await, + Self::Sync(sync) => sync.run(settings).await, + + Self::Stats(stats) => stats.run(&settings).await, + Self::Wrapped { year } => wrapped::run(year, &settings).await, + + // Self::Store(store) => store.run(&settings, &db, sqlite_store).await, + Self::Info => info::run(&settings).await, + + Self::DefaultConfig => { + default_config::run(); + Ok(()) + } + + Self::History(_) | Self::Config(_) => { + unreachable!() + } + } + } +} diff --git a/crates/turtle/src/command/client/config.rs b/crates/client/src/command/client/config.rs index 73d1c35e..73d1c35e 100644 --- a/crates/turtle/src/command/client/config.rs +++ b/crates/client/src/command/client/config.rs diff --git a/crates/client/src/command/client/daemon.rs b/crates/client/src/command/client/daemon.rs new file mode 100644 index 00000000..ccefc14f --- /dev/null +++ b/crates/client/src/command/client/daemon.rs @@ -0,0 +1,46 @@ +use clap::Subcommand; +use eyre::{Result, bail}; + +use turtle_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 { + Self::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}"); + bail!("Daemon connection failed") + } + Probe::Unreachable(_) => { + println!("Daemon is not running"); + bail!("Daemon connection failed") + } + } + + Ok(()) +} diff --git a/crates/turtle/src/command/client/default_config.rs b/crates/client/src/command/client/default_config.rs index 4b03c909..4b03c909 100644 --- a/crates/turtle/src/command/client/default_config.rs +++ b/crates/client/src/command/client/default_config.rs 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..290f7697 --- /dev/null +++ b/crates/client/src/command/client/history/end.rs @@ -0,0 +1,44 @@ +use std::time::Duration; + +use crate::atuin_client::settings::Settings; + +use eyre::{Result, eyre}; +use turtle_api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}; + +pub(super) async fn handle( + settings: &Settings, + id: &str, + exit: i64, + duration: Option<Duration>, +) -> Result<()> { + end_history( + settings, + id.to_string(), + duration.unwrap_or(Duration::ZERO), + exit, + ) + .await?; + + Ok(()) +} + +async fn end_history(settings: &Settings, id: String, duration: Duration, exit: i64) -> Result<()> { + let response = HistoryClient::new(settings.daemon.socket_path.clone()) + .await? + .end_history(id.clone(), duration, exit) + .await; + + match response { + Ok(resp) => { + if daemon_matches_expected(resp.protocol) { + return Ok(()); + } + + Err(eyre!( + "{}. Restart the daemon manually", + daemon_mismatch_message(resp.protocol) + )) + } + Err(err) => Err(err), + } +} diff --git a/crates/client/src/command/client/history/list.rs b/crates/client/src/command/client/history/list.rs new file mode 100644 index 00000000..6fd28660 --- /dev/null +++ b/crates/client/src/command/client/history/list.rs @@ -0,0 +1,283 @@ +use std::{ + fmt::{self, Display}, + io::{self, IsTerminal, Write}, + time::Duration, +}; + +use crate::{ + atuin_client::settings::{Settings, Timezone}, + command::client::history::format_duration_into, +}; + +use eyre::Result; +use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; +use time::{OffsetDateTime, macros::format_description}; +use turtle_api::{client::HistoryClient, history::History}; + +#[derive(Clone, Copy, Debug)] +pub(super) enum ListMode { + Human, + CmdOnly, + Regular, +} + +impl ListMode { + pub(super) const fn from_flags(human: bool, cmd_only: bool) -> Self { + if human { + Self::Human + } else if cmd_only { + Self::CmdOnly + } else { + Self::Regular + } + } +} + +/// 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<'_> { + fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> { + match key { + "command" => match self.cmd_format { + CmdFormat::Literal | CmdFormat::Escaped => f.write_str(self.history.command.trim()), + }?, + "directory" => f.write_str(self.history.cwd.trim())?, + "exit" => f.write_str(&self.history.exit.to_string())?, + "duration" => { + let dur = self.history.duration; + 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.to_string())?, + _ => 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 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, reason = "It's more explicit with one")] + 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); + } + } +} + +pub(super) async fn handle( + settings: &Settings, + mode: ListMode, + format: Option<String>, + print0: bool, + reverse: bool, + tz: Timezone, +) -> Result<()> { + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + let history = client.history(None).await?; + + print_list( + &history, + mode, + match format { + None => Some("{time}\t{command}\t{duration}"), + _ => format.as_deref(), + }, + print0, + reverse, + tz, + ); + + Ok(()) +} + +// pub(super) async fn handle(settings: &Settings) -> Result<()> { +// const CSI: &str = "\x1b["; +// const CSE: &str = "m"; +// fn col(v: impl Display, num: u32) -> String { +// format!("{CSI}{num}{CSE}{v}{CSI}0{CSE}") +// } +// +// struct F(Duration); +// impl Display for F { +// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +// format_duration_into(self.0, f) +// } +// } +// +// let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; +// +// let hists = client.history(None).await?; +// +// for hist in hists { +// println!( +// "{}@{}: {} at {} for {} [{}]", +// hist.author, +// hist.hostname, +// col(hist.command, 36), +// col(hist.cwd, 32), +// F(hist.duration), +// col(hist.exit, 31) +// ); +// } +// +// Ok(()) +// } 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..55647e15 --- /dev/null +++ b/crates/client/src/command/client/history/mod.rs @@ -0,0 +1,261 @@ +use std::{ + fmt::{self}, + ops::ControlFlow, + time::Duration, +}; + +use clap::Subcommand; +use eyre::Result; +use time::macros::format_description; +use turtle_api::history::History; + +use crate::{ + atuin_client::settings::{Settings, Timezone}, + command::client::history::list::ListMode, +}; + +mod end; +mod list; +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, + + /// The duration this command ran, specified as nano seconds. + #[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", verbatim_doc_comment)] + 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>, + }, +} + +impl Cmd { + 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.map(Duration::from_nanos)).await + } + Self::Tail => tail::handle(settings).await, + Self::List { + cwd: _, + session: _, + human, + cmd_only, + print0, + reverse, + timezone, + format, + } => { + let mode = ListMode::from_flags(human, cmd_only); + let tz = timezone.unwrap_or(settings.timezone); + list::handle(settings, mode, format, print0, reverse, tz).await + } + } + } +} + +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"), + } +} + +static TIME_FMT: &[time::format_description::FormatItem<'static>] = + format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]"); + +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}; + + #[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\\\\" + ); + } +} 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..c462755e --- /dev/null +++ b/crates/client/src/command/client/history/start.rs @@ -0,0 +1,80 @@ +use crate::{ + atuin_client::settings::Settings, + command::{ + client::history::{apply_start_metadata, normalize_command_for_storage}, + current_session, + }, +}; + +use eyre::{Result, eyre}; +use time::OffsetDateTime; +use tracing::debug; +use turtle_api::{ + client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}, + history::{History, SettingsFilter}, +}; +use turtle_common::utils::{self, get_hostname, get_username}; + +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::daemon() + .timestamp(OffsetDateTime::now_utc()) + .command(command) + .cwd(cwd) + .session(current_session()?) + .hostname(get_hostname()) + .author(get_username()) + .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.to_string() + } + }; + + Ok(Some(resp)) +} + +async fn start_history(settings: &Settings, history: History) -> Result<String> { + let response = HistoryClient::new(settings.daemon.socket_path.clone()) + .await? + .start_history(history.clone()) + .await; + + match response { + Ok(resp) => { + if daemon_matches_expected(resp.protocol) { + return Ok(resp.id); + } + + Err(eyre!( + "{}. Restart the daemon manually", + daemon_mismatch_message(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..2cad5dd6 --- /dev/null +++ b/crates/client/src/command/client/history/tail.rs @@ -0,0 +1,322 @@ +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_api::{ + client::{ + HistoryClient, HistoryEventKind, Probe, TailHistoryReply, history_entry_to_history, probe, + }, + history::History, +}; +use turtle_common::utils::Escapable; + +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: Option<Duration>, + #[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.to_string(), + 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: self.duration_value(), + 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.to_string()); + push_pretty_field(&mut out, "session", &self.history.session); + push_pretty_field(&mut out, "exit", &self.exit_display()); + push_pretty_field(&mut out, "duration", &self.duration_display()); + + out.push('\n'); + + push_pretty_field(&mut out, "cwd", &self.history.cwd); + push_pretty_field(&mut out, "hostname", &self.history.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<Duration> { + 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 >= Duration::ZERO) + .map(|d| { + time::Duration::nanoseconds_i128( + i128::try_from(d.as_nanos()).expect("to be small enough"), + ) + }) + .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 >= Duration::ZERO => 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: Duration) -> 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).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 = 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(()) +} diff --git a/crates/turtle/src/command/client/info.rs b/crates/client/src/command/client/info.rs index 49c92193..1af8ee39 100644 --- a/crates/turtle/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_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/turtle/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs index 9ea5e283..9f74ecc3 100644 --- a/crates/turtle/src/command/client/stats.rs +++ b/crates/client/src/command/client/stats.rs @@ -1,10 +1,10 @@ use clap::Parser; use eyre::Result; -use interim::parse_date_string; +use interim::{Dialect, parse_date_string}; use time::{Duration, OffsetDateTime, Time}; +use turtle_api::client::{HistoryClient, Range}; -use crate::atuin_client::database::ClientSqlite; -use crate::atuin_client::{database::current_context, settings::Settings}; +use crate::atuin_client::settings::Settings; use crate::atuin_history::stats::{compute, pretty_print}; @@ -36,8 +36,9 @@ pub(crate) struct Cmd { } impl Cmd { - pub(crate) async fn run(&self, db: &ClientSqlite, settings: &Settings) -> Result<()> { - let context = current_context().await?; + pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + let words = if self.period.is_empty() { String::from("all") } else { @@ -47,30 +48,32 @@ impl Cmd { let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0); let last_night = now.replace_time(Time::MIDNIGHT); - let history = if words.as_str() == "all" { - db.list(&[], &context, None, false, false).await? + let range = if words.as_str() == "all" { + None } else if words.trim() == "today" { let start = last_night; let end = start + Duration::days(1); - db.range(start, end).await? + Some(Range { start, end }) } else if words.trim() == "month" { let end = last_night; let start = end - Duration::days(31); - db.range(start, end).await? + Some(Range { start, end }) } else if words.trim() == "week" { let end = last_night; let start = end - Duration::days(7); - db.range(start, end).await? + Some(Range { start, end }) } else if words.trim() == "year" { let end = last_night; let start = end - Duration::days(365); - db.range(start, end).await? + Some(Range { start, end }) } else { - let start = parse_date_string(&words, now, settings.dialect.into())?; + let start = parse_date_string(&words, now, Dialect::Uk)?; let end = start + Duration::days(1); - db.range(start, end).await? + Some(Range { start, end }) }; + let history = client.history(range).await?; + let stats = compute(settings, &history, self.count, self.ngram_size); if let Some(stats) = stats { diff --git a/crates/turtle/src/command/client/store.rs b/crates/client/src/command/client/store/mod.rs index bc57488d..bc57488d 100644 --- a/crates/turtle/src/command/client/store.rs +++ b/crates/client/src/command/client/store/mod.rs diff --git a/crates/turtle/src/command/client/store/pull.rs b/crates/client/src/command/client/store/pull.rs index 3a0865be..3a0865be 100644 --- a/crates/turtle/src/command/client/store/pull.rs +++ b/crates/client/src/command/client/store/pull.rs diff --git a/crates/turtle/src/command/client/store/purge.rs b/crates/client/src/command/client/store/purge.rs index a23f1886..a23f1886 100644 --- a/crates/turtle/src/command/client/store/purge.rs +++ b/crates/client/src/command/client/store/purge.rs diff --git a/crates/turtle/src/command/client/store/push.rs b/crates/client/src/command/client/store/push.rs index 9d66b5b2..9d66b5b2 100644 --- a/crates/turtle/src/command/client/store/push.rs +++ b/crates/client/src/command/client/store/push.rs diff --git a/crates/turtle/src/command/client/store/rebuild.rs b/crates/client/src/command/client/store/rebuild.rs index 6be67cd0..6be67cd0 100644 --- a/crates/turtle/src/command/client/store/rebuild.rs +++ b/crates/client/src/command/client/store/rebuild.rs diff --git a/crates/turtle/src/command/client/store/rekey.rs b/crates/client/src/command/client/store/rekey.rs index 2b379327..2b379327 100644 --- a/crates/turtle/src/command/client/store/rekey.rs +++ b/crates/client/src/command/client/store/rekey.rs diff --git a/crates/turtle/src/command/client/store/verify.rs b/crates/client/src/command/client/store/verify.rs index a39227f9..a39227f9 100644 --- a/crates/turtle/src/command/client/store/verify.rs +++ b/crates/client/src/command/client/store/verify.rs diff --git a/crates/client/src/command/client/sync.rs b/crates/client/src/command/client/sync.rs new file mode 100644 index 00000000..d03dd926 --- /dev/null +++ b/crates/client/src/command/client/sync.rs @@ -0,0 +1,101 @@ +use clap::Subcommand; +use eyre::{Result, bail}; + +use tracing::info; +use turtle_api::client::{Probe, probe}; + +use crate::atuin_client::settings::Settings; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Sync with the configured server + Perform {}, + + /// Print (or generate) the encryption key and user id for transfer to another machine + KeyAndId {}, + + /// Display the sync status + Status, +} + +impl Cmd { + pub(crate) async fn run(self, settings: Settings) -> Result<()> { + match self { + Self::Perform {} => perform_cmd(&settings).await, + Self::Status => status_cmd(&settings).await, + Self::KeyAndId {} => { + 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 status_cmd(settings: &Settings) -> Result<()> { + todo!(); + + // 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(()) +} + +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?; + + match reply.error { + Some(err) => { + bail!("Daemon failed to sync: {err}"); + } + None => { + info!( + down = reply.downloaded, + up = reply.uploaded, + "Sync completed." + ); + } + } + } + Probe::NeedsRestart(msg) => { + bail!("Daemon version mis-match, needs restart: {msg}"); + } + Probe::Unreachable(report) => { + bail!("Daemon unreachable: {report}"); + } + } + + Ok(()) +} diff --git a/crates/turtle/src/command/client/wrapped.rs b/crates/client/src/command/client/wrapped.rs index 2ce19bf7..4219aa2d 100644 --- a/crates/turtle/src/command/client/wrapped.rs +++ b/crates/client/src/command/client/wrapped.rs @@ -2,11 +2,15 @@ use crossterm::style::{ResetColor, SetAttribute}; use eyre::Result; use std::collections::{HashMap, HashSet}; use time::{Date, Duration, Month, OffsetDateTime, Time}; +use turtle_api::{ + client::{HistoryClient, Range}, + history::History, +}; -use crate::atuin_client::database::ClientSqlite; -use crate::atuin_client::settings::Settings; - -use crate::atuin_history::stats::{Stats, compute}; +use crate::{ + atuin_client::settings::Settings, + atuin_history::stats::{Stats, compute}, +}; #[derive(Debug)] struct WrappedStats { @@ -21,11 +25,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 +272,9 @@ 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 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 +298,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(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/turtle/src/command/contributors.rs b/crates/client/src/command/contributors.rs index b2a41522..b2a41522 100644 --- a/crates/turtle/src/command/contributors.rs +++ b/crates/client/src/command/contributors.rs diff --git a/crates/turtle/src/command/gen_completions.rs b/crates/client/src/command/gen_completions.rs index 9f13bffc..9f13bffc 100644 --- a/crates/turtle/src/command/gen_completions.rs +++ b/crates/client/src/command/gen_completions.rs diff --git a/crates/client/src/command/mod.rs b/crates/client/src/command/mod.rs new file mode 100644 index 00000000..a2e75034 --- /dev/null +++ b/crates/client/src/command/mod.rs @@ -0,0 +1,56 @@ +use clap::Subcommand; +use eyre::Result; + +#[cfg(not(windows))] +use rustix::{fs::Mode, process::umask}; + +mod client; +mod contributors; +mod gen_completions; + +#[derive(Subcommand)] +#[command(infer_subcommands = true)] +pub(crate) enum AtuinCmd { + #[command(flatten)] + Client(client::Cmd), + + /// Generate a UUID + Uuid, + + Contributors, + + /// Generate shell completions + GenCompletions(gen_completions::Cmd), +} + +impl AtuinCmd { + pub(crate) fn run(self) -> Result<()> { + #[cfg(not(windows))] + { + // set umask before we potentially open/create files + // or in other words, 077. Do not allow any access to any other user + let mode = Mode::RWXG | Mode::RWXO; + umask(mode); + } + + match self { + Self::Client(client) => client.run(), + + Self::Contributors => { + contributors::run(); + Ok(()) + } + Self::Uuid => { + println!("{}", turtle_common::utils::uuid_v7().as_simple()); + Ok(()) + } + Self::GenCompletions(gen_completions) => gen_completions.run(), + } + } +} + +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.") + }) +} |
