Skip to main content

ag_session/
review.rs

1//! Pure focused-review parsing helpers shared across session frontends.
2
3use std::fmt;
4use std::str::FromStr;
5
6/// Durable state of one focused-review generation attempt.
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum FocusedReviewStatus {
9    /// Review generation is still running.
10    Pending,
11    /// Review generation completed and persisted its markdown.
12    Ready,
13    /// Review generation completed without usable markdown.
14    Failed,
15}
16
17impl fmt::Display for FocusedReviewStatus {
18    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
19        formatter.write_str(match self {
20            Self::Pending => "Pending",
21            Self::Ready => "Ready",
22            Self::Failed => "Failed",
23        })
24    }
25}
26
27impl FromStr for FocusedReviewStatus {
28    type Err = String;
29
30    fn from_str(value: &str) -> Result<Self, Self::Err> {
31        match value {
32            "Pending" => Ok(Self::Pending),
33            "Ready" => Ok(Self::Ready),
34            "Failed" => Ok(Self::Failed),
35            _ => Err(format!("Unknown focused review status: {value}")),
36        }
37    }
38}
39
40/// Extracts actionable suggestion content from focused-review markdown.
41///
42/// Returns `None` when the `### Suggestions` section is missing, empty, or
43/// reports `- None` with optional trailing punctuation.
44#[must_use]
45pub fn review_suggestions(review_text: &str) -> Option<String> {
46    let suggestions_header = "### Suggestions";
47    let header_start = review_text.find(suggestions_header)?;
48    let content_start = header_start + suggestions_header.len();
49    let content = &review_text[content_start..];
50    let section_end = content.find("\n### ").unwrap_or(content.len());
51    let suggestions = content[..section_end].trim();
52
53    if suggestions.is_empty() || is_no_suggestions_sentinel(suggestions) {
54        return None;
55    }
56
57    Some(suggestions.to_string())
58}
59
60/// Returns whether a suggestions section contains only the required `None`
61/// sentinel plus optional trailing punctuation.
62fn is_no_suggestions_sentinel(suggestions: &str) -> bool {
63    suggestions.strip_prefix("- None").is_some_and(|suffix| {
64        suffix
65            .chars()
66            .all(|character| character.is_ascii_punctuation())
67    })
68}
69
70/// Returns whether focused-review markdown contains suggestions that `/apply`
71/// can act on.
72#[must_use]
73pub fn has_actionable_review_suggestions(review_text: Option<&str>) -> bool {
74    review_text.and_then(review_suggestions).is_some()
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn focused_review_status_round_trips_persisted_values() {
83        // Arrange
84        let statuses = [
85            FocusedReviewStatus::Pending,
86            FocusedReviewStatus::Ready,
87            FocusedReviewStatus::Failed,
88        ];
89
90        // Act / Assert
91        for status in statuses {
92            assert_eq!(status.to_string().parse(), Ok(status));
93        }
94        assert!("Unknown".parse::<FocusedReviewStatus>().is_err());
95    }
96
97    #[test]
98    fn test_review_suggestions_returns_suggestions_content() {
99        // Arrange
100        let review_text = "\
101### Summary
102
103- Good shape.
104
105### Suggestions
106
107- Fix the typo in `README.md:10`.";
108
109        // Act
110        let suggestions = review_suggestions(review_text);
111
112        // Assert
113        assert_eq!(
114            suggestions,
115            Some("- Fix the typo in `README.md:10`.".to_string())
116        );
117    }
118
119    #[test]
120    fn test_review_suggestions_returns_none_for_no_suggestions() {
121        // Arrange
122        let review_text = "\
123### Summary
124
125- Good shape.
126
127### Suggestions
128
129- None";
130
131        // Act
132        let suggestions = review_suggestions(review_text);
133
134        // Assert
135        assert_eq!(suggestions, None);
136    }
137
138    #[test]
139    fn test_review_suggestions_returns_none_for_punctuated_no_suggestions() {
140        // Arrange
141        let review_text = "## Review\n\n### Suggestions\n\n- None.";
142
143        // Act
144        let suggestions = review_suggestions(review_text);
145
146        // Assert
147        assert_eq!(suggestions, None);
148    }
149
150    #[test]
151    fn test_review_suggestions_returns_none_when_section_missing() {
152        // Arrange
153        let review_text = "\
154### Summary
155
156- Good shape overall.";
157
158        // Act
159        let suggestions = review_suggestions(review_text);
160
161        // Assert
162        assert_eq!(suggestions, None);
163    }
164
165    #[test]
166    fn test_review_suggestions_stops_at_next_heading() {
167        // Arrange
168        let review_text = "\
169### Summary
170
171- Good shape.
172
173### Suggestions
174
175- Fix the typo in `README.md:10`.
176
177### Project Impact
178
179- Great work overall.";
180
181        // Act
182        let suggestions = review_suggestions(review_text);
183
184        // Assert
185        assert_eq!(
186            suggestions,
187            Some("- Fix the typo in `README.md:10`.".to_string())
188        );
189    }
190
191    #[test]
192    fn test_review_suggestions_returns_none_for_empty_section() {
193        // Arrange
194        let review_text = "\
195### Suggestions
196
197### Project Impact
198
199- None";
200
201        // Act
202        let suggestions = review_suggestions(review_text);
203
204        // Assert
205        assert_eq!(suggestions, None);
206    }
207
208    #[test]
209    fn test_has_actionable_review_suggestions_detects_suggestions_section() {
210        // Arrange
211        let review_with_suggestions = "## Review\n### Suggestions\n- Fix typo\n### Notes";
212        let review_without_suggestions = "## Review\n### Suggestions\n- None\n### Notes";
213
214        // Act
215        let with_suggestions = has_actionable_review_suggestions(Some(review_with_suggestions));
216        let without_suggestions =
217            has_actionable_review_suggestions(Some(review_without_suggestions));
218        let missing_header = has_actionable_review_suggestions(Some("## Review"));
219
220        // Assert
221        assert!(with_suggestions);
222        assert!(!without_suggestions);
223        assert!(!missing_header);
224    }
225}