rs-guard 1.0.0

AI-powered code review CLI for GitHub PRs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
//! Verdict parsing and review state determination.
//!
//! Parses structured metadata from LLM responses to determine the appropriate
//! GitHub review state (`APPROVE`, `REQUEST_CHANGES`, or `COMMENT`).
//!
//! The parser first attempts to extract a `[RS_GUARD_VERDICT_METADATA]` block
//! via substring scanning. If no metadata block is found, it falls back to
//! counting `[Critical]`, `[Security]`, `[Important]`, and `[Suggestion]` tags
//! in the response text.

use crate::error::RsGuardError;
use regex::Regex;
use std::sync::LazyLock;

/// Maximum bytes to scan after the metadata marker for fields.
/// Increased to 4096 to handle large LLM responses where the metadata block
/// may appear near the end. This prevents silent fallback to tag counting
/// which can produce incorrect verdicts.
const METADATA_SCAN_WINDOW: usize = 4096;

/// Minimum number of `[Important]` issues required to trigger `REQUEST_CHANGES`.
/// Below this threshold, important issues produce a `COMMENT` instead.
const IMPORTANT_ISSUES_THRESHOLD: u32 = 3;

/// Marker string that identifies the verdict metadata block.
const METADATA_MARKER: &str = "[RS_GUARD_VERDICT_METADATA]";

/// Compiled regex for counting critical bug tags.
static CRITICAL_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\[Critical Bug\]|\[Critical\]").expect("critical regex is valid")
});

/// Compiled regex for counting security issue tags.
static SECURITY_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\[Security\]|\[Security Issue\]").expect("security regex is valid")
});

/// Compiled regex for counting important issue tags.
/// Matches `[Important]` and `[Important Issue]` for consistency with critical/security variants.
static IMPORTANT_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\[Important\]|\[Important Issue\]").expect("important regex is valid")
});

/// Compiled regex for counting suggestion tags.
/// Matches `[Suggestion]` and `[Suggestion Issue]` for consistency with critical/security variants.
static SUGGESTION_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\[Suggestion\]|\[Suggestion Issue\]").expect("suggestion regex is valid")
});

/// GitHub Pull Request review states.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewState {
    /// Approve the PR — code is ready to merge.
    Approve,
    /// Request changes — issues must be addressed before merging.
    RequestChanges,
    /// Leave a comment without approving or blocking.
    Comment,
}

impl std::fmt::Display for ReviewState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ReviewState::Approve => write!(f, "APPROVE"),
            ReviewState::RequestChanges => write!(f, "REQUEST_CHANGES"),
            ReviewState::Comment => write!(f, "COMMENT"),
        }
    }
}

impl ReviewState {
    /// Returns the GitHub REST API `event` value for creating a pull request review.
    ///
    /// The GitHub REST API has a well-known asymmetry between the input and
    /// output enum names for review events:
    ///
    /// | State                    | `event` (request body) | `state` (response body) |
    /// |--------------------------|------------------------|-------------------------|
    /// | [`ReviewState::Approve`] | `"APPROVE"`            | `"APPROVED"`            |
    /// | [`ReviewState::RequestChanges`] | `"REQUEST_CHANGES"` | `"CHANGES_REQUESTED"`   |
    /// | [`ReviewState::Comment`] | `"COMMENT"`            | `"COMMENTED"`           |
    ///
    /// This function returns the **request-body** form. Use the read-side
    /// string `"CHANGES_REQUESTED"` directly when comparing against the
    /// `state` field of an existing review (e.g. in
    /// [`crate::github::dismiss_previous_reviews`]).
    ///
    /// Sending `"CHANGES_REQUESTED"` as the `event` value causes GitHub to
    /// respond with HTTP 422 and the error
    /// `Variable $event of type PullRequestReviewEvent was provided invalid value`.
    pub fn as_github_state(&self) -> &'static str {
        match self {
            ReviewState::Approve => "APPROVE",
            ReviewState::RequestChanges => "REQUEST_CHANGES",
            ReviewState::Comment => "COMMENT",
        }
    }
}

