aboutsummaryrefslogtreecommitdiffstats
path: root/atuin-client/src/settings.rs
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@elliehuxtable.com>2024-04-18 16:41:28 +0100
committerGitHub <noreply@github.com>2024-04-18 16:41:28 +0100
commit95cc472037fcb3207b510e67f1a44af4e2a2cae9 (patch)
treefc1d3e71d8e0bdb806370e4144fd6f373bcc9c5e /atuin-client/src/settings.rs
parentfeat: show preview auto (#1804) (diff)
downloadatuin-95cc472037fcb3207b510e67f1a44af4e2a2cae9.zip
chore: move crates into crates/ dir (#1958)
I'd like to tidy up the root a little, and it's nice to have all the rust crates in one place
Diffstat (limited to 'atuin-client/src/settings.rs')
-rw-r--r--atuin-client/src/settings.rs784
1 files changed, 0 insertions, 784 deletions
diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs
deleted file mode 100644
index daf8fe34..00000000
--- a/atuin-client/src/settings.rs
+++ /dev/null
@@ -1,784 +0,0 @@
-use std::{
- collections::HashMap,
- convert::TryFrom,
- fmt,
- io::prelude::*,
- path::{Path, PathBuf},
- str::FromStr,
-};
-
-use atuin_common::record::HostId;
-use clap::ValueEnum;
-use config::{
- builder::DefaultState, Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat,
-};
-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 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;
-pub const LAST_SYNC_FILENAME: &str = "last_sync_time";
-pub const LAST_VERSION_CHECK_FILENAME: &str = "last_version_check_time";
-pub const LATEST_VERSION_FILENAME: &str = "latest_version";
-pub const HOST_ID_FILENAME: &str = "host_id";
-static EXAMPLE_CONFIG: &str = include_str!("../config.toml");
-
-mod dotfiles;
-
-#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq)]
-pub enum SearchMode {
- #[serde(rename = "prefix")]
- Prefix,
-
- #[serde(rename = "fulltext")]
- #[clap(aliases = &["fulltext"])]
- FullText,
-
- #[serde(rename = "fuzzy")]
- Fuzzy,
-
- #[serde(rename = "skim")]
- Skim,
-}
-
-impl SearchMode {
- pub fn as_str(&self) -> &'static str {
- match self {
- SearchMode::Prefix => "PREFIX",
- SearchMode::FullText => "FULLTXT",
- SearchMode::Fuzzy => "FUZZY",
- SearchMode::Skim => "SKIM",
- }
- }
- pub fn next(&self, settings: &Settings) -> Self {
- match self {
- SearchMode::Prefix => SearchMode::FullText,
- // if the user is using skim, we go to skim
- SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim,
- // otherwise fuzzy.
- SearchMode::FullText => SearchMode::Fuzzy,
- SearchMode::Fuzzy | SearchMode::Skim => SearchMode::Prefix,
- }
- }
-}
-
-#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)]
-pub enum FilterMode {
- #[serde(rename = "global")]
- Global = 0,
-
- #[serde(rename = "host")]
- Host = 1,
-
- #[serde(rename = "session")]
- Session = 2,
-
- #[serde(rename = "directory")]
- Directory = 3,
-
- #[serde(rename = "workspace")]
- Workspace = 4,
-}
-
-impl FilterMode {
- pub fn as_str(&self) -> &'static str {
- match self {
- FilterMode::Global => "GLOBAL",
- FilterMode::Host => "HOST",
- FilterMode::Session => "SESSION",
- FilterMode::Directory => "DIRECTORY",
- FilterMode::Workspace => "WORKSPACE",
- }
- }
-}
-
-#[derive(Clone, Debug, Deserialize, Copy)]
-pub enum ExitMode {
- #[serde(rename = "return-original")]
- ReturnOriginal,
-
- #[serde(rename = "return-query")]
- ReturnQuery,
-}
-
-// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
-// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim
-#[derive(Clone, Debug, Deserialize, Copy)]
-pub enum Dialect {
- #[serde(rename = "us")]
- Us,
-
- #[serde(rename = "uk")]
- Uk,
-}
-
-impl From<Dialect> for interim::Dialect {
- fn from(d: Dialect) -> interim::Dialect {
- match d {
- Dialect::Uk => interim::Dialect::Uk,
- Dialect::Us => interim::Dialect::Us,
- }
- }
-}
-
-/// 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") {
- // 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, Copy)]
-pub enum Style {
- #[serde(rename = "auto")]
- Auto,
-
- #[serde(rename = "full")]
- Full,
-
- #[serde(rename = "compact")]
- Compact,
-}
-
-#[derive(Clone, Debug, Deserialize, Copy)]
-pub enum WordJumpMode {
- #[serde(rename = "emacs")]
- Emacs,
-
- #[serde(rename = "subl")]
- Subl,
-}
-
-#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)]
-pub enum KeymapMode {
- #[serde(rename = "emacs")]
- Emacs,
-
- #[serde(rename = "vim-normal")]
- VimNormal,
-
- #[serde(rename = "vim-insert")]
- VimInsert,
-
- #[serde(rename = "auto")]
- Auto,
-}
-
-impl KeymapMode {
- pub fn as_str(&self) -> &'static str {
- match self {
- KeymapMode::Emacs => "EMACS",
- KeymapMode::VimNormal => "VIMNORMAL",
- KeymapMode::VimInsert => "VIMINSERT",
- KeymapMode::Auto => "AUTO",
- }
- }
-}
-
-// We want to translate the config to crossterm::cursor::SetCursorStyle, but
-// the original type does not implement trait serde::Deserialize unfortunately.
-// It seems impossible to implement Deserialize for external types when it is
-// used in HashMap (https://stackoverflow.com/questions/67142663). We instead
-// define an adapter type.
-#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)]
-pub enum CursorStyle {
- #[serde(rename = "default")]
- DefaultUserShape,
-
- #[serde(rename = "blink-block")]
- BlinkingBlock,
-
- #[serde(rename = "steady-block")]
- SteadyBlock,
-
- #[serde(rename = "blink-underline")]
- BlinkingUnderScore,
-
- #[serde(rename = "steady-underline")]
- SteadyUnderScore,
-
- #[serde(rename = "blink-bar")]
- BlinkingBar,
-
- #[serde(rename = "steady-bar")]
- SteadyBar,
-}
-
-impl CursorStyle {
- pub fn as_str(&self) -> &'static str {
- match self {
- CursorStyle::DefaultUserShape => "DEFAULT",
- CursorStyle::BlinkingBlock => "BLINKBLOCK",
- CursorStyle::SteadyBlock => "STEADYBLOCK",
- CursorStyle::BlinkingUnderScore => "BLINKUNDERLINE",
- CursorStyle::SteadyUnderScore => "STEADYUNDERLINE",
- CursorStyle::BlinkingBar => "BLINKBAR",
- CursorStyle::SteadyBar => "STEADYBAR",
- }
- }
-}
-
-#[derive(Clone, Debug, Deserialize)]
-pub struct Stats {
- #[serde(default = "Stats::common_prefix_default")]
- pub common_prefix: Vec<String>, // sudo, etc. commands we want to strip off
- #[serde(default = "Stats::common_subcommands_default")]
- pub common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for
- #[serde(default = "Stats::ignored_commands_default")]
- pub ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats
-}
-
-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",
- "git",
- "go",
- "ip",
- "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, Default)]
-pub struct Sync {
- pub records: bool,
-}
-
-#[derive(Clone, Debug, Deserialize, Default)]
-pub struct Keys {
- pub scroll_exits: bool,
-}
-
-#[derive(Clone, Debug, Deserialize)]
-pub struct Settings {
- pub dialect: Dialect,
- pub timezone: Timezone,
- pub style: Style,
- pub auto_sync: bool,
- pub update_check: bool,
- pub sync_address: String,
- pub sync_frequency: String,
- pub db_path: String,
- pub record_store_path: String,
- pub key_path: String,
- pub session_path: String,
- pub search_mode: SearchMode,
- pub filter_mode: FilterMode,
- pub filter_mode_shell_up_key_binding: Option<FilterMode>,
- pub search_mode_shell_up_key_binding: Option<SearchMode>,
- pub shell_up_key_binding: bool,
- pub inline_height: u16,
- pub invert: bool,
- pub show_preview: bool,
- pub show_preview_auto: bool,
- pub max_preview_height: u16,
- pub show_help: bool,
- pub show_tabs: bool,
- pub exit_mode: ExitMode,
- pub keymap_mode: KeymapMode,
- pub keymap_mode_shell: KeymapMode,
- pub keymap_cursor: HashMap<String, CursorStyle>,
- pub word_jump_mode: WordJumpMode,
- pub word_chars: String,
- pub scroll_context_lines: usize,
- pub history_format: String,
- pub prefers_reduced_motion: bool,
- pub store_failed: bool,
-
- #[serde(with = "serde_regex", default = "RegexSet::empty")]
- pub history_filter: RegexSet,
-
- #[serde(with = "serde_regex", default = "RegexSet::empty")]
- pub cwd_filter: RegexSet,
-
- pub secrets_filter: bool,
- pub workspaces: bool,
- pub ctrl_n_shortcuts: bool,
-
- pub network_connect_timeout: u64,
- pub network_timeout: u64,
- pub local_timeout: f64,
- pub enter_accept: bool,
- pub smart_sort: bool,
-
- #[serde(default)]
- pub stats: Stats,
-
- #[serde(default)]
- pub sync: Sync,
-
- #[serde(default)]
- pub keys: Keys,
-
- #[serde(default)]
- pub dotfiles: dotfiles::Settings,
-
- // This is automatically loaded when settings is created. Do not set in
- // config! Keep secrets and settings apart.
- #[serde(skip)]
- pub session_token: String,
-}
-
-impl Settings {
- pub fn utc() -> Self {
- Self::builder()
- .expect("Could not build default")
- .set_override("timezone", "0")
- .expect("failed to override timezone with UTC")
- .build()
- .expect("Could not build config")
- .try_deserialize()
- .expect("Could not deserialize config")
- }
-
- fn save_to_data_dir(filename: &str, value: &str) -> Result<()> {
- let data_dir = atuin_common::utils::data_dir();
- let data_dir = data_dir.as_path();
-
- let path = data_dir.join(filename);
-
- fs_err::write(path, value)?;
-
- Ok(())
- }
-
- fn read_from_data_dir(filename: &str) -> Option<String> {
- let data_dir = atuin_common::utils::data_dir();
- let data_dir = data_dir.as_path();
-
- let path = data_dir.join(filename);
-
- if !path.exists() {
- return None;
- }
-
- let value = fs_err::read_to_string(path);
-
- value.ok()
- }
-
- fn save_current_time(filename: &str) -> Result<()> {
- Settings::save_to_data_dir(
- filename,
- OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),
- )?;
-
- Ok(())
- }
-
- fn load_time_from_file(filename: &str) -> Result<OffsetDateTime> {
- let value = Settings::read_from_data_dir(filename);
-
- match value {
- Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?),
- None => Ok(OffsetDateTime::UNIX_EPOCH),
- }
- }
-
- pub fn save_sync_time() -> Result<()> {
- Settings::save_current_time(LAST_SYNC_FILENAME)
- }
-
- pub fn save_version_check_time() -> Result<()> {
- Settings::save_current_time(LAST_VERSION_CHECK_FILENAME)
- }
-
- pub fn last_sync() -> Result<OffsetDateTime> {
- Settings::load_time_from_file(LAST_SYNC_FILENAME)
- }
-
- pub fn last_version_check() -> Result<OffsetDateTime> {
- Settings::load_time_from_file(LAST_VERSION_CHECK_FILENAME)
- }
-
- pub fn host_id() -> Option<HostId> {
- let id = Settings::read_from_data_dir(HOST_ID_FILENAME);
-
- if let Some(id) = id {
- let parsed =
- Uuid::from_str(id.as_str()).expect("failed to parse host ID from local directory");
- return Some(HostId(parsed));
- }
-
- let uuid = atuin_common::utils::uuid_v7();
-
- Settings::save_to_data_dir(HOST_ID_FILENAME, uuid.as_simple().to_string().as_ref())
- .expect("Could not write host ID to data dir");
-
- Some(HostId(uuid))
- }
-
- pub fn should_sync(&self) -> Result<bool> {
- if !self.auto_sync || !PathBuf::from(self.session_path.as_str()).exists() {
- return Ok(false);
- }
-
- match parse(self.sync_frequency.as_str()) {
- Ok(d) => {
- let d = time::Duration::try_from(d).unwrap();
- Ok(OffsetDateTime::now_utc() - Settings::last_sync()? >= d)
- }
- Err(e) => Err(eyre!("failed to check sync: {}", e)),
- }
- }
-
- #[cfg(feature = "check-update")]
- fn needs_update_check(&self) -> Result<bool> {
- let last_check = Settings::last_version_check()?;
- let diff = OffsetDateTime::now_utc() - last_check;
-
- // Check a max of once per hour
- Ok(diff.whole_hours() >= 1)
- }
-
- #[cfg(feature = "check-update")]
- async fn latest_version(&self) -> Result<Version> {
- // Default to the current version, and if that doesn't parse, a version so high it's unlikely to ever
- // suggest upgrading.
- let current =
- Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0));
-
- if !self.needs_update_check()? {
- // Worst case, we don't want Atuin to fail to start because something funky is going on with
- // version checking.
- let version = tokio::task::spawn_blocking(|| {
- Settings::read_from_data_dir(LATEST_VERSION_FILENAME)
- })
- .await
- .expect("file task panicked");
-
- let version = match version {
- Some(v) => Version::parse(&v).unwrap_or(current),
- None => current,
- };
-
- return Ok(version);
- }
-
- #[cfg(feature = "sync")]
- let latest = crate::api_client::latest_version().await.unwrap_or(current);
-
- #[cfg(not(feature = "sync"))]
- let latest = current;
-
- let latest_encoded = latest.to_string();
- tokio::task::spawn_blocking(move || {
- Settings::save_version_check_time()?;
- Settings::save_to_data_dir(LATEST_VERSION_FILENAME, &latest_encoded)?;
- Ok::<(), eyre::Report>(())
- })
- .await
- .expect("file task panicked")?;
-
- Ok(latest)
- }
-
- // Return Some(latest version) if an update is needed. Otherwise, none.
- #[cfg(feature = "check-update")]
- pub async fn needs_update(&self) -> Option<Version> {
- if !self.update_check {
- return None;
- }
-
- let current =
- Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0));
-
- let latest = self.latest_version().await;
-
- if latest.is_err() {
- return None;
- }
-
- let latest = latest.unwrap();
-
- if latest > current {
- return Some(latest);
- }
-
- None
- }
-
- #[cfg(not(feature = "check-update"))]
- pub async fn needs_update(&self) -> Option<Version> {
- None
- }
-
- pub fn builder() -> Result<ConfigBuilder<DefaultState>> {
- let data_dir = atuin_common::utils::data_dir();
- let db_path = data_dir.join("history.db");
- let record_store_path = data_dir.join("records.db");
-
- let key_path = data_dir.join("key");
- let session_path = data_dir.join("session");
-
- 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("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")?
- .set_default("sync_frequency", "10m")?
- .set_default("search_mode", "fuzzy")?
- .set_default("filter_mode", "global")?
- .set_default("style", "auto")?
- .set_default("inline_height", 0)?
- .set_default("show_preview", false)?
- .set_default("show_preview_auto", true)?
- .set_default("max_preview_height", 4)?
- .set_default("show_help", true)?
- .set_default("show_tabs", true)?
- .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("session_token", "")?
- .set_default("workspaces", false)?
- .set_default("ctrl_n_shortcuts", false)?
- .set_default("secrets_filter", 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("sync.records", false)?
- .set_default("keys.scroll_exits", true)?
- .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("store_failed", true)?
- .set_default(
- "prefers_reduced_motion",
- std::env::var("NO_MOTION")
- .ok()
- .map(|_| config::Value::new(None, config::ValueKind::Boolean(true)))
- .unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))),
- )?
- .add_source(
- Environment::with_prefix("atuin")
- .prefix_separator("_")
- .separator("__"),
- ))
- }
-
- pub fn new() -> Result<Self> {
- let config_dir = atuin_common::utils::config_dir();
- let data_dir = atuin_common::utils::data_dir();
-
- create_dir_all(&config_dir)
- .wrap_err_with(|| format!("could not create dir {config_dir:?}"))?;
-
- create_dir_all(&data_dir).wrap_err_with(|| format!("could not create dir {data_dir:?}"))?;
-
- let mut config_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
- PathBuf::from(p)
- } else {
- let mut config_file = PathBuf::new();
- config_file.push(config_dir);
- config_file
- };
-
- config_file.push("config.toml");
-
- let mut config_builder = Self::builder()?;
-
- config_builder = if config_file.exists() {
- config_builder.add_source(ConfigFile::new(
- config_file.to_str().unwrap(),
- FileFormat::Toml,
- ))
- } else {
- let mut file = File::create(config_file).wrap_err("could not create config file")?;
- file.write_all(EXAMPLE_CONFIG.as_bytes())
- .wrap_err("could not write default config file")?;
-
- config_builder
- };
-
- let config = config_builder.build()?;
- let mut settings: Settings = config
- .try_deserialize()
- .map_err(|e| eyre!("failed to deserialize: {}", e))?;
-
- // all paths should be expanded
- let db_path = settings.db_path;
- let db_path = shellexpand::full(&db_path)?;
- settings.db_path = db_path.to_string();
-
- let key_path = settings.key_path;
- let key_path = shellexpand::full(&key_path)?;
- settings.key_path = key_path.to_string();
-
- let session_path = settings.session_path;
- let session_path = shellexpand::full(&session_path)?;
- settings.session_path = session_path.to_string();
-
- // Finally, set the auth token
- if Path::new(session_path.to_string().as_str()).exists() {
- let token = fs_err::read_to_string(session_path.to_string())?;
- settings.session_token = token.trim().to_string();
- } else {
- settings.session_token = String::from("not logged in");
- }
-
- Ok(settings)
- }
-
- pub fn example_config() -> &'static str {
- EXAMPLE_CONFIG
- }
-}
-
-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)]
-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(())
- }
-}