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
This commit is contained in:
cyqsimon 2024-02-06 23:34:03 +08:00 committed by GitHub
parent 8372abb613
commit 318bdd8955
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 300 additions and 31 deletions

133
Cargo.lock generated
View File

@ -55,6 +55,21 @@ version = "0.2.16"
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"
@ -240,6 +255,7 @@ dependencies = [
"serde",
"serde_json",
"serde_regex",
"serde_with",
"sha2",
"shellexpand",
"sql-builder",
@ -568,6 +584,19 @@ dependencies = [
"cpufeatures",
]
[[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"
@ -865,6 +894,41 @@ dependencies = [
"syn 2.0.48",
]
[[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"
@ -1567,6 +1631,35 @@ dependencies = [
"tracing",
]
[[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"
@ -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]]
@ -2946,6 +3041,35 @@ dependencies = [
"serde",
]
[[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"
@ -4088,6 +4212,15 @@ version = "0.4.0"
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"

View File

@ -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 }

View File

@ -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

View File

@ -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(())
}
}

View File

@ -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.0.hostname, |(host, _)| host),
.map_or(&self.history.hostname, |(host, _)| host),
)?,
"user" => f.write_str(
self.history
.hostname
.split_once(':')
.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(())

View File

@ -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,
);
}
};

View File

@ -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" {