1mod queries;
2mod rows;
3mod types;
4
5pub use queries::{
6 add_comment, ensure_item, list_by_project, list_by_source, list_by_source_with_comments,
7 list_comments, list_recent, list_with_comments, remove_comment, remove_item,
8 update_item_status,
9};
10pub use types::{
11 AddCommentInput, EnsureItemInput, ListWithCommentsInput, ReviewCommentIdInput,
12 ReviewCommentMetadataRecord, ReviewCommentRecord, ReviewExplainabilityMetadataRecord,
13 ReviewIssueSnippetRecord, ReviewItemIdInput, ReviewItemRecord, ReviewItemWithComments,
14 ReviewProjectInput, ReviewSourceInput, UpdateItemStatusInput,
15};
16
17pub(super) const EXPLAINABILITY_SCHEMA_VERSION: u8 = 1;
18const EXPLAINABILITY_TOP_ISSUES_LIMIT: usize = 5;
19
20pub(super) const fn default_explainability_schema_version() -> u8 {
21 EXPLAINABILITY_SCHEMA_VERSION
22}
23
24#[derive(serde::Serialize)]
29#[serde(rename_all = "camelCase")]
30struct ReviewIssueSnippetRef<'a> {
31 severity: &'a str,
32 rule: &'a str,
33 rule_id: Option<&'a str>,
34 message: &'a str,
35 file: Option<&'a str>,
36 line: Option<i32>,
37 suggestion: Option<&'a str>,
38 confidence: f32,
39}
40
41#[derive(serde::Serialize)]
42#[serde(rename_all = "camelCase")]
43struct ReviewExplainabilityRef<'a> {
44 schema_version: u8,
45 matched_rule_ids: &'a [String],
46 matched_rule_titles: &'a [String],
47 prompt_tokens_estimate: i32,
48 trace_id: &'a str,
49 issue_count: usize,
50 summary: Option<&'a crate::models::ReviewSummary>,
51 top_issues: Vec<ReviewIssueSnippetRef<'a>>,
52}
53
54#[derive(serde::Serialize)]
55#[serde(rename_all = "camelCase")]
56struct ReviewCommentMetadataRef<'a> {
57 severity: &'a str,
58 rule: &'a str,
59 rule_id: Option<&'a str>,
60 confidence: f32,
61 suggestion: Option<&'a str>,
62}
63
64pub fn build_explainability_metadata(result: &crate::review::ReviewCheckResult) -> Option<String> {
65 let top_issues = result
66 .issues
67 .iter()
68 .take(EXPLAINABILITY_TOP_ISSUES_LIMIT)
69 .map(|issue| ReviewIssueSnippetRef {
70 severity: &issue.severity,
71 rule: &issue.rule,
72 rule_id: issue.rule_id.as_deref(),
73 message: &issue.message,
74 file: issue.file.as_deref(),
75 line: issue.line,
76 suggestion: issue.suggestion.as_deref(),
77 confidence: issue.confidence,
78 })
79 .collect();
80
81 serde_json::to_string(&ReviewExplainabilityRef {
82 schema_version: EXPLAINABILITY_SCHEMA_VERSION,
83 matched_rule_ids: &result.matched_rule_ids,
84 matched_rule_titles: &result.matched_rule_titles,
85 prompt_tokens_estimate: result.prompt_tokens_estimate,
86 trace_id: &result.trace_id,
87 issue_count: result.issues.len(),
88 summary: result.summary.as_ref(),
89 top_issues,
90 })
91 .ok()
92}
93
94pub fn build_review_comment_metadata(issue: &crate::review::ReviewIssueRecord) -> Option<String> {
95 serde_json::to_string(&ReviewCommentMetadataRef {
96 severity: &issue.severity,
97 rule: &issue.rule,
98 rule_id: issue.rule_id.as_deref(),
99 confidence: issue.confidence,
100 suggestion: issue.suggestion.as_deref(),
101 })
102 .ok()
103}
104
105pub fn format_review_issue_comment(issue: &crate::review::ReviewIssueRecord) -> String {
106 let mut content = issue.message.clone();
107 if let Some(suggestion) = issue.suggestion.as_deref()
108 && !suggestion.trim().is_empty()
109 {
110 content.push_str("\nSuggested fix: ");
111 content.push_str(suggestion.trim());
112 }
113 content
114}
115
116#[cfg(test)]
117mod tests {
118 use super::queries::attach_comments;
119 use super::rows::{
120 ReviewCommentRow, UNKNOWN_REVIEW_COMMENT_LINE_NUMBER, stored_review_comment_line_number,
121 };
122 use super::types::{ReviewCommentRecord, ReviewItemRecord};
123 use super::{build_explainability_metadata, format_review_issue_comment};
124 use std::collections::HashMap;
125
126 fn make_item(id: &str) -> ReviewItemRecord {
127 ReviewItemRecord {
128 id: id.into(),
129 session_id: None,
130 project_id: Some("proj-1".into()),
131 file_path: format!("src/{id}.rs"),
132 diff_content: String::new(),
133 status: "pending".into(),
134 source: "local".into(),
135 source_kind: "manual".into(),
136 external_review_id: None,
137 repo_full_name: None,
138 pr_number: None,
139 author: None,
140 synced_at: None,
141 metadata: None,
142 created_at: "2026-04-10 00:00:00".into(),
143 reviewed_at: None,
144 }
145 }
146
147 fn make_comment(id: &str, item_id: &str) -> ReviewCommentRecord {
148 ReviewCommentRecord {
149 id: id.into(),
150 review_item_id: item_id.into(),
151 external_comment_id: None,
152 line_number: Some(1),
153 content: "nit".into(),
154 author: None,
155 comment_url: None,
156 thread_id: None,
157 metadata: None,
158 created_at: "2026-04-10 00:00:00".into(),
159 }
160 }
161
162 #[test]
163 fn attach_comments_pairs_by_item_id() {
164 let items = vec![make_item("a"), make_item("b")];
165 let mut by_item: HashMap<String, Vec<ReviewCommentRecord>> = HashMap::new();
166 by_item.insert(
167 "a".into(),
168 vec![make_comment("c1", "a"), make_comment("c2", "a")],
169 );
170 by_item.insert("b".into(), vec![make_comment("c3", "b")]);
171
172 let result = attach_comments(items, by_item);
173 assert_eq!(result.len(), 2);
174 assert_eq!(result[0].item.id, "a");
175 assert_eq!(result[0].comments.len(), 2);
176 assert_eq!(result[1].item.id, "b");
177 assert_eq!(result[1].comments.len(), 1);
178 }
179
180 #[test]
181 fn attach_comments_defaults_to_empty_when_no_comments() {
182 let items = vec![make_item("lonely")];
183 let by_item: HashMap<String, Vec<ReviewCommentRecord>> = HashMap::new();
184 let result = attach_comments(items, by_item);
185 assert_eq!(result.len(), 1);
186 assert!(result[0].comments.is_empty());
187 }
188
189 #[test]
190 fn attach_comments_drops_unmatched_comment_buckets() {
191 let items = vec![make_item("a")];
192 let mut by_item: HashMap<String, Vec<ReviewCommentRecord>> = HashMap::new();
193 by_item.insert("a".into(), vec![make_comment("c1", "a")]);
194 by_item.insert("ghost".into(), vec![make_comment("c2", "ghost")]);
196 let result = attach_comments(items, by_item);
197 assert_eq!(result.len(), 1);
198 assert_eq!(result[0].comments.len(), 1);
199 assert_eq!(result[0].comments[0].id, "c1");
200 }
201
202 #[test]
203 fn review_comment_row_converts_line_number_and_preserves_fields() {
204 let row = ReviewCommentRow {
205 id: "c1".into(),
206 review_item_id: "item".into(),
207 external_comment_id: Some("gh-1".into()),
208 line_number: 42i64,
209 content: "hello".into(),
210 author: Some("bob".into()),
211 comment_url: Some("https://x".into()),
212 thread_id: Some("t1".into()),
213 metadata: None,
214 created_at: "t".into(),
215 };
216 let rec: ReviewCommentRecord = row.into();
217 assert_eq!(rec.line_number, Some(42));
218 assert_eq!(rec.author.as_deref(), Some("bob"));
219 assert_eq!(rec.external_comment_id.as_deref(), Some("gh-1"));
220 assert_eq!(rec.thread_id.as_deref(), Some("t1"));
221 }
222
223 #[test]
224 fn review_comment_row_treats_non_positive_line_numbers_as_unknown() {
225 let zero = ReviewCommentRow {
226 id: "c1".into(),
227 review_item_id: "item".into(),
228 external_comment_id: None,
229 line_number: 0,
230 content: "hello".into(),
231 author: None,
232 comment_url: None,
233 thread_id: None,
234 metadata: None,
235 created_at: "t".into(),
236 };
237 let rec: ReviewCommentRecord = zero.into();
238 assert_eq!(rec.line_number, None);
239 assert_eq!(
240 stored_review_comment_line_number(None),
241 UNKNOWN_REVIEW_COMMENT_LINE_NUMBER
242 );
243 assert_eq!(
244 stored_review_comment_line_number(Some(0)),
245 UNKNOWN_REVIEW_COMMENT_LINE_NUMBER
246 );
247 }
248
249 #[test]
250 fn explainability_metadata_round_trips() {
251 let result = crate::review::ReviewCheckResult {
252 issues: vec![crate::review::ReviewIssueRecord {
253 severity: "warning".into(),
254 rule: "avoid-foo".into(),
255 rule_id: Some("rule-1".into()),
256 message: "Avoid foo.".into(),
257 file: Some("src/lib.rs".into()),
258 line: Some(7),
259 suggestion: Some("Use bar.".into()),
260 source_badge: None,
261 perspectives: vec!["style".into()],
262 confidence: 0.82,
263 }],
264 matched_rules: 1,
265 matched_rule_ids: vec!["rule-1".into()],
266 matched_rule_titles: vec!["Avoid foo".into()],
267 prompt_tokens_estimate: 123,
268 trace_id: "trace-1".into(),
269 summary: Some(crate::models::ReviewSummary {
270 one_line_summary: "Touches validation.".into(),
271 walkthrough_by_file: vec![],
272 blocking_count: 0,
273 non_blocking_count: 1,
274 }),
275 stats: None,
276 };
277
278 let json = build_explainability_metadata(&result).expect("metadata json");
279 let item = ReviewItemRecord {
280 metadata: Some(json),
281 ..make_item("meta")
282 };
283 let parsed = item
284 .explainability_metadata()
285 .expect("parsed explainability metadata");
286 assert_eq!(parsed.schema_version, super::EXPLAINABILITY_SCHEMA_VERSION);
287 assert_eq!(parsed.matched_rule_ids, vec!["rule-1"]);
288 assert_eq!(parsed.matched_rule_titles, vec!["Avoid foo"]);
289 assert_eq!(parsed.issue_count, 1);
290 assert_eq!(parsed.top_issues.len(), 1);
291 assert_eq!(parsed.top_issues[0].rule, "avoid-foo");
292 assert_eq!(
293 parsed.summary.unwrap().one_line_summary,
294 "Touches validation."
295 );
296 }
297
298 #[test]
299 fn format_review_issue_comment_appends_suggestion() {
300 let issue = crate::review::ReviewIssueRecord {
301 severity: "warning".into(),
302 rule: "rule".into(),
303 rule_id: None,
304 message: "Main message".into(),
305 file: None,
306 line: None,
307 suggestion: Some("Do the thing.".into()),
308 source_badge: None,
309 perspectives: vec![],
310 confidence: 1.0,
311 };
312 assert_eq!(
313 format_review_issue_comment(&issue),
314 "Main message\nSuggested fix: Do the thing."
315 );
316 }
317}