monitr 0.3.49

A lightweight macOS activity monitor TUI built with Rust and Ratatui
use std::{fs, path::PathBuf, time::Duration};

use serde::{Deserialize, Serialize};

use crate::app::{SortKey, Tab};

const CONFIG_VERSION: u32 = 1;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Preferences {
    version: u32,
    pub tab: String,
    pub sort_key: String,
    pub sort_desc: bool,
    pub show_details: bool,
    pub overview_visible: bool,
    pub interval_ms: u64,
    pub filter: String,
    pub compact_mode: bool,
}

impl Default for Preferences {
    fn default() -> Self {
        Self {
            version: CONFIG_VERSION,
            tab: "Cpu".to_string(),
            sort_key: "Cpu".to_string(),
            sort_desc: true,
            show_details: true,
            overview_visible: true,
            interval_ms: 1_000,
            filter: String::new(),
            compact_mode: false,
        }
    }
}

impl Preferences {
    pub fn load() -> Self {
        let path = match config_path() {
            Some(p) => p,
            None => return Self::default(),
        };
        if !path.exists() {
            return Self::default();
        }
        match fs::read_to_string(&path) {
            Ok(data) => match serde_json::from_str::<Preferences>(&data) {
                Ok(prefs) => prefs.migrate(),
                Err(_) => Self::default(),
            },
            Err(_) => Self::default(),
        }
    }

    pub fn save(&self) {
        let Some(path) = config_path() else {
            return;
        };
        if let Some(parent) = path.parent() {
            let _ = fs::create_dir_all(parent);
        }
        let _ = fs::write(
            &path,
            serde_json::to_string_pretty(self).unwrap_or_default(),
        );
    }

    fn migrate(mut self) -> Self {
        if self.version < CONFIG_VERSION {
            self.version = CONFIG_VERSION;
        }
        self
    }

    pub fn apply_tab(&self) -> Tab {
        match self.tab.as_str() {
            "Cpu" => Tab::Cpu,
            "Memory" => Tab::Memory,
            "Energy" => Tab::Energy,
            "Disk" => Tab::Disk,
            "Network" => Tab::Network,
            "Movers" => Tab::Movers,
            _ => Tab::Cpu,
        }
    }

    pub fn apply_sort_key(&self) -> SortKey {
        SortKey::from_config_name(&self.sort_key)
    }

    pub fn from_app(app: &PreferencesSource) -> Self {
        Self {
            version: CONFIG_VERSION,
            tab: app.tab.title().to_string(),
            sort_key: app.sort_key.config_name().to_string(),
            sort_desc: app.sort_desc,
            show_details: app.show_details,
            overview_visible: app.overview_visible,
            interval_ms: app.interval.as_millis() as u64,
            filter: app.filter.clone(),
            compact_mode: app.compact_mode,
        }
    }
}

pub struct PreferencesSource {
    pub tab: Tab,
    pub sort_key: SortKey,
    pub sort_desc: bool,
    pub show_details: bool,
    pub overview_visible: bool,
    pub interval: Duration,
    pub filter: String,
    pub compact_mode: bool,
}

fn config_path() -> Option<PathBuf> {
    let home = std::env::var("HOME").ok()?;
    Some(
        PathBuf::from(home)
            .join("Library")
            .join("Application Support")
            .join("monitr")
            .join("config.json"),
    )
}

#[cfg(test)]
mod tests {
    use super::{Preferences, PreferencesSource};
    use crate::app::{SortKey, Tab};
    use std::time::Duration;

    const ALL_SORT_KEYS: [SortKey; 12] = [
        SortKey::Cpu,
        SortKey::Memory,
        SortKey::Energy,
        SortKey::DiskRead,
        SortKey::DiskWrite,
        SortKey::NetworkIn,
        SortKey::NetworkOut,
        SortKey::Trend,
        SortKey::Name,
        SortKey::Pid,
        SortKey::User,
        SortKey::Runtime,
    ];

    fn source_for(sort_key: SortKey) -> PreferencesSource {
        PreferencesSource {
            tab: Tab::Cpu,
            sort_key,
            sort_desc: true,
            show_details: true,
            overview_visible: true,
            interval: Duration::from_millis(1_000),
            filter: String::new(),
            compact_mode: false,
        }
    }

    #[test]
    fn sort_preferences_round_trip_every_key() {
        for sort_key in ALL_SORT_KEYS {
            let prefs = Preferences::from_app(&source_for(sort_key));

            assert_eq!(prefs.sort_key, sort_key.config_name());
            assert_eq!(prefs.apply_sort_key(), sort_key);
        }
    }

    #[test]
    fn sort_preferences_accept_legacy_display_labels() {
        let legacy_labels = [
            ("% CPU", SortKey::Cpu),
            ("Impact", SortKey::Energy),
            ("Read/s", SortKey::DiskRead),
            ("Write/s", SortKey::DiskWrite),
            ("In/s", SortKey::NetworkIn),
            ("Out/s", SortKey::NetworkOut),
            ("Change", SortKey::Trend),
            ("PID", SortKey::Pid),
        ];

        for (stored, expected) in legacy_labels {
            let prefs = Preferences {
                sort_key: stored.to_string(),
                ..Preferences::default()
            };

            assert_eq!(prefs.apply_sort_key(), expected);
        }
    }
}