libverify_core/controls/
conventional_title.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, GovernedChange};
3
4const CONVENTIONAL_TYPES: &[&str] = &[
6 "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert",
7];
8
9pub 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
70fn is_conventional(title: &str) -> bool {
72 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 let prefix = prefix.strip_suffix('!').unwrap_or(prefix);
87
88 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}