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, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Config {
#[serde(default)]
pub(crate) sidebar: SidebarSide,
}
impl Config {
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,
})
.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.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();
}
}