/// Parsed verdict metadata from an LLM response.
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use = "Verdict should be used to determine a ReviewState"]
pub struct Verdict {
    /// The verdict string: `"POSITIVE"` or `"NEGATIVE"`.
    pub verdict: String,
    /// Count of `[Critical]` issues identified. Blocks merge unconditionally.
    pub critical_issues: u32,
    /// Count of `[Security]` issues identified. Blocks merge unconditionally.
    pub security_issues: u32,
    /// Count of `[Important]` issues identified. Blocks merge when ≥ 3.
    pub important_issues: u32,
    /// Count of `[Suggestion]` items. Advisory only — never blocks merge.
    pub suggestions: u32,
}

impl std::fmt::Display for Verdict {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "Verdict: {}, CriticalIssues: {}, SecurityIssues: {}, ImportantIssues: {}, Suggestions: {}",
            self.verdict,
            self.critical_issues,
            self.security_issues,
            self.important_issues,
            self.suggestions
        )
    }
}

/// Extracts a named field value from the metadata section.
///
/// Searches for `label:` in `section`, extracts the value until end-of-line,
/// and returns the trimmed result. Fields may appear in any order.
fn extract_field<'a>(section: &'a str, label: &str) -> Option<&'a str> {
    let pos = section.find(label)?;
    let value = section[pos + label.len()..].trim_start();
    let end = value.find(['\n', '\r']).unwrap_or(value.len());
    let result = value[..end].trim();
    if result.is_empty() {
        None
    } else {
        Some(result)
    }
}

/// Attempts to extract a `[RS_GUARD_VERDICT_METADATA]` block from the response
/// using fast substring scanning instead of regex.
///
/// Returns `None` if the metadata block is not present or the `Verdict:` field is missing.
/// All numeric fields (`CriticalIssues`, `SecurityIssues`, `ImportantIssues`, `Suggestions`)
/// default to `0` when absent, allowing partial blocks from older prompt formats to parse
/// successfully. This relaxed policy is intentional — a missing count is treated as zero
/// rather than a parse failure.
pub fn parse_metadata_block(response: &str) -> Option<Verdict> {
    let marker_pos = response.find(METADATA_MARKER)?;
    let section_start = marker_pos + METADATA_MARKER.len();
    let section = &response[section_start..];
    // Only scan a limited window after the marker — the metadata block is small
    let scan_window = &section[..METADATA_SCAN_WINDOW.min(section.len())];

    let verdict = extract_field(scan_window, "Verdict:")?.to_string();
    // Accept both "CriticalIssues:" (new format) and "CriticalBugs:" (legacy format)
    // so that user-supplied prompt files using the old field name continue to work.
    let critical_issues: u32 = extract_field(scan_window, "CriticalIssues:")
        .or_else(|| extract_field(scan_window, "CriticalBugs:"))
        .and_then(|v| v.parse().ok())
        .unwrap_or(0);
    let security_issues: u32 = extract_field(scan_window, "SecurityIssues:")
        .and_then(|v| v.parse().ok())
        .unwrap_or(0);
    let important_issues: u32 = extract_field(scan_window, "ImportantIssues:")
        .and_then(|v| v.parse().ok())
        .unwrap_or(0);
    let suggestions: u32 = extract_field(scan_window, "Suggestions:")
        .and_then(|v| v.parse().ok())
        .unwrap_or(0);

    Some(Verdict {
        verdict,
        critical_issues,
        security_issues,
        important_issues,
        suggestions,
    })
}

