summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorcyqsimon <28627918+cyqsimon@users.noreply.github.com>2024-02-06 23:34:03 +0800
committerGitHub <noreply@github.com>2024-02-06 15:34:03 +0000
commit318bdd895590c97dd53f8d3661d76fa1c0cd67a0 (patch)
tree98fc7c88708230d1a5318cc26424846d4539a2bc
parent8372abb6132e18767e55f87152cc3ac45bd0205b (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.lock133
-rw-r--r--atuin-client/Cargo.toml3
-rw-r--r--atuin-client/config.toml6
-rw-r--r--atuin-client/src/settings.rs98
-rw-r--r--atuin/src/command/client/history.rs75
-rw-r--r--atuin/src/command/client/search.rs13
-rw-r--r--atuin/src/command/client/stats.rs3
7 files changed, 300 insertions, 31 deletions
diff --git a/Cargo.lock b/Cargo.lock
index e7c52eef..ae28eb04 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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" {