feat(timezone): use Google account timezone for day-boundary calculations (#480)

Replace machine-local chrono::Local and UTC epoch math with the
authenticated user's Google account timezone (Calendar Settings API).

- Add chrono-tz dependency for IANA timezone parsing
- New src/timezone.rs: resolve timezone with priority:
  --timezone flag > 24h cache > Calendar API > local fallback
- calendar.rs: add --timezone/--tz flag to +agenda
- workflows.rs: fix +standup-report, +weekly-digest, +meeting-prep
- auth_commands.rs: invalidate timezone cache on logout
- Update README.md and AGENTS.md with timezone docs

Supersedes #369 and #462.

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-13 16:41:16 -06:00
committed by GitHub
parent e4a59c1023
commit 47afe5fdb3
11 changed files with 387 additions and 82 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---
Use Google account timezone instead of machine-local time for day-boundary calculations in calendar and workflow helpers. Adds `--timezone` flag to `+agenda` for explicit override. Timezone is fetched from Calendar Settings API and cached for 24 hours.
+1
View File
@@ -57,6 +57,7 @@ The CLI uses a **two-phase argument parsing** strategy:
| `src/schema.rs` | `gws schema` command — introspect API method schemas |
| `src/error.rs` | Structured JSON error output |
| `src/logging.rs` | Opt-in structured logging (stderr + file) via `tracing` |
| `src/timezone.rs` | Account timezone resolution: `--timezone` flag, Calendar Settings API, 24h cache |
## Demo Videos
Generated
+37 -7
View File
@@ -259,6 +259,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -433,7 +443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
dependencies = [
"lab",
"phf",
"phf 0.11.3",
]
[[package]]
@@ -884,6 +894,7 @@ dependencies = [
"base64",
"bytes",
"chrono",
"chrono-tz",
"clap",
"crossterm",
"derive_builder",
@@ -891,6 +902,7 @@ dependencies = [
"dotenvy",
"futures-util",
"hostname",
"iana-time-zone",
"keyring",
"percent-encoding",
"rand 0.8.5",
@@ -1668,7 +1680,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]]
@@ -1678,7 +1699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
"phf_shared 0.11.3",
]
[[package]]
@@ -1687,7 +1708,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"phf_shared 0.11.3",
"rand 0.8.5",
]
@@ -1698,7 +1719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -1713,6 +1734,15 @@ dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2562,7 +2592,7 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
dependencies = [
"fnv",
"nom",
"phf",
"phf 0.11.3",
"phf_codegen",
]
@@ -2599,7 +2629,7 @@ dependencies = [
"ordered-float",
"pest",
"pest_derive",
"phf",
"phf 0.11.3",
"sha2",
"signal-hook",
"siphasher",
+2
View File
@@ -54,6 +54,8 @@ derive_builder = "0.20.2"
ratatui = "0.30.0"
crossterm = "0.29.0"
chrono = "0.4.44"
chrono-tz = "0.10"
iana-time-zone = "0.1"
async-trait = "0.1.89"
serde_yaml = "0.9.34"
percent-encoding = "2.3.2"
+6 -1
View File
@@ -296,6 +296,8 @@ gws sheets spreadsheets values append \
Some services ship hand-crafted helper commands alongside the auto-generated Discovery surface. Helper commands are prefixed with `+` so they are visually distinct and never collide with Discovery-generated method names.
Time-aware helpers (`+agenda`, `+standup-report`, `+weekly-digest`, `+meeting-prep`) automatically use your **Google account timezone** (fetched from Calendar Settings API and cached for 24 hours). Override with `--timezone`/`--tz` on `+agenda`, or set the `--timezone` flag for explicit control.
Run `gws <service> --help` to see both Discovery methods and helper commands together.
```bash
@@ -320,7 +322,7 @@ gws drive --help # shows +upload …
| `chat` | `+send` | Send a message to a space |
| `drive` | `+upload` | Upload a file with automatic metadata |
| `calendar` | `+insert` | Create a new event |
| `calendar` | `+agenda` | Show upcoming events across all calendars |
| `calendar` | `+agenda` | Show upcoming events (uses Google account timezone; override with `--timezone`) |
| `script` | `+push` | Replace all files in an Apps Script project with local files |
| `workflow` | `+standup-report` | Today's meetings + open tasks as a standup summary |
| `workflow` | `+meeting-prep` | Prepare for your next meeting: agenda, attendees, and linked docs |
@@ -353,6 +355,9 @@ gws drive +upload ./report.pdf --name "Q1 Report"
# Morning standup summary
gws workflow +standup-report
# Show today's agenda in a specific timezone
gws calendar +agenda --today --timezone America/New_York
```
### Model Armor (Response Sanitization)
+3
View File
@@ -31,6 +31,7 @@ gws calendar +agenda
| `--week` | — | — | Show this week's events |
| `--days` | — | — | Number of days ahead to show |
| `--calendar` | — | — | Filter to specific calendar name or ID |
| `--timezone` | — | — | IANA timezone override (e.g. America/Denver). Defaults to Google account timezone. |
## Examples
@@ -39,12 +40,14 @@ gws calendar +agenda
gws calendar +agenda --today
gws calendar +agenda --week --format table
gws calendar +agenda --days 3 --calendar 'Work'
gws calendar +agenda --today --timezone America/New_York
```
## Tips
- Read-only — never modifies events.
- Queries all calendars by default; use --calendar to filter.
- Uses your Google account timezone by default; override with --timezone.
## See Also
+3
View File
@@ -1191,6 +1191,9 @@ fn handle_logout() -> Result<(), GwsError> {
}
}
// Invalidate cached account timezone (may belong to old account)
crate::timezone::invalidate_cache();
let output = if removed.is_empty() {
json!({
"status": "success",
+43 -41
View File
@@ -122,6 +122,13 @@ TIPS:
.help("Filter to specific calendar name or ID")
.value_name("NAME"),
)
.arg(
Arg::new("timezone")
.long("timezone")
.alias("tz")
.help("IANA timezone override (e.g. America/Denver). Defaults to Google account timezone.")
.value_name("TZ"),
)
.after_help(
"\
EXAMPLES:
@@ -129,10 +136,12 @@ EXAMPLES:
gws calendar +agenda --today
gws calendar +agenda --week --format table
gws calendar +agenda --days 3 --calendar 'Work'
gws calendar +agenda --today --timezone America/New_York
TIPS:
Read-only — never modifies events.
Queries all calendars by default; use --calendar to filter.",
Queries all calendars by default; use --calendar to filter.
Uses your Google account timezone by default; override with --timezone.",
),
);
cmd
@@ -201,21 +210,14 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or(crate::formatter::OutputFormat::Table);
// Determine time range using the local timezone so that --today and
// --tomorrow align with the user's wall-clock day, not UTC.
use chrono::{Local, NaiveTime, TimeZone};
let client = crate::client::build_client()?;
let tz_override = matches.get_one::<String>("timezone").map(|s| s.as_str());
let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override).await?;
let local_now = Local::now();
let today_start = local_now
.date_naive()
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
// Use .earliest() to handle DST transitions where midnight may be
// ambiguous or non-existent. Falls back to current time if resolution
// fails entirely (should not happen for midnight in practice).
let today_start_local = Local
.from_local_datetime(&today_start)
.earliest()
.unwrap_or(local_now);
// Determine time range using the account timezone so that --today and
// --tomorrow align with the user's Google account day, not the machine.
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let today_start_tz = crate::timezone::start_of_today(tz)?;
let days: i64 = if matches.get_flag("tomorrow") {
1
@@ -229,24 +231,24 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
};
let (time_min_dt, time_max_dt) = if matches.get_flag("today") {
// Today: local midnight to local midnight+1
let end = today_start_local + chrono::Duration::days(1);
(today_start_local, end)
// Today: account tz midnight to midnight+1
let end = today_start_tz + chrono::Duration::days(1);
(today_start_tz, end)
} else if matches.get_flag("tomorrow") {
// Tomorrow: local midnight+1 to local midnight+2
let start = today_start_local + chrono::Duration::days(1);
let end = today_start_local + chrono::Duration::days(2);
// Tomorrow: account tz midnight+1 to midnight+2
let start = today_start_tz + chrono::Duration::days(1);
let end = today_start_tz + chrono::Duration::days(2);
(start, end)
} else {
// From now, N days ahead
let end = local_now + chrono::Duration::days(days);
(local_now, end)
let end = now_in_tz + chrono::Duration::days(days);
(now_in_tz, end)
};
let time_min = time_min_dt.to_rfc3339();
let time_max = time_max_dt.to_rfc3339();
let client = crate::client::build_client()?;
// client already built above for timezone resolution
let calendar_filter = matches.get_one::<String>("calendar");
// 1. List all calendars
@@ -547,35 +549,35 @@ mod tests {
assert!(body.contains("c@d.com"));
}
/// Verify that agenda day boundaries use local timezone offsets, not UTC.
/// Verify that agenda day boundaries use a specific timezone, not UTC.
#[test]
fn agenda_day_boundaries_use_local_timezone() {
use chrono::{Local, NaiveTime, TimeZone};
fn agenda_day_boundaries_use_account_timezone() {
use chrono::{NaiveTime, TimeZone, Utc};
let local_now = Local::now();
let today_start = local_now
// Simulate using a known account timezone (America/Denver = UTC-7 / UTC-6 DST)
let tz = chrono_tz::America::Denver;
let now_in_tz = Utc::now().with_timezone(&tz);
let today_start = now_in_tz
.date_naive()
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
let today_start_local = Local
let today_start_tz = tz
.from_local_datetime(&today_start)
.earliest()
.unwrap_or(local_now.into());
.expect("midnight should resolve");
let today_rfc = today_start_local.to_rfc3339();
let tomorrow_start = today_start_local + chrono::Duration::days(1);
let today_rfc = today_start_tz.to_rfc3339();
let tomorrow_start = today_start_tz + chrono::Duration::days(1);
let tomorrow_rfc = tomorrow_start.to_rfc3339();
// The local offset should appear in the RFC3339 string (e.g. -07:00, +05:30).
// If the code were using UTC, the string would end with +00:00 (unless
// the machine is actually in UTC, in which case this test is a no-op).
let local_offset = local_now.format("%:z").to_string();
// The Denver offset should appear in the RFC3339 string (-07:00 or -06:00 for DST).
// Crucially, it should NOT be +00:00 (UTC).
assert!(
today_rfc.contains(&local_offset),
"today boundary should carry local offset {local_offset}, got {today_rfc}"
today_rfc.contains("-07:00") || today_rfc.contains("-06:00"),
"today boundary should carry Denver offset, got {today_rfc}"
);
assert!(
tomorrow_rfc.contains(&local_offset),
"tomorrow boundary should carry local offset {local_offset}, got {tomorrow_rfc}"
tomorrow_rfc.contains("-07:00") || tomorrow_rfc.contains("-06:00"),
"tomorrow boundary should carry Denver offset, got {tomorrow_rfc}"
);
}
}
+19 -33
View File
@@ -275,15 +275,13 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
let client = crate::client::build_client()?;
// Today's time range
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let day_start = (now / 86400) * 86400;
let day_end = day_start + 86400;
let time_min = epoch_to_rfc3339(day_start);
let time_max = epoch_to_rfc3339(day_end);
// Resolve account timezone for day boundaries
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let today_start_tz = crate::timezone::start_of_today(tz)?;
let today_end_tz = today_start_tz + chrono::Duration::days(1);
let time_min = today_start_tz.to_rfc3339();
let time_max = today_end_tz.to_rfc3339();
// Fetch today's events
let events_json = get_json(
@@ -355,7 +353,7 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
"meetingCount": meetings.len(),
"tasks": open_tasks,
"taskCount": open_tasks.len(),
"date": time_min.split('T').next().unwrap_or(""),
"date": now_in_tz.format("%Y-%m-%d").to_string(),
});
format_and_print(&output, matches);
@@ -374,13 +372,9 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> {
.map(|s| s.as_str())
.unwrap_or("primary");
// Fetch next upcoming event
let now_rfc = epoch_to_rfc3339(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
// Use account timezone for current time
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
let now_rfc = chrono::Utc::now().with_timezone(&tz).to_rfc3339();
let events_url = format!(
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
@@ -542,13 +536,12 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
let client = crate::client::build_client()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let week_end = now + 7 * 86400;
let time_min = epoch_to_rfc3339(now);
let time_max = epoch_to_rfc3339(week_end);
// Resolve account timezone for week boundaries
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
let week_end = now_in_tz + chrono::Duration::days(7);
let time_min = now_in_tz.to_rfc3339();
let time_max = week_end.to_rfc3339();
// Fetch this week's events
let events_json = get_json(
@@ -692,10 +685,7 @@ async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> {
// Utilities
// ---------------------------------------------------------------------------
fn epoch_to_rfc3339(epoch: u64) -> String {
use chrono::{TimeZone, Utc};
Utc.timestamp_opt(epoch as i64, 0).unwrap().to_rfc3339()
}
// (epoch_to_rfc3339 removed — replaced by account timezone resolution)
#[cfg(test)]
mod tests {
@@ -723,11 +713,7 @@ mod tests {
assert!(WorkflowHelper.helper_only());
}
#[test]
fn test_epoch_to_rfc3339() {
assert_eq!(epoch_to_rfc3339(0), "1970-01-01T00:00:00+00:00");
assert_eq!(epoch_to_rfc3339(1710000000), "2024-03-09T16:00:00+00:00");
}
// (test_epoch_to_rfc3339 removed — function replaced by timezone resolution)
#[test]
fn test_build_standup_report_cmd() {
+1
View File
@@ -38,6 +38,7 @@ mod services;
mod setup;
mod setup_tui;
mod text;
mod timezone;
mod token_storage;
pub(crate) mod validate;
+267
View File
@@ -0,0 +1,267 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Account timezone resolution for Google Workspace CLI.
//!
//! Resolves the authenticated user's timezone with the following priority:
//! 1. Explicit `--timezone` CLI flag (hard error if invalid)
//! 2. Cached value from config dir (24h TTL)
//! 3. Google Calendar Settings API (`users/me/settings/timezone`)
//! 4. Machine-local timezone (fallback with warning)
use crate::error::GwsError;
use chrono_tz::Tz;
use std::path::PathBuf;
/// Cache filename stored in the gws config directory.
const CACHE_FILENAME: &str = "account_timezone";
/// Cache TTL in seconds (24 hours).
const CACHE_TTL_SECS: u64 = 86400;
/// Returns the path to the timezone cache file.
fn cache_path() -> PathBuf {
crate::auth_commands::config_dir().join(CACHE_FILENAME)
}
/// Remove the cached timezone file. Called on auth login/logout to
/// invalidate stale values when the account changes.
pub fn invalidate_cache() {
let path = cache_path();
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = %path.display(), error = %e, "failed to invalidate timezone cache");
}
}
}
/// Read the cached timezone if it exists and is fresh (< 24h old).
fn read_cache() -> Option<Tz> {
let path = cache_path();
let metadata = std::fs::metadata(&path).ok()?;
let modified = metadata.modified().ok()?;
let age = std::time::SystemTime::now().duration_since(modified).ok()?;
if age.as_secs() > CACHE_TTL_SECS {
return None;
}
let contents = std::fs::read_to_string(&path).ok()?;
let tz_name = contents.trim();
tz_name.parse::<Tz>().ok()
}
/// Write a timezone name to the cache file.
fn write_cache(tz_name: &str) {
let path = cache_path();
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!(path = %parent.display(), error = %e, "failed to create timezone cache directory");
return;
}
}
if let Err(e) = std::fs::write(&path, tz_name) {
tracing::warn!(path = %path.display(), error = %e, "failed to write timezone cache");
}
}
/// Fetch the account timezone from the Google Calendar Settings API.
async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result<Tz, GwsError> {
let url = "https://www.googleapis.com/calendar/v3/users/me/settings/timezone";
let resp = client
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch account timezone: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "timezone_fetch_failed".to_string(),
enable_url: None,
});
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse timezone response: {e}")))?;
let tz_name = json
.get("value")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
GwsError::Other(anyhow::anyhow!(
"Timezone setting missing or empty 'value' field"
))
})?;
let tz: Tz = tz_name.parse().map_err(|_| {
GwsError::Other(anyhow::anyhow!(
"Google returned unrecognized timezone: {tz_name}"
))
})?;
// Cache for future use
write_cache(tz_name);
tracing::info!(
timezone = tz_name,
source = "calendar_api",
"resolved account timezone"
);
Ok(tz)
}
/// Parse an explicit timezone string, returning an error if invalid.
pub fn parse_timezone(tz_str: &str) -> Result<Tz, GwsError> {
tz_str.parse::<Tz>().map_err(|_| {
GwsError::Validation(format!(
"Invalid timezone '{tz_str}'. Use an IANA timezone name (e.g. America/Denver, Europe/London, UTC)."
))
})
}
/// Resolve the user's timezone with this priority:
/// 1. `tz_override` (from `--timezone` flag) — hard error if invalid
/// 2. Cached value in config dir — use if < 24h old
/// 3. Google Calendar Settings API — fetch and cache
/// 4. Machine-local timezone (log warning)
pub async fn resolve_account_timezone(
client: &reqwest::Client,
token: &str,
tz_override: Option<&str>,
) -> Result<Tz, GwsError> {
// 1. Explicit override — fail if invalid
if let Some(tz_str) = tz_override {
let tz = parse_timezone(tz_str)?;
tracing::info!(
timezone = tz_str,
source = "cli_flag",
"using explicit timezone"
);
return Ok(tz);
}
// 2. Check cache
if let Some(tz) = read_cache() {
tracing::debug!(timezone = %tz, source = "cache", "using cached timezone");
return Ok(tz);
}
// 3. Fetch from Calendar Settings API
match fetch_account_timezone(client, token).await {
Ok(tz) => return Ok(tz),
Err(e) => {
tracing::warn!(error = %e, "failed to fetch account timezone, falling back to local");
}
}
// 4. Fall back to machine-local timezone
let local_iana = iana_time_zone_fallback();
tracing::warn!(
timezone = local_iana.as_str(),
source = "local_machine",
"using machine-local timezone as fallback"
);
let tz: Tz = local_iana.parse().unwrap_or(chrono_tz::UTC);
Ok(tz)
}
/// Return the start of today (midnight) in the given timezone as a
/// timezone-aware `DateTime`. Errors if midnight cannot be resolved
/// (e.g. a DST transition that skips midnight — extremely rare).
pub fn start_of_today(tz: Tz) -> Result<chrono::DateTime<Tz>, crate::error::GwsError> {
use chrono::{NaiveTime, TimeZone, Utc};
let now_in_tz = Utc::now().with_timezone(&tz);
let today_start = now_in_tz
.date_naive()
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
tz.from_local_datetime(&today_start)
.earliest()
.ok_or_else(|| {
crate::error::GwsError::Other(anyhow::anyhow!(
"Could not determine start of day in timezone '{}'",
tz
))
})
}
/// Best-effort machine-local IANA timezone detection using the
/// `iana-time-zone` crate, which reads the OS timezone database.
fn iana_time_zone_fallback() -> String {
match iana_time_zone::get_timezone() {
Ok(tz) => tz,
Err(_) => "UTC".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_iana_timezone() {
let tz = parse_timezone("America/Denver").unwrap();
assert_eq!(tz, chrono_tz::America::Denver);
}
#[test]
fn parse_utc_timezone() {
let tz = parse_timezone("UTC").unwrap();
assert_eq!(tz, chrono_tz::UTC);
}
#[test]
fn parse_invalid_timezone_fails() {
let result = parse_timezone("Not/A/Zone");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid timezone"));
assert!(err.contains("Not/A/Zone"));
}
#[test]
fn parse_empty_string_fails() {
let result = parse_timezone("");
assert!(result.is_err());
}
#[test]
fn cache_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let cache_file = dir.path().join(CACHE_FILENAME);
// Write directly to test location
std::fs::write(&cache_file, "America/New_York").unwrap();
let contents = std::fs::read_to_string(&cache_file).unwrap();
let tz: Tz = contents.trim().parse().unwrap();
assert_eq!(tz, chrono_tz::America::New_York);
}
#[test]
fn iana_fallback_returns_valid_tz() {
let tz_name = iana_time_zone_fallback();
// Should be parseable
let result: Result<Tz, _> = tz_name.parse();
assert!(
result.is_ok(),
"Fallback timezone '{tz_name}' should be parseable"
);
}
}