diff --git a/Cargo.lock b/Cargo.lock index 0b571aa..1eed88c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,25 +130,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "num-traits", -] - -[[package]] -name = "chrono-tz" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" -dependencies = [ - "chrono", - "phf", -] - [[package]] name = "cliclack" version = "0.3.5" @@ -725,7 +706,6 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" name = "lichen_cli" version = "0.1.0" dependencies = [ - "chrono-tz", "cliclack", "color-eyre", "console", @@ -810,15 +790,6 @@ dependencies = [ "libc", ] -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "number_prefix" version = "0.4.0" @@ -881,24 +852,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" -dependencies = [ - "phf_shared", -] - -[[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" @@ -1142,12 +1095,6 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c11532d9d241904f095185f35dcdaf930b1427a94d5b01d7002d74ba19b44cc4" -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index fd9343e..1c3de81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,15 @@ [workspace] members = [ - "crates/*", - "lichen_cli", -# This is unused -# "lichen_ipc" -] -default-members = [ - "lichen_cli" + "crates/*", + "lichen_cli", + # This is unused + # "lichen_ipc" ] +default-members = ["lichen_cli"] resolver = "2" [workspace.dependencies] bitflags = "2.6.0" -chrono-tz = "0.10.0" color-eyre = { version = "0.6.3", features = ["issue-url"] } crossterm = { version = "0.29.0", features = ["serde", "event-stream"] } env_logger = "0.11.5" @@ -29,7 +26,7 @@ superblock = { git = "https://github.com/AerynOS/disks-rs.git", rev = "d08bc11dc thiserror = "2.0.3" topology = { git = "https://github.com/AerynOS/blsforme.git", rev = "680720545303e123e47e0df07a8a85178c9f5c19" } varlink = { version = "11.0.1" } -varlink_generator = { version = "10.1.0 "} +varlink_generator = { version = "10.1.0 " } [workspace.lints.rust] rust_2018_idioms = { level = "warn", priority = -1 } diff --git a/crates/installer/src/engine.rs b/crates/installer/src/engine.rs index 612ccb9..753fe5d 100644 --- a/crates/installer/src/engine.rs +++ b/crates/installer/src/engine.rs @@ -9,6 +9,7 @@ use std::path::Path; use system::{ disk::{self, Disk}, locale::{self, Locale}, + zoneinfo, }; use thiserror::Error; use topology::disk::Builder; @@ -29,6 +30,9 @@ pub enum Error { #[error("locale: {0}")] Locale(#[from] locale::Error), + #[error("zoneinfo: {0}")] + Zoneinfo(#[from] zoneinfo::Error), + #[error("missing mandatory partition: {0}")] MissingPartition(&'static str), @@ -48,6 +52,9 @@ pub struct Installer { /// Complete locale registry locale_registry: locale::Registry, + /// Complete timezone registry + zoneinfo_registry: zoneinfo::Registry, + /// Boot partitions boot_parts: Vec, @@ -59,6 +66,7 @@ impl Installer { /// Return a newly initialised installer pub fn new() -> Result { let locale_registry = locale::Registry::new()?; + let zoneinfo_registry = zoneinfo::Registry::new()?; let disks = Disk::discover()?; // Figure out where we live right now and exclude the rootfs @@ -124,6 +132,7 @@ impl Installer { Ok(Self { locale_registry, + zoneinfo_registry, system_parts, boot_parts, }) @@ -133,6 +142,11 @@ impl Installer { pub fn locales(&self) -> &locale::Registry { &self.locale_registry } + /// + /// Allow access to zoneinfo registry + pub fn zoneinfo(&self) -> &zoneinfo::Registry { + &self.zoneinfo_registry + } /// Generate/load the locale map pub fn locales_for_ids>>(&self, ids: S) -> Result>, Error> { diff --git a/crates/system/src/lib.rs b/crates/system/src/lib.rs index 8989e1f..f5bfd2c 100644 --- a/crates/system/src/lib.rs +++ b/crates/system/src/lib.rs @@ -6,3 +6,4 @@ pub mod disk; pub mod locale; +pub mod zoneinfo; diff --git a/crates/system/src/zoneinfo.rs b/crates/system/src/zoneinfo.rs new file mode 100644 index 0000000..2899c4c --- /dev/null +++ b/crates/system/src/zoneinfo.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright © 2025 Serpent OS Developers +// +// SPDX-License-Identifier: MPL-2.0 +use std::collections::{HashMap, HashSet}; + +use fs_err as fs; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("io: {0}")] + IO(#[from] std::io::Error), +} + +const ZONEINFO_BASE: &str = "/usr/share/zoneinfo"; + +pub struct Registry { + timezones: Vec, + timezones_lookup: HashMap>, +} + +impl Registry { + pub fn new() -> Result { + let timezones_table = Self::load_timezones_table()?; + let mut timezones: Vec = timezones_table + .values() + .flat_map(|zones| zones.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + timezones.sort(); + + let timezone_index_lookup: HashMap<&str, usize> = + timezones.iter().enumerate().map(|(i, v)| (v.as_str(), i)).collect(); + + let timezones_lookup: HashMap> = timezones_table + .into_iter() + .map(|(code2, zones)| { + let indices = zones.iter().map(|tz| timezone_index_lookup[tz.as_str()]).collect(); + (code2, indices) + }) + .collect(); + + Ok(Self { + timezones, + timezones_lookup, + }) + } + + /// Parse the TSV file zone1970.tab with the following structure: + /// Field 1: List of 2 character country codes, comma delimited + /// Field 2: Latitude/Longitude + /// Field 3: Timezone name + /// Field 4: Optional Comments + /// Only fields 1 and 3 are extracted as a HashMap which maps + /// 2 character country codes to a collection of associated timezones + fn load_timezones_table() -> Result>, std::io::Error> { + let zone_tab = format!("{ZONEINFO_BASE}/zone1970.tab"); + let contents = fs::read_to_string(zone_tab)?; + let mut timezones_lookup: HashMap> = HashMap::new(); + + for line in contents.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.splitn(4, '\t'); + let (Some(codes), Some(_), Some(timezone)) = (fields.next(), fields.next(), fields.next()) else { + continue; + }; + for code2 in codes.split(',') { + timezones_lookup + .entry(code2.to_string()) + .or_default() + .push(timezone.to_string()); + } + } + Ok(timezones_lookup) + } + + pub fn all_timezones(&self) -> &[String] { + &self.timezones + } + + pub fn timezones_for_territory(&self, code2: &str) -> Vec<&str> { + self.timezones_lookup + .get(code2) + .map(|indices| indices.iter().map(|&i| self.timezones[i].as_str()).collect()) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_single_zone_territory() { + let r = Registry::new().expect("Failed to initialise registry"); + + let test_cases = [("GB", "Europe/London"), ("IE", "Europe/Dublin")]; + + for (code2, expected_timezone) in test_cases { + let timezones = r.timezones_for_territory(code2); + assert!(timezones.len() == 1); + assert!(timezones.contains(&expected_timezone)); + eprintln!("Available timezones: {timezones:?}"); + } + } + + #[test] + fn test_multi_zone_territory() { + let r = Registry::new().expect("Failed to initialise registry"); + + let test_cases = HashMap::from([ + ("AU", vec!["Australia/Sydney", "Australia/Perth"]), + ("US", vec!["America/New_York", "America/Los_Angeles"]), + ]); + for (code2, expected_timezones) in test_cases { + let timezones = r.timezones_for_territory(code2); + for expected in expected_timezones { + assert!(timezones.contains(&expected)); + } + eprintln!("Available timezones: {timezones:?}"); + } + } + + #[test] + fn test_exclusive_timezones() { + let r = Registry::new().expect("Failed to initialise registry"); + + let test_cases = HashMap::from([ + ("AU", vec!["America/New_York", "America/Los_Angeles"]), + ("US", vec!["Australia/Sydney", "Australia/Perth"]), + ]); + for (code2, expected_timezones) in test_cases { + let timezones = r.timezones_for_territory(code2); + for expected in expected_timezones { + assert!(!timezones.contains(&expected)); + } + eprintln!("Available timezones: {timezones:?}"); + } + } +} diff --git a/lichen_cli/Cargo.toml b/lichen_cli/Cargo.toml index 56b3e40..49de38f 100644 --- a/lichen_cli/Cargo.toml +++ b/lichen_cli/Cargo.toml @@ -6,7 +6,6 @@ build = "build.rs" [dependencies] color-eyre.workspace = true -chrono-tz.workspace = true crossterm.workspace = true cliclack = { git = "https://github.com/ikeycode/cliclack.git", rev = "35a1882c601b90bf1398c3cb867cc6b20bbe9ce9" } dialoguer = { version = "0.11.0", features = ["completion", "fuzzy-matcher", "fuzzy-select"] } diff --git a/lichen_cli/src/main.rs b/lichen_cli/src/main.rs index ca5e0be..bf51b66 100644 --- a/lichen_cli/src/main.rs +++ b/lichen_cli/src/main.rs @@ -26,6 +26,8 @@ use nix::libc::geteuid; include!(concat!(env!("OUT_DIR"), "/selections.rs")); +const SEE_ALL_TIMEZONES: &str = "See all timezones"; + #[derive(Debug)] struct CliContext { root: PathBuf, @@ -80,11 +82,11 @@ fn ask_locale<'a>(locales: &'a [Locale<'a>]) -> color_eyre::Result<&'a Locale<'a Ok(&locales[index]) } -fn ask_timezone() -> color_eyre::Result { - let variants = chrono_tz::TZ_VARIANTS +fn pick_timezone(available_timezones: &[&str]) -> color_eyre::Result { + let variants = available_timezones .iter() .enumerate() - .map(|(i, v)| (i, v, "")) + .map(|(i, v)| (i, *v, "")) .collect::>(); ensure!(!variants.is_empty(), "Internal error: No timezones"); let index = cliclack::select("Pick a timezone") @@ -94,7 +96,24 @@ fn ask_timezone() -> color_eyre::Result { .set_size(10) .interact()?; - Ok(chrono_tz::TZ_VARIANTS[index].to_string()) + Ok(available_timezones[index].to_string()) +} + +/// Try limiting timezones by selected locale, otherwise select from all timezones +fn ask_timezone(inst: &Installer, selected_locale: &Locale<'_>) -> color_eyre::Result { + let mut available_timezones = inst + .zoneinfo() + .timezones_for_territory(&selected_locale.territory.code2); + // TODO: Should UTC timezone be added in crates/system/zoneinfo? + available_timezones.push("UTC"); + available_timezones.insert(0, SEE_ALL_TIMEZONES); + let timezone = pick_timezone(&available_timezones)?; + if timezone == SEE_ALL_TIMEZONES { + let all_timezones: Vec<&str> = inst.zoneinfo().all_timezones().iter().map(String::as_str).collect(); + pick_timezone(&all_timezones) + } else { + Ok(timezone) + } } /// Pick an ESP please... @@ -284,7 +303,7 @@ fn main() -> color_eyre::Result<()> { let selected_desktop = ask_desktop(&desktops)?; let selected_locale = ask_locale(&locales)?; - let timezone = ask_timezone()?; + let timezone = ask_timezone(&inst, selected_locale)?; let keyboard_layout_warning = indoc! {" Note that the keyboard layout for the current virtual terminal is controlled via the Settings application.