use serde::{Deserialize, Serialize};
use std::env;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum SidebarSide {
#[default]
Left,
Right,
}
impl SidebarSide {
pub(crate) fn parse(value: &str) -> Result<Self, String> {
match value.trim().to_ascii_lowercase().as_str() {
"left" => Ok(Self::Left),
"right" => Ok(Self::Right),
_ => Err(format!(
"invalid sidebar position '{value}'; expected left or right"
)),
}
}
pub(crate) fn toggled(self) -> Self {
match self {
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
pub(crate) fn label(self) -> &'static str {
match self {
Self::Left => "Left",
Self::Right => "Right",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct Config {
pub(crate) sidebar: SidebarSide,
pub(crate) compact: bool,
pub(crate) show_titles: bool,
pub(crate) show_hints: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
sidebar: SidebarSide::Left,
compact: false,
show_titles: true,
show_hints: true,
}
}
}
impl Config {
pub(crate) fn preferences(&self) -> lios_bar::SidebarPreferences {
lios_bar::SidebarPreferences {
compact: self.compact,
show_titles: self.show_titles,
show_hints: self.show_hints,
}
}
pub(crate) fn save_changes(&self, baseline: &Self, path: &Path) -> Result<(), String> {
let mut current = Self::load(path)?;
macro_rules! changed {
($field:ident) => {
if self.$field != baseline.$field {
current.$field = self.$field;
}
};
}
changed!(sidebar);
changed!(compact);
changed!(show_titles);
changed!(show_hints);
current.save(path)
}
pub(crate) fn load(path: &Path) -> Result<Self, String> {
match fs::read_to_string(path) {
Ok(contents) => toml::from_str(&contents)
.map_err(|error| format!("invalid config '{}': {error}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(format!(
"could not read config '{}': {error}",
path.display()
)),
}
}
pub(crate) fn save(&self, path: &Path) -> Result<(), String> {
let contents = toml::to_string_pretty(self)
.map_err(|error| format!("could not encode config: {error}"))?;
atomic_write(path, contents.as_bytes(), 0o600)
}
}
pub(crate) fn default_path() -> Result<PathBuf, String> {
if let Some(root) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
return Ok(PathBuf::from(root).join("lios/bar.toml"));
}
let home = env::var_os("HOME")
.filter(|value| !value.is_empty())
.ok_or_else(|| "set HOME or XDG_CONFIG_HOME to locate LiosBar config".to_string())?;
Ok(PathBuf::from(home).join(".config/lios/bar.toml"))
}
pub(crate) fn atomic_write(path: &Path, contents: &[u8], mode: u32) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| format!("path '{}' has no parent directory", path.display()))?;
fs::create_dir_all(parent)
.map_err(|error| format!("could not create '{}': {error}", parent.display()))?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("path '{}' has no UTF-8 file name", path.display()))?;
let temporary = parent.join(format!(".{name}.{}-{nanos:x}.tmp", std::process::id()));
let result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(&temporary)
.map_err(|error| {
format!(
"could not create temporary config '{}': {error}",
temporary.display()
)
})?;
file.write_all(contents).map_err(|error| {
format!(
"could not write temporary config '{}': {error}",
temporary.display()
)
})?;
file.sync_all().map_err(|error| {
format!(
"could not sync temporary config '{}': {error}",
temporary.display()
)
})?;
fs::rename(&temporary, path)
.map_err(|error| format!("could not replace config '{}': {error}", path.display()))
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn test_dir(label: &str) -> PathBuf {
env::temp_dir().join(format!("lios-bar-{label}-{}", std::process::id()))
}
#[test]
fn default_is_left_and_round_trips_right() {
assert_eq!(Config::default().sidebar, SidebarSide::Left);
let encoded = toml::to_string(&Config {
sidebar: SidebarSide::Right,
..Config::default()
})
.unwrap();
assert_eq!(
toml::from_str::<Config>(&encoded).unwrap().sidebar,
SidebarSide::Right
);
}
#[test]
fn unknown_fields_fail_closed() {
assert!(toml::from_str::<Config>("sidebar = \"left\"\nsecret = true").is_err());
}
#[test]
fn save_is_user_only_and_loads() {
let directory = test_dir("config");
let path = directory.join("bar.toml");
let config = Config {
sidebar: SidebarSide::Right,
..Config::default()
};
config.save(&path).unwrap();
assert_eq!(Config::load(&path).unwrap(), config);
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn old_configs_keep_original_layout_defaults() {
let config: Config = toml::from_str("sidebar = \"right\"").unwrap();
assert_eq!(
config.preferences(),
lios_bar::SidebarPreferences::default()
);
assert!(toml::from_str::<Config>("compact = \"yes\"").is_err());
assert!(toml::from_str::<Config>("show_title = false").is_err());
}
#[test]
fn layout_changes_merge_without_resetting_other_choices() {
let directory = test_dir("layout-merge");
let path = directory.join("bar.toml");
let baseline = Config::default();
let other_window = Config {
sidebar: SidebarSide::Right,
show_hints: false,
..baseline.clone()
};
other_window.save(&path).unwrap();
let edited = Config {
compact: true,
..baseline.clone()
};
edited.save_changes(&baseline, &path).unwrap();
assert_eq!(
Config::load(&path).unwrap(),
Config {
compact: true,
..other_window
}
);
fs::write(&path, "not valid = [").unwrap();
assert!(edited.save_changes(&baseline, &path).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), "not valid = [");
fs::remove_dir_all(directory).unwrap();
}
}