Skip to main content

exception_collector/
lib.rs

1//! Exception Collector — 全项目组异常收集系统。
2//!
3//! 自动捕获 panic、error 日志和 `Result::Err`,根据用户是否参与「共建计划」
4//! 走两条上报路径:
5//!
6//! - **共建用户**(已安装 `gh` CLI):LLM 语义去重 → 自动创建 GitHub Issue
7//! - **普通用户**(无 `gh`):本地签名去重 → 批量上传自研收集平台
8//!
9//! # 集成方式
10//!
11//! 每个组件在启动入口调用 `install()` 注册 panic hook 和 tracing layer:
12//!
13//! ```rust,ignore
14//! use exception_collector::{CollectorConfig, install};
15//!
16//! let config = CollectorConfig::from_file("repo-map.toml")?;
17//! install(config, "component-name".into());
18//! ```
19
20#![forbid(unsafe_code)]
21#![warn(missing_docs, missing_debug_implementations)]
22
23mod buffer;
24#[cfg(feature = "http-llm")]
25mod channel;
26mod collector;
27mod config;
28mod dedup;
29mod llm;
30mod normalize;
31mod pipeline;
32mod reporter;
33mod retry;
34mod signature;
35
36use std::{fmt, path::PathBuf};
37
38use async_trait::async_trait;
39pub use buffer::{ExceptionBuffer, exceptions_dir};
40#[cfg(feature = "http-llm")]
41pub use channel::HttpLlmChannel;
42use chrono::{DateTime, Utc};
43pub use collector::{collect_result_err, collect_unreported};
44pub use config::CollectorConfig;
45pub use dedup::DedupEngine;
46#[cfg(feature = "http-llm")]
47pub use llm::default_classifier;
48pub use llm::{
49    ClassificationAction, ClassificationResult, ExistingIssueData, LlmChannel, LlmClassifier,
50};
51pub use pipeline::PipelineRunner;
52pub use reporter::{CustomPlatformReporter, GitHubReporter};
53pub use retry::{RetryPolicy, with_retry};
54use serde::{Deserialize, Serialize};
55pub use signature::compute_signature;
56use thiserror::Error;
57use uuid::Uuid;
58
59// ── ExceptionKind ─────────────────────────────────────────────────────────
60
61/// 异常来源类型。
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub enum ExceptionKind {
64    /// 来自 `std::panic::set_hook` 捕获的 panic。
65    Panic,
66    /// 来自 tracing `error!` / `warn!` 日志级别。
67    ErrorLog,
68    /// 来自显式 `Result::Err` 包装。
69    ResultErr,
70}
71
72impl fmt::Display for ExceptionKind {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            Self::Panic => f.write_str("panic"),
76            Self::ErrorLog => f.write_str("error_log"),
77            Self::ResultErr => f.write_str("result_err"),
78        }
79    }
80}
81
82// ── ExceptionRecord ───────────────────────────────────────────────────────
83
84/// 单条异常记录。
85///
86/// 每次捕获异常时创建一条记录,包含完整的上下文信息用于后续
87/// 签名计算、归一化和上报。
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ExceptionRecord {
90    /// 全局唯一标识。
91    pub id: Uuid,
92    /// 产生异常的组件名,如 `"token-fleet-switch"`。
93    pub component: String,
94    /// 异常来源类型。
95    pub kind: ExceptionKind,
96    /// 异常消息,截断至 500 字符。
97    pub message: String,
98    /// 完整堆栈信息。
99    pub stacktrace: String,
100    /// tracing span 链(从外到内)。
101    pub span_chain: Vec<String>,
102    /// 产生异常的 Rust 模块路径。
103    pub module_path: Option<String>,
104    /// 源码位置,格式为 `file:line`。
105    pub location: Option<String>,
106    /// 异常发生时间(UTC)。
107    pub timestamp: DateTime<Utc>,
108    /// SHA256 归一化签名,用于本地去重。
109    pub dedup_signature: String,
110    /// 是否已上报。
111    pub reported: bool,
112    /// 关联的 GitHub Issue URL(重复异常时填写)。
113    pub duplicate_of: Option<String>,
114}
115
116impl ExceptionRecord {
117    /// 创建一条新的异常记录。
118    ///
119    /// `message` 超过 500 字符时自动截断。
120    #[must_use]
121    pub fn new(
122        component: impl Into<String>,
123        kind: ExceptionKind,
124        message: impl Into<String>,
125        stacktrace: impl Into<String>,
126    ) -> Self {
127        let raw_message = message.into();
128        let truncated_message = if raw_message.len() > 500 {
129            raw_message[..500].to_string()
130        } else {
131            raw_message
132        };
133
134        Self {
135            id: Uuid::new_v4(),
136            component: component.into(),
137            kind,
138            message: truncated_message,
139            stacktrace: stacktrace.into(),
140            span_chain: Vec::new(),
141            module_path: None,
142            location: None,
143            timestamp: Utc::now(),
144            dedup_signature: String::new(),
145            reported: false,
146            duplicate_of: None,
147        }
148    }
149}
150
151// ── AggregatedException ───────────────────────────────────────────────────
152
153/// 缓冲区内按签名聚合的异常。
154///
155/// 相同 `dedup_signature` 的异常记录被聚合为一条,
156/// 只保留首次出现的样本记录,后续仅累加计数和更新时间。
157#[derive(Debug, Clone)]
158pub struct AggregatedException {
159    /// 归一化签名。
160    pub signature: String,
161    /// 首次出现时间。
162    pub first_seen: DateTime<Utc>,
163    /// 最近出现时间。
164    pub last_seen: DateTime<Utc>,
165    /// 累计出现次数。
166    pub count: u32,
167    /// 样本异常记录(首次出现的记录)。
168    pub sample: ExceptionRecord,
169}
170
171// ── ExceptionBatch ────────────────────────────────────────────────────────
172
173/// 上报批次。
174///
175/// 触发条件满足时,从缓冲区取出待上报的聚合异常,
176/// 组装成一个批次进行上报。
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ExceptionBatch {
179    /// 批次唯一标识。
180    pub id: Uuid,
181    /// 组件名。
182    pub component: String,
183    /// 本批次包含的异常记录。
184    pub records: Vec<ExceptionRecord>,
185    /// 上报目标。
186    pub target: ReportTarget,
187    /// 批次创建时间(UTC)。
188    pub created_at: DateTime<Utc>,
189    /// 批次状态。
190    pub status: BatchStatus,
191}
192
193impl ExceptionBatch {
194    /// 创建一个新的待上报批次。
195    #[must_use]
196    pub fn new(
197        component: impl Into<String>,
198        records: Vec<ExceptionRecord>,
199        target: ReportTarget,
200    ) -> Self {
201        Self {
202            id: Uuid::new_v4(),
203            component: component.into(),
204            records,
205            target,
206            created_at: Utc::now(),
207            status: BatchStatus::Pending,
208        }
209    }
210}
211
212// ── ReportTarget ──────────────────────────────────────────────────────────
213
214/// 上报目标。
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub enum ReportTarget {
217    /// GitHub Issue(共建用户路径)。
218    GitHub {
219        /// 目标仓库,格式 `owner/repo`。
220        repo: String,
221        /// 关联的 Issue URL(评论已有 Issue 时填写)。
222        issue_url: String,
223    },
224    /// 自研收集平台(普通用户路径)。
225    CustomPlatform {
226        /// API endpoint URL。
227        endpoint: String,
228    },
229}
230
231// ── BatchStatus ───────────────────────────────────────────────────────────
232
233/// 批次上报状态。
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub enum BatchStatus {
236    /// 等待上报。
237    Pending,
238    /// 已成功上报。
239    Reported,
240    /// 上报失败,包含失败原因和下次重试时间。
241    Failed {
242        /// 失败原因。
243        reason: String,
244        /// 下次重试时间(UTC)。
245        retry_at: DateTime<Utc>,
246    },
247}
248
249// ── Error Types ───────────────────────────────────────────────────────────
250
251/// 收集器错误类型。
252#[derive(Debug, Error)]
253pub enum CollectorError {
254    /// I/O 错误(文件读写失败)。
255    #[error("failed to read file: {}", .path.display())]
256    Io {
257        /// 底层 I/O 错误。
258        #[source]
259        source: std::io::Error,
260        /// 失败的文件路径。
261        path: PathBuf,
262    },
263
264    /// TOML 解析错误。
265    #[error("failed to parse TOML: {}", .path.display())]
266    TomlParse {
267        /// 底层解析错误。
268        #[source]
269        source: toml::de::Error,
270        /// 失败的文件路径。
271        path: PathBuf,
272    },
273
274    /// `SQLite` 持久化错误。
275    #[error("sqlite error: {0}")]
276    Sqlite(
277        /// 底层 `SQLite` 错误。
278        #[source]
279        rusqlite::Error,
280    ),
281
282    /// JSON 序列化/反序列化错误。
283    #[error("json error: {0}")]
284    SerdeJson(
285        /// 底层 JSON 错误。
286        #[source]
287        serde_json::Error,
288    ),
289
290    /// 外部命令执行错误(如 `gh` CLI)。
291    #[error("command error: {message}")]
292    Command {
293        /// 人类可读的错误描述。
294        message: String,
295        /// 底层 I/O 错误。
296        #[source]
297        source: Box<dyn std::error::Error + Send + Sync>,
298    },
299
300    /// HTTP 请求错误。
301    #[error("http error: {message}")]
302    Http {
303        /// 人类可读的错误描述。
304        message: String,
305        /// 底层传输错误。
306        #[source]
307        source: Box<dyn std::error::Error + Send + Sync>,
308    },
309    /// 上报器错误(通用)。
310    #[error("reporter error: {reason}")]
311    Reporter {
312        /// 人类可读的错误原因。
313        reason: String,
314    },
315}
316
317/// 收集器结果类型别名。
318pub type CollectorResult<T> = std::result::Result<T, CollectorError>;
319
320// ── Reporter Trait ────────────────────────────────────────────────────────
321
322/// 上报结果。
323#[derive(Debug, Clone)]
324pub enum ReportResult {
325    /// 全部成功。
326    Success,
327    /// 部分成功,包含成功上报的记录数。
328    Partial {
329        /// 成功上报的记录数量。
330        reported_count: usize,
331    },
332    /// 全部失败,包含失败原因和下次重试时间。
333    Failed {
334        /// 失败原因。
335        reason: String,
336        /// 下次重试时间(UTC)。
337        retry_at: DateTime<Utc>,
338    },
339}
340
341/// 异常上报器 trait。
342///
343/// 定义异常批次上报的统一接口。不同的上报路径
344/// (GitHub Issue、自研平台)实现此 trait。
345#[async_trait]
346pub trait ExceptionReporter: Send + Sync {
347    /// 上报一个异常批次。
348    ///
349    /// # Errors
350    ///
351    /// 当上报过程发生不可恢复的错误时返回 `CollectorError`。
352    async fn report(&self, batch: &ExceptionBatch) -> CollectorResult<ReportResult>;
353
354    /// 检查此 reporter 是否可用。
355    ///
356    /// 例如 `GitHubReporter` 检查 `gh` CLI 是否在 PATH 中,
357    /// `CustomPlatformReporter` 检查 endpoint 是否已配置。
358    ///
359    /// 默认返回 `true`。
360    async fn check_available(&self) -> bool {
361        true
362    }
363}
364
365// ── Tests ─────────────────────────────────────────────────────────────────
366
367#[cfg(test)]
368#[allow(
369    clippy::unwrap_used,
370    clippy::unwrap_in_result,
371    clippy::expect_used,
372    clippy::panic,
373    clippy::pedantic,
374    clippy::disallowed_methods,
375    clippy::indexing_slicing,
376    clippy::wildcard_enum_match_arm,
377    reason = "test module relaxes production lint strictness"
378)]
379mod tests {
380    use std::time::Duration;
381
382    use super::*;
383
384    // -- ExceptionKind --
385
386    #[test]
387    fn test_exception_kind_should_have_three_variants() {
388        let panic = ExceptionKind::Panic;
389        let error_log = ExceptionKind::ErrorLog;
390        let result_err = ExceptionKind::ResultErr;
391
392        assert_ne!(panic, error_log);
393        assert_ne!(panic, result_err);
394        assert_ne!(error_log, result_err);
395    }
396
397    #[test]
398    fn test_exception_kind_should_be_copy() {
399        let kind = ExceptionKind::Panic;
400        let copied = kind;
401        assert_eq!(kind, copied);
402    }
403
404    #[test]
405    fn test_exception_kind_should_display_correctly() {
406        assert_eq!(ExceptionKind::Panic.to_string(), "panic");
407        assert_eq!(ExceptionKind::ErrorLog.to_string(), "error_log");
408        assert_eq!(ExceptionKind::ResultErr.to_string(), "result_err");
409    }
410
411    #[test]
412    fn test_exception_kind_should_serialize_deserialize() {
413        let kind = ExceptionKind::Panic;
414        let json = serde_json::to_string(&kind).expect("serialize ExceptionKind");
415        let deserialized: ExceptionKind =
416            serde_json::from_str(&json).expect("deserialize ExceptionKind");
417        assert_eq!(kind, deserialized);
418    }
419
420    // -- ExceptionRecord --
421
422    #[test]
423    fn test_exception_record_should_create_with_defaults() {
424        let record =
425            ExceptionRecord::new("test-component", ExceptionKind::Panic, "test message", "");
426
427        assert_eq!(record.component, "test-component");
428        assert_eq!(record.kind, ExceptionKind::Panic);
429        assert_eq!(record.message, "test message");
430        assert!(record.stacktrace.is_empty());
431        assert!(record.span_chain.is_empty());
432        assert!(record.module_path.is_none());
433        assert!(record.location.is_none());
434        assert!(!record.reported);
435        assert!(record.duplicate_of.is_none());
436        assert!(record.dedup_signature.is_empty());
437    }
438
439    #[test]
440    fn test_exception_record_should_truncate_long_message() {
441        let long_message = "x".repeat(600);
442        let record =
443            ExceptionRecord::new("comp", ExceptionKind::ErrorLog, long_message.as_str(), "");
444
445        assert_eq!(record.message.len(), 500);
446    }
447
448    #[test]
449    fn test_exception_record_should_not_truncate_short_message() {
450        let record = ExceptionRecord::new("comp", ExceptionKind::ErrorLog, "short", "");
451
452        assert_eq!(record.message, "short");
453    }
454
455    #[test]
456    fn test_exception_record_should_not_truncate_exact_500_message() {
457        let exact_message = "a".repeat(500);
458        let record =
459            ExceptionRecord::new("comp", ExceptionKind::ErrorLog, exact_message.as_str(), "");
460
461        assert_eq!(record.message.len(), 500);
462    }
463
464    #[test]
465    fn test_exception_record_should_have_unique_ids() {
466        let r1 = ExceptionRecord::new("comp", ExceptionKind::Panic, "msg1", "");
467        let r2 = ExceptionRecord::new("comp", ExceptionKind::Panic, "msg2", "");
468
469        assert_ne!(r1.id, r2.id);
470    }
471
472    #[test]
473    fn test_exception_record_should_serialize_deserialize() {
474        let mut record =
475            ExceptionRecord::new("comp", ExceptionKind::ResultErr, "err msg", "stack trace");
476        record.dedup_signature = "abc123".to_string();
477        record.module_path = Some("my::module".to_string());
478        record.location = Some("src/main.rs:42".to_string());
479        record.span_chain = vec!["span1".to_string(), "span2".to_string()];
480
481        let json = serde_json::to_string(&record).expect("serialize ExceptionRecord");
482        let deserialized: ExceptionRecord =
483            serde_json::from_str(&json).expect("deserialize ExceptionRecord");
484
485        assert_eq!(deserialized.id, record.id);
486        assert_eq!(deserialized.component, "comp");
487        assert_eq!(deserialized.kind, ExceptionKind::ResultErr);
488        assert_eq!(deserialized.message, "err msg");
489        assert_eq!(deserialized.stacktrace, "stack trace");
490        assert_eq!(deserialized.dedup_signature, "abc123");
491        assert_eq!(deserialized.module_path.as_deref(), Some("my::module"));
492        assert_eq!(deserialized.location.as_deref(), Some("src/main.rs:42"));
493        assert_eq!(deserialized.span_chain, vec!["span1", "span2"]);
494        assert!(!deserialized.reported);
495        assert!(deserialized.duplicate_of.is_none());
496    }
497
498    // -- AggregatedException --
499
500    #[test]
501    fn test_aggregated_exception_should_track_count() {
502        let sample = ExceptionRecord::new("comp", ExceptionKind::Panic, "msg", "");
503        let now = Utc::now();
504        let agg = AggregatedException {
505            signature: "sig_abc".to_string(),
506            first_seen: now,
507            last_seen: now,
508            count: 5,
509            sample,
510        };
511
512        assert_eq!(agg.count, 5);
513        assert_eq!(agg.signature, "sig_abc");
514        assert_eq!(agg.first_seen, agg.last_seen);
515    }
516
517    #[test]
518    fn test_aggregated_exception_should_be_cloneable() {
519        let sample = ExceptionRecord::new("comp", ExceptionKind::Panic, "msg", "");
520        let now = Utc::now();
521        let agg = AggregatedException {
522            signature: "sig".to_string(),
523            first_seen: now,
524            last_seen: now,
525            count: 1,
526            sample,
527        };
528        let cloned = agg.clone();
529
530        assert_eq!(cloned.signature, agg.signature);
531        assert_eq!(cloned.count, agg.count);
532        assert_eq!(cloned.first_seen, agg.first_seen);
533    }
534
535    // -- ExceptionBatch --
536
537    #[test]
538    fn test_exception_batch_should_create_with_pending_status() {
539        let records = vec![ExceptionRecord::new(
540            "comp",
541            ExceptionKind::Panic,
542            "msg",
543            "",
544        )];
545        let target = ReportTarget::GitHub {
546            repo: "owner/repo".to_string(),
547            issue_url: String::new(),
548        };
549        let batch = ExceptionBatch::new("comp", records, target);
550
551        assert_eq!(batch.component, "comp");
552        assert_eq!(batch.records.len(), 1);
553        assert_eq!(batch.status, BatchStatus::Pending);
554    }
555
556    #[test]
557    fn test_exception_batch_should_have_unique_ids() {
558        let target = ReportTarget::GitHub {
559            repo: "owner/repo".to_string(),
560            issue_url: String::new(),
561        };
562        let b1 = ExceptionBatch::new("comp", vec![], target.clone());
563        let b2 = ExceptionBatch::new("comp", vec![], target);
564
565        assert_ne!(b1.id, b2.id);
566    }
567
568    #[test]
569    fn test_exception_batch_should_serialize_deserialize() {
570        let record = ExceptionRecord::new("comp", ExceptionKind::ErrorLog, "msg", "");
571        let target = ReportTarget::CustomPlatform {
572            endpoint: "https://example.com/api".to_string(),
573        };
574        let batch = ExceptionBatch::new("comp", vec![record], target);
575
576        let json = serde_json::to_string(&batch).expect("serialize ExceptionBatch");
577        let deserialized: ExceptionBatch =
578            serde_json::from_str(&json).expect("deserialize ExceptionBatch");
579
580        assert_eq!(deserialized.id, batch.id);
581        assert_eq!(deserialized.component, "comp");
582        assert_eq!(deserialized.records.len(), 1);
583        assert_eq!(deserialized.status, BatchStatus::Pending);
584    }
585
586    // -- ReportTarget --
587
588    #[test]
589    fn test_report_target_should_create_github_variant() {
590        let target = ReportTarget::GitHub {
591            repo: "TokenFleet-AI/token-fleet-switch".to_string(),
592            issue_url: "https://github.com/TokenFleet-AI/token-fleet-switch/issues/1".to_string(),
593        };
594        match &target {
595            ReportTarget::GitHub { repo, issue_url } => {
596                assert_eq!(repo, "TokenFleet-AI/token-fleet-switch");
597                assert!(!issue_url.is_empty());
598            }
599            ReportTarget::CustomPlatform { .. } => panic!("expected GitHub variant"),
600        }
601    }
602
603    #[test]
604    fn test_report_target_should_create_custom_platform_variant() {
605        let target = ReportTarget::CustomPlatform {
606            endpoint: "https://exceptions.example.com".to_string(),
607        };
608        match &target {
609            ReportTarget::CustomPlatform { endpoint } => {
610                assert_eq!(endpoint, "https://exceptions.example.com");
611            }
612            ReportTarget::GitHub { .. } => panic!("expected CustomPlatform variant"),
613        }
614    }
615
616    #[test]
617    fn test_report_target_should_support_equality() {
618        let t1 = ReportTarget::GitHub {
619            repo: "owner/repo".to_string(),
620            issue_url: String::new(),
621        };
622        let t2 = ReportTarget::GitHub {
623            repo: "owner/repo".to_string(),
624            issue_url: String::new(),
625        };
626        let t3 = ReportTarget::CustomPlatform {
627            endpoint: "https://example.com".to_string(),
628        };
629
630        assert_eq!(t1, t2);
631        assert_ne!(t1, t3);
632    }
633
634    #[test]
635    fn test_report_target_should_serialize_deserialize() {
636        let target = ReportTarget::GitHub {
637            repo: "owner/repo".to_string(),
638            issue_url: "https://github.com/owner/repo/issues/1".to_string(),
639        };
640        let json = serde_json::to_string(&target).expect("serialize ReportTarget");
641        let deserialized: ReportTarget =
642            serde_json::from_str(&json).expect("deserialize ReportTarget");
643        assert_eq!(target, deserialized);
644    }
645
646    // -- BatchStatus --
647
648    #[test]
649    fn test_batch_status_should_have_three_variants() {
650        let pending = BatchStatus::Pending;
651        let reported = BatchStatus::Reported;
652        let failed = BatchStatus::Failed {
653            reason: "network error".to_string(),
654            retry_at: Utc::now(),
655        };
656
657        assert_eq!(pending, BatchStatus::Pending);
658        assert_eq!(reported, BatchStatus::Reported);
659        assert_ne!(pending, reported);
660        match &failed {
661            BatchStatus::Failed { reason, .. } => {
662                assert_eq!(reason, "network error");
663            }
664            _ => panic!("expected Failed variant"),
665        }
666    }
667
668    #[test]
669    fn test_batch_status_should_serialize_deserialize() {
670        let status = BatchStatus::Failed {
671            reason: "timeout".to_string(),
672            retry_at: Utc::now(),
673        };
674        let json = serde_json::to_string(&status).expect("serialize BatchStatus");
675        let deserialized: BatchStatus =
676            serde_json::from_str(&json).expect("deserialize BatchStatus");
677        assert_eq!(status, deserialized);
678    }
679
680    // -- CollectorConfig --
681
682    #[test]
683    fn test_collector_config_should_have_sane_defaults() {
684        let config = CollectorConfig::default();
685
686        assert_eq!(config.daily_trigger_hour, 2);
687        assert_eq!(config.distinct_count_threshold, 20);
688        assert_eq!(config.cooldown_duration, Duration::from_hours(1));
689        assert_eq!(config.max_retries, 3);
690        assert_eq!(config.retry_base_delay, Duration::from_mins(5));
691        assert!(config.repo_map.is_empty());
692    }
693
694    #[test]
695    fn test_collector_config_new_should_equal_default() {
696        let config = CollectorConfig::new();
697        let default = CollectorConfig::default();
698
699        assert_eq!(config.daily_trigger_hour, default.daily_trigger_hour);
700        assert_eq!(
701            config.distinct_count_threshold,
702            default.distinct_count_threshold
703        );
704        assert_eq!(config.max_retries, default.max_retries);
705    }
706
707    #[test]
708    fn test_collector_config_should_be_cloneable() {
709        let mut config = CollectorConfig::default();
710        config
711            .repo_map
712            .insert("comp".to_string(), "owner/repo".to_string());
713        let cloned = config.clone();
714
715        assert_eq!(cloned.daily_trigger_hour, config.daily_trigger_hour);
716        assert_eq!(cloned.repo_map, config.repo_map);
717    }
718
719    #[test]
720    fn test_collector_config_from_file_should_parse_repo_map() {
721        let dir = tempfile::tempdir().expect("create temp dir");
722        let path = dir.path().join("repo-map.toml");
723        std::fs::write(
724            &path,
725            r#"
726[components]
727"token-fleet-switch" = "TokenFleet-AI/token-fleet-switch"
728"agent-proxy-rust" = "TokenFleet-AI/agent-proxy-rust"
729"#,
730        )
731        .expect("write test file");
732
733        let config = CollectorConfig::from_file(&path).expect("parse config");
734
735        assert_eq!(config.repo_map.len(), 2);
736        assert_eq!(
737            config
738                .repo_map
739                .get("token-fleet-switch")
740                .map(String::as_str),
741            Some("TokenFleet-AI/token-fleet-switch")
742        );
743        assert_eq!(
744            config.repo_map.get("agent-proxy-rust").map(String::as_str),
745            Some("TokenFleet-AI/agent-proxy-rust")
746        );
747        // defaults preserved
748        assert_eq!(config.daily_trigger_hour, 2);
749        assert_eq!(config.distinct_count_threshold, 20);
750    }
751
752    #[test]
753    fn test_collector_config_from_file_should_error_on_missing_file() {
754        let result = CollectorConfig::from_file("/nonexistent/path/repo-map.toml");
755
756        assert!(result.is_err());
757        let err = result.expect_err("expected error for missing file");
758        match err {
759            CollectorError::Io { path, .. } => {
760                assert_eq!(path, PathBuf::from("/nonexistent/path/repo-map.toml"));
761            }
762            _ => panic!("expected Io error, got: {err:?}"),
763        }
764    }
765
766    #[test]
767    fn test_collector_config_from_file_should_error_on_invalid_toml() {
768        let dir = tempfile::tempdir().expect("create temp dir");
769        let path = dir.path().join("bad.toml");
770        std::fs::write(&path, "this is not valid toml {{{").expect("write bad file");
771
772        let result = CollectorConfig::from_file(&path);
773
774        assert!(result.is_err());
775        let err = result.expect_err("expected error for invalid TOML");
776        match err {
777            CollectorError::TomlParse { path: p, .. } => {
778                assert_eq!(p, path);
779            }
780            _ => panic!("expected TomlParse error, got: {err:?}"),
781        }
782    }
783
784    #[test]
785    fn test_collector_config_from_file_should_error_when_components_key_missing() {
786        let dir = tempfile::tempdir().expect("create temp dir");
787        let path = dir.path().join("no-components.toml");
788        std::fs::write(&path, "[other]\nkey = \"value\"\n").expect("write file");
789
790        let result = CollectorConfig::from_file(&path);
791
792        assert!(result.is_err());
793    }
794
795    #[test]
796    fn test_collector_config_from_file_should_handle_empty_components() {
797        let dir = tempfile::tempdir().expect("create temp dir");
798        let path = dir.path().join("empty.toml");
799        std::fs::write(&path, "[components]\n").expect("write file");
800
801        let config = CollectorConfig::from_file(&path).expect("parse empty components");
802
803        assert!(config.repo_map.is_empty());
804        assert_eq!(config.daily_trigger_hour, 2);
805    }
806
807    // -- CollectorError --
808
809    #[test]
810    fn test_collector_error_should_display_io_error() {
811        let err = CollectorError::Io {
812            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
813            path: PathBuf::from("/foo/bar.toml"),
814        };
815        let display = err.to_string();
816
817        assert!(display.contains("/foo/bar.toml"), "got: {display}");
818    }
819
820    #[test]
821    fn test_collector_error_should_display_toml_parse_error() {
822        let toml_err = toml::from_str::<toml::Table>("bad").expect_err("should fail to parse");
823        let err = CollectorError::TomlParse {
824            source: toml_err,
825            path: PathBuf::from("/foo/bar.toml"),
826        };
827        let display = err.to_string();
828
829        assert!(display.contains("/foo/bar.toml"), "got: {display}");
830    }
831
832    // -- ReportResult --
833
834    #[test]
835    fn test_report_result_should_have_three_variants() {
836        let success = ReportResult::Success;
837        let partial = ReportResult::Partial { reported_count: 3 };
838        let failed = ReportResult::Failed {
839            reason: "timeout".to_string(),
840            retry_at: Utc::now(),
841        };
842
843        match success {
844            ReportResult::Success => {}
845            _ => panic!("expected Success"),
846        }
847        match partial {
848            ReportResult::Partial { reported_count } => assert_eq!(reported_count, 3),
849            _ => panic!("expected Partial"),
850        }
851        match failed {
852            ReportResult::Failed { reason, .. } => assert_eq!(reason, "timeout"),
853            _ => panic!("expected Failed"),
854        }
855    }
856
857    #[test]
858    fn test_report_result_should_be_cloneable() {
859        let result = ReportResult::Partial { reported_count: 5 };
860        let cloned = result.clone();
861
862        match cloned {
863            ReportResult::Partial { reported_count } => assert_eq!(reported_count, 5),
864            _ => panic!("expected Partial after clone"),
865        }
866    }
867
868    // -- ExceptionReporter default check_available --
869
870    /// Minimal stub reporter used to test the trait's default method.
871    struct StubReporter;
872
873    #[async_trait]
874    impl ExceptionReporter for StubReporter {
875        async fn report(&self, _batch: &ExceptionBatch) -> CollectorResult<ReportResult> {
876            Ok(ReportResult::Success)
877        }
878    }
879
880    #[tokio::test]
881    async fn test_check_available_should_default_to_true() {
882        let reporter = StubReporter;
883        assert!(reporter.check_available().await);
884    }
885}