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 pub severity: String,
250 pub section: String,
252 pub rule: String,
254 pub explanation: String,
256}
257
258#[derive(Debug, Clone, Deserialize)]
260pub struct AiSuggestion {
261 pub message: String,
263 pub explanation: String,
265}
266
267impl From<AiCommitCheck> for CommitCheckResult {
268 fn from(ai: AiCommitCheck) -> Self {
269 let issues: Vec<CommitIssue> = ai
270 .issues
271 .into_iter()
272 .map(|i| CommitIssue {
273 severity: IssueSeverity::parse(&i.severity),
274 section: i.section,
275 rule: i.rule,
276 explanation: i.explanation,
277 })
278 .collect();
279
280 let suggestion = ai.suggestion.map(|s| CommitSuggestion {
281 message: s.message,
282 explanation: s.explanation,
283 });
284
285 Self {
286 hash: ai.commit,
287 message: String::new(), issues,
289 suggestion,
290 passes: ai.passes,
291 summary: ai.summary,
292 }
293 }
294}
295
296#[cfg(test)]
297#[allow(clippy::unwrap_used, clippy::expect_used)]
298mod tests {
299 use super::*;
300
301 #[test]
304 fn severity_parse_known() {
305 assert_eq!(IssueSeverity::parse("error"), IssueSeverity::Error);
306 assert_eq!(IssueSeverity::parse("warning"), IssueSeverity::Warning);
307 assert_eq!(IssueSeverity::parse("info"), IssueSeverity::Info);
308 }
309
310 #[test]
311 fn severity_parse_case_insensitive() {
312 assert_eq!(IssueSeverity::parse("ERROR"), IssueSeverity::Error);
313 assert_eq!(IssueSeverity::parse("Warning"), IssueSeverity::Warning);
314 assert_eq!(IssueSeverity::parse("INFO"), IssueSeverity::Info);
315 }
316
317 #[test]
318 fn severity_parse_unknown_defaults_warning() {
319 assert_eq!(IssueSeverity::parse("foo"), IssueSeverity::Warning);
320 assert_eq!(IssueSeverity::parse(""), IssueSeverity::Warning);
321 }
322
323 #[test]
324 fn severity_display() {
325 assert_eq!(IssueSeverity::Error.to_string(), "ERROR");
326 assert_eq!(IssueSeverity::Warning.to_string(), "WARNING");
327 assert_eq!(IssueSeverity::Info.to_string(), "INFO");
328 }
329
330 #[test]
333 fn output_format_parsing() {
334 assert_eq!("text".parse::<OutputFormat>(), Ok(OutputFormat::Text));
335 assert_eq!("json".parse::<OutputFormat>(), Ok(OutputFormat::Json));
336 assert_eq!("yaml".parse::<OutputFormat>(), Ok(OutputFormat::Yaml));
337 assert!("unknown".parse::<OutputFormat>().is_err());
338 }
339
340 #[test]
341 fn output_format_display() {
342 assert_eq!(OutputFormat::Text.to_string(), "text");
343 assert_eq!(OutputFormat::Json.to_string(), "json");
344 assert_eq!(OutputFormat::Yaml.to_string(), "yaml");
345 }
346
347 fn make_result(passes: bool, issues: Vec<CommitIssue>) -> CommitCheckResult {
350 CommitCheckResult {
351 hash: "abc123".to_string(),
352 message: "test".to_string(),
353 issues,
354 suggestion: None,
355 passes,
356 summary: None,
357 }
358 }
359
360 fn make_issue(severity: IssueSeverity) -> CommitIssue {
361 CommitIssue {
362 severity,
363 section: "Format".to_string(),
364 rule: "test-rule".to_string(),
365 explanation: "test explanation".to_string(),
366 }
367 }
368
369 #[test]
370 fn summary_empty_results() {
371 let summary = CheckSummary::from_results(&[]);
372 assert_eq!(summary.total_commits, 0);
373 assert_eq!(summary.passing_commits, 0);
374 assert_eq!(summary.failing_commits, 0);
375 assert_eq!(summary.error_count, 0);
376 assert_eq!(summary.warning_count, 0);
377 assert_eq!(summary.info_count, 0);
378 }
379
380 #[test]
381 fn summary_mixed_results() {
382 let results = vec![
383 make_result(
384 false,
385 vec![
386 make_issue(IssueSeverity::Error),
387 make_issue(IssueSeverity::Warning),
388 ],
389 ),
390 make_result(true, vec![make_issue(IssueSeverity::Info)]),
391 ];
392 let summary = CheckSummary::from_results(&results);
393 assert_eq!(summary.total_commits, 2);
394 assert_eq!(summary.passing_commits, 1);
395 assert_eq!(summary.failing_commits, 1);
396 assert_eq!(summary.error_count, 1);
397 assert_eq!(summary.warning_count, 1);
398 assert_eq!(summary.info_count, 1);
399 }
400
401 #[test]
402 fn summary_all_passing() {
403 let results = vec![make_result(true, vec![]), make_result(true, vec![])];
404 let summary = CheckSummary::from_results(&results);
405 assert_eq!(summary.passing_commits, 2);
406 assert_eq!(summary.failing_commits, 0);
407 }
408
409 #[test]
412 fn exit_code_no_issues() {
413 let report = CheckReport::new(vec![make_result(true, vec![])]);
414 assert_eq!(report.exit_code(false), 0);
415 assert_eq!(report.exit_code(true), 0);
416 }
417
418 #[test]
419 fn exit_code_errors() {
420 let report = CheckReport::new(vec![make_result(
421 false,
422 vec![make_issue(IssueSeverity::Error)],
423 )]);
424 assert_eq!(report.exit_code(false), 1);
425 assert_eq!(report.exit_code(true), 1);
426 }
427
428 #[test]
429 fn exit_code_warnings_strict() {
430 let report = CheckReport::new(vec![make_result(
431 false,
432 vec![make_issue(IssueSeverity::Warning)],
433 )]);
434 assert_eq!(report.exit_code(false), 0);
435 assert_eq!(report.exit_code(true), 2);
436 }
437
438 #[test]
439 fn has_errors_and_warnings() {
440 let report = CheckReport::new(vec![make_result(
441 false,
442 vec![
443 make_issue(IssueSeverity::Error),
444 make_issue(IssueSeverity::Warning),
445 ],
446 )]);
447 assert!(report.has_errors());
448 assert!(report.has_warnings());
449 }
450
451 #[test]
454 fn ai_check_converts_issues() {
455 let ai = AiCommitCheck {
456 commit: "abc123".to_string(),
457 passes: false,
458 issues: vec![AiIssue {
459 severity: "error".to_string(),
460 section: "Format".to_string(),
461 rule: "subject-line".to_string(),
462 explanation: "too long".to_string(),
463 }],
464 suggestion: None,
465 summary: Some("Added feature".to_string()),
466 };
467 let result: CommitCheckResult = ai.into();
468 assert_eq!(result.hash, "abc123");
469 assert!(!result.passes);
470 assert_eq!(result.issues.len(), 1);
471 assert_eq!(result.issues[0].severity, IssueSeverity::Error);
472 assert_eq!(result.issues[0].section, "Format");
473 assert_eq!(result.summary, Some("Added feature".to_string()));
474 }
475
476 #[test]
477 fn ai_check_converts_suggestion() {
478 let ai = AiCommitCheck {
479 commit: "def456".to_string(),
480 passes: false,
481 issues: vec![],
482 suggestion: Some(AiSuggestion {
483 message: "feat(cli): better message".to_string(),
484 explanation: "improved clarity".to_string(),
485 }),
486 summary: None,
487 };
488 let result: CommitCheckResult = ai.into();
489 let suggestion = result.suggestion.unwrap();
490 assert_eq!(suggestion.message, "feat(cli): better message");
491 assert_eq!(suggestion.explanation, "improved clarity");
492 }
493
494 #[test]
495 fn ai_check_no_suggestion() {
496 let ai = AiCommitCheck {
497 commit: "abc".to_string(),
498 passes: true,
499 issues: vec![],
500 suggestion: None,
501 summary: None,
502 };
503 let result: CommitCheckResult = ai.into();
504 assert!(result.suggestion.is_none());
505 assert!(result.passes);
506 }
507
508 #[test]
513 fn severity_hash_consistent_with_eq() {
514 use std::collections::HashSet;
515
516 let mut set = HashSet::new();
517 set.insert(IssueSeverity::Error);
518 set.insert(IssueSeverity::Warning);
519 set.insert(IssueSeverity::Info);
520 assert_eq!(set.len(), 3);
521
522 set.insert(IssueSeverity::Error);
524 assert_eq!(set.len(), 3);
525 }
526
527 #[test]
528 fn issue_dedup_by_rule_severity_section() {
529 use std::collections::HashSet;
530
531 let issues = vec![
532 CommitIssue {
533 severity: IssueSeverity::Error,
534 section: "Format".to_string(),
535 rule: "subject-line".to_string(),
536 explanation: "too long".to_string(),
537 },
538 CommitIssue {
539 severity: IssueSeverity::Error,
540 section: "Format".to_string(),
541 rule: "subject-line".to_string(),
542 explanation: "different wording".to_string(),
543 },
544 CommitIssue {
545 severity: IssueSeverity::Warning,
546 section: "Content".to_string(),
547 rule: "body-required".to_string(),
548 explanation: "missing body".to_string(),
549 },
550 ];
551
552 let mut seen = HashSet::new();
553 let mut deduped = Vec::new();
554 for issue in &issues {
555 let key = (issue.rule.clone(), issue.severity, issue.section.clone());
556 if seen.insert(key) {
557 deduped.push(issue.clone());
558 }
559 }
560
561 assert_eq!(deduped.len(), 2);
562 assert_eq!(deduped[0].rule, "subject-line");
563 assert_eq!(deduped[1].rule, "body-required");
564 }
565
566 mod prop {
567 use super::*;
568 use proptest::prelude::*;
569
570 fn arb_severity() -> impl Strategy<Value = IssueSeverity> {
571 prop_oneof![
572 Just(IssueSeverity::Error),
573 Just(IssueSeverity::Warning),
574 Just(IssueSeverity::Info),
575 ]
576 }
577
578 fn arb_issue() -> impl Strategy<Value = CommitIssue> {
579 arb_severity().prop_map(make_issue)
580 }
581
582 fn arb_result() -> impl Strategy<Value = CommitCheckResult> {
583 (any::<bool>(), proptest::collection::vec(arb_issue(), 0..5))
584 .prop_map(|(passes, issues)| make_result(passes, issues))
585 }
586
587 proptest! {
588 #[test]
589 fn severity_display_roundtrip(sev in arb_severity()) {
590 let displayed = sev.to_string();
591 let parsed: IssueSeverity = displayed.parse().unwrap();
592 prop_assert_eq!(parsed, sev);
593 }
594
595 #[test]
596 fn severity_from_str_never_errors(s in ".*") {
597 let result: Result<IssueSeverity, ()> = s.parse();
598 prop_assert!(result.is_ok());
599 }
600
601 #[test]
602 fn summary_total_is_passing_plus_failing(
603 results in proptest::collection::vec(arb_result(), 0..20),
604 ) {
605 let summary = CheckSummary::from_results(&results);
606 prop_assert_eq!(summary.total_commits, summary.passing_commits + summary.failing_commits);
607 prop_assert_eq!(summary.total_commits, results.len());
608 }
609
610 #[test]
611 fn summary_issue_counts_match(
612 results in proptest::collection::vec(arb_result(), 0..20),
613 ) {
614 let summary = CheckSummary::from_results(&results);
615 let total_issues: usize = results.iter().map(|r| r.issues.len()).sum();
616 prop_assert_eq!(
617 summary.error_count + summary.warning_count + summary.info_count,
618 total_issues
619 );
620 }
621
622 #[test]
623 fn exit_code_bounded(
624 results in proptest::collection::vec(arb_result(), 0..10),
625 strict in any::<bool>(),
626 ) {
627 let report = CheckReport::new(results);
628 let code = report.exit_code(strict);
629 prop_assert!(code == 0 || code == 1 || code == 2);
630 }
631
632 #[test]
633 fn exit_code_errors_always_one(
634 mut results in proptest::collection::vec(arb_result(), 0..10),
635 strict in any::<bool>(),
636 ) {
637 results.push(make_result(false, vec![make_issue(IssueSeverity::Error)]));
639 let report = CheckReport::new(results);
640 prop_assert_eq!(report.exit_code(strict), 1);
641 }
642 }
643 }
644}