Skip to content
53 changes: 0 additions & 53 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 6 additions & 9 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 }
Expand Down
14 changes: 14 additions & 0 deletions crates/installer/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use std::path::Path;
use system::{
disk::{self, Disk},
locale::{self, Locale},
zoneinfo,
};
use thiserror::Error;
use topology::disk::Builder;
Expand All @@ -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),

Expand All @@ -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<BootPartition>,

Expand All @@ -59,6 +66,7 @@ impl Installer {
/// Return a newly initialised installer
pub fn new() -> Result<Self, Error> {
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
Expand Down Expand Up @@ -124,6 +132,7 @@ impl Installer {

Ok(Self {
locale_registry,
zoneinfo_registry,
system_parts,
boot_parts,
})
Expand All @@ -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<S: IntoIterator<Item = impl AsRef<str>>>(&self, ids: S) -> Result<Vec<Locale<'_>>, Error> {
Expand Down
1 change: 1 addition & 0 deletions crates/system/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@

pub mod disk;
pub mod locale;
pub mod zoneinfo;
143 changes: 143 additions & 0 deletions crates/system/src/zoneinfo.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
timezones_lookup: HashMap<String, Vec<usize>>,
}

impl Registry {
pub fn new() -> Result<Self, Error> {
let timezones_table = Self::load_timezones_table()?;
let mut timezones: Vec<String> = timezones_table
.values()
.flat_map(|zones| zones.iter().cloned())
.collect::<HashSet<String>>()
.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<String, Vec<usize>> = 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<HashMap<String, Vec<String>>, std::io::Error> {
let zone_tab = format!("{ZONEINFO_BASE}/zone1970.tab");
let contents = fs::read_to_string(zone_tab)?;
let mut timezones_lookup: HashMap<String, Vec<String>> = 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:?}");
}
}
}
1 change: 0 additions & 1 deletion lichen_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
29 changes: 24 additions & 5 deletions lichen_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String> {
let variants = chrono_tz::TZ_VARIANTS
fn pick_timezone(available_timezones: &[&str]) -> color_eyre::Result<String> {
let variants = available_timezones
.iter()
.enumerate()
.map(|(i, v)| (i, v, ""))
.map(|(i, v)| (i, *v, ""))
.collect::<Vec<_>>();
ensure!(!variants.is_empty(), "Internal error: No timezones");
let index = cliclack::select("Pick a timezone")
Expand All @@ -94,7 +96,24 @@ fn ask_timezone() -> color_eyre::Result<String> {
.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<String> {
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...
Expand Down Expand Up @@ -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.
Expand Down