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