Skip to main content

odox_ui/
settings.rs

1//! The one preference the suite keeps, and the file it keeps it in.
2//!
3//! The file is written only when a person changes a preference in the menu,
4//! so an installation nobody has configured has no file, and the privacy
5//! statement can say what is there and when. It is shared by the three
6//! applications, because a preference about editing is about the suite.
7//!
8//! The format is one `key = value` line per setting, which is also valid TOML,
9//! read and written here rather than through a parser: there is one key.
10//! DESIGN.md ยง11.
11//
12// Author: David M. Anderson
13// Built with AI assistance (Claude, Anthropic)
14
15use std::path::PathBuf;
16
17/// What a person has chosen.
18#[derive(Debug, Default, Clone, PartialEq, Eq)]
19pub struct Settings {
20    /// Open every document in edit mode rather than reading.
21    pub open_in_edit_mode: bool,
22}
23
24const OPEN_IN_EDIT_MODE: &str = "open-in-edit-mode";
25
26impl Settings {
27    /// Read the file, or the defaults where there is none or it cannot be
28    /// read. A settings file that cannot be read is not a reason to refuse to
29    /// open a window.
30    pub fn load() -> Self {
31        path()
32            .and_then(|path| std::fs::read_to_string(path).ok())
33            .map_or_else(Self::default, |text| Self::parse(&text))
34    }
35
36    /// Write the file, creating its directory.
37    ///
38    /// # Errors
39    ///
40    /// The directory could not be made or the file could not be written, or
41    /// there is no place to put it, which is a machine with no home directory.
42    pub fn save(&self) -> std::io::Result<()> {
43        let path = path().ok_or_else(|| {
44            std::io::Error::new(std::io::ErrorKind::NotFound, "no configuration directory")
45        })?;
46        if let Some(directory) = path.parent() {
47            std::fs::create_dir_all(directory)?;
48        }
49        std::fs::write(path, self.to_string())
50    }
51
52    fn parse(text: &str) -> Self {
53        let mut settings = Self::default();
54        for line in text.lines() {
55            let Some((key, value)) = line.split_once('=') else {
56                continue;
57            };
58            if key.trim() == OPEN_IN_EDIT_MODE {
59                settings.open_in_edit_mode = value.trim() == "true";
60            }
61        }
62        settings
63    }
64}
65
66impl std::fmt::Display for Settings {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        writeln!(f, "{OPEN_IN_EDIT_MODE} = {}", self.open_in_edit_mode)
69    }
70}
71
72/// Where the file is: the platform's configuration directory, then `odox`.
73///
74/// Linux follows the XDG base directory specification, macOS puts an
75/// application's files under `Library/Application Support` inside the sandbox
76/// container the bundle identifier keys, and Windows uses roaming
77/// `AppData`. Each is read from the environment and the home directory rather
78/// than through a crate, because there is one file and three answers.
79pub fn path() -> Option<PathBuf> {
80    let base = if cfg!(target_os = "windows") {
81        std::env::var_os("APPDATA").map(PathBuf::from)?
82    } else if cfg!(target_os = "macos") {
83        std::env::home_dir()?.join("Library/Application Support")
84    } else {
85        match std::env::var_os("XDG_CONFIG_HOME") {
86            Some(config) if !config.is_empty() => PathBuf::from(config),
87            _ => std::env::home_dir()?.join(".config"),
88        }
89    };
90    Some(base.join("odox").join("settings.toml"))
91}
92
93#[cfg(test)]
94mod tests {
95    use super::Settings;
96
97    #[test]
98    fn what_is_written_is_what_is_read() {
99        let on = Settings {
100            open_in_edit_mode: true,
101        };
102        assert_eq!(Settings::parse(&on.to_string()), on);
103        assert_eq!(
104            Settings::parse(&Settings::default().to_string()),
105            Settings::default()
106        );
107    }
108
109    #[test]
110    fn a_file_from_a_later_version_is_read_for_what_this_one_knows() {
111        let text = "# a comment\nopen-in-edit-mode = true\nsomething-newer = 3\n\n";
112        assert!(Settings::parse(text).open_in_edit_mode);
113        assert!(!Settings::parse("").open_in_edit_mode);
114        assert!(!Settings::parse("open-in-edit-mode = yes").open_in_edit_mode);
115    }
116}