diff options
author | cyqsimon <28627918+cyqsimon@users.noreply.github.com> | 2024-02-06 23:34:03 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-02-06 15:34:03 +0000 |
commit | 318bdd895590c97dd53f8d3661d76fa1c0cd67a0 (patch) | |
tree | 98fc7c88708230d1a5318cc26424846d4539a2bc | |
parent | 8372abb6132e18767e55f87152cc3ac45bd0205b (diff) |
Add timezone configuration option & CLI overrides (#1517)
* Allow specifying a timezone in history search/list
* Fix clippy complaints
* Add a bit more comment on supporting named timezones
* Add rudimentary tests
* Ditch local timezone test
* Timezone configuration support
* Set default timezone to `local`
* `--tz` -> `--timezone`
`--tz` is kept as a visible alias
-rw-r--r-- | Cargo.lock | 133 | ||||
-rw-r--r-- | atuin-client/Cargo.toml | 3 | ||||
-rw-r--r-- | atuin-client/config.toml | 6 | ||||
-rw-r--r-- | atuin-client/src/settings.rs | 98 | ||||
-rw-r--r-- | atuin/src/command/client/history.rs | 75 | ||||
-rw-r--r-- | atuin/src/command/client/search.rs | 13 | ||||
-rw-r--r-- | atuin/src/command/client/stats.rs | 3 |
7 files changed, 300 insertions, 31 deletions
@@ -56,6 +56,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" [[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] name = "anstream" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -240,6 +255,7 @@ dependencies = [ "serde", "serde_json", "serde_regex", + "serde_with", "sha2", "shellexpand", "sql-builder", @@ -569,6 +585,19 @@ dependencies = [ ] [[package]] +name = "chrono" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f13690e35a5e4ace198e7beea2895d29f3a9cc55015fcebe6336bd2010af9eb" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-targets 0.52.0", +] + +[[package]] name = "cipher" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -866,6 +895,41 @@ dependencies = [ ] [[package]] +name = "darling" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.48", +] + +[[package]] +name = "darling_macro" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.48", +] + +[[package]] name = "der" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1568,6 +1632,35 @@ dependencies = [ ] [[package]] +name = "iana-time-zone" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] name = "idna" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1591,6 +1684,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1601,6 +1695,7 @@ checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ "equivalent", "hashbrown 0.14.3", + "serde", ] [[package]] @@ -2947,6 +3042,35 @@ dependencies = [ ] [[package]] +name = "serde_with" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5c9fdb6b00a489875b22efd4b78fe2b363b72265cc5f6eb2e2b9ee270e6140c" +dependencies = [ + "base64 0.21.7", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.1.0", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbff351eb4b33600a2e138dfa0b10b65a238ea8ff8fb2387c422c5022a3e8298" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4089,6 +4213,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/atuin-client/Cargo.toml b/atuin-client/Cargo.toml index e224ad83..991c594e 100644 --- a/atuin-client/Cargo.toml +++ b/atuin-client/Cargo.toml @@ -22,7 +22,7 @@ atuin-common = { path = "../atuin-common", version = "17.2.1" } log = { workspace = true } base64 = { workspace = true } -time = { workspace = true } +time = { workspace = true, features = ["macros", "formatting"] } clap = { workspace = true } eyre = { workspace = true } directories = { workspace = true } @@ -53,6 +53,7 @@ thiserror = { workspace = true } futures = "0.3" crypto_secretbox = "0.1.1" generic-array = { version = "0.14", features = ["serde"] } +serde_with = "3.5.1" # encryption rusty_paseto = { version = "0.6.0", default-features = false } diff --git a/atuin-client/config.toml b/atuin-client/config.toml index 1ddaa12e..faed994c 100644 --- a/atuin-client/config.toml +++ b/atuin-client/config.toml @@ -16,6 +16,12 @@ ## date format used, either "us" or "uk" # dialect = "us" +## default timezone to use when displaying time +## either "l", "local" to use the system's current local timezone, or an offset +## from UTC in the format of "<+|->H[H][:M[M][:S[S]]]" +## for example: "+9", "-05", "+03:30", "-01:23:45", etc. +# timezone = "local" + ## enable or disable automatic sync # auto_sync = true diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs index 833311c8..7abacd0d 100644 --- a/atuin-client/src/settings.rs +++ b/atuin-client/src/settings.rs @@ -1,6 +1,7 @@ use std::{ collections::HashMap, convert::TryFrom, + fmt, io::prelude::*, path::{Path, PathBuf}, str::FromStr, @@ -11,13 +12,18 @@ use clap::ValueEnum; use config::{ builder::DefaultState, Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, }; -use eyre::{eyre, Context, Result}; +use eyre::{bail, eyre, Context, Error, Result}; use fs_err::{create_dir_all, File}; use parse_duration::parse; use regex::RegexSet; use semver::Version; use serde::Deserialize; -use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use serde_with::DeserializeFromStr; +use time::{ + format_description::{well_known::Rfc3339, FormatItem}, + macros::format_description, + OffsetDateTime, UtcOffset, +}; use uuid::Uuid; pub const HISTORY_PAGE_SIZE: i64 = 100; @@ -123,6 +129,46 @@ impl From<Dialect> for interim::Dialect { } } +/// 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)] +pub struct Timezone(pub 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") { + let offset = UtcOffset::current_local_offset()?; + 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, Copy)] pub enum Style { #[serde(rename = "auto")] @@ -251,6 +297,7 @@ pub struct Sync { #[derive(Clone, Debug, Deserialize)] pub struct Settings { pub dialect: Dialect, + pub timezone: Timezone, pub style: Style, pub auto_sync: bool, pub update_check: bool, @@ -305,11 +352,6 @@ pub struct Settings { // config! Keep secrets and settings apart. #[serde(skip)] pub session_token: String, - - // This is determined at startup and cached. - // This is due to non-threadsafe get-env limitations. - #[serde(skip)] - pub local_tz: Option<time::UtcOffset>, } impl Settings { @@ -488,6 +530,7 @@ impl Settings { .set_default("key_path", key_path.to_str())? .set_default("session_path", session_path.to_str())? .set_default("dialect", "us")? + .set_default("timezone", "local")? .set_default("auto_sync", true)? .set_default("update_check", cfg!(feature = "check-update"))? .set_default("sync_address", "https://api.atuin.sh")? @@ -599,8 +642,6 @@ impl Settings { settings.session_token = String::from("not logged in"); } - settings.local_tz = time::UtcOffset::current_local_offset().ok(); - Ok(settings) } @@ -621,3 +662,42 @@ impl Default for Settings { .expect("Could not deserialize config") } } + +#[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(()) + } +} diff --git a/atuin/src/command/client/history.rs b/atuin/src/command/client/history.rs index e9ebaec1..b0a70b81 100644 --- a/atuin/src/command/client/history.rs +++ b/atuin/src/command/client/history.rs @@ -16,7 +16,7 @@ use atuin_client::{ record::sqlite_store::SqliteStore, settings::{ FilterMode::{Directory, Global, Session}, - Settings, + Settings, Timezone, }, }; @@ -71,6 +71,14 @@ pub enum Cmd { #[arg(action = clap::ArgAction::Set)] reverse: bool, + /// Display the command time in another timezone other than the configured default. + /// + /// This option takes one of the following kinds of values: + /// - the special value "local" (or "l") which refers to the system time zone + /// - an offset from UTC (e.g. "+9", "-2:30") + #[arg(long, visible_alias = "tz")] + timezone: Option<Timezone>, + /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {exit} and {time}. /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] @@ -86,6 +94,14 @@ pub enum Cmd { #[arg(long)] cmd_only: bool, + /// Display the command time in another timezone other than the configured default. + /// + /// This option takes one of the following kinds of values: + /// - the special value "local" (or "l") which refers to the system time zone + /// - an offset from UTC (e.g. "+9", "-2:30") + #[arg(long, visible_alias = "tz")] + timezone: Option<Timezone>, + /// Available variables: {command}, {directory}, {duration}, {user}, {host} and {time}. /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] @@ -121,6 +137,7 @@ pub fn print_list( format: Option<&str>, print0: bool, reverse: bool, + tz: Timezone, ) { let w = std::io::stdout(); let mut w = w.lock(); @@ -150,8 +167,12 @@ pub fn print_list( let entry_terminator = if print0 { "\0" } else { "\n" }; let flush_each_line = print0; - for h in iterator { - let fh = FmtHistory(h, CmdFormat::for_output(&w)); + for history in iterator { + let fh = FmtHistory { + history, + cmd_format: CmdFormat::for_output(&w), + tz: &tz, + }; let args = parsed_fmt.with_args(&fh); let write = write!(w, "{args}{entry_terminator}"); if let Err(err) = args.status() { @@ -179,14 +200,19 @@ fn check_for_write_errors(write: Result<(), io::Error>) { } } -/// Wrapper around `History` so we can format output dynamically at runtime -struct FmtHistory<'a>(&'a History, CmdFormat); +/// 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() { @@ -205,35 +231,41 @@ impl FormatKey for FmtHistory<'_> { #[allow(clippy::cast_sign_loss)] fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> { match key { - "command" => match self.1 { - CmdFormat::Literal => f.write_str(self.0.command.trim()), - CmdFormat::Escaped => f.write_str(&self.0.command.trim().escape_control()), + "command" => match self.cmd_format { + CmdFormat::Literal => f.write_str(self.history.command.trim()), + CmdFormat::Escaped => f.write_str(&self.history.command.trim().escape_control()), }?, - "directory" => f.write_str(self.0.cwd.trim())?, - "exit" => f.write_str(&self.0.exit.to_string())?, + "directory" => f.write_str(self.history.cwd.trim())?, + "exit" => f.write_str(&self.history.exit.to_string())?, "duration" => { - let dur = Duration::from_nanos(std::cmp::max(self.0.duration, 0) as u64); + let dur = Duration::from_nanos(std::cmp::max(self.history.duration, 0) as u64); format_duration_into(dur, f)?; } "time" => { - self.0 + self.history .timestamp + .to_offset(self.tz.0) .format(TIME_FMT) .map_err(|_| fmt::Error)? .fmt(f)?; } "relativetime" => { - let since = OffsetDateTime::now_utc() - self.0.timestamp; + 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.0 + self.history + .hostname + .split_once(':') + .map_or(&self.history.hostname, |(host, _)| host), + )?, + "user" => f.write_str( + self.history .hostname .split_once(':') - .map_or(&self.0.hostname, |(host, _)| host), + .map_or("", |(_, user)| user), )?, - "user" => f.write_str(self.0.hostname.split_once(':').map_or("", |(_, user)| user))?, _ => return Err(FormatKeyError::UnknownKey), } Ok(()) @@ -353,6 +385,7 @@ impl Cmd { include_deleted: bool, print0: bool, reverse: bool, + tz: Timezone, ) -> Result<()> { let filters = match (session, cwd) { (true, true) => [Session, Directory], @@ -374,6 +407,7 @@ impl Cmd { }, print0, reverse, + tz, ); Ok(()) @@ -411,11 +445,13 @@ impl Cmd { cmd_only, print0, reverse, + timezone, format, } => { let mode = ListMode::from_flags(human, cmd_only); + let tz = timezone.unwrap_or(settings.timezone); Self::handle_list( - db, settings, context, session, cwd, mode, format, false, print0, reverse, + db, settings, context, session, cwd, mode, format, false, print0, reverse, tz, ) .await } @@ -423,10 +459,12 @@ impl Cmd { Self::Last { human, cmd_only, + timezone, format, } => { let last = db.last().await?; let last = last.as_ref().map(std::slice::from_ref).unwrap_or_default(); + let tz = timezone.unwrap_or(settings.timezone); print_list( last, ListMode::from_flags(human, cmd_only), @@ -436,6 +474,7 @@ impl Cmd { }, false, true, + tz, ); Ok(()) diff --git a/atuin/src/command/client/search.rs b/atuin/src/command/client/search.rs index f724ef7f..4a70cb98 100644 --- a/atuin/src/command/client/search.rs +++ b/atuin/src/command/client/search.rs @@ -10,7 +10,7 @@ use atuin_client::{ encryption, history::{store::HistoryStore, History}, record::sqlite_store::SqliteStore, - settings::{FilterMode, KeymapMode, SearchMode, Settings}, + settings::{FilterMode, KeymapMode, SearchMode, Settings, Timezone}, }; use super::history::ListMode; @@ -101,6 +101,14 @@ pub struct Cmd { #[arg(long, short)] reverse: bool, + /// Display the command time in another timezone other than the configured default. + /// + /// This option takes one of the following kinds of values: + /// - the special value "local" (or "l") which refers to the system time zone + /// - an offset from UTC (e.g. "+9", "-2:30") + #[arg(long, visible_alias = "tz")] + timezone: Option<Timezone>, + /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and /// {relativetime}. /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" @@ -220,12 +228,15 @@ impl Cmd { None => Some(settings.history_format.as_str()), _ => self.format.as_deref(), }; + let tz = self.timezone.unwrap_or(settings.timezone); + super::history::print_list( &entries, ListMode::from_flags(self.human, self.cmd_only), format, false, true, + tz, ); } }; diff --git a/atuin/src/command/client/stats.rs b/atuin/src/command/client/stats.rs index e8fff45f..359f0d46 100644 --- a/atuin/src/command/client/stats.rs +++ b/atuin/src/command/client/stats.rs @@ -86,8 +86,7 @@ impl Cmd { self.period.join(" ") }; - let now = OffsetDateTime::now_utc(); - let now = settings.local_tz.map_or(now, |local| now.to_offset(local)); + 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" { |