1use std::fmt;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
8pub struct TurnPromptAttachment {
9 pub placeholder: String,
11 pub local_image_path: PathBuf,
13}
14
15#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
17pub struct TurnPrompt {
18 pub attachments: Vec<TurnPromptAttachment>,
20 pub text: String,
22 #[serde(default)]
24 pub text_source: TurnPromptTextSource,
25}
26
27#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
29pub enum TurnPromptTextSource {
30 #[default]
33 UserPrompt,
34 AgentData,
37}
38
39#[derive(Debug, Clone, Copy, Eq, PartialEq)]
41pub enum TurnPromptContentPart<'prompt> {
42 Attachment(&'prompt TurnPromptAttachment),
44 OrphanAttachment(&'prompt TurnPromptAttachment),
46 Text(&'prompt str),
48}
49
50impl TurnPrompt {
51 #[must_use]
53 pub fn from_text(text: String) -> Self {
54 Self {
55 attachments: Vec::new(),
56 text,
57 text_source: TurnPromptTextSource::UserPrompt,
58 }
59 }
60
61 #[must_use]
64 pub fn from_agent_data(text: String) -> Self {
65 Self {
66 attachments: Vec::new(),
67 text,
68 text_source: TurnPromptTextSource::AgentData,
69 }
70 }
71
72 #[must_use]
74 pub fn is_empty(&self) -> bool {
75 self.text.is_empty() && self.attachments.is_empty()
76 }
77
78 #[must_use]
80 pub fn has_attachments(&self) -> bool {
81 !self.attachments.is_empty()
82 }
83
84 pub fn local_image_paths(&self) -> impl Iterator<Item = &PathBuf> {
86 self.attachments
87 .iter()
88 .map(|attachment| &attachment.local_image_path)
89 }
90
91 #[must_use]
93 pub fn contains(&self, needle: &str) -> bool {
94 self.text.contains(needle)
95 }
96
97 #[must_use]
99 pub fn ends_with(&self, suffix: &str) -> bool {
100 self.text.ends_with(suffix)
101 }
102
103 #[must_use]
109 pub fn agent_text(&self) -> String {
110 match self.text_source {
111 TurnPromptTextSource::UserPrompt => render_prompt_text_for_agent(&self.text),
112 TurnPromptTextSource::AgentData => self.text.clone(),
113 }
114 }
115
116 #[must_use]
123 pub fn content_parts(&self) -> Vec<TurnPromptContentPart<'_>> {
124 split_turn_prompt_content(&self.text, &self.attachments)
125 }
126
127 #[must_use]
135 pub fn transcript_text(&self) -> String {
136 let mut transcript_text = self.text.clone();
137 let missing_placeholders = self
138 .attachments
139 .iter()
140 .filter(|attachment| !self.text.contains(&attachment.placeholder))
141 .map(|attachment| attachment.placeholder.as_str())
142 .collect::<Vec<_>>();
143
144 if missing_placeholders.is_empty() {
145 return transcript_text;
146 }
147
148 if transcript_text
149 .chars()
150 .last()
151 .is_some_and(|character| !character.is_whitespace())
152 {
153 transcript_text.push(' ');
154 }
155
156 transcript_text.push_str(&missing_placeholders.join(" "));
157
158 transcript_text
159 }
160}
161
162#[must_use]
165pub fn split_turn_prompt_content<'prompt>(
166 text: &'prompt str,
167 attachments: &'prompt [TurnPromptAttachment],
168) -> Vec<TurnPromptContentPart<'prompt>> {
169 if attachments.is_empty() {
170 return vec![TurnPromptContentPart::Text(text)];
171 }
172
173 let mut ordered_attachments = attachments.iter().collect::<Vec<_>>();
174 ordered_attachments
175 .sort_by_key(|attachment| text.find(&attachment.placeholder).unwrap_or(usize::MAX));
178
179 let mut content_parts = Vec::new();
180 let mut orphan_attachments = Vec::new();
181 let mut remaining_text = text;
182
183 for attachment in ordered_attachments {
184 if let Some(placeholder_index) = remaining_text.find(&attachment.placeholder) {
185 let (before_placeholder, after_placeholder) =
186 remaining_text.split_at(placeholder_index);
187
188 if !before_placeholder.is_empty() {
189 content_parts.push(TurnPromptContentPart::Text(before_placeholder));
190 }
191
192 content_parts.push(TurnPromptContentPart::Attachment(attachment));
193 remaining_text = &after_placeholder[attachment.placeholder.len()..];
194
195 continue;
196 }
197
198 orphan_attachments.push(attachment);
199 }
200
201 if !remaining_text.is_empty() {
202 content_parts.push(TurnPromptContentPart::Text(remaining_text));
203 }
204
205 content_parts.extend(
206 orphan_attachments
207 .into_iter()
208 .map(TurnPromptContentPart::OrphanAttachment),
209 );
210
211 content_parts
212}
213
214#[must_use]
221pub fn render_prompt_text_for_agent(text: &str) -> String {
222 let characters = text.chars().collect::<Vec<char>>();
223 let mut output = String::with_capacity(text.len());
224 let mut index = 0;
225
226 while let Some(&character) = characters.get(index) {
227 if character != '@'
228 || !is_at_mention_boundary(characters.get(index.wrapping_sub(1)).copied())
229 || index + 1 >= characters.len()
230 {
231 output.push(character);
232 index += 1;
233
234 continue;
235 }
236
237 let mut scan_index = index + 1;
238 while scan_index < characters.len() && is_at_mention_query_character(characters[scan_index])
239 {
240 scan_index += 1;
241 }
242
243 if scan_index == index + 1 {
244 output.push(character);
245 index += 1;
246
247 continue;
248 }
249
250 output.push('"');
251 output.extend(characters[index + 1..scan_index].iter());
252 output.push('"');
253 index = scan_index;
254 }
255
256 output
257}
258
259fn is_at_mention_boundary(previous_character: Option<char>) -> bool {
261 previous_character.is_none_or(|character| {
262 character.is_whitespace() || is_at_mention_opening_delimiter(character)
263 })
264}
265
266fn is_at_mention_query_character(character: char) -> bool {
268 character.is_alphanumeric() || matches!(character, '/' | '.' | '_' | '-')
269}
270
271fn is_at_mention_opening_delimiter(character: char) -> bool {
273 matches!(character, '(' | '[' | '{')
274}
275
276impl From<String> for TurnPrompt {
277 fn from(text: String) -> Self {
278 Self::from_text(text)
279 }
280}
281
282impl From<&str> for TurnPrompt {
283 fn from(text: &str) -> Self {
284 Self::from_text(text.to_string())
285 }
286}
287
288impl From<&TurnPrompt> for TurnPrompt {
289 fn from(prompt: &TurnPrompt) -> Self {
290 prompt.clone()
291 }
292}
293
294impl fmt::Display for TurnPrompt {
295 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
296 formatter.write_str(&self.text)
297 }
298}
299
300impl PartialEq<&str> for TurnPrompt {
301 fn eq(&self, other: &&str) -> bool {
302 self.text == *other
303 }
304}
305
306impl PartialEq<TurnPrompt> for &str {
307 fn eq(&self, other: &TurnPrompt) -> bool {
308 *self == other.text
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use std::path::PathBuf;
315
316 use super::*;
317
318 #[test]
319 fn test_turn_prompt_transcript_text_keeps_inline_placeholders() {
321 let prompt = TurnPrompt {
323 attachments: vec![TurnPromptAttachment {
324 placeholder: "[Image #1]".to_string(),
325 local_image_path: PathBuf::from("/tmp/image-1.png"),
326 }],
327 text: "Review [Image #1] carefully".to_string(),
328 text_source: TurnPromptTextSource::UserPrompt,
329 };
330
331 let transcript_text = prompt.transcript_text();
333
334 assert_eq!(transcript_text, "Review [Image #1] carefully");
336 }
337
338 #[test]
339 fn test_turn_prompt_agent_text_rewrites_user_at_lookups() {
342 let prompt = TurnPrompt::from("Review @src/main.rs and person@example.com");
344
345 let agent_text = prompt.agent_text();
347 let transcript_text = prompt.transcript_text();
348
349 assert_eq!(agent_text, "Review \"src/main.rs\" and person@example.com");
351 assert_eq!(
352 transcript_text,
353 "Review @src/main.rs and person@example.com"
354 );
355 }
356
357 #[test]
358 fn test_turn_prompt_agent_text_preserves_agent_data_at_tokens() {
360 let prompt = TurnPrompt::from_agent_data(
362 "Diff:\n```diff\n+@dataclass\n+class Config:\n+ pass\n```".to_string(),
363 );
364
365 let agent_text = prompt.agent_text();
367
368 assert!(agent_text.contains("+@dataclass"));
370 assert!(!agent_text.contains("+\"dataclass\""));
371 }
372
373 #[test]
374 fn test_turn_prompt_transcript_text_appends_missing_placeholders() {
377 let prompt = TurnPrompt {
379 attachments: vec![
380 TurnPromptAttachment {
381 placeholder: "[Image #1]".to_string(),
382 local_image_path: PathBuf::from("/tmp/image-1.png"),
383 },
384 TurnPromptAttachment {
385 placeholder: "[Image #2]".to_string(),
386 local_image_path: PathBuf::from("/tmp/image-2.png"),
387 },
388 ],
389 text: "Review".to_string(),
390 text_source: TurnPromptTextSource::UserPrompt,
391 };
392
393 let transcript_text = prompt.transcript_text();
395
396 assert_eq!(transcript_text, "Review [Image #1] [Image #2]");
398 }
399
400 #[test]
401 fn test_split_turn_prompt_content_orders_placeholders_and_appends_orphans() {
404 let attachments = vec![
406 TurnPromptAttachment {
407 placeholder: "[Image #1]".to_string(),
408 local_image_path: PathBuf::from("/tmp/image-1.png"),
409 },
410 TurnPromptAttachment {
411 placeholder: "[Image #2]".to_string(),
412 local_image_path: PathBuf::from("/tmp/image-2.png"),
413 },
414 TurnPromptAttachment {
415 placeholder: "[Image #3]".to_string(),
416 local_image_path: PathBuf::from("/tmp/image-3.png"),
417 },
418 ];
419
420 let content_parts =
422 split_turn_prompt_content("Compare [Image #2] with [Image #1] now", &attachments);
423
424 assert_eq!(
426 content_parts,
427 vec![
428 TurnPromptContentPart::Text("Compare "),
429 TurnPromptContentPart::Attachment(&attachments[1]),
430 TurnPromptContentPart::Text(" with "),
431 TurnPromptContentPart::Attachment(&attachments[0]),
432 TurnPromptContentPart::Text(" now"),
433 TurnPromptContentPart::OrphanAttachment(&attachments[2]),
434 ]
435 );
436 }
437}