idet-core 0.4.0

Editing logic for text editors, without a frontend
Documentation
//! Editing options both frontends read from one file, so that changing them
//! in the window editor changes them for the terminal editor as well.

use std::{fs, path::PathBuf};

const DEFAULT_TAB_WIDTH: usize = 4;
const MAX_TAB_WIDTH: usize = 16;

/// The settings shared by every idet frontend, stored as one line per option
/// under `$XDG_CONFIG_HOME/idet/options.conf`.
///
/// Editors read them at startup and write them back the moment one changes.
/// Two editors running at once each keep their own copy, and the last one to
/// change an option is the one whose value survives on disk.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Options {
    /// Whether a line too long for the window continues on the next screen
    /// row rather than running off the side. The text on disk is untouched
    /// either way.
    pub wrap: bool,
    /// How many spaces one indentation step adds or removes.
    pub tab_width: usize,
}

impl Default for Options {
    fn default() -> Self {
        Self {
            wrap: true,
            tab_width: DEFAULT_TAB_WIDTH,
        }
    }
}

impl std::fmt::Display for Options {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        writeln!(formatter, "wrap {}", self.wrap)?;
        writeln!(formatter, "tab-width {}", self.tab_width)
    }
}

impl Options {
    /// Reads the options file, falling back to the defaults for anything it
    /// does not name and for a file that is missing or unreadable.
    #[must_use]
    pub fn load() -> Self {
        let text = Self::path()
            .and_then(|path| fs::read_to_string(path).ok())
            .unwrap_or_default();
        Self::parse(&text)
    }

    /// Writes the options back to their file, creating the directory if it is
    /// not there yet.
    ///
    /// # Errors
    ///
    /// Fails when the config directory cannot be determined or written to.
    pub fn save(&self) -> std::io::Result<()> {
        let Some(path) = Self::path() else {
            return Err(std::io::Error::other("no config directory"));
        };
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(path, self.to_string())
    }

    /// Where the options are stored, or `None` when the environment names
    /// neither a config directory nor a home directory.
    #[must_use]
    pub fn path() -> Option<PathBuf> {
        let base = std::env::var_os("XDG_CONFIG_HOME")
            .map(PathBuf::from)
            .filter(|path| path.is_absolute())
            .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
        Some(base.join("idet").join("options.conf"))
    }

    /// One indentation step as the spaces it inserts.
    #[must_use]
    pub fn indent(&self) -> String {
        " ".repeat(self.tab_width)
    }

    fn parse(text: &str) -> Self {
        let mut options = Self::default();
        for line in text.lines() {
            let mut words = line.split_whitespace();
            match (words.next(), words.next()) {
                (Some("wrap"), Some(value)) => options.wrap = value == "true",
                (Some("tab-width"), Some(value)) => {
                    if let Ok(width) = value.parse::<usize>() {
                        options.tab_width = width.clamp(1, MAX_TAB_WIDTH);
                    }
                }
                _ => {}
            }
        }
        options
    }
}

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

    #[test]
    fn what_is_written_is_what_is_read_back() {
        let options = Options {
            wrap: false,
            tab_width: 2,
        };
        assert_eq!(Options::parse(&options.to_string()), options);
    }

    #[test]
    fn an_empty_or_broken_file_leaves_the_defaults_standing() {
        assert_eq!(Options::parse(""), Options::default());
        assert_eq!(
            Options::parse("tab-width wide\nnonsense\n"),
            Options::default()
        );
    }

    #[test]
    fn an_unusable_tab_width_is_pulled_into_range() {
        assert_eq!(Options::parse("tab-width 0").tab_width, 1);
        assert_eq!(Options::parse("tab-width 99").tab_width, 16);
    }
}