quelch 0.8.0

Ingest data from Jira, Confluence, and more directly into Azure AI Search
Documentation
//! Persisted TUI preferences: collapsed sections, log-view toggle, selection.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::Path;

pub const CURRENT_PREFS_VERSION: u32 = 1;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prefs {
    pub version: u32,
    #[serde(default)]
    pub collapsed_sources: HashSet<String>,
    #[serde(default)]
    pub collapsed_subsources: HashMap<String, HashSet<String>>,
    #[serde(default)]
    pub log_view_on: bool,
    #[serde(default = "default_focus")]
    pub focus: String,
    #[serde(default)]
    pub selected_source: Option<String>,
    #[serde(default)]
    pub selected_subsource: Option<(String, String)>,
    #[serde(default)]
    pub drilldown_open: bool,
}

fn default_focus() -> String {
    "sources".into()
}

impl Default for Prefs {
    fn default() -> Self {
        Self {
            version: CURRENT_PREFS_VERSION,
            collapsed_sources: HashSet::new(),
            collapsed_subsources: HashMap::new(),
            log_view_on: false,
            focus: default_focus(),
            selected_source: None,
            selected_subsource: None,
            drilldown_open: false,
        }
    }
}

impl Prefs {
    pub fn load(path: &Path) -> Result<Self> {
        if !path.exists() {
            return Ok(Self::default());
        }
        let raw = std::fs::read_to_string(path).context("read prefs")?;
        match serde_json::from_str::<Self>(&raw) {
            Ok(p) => Ok(p),
            Err(e) => {
                tracing::warn!(error = %e, "prefs file unreadable — using defaults");
                Ok(Self::default())
            }
        }
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        let tmp = path.with_extension("tmp");
        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
        std::fs::rename(tmp, path)?;
        Ok(())
    }

    pub fn toggle_source_collapsed(&mut self, src: &str) {
        if !self.collapsed_sources.remove(src) {
            self.collapsed_sources.insert(src.into());
        }
    }

    pub fn toggle_subsource_collapsed(&mut self, src: &str, sub: &str) {
        let set = self
            .collapsed_subsources
            .entry(src.to_string())
            .or_default();
        if !set.remove(sub) {
            set.insert(sub.into());
        }
    }

    pub fn is_source_collapsed(&self, src: &str) -> bool {
        self.collapsed_sources.contains(src)
    }
}

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

    #[test]
    fn roundtrip_default() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("ui.json");
        Prefs::default().save(&path).unwrap();
        let loaded = Prefs::load(&path).unwrap();
        assert_eq!(loaded.version, CURRENT_PREFS_VERSION);
        assert!(loaded.collapsed_sources.is_empty());
        assert!(!loaded.log_view_on);
    }

    #[test]
    fn toggle_source_collapsed() {
        let mut p = Prefs::default();
        p.toggle_source_collapsed("s");
        assert!(p.is_source_collapsed("s"));
        p.toggle_source_collapsed("s");
        assert!(!p.is_source_collapsed("s"));
    }

    #[test]
    fn corrupt_file_falls_back_to_defaults() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("ui.json");
        std::fs::write(&path, "not valid json").unwrap();
        let loaded = Prefs::load(&path).unwrap();
        assert_eq!(loaded.version, CURRENT_PREFS_VERSION);
    }

    #[test]
    fn new_fields_default_to_none_and_false() {
        let p = Prefs::default();
        assert!(p.selected_source.is_none());
        assert!(p.selected_subsource.is_none());
        assert!(!p.drilldown_open);
    }

    #[test]
    fn old_file_without_new_fields_loads_cleanly() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("ui.json");
        std::fs::write(
            &path,
            r#"{"version":1,"collapsed_sources":[],"collapsed_subsources":{},"log_view_on":false,"focus":"sources"}"#,
        )
        .unwrap();
        let p = Prefs::load(&path).unwrap();
        assert_eq!(p.version, 1);
        assert!(p.selected_source.is_none());
    }
}