1#![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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub enum ExceptionKind {
64 Panic,
66 ErrorLog,
68 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#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ExceptionRecord {
90 pub id: Uuid,
92 pub component: String,
94 pub kind: ExceptionKind,
96 pub message: String,
98 pub stacktrace: String,
100 pub span_chain: Vec<String>,
102 pub module_path: Option<String>,
104 pub location: Option<String>,
106 pub timestamp: DateTime<Utc>,
108 pub dedup_signature: String,
110 pub reported: bool,
112 pub duplicate_of: Option<String>,
114}
115
116impl ExceptionRecord {
117 #[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#[derive(Debug, Clone)]
158pub struct AggregatedException {
159 pub signature: String,
161 pub first_seen: DateTime<Utc>,
163 pub last_seen: DateTime<Utc>,
165 pub count: u32,
167 pub sample: ExceptionRecord,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ExceptionBatch {
179 pub id: Uuid,
181 pub component: String,
183 pub records: Vec<ExceptionRecord>,
185 pub target: ReportTarget,
187 pub created_at: DateTime<Utc>,
189 pub status: BatchStatus,
191}
192
193impl ExceptionBatch {
194 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub enum ReportTarget {
217 GitHub {
219 repo: String,
221 issue_url: String,
223 },
224 CustomPlatform {
226 endpoint: String,
228 },
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub enum BatchStatus {
236 Pending,
238 Reported,
240 Failed {
242 reason: String,
244 retry_at: DateTime<Utc>,
246 },
247}
248
249#[derive(Debug, Error)]
253pub enum CollectorError {
254 #[error("failed to read file: {}", .path.display())]
256 Io {
257 #[source]
259 source: std::io::Error,
260 path: PathBuf,
262 },
263
264 #[error("failed to parse TOML: {}", .path.display())]
266 TomlParse {
267 #[source]
269 source: toml::de::Error,
270 path: PathBuf,
272 },
273
274 #[error("sqlite error: {0}")]
276 Sqlite(
277 #[source]
279 rusqlite::Error,
280 ),
281
282 #[error("json error: {0}")]
284 SerdeJson(
285 #[source]
287 serde_json::Error,
288 ),
289
290 #[error("command error: {message}")]
292 Command {
293 message: String,
295 #[source]
297 source: Box<dyn std::error::Error + Send + Sync>,
298 },
299
300 #[error("http error: {message}")]
302 Http {
303 message: String,
305 #[source]
307 source: Box<dyn std::error::Error + Send + Sync>,
308 },
309 #[error("reporter error: {reason}")]
311 Reporter {
312 reason: String,
314 },
315}
316
317pub type CollectorResult<T> = std::result::Result<T, CollectorError>;
319
320#[derive(Debug, Clone)]
324pub enum ReportResult {
325 Success,
327 Partial {
329 reported_count: usize,
331 },
332 Failed {
334 reason: String,
336 retry_at: DateTime<Utc>,
338 },
339}
340
341#[async_trait]
346pub trait ExceptionReporter: Send + Sync {
347 async fn report(&self, batch: &ExceptionBatch) -> CollectorResult<ReportResult>;
353
354 async fn check_available(&self) -> bool {
361 true
362 }
363}
364
365#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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}