Skip to main content

libverify_core/controls/
conventional_title.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, GovernedChange};
3
4/// Recognized conventional commit type prefixes.
5const CONVENTIONAL_TYPES: &[&str] = &[
6    "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert",
7];
8
9/// Verifies that change request titles follow the Conventional Commits format.
10///
11/// Maps to SOC2 CC8.1: structured change documentation.
12/// Conventional commit titles (e.g. `feat: add X`, `fix!: resolve Y`) enable
13/// automated changelog generation and ensure changes are categorized consistently.
14pub struct ConventionalTitleControl;
15
16impl Control for ConventionalTitleControl {
17    fn id(&self) -> ControlId {
18        builtin::id(builtin::CONVENTIONAL_TITLE)
19    }
20
21    fn description(&self) -> &'static str {
22        "Titles must follow Conventional Commits format"
23    }
24
25    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
26        if evidence.change_requests.is_empty() {
27            return vec![ControlFinding::not_applicable(
28                self.id(),
29                "No change requests found",
30            )];
31        }
32
33        evidence
34            .change_requests
35            .iter()
36            .map(|cr| evaluate_change(self.id(), cr))
37            .collect()
38    }
39}
40
41fn evaluate_change(id: ControlId, cr: &GovernedChange) -> ControlFinding {
42    let cr_subject = cr.id.to_string();
43    let title = cr.title.trim();
44
45    if title.is_empty() {
46        return ControlFinding::violated(
47            id,
48            format!("{cr_subject}: change request has an empty title"),
49            vec![cr_subject],
50        );
51    }
52
53    if is_conventional(title) {
54        ControlFinding::satisfied(
55            id,
56            format!("{cr_subject}: title follows Conventional Commits format"),
57            vec![cr_subject],
58        )
59    } else {
60        ControlFinding::violated(
61            id,
62            format!(
63                "{cr_subject}: title does not follow Conventional Commits format (expected `type: description` or `type(scope): description`)"
64            ),
65            vec![cr_subject],
66        )
67    }
68}
69
70/// Checks if a title matches `type[(scope)][!]: description`.
71fn is_conventional(title: &str) -> bool {
72    // Find the colon separator
73    let colon_pos = match title.find(": ") {
74        Some(pos) => pos,
75        None => return false,
76    };
77
78    let prefix = &title[..colon_pos];
79    let description = title[colon_pos + 2..].trim();
80
81    if description.is_empty() {
82        return false;
83    }
84
85    // Strip optional breaking change marker
86    let prefix = prefix.strip_suffix('!').unwrap_or(prefix);
87
88    // Strip optional scope
89    let type_part = if let Some(paren_start) = prefix.find('(') {
90        if !prefix.ends_with(')') {
91            return false;
92        }
93        &prefix[..paren_start]
94    } else {
95        prefix
96    };
97
98    CONVENTIONAL_TYPES.contains(&type_part)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::control::ControlStatus;
105    use crate::evidence::{ChangeRequestId, EvidenceState};
106
107    fn make_change(title: &str) -> GovernedChange {
108        GovernedChange {
109            id: ChangeRequestId::new("test", "owner/repo#1"),
110            title: title.to_string(),
111            summary: None,
112            submitted_by: None,
113            changed_assets: EvidenceState::not_applicable(),
114            approval_decisions: EvidenceState::not_applicable(),
115            source_revisions: EvidenceState::not_applicable(),
116            work_item_refs: EvidenceState::not_applicable(),
117        }
118    }
119
120    fn bundle(changes: Vec<GovernedChange>) -> EvidenceBundle {
121        EvidenceBundle {
122            change_requests: changes,
123            ..Default::default()
124        }
125    }
126
127    #[test]
128    fn not_applicable_when_no_changes() {
129        let findings = ConventionalTitleControl.evaluate(&EvidenceBundle::default());
130        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
131    }
132
133    #[test]
134    fn satisfied_for_feat() {
135        let cr = make_change("feat: add new compliance control");
136        let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
137        assert_eq!(findings[0].status, ControlStatus::Satisfied);
138    }
139
140    #[test]
141    fn satisfied_for_fix_with_scope() {
142        let cr = make_change("fix(core): resolve null pointer");
143        let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
144        assert_eq!(findings[0].status, ControlStatus::Satisfied);
145    }
146
147    #[test]
148    fn satisfied_for_breaking_change() {
149        let cr = make_change("refactor!: rename API endpoint");
150        let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
151        assert_eq!(findings[0].status, ControlStatus::Satisfied);
152    }
153
154    #[test]
155    fn satisfied_for_breaking_with_scope() {
156        let cr = make_change("feat(api)!: redesign auth flow");
157        let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
158        assert_eq!(findings[0].status, ControlStatus::Satisfied);
159    }
160
161    #[test]
162    fn violated_for_untyped_title() {
163        let cr = make_change("Add new feature");
164        let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
165        assert_eq!(findings[0].status, ControlStatus::Violated);
166    }
167
168    #[test]
169    fn violated_for_unknown_type() {
170        let cr = make_change("wip: work in progress");
171        let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
172        assert_eq!(findings[0].status, ControlStatus::Violated);
173    }
174
175    #[test]
176    fn violated_for_missing_space_after_colon() {
177        let cr = make_change("feat:no space");
178        let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
179        assert_eq!(findings[0].status, ControlStatus::Violated);
180    }
181
182    #[test]
183    fn violated_for_empty_description() {
184        let cr = make_change("feat: ");
185        let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
186        assert_eq!(findings[0].status, ControlStatus::Violated);
187    }
188
189    #[test]
190    fn violated_for_empty_title() {
191        let cr = make_change("");
192        let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
193        assert_eq!(findings[0].status, ControlStatus::Violated);
194    }
195
196    #[test]
197    fn all_conventional_types_accepted() {
198        for ty in CONVENTIONAL_TYPES {
199            let cr = make_change(&format!("{ty}: test description"));
200            let findings = ConventionalTitleControl.evaluate(&bundle(vec![cr]));
201            assert_eq!(
202                findings[0].status,
203                ControlStatus::Satisfied,
204                "type '{ty}' should be accepted"
205            );
206        }
207    }
208}