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}