llm_toolkit/
prompt.rs

1//! A trait and macros for powerful, type-safe prompt generation.
2
3use minijinja::Environment;
4use serde::Serialize;
5
6/// Represents a part of a multimodal prompt.
7///
8/// This enum allows prompts to contain different types of content,
9/// such as text and images, enabling multimodal LLM interactions.
10#[derive(Debug, Clone)]
11pub enum PromptPart {
12    /// Text content in the prompt.
13    Text(String),
14    /// Image content with media type and binary data.
15    Image {
16        /// The MIME media type (e.g., "image/jpeg", "image/png").
17        media_type: String,
18        /// The raw image data.
19        data: Vec<u8>,
20    },
21    // Future variants like Audio or Video can be added here
22}
23
24/// A trait for converting any type into a string suitable for an LLM prompt.
25///
26/// This trait provides a standard interface for converting various types
27/// into strings that can be used as prompts for language models.
28///
29/// # Example
30///
31/// ```
32/// use llm_toolkit::prompt::ToPrompt;
33///
34/// // Common types have ToPrompt implementations
35/// let number = 42;
36/// assert_eq!(number.to_prompt(), "42");
37///
38/// let text = "Hello, LLM!";
39/// assert_eq!(text.to_prompt(), "Hello, LLM!");
40/// ```
41///
42/// # Custom Implementation
43///
44/// You can also implement `ToPrompt` directly for your own types:
45///
46/// ```
47/// use llm_toolkit::prompt::{ToPrompt, PromptPart};
48/// use std::fmt;
49///
50/// struct CustomType {
51///     value: String,
52/// }
53///
54/// impl fmt::Display for CustomType {
55///     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56///         write!(f, "{}", self.value)
57///     }
58/// }
59///
60/// // By implementing ToPrompt directly, you can control the conversion.
61/// impl ToPrompt for CustomType {
62///     fn to_prompt_parts(&self) -> Vec<PromptPart> {
63///         vec![PromptPart::Text(self.to_string())]
64///     }
65///
66///     fn to_prompt(&self) -> String {
67///         self.to_string()
68///     }
69/// }
70///
71/// let custom = CustomType { value: "custom".to_string() };
72/// assert_eq!(custom.to_prompt(), "custom");
73/// ```
74pub trait ToPrompt {
75    /// Converts the object into a vector of `PromptPart`s.
76    ///
77    /// This method enables multimodal prompt generation by returning
78    /// a collection of prompt parts that can include text, images, and
79    /// other media types.
80    fn to_prompt_parts(&self) -> Vec<PromptPart>;
81
82    /// Converts the object into a prompt string.
83    ///
84    /// This method provides backward compatibility by extracting only
85    /// the text portions from `to_prompt_parts()` and joining them.
86    fn to_prompt(&self) -> String {
87        self.to_prompt_parts()
88            .iter()
89            .filter_map(|part| match part {
90                PromptPart::Text(text) => Some(text.as_str()),
91                _ => None,
92            })
93            .collect::<Vec<_>>()
94            .join("")
95    }
96}
97
98// Add implementations for common types
99
100impl ToPrompt for String {
101    fn to_prompt_parts(&self) -> Vec<PromptPart> {
102        vec![PromptPart::Text(self.clone())]
103    }
104
105    fn to_prompt(&self) -> String {
106        self.clone()
107    }
108}
109
110impl ToPrompt for &str {
111    fn to_prompt_parts(&self) -> Vec<PromptPart> {
112        vec![PromptPart::Text(self.to_string())]
113    }
114
115    fn to_prompt(&self) -> String {
116        self.to_string()
117    }
118}
119
120impl ToPrompt for bool {
121    fn to_prompt_parts(&self) -> Vec<PromptPart> {
122        vec![PromptPart::Text(self.to_string())]
123    }
124
125    fn to_prompt(&self) -> String {
126        self.to_string()
127    }
128}
129
130impl ToPrompt for char {
131    fn to_prompt_parts(&self) -> Vec<PromptPart> {
132        vec![PromptPart::Text(self.to_string())]
133    }
134
135    fn to_prompt(&self) -> String {
136        self.to_string()
137    }
138}
139
140macro_rules! impl_to_prompt_for_numbers {
141    ($($t:ty),*) => {
142        $(
143            impl ToPrompt for $t {
144                fn to_prompt_parts(&self) -> Vec<PromptPart> {
145                    vec![PromptPart::Text(self.to_string())]
146                }
147
148                fn to_prompt(&self) -> String {
149                    self.to_string()
150                }
151            }
152        )*
153    };
154}
155
156impl_to_prompt_for_numbers!(
157    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64
158);
159
160/// Renders a prompt from a template string and a serializable context.
161///
162/// This is the underlying function for the `prompt!` macro.
163pub fn render_prompt<T: Serialize>(template: &str, context: T) -> Result<String, minijinja::Error> {
164    let mut env = Environment::new();
165    env.add_template("prompt", template)?;
166    let tmpl = env.get_template("prompt")?;
167    tmpl.render(context)
168}
169
170/// Creates a prompt string from a template and key-value pairs.
171///
172/// This macro provides a `println!`-like experience for building prompts
173/// from various data sources. It leverages `minijinja` for templating.
174///
175/// # Example
176///
177/// ```
178/// use llm_toolkit::prompt;
179/// use serde::Serialize;
180///
181/// #[derive(Serialize)]
182/// struct User {
183///     name: &'static str,
184///     role: &'static str,
185/// }
186///
187/// let user = User { name: "Mai", role: "UX Engineer" };
188/// let task = "designing a new macro";
189///
190/// let p = prompt!(
191///     "User {{user.name}} ({{user.role}}) is currently {{task}}.",
192///     user = user,
193///     task = task
194/// ).unwrap();
195///
196/// assert_eq!(p, "User Mai (UX Engineer) is currently designing a new macro.");
197/// ```
198#[macro_export]
199macro_rules! prompt {
200    ($template:expr, $($key:ident = $value:expr),* $(,)?) => {
201        $crate::prompt::render_prompt($template, minijinja::context!($($key => $value),*))
202    };
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use serde::Serialize;
209    use std::fmt::Display;
210
211    enum TestEnum {
212        VariantA,
213        VariantB,
214    }
215
216    impl Display for TestEnum {
217        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218            match self {
219                TestEnum::VariantA => write!(f, "Variant A"),
220                TestEnum::VariantB => write!(f, "Variant B"),
221            }
222        }
223    }
224
225    impl ToPrompt for TestEnum {
226        fn to_prompt_parts(&self) -> Vec<PromptPart> {
227            vec![PromptPart::Text(self.to_string())]
228        }
229
230        fn to_prompt(&self) -> String {
231            self.to_string()
232        }
233    }
234
235    #[test]
236    fn test_to_prompt_for_enum() {
237        let variant = TestEnum::VariantA;
238        assert_eq!(variant.to_prompt(), "Variant A");
239    }
240
241    #[test]
242    fn test_to_prompt_for_enum_variant_b() {
243        let variant = TestEnum::VariantB;
244        assert_eq!(variant.to_prompt(), "Variant B");
245    }
246
247    #[test]
248    fn test_to_prompt_for_string() {
249        let s = "hello world";
250        assert_eq!(s.to_prompt(), "hello world");
251    }
252
253    #[test]
254    fn test_to_prompt_for_number() {
255        let n = 42;
256        assert_eq!(n.to_prompt(), "42");
257    }
258
259    #[derive(Serialize)]
260    struct SystemInfo {
261        version: &'static str,
262        os: &'static str,
263    }
264
265    #[test]
266    fn test_prompt_macro_simple() {
267        let user = "Yui";
268        let task = "implementation";
269        let prompt = prompt!(
270            "User {{user}} is working on the {{task}}.",
271            user = user,
272            task = task
273        )
274        .unwrap();
275        assert_eq!(prompt, "User Yui is working on the implementation.");
276    }
277
278    #[test]
279    fn test_prompt_macro_with_struct() {
280        let sys = SystemInfo {
281            version: "0.1.0",
282            os: "Rust",
283        };
284        let prompt = prompt!("System: {{sys.version}} on {{sys.os}}", sys = sys).unwrap();
285        assert_eq!(prompt, "System: 0.1.0 on Rust");
286    }
287
288    #[test]
289    fn test_prompt_macro_mixed() {
290        let user = "Mai";
291        let sys = SystemInfo {
292            version: "0.1.0",
293            os: "Rust",
294        };
295        let prompt = prompt!(
296            "User {{user}} is using {{sys.os}} v{{sys.version}}.",
297            user = user,
298            sys = sys
299        )
300        .unwrap();
301        assert_eq!(prompt, "User Mai is using Rust v0.1.0.");
302    }
303
304    #[test]
305    fn test_prompt_macro_no_args() {
306        let prompt = prompt!("This is a static prompt.",).unwrap();
307        assert_eq!(prompt, "This is a static prompt.");
308    }
309}
310
311#[derive(Debug, thiserror::Error)]
312pub enum PromptSetError {
313    #[error("Target '{target}' not found. Available targets: {available:?}")]
314    TargetNotFound {
315        target: String,
316        available: Vec<String>,
317    },
318    #[error("Failed to render prompt for target '{target}': {source}")]
319    RenderFailed {
320        target: String,
321        source: minijinja::Error,
322    },
323}
324
325/// A trait for types that can generate multiple named prompt targets.
326///
327/// This trait enables a single data structure to produce different prompt formats
328/// for various use cases (e.g., human-readable vs. machine-parsable formats).
329///
330/// # Example
331///
332/// ```ignore
333/// use llm_toolkit::prompt::{ToPromptSet, PromptPart};
334/// use serde::Serialize;
335///
336/// #[derive(ToPromptSet, Serialize)]
337/// #[prompt_for(name = "Visual", template = "## {{title}}\n\n> {{description}}")]
338/// struct Task {
339///     title: String,
340///     description: String,
341///
342///     #[prompt_for(name = "Agent")]
343///     priority: u8,
344///
345///     #[prompt_for(name = "Agent", rename = "internal_id")]
346///     id: u64,
347///
348///     #[prompt_for(skip)]
349///     is_dirty: bool,
350/// }
351///
352/// let task = Task {
353///     title: "Implement feature".to_string(),
354///     description: "Add new functionality".to_string(),
355///     priority: 1,
356///     id: 42,
357///     is_dirty: false,
358/// };
359///
360/// // Generate visual prompt
361/// let visual_prompt = task.to_prompt_for("Visual")?;
362///
363/// // Generate agent prompt
364/// let agent_prompt = task.to_prompt_for("Agent")?;
365/// ```
366pub trait ToPromptSet {
367    /// Generates multimodal prompt parts for the specified target.
368    fn to_prompt_parts_for(&self, target: &str) -> Result<Vec<PromptPart>, PromptSetError>;
369
370    /// Generates a text prompt for the specified target.
371    ///
372    /// This method extracts only the text portions from `to_prompt_parts_for()`
373    /// and joins them together.
374    fn to_prompt_for(&self, target: &str) -> Result<String, PromptSetError> {
375        let parts = self.to_prompt_parts_for(target)?;
376        let text = parts
377            .iter()
378            .filter_map(|part| match part {
379                PromptPart::Text(text) => Some(text.as_str()),
380                _ => None,
381            })
382            .collect::<Vec<_>>()
383            .join("\n");
384        Ok(text)
385    }
386}