Skip to main content

difflore_core/reviews/
mod.rs

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// Borrow-only serialize mirrors of the metadata records. Both functions
25// stringify and discard, so the wrapper never needs ownership of the
26// underlying review fields. The owned structs in `types.rs` stay around
27// for the deserialize side.
28#[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        // Orphan bucket for non-existent item — should be ignored, not crash.
195        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}