Skip to main content

omni_dev/data/
check.rs

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