gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! Configuration management
//!
//! Save and load configuration files under ~/.config/gitstack/
//! - filters.toml: Filter presets
//! - config.toml: Application settings (language, etc.)

use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::i18n::Language;

/// Number of filter preset slots
pub const PRESET_SLOT_COUNT: usize = 5;

/// A single filter preset
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FilterPreset {
    /// Preset name
    pub name: String,
    /// Filter query string
    pub query: String,
}

/// Filter preset settings
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FilterPresets {
    /// Presets for slots 1-5
    #[serde(default)]
    pub slot1: Option<FilterPreset>,
    #[serde(default)]
    pub slot2: Option<FilterPreset>,
    #[serde(default)]
    pub slot3: Option<FilterPreset>,
    #[serde(default)]
    pub slot4: Option<FilterPreset>,
    #[serde(default)]
    pub slot5: Option<FilterPreset>,
}

impl FilterPresets {
    /// Get config file path
    pub fn config_path() -> Option<PathBuf> {
        dirs::config_dir().map(|p| p.join("gitstack").join("filters.toml"))
    }

    /// Load from config file
    pub fn load() -> Self {
        Self::config_path()
            .and_then(|path| fs::read_to_string(&path).ok())
            .and_then(|content| toml::from_str(&content).ok())
            .unwrap_or_default()
    }

    /// Save to config file
    pub fn save(&self) -> Result<()> {
        let path = Self::config_path().context("Config directory not found")?;

        // Create directory
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).context("Failed to create config directory")?;
        }

        let content = toml::to_string_pretty(self).context("Failed to serialize TOML")?;

        // Set permissions to 600 on Unix
        #[cfg(unix)]
        {
            use std::io::Write;
            use std::os::unix::fs::OpenOptionsExt;
            let mut file = std::fs::OpenOptions::new()
                .create(true)
                .write(true)
                .truncate(true)
                .mode(0o600)
                .open(&path)
                .context("Failed to open config file")?;
            file.write_all(content.as_bytes())
                .context("Failed to write config file")?;
        }

        #[cfg(not(unix))]
        {
            fs::write(&path, content).context("Failed to write config file")?;
        }

        Ok(())
    }

    /// Get preset by slot number (1-5)
    pub fn get(&self, slot: usize) -> Option<&FilterPreset> {
        match slot {
            1 => self.slot1.as_ref(),
            2 => self.slot2.as_ref(),
            3 => self.slot3.as_ref(),
            4 => self.slot4.as_ref(),
            5 => self.slot5.as_ref(),
            _ => None,
        }
    }

    /// Set preset for slot number (1-5)
    pub fn set(&mut self, slot: usize, preset: FilterPreset) {
        match slot {
            1 => self.slot1 = Some(preset),
            2 => self.slot2 = Some(preset),
            3 => self.slot3 = Some(preset),
            4 => self.slot4 = Some(preset),
            5 => self.slot5 = Some(preset),
            _ => {}
        }
    }

    /// Iterate all presets (slot number, preset)
    pub fn iter(&self) -> impl Iterator<Item = (usize, &FilterPreset)> {
        [
            (1, &self.slot1),
            (2, &self.slot2),
            (3, &self.slot3),
            (4, &self.slot4),
            (5, &self.slot5),
        ]
        .into_iter()
        .filter_map(|(slot, opt)| opt.as_ref().map(|p| (slot, p)))
    }

    /// Get number of used slots
    pub fn count(&self) -> usize {
        self.iter().count()
    }

    /// Get next empty slot (first available slot number)
    pub fn next_empty_slot(&self) -> Option<usize> {
        (1..=PRESET_SLOT_COUNT).find(|&slot| self.get(slot).is_none())
    }
}

/// Language configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LanguageConfig {
    #[serde(default)]
    pub language: Language,
}

impl LanguageConfig {
    /// Get config file path
    pub fn config_path() -> Option<PathBuf> {
        dirs::config_dir().map(|p| p.join("gitstack").join("config.toml"))
    }

    /// Load from config file
    pub fn load() -> Self {
        Self::config_path()
            .and_then(|path| fs::read_to_string(&path).ok())
            .and_then(|content| toml::from_str(&content).ok())
            .unwrap_or_default()
    }

    /// Save to config file
    pub fn save(&self) -> Result<()> {
        let path = Self::config_path().context("Config directory not found")?;

        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).context("Failed to create config directory")?;
        }

        let content = toml::to_string_pretty(self).context("Failed to serialize TOML")?;

        #[cfg(unix)]
        {
            use std::io::Write;
            use std::os::unix::fs::OpenOptionsExt;
            let mut file = std::fs::OpenOptions::new()
                .create(true)
                .write(true)
                .truncate(true)
                .mode(0o600)
                .open(&path)
                .context("Failed to open config file")?;
            file.write_all(content.as_bytes())
                .context("Failed to write config file")?;
        }

        #[cfg(not(unix))]
        {
            fs::write(&path, content).context("Failed to write config file")?;
        }

        Ok(())
    }
}

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

    #[test]
    fn test_filter_presets_get_set() {
        let mut presets = FilterPresets::default();

        // Empty state
        assert!(presets.get(1).is_none());
        assert_eq!(presets.count(), 0);

        // Set slot 1
        presets.set(
            1,
            FilterPreset {
                name: "My commits".to_string(),
                query: "/author:john".to_string(),
            },
        );
        assert!(presets.get(1).is_some());
        assert_eq!(presets.get(1).unwrap().name, "My commits");
        assert_eq!(presets.count(), 1);

        // Set slot 3
        presets.set(
            3,
            FilterPreset {
                name: "Recent".to_string(),
                query: "/since:1week".to_string(),
            },
        );
        assert_eq!(presets.count(), 2);
        assert_eq!(presets.next_empty_slot(), Some(2));
    }

    #[test]
    fn test_filter_presets_iter() {
        let mut presets = FilterPresets::default();
        presets.set(
            2,
            FilterPreset {
                name: "Test".to_string(),
                query: "/test".to_string(),
            },
        );
        presets.set(
            5,
            FilterPreset {
                name: "Test2".to_string(),
                query: "/test2".to_string(),
            },
        );

        let slots: Vec<usize> = presets.iter().map(|(s, _)| s).collect();
        assert_eq!(slots, vec![2, 5]);
    }
}