1use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CheckReport {
10 pub commits: Vec<CommitCheckResult>,
12 pub summary: CheckSummary,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CommitCheckResult {
19 pub hash: String,
21 pub message: String,
23 pub issues: Vec<CommitIssue>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub suggestion: Option<CommitSuggestion>,
28 pub passes: bool,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub summary: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CommitIssue {
38 pub severity: IssueSeverity,
40 pub section: String,
42 pub rule: String,
44 pub explanation: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CommitSuggestion {
51 pub message: String,
53 pub explanation: String,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum IssueSeverity {
61 Error,
63 Warning,
65 Info,
67}
68
69impl fmt::Display for IssueSeverity {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::Error => write!(f, "ERROR"),
73 Self::Warning => write!(f, "WARNING"),
74 Self::Info => write!(f, "INFO"),
75 }
76 }
77}
78
79impl std::str::FromStr for IssueSeverity {
80 type Err = ();
81
82 fn from_str(s: &str) -> Result<Self, Self::Err> {
83 match s.to_lowercase().as_str() {
84 "error" => Ok(Self::Error),
85 "warning" => Ok(Self::Warning),
86 "info" => Ok(Self::Info),
87 other => {
88 tracing::debug!("Unknown severity {other:?}, defaulting to Warning");
89 Ok(Self::Warning)
90 }
91 }
92 }
93}
94
95impl IssueSeverity {
96 #[must_use]
98 pub fn parse(s: &str) -> Self {
99 #[allow(clippy::expect_used)] s.parse().expect("IssueSeverity::from_str is infallible")
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct CheckSummary {
108 pub total_commits: usize,
110 pub passing_commits: usize,
112 pub failing_commits: usize,
114 pub error_count: usize,
116 pub warning_count: usize,
118 pub info_count: usize,
120}
121
122impl CheckSummary {
123 pub fn from_results(results: &[CommitCheckResult]) -> Self {
125 let total_commits = results.len();
126 let passing_commits = results.iter().filter(|r| r.passes).count();
127 let failing_commits = total_commits - passing_commits;
128
129 let mut error_count = 0;
130 let mut warning_count = 0;
131 let mut info_count = 0;
132
133 for result in results {
134 for issue in &result.issues {
135 match issue.severity {
136 IssueSeverity::Error => error_count += 1,
137 IssueSeverity::Warning => warning_count += 1,
138 IssueSeverity::Info => info_count += 1,
139 }
140 }
141 }
142
143 Self {
144 total_commits,
145 passing_commits,
146 failing_commits,
147 error_count,
148 warning_count,
149 info_count,
150 }
151 }
152}
153
154impl CheckReport {
155 pub fn new(commits: Vec<CommitCheckResult>) -> Self {
157 let summary = CheckSummary::from_results(&commits);
158 Self { commits, summary }
159 }
160
161 #[must_use]
163 pub fn has_errors(&self) -> bool {
164 self.summary.error_count > 0
165 }
166
167 #[must_use]
169 pub fn has_warnings(&self) -> bool {
170 self.summary.warning_count > 0
171 }
172
173 pub fn exit_code(&self, strict: bool) -> i32 {
175 if self.has_errors() {
176 1
177 } else if strict && self.has_warnings() {
178 2
179 } else {
180 0
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
187pub enum OutputFormat {
188 #[default]
190 Text,
191 Json,
193 Yaml,
195}
196
197impl std::str::FromStr for OutputFormat {
198 type Err = ();
199
200 fn from_str(s: &str) -> Result<Self, Self::Err> {
201 match s.to_lowercase().as_str() {
202 "text" => Ok(Self::Text),
203 "json" => Ok(Self::Json),
204 "yaml" => Ok(Self::Yaml),
205 _ => Err(()),
206 }
207 }
208}
209
210impl fmt::Display for OutputFormat {
211 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212 match self {
213 Self::Text => write!(f, "text"),
214 Self::Json => write!(f, "json"),
215 Self::Yaml => write!(f, "yaml"),
216 }
217 }
218}
219
220#[derive(Debug, Clone, Deserialize)]
222pub struct AiCheckResponse {
223 pub checks: Vec<AiCommitCheck>,
225}
226
227#[derive(Debug, Clone, Deserialize)]
229pub struct AiCommitCheck {
230 pub commit: String,
232 pub passes: bool,
234 #[serde(default)]
236 pub issues: Vec<AiIssue>,
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub suggestion: Option<AiSuggestion>,
240 #[serde(default)]
242 pub summary: Option<String>,
243}
244
245#[derive(Debug, Clone, Deserialize)]
247pub struct AiIssue {
248 #[serde(default)]
253 pub reasoning: Option<String>,
254 pub severity: String,
256 pub section: String,
258 pub rule: String,
260 pub explanation: String,
262}
263
264#[derive(Debug, Clone, Deserialize)]
266pub struct AiSuggestion {
267 pub message: String,
269 pub explanation: String,
271}
272
273impl From<AiCommitCheck> for CommitCheckResult {
274 fn from(ai: AiCommitCheck) -> Self {
275 let issues: Vec<CommitIssue> = ai
276 .issues
277 .into_iter()
278 .map(|i| CommitIssue {
279 severity: IssueSeverity::parse(&i.severity),
280 section: i.section,
281 rule: i.rule,
282 explanation: i.explanation,
283 })
284 .collect();
285
286 let suggestion = ai.suggestion.map(|s| CommitSuggestion {
287 message: s.message,
288 explanation: s.explanation,
289 });
290
291 Self {
292 hash: ai.commit,
293 message: String::new(), issues,
295 suggestion,
296 passes: ai.passes,
297 summary: ai.summary,
298 }
299 }
300}
301
302#[cfg(test)]
303#[allow(clippy::unwrap_used, clippy::expect_used)]
304mod tests {
305 use super::*;
306
307 #[test]
310 fn severity_parse_known() {
311 assert_eq!(IssueSeverity::parse("error"), IssueSeverity::Error);
312 assert_eq!(IssueSeverity::parse("warning"), IssueSeverity::Warning);
313 assert_eq!(IssueSeverity::parse("info"), IssueSeverity::Info);
314 }
315
316 #[test]
317 fn severity_parse_case_insensitive() {
318 assert_eq!(IssueSeverity::parse("ERROR"), IssueSeverity::Error);
319 assert_eq!(IssueSeverity::parse("Warning"), IssueSeverity::Warning);
320 assert_eq!(IssueSeverity::parse("INFO"), IssueSeverity::Info);
321 }
322
323 #[test]
324 fn severity_parse_unknown_defaults_warning() {
325 assert_eq!(IssueSeverity::parse("foo"), IssueSeverity::Warning);
326 assert_eq!(IssueSeverity::parse(""), IssueSeverity::Warning);
327 }
328
329 #[test]
330 fn severity_display() {
331 assert_eq!(IssueSeverity::Error.to_string(), "ERROR");
332 assert_eq!(IssueSeverity::Warning.to_string(), "WARNING");
333 assert_eq!(IssueSeverity::Info.to_string(), "INFO");
334 }
335
336 #[test]
339 fn output_format_parsing() {
340 assert_eq!("text".parse::<OutputFormat>(), Ok(OutputFormat::Text));
341 assert_eq!("json".parse::<OutputFormat>(), Ok(OutputFormat::Json));
342 assert_eq!("yaml".parse::<OutputFormat>(), Ok(OutputFormat::Yaml));
343 assert!("unknown".parse::<OutputFormat>().is_err());
344 }
345
346 #[test]
347 fn output_format_display() {
348 assert_eq!(OutputFormat::Text.to_string(), "text");
349 assert_eq!(OutputFormat::Json.to_string(), "json");
350 assert_eq!(OutputFormat::Yaml.to_string(), "yaml");
351 }
352
353 fn make_result(passes: bool, issues: Vec<CommitIssue>) -> CommitCheckResult {
356 CommitCheckResult {
357 hash: "abc123".to_string(),
358 message: "test".to_string(),
359 issues,
360 suggestion: None,
361 passes,
362 summary: None,
363 }
364 }
365
366 fn make_issue(severity: IssueSeverity) -> CommitIssue {
367 CommitIssue {
368 severity,
369 section: "Format".to_string(),
370 rule: "test-rule".to_string(),
371 explanation: "test explanation".to_string(),
372 }
373 }
374
375 #[test]
376 fn summary_empty_results() {
377 let summary = CheckSummary::from_results(&[]);
378 assert_eq!(summary.total_commits, 0);
379 assert_eq!(summary.passing_commits, 0);
380 assert_eq!(summary.failing_commits, 0);
381 assert_eq!(summary.error_count, 0);
382 assert_eq!(summary.warning_count, 0);
383 assert_eq!(summary.info_count, 0);
384 }
385
386 #[test]
387 fn summary_mixed_results() {
388 let results = vec![
389 make_result(
390 false,
391 vec![
392 make_issue(IssueSeverity::Error),
393 make_issue(IssueSeverity::Warning),
394 ],
395 ),
396 make_result(true, vec![make_issue(IssueSeverity::Info)]),
397 ];
398 let summary = CheckSummary::from_results(&results);
399 assert_eq!(summary.total_commits, 2);
400 assert_eq!(summary.passing_commits, 1);
401 assert_eq!(summary.failing_commits, 1);
402 assert_eq!(summary.error_count, 1);
403 assert_eq!(summary.warning_count, 1);
404 assert_eq!(summary.info_count, 1);
405 }
406
407 #[test]
408 fn summary_all_passing() {
409 let results = vec![make_result(true, vec![]), make_result(true, vec![])];
410 let summary = CheckSummary::from_results(&results);
411 assert_eq!(summary.passing_commits, 2);
412 assert_eq!(summary.failing_commits, 0);
413 }
414
415 #[test]
418 fn exit_code_no_issues() {
419 let report = CheckReport::new(vec![make_result(true, vec![])]);
420 assert_eq!(report.exit_code(false), 0);
421 assert_eq!(report.exit_code(true), 0);
422 }
423
424 #[test]
425 fn exit_code_errors() {
426 let report = CheckReport::new(vec![make_result(
427 false,
428 vec![make_issue(IssueSeverity::Error)],
429 )]);
430 assert_eq!(report.exit_code(false), 1);
431 assert_eq!(report.exit_code(true), 1);
432 }
433
434 #[test]
435 fn exit_code_warnings_strict() {
436 let report = CheckReport::new(vec![make_result(
437 false,
438 vec![make_issue(IssueSeverity::Warning)],
439 )]);
440 assert_eq!(report.exit_code(false), 0);
441 assert_eq!(report.exit_code(true), 2);
442 }
443
444 #[test]
445 fn has_errors_and_warnings() {
446 let report = CheckReport::new(vec![make_result(
447 false,
448 vec![
449 make_issue(IssueSeverity::Error),
450 make_issue(IssueSeverity::Warning),
451 ],
452 )]);
453 assert!(report.has_errors());
454 assert!(report.has_warnings());
455 }
456
457 #[test]
460 fn ai_check_converts_issues() {
461 let ai = AiCommitCheck {
462 commit: "abc123".to_string(),
463 passes: false,
464 issues: vec![AiIssue {
465 reasoning: Some("Subject exceeds cap; violates Format rule.".to_string()),
466 severity: "error".to_string(),
467 section: "Format".to_string(),
468 rule: "subject-line".to_string(),
469 explanation: "too long".to_string(),
470 }],
471 suggestion: None,
472 summary: Some("Added feature".to_string()),
473 };
474 let result: CommitCheckResult = ai.into();
475 assert_eq!(result.hash, "abc123");
476 assert!(!result.passes);
477 assert_eq!(result.issues.len(), 1);
478 assert_eq!(result.issues[0].severity, IssueSeverity::Error);
479 assert_eq!(result.issues[0].section, "Format");
480 assert_eq!(result.summary, Some("Added feature".to_string()));
481 }
482
483 #[test]
484 fn ai_check_converts_suggestion() {
485 let ai = AiCommitCheck {
486 commit: "def456".to_string(),
487 passes: false,
488 issues: vec![],
489 suggestion: Some(AiSuggestion {
490 message: "feat(cli): better message".to_string(),
491 explanation: "improved clarity".to_string(),
492 }),
493 summary: None,
494 };
495 let result: CommitCheckResult = ai.into();
496 let suggestion = result.suggestion.unwrap();
497 assert_eq!(suggestion.message, "feat(cli): better message");
498 assert_eq!(suggestion.explanation, "improved clarity");
499 }
500
501 #[test]
502 fn ai_issue_deserializes_with_reasoning_field() {
503 let yaml = r#"
506reasoning: "Scope 'lib' is in the valid scopes list; scope validity check passes. No violation."
507severity: info
508section: "Scope Appropriateness"
509rule: "scope-suggestion"
510explanation: "Consider a narrower scope."
511"#;
512 let issue: AiIssue = serde_yaml::from_str(yaml).unwrap();
513 assert_eq!(issue.severity, "info");
514 assert!(issue
515 .reasoning
516 .as_deref()
517 .unwrap()
518 .contains("valid scopes list"));
519 }
520
521 #[test]
522 fn ai_issue_deserializes_without_reasoning_field() {
523 let yaml = r#"
525severity: error
526section: "Subject Line"
527rule: "subject-too-long"
528explanation: "Subject exceeds 72 characters"
529"#;
530 let issue: AiIssue = serde_yaml::from_str(yaml).unwrap();
531 assert_eq!(issue.severity, "error");
532 assert!(issue.reasoning.is_none());
533 }
534
535 #[test]
536 fn ai_check_no_suggestion() {
537 let ai = AiCommitCheck {
538 commit: "abc".to_string(),
539 passes: true,
540 issues: vec![],
541 suggestion: None,
542 summary: None,
543 };
544 let result: CommitCheckResult = ai.into();
545 assert!(result.suggestion.is_none());
546 assert!(result.passes);
547 }
548
549 #[test]
554 fn severity_hash_consistent_with_eq() {
555 use std::collections::HashSet;
556
557 let mut set = HashSet::new();
558 set.insert(IssueSeverity::Error);
559 set.insert(IssueSeverity::Warning);
560 set.insert(IssueSeverity::Info);
561 assert_eq!(set.len(), 3);
562
563 set.insert(IssueSeverity::Error);
565 assert_eq!(set.len(), 3);
566 }
567
568 #[test]
569 fn issue_dedup_by_rule_severity_section() {
570 use std::collections::HashSet;
571
572 let issues = vec![
573 CommitIssue {
574 severity: IssueSeverity::Error,
575 section: "Format".to_string(),
576 rule: "subject-line".to_string(),
577 explanation: "too long".to_string(),
578 },
579 CommitIssue {
580 severity: IssueSeverity::Error,
581 section: "Format".to_string(),
582 rule: "subject-line".to_string(),
583 explanation: "different wording".to_string(),
584 },
585 CommitIssue {
586 severity: IssueSeverity::Warning,
587 section: "Content".to_string(),
588 rule: "body-required".to_string(),
589 explanation: "missing body".to_string(),
590 },
591 ];
592
593 let mut seen = HashSet::new();
594 let mut deduped = Vec::new();
595 for issue in &issues {
596 let key = (issue.rule.clone(), issue.severity, issue.section.clone());
597 if seen.insert(key) {
598 deduped.push(issue.clone());
599 }
600 }
601
602 assert_eq!(deduped.len(), 2);
603 assert_eq!(deduped[0].rule, "subject-line");
604 assert_eq!(deduped[1].rule, "body-required");
605 }
606
607 mod prop {
608 use super::*;
609 use proptest::prelude::*;
610
611 fn arb_severity() -> impl Strategy<Value = IssueSeverity> {
612 prop_oneof![
613 Just(IssueSeverity::Error),
614 Just(IssueSeverity::Warning),
615 Just(IssueSeverity::Info),
616 ]
617 }
618
619 fn arb_issue() -> impl Strategy<Value = CommitIssue> {
620 arb_severity().prop_map(make_issue)
621 }
622
623 fn arb_result() -> impl Strategy<Value = CommitCheckResult> {
624 (any::<bool>(), proptest::collection::vec(arb_issue(), 0..5))
625 .prop_map(|(passes, issues)| make_result(passes, issues))
626 }
627
628 proptest! {
629 #[test]
630 fn severity_display_roundtrip(sev in arb_severity()) {
631 let displayed = sev.to_string();
632 let parsed: IssueSeverity = displayed.parse().unwrap();
633 prop_assert_eq!(parsed, sev);
634 }
635
636 #[test]
637 fn severity_from_str_never_errors(s in ".*") {
638 let result: Result<IssueSeverity, ()> = s.parse();
639 prop_assert!(result.is_ok());
640 }
641
642 #[test]
643 fn summary_total_is_passing_plus_failing(
644 results in proptest::collection::vec(arb_result(), 0..20),
645 ) {
646 let summary = CheckSummary::from_results(&results);
647 prop_assert_eq!(summary.total_commits, summary.passing_commits + summary.failing_commits);
648 prop_assert_eq!(summary.total_commits, results.len());
649 }
650
651 #[test]
652 fn summary_issue_counts_match(
653 results in proptest::collection::vec(arb_result(), 0..20),
654 ) {
655 let summary = CheckSummary::from_results(&results);
656 let total_issues: usize = results.iter().map(|r| r.issues.len()).sum();
657 prop_assert_eq!(
658 summary.error_count + summary.warning_count + summary.info_count,
659 total_issues
660 );
661 }
662
663 #[test]
664 fn exit_code_bounded(
665 results in proptest::collection::vec(arb_result(), 0..10),
666 strict in any::<bool>(),
667 ) {
668 let report = CheckReport::new(results);
669 let code = report.exit_code(strict);
670 prop_assert!(code == 0 || code == 1 || code == 2);
671 }
672
673 #[test]
674 fn exit_code_errors_always_one(
675 mut results in proptest::collection::vec(arb_result(), 0..10),
676 strict in any::<bool>(),
677 ) {
678 results.push(make_result(false, vec![make_issue(IssueSeverity::Error)]));
680 let report = CheckReport::new(results);
681 prop_assert_eq!(report.exit_code(strict), 1);
682 }
683 }
684 }
685}