Skip to main content

omni_dev/data/
check.rs

1//! Check command result types for commit message validation.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7/// Complete check report containing all commit analysis results.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CheckReport {
10    /// Individual commit check results.
11    pub commits: Vec<CommitCheckResult>,
12    /// Summary statistics.
13    pub summary: CheckSummary,
14}
15
16/// Result of checking a single commit.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CommitCheckResult {
19    /// Commit hash (short form).
20    pub hash: String,
21    /// Original commit message (first line).
22    pub message: String,
23    /// List of issues found.
24    pub issues: Vec<CommitIssue>,
25    /// Suggested improved message (if issues were found).
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub suggestion: Option<CommitSuggestion>,
28    /// Whether the commit passes all checks.
29    pub passes: bool,
30    /// Brief summary of what this commit changes (for cross-commit coherence).
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub summary: Option<String>,
33}
34
35/// A single issue found in a commit message.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CommitIssue {
38    /// Severity level of the issue.
39    pub severity: IssueSeverity,
40    /// Which guideline section was violated.
41    pub section: String,
42    /// Specific rule that was violated.
43    pub rule: String,
44    /// Explanation of why this is a violation.
45    pub explanation: String,
46}
47
48/// Suggested correction for a commit message.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CommitSuggestion {
51    /// The suggested improved commit message.
52    pub message: String,
53    /// Explanation of why this message is better.
54    pub explanation: String,
55}
56
57/// Severity level for issues.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum IssueSeverity {
61    /// Errors block CI (exit code 1).
62    Error,
63    /// Advisory issues (exit code 0, or 2 with --strict).
64    Warning,
65    /// Suggestions only (never affect exit code).
66    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    /// Parses severity from a string (case-insensitive).
97    #[must_use]
98    pub fn parse(s: &str) -> Self {
99        // FromStr impl is infallible (unknown values default to Warning with a log).
100        #[allow(clippy::expect_used)] // FromStr for IssueSeverity always returns Ok
101        s.parse().expect("IssueSeverity::from_str is infallible")
102    }
103}
104
105/// Summary statistics for a check report.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct CheckSummary {
108    /// Total number of commits checked.
109    pub total_commits: usize,
110    /// Number of commits that pass all checks.
111    pub passing_commits: usize,
112    /// Number of commits with issues.
113    pub failing_commits: usize,
114    /// Total number of errors found.
115    pub error_count: usize,
116    /// Total number of warnings found.
117    pub warning_count: usize,
118    /// Total number of info-level issues found.
119    pub info_count: usize,
120}
121
122impl CheckSummary {
123    /// Creates a summary from a list of commit check results.
124    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    /// Creates a new check report from commit results.
156    pub fn new(commits: Vec<CommitCheckResult>) -> Self {
157        let summary = CheckSummary::from_results(&commits);
158        Self { commits, summary }
159    }
160
161    /// Checks if the report has any errors.
162    #[must_use]
163    pub fn has_errors(&self) -> bool {
164        self.summary.error_count > 0
165    }
166
167    /// Checks if the report has any warnings.
168    #[must_use]
169    pub fn has_warnings(&self) -> bool {
170        self.summary.warning_count > 0
171    }
172
173    /// Determines exit code based on report and options.
174    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/// Output format for check results.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
187pub enum OutputFormat {
188    /// Human-readable text format.
189    #[default]
190    Text,
191    /// JSON format.
192    Json,
193    /// YAML format.
194    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/// AI response structure for parsing check results.
221#[derive(Debug, Clone, Deserialize)]
222pub struct AiCheckResponse {
223    /// List of commit checks.
224    pub checks: Vec<AiCommitCheck>,
225}
226
227/// Single commit check from AI response.
228#[derive(Debug, Clone, Deserialize)]
229pub struct AiCommitCheck {
230    /// Commit hash (short or full).
231    pub commit: String,
232    /// Whether the commit passes all checks.
233    pub passes: bool,
234    /// List of issues found.
235    #[serde(default)]
236    pub issues: Vec<AiIssue>,
237    /// Suggested message improvement.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub suggestion: Option<AiSuggestion>,
240    /// Brief summary of what this commit changes (for cross-commit coherence).
241    #[serde(default)]
242    pub summary: Option<String>,
243}
244
245/// Issue from AI response.
246#[derive(Debug, Clone, Deserialize)]
247pub struct AiIssue {
248    /// Severity level.
249    pub severity: String,
250    /// Guideline section.
251    pub section: String,
252    /// Specific rule violated.
253    pub rule: String,
254    /// Explanation.
255    pub explanation: String,
256}
257
258/// Suggestion from AI response.
259#[derive(Debug, Clone, Deserialize)]
260pub struct AiSuggestion {
261    /// Suggested message.
262    pub message: String,
263    /// Explanation of improvements.
264    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(), // Will be filled in by caller
288            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    // ── IssueSeverity ────────────────────────────────────────────────
302
303    #[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    // ── OutputFormat ─────────────────────────────────────────────────
331
332    #[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    // ── CheckSummary ─────────────────────────────────────────────────
348
349    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    // ── CheckReport::exit_code ───────────────────────────────────────
410
411    #[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    // ── From<AiCommitCheck> ──────────────────────────────────────────
452
453    #[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    // ── property tests ────────────────────────────────────────────
509
510    // ── IssueSeverity Hash ────────────────────────────────────────
511
512    #[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        // Duplicate insert should not increase size
523        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                // Ensure at least one result with an error
638                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}