main
  1use anyhow::{Context, Result};
  2use serde::{Deserialize, Serialize};
  3use std::collections::HashMap;
  4use std::fs;
  5use std::path::Path;
  6
  7#[derive(Debug, Deserialize, Serialize, Clone)]
  8pub struct Config {
  9    pub rules: Vec<Rule>,
 10}
 11
 12#[derive(Debug, Deserialize, Serialize, Clone)]
 13pub struct Rule {
 14    pub name: Option<String>,
 15    pub description: Option<String>,
 16    pub filters: HashMap<String, FilterValue>,
 17    pub action: Action,
 18    #[serde(default = "default_enabled")]
 19    pub enabled: bool,
 20}
 21
 22fn default_enabled() -> bool {
 23    true
 24}
 25
 26#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
 27#[serde(rename_all = "snake_case")]
 28pub enum Action {
 29    Done,
 30    Skip,
 31}
 32
 33#[derive(Debug, Deserialize, Serialize, Clone)]
 34#[serde(untagged)]
 35pub enum FilterValue {
 36    String(String),
 37    Bool(bool),
 38    Number(i64),
 39}
 40
 41impl Config {
 42    /// Load configuration from a YAML file
 43    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
 44        let content = fs::read_to_string(path.as_ref())
 45            .context(format!("Failed to read config file: {:?}", path.as_ref()))?;
 46
 47        let config: Config = serde_yaml::from_str(&content)
 48            .context("Failed to parse YAML configuration")?;
 49
 50        config.validate()?;
 51        Ok(config)
 52    }
 53
 54    /// Validate the configuration
 55    pub fn validate(&self) -> Result<()> {
 56        const VALID_FILTERS: &[&str] = &[
 57            "repository",
 58            "reason",
 59            "subject_type",
 60            "subject_title",
 61            "unread",
 62            "age_days",
 63            "updated_before",
 64            "updated_after",
 65        ];
 66
 67        for (i, rule) in self.rules.iter().enumerate() {
 68            let rule_id = rule
 69                .name
 70                .as_ref()
 71                .map(|n| format!("Rule '{}'", n))
 72                .unwrap_or_else(|| format!("Rule {}", i + 1));
 73
 74            // Check that filters is not empty
 75            if rule.filters.is_empty() {
 76                anyhow::bail!("{}: filters cannot be empty", rule_id);
 77            }
 78
 79            // Validate filter field names
 80            for field in rule.filters.keys() {
 81                if !VALID_FILTERS.contains(&field.as_str()) {
 82                    anyhow::bail!(
 83                        "{}: invalid filter field '{}'. Valid fields: {}",
 84                        rule_id,
 85                        field,
 86                        VALID_FILTERS.join(", ")
 87                    );
 88                }
 89            }
 90
 91            // Validate filter types
 92            if let Some(FilterValue::String(_)) = rule.filters.get("unread") {
 93                anyhow::bail!("{}: 'unread' filter must be a boolean", rule_id);
 94            }
 95
 96            if let Some(FilterValue::String(_)) = rule.filters.get("age_days") {
 97                anyhow::bail!("{}: 'age_days' filter must be a number", rule_id);
 98            }
 99        }
100
101        Ok(())
102    }
103
104    /// Get all enabled rules
105    pub fn enabled_rules(&self) -> Vec<&Rule> {
106        self.rules.iter().filter(|r| r.enabled).collect()
107    }
108
109    /// Get count of enabled rules
110    pub fn enabled_count(&self) -> usize {
111        self.rules.iter().filter(|r| r.enabled).count()
112    }
113
114    /// Get count of disabled rules
115    pub fn disabled_count(&self) -> usize {
116        self.rules.iter().filter(|r| !r.enabled).count()
117    }
118}
119
120impl std::fmt::Display for Action {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            Action::Done => write!(f, "done"),
124            Action::Skip => write!(f, "skip"),
125        }
126    }
127}