diff options
Diffstat (limited to '')
| -rw-r--r-- | crates/client/src/atuin_client/mod.rs | 16 | ||||
| -rw-r--r-- | crates/client/src/atuin_client/settings/meta.rs | 0 | ||||
| -rw-r--r-- | crates/client/src/atuin_client/settings/mod.rs | 596 | ||||
| -rw-r--r-- | crates/client/src/atuin_history/mod.rs (renamed from crates/turtle/src/atuin_history/mod.rs) | 1 | ||||
| -rw-r--r-- | crates/client/src/atuin_history/stats.rs (renamed from crates/turtle/src/atuin_history/stats.rs) | 28 | ||||
| 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 | ||||
| -rw-r--r-- | crates/client/src/main.rs (renamed from crates/turtle/src/main.rs) | 29 |
30 files changed, 1992 insertions, 69 deletions
diff --git a/crates/client/src/atuin_client/mod.rs b/crates/client/src/atuin_client/mod.rs new file mode 100644 index 00000000..ed9f57b6 --- /dev/null +++ b/crates/client/src/atuin_client/mod.rs @@ -0,0 +1,16 @@ +pub(crate) mod settings; + +// pub async fn current_context(session: String) -> eyre::Result<Context> { +// let hostname = get_host_user(); +// let cwd = utils::get_current_dir(); +// let host_id = Settings::host_id().await?; +// let git_root = utils::in_git_repo(cwd.as_str()); +// +// Ok(Context { +// session, +// hostname, +// cwd, +// git_root, +// host_id: host_id.0.as_simple().to_string(), +// }) +// } diff --git a/crates/client/src/atuin_client/settings/meta.rs b/crates/client/src/atuin_client/settings/meta.rs new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/crates/client/src/atuin_client/settings/meta.rs diff --git a/crates/client/src/atuin_client/settings/mod.rs b/crates/client/src/atuin_client/settings/mod.rs new file mode 100644 index 00000000..21ac3778 --- /dev/null +++ b/crates/client/src/atuin_client/settings/mod.rs @@ -0,0 +1,596 @@ +use std::{collections::HashMap, fmt, path::PathBuf, str::FromStr, sync::OnceLock}; + +use clap::ValueEnum; +use config::{ + Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState, +}; +use eyre::{Context, Error, Result, bail, eyre}; +use fs_err::create_dir_all; +use regex::RegexSet; +use serde::{Deserialize, Serialize}; +use serde_with::DeserializeFromStr; +use time::{UtcOffset, format_description::FormatItem, macros::format_description}; +use tracing::info; +use turtle_common::utils; + +static DATA_DIR: OnceLock<PathBuf> = OnceLock::new(); + +/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats. +/// +/// Note that the parsing of this struct needs to be done before starting any +/// multithreaded runtime, otherwise it will fail on most Unix systems. +/// +/// See: <https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426> +#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)] +pub(crate) struct Timezone(pub(crate) UtcOffset); +impl fmt::Display for Timezone { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} +/// format: <+|-><hour>[:<minute>[:<second>]] +static OFFSET_FMT: &[FormatItem<'_>] = format_description!( + "[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]" +); +impl FromStr for Timezone { + type Err = Error; + + fn from_str(s: &str) -> Result<Self> { + // local timezone + if matches!(s.to_lowercase().as_str(), "l" | "local") { + // There have been some timezone issues, related to errors fetching it on some + // platforms + // Rather than fail to start, fallback to UTC. The user should still be able to specify + // their timezone manually in the config file. + let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); + return Ok(Self(offset)); + } + + if matches!(s.to_lowercase().as_str(), "0" | "utc") { + let offset = UtcOffset::UTC; + return Ok(Self(offset)); + } + + // offset from UTC + if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) { + return Ok(Self(offset)); + } + + // IDEA: Currently named timezones are not supported, because the well-known crate + // for this is `chrono_tz`, which is not really interoperable with the datetime crate + // that we currently use - `time`. If ever we migrate to using `chrono`, this would + // be a good feature to add. + + bail!(r#""{s}" is not a valid timezone spec"#) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct Stats { + /// sudo, etc. commands we want to strip off + #[serde(default = "Stats::common_prefix_default")] + pub(crate) common_prefix: Vec<String>, + + /// kubectl, commands we should consider subcommands for + #[serde(default = "Stats::common_subcommands_default")] + pub(crate) common_subcommands: Vec<String>, + + /// cd, ls, etc. commands we want to completely hide from stats + #[serde(default = "Stats::ignored_commands_default")] + pub(crate) ignored_commands: Vec<String>, +} + +impl Stats { + fn common_prefix_default() -> Vec<String> { + vec!["sudo", "doas"].into_iter().map(String::from).collect() + } + + fn common_subcommands_default() -> Vec<String> { + vec![ + "apt", + "cargo", + "composer", + "dnf", + "docker", + "dotnet", + "git", + "go", + "ip", + "jj", + "kubectl", + "nix", + "nmcli", + "npm", + "pecl", + "pnpm", + "podman", + "port", + "systemctl", + "tmux", + "yarn", + ] + .into_iter() + .map(String::from) + .collect() + } + + fn ignored_commands_default() -> Vec<String> { + vec![] + } +} + +impl Default for Stats { + fn default() -> Self { + Self { + common_prefix: Self::common_prefix_default(), + common_subcommands: Self::common_subcommands_default(), + ignored_commands: Self::ignored_commands_default(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, Default)] +pub(crate) struct Daemon { + /// The path to the unix socket used by the daemon + pub(crate) socket_path: String, + + /// Use a socket passed via systemd's socket activation protocol, instead of the path + pub(crate) systemd_socket: bool, +} + +// The preview height strategy also takes max_preview_height into account. +#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] +pub(crate) enum PreviewStrategy { + // Preview height is calculated for the length of the selected command. + #[serde(rename = "auto")] + Auto, + + // Preview height is calculated for the length of the longest command stored in the history. + #[serde(rename = "static")] + Static, + + // max_preview_height is used as fixed height. + #[serde(rename = "fixed")] + Fixed, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct Settings { + data_dir: Option<String>, + pub(crate) timezone: Timezone, + + pub(crate) strip_trailing_whitespace: bool, + + #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)] + pub(crate) history_filter: RegexSet, + + #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)] + pub(crate) cwd_filter: RegexSet, + + pub(crate) secrets_filter: bool, + + #[serde(default)] + pub(crate) stats: Stats, + + #[serde(default)] + pub(crate) daemon: Daemon, +} + +impl Settings { + fn builder() -> Result<ConfigBuilder<DefaultState>> { + Self::builder_with_data_dir(&utils::data_dir()) + } + + #[expect(clippy::too_many_lines)] + fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> { + let db_path = data_dir.join("history.db"); + let record_store_path = data_dir.join("records.db"); + let kv_path = data_dir.join("kv.db"); + let scripts_path = data_dir.join("scripts.db"); + let socket_path = utils::daemon_socket_path(); + let pidfile_path = data_dir.join("atuin-daemon.pid"); + + let key_path = data_dir.join("key"); + + Ok(Config::builder() + .set_default("history_format", "{time}\t{command}\t{duration}")? + .set_default("db_path", db_path.to_str())? + .set_default("record_store_path", record_store_path.to_str())? + .set_default("key_path", key_path.to_str())? + .set_default("dialect", "us")? + .set_default("timezone", "local")? + .set_default("auto_sync", true)? + .set_default("sync.address", "https://api.atuin.sh")? + .set_default("sync_frequency", "5m")? + .set_default("search_mode", "fuzzy")? + .set_default("filter_mode", None::<String>)? + .set_default("style", "compact")? + .set_default("inline_height", 40)? + .set_default("show_preview", true)? + .set_default("preview.strategy", "auto")? + .set_default("max_preview_height", 4)? + .set_default("show_help", true)? + .set_default("show_tabs", true)? + .set_default("show_numeric_shortcuts", true)? + .set_default("auto_hide_height", 8)? + .set_default("invert", false)? + .set_default("exit_mode", "return-original")? + .set_default("word_jump_mode", "emacs")? + .set_default( + "word_chars", + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + )? + .set_default("scroll_context_lines", 1)? + .set_default("shell_up_key_binding", false)? + .set_default("workspaces", false)? + .set_default("ctrl_n_shortcuts", false)? + .set_default("secrets_filter", true)? + .set_default("strip_trailing_whitespace", true)? + .set_default("network_connect_timeout", 5)? + .set_default("network_timeout", 30)? + .set_default("local_timeout", 2.0)? + // enter_accept defaults to false here, but true in the default config file. The dissonance is + // intentional! + // Existing users will get the default "False", so we don't mess with any potential + // muscle memory. + // New users will get the new default, that is more similar to what they are used to. + .set_default("enter_accept", false)? + .set_default("keys.scroll_exits", true)? + .set_default("keys.accept_past_line_end", true)? + .set_default("keys.exit_past_line_start", true)? + .set_default("keys.accept_past_line_start", false)? + .set_default("keys.accept_with_backspace", false)? + .set_default("keys.prefix", "a")? + .set_default("keymap_mode", "emacs")? + .set_default("keymap_mode_shell", "auto")? + .set_default("keymap_cursor", HashMap::<String, String>::new())? + .set_default("smart_sort", false)? + .set_default("command_chaining", false)? + .set_default("store_failed", true)? + .set_default("daemon.sync_frequency", 300)? + .set_default("daemon.socket_path", socket_path.to_str())? + .set_default("daemon.pidfile_path", pidfile_path.to_str())? + .set_default("daemon.systemd_socket", false)? + .set_default("daemon.tcp_port", 8889)? + .set_default("logs.enabled", true)? + .set_default("logs.level", "info")? + .set_default("logs.search.file", "search.log")? + .set_default("logs.daemon.file", "daemon.log")? + .set_default("logs.ai.file", "ai.log")? + .set_default("kv.db_path", kv_path.to_str())? + .set_default("scripts.db_path", scripts_path.to_str())? + .set_default("search.recency_score_multiplier", 1.0)? + .set_default("search.frequency_score_multiplier", 1.0)? + .set_default("search.frecency_score_multiplier", 1.0)? + .set_default("ai.session_continue_minutes", 60)? + .set_default("ai.send_cwd", false)? + .set_default("ai.opening.send_cwd", false)? + .set_default("ai.opening.send_last_command", false)? + .set_default( + "search.filters", + vec![ + "global", + "host", + "session", + "workspace", + "directory", + "session-preload", + ], + )? + .set_default("theme.name", "default")? + .set_default("theme.debug", None::<bool>)? + .set_default("tmux.enabled", false)? + .set_default("tmux.width", "80%")? + .set_default("tmux.height", "60%")? + .set_default( + "prefers_reduced_motion", + std::env::var("NO_MOTION").ok().map_or_else( + || config::Value::new(None, config::ValueKind::Boolean(false)), + |_| config::Value::new(None, config::ValueKind::Boolean(true)), + ), + )? + .set_default("no_mouse", false)? + .add_source( + Environment::with_prefix("atuin") + .prefix_separator("_") + .separator("__"), + )) + } + + pub(crate) fn get_config_path() -> Result<PathBuf> { + let config_dir = utils::config_dir(); + + create_dir_all(&config_dir) + .wrap_err_with(|| format!("could not create dir {}", config_dir.display()))?; + + let mut config_file = std::env::var("ATUIN_CONFIG_DIR").map_or_else( + |_| { + let mut config_file = PathBuf::new(); + config_file.push(config_dir); + config_file + }, + PathBuf::from, + ); + + config_file.push("config.toml"); + + Ok(config_file) + } + + /// Build a merged `Config` from defaults, config file, and environment. + /// + /// This resolves `data_dir`, initializes the data directory on disk, + /// and layers defaults → config file → env overrides. Both `new()` and + /// `get_config_value()` use this so the resolution logic lives in one place. + fn build_config() -> Result<Config> { + let config_file = Self::get_config_path()?; + + // extract data_dir first so we can use it as the base for other path defaults + let effective_data_dir = if config_file.exists() { + #[derive(Deserialize, Default)] + struct DataDirOnly { + data_dir: Option<String>, + } + + let config_file_str = config_file + .to_str() + .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?; + + let partial_config = Config::builder() + .add_source(ConfigFile::new(config_file_str, FileFormat::Toml)) + .add_source( + Environment::with_prefix("atuin") + .prefix_separator("_") + .separator("__"), + ) + .build() + .ok(); + + let custom_data_dir = partial_config + .and_then(|c| c.try_deserialize::<DataDirOnly>().ok()) + .and_then(|d| d.data_dir); + + match custom_data_dir { + Some(dir) => { + let expanded = shellexpand::full(&dir) + .map_err(|e| eyre!("failed to expand data_dir path: {}", e))?; + PathBuf::from(expanded.as_ref()) + } + None => utils::data_dir(), + } + } else { + utils::data_dir() + }; + + DATA_DIR.set(effective_data_dir.clone()).ok(); + + create_dir_all(&effective_data_dir) + .wrap_err_with(|| format!("could not create dir {}", effective_data_dir.display()))?; + + let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?; + + config_builder = if config_file.exists() { + let config_file_str = config_file + .to_str() + .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?; + config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml)) + } else { + // TODO(@bpeetz): Rework the config handling, so that we can actually auto-write a + // file with defaults. <2026-06-13> + create_dir_all(config_file.parent().unwrap())?; + + info!( + "No config file at: `{}`. Not adding one.", + config_file.display() + ); + + config_builder + }; + + // all paths should be expanded + let built = config_builder.build_cloned()?; + config_builder = [ + "db_path", + "record_store_path", + "key_path", + "daemon.socket_path", + "daemon.pidfile_path", + "logs.dir", + "logs.search.file", + "logs.daemon.file", + ] + .iter() + .map(|key| (key, built.get_string(key).unwrap_or_default())) + .filter_map(|(key, value)| match Self::expand_path(&value) { + Ok(expanded) => Some((key, expanded)), + Err(e) => { + log::warn!("failed to expand path for {key}: {e}"); + None + } + }) + .fold(config_builder, |builder, (key, value)| { + builder + .set_override(key, value) + .unwrap_or_else(|_| panic!("failed to set absolute path override for {key}")) + }); + + config_builder.build().map_err(Into::into) + } + + /// Look up a single config value by dotted key (e.g. `"daemon.sync_frequency"`). + /// + /// Returns the effective value after merging defaults, config file, and + /// environment — without the side-effects of full `Settings` construction + /// (meta store init, path expansion, etc.). + pub(crate) fn get_config_value(key: &str) -> Result<String> { + let config = Self::build_config()?; + let value: config::Value = config + .get(key) + .map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?; + Ok(Self::format_resolved_value(&value, key)) + } + + fn format_resolved_value(value: &config::Value, prefix: &str) -> String { + use config::ValueKind; + + match &value.kind { + ValueKind::Nil => String::new(), + ValueKind::Boolean(b) => b.to_string(), + ValueKind::I64(i) => i.to_string(), + ValueKind::I128(i) => i.to_string(), + ValueKind::U64(u) => u.to_string(), + ValueKind::U128(u) => u.to_string(), + ValueKind::Float(f) => f.to_string(), + ValueKind::String(s) => s.clone(), + ValueKind::Array(arr) => { + let items: Vec<String> = arr + .iter() + .map(|v| Self::format_resolved_value(v, "")) + .collect(); + format!("[{}]", items.join(", ")) + } + ValueKind::Table(map) => { + let mut lines = Vec::new(); + let mut keys: Vec<_> = map.keys().collect(); + keys.sort(); + + for k in keys { + let v = &map[k]; + let full_key = if prefix.is_empty() { + k.clone() + } else { + format!("{prefix}.{k}") + }; + + match &v.kind { + ValueKind::Table(_) => { + lines.push(Self::format_resolved_value(v, &full_key)); + } + _ => { + lines.push(format!( + "{} = {}", + full_key, + Self::format_resolved_value(v, "") + )); + } + } + } + + lines.join("\n") + } + } + } + + pub(crate) fn new() -> Result<Self> { + let config = Self::build_config()?; + let settings: Self = config + .try_deserialize() + .map_err(|e| eyre!("failed to deserialize: {}", e))?; + + Ok(settings) + } + + fn expand_path(path: &str) -> Result<String> { + shellexpand::full(&path) + .map(|p| p.to_string()) + .map_err(|e| eyre!("failed to expand path: {}", e)) + } +} + +impl Default for Settings { + fn default() -> Self { + // if this panics something is very wrong, as the default config + // does not build or deserialize into the settings struct + Self::builder() + .expect("Could not build default") + .build() + .expect("Could not build config") + .try_deserialize() + .expect("Could not deserialize config") + } +} + +#[cfg(test)] +pub(crate) fn test_local_timeout() -> f64 { + std::env::var("ATUIN_TEST_LOCAL_TIMEOUT") + .ok() + .and_then(|x| x.parse().ok()) + // this hardcoded value should be replaced by a simple way to get the + // default local_timeout of Settings if possible + .unwrap_or(2.0) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use eyre::Result; + + use super::Timezone; + + #[test] + fn can_parse_offset_timezone_spec() -> Result<()> { + assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0)); + assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0)); + assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0)); + assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0)); + + // single digit hours are allowed + assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0)); + assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0)); + assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0)); + assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0)); + + // fully qualified form + assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0)); + assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0)); + + // these offsets don't really exist but are supported anyway + assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0)); + assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0)); + assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45)); + assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45)); + + // require a leading sign for clarity + assert!(Timezone::from_str("5").is_err()); + assert!(Timezone::from_str("10:30").is_err()); + + Ok(()) + } + + #[test] + fn builder_with_data_dir_uses_custom_paths() -> Result<()> { + use std::path::PathBuf; + + let custom_dir = PathBuf::from("/custom/data/dir"); + let builder = super::Settings::builder_with_data_dir(&custom_dir)?; + let config = builder.build()?; + + let db_path: String = config.get("db_path")?; + let key_path: String = config.get("key_path")?; + let record_store_path: String = config.get("record_store_path")?; + let kv_db_path: String = config.get("kv.db_path")?; + let scripts_db_path: String = config.get("scripts.db_path")?; + let daemon_socket_path: String = config.get("daemon.socket_path")?; + let daemon_pidfile_path: String = config.get("daemon.pidfile_path")?; + + assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap()); + assert_eq!(key_path, custom_dir.join("key").to_str().unwrap()); + assert_eq!( + record_store_path, + custom_dir.join("records.db").to_str().unwrap() + ); + assert_eq!(kv_db_path, custom_dir.join("kv.db").to_str().unwrap()); + assert_eq!( + scripts_db_path, + custom_dir.join("scripts.db").to_str().unwrap() + ); + + assert_eq!( + daemon_pidfile_path, + custom_dir.join("atuin-daemon.pid").to_str().unwrap() + ); + + Ok(()) + } +} diff --git a/crates/turtle/src/atuin_history/mod.rs b/crates/client/src/atuin_history/mod.rs index 41336a14..b3ca0d2f 100644 --- a/crates/turtle/src/atuin_history/mod.rs +++ b/crates/client/src/atuin_history/mod.rs @@ -1,2 +1 @@ -pub(crate) mod sort; pub(crate) mod stats; diff --git a/crates/turtle/src/atuin_history/stats.rs b/crates/client/src/atuin_history/stats.rs index c53dafb2..40be14f9 100644 --- a/crates/turtle/src/atuin_history/stats.rs +++ b/crates/client/src/atuin_history/stats.rs @@ -2,9 +2,10 @@ use std::collections::{HashMap, HashSet}; use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor}; use serde::{Deserialize, Serialize}; +use turtle_api::history::History; use unicode_segmentation::UnicodeSegmentation; -use crate::atuin_client::{history::History, settings::Settings}; +use crate::atuin_client::settings::Settings; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct Stats { @@ -278,9 +279,9 @@ pub(crate) fn compute( #[cfg(test)] mod tests { - use crate::atuin_client::history::History; use crate::atuin_client::settings::Settings; use time::OffsetDateTime; + use turtle_daemon::aclient::history::History; use super::compute; use super::{interesting_command, split_at_pipe, strip_leading_env_vars}; @@ -301,29 +302,6 @@ mod tests { } #[test] - fn ignored_commands() { - let mut settings = Settings::new().unwrap(); - settings.stats.ignored_commands.push("cd".to_string()); - - let history = [ - History::import() - .timestamp(OffsetDateTime::now_utc()) - .command("cd foo") - .build() - .into(), - History::import() - .timestamp(OffsetDateTime::now_utc()) - .command("cargo build stuff") - .build() - .into(), - ]; - - let stats = compute(&settings, &history, 10, 1).expect("failed to compute stats"); - assert_eq!(stats.total_commands, 1); - assert_eq!(stats.unique_commands, 1); - } - - #[test] fn interesting_commands() { let settings = Settings::new().unwrap(); 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.") + }) +} diff --git a/crates/turtle/src/main.rs b/crates/client/src/main.rs index 12858140..45f09b1f 100644 --- a/crates/turtle/src/main.rs +++ b/crates/client/src/main.rs @@ -4,12 +4,6 @@ clippy::missing_const_for_fn, // not 100% reliable clippy::redundant_pub_crate, )] -#![expect( - clippy::cast_possible_wrap, - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - reason = "We should remove all of these. But it's just a lot of work in this code-base" -)] use clap::Parser; use clap::builder::Styles; @@ -17,18 +11,12 @@ use clap::builder::styling::{AnsiColor, Effects}; use eyre::Result; use command::AtuinCmd; +use tracing_subscriber::EnvFilter; mod command; pub(crate) mod atuin_client; -pub(crate) mod atuin_common; -pub(crate) mod atuin_daemon; pub(crate) mod atuin_history; -pub(crate) mod atuin_pty_proxy; -pub(crate) mod atuin_server; - -mod print_error; -mod sync; const VERSION: &str = env!("CARGO_PKG_VERSION"); const SHA: &str = env!("GIT_HASH"); @@ -72,5 +60,20 @@ impl Atuin { } fn main() -> Result<()> { + if let Err(e) = tracing_subscriber::fmt() + .with_file(true) + .with_line_number(true) + .with_level(true) + .without_time() + .with_env_filter( + EnvFilter::builder() + .from_env_lossy() + .add_directive("turtle=debug".parse().unwrap()), + ) + .try_init() + { + eprintln!("failed to initialize logging: {e}"); + } + Atuin::parse().run() } |
