Skip to main content

roder_api/
plan_review.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::events::{ThreadId, TurnId};
5
6pub type PlanReviewId = String;
7pub type PlanStepId = String;
8pub type PlanCommentId = String;
9pub type PlanRewriteId = String;
10pub type HunkId = String;
11
12pub const MAX_PLAN_REVIEW_TEXT_CHARS: usize = 64 * 1024;
13pub const MAX_HUNK_DIFF_LINES: usize = 400;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "camelCase")]
17pub enum PlanReviewStatus {
18    Drafted,
19    AwaitingReview,
20    Rewritten,
21    Approved,
22    Executing,
23    Completed,
24    Rejected,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "camelCase")]
29pub enum PlanCommentAnchor {
30    WholePlan,
31    #[serde(rename_all = "camelCase")]
32    Step {
33        step_id: PlanStepId,
34    },
35    #[serde(rename_all = "camelCase")]
36    File {
37        path: String,
38        start_line: Option<u32>,
39        end_line: Option<u32>,
40    },
41    #[serde(rename_all = "camelCase")]
42    Hunk {
43        hunk_id: HunkId,
44    },
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(rename_all = "camelCase")]
49pub struct PlanStep {
50    pub id: PlanStepId,
51    pub title: String,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub detail: Option<String>,
54    #[serde(default)]
55    pub completed: bool,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "camelCase")]
60pub struct PlanComment {
61    pub id: PlanCommentId,
62    pub review_id: PlanReviewId,
63    pub anchor: PlanCommentAnchor,
64    pub body: String,
65    #[serde(with = "time::serde::rfc3339")]
66    pub created_at: OffsetDateTime,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "camelCase")]
71pub struct PlanRewrite {
72    pub id: PlanRewriteId,
73    pub review_id: PlanReviewId,
74    pub replacement_markdown: String,
75    #[serde(with = "time::serde::rfc3339")]
76    pub created_at: OffsetDateTime,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "camelCase")]
81pub struct PlanReview {
82    pub id: PlanReviewId,
83    pub thread_id: ThreadId,
84    pub turn_id: TurnId,
85    pub status: PlanReviewStatus,
86    pub title: String,
87    pub markdown: String,
88    #[serde(default)]
89    pub steps: Vec<PlanStep>,
90    #[serde(default)]
91    pub comments: Vec<PlanComment>,
92    #[serde(default)]
93    pub rewrites: Vec<PlanRewrite>,
94    #[serde(with = "time::serde::rfc3339")]
95    pub created_at: OffsetDateTime,
96    #[serde(with = "time::serde::rfc3339")]
97    pub updated_at: OffsetDateTime,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "camelCase")]
102pub enum HunkRollbackState {
103    Unavailable { reason: String },
104    Available,
105    Applied,
106    Conflict { reason: String },
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(rename_all = "camelCase")]
111pub enum HunkDiffLineKind {
112    Context,
113    Added,
114    Removed,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(rename_all = "camelCase")]
119pub struct HunkDiffLine {
120    pub kind: HunkDiffLineKind,
121    pub text: String,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub old_line: Option<u32>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub new_line: Option<u32>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
129#[serde(rename_all = "camelCase")]
130pub struct HunkRecord {
131    pub id: HunkId,
132    pub thread_id: ThreadId,
133    pub turn_id: TurnId,
134    pub path: String,
135    pub old_start: u32,
136    pub old_lines: u32,
137    pub new_start: u32,
138    pub new_lines: u32,
139    #[serde(default)]
140    pub diff: Vec<HunkDiffLine>,
141    pub tool_call_id: String,
142    pub tool_name: String,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub plan_review_id: Option<PlanReviewId>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub plan_step_id: Option<PlanStepId>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub timeline_event_id: Option<String>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub checkpoint_id: Option<String>,
151    pub rollback: HunkRollbackState,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub reverse_patch: Option<String>,
154    #[serde(with = "time::serde::rfc3339")]
155    pub created_at: OffsetDateTime,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "camelCase")]
160pub struct PagedHunkDiff {
161    pub hunk: HunkRecord,
162    pub offset: usize,
163    pub limit: usize,
164    pub total_lines: usize,
165    pub lines: Vec<HunkDiffLine>,
166    pub next_offset: Option<usize>,
167}
168
169pub fn cap_text(mut text: String, max_chars: usize) -> String {
170    if text.chars().count() <= max_chars {
171        return text;
172    }
173    text = text.chars().take(max_chars).collect();
174    text.push_str("\n[truncated]");
175    text
176}
177
178pub fn page_hunk_diff(hunk: HunkRecord, offset: usize, limit: usize) -> PagedHunkDiff {
179    let total_lines = hunk.diff.len();
180    let start = offset.min(total_lines);
181    let end = start.saturating_add(limit).min(total_lines);
182    let lines = hunk.diff[start..end].to_vec();
183    PagedHunkDiff {
184        hunk,
185        offset: start,
186        limit,
187        total_lines,
188        lines,
189        next_offset: (end < total_lines).then_some(end),
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn plan_review_uses_camel_case_and_stable_ids() {
199        let review = PlanReview {
200            id: "review-1".to_string(),
201            thread_id: "thread-1".to_string(),
202            turn_id: "turn-1".to_string(),
203            status: PlanReviewStatus::AwaitingReview,
204            title: "Plan".to_string(),
205            markdown: "- step".to_string(),
206            steps: vec![PlanStep {
207                id: "step-1".to_string(),
208                title: "Edit file".to_string(),
209                detail: None,
210                completed: false,
211            }],
212            comments: vec![PlanComment {
213                id: "comment-1".to_string(),
214                review_id: "review-1".to_string(),
215                anchor: PlanCommentAnchor::Step {
216                    step_id: "step-1".to_string(),
217                },
218                body: "Tighten this.".to_string(),
219                created_at: OffsetDateTime::UNIX_EPOCH,
220            }],
221            rewrites: vec![],
222            created_at: OffsetDateTime::UNIX_EPOCH,
223            updated_at: OffsetDateTime::UNIX_EPOCH,
224        };
225
226        let value = serde_json::to_value(&review).unwrap();
227        assert_eq!(value["threadId"], "thread-1");
228        assert_eq!(value["status"], "awaitingReview");
229        assert_eq!(value["steps"][0]["id"], "step-1");
230
231        let round_trip: PlanReview = serde_json::from_value(value).unwrap();
232        assert_eq!(round_trip.id, "review-1");
233    }
234
235    #[test]
236    fn hunk_diff_pages_are_bounded() {
237        let hunk = HunkRecord {
238            id: "hunk-1".to_string(),
239            thread_id: "thread-1".to_string(),
240            turn_id: "turn-1".to_string(),
241            path: "src/lib.rs".to_string(),
242            old_start: 1,
243            old_lines: 1,
244            new_start: 1,
245            new_lines: 2,
246            diff: vec![
247                HunkDiffLine {
248                    kind: HunkDiffLineKind::Removed,
249                    text: "old".to_string(),
250                    old_line: Some(1),
251                    new_line: None,
252                },
253                HunkDiffLine {
254                    kind: HunkDiffLineKind::Added,
255                    text: "new".to_string(),
256                    old_line: None,
257                    new_line: Some(1),
258                },
259            ],
260            tool_call_id: "tool-1".to_string(),
261            tool_name: "apply_patch".to_string(),
262            plan_review_id: Some("review-1".to_string()),
263            plan_step_id: Some("step-1".to_string()),
264            timeline_event_id: None,
265            checkpoint_id: None,
266            rollback: HunkRollbackState::Available,
267            reverse_patch: Some("*** Begin Patch".to_string()),
268            created_at: OffsetDateTime::UNIX_EPOCH,
269        };
270
271        let page = page_hunk_diff(hunk, 0, 1);
272        assert_eq!(page.lines.len(), 1);
273        assert_eq!(page.next_offset, Some(1));
274    }
275}