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}