timerd-rs 0.6.0

A small foreground/background terminal timer for Linux
use std::env;
use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result, bail};

use crate::display::DisplaySize;

#[derive(Debug)]
pub struct LoadedConfig {
    pub path: PathBuf,
    pub exists: bool,
    pub display_size: DisplaySize,
}

pub fn path() -> Result<PathBuf> {
    let base = match env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
        Some(path) => PathBuf::from(path),
        None => {
            let home = env::var_os("HOME").context("HOME is not set")?;
            PathBuf::from(home).join(".config")
        }
    };
    Ok(base.join("timerd-rs/config.toml"))
}

pub fn load() -> Result<LoadedConfig> {
    let path = path()?;
    let contents = match fs::read_to_string(&path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok(LoadedConfig {
                path,
                exists: false,
                display_size: DisplaySize::default(),
            });
        }
        Err(error) => {
            return Err(error).with_context(|| format!("failed to read {}", path.display()));
        }
    };
    let display_size = parse_display_size(&contents)
        .with_context(|| format!("failed to parse {}", path.display()))?;
    Ok(LoadedConfig {
        path,
        exists: true,
        display_size,
    })
}

fn parse_display_size(contents: &str) -> Result<DisplaySize> {
    let mut in_display = false;
    let mut display_size = None;

    for (index, original_line) in contents.lines().enumerate() {
        let line_number = index + 1;
        let line = strip_comment(original_line).trim();
        if line.is_empty() {
            continue;
        }

        if line.starts_with('[') {
            let Some(section) = line
                .strip_prefix('[')
                .and_then(|line| line.strip_suffix(']'))
            else {
                bail!("line {line_number}: invalid section header");
            };
            if section.trim() != "display" {
                bail!("line {line_number}: unknown section `{}`", section.trim());
            }
            in_display = true;
            continue;
        }

        if !in_display {
            bail!("line {line_number}: expected `[display]` before a setting");
        }
        let Some((key, value)) = line.split_once('=') else {
            bail!("line {line_number}: expected `key = value`");
        };
        let key = key.trim();
        if key != "size" {
            bail!("line {line_number}: unknown display setting `{key}`");
        }
        if display_size.is_some() {
            bail!("line {line_number}: duplicate display setting `size`");
        }

        let value = value.trim();
        let Some(value) = quoted_string(value) else {
            bail!("line {line_number}: `size` must be a quoted string");
        };
        display_size = Some(DisplaySize::from_config_value(value).with_context(|| {
            format!(
                "line {line_number}: invalid size `{value}`; expected small, medium, large, or xlarge"
            )
        })?);
    }

    Ok(display_size.unwrap_or_default())
}

fn strip_comment(line: &str) -> &str {
    let mut quote = None;
    let mut escaped = false;
    for (index, character) in line.char_indices() {
        if escaped {
            escaped = false;
            continue;
        }
        if character == '\\' && quote == Some('"') {
            escaped = true;
            continue;
        }
        if matches!(character, '\'' | '"') {
            if quote == Some(character) {
                quote = None;
            } else if quote.is_none() {
                quote = Some(character);
            }
        } else if character == '#' && quote.is_none() {
            return &line[..index];
        }
    }
    line
}

fn quoted_string(value: &str) -> Option<&str> {
    let quote = value.chars().next()?;
    if !matches!(quote, '\'' | '"') || value.chars().last()? != quote || value.len() < 2 {
        return None;
    }
    let inner = &value[quote.len_utf8()..value.len() - quote.len_utf8()];
    (!inner.contains(quote)).then_some(inner)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_config_uses_small_display() {
        assert_eq!(parse_display_size("").unwrap(), DisplaySize::Small);
    }

    #[test]
    fn parses_display_size() {
        assert_eq!(
            parse_display_size("[display]\nsize = \"xlarge\" # preferred\n").unwrap(),
            DisplaySize::Xlarge
        );
    }

    #[test]
    fn rejects_typos_instead_of_ignoring_them() {
        let error = parse_display_size("[display]\nszie = \"large\"\n").unwrap_err();
        assert!(error.to_string().contains("unknown display setting `szie`"));
    }
}