Skip to main content

exception_collector/
config.rs

1//! 配置加载与 repo-map 管理。
2//!
3//! 从 `repo-map.toml` 加载组件到 GitHub 仓库的映射,
4//! 未显式配置的字段使用 `Default` 值。
5
6use std::{collections::HashMap, path::Path, time::Duration};
7
8use serde::Deserialize;
9
10use crate::CollectorError;
11
12// ── RepoMapConfig ─────────────────────────────────────────────────────────
13
14/// `repo-map.toml` 文件解析结构。
15#[derive(Debug, Deserialize)]
16struct RepoMapFile {
17    /// 组件名 → GitHub 仓库映射。
18    components: HashMap<String, String>,
19}
20
21// ── CollectorConfig ───────────────────────────────────────────────────────
22
23/// 收集器配置。
24///
25/// 控制扫描周期、触发条件、重试策略和组件仓库映射。
26/// 未显式配置的字段使用默认值。
27#[derive(Debug, Clone)]
28pub struct CollectorConfig {
29    /// 日志扫描目录列表,支持 glob pattern,默认空(需手动配置)。
30    pub log_dirs: Vec<String>,
31    /// 日志扫描间隔(秒),默认 300(5 分钟)。
32    pub scan_interval_secs: u64,
33    /// 每日触发小时(UTC),默认 2(即 UTC 02:00)。
34    pub daily_trigger_hour: u8,
35    /// 不同签名数量阈值,达到后触发上报,默认 20。
36    pub distinct_count_threshold: u32,
37    /// 上报成功后的冷却时间,默认 1 小时。
38    pub cooldown_duration: Duration,
39    /// 最大重试次数,默认 3。
40    pub max_retries: u32,
41    /// 重试基础延迟(指数退避),默认 5 分钟。
42    pub retry_base_delay: Duration,
43    /// 组件名 → GitHub 仓库(`owner/repo`)映射。
44    pub repo_map: HashMap<String, String>,
45}
46
47impl Default for CollectorConfig {
48    fn default() -> Self {
49        Self {
50            log_dirs: Vec::new(),
51            scan_interval_secs: 300,
52            daily_trigger_hour: 2,
53            distinct_count_threshold: 20,
54            cooldown_duration: Duration::from_hours(1),
55            max_retries: 3,
56            retry_base_delay: Duration::from_mins(5),
57            repo_map: HashMap::new(),
58        }
59    }
60}
61
62impl CollectorConfig {
63    /// 创建使用默认值的配置。
64    #[must_use]
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// 从 `repo-map.toml` 文件加载配置。
70    ///
71    /// 文件中只包含 `[components]` 仓库映射表,其余字段使用默认值。
72    ///
73    /// # Errors
74    ///
75    /// 返回 `CollectorError::Io` 当文件不存在或读取失败。
76    /// 返回 `CollectorError::TomlParse` 当 TOML 格式不合法。
77    #[allow(clippy::disallowed_methods, reason = "同步初始化函数,非 async 上下文")]
78    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, CollectorError> {
79        let path = path.as_ref();
80        let content = std::fs::read_to_string(path).map_err(|source| CollectorError::Io {
81            source,
82            path: path.to_path_buf(),
83        })?;
84        let file: RepoMapFile =
85            toml::from_str(&content).map_err(|source| CollectorError::TomlParse {
86                source,
87                path: path.to_path_buf(),
88            })?;
89        Ok(Self {
90            repo_map: file.components,
91            ..Default::default()
92        })
93    }
94
95    /// 根据组件名查找对应的 GitHub 仓库(`owner/repo` 格式)。
96    ///
97    /// 如果组件名不在 `repo_map` 中,返回 `None`。
98    #[must_use]
99    pub fn repo_for(&self, component: &str) -> Option<&str> {
100        self.repo_map.get(component).map(String::as_str)
101    }
102}
103
104// ── Tests ─────────────────────────────────────────────────────────────────
105
106#[cfg(test)]
107#[allow(
108    clippy::unwrap_used,
109    clippy::unwrap_in_result,
110    clippy::expect_used,
111    clippy::panic,
112    clippy::pedantic,
113    clippy::disallowed_methods,
114    clippy::indexing_slicing,
115    reason = "test module relaxes production lint strictness"
116)]
117mod tests {
118    use std::path::PathBuf;
119
120    use super::*;
121
122    #[test]
123    fn test_collector_config_should_have_sane_defaults() {
124        let config = CollectorConfig::default();
125
126        assert!(config.log_dirs.is_empty());
127        assert_eq!(config.scan_interval_secs, 300);
128        assert_eq!(config.daily_trigger_hour, 2);
129        assert_eq!(config.distinct_count_threshold, 20);
130        assert_eq!(config.cooldown_duration, Duration::from_hours(1));
131        assert_eq!(config.max_retries, 3);
132        assert_eq!(config.retry_base_delay, Duration::from_mins(5));
133        assert!(config.repo_map.is_empty());
134    }
135
136    #[test]
137    fn test_collector_config_new_should_equal_default() {
138        let config = CollectorConfig::new();
139        let default = CollectorConfig::default();
140
141        assert_eq!(config.daily_trigger_hour, default.daily_trigger_hour);
142        assert_eq!(
143            config.distinct_count_threshold,
144            default.distinct_count_threshold
145        );
146        assert_eq!(config.max_retries, default.max_retries);
147    }
148
149    #[test]
150    fn test_collector_config_should_be_cloneable() {
151        let mut config = CollectorConfig::default();
152        config
153            .repo_map
154            .insert("comp".to_string(), "owner/repo".to_string());
155        let cloned = config.clone();
156
157        assert_eq!(cloned.daily_trigger_hour, config.daily_trigger_hour);
158        assert_eq!(cloned.repo_map, config.repo_map);
159    }
160
161    #[test]
162    fn test_collector_config_from_file_should_parse_repo_map() {
163        let dir = tempfile::tempdir().expect("create temp dir");
164        let path = dir.path().join("repo-map.toml");
165        std::fs::write(
166            &path,
167            r#"
168[components]
169"token-fleet-switch" = "TokenFleet-AI/token-fleet-switch"
170"agent-proxy-rust" = "TokenFleet-AI/agent-proxy-rust"
171"#,
172        )
173        .expect("write test file");
174
175        let config = CollectorConfig::from_file(&path).expect("parse config");
176
177        assert_eq!(config.repo_map.len(), 2);
178        assert_eq!(
179            config
180                .repo_map
181                .get("token-fleet-switch")
182                .map(String::as_str),
183            Some("TokenFleet-AI/token-fleet-switch")
184        );
185        assert_eq!(
186            config.repo_map.get("agent-proxy-rust").map(String::as_str),
187            Some("TokenFleet-AI/agent-proxy-rust")
188        );
189        // defaults preserved
190        assert_eq!(config.daily_trigger_hour, 2);
191        assert_eq!(config.distinct_count_threshold, 20);
192    }
193
194    #[test]
195    fn test_collector_config_from_file_should_error_on_missing_file() {
196        let result = CollectorConfig::from_file("/nonexistent/path/repo-map.toml");
197
198        assert!(result.is_err());
199        let err = result.expect_err("expected error for missing file");
200        match err {
201            CollectorError::Io { path, .. } => {
202                assert_eq!(path, PathBuf::from("/nonexistent/path/repo-map.toml"));
203            }
204            _ => panic!("expected Io error, got: {err:?}"),
205        }
206    }
207
208    #[test]
209    fn test_collector_config_from_file_should_error_on_invalid_toml() {
210        let dir = tempfile::tempdir().expect("create temp dir");
211        let path = dir.path().join("bad.toml");
212        std::fs::write(&path, "this is not valid toml {{{").expect("write bad file");
213
214        let result = CollectorConfig::from_file(&path);
215
216        assert!(result.is_err());
217        let err = result.expect_err("expected error for invalid TOML");
218        match err {
219            CollectorError::TomlParse { path: p, .. } => {
220                assert_eq!(p, path);
221            }
222            _ => panic!("expected TomlParse error, got: {err:?}"),
223        }
224    }
225
226    #[test]
227    fn test_collector_config_from_file_should_error_when_components_key_missing() {
228        let dir = tempfile::tempdir().expect("create temp dir");
229        let path = dir.path().join("no-components.toml");
230        std::fs::write(&path, "[other]\nkey = \"value\"\n").expect("write file");
231
232        let result = CollectorConfig::from_file(&path);
233
234        assert!(result.is_err());
235    }
236
237    #[test]
238    fn test_collector_config_from_file_should_handle_empty_components() {
239        let dir = tempfile::tempdir().expect("create temp dir");
240        let path = dir.path().join("empty.toml");
241        std::fs::write(&path, "[components]\n").expect("write file");
242
243        let config = CollectorConfig::from_file(&path).expect("parse empty components");
244
245        assert!(config.repo_map.is_empty());
246        assert_eq!(config.daily_trigger_hour, 2);
247    }
248
249    // -- repo_for --
250
251    #[test]
252    fn test_repo_for_should_return_repo_for_known_component() {
253        let mut config = CollectorConfig::default();
254        config.repo_map.insert(
255            "token-fleet-switch".to_string(),
256            "TokenFleet-AI/token-fleet-switch".to_string(),
257        );
258
259        assert_eq!(
260            config.repo_for("token-fleet-switch"),
261            Some("TokenFleet-AI/token-fleet-switch")
262        );
263    }
264
265    #[test]
266    fn test_repo_for_should_return_none_for_unknown_component() {
267        let config = CollectorConfig::default();
268
269        assert_eq!(config.repo_for("unknown-component"), None);
270    }
271
272    #[test]
273    fn test_repo_for_should_return_none_for_empty_map() {
274        let config = CollectorConfig::new();
275
276        assert_eq!(config.repo_for("anything"), None);
277    }
278}