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