/// Fallback verdict derivation by counting severity tags in the response text.
///
/// Used when the LLM response does not contain a structured metadata block.
/// Counts `[Critical]` / `[Critical Bug]`, `[Security]` / `[Security Issue]`,
/// `[Important]` / `[Important Issue]`, and `[Suggestion]` / `[Suggestion Issue]` tags.
pub fn evaluate_by_tags(response: &str) -> Verdict {
    let critical_issues = CRITICAL_RE.find_iter(response).count() as u32;
    let security_issues = SECURITY_RE.find_iter(response).count() as u32;
    let important_issues = IMPORTANT_RE.find_iter(response).count() as u32;
    let suggestions = SUGGESTION_RE.find_iter(response).count() as u32;

    Verdict {
        verdict: if critical_issues > 0 || security_issues > 0 {
            "NEGATIVE".to_string()
        } else {
            "POSITIVE".to_string()
        },
        critical_issues,
        security_issues,
        important_issues,
        suggestions,
    }
}

/// Determines the GitHub review state from a parsed verdict.
///
/// Uses an asymmetric safety model:
/// - `NEGATIVE` verdict, any `[Security]` issues, or any `[Critical]` issues → `REQUEST_CHANGES`.
/// - `[Important]` issues ≥ `IMPORTANT_ISSUES_THRESHOLD` → `REQUEST_CHANGES`.
/// - `[Important]` issues 1–2 → `COMMENT` (human review recommended).
/// - All counts zero and verdict `POSITIVE` → `APPROVE`.
pub fn determine_review_state(verdict: &Verdict) -> ReviewState {
    if verdict.verdict == "NEGATIVE"
        || verdict.security_issues > 0
        || verdict.critical_issues > 0
        || verdict.important_issues >= IMPORTANT_ISSUES_THRESHOLD
    {
        ReviewState::RequestChanges
    } else if verdict.important_issues > 0 {
        ReviewState::Comment
    } else {
        ReviewState::Approve
    }
}

