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///
231/// `#[schemars(extend(...))]` force-includes serde-defaulted fields in
232/// `required` so the JSON Schema satisfies OpenAI's strict-subset rule
233/// (every property in `properties` must also appear in `required`);
234/// nullability for the AI's response continues to be expressed via
235/// `Option<T>` (which schemars renders as `type: ["...", "null"]`).
236#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
237#[schemars(deny_unknown_fields)]
238#[schemars(extend("required" = ["commit", "passes", "issues", "suggestion", "summary"]))]
239pub struct AiCommitCheck {
240    /// Commit hash (short or full).
241    pub commit: String,
242    /// Whether the commit passes all checks.
243    pub passes: bool,
244    /// List of issues found.
245    #[serde(default)]
246    pub issues: Vec<AiIssue>,
247    /// Suggested message improvement.
248    #[serde(default)]
249    pub suggestion: Option<AiSuggestion>,
250    /// Brief summary of what this commit changes (for cross-commit coherence).
251    #[serde(default)]
252    pub summary: Option<String>,
253}
254
255/// Issue from AI response.
256#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
257#[schemars(deny_unknown_fields)]
258#[schemars(extend("required" = ["reasoning", "severity", "section", "rule", "explanation"]))]
259pub struct AiIssue {
260    /// Reasoning written before the verdict. Forces think-first ordering so
261    /// `severity` is conditioned on fully-worked-through reasoning instead of
262    /// a first guess. Not surfaced to end users — the concise `explanation`
263    /// is shown instead.
264    #[serde(default)]
265    pub reasoning: Option<String>,
266    /// Severity level.
267    pub severity: String,
268    /// Guideline section.
269    pub section: String,
270    /// Specific rule violated.
271    pub rule: String,
272    /// Explanation.
273    pub explanation: String,
274}
275
276/// Suggestion from AI response.
277#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
278#[schemars(deny_unknown_fields)]
279pub struct AiSuggestion {
280    /// Suggested message.
281    pub message: String,
282    /// Explanation of improvements.
283    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(), // Will be filled in by caller
307            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    // ── IssueSeverity ────────────────────────────────────────────────
321
322    #[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    // ── OutputFormat ─────────────────────────────────────────────────
350
351    #[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    // ── CheckSummary ─────────────────────────────────────────────────
367
368    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    // ── CheckReport::exit_code ───────────────────────────────────────
429
430    #[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    // ── From<AiCommitCheck> ──────────────────────────────────────────
471
472    #[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        // Reasoning-before-severity YAML shape (the intended model output for
517        // issue #627). Parser must accept it and preserve severity correctly.
518        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        // Older/fallback YAML shape with no reasoning field must still parse.
537        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    // ── property tests ────────────────────────────────────────────
563
564    // ── IssueSeverity Hash ────────────────────────────────────────
565
566    #[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        // Duplicate insert should not increase size
577        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                // Ensure at least one result with an error
692                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}