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)]
237#[schemars(deny_unknown_fields)]
238#[schemars(extend("required" = ["commit", "passes", "issues", "suggestion", "summary"]))]
239pub struct AiCommitCheck {
240 pub commit: String,
242 pub passes: bool,
244 #[serde(default)]
246 pub issues: Vec<AiIssue>,
247 #[serde(default)]
249 pub suggestion: Option<AiSuggestion>,
250 #[serde(default)]
252 pub summary: Option<String>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
257#[schemars(deny_unknown_fields)]
258#[schemars(extend("required" = ["reasoning", "severity", "section", "rule", "explanation"]))]
259pub struct AiIssue {
260 #[serde(default)]
265 pub reasoning: Option<String>,
266 pub severity: String,
268 pub section: String,
270 pub rule: String,
272 pub explanation: String,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
278#[schemars(deny_unknown_fields)]
279pub struct AiSuggestion {
280 pub message: String,
282 pub explanation: String,
284}
285
286impl From<AiCommitCheck> for CommitCheckResult {
287 fn from(ai: AiCommitCheck) -> Self {
288 let issues: Vec<CommitIssue> = ai
289 .issues
290 .into_iter()
291 .map(|i| CommitIssue {
292 severity: IssueSeverity::parse(&i.severity),
293 section: i.section,
294 rule: i.rule,
295 explanation: i.explanation,
296 })
297 .collect();
298
299 let suggestion = ai.suggestion.map(|s| CommitSuggestion {
300 message: s.message,
301 explanation: s.explanation,
302 });
303
304 Self {
305 hash: ai.commit,
306 message: String::new(), issues,
308 suggestion,
309 passes: ai.passes,
310 summary: ai.summary,
311 }
312 }
313}
314
315#[cfg(test)]
316#[allow(clippy::unwrap_used, clippy::expect_used)]
317mod tests {
318 use super::*;
319
320 #[test]
323 fn severity_parse_known() {
324 assert_eq!(IssueSeverity::parse("error"), IssueSeverity::Error);
325 assert_eq!(IssueSeverity::parse("warning"), IssueSeverity::Warning);
326 assert_eq!(IssueSeverity::parse("info"), IssueSeverity::Info);
327 }
328
329 #[test]
330 fn severity_parse_case_insensitive() {
331 assert_eq!(IssueSeverity::parse("ERROR"), IssueSeverity::Error);
332 assert_eq!(IssueSeverity::parse("Warning"), IssueSeverity::Warning);
333 assert_eq!(IssueSeverity::parse("INFO"), IssueSeverity::Info);
334 }
335
336 #[test]
337 fn severity_parse_unknown_defaults_warning() {
338 assert_eq!(IssueSeverity::parse("foo"), IssueSeverity::Warning);
339 assert_eq!(IssueSeverity::parse(""), IssueSeverity::Warning);
340 }
341
342 #[test]
343 fn severity_display() {
344 assert_eq!(IssueSeverity::Error.to_string(), "ERROR");
345 assert_eq!(IssueSeverity::Warning.to_string(), "WARNING");
346 assert_eq!(IssueSeverity::Info.to_string(), "INFO");
347 }
348
349 #[test]
352 fn output_format_parsing() {
353 assert_eq!("text".parse::<OutputFormat>(), Ok(OutputFormat::Text));
354 assert_eq!("json".parse::<OutputFormat>(), Ok(OutputFormat::Json));
355 assert_eq!("yaml".parse::<OutputFormat>(), Ok(OutputFormat::Yaml));
356 assert!("unknown".parse::<OutputFormat>().is_err());
357 }
358
359 #[test]
360 fn output_format_display() {
361 assert_eq!(OutputFormat::Text.to_string(), "text");
362 assert_eq!(OutputFormat::Json.to_string(), "json");
363 assert_eq!(OutputFormat::Yaml.to_string(), "yaml");
364 }
365
366 fn make_result(passes: bool, issues: Vec<CommitIssue>) -> CommitCheckResult {
369 CommitCheckResult {
370 hash: "abc123".to_string(),
371 message: "test".to_string(),
372 issues,
373 suggestion: None,
374 passes,
375 summary: None,
376 }
377 }
378
379 fn make_issue(severity: IssueSeverity) -> CommitIssue {
380 CommitIssue {
381 severity,
382 section: "Format".to_string(),
383 rule: "test-rule".to_string(),
384 explanation: "test explanation".to_string(),
385 }
386 }
387
388 #[test]
389 fn summary_empty_results() {
390 let summary = CheckSummary::from_results(&[]);
391 assert_eq!(summary.total_commits, 0);
392 assert_eq!(summary.passing_commits, 0);
393 assert_eq!(summary.failing_commits, 0);
394 assert_eq!(summary.error_count, 0);
395 assert_eq!(summary.warning_count, 0);
396 assert_eq!(summary.info_count, 0);
397 }
398
399 #[test]
400 fn summary_mixed_results() {
401 let results = vec![
402 make_result(
403 false,
404 vec![
405 make_issue(IssueSeverity::Error),
406 make_issue(IssueSeverity::Warning),
407 ],
408 ),
409 make_result(true, vec![make_issue(IssueSeverity::Info)]),
410 ];
411 let summary = CheckSummary::from_results(&results);
412 assert_eq!(summary.total_commits, 2);
413 assert_eq!(summary.passing_commits, 1);
414 assert_eq!(summary.failing_commits, 1);
415 assert_eq!(summary.error_count, 1);
416 assert_eq!(summary.warning_count, 1);
417 assert_eq!(summary.info_count, 1);
418 }
419
420 #[test]
421 fn summary_all_passing() {
422 let results = vec![make_result(true, vec![]), make_result(true, vec![])];
423 let summary = CheckSummary::from_results(&results);
424 assert_eq!(summary.passing_commits, 2);
425 assert_eq!(summary.failing_commits, 0);
426 }
427
428 #[test]
431 fn exit_code_no_issues() {
432 let report = CheckReport::new(vec![make_result(true, vec![])]);
433 assert_eq!(report.exit_code(false), 0);
434 assert_eq!(report.exit_code(true), 0);
435 }
436
437 #[test]
438 fn exit_code_errors() {
439 let report = CheckReport::new(vec![make_result(
440 false,
441 vec![make_issue(IssueSeverity::Error)],
442 )]);
443 assert_eq!(report.exit_code(false), 1);
444 assert_eq!(report.exit_code(true), 1);
445 }
446
447 #[test]
448 fn exit_code_warnings_strict() {
449 let report = CheckReport::new(vec![make_result(
450 false,
451 vec![make_issue(IssueSeverity::Warning)],
452 )]);
453 assert_eq!(report.exit_code(false), 0);
454 assert_eq!(report.exit_code(true), 2);
455 }
456
457 #[test]
458 fn has_errors_and_warnings() {
459 let report = CheckReport::new(vec![make_result(
460 false,
461 vec![
462 make_issue(IssueSeverity::Error),
463 make_issue(IssueSeverity::Warning),
464 ],
465 )]);
466 assert!(report.has_errors());
467 assert!(report.has_warnings());
468 }
469
470 #[test]
473 fn ai_check_converts_issues() {
474 let ai = AiCommitCheck {
475 commit: "abc123".to_string(),
476 passes: false,
477 issues: vec![AiIssue {
478 reasoning: Some("Subject exceeds cap; violates Format rule.".to_string()),
479 severity: "error".to_string(),
480 section: "Format".to_string(),
481 rule: "subject-line".to_string(),
482 explanation: "too long".to_string(),
483 }],
484 suggestion: None,
485 summary: Some("Added feature".to_string()),
486 };
487 let result: CommitCheckResult = ai.into();
488 assert_eq!(result.hash, "abc123");
489 assert!(!result.passes);
490 assert_eq!(result.issues.len(), 1);
491 assert_eq!(result.issues[0].severity, IssueSeverity::Error);
492 assert_eq!(result.issues[0].section, "Format");
493 assert_eq!(result.summary, Some("Added feature".to_string()));
494 }
495
496 #[test]
497 fn ai_check_converts_suggestion() {
498 let ai = AiCommitCheck {
499 commit: "def456".to_string(),
500 passes: false,
501 issues: vec![],
502 suggestion: Some(AiSuggestion {
503 message: "feat(cli): better message".to_string(),
504 explanation: "improved clarity".to_string(),
505 }),
506 summary: None,
507 };
508 let result: CommitCheckResult = ai.into();
509 let suggestion = result.suggestion.unwrap();
510 assert_eq!(suggestion.message, "feat(cli): better message");
511 assert_eq!(suggestion.explanation, "improved clarity");
512 }
513
514 #[test]
515 fn ai_issue_deserializes_with_reasoning_field() {
516 let yaml = r#"
519reasoning: "Scope 'lib' is in the valid scopes list; scope validity check passes. No violation."
520severity: info
521section: "Scope Appropriateness"
522rule: "scope-suggestion"
523explanation: "Consider a narrower scope."
524"#;
525 let issue: AiIssue = serde_yaml::from_str(yaml).unwrap();
526 assert_eq!(issue.severity, "info");
527 assert!(issue
528 .reasoning
529 .as_deref()
530 .unwrap()
531 .contains("valid scopes list"));
532 }
533
534 #[test]
535 fn ai_issue_deserializes_without_reasoning_field() {
536 let yaml = r#"
538severity: error
539section: "Subject Line"
540rule: "imperative-mood"
541explanation: "Subject uses past tense"
542"#;
543 let issue: AiIssue = serde_yaml::from_str(yaml).unwrap();
544 assert_eq!(issue.severity, "error");
545 assert!(issue.reasoning.is_none());
546 }
547
548 #[test]
549 fn ai_check_no_suggestion() {
550 let ai = AiCommitCheck {
551 commit: "abc".to_string(),
552 passes: true,
553 issues: vec![],
554 suggestion: None,
555 summary: None,
556 };
557 let result: CommitCheckResult = ai.into();
558 assert!(result.suggestion.is_none());
559 assert!(result.passes);
560 }
561
562 #[test]
567 fn severity_hash_consistent_with_eq() {
568 use std::collections::HashSet;
569
570 let mut set = HashSet::new();
571 set.insert(IssueSeverity::Error);
572 set.insert(IssueSeverity::Warning);
573 set.insert(IssueSeverity::Info);
574 assert_eq!(set.len(), 3);
575
576 set.insert(IssueSeverity::Error);
578 assert_eq!(set.len(), 3);
579 }
580
581 #[test]
582 fn issue_dedup_by_rule_severity_section() {
583 use std::collections::HashSet;
584
585 let issues = vec![
586 CommitIssue {
587 severity: IssueSeverity::Error,
588 section: "Format".to_string(),
589 rule: "subject-line".to_string(),
590 explanation: "too long".to_string(),
591 },
592 CommitIssue {
593 severity: IssueSeverity::Error,
594 section: "Format".to_string(),
595 rule: "subject-line".to_string(),
596 explanation: "different wording".to_string(),
597 },
598 CommitIssue {
599 severity: IssueSeverity::Warning,
600 section: "Content".to_string(),
601 rule: "body-required".to_string(),
602 explanation: "missing body".to_string(),
603 },
604 ];
605
606 let mut seen = HashSet::new();
607 let mut deduped = Vec::new();
608 for issue in &issues {
609 let key = (issue.rule.clone(), issue.severity, issue.section.clone());
610 if seen.insert(key) {
611 deduped.push(issue.clone());
612 }
613 }
614
615 assert_eq!(deduped.len(), 2);
616 assert_eq!(deduped[0].rule, "subject-line");
617 assert_eq!(deduped[1].rule, "body-required");
618 }
619
620 mod prop {
621 use super::*;
622 use proptest::prelude::*;
623
624 fn arb_severity() -> impl Strategy<Value = IssueSeverity> {
625 prop_oneof![
626 Just(IssueSeverity::Error),
627 Just(IssueSeverity::Warning),
628 Just(IssueSeverity::Info),
629 ]
630 }
631
632 fn arb_issue() -> impl Strategy<Value = CommitIssue> {
633 arb_severity().prop_map(make_issue)
634 }
635
636 fn arb_result() -> impl Strategy<Value = CommitCheckResult> {
637 (any::<bool>(), proptest::collection::vec(arb_issue(), 0..5))
638 .prop_map(|(passes, issues)| make_result(passes, issues))
639 }
640
641 proptest! {
642 #[test]
643 fn severity_display_roundtrip(sev in arb_severity()) {
644 let displayed = sev.to_string();
645 let parsed: IssueSeverity = displayed.parse().unwrap();
646 prop_assert_eq!(parsed, sev);
647 }
648
649 #[test]
650 fn severity_from_str_never_errors(s in ".*") {
651 let result: Result<IssueSeverity, ()> = s.parse();
652 prop_assert!(result.is_ok());
653 }
654
655 #[test]
656 fn summary_total_is_passing_plus_failing(
657 results in proptest::collection::vec(arb_result(), 0..20),
658 ) {
659 let summary = CheckSummary::from_results(&results);
660 prop_assert_eq!(summary.total_commits, summary.passing_commits + summary.failing_commits);
661 prop_assert_eq!(summary.total_commits, results.len());
662 }
663
664 #[test]
665 fn summary_issue_counts_match(
666 results in proptest::collection::vec(arb_result(), 0..20),
667 ) {
668 let summary = CheckSummary::from_results(&results);
669 let total_issues: usize = results.iter().map(|r| r.issues.len()).sum();
670 prop_assert_eq!(
671 summary.error_count + summary.warning_count + summary.info_count,
672 total_issues
673 );
674 }
675
676 #[test]
677 fn exit_code_bounded(
678 results in proptest::collection::vec(arb_result(), 0..10),
679 strict in any::<bool>(),
680 ) {
681 let report = CheckReport::new(results);
682 let code = report.exit_code(strict);
683 prop_assert!(code == 0 || code == 1 || code == 2);
684 }
685
686 #[test]
687 fn exit_code_errors_always_one(
688 mut results in proptest::collection::vec(arb_result(), 0..10),
689 strict in any::<bool>(),
690 ) {
691 results.push(make_result(false, vec![make_issue(IssueSeverity::Error)]));
693 let report = CheckReport::new(results);
694 prop_assert_eq!(report.exit_code(strict), 1);
695 }
696 }
697 }
698}