use std::fmt;
use std::path::PathBuf;
#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct TurnPromptAttachment {
pub placeholder: String,
pub local_image_path: PathBuf,
}
#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct TurnPrompt {
pub attachments: Vec<TurnPromptAttachment>,
pub text: String,
#[serde(default)]
pub text_source: TurnPromptTextSource,
}
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum TurnPromptTextSource {
#[default]
UserPrompt,
AgentData,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum TurnPromptContentPart<'prompt> {
Attachment(&'prompt TurnPromptAttachment),
OrphanAttachment(&'prompt TurnPromptAttachment),
Text(&'prompt str),
}
impl TurnPrompt {
#[must_use]
pub fn from_text(text: String) -> Self {
Self {
attachments: Vec::new(),
text,
text_source: TurnPromptTextSource::UserPrompt,
}
}
#[must_use]
pub fn from_agent_data(text: String) -> Self {
Self {
attachments: Vec::new(),
text,
text_source: TurnPromptTextSource::AgentData,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.text.is_empty() && self.attachments.is_empty()
}
#[must_use]
pub fn has_attachments(&self) -> bool {
!self.attachments.is_empty()
}
pub fn local_image_paths(&self) -> impl Iterator<Item = &PathBuf> {
self.attachments
.iter()
.map(|attachment| &attachment.local_image_path)
}
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
self.text.contains(needle)
}
#[must_use]
pub fn ends_with(&self, suffix: &str) -> bool {
self.text.ends_with(suffix)
}
#[must_use]
pub fn agent_text(&self) -> String {
match self.text_source {
TurnPromptTextSource::UserPrompt => render_prompt_text_for_agent(&self.text),
TurnPromptTextSource::AgentData => self.text.clone(),
}
}
#[must_use]
pub fn content_parts(&self) -> Vec<TurnPromptContentPart<'_>> {
split_turn_prompt_content(&self.text, &self.attachments)
}
#[must_use]
pub fn transcript_text(&self) -> String {
let mut transcript_text = self.text.clone();
let missing_placeholders = self
.attachments
.iter()
.filter(|attachment| !self.text.contains(&attachment.placeholder))
.map(|attachment| attachment.placeholder.as_str())
.collect::<Vec<_>>();
if missing_placeholders.is_empty() {
return transcript_text;
}
if transcript_text
.chars()
.last()
.is_some_and(|character| !character.is_whitespace())
{
transcript_text.push(' ');
}
transcript_text.push_str(&missing_placeholders.join(" "));
transcript_text
}
}
#[must_use]
pub fn split_turn_prompt_content<'prompt>(
text: &'prompt str,
attachments: &'prompt [TurnPromptAttachment],
) -> Vec<TurnPromptContentPart<'prompt>> {
if attachments.is_empty() {
return vec![TurnPromptContentPart::Text(text)];
}
let mut ordered_attachments = attachments.iter().collect::<Vec<_>>();
ordered_attachments
.sort_by_key(|attachment| text.find(&attachment.placeholder).unwrap_or(usize::MAX));
let mut content_parts = Vec::new();
let mut orphan_attachments = Vec::new();
let mut remaining_text = text;
for attachment in ordered_attachments {
if let Some(placeholder_index) = remaining_text.find(&attachment.placeholder) {
let (before_placeholder, after_placeholder) =
remaining_text.split_at(placeholder_index);
if !before_placeholder.is_empty() {
content_parts.push(TurnPromptContentPart::Text(before_placeholder));
}
content_parts.push(TurnPromptContentPart::Attachment(attachment));
remaining_text = &after_placeholder[attachment.placeholder.len()..];
continue;
}
orphan_attachments.push(attachment);
}
if !remaining_text.is_empty() {
content_parts.push(TurnPromptContentPart::Text(remaining_text));
}
content_parts.extend(
orphan_attachments
.into_iter()
.map(TurnPromptContentPart::OrphanAttachment),
);
content_parts
}
#[must_use]
pub fn render_prompt_text_for_agent(text: &str) -> String {
let characters = text.chars().collect::<Vec<char>>();
let mut output = String::with_capacity(text.len());
let mut index = 0;
while let Some(&character) = characters.get(index) {
if character != '@'
|| !is_at_mention_boundary(characters.get(index.wrapping_sub(1)).copied())
|| index + 1 >= characters.len()
{
output.push(character);
index += 1;
continue;
}
let mut scan_index = index + 1;
while scan_index < characters.len() && is_at_mention_query_character(characters[scan_index])
{
scan_index += 1;
}
if scan_index == index + 1 {
output.push(character);
index += 1;
continue;
}
output.push('"');
output.extend(characters[index + 1..scan_index].iter());
output.push('"');
index = scan_index;
}
output
}
fn is_at_mention_boundary(previous_character: Option<char>) -> bool {
previous_character.is_none_or(|character| {
character.is_whitespace() || is_at_mention_opening_delimiter(character)
})
}
fn is_at_mention_query_character(character: char) -> bool {
character.is_alphanumeric() || matches!(character, '/' | '.' | '_' | '-')
}
fn is_at_mention_opening_delimiter(character: char) -> bool {
matches!(character, '(' | '[' | '{')
}
impl From<String> for TurnPrompt {
fn from(text: String) -> Self {
Self::from_text(text)
}
}
impl From<&str> for TurnPrompt {
fn from(text: &str) -> Self {
Self::from_text(text.to_string())
}
}
impl From<&TurnPrompt> for TurnPrompt {
fn from(prompt: &TurnPrompt) -> Self {
prompt.clone()
}
}
impl fmt::Display for TurnPrompt {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.text)
}
}
impl PartialEq<&str> for TurnPrompt {
fn eq(&self, other: &&str) -> bool {
self.text == *other
}
}
impl PartialEq<TurnPrompt> for &str {
fn eq(&self, other: &TurnPrompt) -> bool {
*self == other.text
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn test_turn_prompt_transcript_text_keeps_inline_placeholders() {
let prompt = TurnPrompt {
attachments: vec![TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/image-1.png"),
}],
text: "Review [Image #1] carefully".to_string(),
text_source: TurnPromptTextSource::UserPrompt,
};
let transcript_text = prompt.transcript_text();
assert_eq!(transcript_text, "Review [Image #1] carefully");
}
#[test]
fn test_turn_prompt_agent_text_rewrites_user_at_lookups() {
let prompt = TurnPrompt::from("Review @src/main.rs and person@example.com");
let agent_text = prompt.agent_text();
let transcript_text = prompt.transcript_text();
assert_eq!(agent_text, "Review \"src/main.rs\" and person@example.com");
assert_eq!(
transcript_text,
"Review @src/main.rs and person@example.com"
);
}
#[test]
fn test_turn_prompt_agent_text_preserves_agent_data_at_tokens() {
let prompt = TurnPrompt::from_agent_data(
"Diff:\n```diff\n+@dataclass\n+class Config:\n+ pass\n```".to_string(),
);
let agent_text = prompt.agent_text();
assert!(agent_text.contains("+@dataclass"));
assert!(!agent_text.contains("+\"dataclass\""));
}
#[test]
fn test_turn_prompt_transcript_text_appends_missing_placeholders() {
let prompt = TurnPrompt {
attachments: vec![
TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/image-1.png"),
},
TurnPromptAttachment {
placeholder: "[Image #2]".to_string(),
local_image_path: PathBuf::from("/tmp/image-2.png"),
},
],
text: "Review".to_string(),
text_source: TurnPromptTextSource::UserPrompt,
};
let transcript_text = prompt.transcript_text();
assert_eq!(transcript_text, "Review [Image #1] [Image #2]");
}
#[test]
fn test_split_turn_prompt_content_orders_placeholders_and_appends_orphans() {
let attachments = vec![
TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/image-1.png"),
},
TurnPromptAttachment {
placeholder: "[Image #2]".to_string(),
local_image_path: PathBuf::from("/tmp/image-2.png"),
},
TurnPromptAttachment {
placeholder: "[Image #3]".to_string(),
local_image_path: PathBuf::from("/tmp/image-3.png"),
},
];
let content_parts =
split_turn_prompt_content("Compare [Image #2] with [Image #1] now", &attachments);
assert_eq!(
content_parts,
vec![
TurnPromptContentPart::Text("Compare "),
TurnPromptContentPart::Attachment(&attachments[1]),
TurnPromptContentPart::Text(" with "),
TurnPromptContentPart::Attachment(&attachments[0]),
TurnPromptContentPart::Text(" now"),
TurnPromptContentPart::OrphanAttachment(&attachments[2]),
]
);
}
}