sequoia-platform 0.1.0

Sequoia GUI framework — windowing platform (winit + wgpu)
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;

use sequoia_reactive::Signal;

#[derive(Clone)]
pub struct Settings {
    dir: PathBuf,
    file_name: String,
    map: Signal<HashMap<String, String>>,
    watchers: Rc<RefCell<HashMap<(String, String), Signal<String>>>>,
}

#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum SettingsError {
    Io { path: String, message: String },

    Mkdir { path: String, message: String },

    Json { path: String, message: String },

    Encode { message: String },
}

impl std::fmt::Display for SettingsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io { path, message } => write!(f, "odczyt {path}: {message}"),
            Self::Mkdir { path, message } => write!(f, "mkdir {path}: {message}"),
            Self::Json { path, message } => write!(f, "JSON {path}: {message}"),
            Self::Encode { message } => write!(f, "JSON: {message}"),
        }
    }
}

impl std::error::Error for SettingsError {}

impl Settings {
    pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
        Self {
            dir: dir.into(),
            file_name: "settings.json".to_string(),
            map: Signal::new(HashMap::new()),
            watchers: Rc::new(RefCell::new(HashMap::new())),
        }
    }

    pub fn for_app(qualifier: &str, organization: &str, application: &str) -> Self {
        let dir = directories::ProjectDirs::from(qualifier, organization, application)
            .map(|p| p.config_dir().to_path_buf())
            .unwrap_or_else(|| PathBuf::from("."));
        Self::with_dir(dir)
    }

    pub fn path(&self) -> PathBuf {
        self.dir.join(&self.file_name)
    }

    pub fn file_name(mut self, name: impl Into<String>) -> Self {
        self.file_name = name.into();
        self
    }

    pub fn get(&self, key: &str) -> Option<String> {
        self.map.with_untracked(|m| m.get(key).cloned())
    }

    pub fn get_or(&self, key: &str, default: &str) -> String {
        self.get(key).unwrap_or_else(|| default.to_string())
    }

    pub fn set(&self, key: impl Into<String>, value: impl Into<String>) {
        let (k, v) = (key.into(), value.into());
        self.map.update(|m| {
            m.insert(k, v);
        });
    }

    pub fn remove(&self, key: &str) -> bool {
        self.map.with_untracked(|m| m.contains_key(key));
        let mut removed = false;
        self.map.update(|m| {
            removed = m.remove(key).is_some();
        });
        removed
    }

    pub fn watch(&self, key: String, default: String) -> Signal<String> {
        let cache_key = (key, default);
        if let Some(sig) = self.watchers.borrow().get(&cache_key) {
            return sig.clone();
        }
        let (key, default) = cache_key.clone();
        let map = self.map.clone();
        let out = Signal::new(
            map.with_untracked(|m| m.get(&key).cloned().unwrap_or_else(|| default.clone())),
        );
        let out2 = out.clone();
        sequoia_reactive::EffectHandle::new(sequoia_reactive::effect(move || {
            let v = map
                .with(|m| m.get(&key).cloned())
                .unwrap_or_else(|| default.clone());
            out2.set(v);
        }))
        .leak();
        self.watchers.borrow_mut().insert(cache_key, out.clone());
        out
    }

    pub fn load(&self) -> Result<(), SettingsError> {
        let path = self.path();
        let text = match std::fs::read_to_string(&path) {
            Ok(t) => t,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
            Err(e) => {
                return Err(SettingsError::Io {
                    path: path.display().to_string(),
                    message: e.to_string(),
                });
            }
        };
        let map: HashMap<String, String> =
            serde_json::from_str(&text).map_err(|e| SettingsError::Json {
                path: path.display().to_string(),
                message: e.to_string(),
            })?;
        self.map.set(map);
        Ok(())
    }

    pub fn save(&self) -> Result<(), SettingsError> {
        let path = self.path();
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| SettingsError::Mkdir {
                path: parent.display().to_string(),
                message: e.to_string(),
            })?;
        }
        let map = self.map.get_untracked();
        let text = serde_json::to_string_pretty(&map).map_err(|e| SettingsError::Encode {
            message: e.to_string(),
        })?;
        std::fs::write(&path, text).map_err(|e| SettingsError::Io {
            path: path.display().to_string(),
            message: e.to_string(),
        })?;
        Ok(())
    }

    pub fn dir(&self) -> &Path {
        &self.dir
    }

    pub fn len(&self) -> usize {
        self.map.with_untracked(|m| m.len())
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

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

    fn tmp_app(name: &str) -> (Settings, PathBuf) {
        let dir = std::env::temp_dir().join(format!(
            "sequoia-settings-test-{name}-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        (Settings::with_dir(dir.clone()), dir)
    }

    #[test]
    fn roundtrip_through_disk() {
        let (s, dir) = tmp_app("roundtrip");
        s.set("theme", "light");
        s.set("w", "1200");
        s.save().unwrap();
        assert!(s.path().starts_with(&dir));

        let s2 = Settings::with_dir(dir.clone());
        s2.load().unwrap();
        assert_eq!(s2.get("theme").as_deref(), Some("light"));
        assert_eq!(s2.get_or("missing", "d"), "d");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn missing_file_is_empty_not_error() {
        let (s, dir) = tmp_app("missing");
        s.load().unwrap();
        assert!(s.is_empty());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn watch_follows_key() {
        let (s, dir) = tmp_app("watch");
        let t = s.watch("theme".to_string(), "dark".to_string());
        assert_eq!(t.get_untracked(), "dark");
        s.set("theme", "light");
        sequoia_reactive::flush_effects();
        assert_eq!(t.get_untracked(), "light");
        assert!(s.remove("theme"));
        assert!(!s.remove("theme"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn repeated_watch_returns_same_signal() {
        let (s, dir) = tmp_app("watch-cache");
        let a = s.watch("theme".to_string(), "dark".to_string());
        let b = s.watch("theme".to_string(), "dark".to_string());

        assert_eq!(a.id(), b.id(), "second watch must return the same signal");
        s.set("theme", "light");
        sequoia_reactive::flush_effects();
        assert_eq!(a.get_untracked(), "light");
        assert_eq!(b.get_untracked(), "light");

        let c = s.watch("theme".to_string(), "sepia".to_string());
        assert_ne!(c.id(), a.id(), "different default = separate signal");
        assert_eq!(
            c.get_untracked(),
            "light",
            "key already exists - default does not overwrite"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn bad_json_is_error_not_panic() {
        let (s, dir) = tmp_app("badjson");
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(s.path(), "{nie json").unwrap();
        assert!(s.load().is_err());
        let _ = std::fs::remove_dir_all(&dir);
    }
}