use serde::{Deserialize, Serialize};
use std::env;
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use toml_edit::{DocumentMut, Item, value};
const MAX_CONFIG_BYTES: u64 = 64 * 1024;
#[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 target = resolve_config_write_path(path)?;
let (_, mut document) = load_document(&target)?;
let mut changed = false;
macro_rules! changed {
($field:ident) => {
if self.$field != baseline.$field {
set_document_value(&mut document, stringify!($field), value(self.$field));
changed = true;
}
};
}
if self.sidebar != baseline.sidebar {
set_document_value(
&mut document,
"sidebar",
value(self.sidebar.label().to_ascii_lowercase()),
);
changed = true;
}
changed!(compact);
changed!(show_titles);
changed!(show_hints);
if changed {
save_document(&target, &document)?;
}
Ok(())
}
pub(crate) fn load(path: &Path) -> Result<Self, String> {
load_document(path).map(|(config, _)| config)
}
#[cfg(test)]
fn save(&self, path: &Path) -> Result<(), String> {
let target = resolve_config_write_path(path)?;
let (_, mut document) = load_document(&target)?;
set_document_value(
&mut document,
"sidebar",
value(self.sidebar.label().to_ascii_lowercase()),
);
set_document_value(&mut document, "compact", value(self.compact));
set_document_value(&mut document, "show_titles", value(self.show_titles));
set_document_value(&mut document, "show_hints", value(self.show_hints));
save_document(&target, &document)
}
}
fn load_document(path: &Path) -> Result<(Config, DocumentMut), String> {
let file = match OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
{
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok((Config::default(), DocumentMut::new()));
}
Err(error) => {
return Err(format!(
"could not read config '{}': {error}",
path.display()
));
}
};
let metadata = file.metadata().map_err(|error| error.to_string())?;
if !metadata.is_file() {
return Err(format!(
"config '{}' must be a regular file",
path.display()
));
}
let mut contents = String::new();
file.take(MAX_CONFIG_BYTES + 1)
.read_to_string(&mut contents)
.map_err(|error| format!("could not read config '{}': {error}", path.display()))?;
if contents.len() as u64 > MAX_CONFIG_BYTES {
return Err(format!(
"config '{}' exceeds the 64 KiB limit",
path.display()
));
}
let config = toml::from_str(&contents)
.map_err(|error| format!("invalid config '{}': {error}", path.display()))?;
let document = contents
.parse::<DocumentMut>()
.map_err(|error| format!("invalid config '{}': {error}", path.display()))?;
Ok((config, document))
}
fn set_document_value(document: &mut DocumentMut, key: &str, mut item: Item) {
if let (Some(previous), Some(next)) = (
document.get(key).and_then(Item::as_value),
item.as_value_mut(),
) {
*next.decor_mut() = previous.decor().clone();
}
document[key] = item;
}
fn save_document(path: &Path, document: &DocumentMut) -> Result<(), String> {
let contents = document.to_string();
if contents.len() as u64 > MAX_CONFIG_BYTES {
return Err(format!(
"config '{}' exceeds the 64 KiB limit",
path.display()
));
}
atomic_write(path, contents.as_bytes(), 0o600)
}
fn resolve_config_write_path(path: &Path) -> Result<PathBuf, String> {
let mut current = path.to_path_buf();
for _ in 0..40 {
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() => {
let target = fs::read_link(¤t).map_err(|error| error.to_string())?;
current = if target.is_absolute() {
target
} else {
current
.parent()
.unwrap_or_else(|| Path::new("."))
.join(target)
};
}
Ok(metadata) => {
if !metadata.is_file() {
return Err(format!(
"config '{}' must be a regular file",
path.display()
));
}
return fs::canonicalize(¤t).map_err(|error| error.to_string());
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let parent = current.parent().unwrap_or_else(|| Path::new("."));
return match fs::canonicalize(parent) {
Ok(parent) => {
Ok(parent.join(current.file_name().ok_or("config path has no filename")?))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(current),
Err(error) => Err(format!(
"could not resolve config '{}': {error}",
path.display()
)),
};
}
Err(error) => {
return Err(format!(
"could not resolve config '{}': {error}",
path.display()
));
}
}
}
Err(format!(
"config symlink '{}' has too many levels",
path.display()
))
}
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()));
atomic_replace(path, &temporary, contents, mode)
}
fn atomic_replace(path: &Path, temporary: &Path, contents: &[u8], mode: u32) -> Result<(), String> {
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()
)
})?;
let result = (|| {
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_save_preserves_relative_dotfile_links_comments_and_independent_fields() {
let directory = test_dir("dotfiles");
fs::create_dir_all(directory.join("config")).unwrap();
fs::create_dir_all(directory.join("dotfiles")).unwrap();
let target = directory.join("dotfiles/bar.toml");
let link = directory.join("config/bar.toml");
fs::write(&target, "# My rice\nsidebar = 'right' # position\ncompact = false # density\nshow_hints = false # independent edit\n").unwrap();
std::os::unix::fs::symlink("../dotfiles/bar.toml", &link).unwrap();
let baseline = Config::default();
let edited = Config {
compact: true,
..baseline.clone()
};
edited.save_changes(&baseline, &link).unwrap();
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
let saved = Config::load(&link).unwrap();
assert!(saved.compact);
assert_eq!(saved.sidebar, SidebarSide::Right);
assert!(!saved.show_hints);
let contents = fs::read_to_string(&target).unwrap();
for comment in ["# My rice", "# position", "# density", "# independent edit"] {
assert!(contents.contains(comment));
}
assert!(
!contents.contains("show_titles"),
"saving one field does not fill untouched defaults"
);
assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
fs::read_link(&link).unwrap(),
PathBuf::from("../dotfiles/bar.toml")
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn absolute_and_dangling_symlink_chains_update_the_target() {
let directory = test_dir("link-chain");
fs::create_dir_all(&directory).unwrap();
let target = directory.join("missing/bar.toml");
let middle = directory.join("middle.toml");
let link = directory.join("bar.toml");
std::os::unix::fs::symlink("missing/bar.toml", &middle).unwrap();
std::os::unix::fs::symlink(&middle, &link).unwrap();
let baseline = Config::load(&link).unwrap();
let edited = Config {
sidebar: SidebarSide::Right,
..baseline.clone()
};
edited.save_changes(&baseline, &link).unwrap();
assert_eq!(Config::load(&target).unwrap().sidebar, SidebarSide::Right);
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert!(
fs::symlink_metadata(&middle)
.unwrap()
.file_type()
.is_symlink()
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn symlink_cycles_special_files_and_oversized_configs_fail_without_overwriting() {
let directory = test_dir("invalid-files");
fs::create_dir_all(&directory).unwrap();
let cycle = directory.join("cycle");
std::os::unix::fs::symlink("cycle", &cycle).unwrap();
let baseline = Config::default();
let edited = Config {
compact: true,
..baseline.clone()
};
assert!(Config::load(&cycle).is_err());
assert!(edited.save_changes(&baseline, &cycle).is_err());
assert!(
fs::symlink_metadata(&cycle)
.unwrap()
.file_type()
.is_symlink()
);
assert!(Config::load(&directory).is_err());
assert!(edited.save_changes(&baseline, &directory).is_err());
let fifo = directory.join("fifo");
let name = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
assert!(Config::load(&fifo).is_err());
assert!(edited.save_changes(&baseline, &fifo).is_err());
let oversized = directory.join("oversized.toml");
let contents = format!("#{}\n", "x".repeat(MAX_CONFIG_BYTES as usize));
fs::write(&oversized, &contents).unwrap();
assert!(Config::load(&oversized).is_err());
assert!(edited.save_changes(&baseline, &oversized).is_err());
assert_eq!(fs::read_to_string(&oversized).unwrap(), contents);
let boundary = directory.join("boundary.toml");
let contents = format!("#{}\n", "x".repeat(MAX_CONFIG_BYTES as usize - 2));
fs::write(&boundary, &contents).unwrap();
assert_eq!(Config::load(&boundary).unwrap(), baseline);
assert!(edited.save_changes(&baseline, &boundary).is_err());
assert_eq!(fs::read_to_string(&boundary).unwrap(), contents);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn temporary_collision_does_not_delete_existing_files_or_links() {
let directory = test_dir("temporary-collision");
fs::create_dir_all(&directory).unwrap();
let target = directory.join("bar.toml");
let temporary = directory.join("existing.tmp");
fs::write(&target, "original").unwrap();
fs::write(&temporary, "keep this file").unwrap();
assert!(atomic_replace(&target, &temporary, b"replacement", 0o600).is_err());
assert_eq!(fs::read_to_string(&target).unwrap(), "original");
assert_eq!(fs::read_to_string(&temporary).unwrap(), "keep this file");
let link = directory.join("linked.tmp");
std::os::unix::fs::symlink("existing.tmp", &link).unwrap();
assert!(atomic_replace(&target, &link, b"replacement", 0o600).is_err());
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read_to_string(&temporary).unwrap(), "keep this file");
fs::remove_dir_all(directory).unwrap();
}
#[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!(edited.save(&path).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), "not valid = [");
fs::remove_dir_all(directory).unwrap();
}
}