pulsedeck 0.1.5

A cyber-synthwave internet radio player and smart tape recorder for your terminal
use super::LayoutMode;
use serde::{Deserialize, Serialize};

#[cfg(not(test))]
use std::fs;
#[cfg(not(test))]
use std::path::{Path, PathBuf};

const DEFAULT_VOLUME: u8 = 80;
const MAX_VOLUME: u8 = 100;
const VISUALIZER_MODE_COUNT: usize = 3;
#[cfg(not(test))]
const NEW_CONFIG_DIR: &str = "pulsedeck";
#[cfg(not(test))]
const OLD_CONFIG_DIR: &str = "driftfm";
#[cfg(not(test))]
const UI_STATE_FILE: &str = "ui-state.json";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct UiState {
    #[serde(default = "default_volume")]
    volume: u8,
    #[serde(default)]
    muted: bool,
    #[serde(default = "default_layout_mode_key")]
    layout_mode: String,
    #[serde(default)]
    visualizer_mode: usize,
}

impl Default for UiState {
    fn default() -> Self {
        Self {
            volume: DEFAULT_VOLUME,
            muted: false,
            layout_mode: default_layout_mode_key(),
            visualizer_mode: 0,
        }
    }
}

impl UiState {
    #[cfg(not(test))]
    pub(super) fn load() -> Self {
        let Some(path) = ui_state_path() else {
            return Self::default();
        };

        fs::read_to_string(path)
            .ok()
            .and_then(|contents| serde_json::from_str::<Self>(&contents).ok())
            .map(Self::sanitized)
            .unwrap_or_default()
    }

    #[cfg(test)]
    pub(super) fn load() -> Self {
        Self::default()
    }

    pub(super) fn from_app_values(
        volume: u8,
        muted: bool,
        layout_mode: LayoutMode,
        visualizer_mode: usize,
    ) -> Self {
        Self {
            volume,
            muted,
            layout_mode: layout_mode_key(layout_mode).to_string(),
            visualizer_mode,
        }
        .sanitized()
    }

    pub(super) fn volume(&self) -> u8 {
        self.volume
    }

    pub(super) fn muted(&self) -> bool {
        self.muted
    }

    pub(super) fn layout_mode(&self) -> LayoutMode {
        parse_layout_mode_key(&self.layout_mode).unwrap_or(LayoutMode::Split)
    }

    pub(super) fn visualizer_mode(&self) -> usize {
        self.visualizer_mode.min(VISUALIZER_MODE_COUNT - 1)
    }

    #[cfg(not(test))]
    pub(super) fn save(&self) -> anyhow::Result<()> {
        let Some(path) = ui_state_path() else {
            return Ok(());
        };

        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }

        let json = serde_json::to_string_pretty(&self.clone().sanitized())?;
        fs::write(path, json)?;
        Ok(())
    }

    fn sanitized(mut self) -> Self {
        self.volume = self.volume.min(MAX_VOLUME);
        if parse_layout_mode_key(&self.layout_mode).is_none() {
            self.layout_mode = default_layout_mode_key();
        }
        self.visualizer_mode = self.visualizer_mode.min(VISUALIZER_MODE_COUNT - 1);
        self
    }
}

#[cfg(not(test))]
pub(super) fn save_ui_state_or_notice(app: &mut super::App) {
    let state =
        UiState::from_app_values(app.volume, app.muted, app.layout_mode, app.visualizer_mode);

    if let Err(err) = state.save() {
        app.set_error_notice(format!("Could not save UI state: {err}"));
    }
}

#[cfg(test)]
pub(super) fn save_ui_state_or_notice(_app: &mut super::App) {}

fn default_volume() -> u8 {
    DEFAULT_VOLUME
}

fn default_layout_mode_key() -> String {
    layout_mode_key(LayoutMode::Split).to_string()
}

fn layout_mode_key(layout_mode: LayoutMode) -> &'static str {
    match layout_mode {
        LayoutMode::Split => "split",
        LayoutMode::LeftOnly => "left-only",
        LayoutMode::RightOnly => "right-only",
    }
}

fn parse_layout_mode_key(key: &str) -> Option<LayoutMode> {
    match key {
        "split" => Some(LayoutMode::Split),
        "left-only" => Some(LayoutMode::LeftOnly),
        "right-only" => Some(LayoutMode::RightOnly),
        _ => None,
    }
}

#[cfg(not(test))]
fn ui_state_path() -> Option<PathBuf> {
    dirs::config_dir().map(|base| {
        let new_path = ui_state_path_for(&base, NEW_CONFIG_DIR);
        let old_path = ui_state_path_for(&base, OLD_CONFIG_DIR);
        migrate_file_if_needed(&old_path, &new_path);
        new_path
    })
}

#[cfg(not(test))]
fn ui_state_path_for(base: &Path, config_dir: &str) -> PathBuf {
    base.join(config_dir).join(UI_STATE_FILE)
}

#[cfg(not(test))]
fn migrate_file_if_needed(old_path: &Path, new_path: &Path) {
    if new_path.exists() || !old_path.exists() {
        return;
    }

    if let Some(parent) = new_path.parent() {
        if fs::create_dir_all(parent).is_err() {
            return;
        }
    }

    let _ = fs::copy(old_path, new_path);
}

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

    #[test]
    fn test_load_uses_defaults() {
        let state = UiState::load();

        assert_eq!(state.volume(), 80);
        assert!(!state.muted());
        assert_eq!(state.layout_mode(), LayoutMode::Split);
        assert_eq!(state.visualizer_mode(), 0);
    }

    #[test]
    fn sanitizes_loaded_values() {
        let state = UiState {
            volume: 255,
            muted: true,
            layout_mode: "garbage".to_string(),
            visualizer_mode: 99,
        };

        let state = state.sanitized();

        assert_eq!(state.volume(), 100);
        assert!(state.muted());
        assert_eq!(state.layout_mode(), LayoutMode::Split);
        assert_eq!(state.visualizer_mode(), 2);
    }

    #[test]
    fn layout_mode_keys_roundtrip() {
        for mode in [
            LayoutMode::Split,
            LayoutMode::LeftOnly,
            LayoutMode::RightOnly,
        ] {
            assert_eq!(parse_layout_mode_key(layout_mode_key(mode)), Some(mode));
        }
    }

    #[test]
    fn from_app_values_clamps_visualizer_mode() {
        let state = UiState::from_app_values(65, true, LayoutMode::RightOnly, 10);

        assert_eq!(state.volume(), 65);
        assert!(state.muted());
        assert_eq!(state.layout_mode(), LayoutMode::RightOnly);
        assert_eq!(state.visualizer_mode(), 2);
    }
}