/// Parses an LLM response into a verdict and corresponding review state.
///
/// First validates the response is not empty or whitespace-only, then attempts
/// structured metadata extraction, falls back to tag counting, validates the
/// verdict value, and computes the review state.
///
/// # Errors
///
/// Returns [`RsGuardError::VerdictParse`] if:
/// - The response is empty or whitespace-only
/// - The verdict value is neither `"POSITIVE"` nor `"NEGATIVE"`
pub fn parse_verdict(response: &str) -> Result<(Verdict, ReviewState), RsGuardError> {
    // Validate response is not empty or whitespace-only
    if response.trim().is_empty() {
        return Err(RsGuardError::VerdictParse(
            "LLM response is empty or whitespace-only. Cannot determine verdict.".to_string(),
        ));
    }

    let verdict = parse_metadata_block(response).unwrap_or_else(|| evaluate_by_tags(response));

    if verdict.verdict != "POSITIVE" && verdict.verdict != "NEGATIVE" {
        return Err(RsGuardError::VerdictParse(format!(
            "Invalid verdict value: {}. Expected POSITIVE or NEGATIVE.",
            verdict.verdict
        )));
    }

    let state = determine_review_state(&verdict);
    Ok((verdict, state))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_valid_positive() {
        let response = "Some review text\n\n[RS_GUARD_VERDICT_METADATA]\nVerdict: POSITIVE\nCriticalIssues: 0\nSecurityIssues: 0\nImportantIssues: 0\nSuggestions: 0";
        let verdict = parse_metadata_block(response).unwrap();
        assert_eq!(verdict.verdict, "POSITIVE");
        assert_eq!(verdict.critical_issues, 0);
        assert_eq!(verdict.security_issues, 0);
        assert_eq!(verdict.important_issues, 0);
        assert_eq!(verdict.suggestions, 0);
        assert_eq!(determine_review_state(&verdict), ReviewState::Approve);
    }

    #[test]
    fn test_parse_negative() {
        let response = "Some review text\n\n[RS_GUARD_VERDICT_METADATA]\nVerdict: NEGATIVE\nCriticalIssues: 0\nSecurityIssues: 0\nImportantIssues: 0\nSuggestions: 0";
        let verdict = parse_metadata_block(response).unwrap();
        assert_eq!(
            determine_review_state(&verdict),
            ReviewState::RequestChanges
        );
    }

    #[test]
    fn test_parse_critical_gt_0() {
        let response =
            "[RS_GUARD_VERDICT_METADATA]\nVerdict: POSITIVE\nCriticalIssues: 1\nSecurityIssues: 0\nImportantIssues: 0\nSuggestions: 0";
        let verdict = parse_metadata_block(response).unwrap();
        assert_eq!(
            determine_review_state(&verdict),
            ReviewState::RequestChanges
        );
    }

    #[test]
    fn test_parse_security_gt_0() {
        let response =
            "[RS_GUARD_VERDICT_METADATA]\nVerdict: POSITIVE\nCriticalIssues: 0\nSecurityIssues: 1\nImportantIssues: 0\nSuggestions: 0";
        let verdict = parse_metadata_block(response).unwrap();
        assert_eq!(
            determine_review_state(&verdict),
            ReviewState::RequestChanges
        );
    }

    #[test]
    fn test_missing_metadata_fallback_to_tags() {
        let response = "Review found some issues.\n[Critical Bug] Race condition in handler\n[Security] SQL injection risk";
        let verdict = evaluate_by_tags(response);
        assert_eq!(verdict.critical_issues, 1);
        assert_eq!(verdict.security_issues, 1);
        assert_eq!(
            determine_review_state(&verdict),
            ReviewState::RequestChanges
        );
    }

    #[test]
    fn test_clean_tag_fallback() {
        let response = "Everything looks good. No issues found.";
        let verdict = evaluate_by_tags(response);
        assert_eq!(verdict.critical_issues, 0);
        assert_eq!(verdict.security_issues, 0);
        assert_eq!(verdict.important_issues, 0);
        assert_eq!(verdict.suggestions, 0);
        assert_eq!(determine_review_state(&verdict), ReviewState::Approve);
    }

    #[test]
    fn test_positive_with_important_issues_comment() {
        let response =
            "[RS_GUARD_VERDICT_METADATA]\nVerdict: POSITIVE\nCriticalIssues: 0\nSecurityIssues: 0\nImportantIssues: 1\nSuggestions: 0";
        let verdict = parse_metadata_block(response).unwrap();
        assert_eq!(determine_review_state(&verdict), ReviewState::Comment);
    }

    /// Regression test for the GitHub REST API `event` field values.
    ///
    /// GitHub's REST API has a request/response asymmetry for review event
    /// names: the **input** field `event` expects `REQUEST_CHANGES`, but the
    /// **output** field `state` returns `CHANGES_REQUESTED`. This test pins
    /// the request-side strings so a future refactor cannot regress to
    /// sending `CHANGES_REQUESTED` (which causes a 422 with the error
    /// `Variable $event of type PullRequestReviewEvent was provided invalid value`).
    #[test]
    fn test_as_github_state_request_body_values() {
        assert_eq!(ReviewState::Approve.as_github_state(), "APPROVE");
        assert_eq!(
            ReviewState::RequestChanges.as_github_state(),
            "REQUEST_CHANGES"
        );
        assert_eq!(ReviewState::Comment.as_github_state(), "COMMENT");
    }

    #[test]
    fn test_metadata_block_at_end_of_large_response() {
        let padding = "x".repeat(3000);
        let response = format!(
            "{}\n[RS_GUARD_VERDICT_METADATA]\nVerdict: POSITIVE\nCriticalIssues: 0\nSecurityIssues: 0\nImportantIssues: 0\nSuggestions: 0",
            padding
        );
        let verdict = parse_metadata_block(&response).unwrap();
        assert_eq!(verdict.verdict, "POSITIVE");
        assert_eq!(verdict.critical_issues, 0);
        assert_eq!(verdict.security_issues, 0);
    }

    #[test]
    fn test_metadata_block_near_boundary() {
        let padding = "x".repeat(3500);
        let response = format!(
            "{}\n[RS_GUARD_VERDICT_METADATA]\nVerdict: NEGATIVE\nCriticalIssues: 1\nSecurityIssues: 0\nImportantIssues: 0\nSuggestions: 0",
            padding
        );
        let verdict = parse_metadata_block(&response).unwrap();
        assert_eq!(verdict.verdict, "NEGATIVE");
        assert_eq!(verdict.critical_issues, 1);
        assert_eq!(verdict.security_issues, 0);
    }

    #[test]
    fn test_metadata_block_beyond_window_fallback_to_tags() {
        let padding = "x".repeat(5000);
        let response = format!(
            "[RS_GUARD_VERDICT_METADATA]\n{}\nVerdict: POSITIVE\nCriticalIssues: 0\nSecurityIssues: 0\nImportantIssues: 0\nSuggestions: 0",
            padding
        );
        let verdict = parse_metadata_block(&response);
        assert!(verdict.is_none());
    }

    #[test]
    fn test_empty_response_returns_error() {
        let response = "";
        let result = parse_verdict(response);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("empty or whitespace-only"));
    }

    #[test]
    fn test_whitespace_only_response_returns_error() {
        let response = "   \n\t  \n  ";
        let result = parse_verdict(response);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("empty or whitespace-only"));
    }

    #[test]
    fn test_valid_response_parses_successfully() {
        let response = "Some review text\n\n[RS_GUARD_VERDICT_METADATA]\nVerdict: POSITIVE\nCriticalIssues: 0\nSecurityIssues: 0\nImportantIssues: 0\nSuggestions: 0";
        let result = parse_verdict(response);
        assert!(result.is_ok());
        let (verdict, state) = result.unwrap();
        assert_eq!(verdict.verdict, "POSITIVE");
        assert_eq!(state, ReviewState::Approve);
    }

    // --- Issue #22: Metadata block with non-standard field order ---

    #[test]
    fn test_metadata_block_reversed_field_order() {
        // Fields in reverse order should still parse correctly
        let response =
            "[RS_GUARD_VERDICT_METADATA]\nSuggestions: 1\nImportantIssues: 0\nSecurityIssues: 0\nCriticalIssues: 1\nVerdict: NEGATIVE";
        let verdict = parse_metadata_block(response).unwrap();
        assert_eq!(verdict.verdict, "NEGATIVE");
        assert_eq!(verdict.critical_issues, 1);
        assert_eq!(verdict.security_issues, 0);
        assert_eq!(verdict.suggestions, 1);
    }

    #[test]
    fn test_metadata_block_fields_with_content_between() {
        // Content between fields should not affect parsing
        let response = "[RS_GUARD_VERDICT_METADATA]\nVerdict: POSITIVE\nSome extra text here\nCriticalIssues: 0\nMore text\nSecurityIssues: 0\nImportantIssues: 0\nSuggestions: 0";
        let verdict = parse_metadata_block(response).unwrap();
        assert_eq!(verdict.verdict, "POSITIVE");
        assert_eq!(verdict.critical_issues, 0);
        assert_eq!(verdict.security_issues, 0);
    }

    #[test]
    fn test_metadata_block_random_field_order() {
        // Random field order should work
        let response =
            "[RS_GUARD_VERDICT_METADATA]\nImportantIssues: 2\nCriticalIssues: 1\nVerdict: NEGATIVE\nSecurityIssues: 0\nSuggestions: 3";
        let verdict = parse_metadata_block(response).unwrap();
        assert_eq!(verdict.verdict, "NEGATIVE");
        assert_eq!(verdict.critical_issues, 1);
        assert_eq!(verdict.security_issues, 0);
        assert_eq!(verdict.important_issues, 2);
        assert_eq!(verdict.suggestions, 3);
    }

    #[test]
    fn test_legacy_critical_issues_field_still_parses() {
        // Regression: user-supplied prompts using old CriticalBugs: field must keep working
        let response =
            "[RS_GUARD_VERDICT_METADATA]\nVerdict: NEGATIVE\nCriticalBugs: 2\nSecurityIssues: 1";
        let verdict = parse_metadata_block(response).unwrap();
        assert_eq!(verdict.critical_issues, 2);
        assert_eq!(verdict.security_issues, 1);
        assert_eq!(verdict.important_issues, 0);
        assert_eq!(verdict.suggestions, 0);
    }
}