1use std::fmt;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
11pub struct TurnPromptAttachment {
12 pub local_image_path: PathBuf,
14 pub placeholder: String,
16}
17
18#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
20pub struct TurnPrompt {
21 pub attachments: Vec<TurnPromptAttachment>,
23 pub text: String,
25 #[serde(default)]
27 pub text_source: TurnPromptTextSource,
28}
29
30impl TurnPrompt {
31 #[must_use]
33 pub fn from_text(text: String) -> Self {
34 Self {
35 attachments: Vec::new(),
36 text,
37 text_source: TurnPromptTextSource::UserPrompt,
38 }
39 }
40
41 #[must_use]
44 pub fn from_agent_data(text: String) -> Self {
45 Self {
46 attachments: Vec::new(),
47 text,
48 text_source: TurnPromptTextSource::AgentData,
49 }
50 }
51
52 #[must_use]
54 pub fn is_empty(&self) -> bool {
55 self.text.is_empty() && self.attachments.is_empty()
56 }
57
58 #[must_use]
60 pub fn has_attachments(&self) -> bool {
61 !self.attachments.is_empty()
62 }
63
64 pub fn local_image_paths(&self) -> impl Iterator<Item = &PathBuf> {
66 self.attachments
67 .iter()
68 .map(|attachment| &attachment.local_image_path)
69 }
70
71 #[must_use]
73 pub fn contains(&self, needle: &str) -> bool {
74 self.text.contains(needle)
75 }
76
77 #[must_use]
79 pub fn ends_with(&self, suffix: &str) -> bool {
80 self.text.ends_with(suffix)
81 }
82
83 #[must_use]
89 pub fn agent_text(&self) -> String {
90 match self.text_source {
91 TurnPromptTextSource::UserPrompt => render_prompt_text_for_agent(&self.text),
92 TurnPromptTextSource::AgentData => self.text.clone(),
93 }
94 }
95
96 #[must_use]
103 pub fn content_parts(&self) -> Vec<TurnPromptContentPart<'_>> {
104 split_turn_prompt_content(&self.text, &self.attachments)
105 }
106
107 #[must_use]
115 pub fn transcript_text(&self) -> String {
116 let mut transcript_text = self.text.clone();
117 let missing_placeholders = self
118 .attachments
119 .iter()
120 .filter(|attachment| !self.text.contains(&attachment.placeholder))
121 .map(|attachment| attachment.placeholder.as_str())
122 .collect::<Vec<_>>();
123
124 if missing_placeholders.is_empty() {
125 return transcript_text;
126 }
127
128 if transcript_text
129 .chars()
130 .last()
131 .is_some_and(|character| !character.is_whitespace())
132 {
133 transcript_text.push(' ');
134 }
135
136 transcript_text.push_str(&missing_placeholders.join(" "));
137
138 transcript_text
139 }
140}
141
142impl From<String> for TurnPrompt {
143 fn from(text: String) -> Self {
144 Self::from_text(text)
145 }
146}
147
148impl From<&str> for TurnPrompt {
149 fn from(text: &str) -> Self {
150 Self::from_text(text.to_string())
151 }
152}
153
154impl From<&TurnPrompt> for TurnPrompt {
155 fn from(prompt: &TurnPrompt) -> Self {
156 prompt.clone()
157 }
158}
159
160impl fmt::Display for TurnPrompt {
161 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162 formatter.write_str(&self.text)
163 }
164}
165
166impl PartialEq<&str> for TurnPrompt {
167 fn eq(&self, other: &&str) -> bool {
168 self.text == *other
169 }
170}
171
172impl PartialEq<TurnPrompt> for &str {
173 fn eq(&self, other: &TurnPrompt) -> bool {
174 *self == other.text
175 }
176}
177
178#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
180pub enum TurnPromptTextSource {
181 #[default]
184 UserPrompt,
185 AgentData,
188}
189
190#[derive(Debug, Clone, Copy, Eq, PartialEq)]
192pub enum TurnPromptContentPart<'prompt> {
193 Attachment(&'prompt TurnPromptAttachment),
195 OrphanAttachment(&'prompt TurnPromptAttachment),
197 Text(&'prompt str),
199}
200
201#[must_use]
208pub fn render_prompt_text_for_agent(text: &str) -> String {
209 let characters = text.chars().collect::<Vec<char>>();
210 let mut output = String::with_capacity(text.len());
211 let mut index = 0;
212
213 while let Some(&character) = characters.get(index) {
214 if character != '@'
215 || !is_at_mention_boundary(characters.get(index.wrapping_sub(1)).copied())
216 || index + 1 >= characters.len()
217 {
218 output.push(character);
219 index += 1;
220
221 continue;
222 }
223
224 let mut scan_index = index + 1;
225 while scan_index < characters.len() && is_at_mention_query_character(characters[scan_index])
226 {
227 scan_index += 1;
228 }
229
230 if scan_index == index + 1 {
231 output.push(character);
232 index += 1;
233
234 continue;
235 }
236
237 output.push('"');
238 output.extend(characters[index + 1..scan_index].iter());
239 output.push('"');
240 index = scan_index;
241 }
242
243 output
244}
245
246#[must_use]
249pub fn split_turn_prompt_content<'prompt>(
250 text: &'prompt str,
251 attachments: &'prompt [TurnPromptAttachment],
252) -> Vec<TurnPromptContentPart<'prompt>> {
253 if attachments.is_empty() {
254 return vec![TurnPromptContentPart::Text(text)];
255 }
256
257 let mut ordered_attachments = attachments.iter().collect::<Vec<_>>();
258 ordered_attachments
259 .sort_by_key(|attachment| text.find(&attachment.placeholder).unwrap_or(usize::MAX));
262
263 let mut content_parts = Vec::new();
264 let mut orphan_attachments = Vec::new();
265 let mut remaining_text = text;
266
267 for attachment in ordered_attachments {
268 if let Some(placeholder_index) = remaining_text.find(&attachment.placeholder) {
269 let (before_placeholder, after_placeholder) =
270 remaining_text.split_at(placeholder_index);
271
272 if !before_placeholder.is_empty() {
273 content_parts.push(TurnPromptContentPart::Text(before_placeholder));
274 }
275
276 content_parts.push(TurnPromptContentPart::Attachment(attachment));
277 remaining_text = &after_placeholder[attachment.placeholder.len()..];
278
279 continue;
280 }
281
282 orphan_attachments.push(attachment);
283 }
284
285 if !remaining_text.is_empty() {
286 content_parts.push(TurnPromptContentPart::Text(remaining_text));
287 }
288
289 content_parts.extend(
290 orphan_attachments
291 .into_iter()
292 .map(TurnPromptContentPart::OrphanAttachment),
293 );
294
295 content_parts
296}
297
298fn is_at_mention_boundary(previous_character: Option<char>) -> bool {
300 previous_character.is_none_or(|character| {
301 character.is_whitespace() || is_at_mention_opening_delimiter(character)
302 })
303}
304
305fn is_at_mention_query_character(character: char) -> bool {
307 character.is_alphanumeric() || matches!(character, '/' | '.' | '_' | '-')
308}
309
310fn is_at_mention_opening_delimiter(character: char) -> bool {
312 matches!(character, '(' | '[' | '{')
313}
314
315#[cfg(test)]
316mod tests {
317 use std::path::PathBuf;
318
319 use super::*;
320
321 #[test]
322 fn test_turn_prompt_attachment_json_uses_named_fields_independent_of_order() {
325 let attachment = TurnPromptAttachment {
327 local_image_path: PathBuf::from("/tmp/image-1.png"),
328 placeholder: "[Image #1]".to_string(),
329 };
330 let alternate_order_json =
331 r#"{"placeholder":"[Image #1]","local_image_path":"/tmp/image-1.png"}"#;
332
333 let deserialized_attachment =
335 serde_json::from_str::<TurnPromptAttachment>(alternate_order_json)
336 .expect("attachment JSON should deserialize by field name");
337 let serialized_value =
338 serde_json::to_value(&attachment).expect("attachment should serialize");
339
340 assert_eq!(deserialized_attachment, attachment);
342 assert_eq!(
343 serialized_value,
344 serde_json::json!({
345 "local_image_path": "/tmp/image-1.png",
346 "placeholder": "[Image #1]",
347 })
348 );
349 }
350
351 #[test]
352 fn test_turn_prompt_compares_with_str_in_both_directions() {
354 let prompt = TurnPrompt::from("Review this change");
356
357 let prompt_matches = prompt == "Review this change";
359 let text_matches = "Review this change" == prompt;
360
361 assert!(prompt_matches);
363 assert!(text_matches);
364 }
365
366 #[test]
367 fn test_turn_prompt_transcript_text_keeps_inline_placeholders() {
369 let prompt = TurnPrompt {
371 attachments: vec![TurnPromptAttachment {
372 local_image_path: PathBuf::from("/tmp/image-1.png"),
373 placeholder: "[Image #1]".to_string(),
374 }],
375 text: "Review [Image #1] carefully".to_string(),
376 text_source: TurnPromptTextSource::UserPrompt,
377 };
378
379 let transcript_text = prompt.transcript_text();
381
382 assert_eq!(transcript_text, "Review [Image #1] carefully");
384 }
385
386 #[test]
387 fn test_turn_prompt_agent_text_rewrites_user_at_lookups() {
390 let prompt = TurnPrompt::from("Review @src/main.rs and person@example.com");
392
393 let agent_text = prompt.agent_text();
395 let transcript_text = prompt.transcript_text();
396
397 assert_eq!(agent_text, "Review \"src/main.rs\" and person@example.com");
399 assert_eq!(
400 transcript_text,
401 "Review @src/main.rs and person@example.com"
402 );
403 }
404
405 #[test]
406 fn test_turn_prompt_agent_text_preserves_agent_data_at_tokens() {
408 let prompt = TurnPrompt::from_agent_data(
410 "Diff:\n```diff\n+@dataclass\n+class Config:\n+ pass\n```".to_string(),
411 );
412
413 let agent_text = prompt.agent_text();
415
416 assert!(agent_text.contains("+@dataclass"));
418 assert!(!agent_text.contains("+\"dataclass\""));
419 }
420
421 #[test]
422 fn test_turn_prompt_transcript_text_appends_missing_placeholders() {
425 let prompt = TurnPrompt {
427 attachments: vec![
428 TurnPromptAttachment {
429 local_image_path: PathBuf::from("/tmp/image-1.png"),
430 placeholder: "[Image #1]".to_string(),
431 },
432 TurnPromptAttachment {
433 local_image_path: PathBuf::from("/tmp/image-2.png"),
434 placeholder: "[Image #2]".to_string(),
435 },
436 ],
437 text: "Review".to_string(),
438 text_source: TurnPromptTextSource::UserPrompt,
439 };
440
441 let transcript_text = prompt.transcript_text();
443
444 assert_eq!(transcript_text, "Review [Image #1] [Image #2]");
446 }
447
448 #[test]
449 fn test_split_turn_prompt_content_returns_text_without_attachments() {
451 let text = "Review this change";
453
454 let content_parts = split_turn_prompt_content(text, &[]);
456
457 assert_eq!(content_parts, vec![TurnPromptContentPart::Text(text)]);
459 }
460
461 #[test]
462 fn test_split_turn_prompt_content_orders_placeholders_and_appends_orphans() {
465 let attachments = vec![
467 TurnPromptAttachment {
468 local_image_path: PathBuf::from("/tmp/image-1.png"),
469 placeholder: "[Image #1]".to_string(),
470 },
471 TurnPromptAttachment {
472 local_image_path: PathBuf::from("/tmp/image-2.png"),
473 placeholder: "[Image #2]".to_string(),
474 },
475 TurnPromptAttachment {
476 local_image_path: PathBuf::from("/tmp/image-3.png"),
477 placeholder: "[Image #3]".to_string(),
478 },
479 ];
480
481 let content_parts =
483 split_turn_prompt_content("Compare [Image #2] with [Image #1] now", &attachments);
484
485 assert_eq!(
487 content_parts,
488 vec![
489 TurnPromptContentPart::Text("Compare "),
490 TurnPromptContentPart::Attachment(&attachments[1]),
491 TurnPromptContentPart::Text(" with "),
492 TurnPromptContentPart::Attachment(&attachments[0]),
493 TurnPromptContentPart::Text(" now"),
494 TurnPromptContentPart::OrphanAttachment(&attachments[2]),
495 ]
496 );
497 }
498}