lios-bar 0.1.0

A native GTK4/VTE sidebar workspace for navigating live Lios terminal sessions.
Documentation
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();
    }
}