gemini_adk_fluent_rs/compose/
prompt.rs

1//! P — Prompt composition.
2//!
3//! Compose prompt sections additively with `+`.
4
5/// Adapter rewriting a rendered prompt section.
6pub type AdapterFn = std::sync::Arc<dyn Fn(&str) -> String + Send + Sync>;
7
8/// A section of a prompt.
9#[derive(Clone)]
10pub struct PromptSection {
11    /// The semantic category of this section.
12    pub kind: PromptSectionKind,
13    /// The text content of this section.
14    pub content: String,
15    /// Optional name for this section (used for name-based filtering/reordering).
16    pub name: Option<String>,
17    /// Optional adapter function for adaptive prompts.
18    pub adapter: Option<AdapterFn>,
19}
20
21impl std::fmt::Debug for PromptSection {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        f.debug_struct("PromptSection")
24            .field("kind", &self.kind)
25            .field("content", &self.content)
26            .field("name", &self.name)
27            .field(
28                "adapter",
29                &self.adapter.as_ref().map(|_| "Fn(&str) -> String"),
30            )
31            .finish()
32    }
33}
34
35/// The semantic category of a prompt section.
36#[derive(Clone, Debug, PartialEq)]
37pub enum PromptSectionKind {
38    /// Agent role definition (e.g., "You are ...").
39    Role,
40    /// Task description (e.g., "Your task: ...").
41    Task,
42    /// Behavioral constraint (e.g., "Constraint: ...").
43    Constraint,
44    /// Output format specification.
45    Format,
46    /// Input/output example.
47    Example,
48    /// Free-form text.
49    Text,
50    /// Background context.
51    Context,
52    /// Personality or persona description.
53    Persona,
54    /// Bulleted guideline list.
55    Guidelines,
56    /// Step-by-step scaffolded prompt.
57    Scaffolded,
58    /// Versioned prompt section.
59    Versioned,
60    /// Marker indicating the prompt should be compressed.
61    Compressed,
62    /// Adaptive prompt that adjusts based on context.
63    Adaptive,
64}
65
66impl PromptSection {
67    /// Render this section as a formatted string.
68    pub fn render(&self) -> String {
69        match &self.kind {
70            PromptSectionKind::Role => format!("You are {}.", self.content),
71            PromptSectionKind::Task => format!("Your task: {}", self.content),
72            PromptSectionKind::Constraint => format!("Constraint: {}", self.content),
73            PromptSectionKind::Format => format!("Output format: {}", self.content),
74            PromptSectionKind::Example => self.content.clone(),
75            PromptSectionKind::Text => self.content.clone(),
76            PromptSectionKind::Context => format!("Context: {}", self.content),
77            PromptSectionKind::Persona => format!("Persona: {}", self.content),
78            PromptSectionKind::Guidelines => self.content.clone(),
79            PromptSectionKind::Scaffolded => self.content.clone(),
80            PromptSectionKind::Versioned => self.content.clone(),
81            PromptSectionKind::Compressed => compress_text(&self.content),
82            PromptSectionKind::Adaptive => self.content.clone(),
83        }
84    }
85
86    /// Render an adaptive section with a context string.
87    ///
88    /// If this section has an adapter function, invokes it with the given context.
89    /// Otherwise, falls back to the normal `render()`.
90    pub fn render_with_context(&self, ctx: &str) -> String {
91        if let Some(adapter) = &self.adapter {
92            adapter(ctx)
93        } else {
94            self.render()
95        }
96    }
97
98    /// Attach an adapter function to this section (builder pattern).
99    pub fn with_adapter<F>(mut self, f: F) -> Self
100    where
101        F: Fn(&str) -> String + Send + Sync + 'static,
102    {
103        self.adapter = Some(std::sync::Arc::new(f));
104        self
105    }
106}
107
108/// Compose two prompt sections with `+`.
109impl std::ops::Add for PromptSection {
110    type Output = PromptComposite;
111
112    fn add(self, rhs: PromptSection) -> Self::Output {
113        PromptComposite {
114            sections: vec![self, rhs],
115        }
116    }
117}
118
119/// A composed prompt built from multiple sections.
120#[derive(Clone, Debug)]
121pub struct PromptComposite {
122    /// The ordered list of prompt sections.
123    pub sections: Vec<PromptSection>,
124}
125
126impl PromptComposite {
127    /// Render the full prompt by joining all sections.
128    ///
129    /// If a [`P::compress`](super::P::compress) marker is present, the joined
130    /// prompt is run through [`compress_text`] (the marker itself is dropped),
131    /// deterministically shrinking the prompt before it reaches the model.
132    pub fn render(&self) -> String {
133        let compress = self
134            .sections
135            .iter()
136            .any(|s| s.kind == PromptSectionKind::Compressed);
137        let body = self
138            .sections
139            .iter()
140            .filter(|s| s.kind != PromptSectionKind::Compressed)
141            .map(PromptSection::render)
142            .collect::<Vec<_>>()
143            .join("\n\n");
144        if compress { compress_text(&body) } else { body }
145    }
146}
147
148/// Deterministically compress prompt text to reduce tokens without an LLM:
149/// trims each line, collapses internal whitespace runs to single spaces, drops
150/// blank lines, and removes consecutive duplicate lines.
151pub fn compress_text(s: &str) -> String {
152    let mut out: Vec<String> = Vec::new();
153    for line in s.lines() {
154        let collapsed = line.split_whitespace().collect::<Vec<_>>().join(" ");
155        if collapsed.is_empty() {
156            continue;
157        }
158        if out.last().map(std::string::String::as_str) == Some(collapsed.as_str()) {
159            continue;
160        }
161        out.push(collapsed);
162    }
163    out.join("\n")
164}
165
166impl PromptComposite {
167    /// Keep only sections of specified kinds.
168    pub fn only(self, kinds: &[PromptSectionKind]) -> Self {
169        Self {
170            sections: self
171                .sections
172                .into_iter()
173                .filter(|s| kinds.contains(&s.kind))
174                .collect(),
175        }
176    }
177
178    /// Remove sections of specified kinds.
179    pub fn without(self, kinds: &[PromptSectionKind]) -> Self {
180        Self {
181            sections: self
182                .sections
183                .into_iter()
184                .filter(|s| !kinds.contains(&s.kind))
185                .collect(),
186        }
187    }
188
189    /// Reorder sections by kind priority.
190    pub fn reorder(mut self, order: &[PromptSectionKind]) -> Self {
191        self.sections.sort_by_key(|s| {
192            order
193                .iter()
194                .position(|k| k == &s.kind)
195                .unwrap_or(usize::MAX)
196        });
197        self
198    }
199
200    /// Reorder sections by name. Sections matching the given names come first
201    /// (in the specified order); unmatched sections are appended at the end
202    /// in their original order.
203    pub fn reorder_by_name(self, order: &[&str]) -> Self {
204        let order: Vec<&str> = order.to_vec();
205        let mut ordered = Vec::with_capacity(self.sections.len());
206        let mut remaining = self.sections;
207
208        for name in &order {
209            let mut i = 0;
210            while i < remaining.len() {
211                if remaining[i].name.as_deref() == Some(name) {
212                    ordered.push(remaining.remove(i));
213                } else {
214                    i += 1;
215                }
216            }
217        }
218        ordered.extend(remaining);
219        Self { sections: ordered }
220    }
221
222    /// Keep only sections whose names match the given list.
223    pub fn only_by_name(self, names: &[&str]) -> Self {
224        Self {
225            sections: self
226                .sections
227                .into_iter()
228                .filter(|s| {
229                    s.name
230                        .as_deref()
231                        .map(|n| names.contains(&n))
232                        .unwrap_or(false)
233                })
234                .collect(),
235        }
236    }
237
238    /// Remove sections whose names match the given list.
239    pub fn without_by_name(self, names: &[&str]) -> Self {
240        Self {
241            sections: self
242                .sections
243                .into_iter()
244                .filter(|s| {
245                    s.name
246                        .as_deref()
247                        .map(|n| !names.contains(&n))
248                        .unwrap_or(true)
249                })
250                .collect(),
251        }
252    }
253
254    /// Apply a `PromptTransform` to this composite.
255    pub fn apply(self, transform: PromptTransform) -> Self {
256        match transform {
257            PromptTransform::Reorder(order) => {
258                let refs: Vec<&str> = order.iter().map(std::string::String::as_str).collect();
259                self.reorder_by_name(&refs)
260            }
261            PromptTransform::Only(names) => {
262                let refs: Vec<&str> = names.iter().map(std::string::String::as_str).collect();
263                self.only_by_name(&refs)
264            }
265            PromptTransform::Without(names) => {
266                let refs: Vec<&str> = names.iter().map(std::string::String::as_str).collect();
267                self.without_by_name(&refs)
268            }
269        }
270    }
271}
272
273/// A declarative transform that can be applied to a `PromptComposite`.
274///
275/// Created by `P::reorder()`, `P::only()`, and `P::without()`.
276#[derive(Clone, Debug)]
277pub enum PromptTransform {
278    /// Reorder sections by name.
279    Reorder(Vec<String>),
280    /// Keep only sections with these names.
281    Only(Vec<String>),
282    /// Remove sections with these names.
283    Without(Vec<String>),
284}
285
286impl From<PromptComposite> for String {
287    fn from(p: PromptComposite) -> String {
288        p.render()
289    }
290}
291
292impl From<PromptSection> for String {
293    fn from(s: PromptSection) -> String {
294        s.render()
295    }
296}
297
298impl std::ops::Add<PromptSection> for PromptComposite {
299    type Output = PromptComposite;
300
301    fn add(mut self, rhs: PromptSection) -> Self::Output {
302        self.sections.push(rhs);
303        self
304    }
305}
306
307/// The `P` namespace — static factory methods for prompt sections.
308pub struct P;
309
310impl P {
311    /// Define the agent's role.
312    pub fn role(role: &str) -> PromptSection {
313        PromptSection {
314            kind: PromptSectionKind::Role,
315            content: role.to_string(),
316            name: Some("role".to_string()),
317            adapter: None,
318        }
319    }
320
321    /// Define the agent's task.
322    pub fn task(task: &str) -> PromptSection {
323        PromptSection {
324            kind: PromptSectionKind::Task,
325            content: task.to_string(),
326            name: Some("task".to_string()),
327            adapter: None,
328        }
329    }
330
331    /// Add a constraint.
332    pub fn constraint(c: &str) -> PromptSection {
333        PromptSection {
334            kind: PromptSectionKind::Constraint,
335            content: c.to_string(),
336            name: Some("constraint".to_string()),
337            adapter: None,
338        }
339    }
340
341    /// Specify output format.
342    pub fn format(f: &str) -> PromptSection {
343        PromptSection {
344            kind: PromptSectionKind::Format,
345            content: f.to_string(),
346            name: Some("format".to_string()),
347            adapter: None,
348        }
349    }
350
351    /// Add an input/output example.
352    pub fn example(input: &str, output: &str) -> PromptSection {
353        PromptSection {
354            kind: PromptSectionKind::Example,
355            content: format!("Example:\nInput: {input}\nOutput: {output}"),
356            name: Some("example".to_string()),
357            adapter: None,
358        }
359    }
360
361    /// Add free-form text.
362    pub fn text(t: &str) -> PromptSection {
363        PromptSection {
364            kind: PromptSectionKind::Text,
365            content: t.to_string(),
366            name: None,
367            adapter: None,
368        }
369    }
370
371    /// Add background context.
372    pub fn context(ctx: &str) -> PromptSection {
373        PromptSection {
374            kind: PromptSectionKind::Context,
375            content: ctx.to_string(),
376            name: Some("context".to_string()),
377            adapter: None,
378        }
379    }
380
381    /// Define a personality/persona.
382    pub fn persona(desc: &str) -> PromptSection {
383        PromptSection {
384            kind: PromptSectionKind::Persona,
385            content: desc.to_string(),
386            name: Some("persona".to_string()),
387            adapter: None,
388        }
389    }
390
391    /// Add multiple guidelines as a bulleted list.
392    pub fn guidelines(items: &[&str]) -> PromptSection {
393        let content = items
394            .iter()
395            .map(|item| format!("- {item}"))
396            .collect::<Vec<_>>()
397            .join("\n");
398        PromptSection {
399            kind: PromptSectionKind::Guidelines,
400            content: format!("Guidelines:\n{content}"),
401            name: Some("guidelines".to_string()),
402            adapter: None,
403        }
404    }
405
406    /// Add a named section (flexible section kind).
407    pub fn section(name: &str, text: &str) -> PromptSection {
408        PromptSection {
409            kind: PromptSectionKind::Text,
410            content: format!("## {name}\n{text}"),
411            name: Some(name.to_string()),
412            adapter: None,
413        }
414    }
415
416    /// Template with `{key}` placeholders — rendered with state values at runtime.
417    pub fn template(tpl: &str) -> PromptSection {
418        PromptSection {
419            kind: PromptSectionKind::Text,
420            content: tpl.to_string(),
421            name: Some("template".to_string()),
422            adapter: None,
423        }
424    }
425
426    /// Reorder sections in a composite by name.
427    ///
428    /// Sections whose names match the given order come first (in order);
429    /// unmatched sections are appended at the end in their original order.
430    ///
431    /// ```
432    /// # use gemini_adk_fluent_rs::prelude::*;
433    /// let prompt = (P::role("analyst") + P::task("analyze") + P::format("JSON"))
434    ///     .reorder_by_name(&["format", "role", "task"]);
435    /// # let _: String = prompt.into();
436    /// ```
437    pub fn reorder(order: &[&str]) -> PromptTransform {
438        let order: Vec<String> = order.iter().map(std::string::ToString::to_string).collect();
439        PromptTransform::Reorder(order)
440    }
441
442    /// Keep only sections whose names match the given list.
443    ///
444    /// ```
445    /// # use gemini_adk_fluent_rs::prelude::*;
446    /// let prompt = (P::role("analyst") + P::task("analyze") + P::format("JSON"))
447    ///     .only_by_name(&["role", "task"]);
448    /// # let _: String = prompt.into();
449    /// ```
450    pub fn only(names: &[&str]) -> PromptTransform {
451        let names: Vec<String> = names.iter().map(std::string::ToString::to_string).collect();
452        PromptTransform::Only(names)
453    }
454
455    /// Remove sections whose names match the given list.
456    ///
457    /// ```
458    /// # use gemini_adk_fluent_rs::prelude::*;
459    /// let prompt = (P::role("analyst") + P::task("analyze") + P::format("JSON"))
460    ///     .without_by_name(&["format"]);
461    /// # let _: String = prompt.into();
462    /// ```
463    pub fn without(names: &[&str]) -> PromptTransform {
464        let names: Vec<String> = names.iter().map(std::string::ToString::to_string).collect();
465        PromptTransform::Without(names)
466    }
467
468    /// Add a compression marker to a prompt. When present in a composite, the
469    /// whole rendered prompt is run through [`compress_text`] — trimming lines,
470    /// collapsing whitespace, and dropping blank/duplicate lines — to reduce
471    /// tokens deterministically (no LLM call) before it reaches the model.
472    pub fn compress() -> PromptSection {
473        PromptSection {
474            kind: PromptSectionKind::Compressed,
475            content: String::new(),
476            name: Some("compress".to_string()),
477            adapter: None,
478        }
479    }
480
481    /// Create an adaptive prompt that adjusts based on a context function.
482    ///
483    /// The function receives context (e.g., token budget, turn count) and returns
484    /// the adapted prompt text.
485    ///
486    /// ```
487    /// # use gemini_adk_fluent_rs::prelude::*;
488    /// let prompt = P::adapt(|ctx| {
489    ///     if ctx.contains("detailed") {
490    ///         "Provide a thorough analysis with citations.".to_string()
491    ///     } else {
492    ///         "Be concise.".to_string()
493    ///     }
494    /// });
495    /// ```
496    pub fn adapt<F>(f: F) -> PromptSection
497    where
498        F: Fn(&str) -> String + Send + Sync + 'static,
499    {
500        PromptSection {
501            kind: PromptSectionKind::Adaptive,
502            content: String::new(),
503            name: Some("adapt".to_string()),
504            adapter: None,
505        }
506        .with_adapter(f)
507    }
508
509    /// Create a step-by-step scaffolded prompt from ordered steps.
510    ///
511    /// ```
512    /// # use gemini_adk_fluent_rs::prelude::*;
513    /// let prompt = P::scaffolded(&["Identify the problem", "Gather data", "Analyze", "Conclude"]);
514    /// # let _ = prompt;
515    /// ```
516    pub fn scaffolded(steps: &[&str]) -> PromptSection {
517        let content = steps
518            .iter()
519            .enumerate()
520            .map(|(i, step)| format!("Step {}: {step}", i + 1))
521            .collect::<Vec<_>>()
522            .join("\n");
523        PromptSection {
524            kind: PromptSectionKind::Scaffolded,
525            content: format!("Follow these steps:\n{content}"),
526            name: Some("scaffolded".to_string()),
527            adapter: None,
528        }
529    }
530
531    /// Create a versioned prompt section with a version tag.
532    ///
533    /// ```
534    /// # use gemini_adk_fluent_rs::prelude::*;
535    /// let prompt = P::versioned("v2.1", "Analyze the data using the new methodology");
536    /// # let _ = prompt;
537    /// ```
538    pub fn versioned(version: &str, text: &str) -> PromptSection {
539        PromptSection {
540            kind: PromptSectionKind::Versioned,
541            content: format!("[{version}] {text}"),
542            name: Some(format!("versioned:{version}")),
543            adapter: None,
544        }
545    }
546
547    // ── Instruction modifier factories ──────────────────────────────────────
548    // Bridge P-module composition to the InstructionModifier system.
549
550    /// Create a modifier that shows the selected state keys to the model by
551    /// rendering them into the instruction.
552    ///
553    /// ```
554    /// # use gemini_adk_fluent_rs::prelude::*;
555    /// let modifier = P::show_state(&["emotional_state", "willingness_to_pay"]);
556    /// ```
557    pub fn show_state(keys: &[&str]) -> gemini_adk_rs::live::InstructionModifier {
558        gemini_adk_rs::live::InstructionModifier::StateAppend(
559            keys.iter().map(std::string::ToString::to_string).collect(),
560        )
561    }
562
563    /// Create a conditional modifier that appends text when the predicate is true.
564    ///
565    /// ```
566    /// # use gemini_adk_fluent_rs::prelude::*;
567    /// # fn risk_is_elevated(s: &State) -> bool { s.get::<String>("risk").unwrap_or_default() == "high" }
568    /// let risk_mod = P::when(risk_is_elevated, "IMPORTANT: Show extra empathy.");
569    /// # let _ = risk_mod;
570    /// ```
571    pub fn when(
572        predicate: impl Fn(&gemini_adk_rs::State) -> bool + Send + Sync + 'static,
573        text: impl Into<String>,
574    ) -> gemini_adk_rs::live::InstructionModifier {
575        gemini_adk_rs::live::InstructionModifier::Conditional {
576            predicate: std::sync::Arc::new(predicate),
577            text: text.into(),
578        }
579    }
580
581    /// Create a custom-append modifier from a formatting function.
582    ///
583    /// ```
584    /// # use gemini_adk_fluent_rs::prelude::*;
585    /// let ctx = P::context_fn(|s| format!("Customer: {}", s.get::<String>("name").unwrap_or_default()));
586    /// # let _ = ctx;
587    /// ```
588    pub fn context_fn(
589        f: impl Fn(&gemini_adk_rs::State) -> String + Send + Sync + 'static,
590    ) -> gemini_adk_rs::live::InstructionModifier {
591        gemini_adk_rs::live::InstructionModifier::CustomAppend(std::sync::Arc::new(f))
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn role_renders() {
601        let s = P::role("analyst");
602        assert_eq!(s.render(), "You are analyst.");
603    }
604
605    #[test]
606    fn task_renders() {
607        let s = P::task("analyze data");
608        assert_eq!(s.render(), "Your task: analyze data");
609    }
610
611    #[test]
612    fn constraint_renders() {
613        let s = P::constraint("be concise");
614        assert_eq!(s.render(), "Constraint: be concise");
615    }
616
617    #[test]
618    fn format_renders() {
619        let s = P::format("JSON");
620        assert_eq!(s.render(), "Output format: JSON");
621    }
622
623    #[test]
624    fn example_renders() {
625        let s = P::example("hello", "world");
626        assert!(s.render().contains("Input: hello"));
627        assert!(s.render().contains("Output: world"));
628    }
629
630    #[test]
631    fn compose_with_add() {
632        let prompt = P::role("analyst") + P::task("analyze data") + P::format("JSON");
633        assert_eq!(prompt.sections.len(), 3);
634    }
635
636    #[test]
637    fn composite_renders_all() {
638        let prompt = P::role("analyst") + P::task("analyze data");
639        let rendered = prompt.render();
640        assert!(rendered.contains("You are analyst."));
641        assert!(rendered.contains("Your task: analyze data"));
642    }
643
644    #[test]
645    fn context_renders() {
646        let s = P::context("user is a developer");
647        assert_eq!(s.render(), "Context: user is a developer");
648        assert_eq!(s.kind, PromptSectionKind::Context);
649    }
650
651    #[test]
652    fn persona_renders() {
653        let s = P::persona("friendly and concise");
654        assert_eq!(s.render(), "Persona: friendly and concise");
655        assert_eq!(s.kind, PromptSectionKind::Persona);
656    }
657
658    #[test]
659    fn guidelines_renders() {
660        let s = P::guidelines(&["be concise", "use examples", "cite sources"]);
661        assert!(s.render().contains("Guidelines:"));
662        assert!(s.render().contains("- be concise"));
663        assert!(s.render().contains("- use examples"));
664        assert!(s.render().contains("- cite sources"));
665        assert_eq!(s.kind, PromptSectionKind::Guidelines);
666    }
667
668    #[test]
669    fn section_kinds() {
670        assert_eq!(P::role("x").kind, PromptSectionKind::Role);
671        assert_eq!(P::task("x").kind, PromptSectionKind::Task);
672        assert_eq!(P::text("x").kind, PromptSectionKind::Text);
673    }
674
675    #[test]
676    fn section_into_string() {
677        let s: String = P::role("analyst").into();
678        assert_eq!(s, "You are analyst.");
679    }
680
681    #[test]
682    fn composite_into_string() {
683        let s: String = (P::role("analyst") + P::task("analyze data")).into();
684        assert!(s.contains("You are analyst."));
685        assert!(s.contains("Your task: analyze data"));
686    }
687
688    #[test]
689    fn reorder_by_name() {
690        let prompt = P::role("analyst") + P::task("analyze") + P::format("JSON");
691        let reordered = prompt.reorder_by_name(&["format", "task", "role"]);
692        assert_eq!(reordered.sections[0].name.as_deref(), Some("format"));
693        assert_eq!(reordered.sections[1].name.as_deref(), Some("task"));
694        assert_eq!(reordered.sections[2].name.as_deref(), Some("role"));
695    }
696
697    #[test]
698    fn only_by_name() {
699        let prompt = P::role("analyst") + P::task("analyze") + P::format("JSON");
700        let filtered = prompt.only_by_name(&["role", "task"]);
701        assert_eq!(filtered.sections.len(), 2);
702        assert_eq!(filtered.sections[0].name.as_deref(), Some("role"));
703        assert_eq!(filtered.sections[1].name.as_deref(), Some("task"));
704    }
705
706    #[test]
707    fn without_by_name() {
708        let prompt = P::role("analyst") + P::task("analyze") + P::format("JSON");
709        let filtered = prompt.without_by_name(&["format"]);
710        assert_eq!(filtered.sections.len(), 2);
711        assert!(
712            filtered
713                .sections
714                .iter()
715                .all(|s| s.name.as_deref() != Some("format"))
716        );
717    }
718
719    #[test]
720    fn reorder_transform_via_apply() {
721        let prompt = P::role("analyst") + P::task("analyze") + P::format("JSON");
722        let transform = P::reorder(&["format", "role"]);
723        let reordered = prompt.apply(transform);
724        assert_eq!(reordered.sections[0].name.as_deref(), Some("format"));
725        assert_eq!(reordered.sections[1].name.as_deref(), Some("role"));
726    }
727
728    #[test]
729    fn only_transform_via_apply() {
730        let prompt = P::role("analyst") + P::task("analyze") + P::format("JSON");
731        let transform = P::only(&["task"]);
732        let filtered = prompt.apply(transform);
733        assert_eq!(filtered.sections.len(), 1);
734        assert_eq!(filtered.sections[0].name.as_deref(), Some("task"));
735    }
736
737    #[test]
738    fn without_transform_via_apply() {
739        let prompt = P::role("analyst") + P::task("analyze") + P::format("JSON");
740        let transform = P::without(&["role", "format"]);
741        let filtered = prompt.apply(transform);
742        assert_eq!(filtered.sections.len(), 1);
743        assert_eq!(filtered.sections[0].name.as_deref(), Some("task"));
744    }
745
746    #[test]
747    fn compress_renders() {
748        let s = P::compress();
749        assert_eq!(s.kind, PromptSectionKind::Compressed);
750        // The bare marker carries no content, so it renders empty.
751        assert_eq!(s.render(), "");
752    }
753
754    #[test]
755    fn compress_marker_shrinks_composite() {
756        // A composite with redundant whitespace/blank lines + a compress marker.
757        let verbose = P::text("You   are    helpful.")
758            + P::text("You   are    helpful.")
759            + P::text("Be    concise.")
760            + P::compress();
761        let out = verbose.render();
762        // Whitespace collapsed, duplicate line dropped, marker removed.
763        assert_eq!(out, "You are helpful.\nBe concise.");
764
765        // Without the marker, nothing is compressed.
766        let plain = (P::text("a    b") + P::text("c")).render();
767        assert_eq!(plain, "a    b\n\nc");
768    }
769
770    #[test]
771    fn adapt_renders_with_context() {
772        let s = P::adapt(|ctx| {
773            if ctx.contains("detailed") {
774                "Be thorough.".to_string()
775            } else {
776                "Be concise.".to_string()
777            }
778        });
779        assert_eq!(s.kind, PromptSectionKind::Adaptive);
780        assert_eq!(s.render_with_context("detailed"), "Be thorough.");
781        assert_eq!(s.render_with_context("brief"), "Be concise.");
782    }
783
784    #[test]
785    fn adapt_fallback_render() {
786        let s = P::adapt(|_| "adapted".to_string());
787        // render() without context returns the empty content
788        assert_eq!(s.render(), "");
789    }
790
791    #[test]
792    fn scaffolded_renders() {
793        let s = P::scaffolded(&["Identify", "Analyze", "Conclude"]);
794        assert_eq!(s.kind, PromptSectionKind::Scaffolded);
795        let rendered = s.render();
796        assert!(rendered.contains("Follow these steps:"));
797        assert!(rendered.contains("Step 1: Identify"));
798        assert!(rendered.contains("Step 2: Analyze"));
799        assert!(rendered.contains("Step 3: Conclude"));
800    }
801
802    #[test]
803    fn versioned_renders() {
804        let s = P::versioned("v2.1", "Use the new methodology");
805        assert_eq!(s.kind, PromptSectionKind::Versioned);
806        assert_eq!(s.render(), "[v2.1] Use the new methodology");
807        assert_eq!(s.name.as_deref(), Some("versioned:v2.1"));
808    }
809
810    #[test]
811    fn sections_have_names() {
812        assert_eq!(P::role("x").name.as_deref(), Some("role"));
813        assert_eq!(P::task("x").name.as_deref(), Some("task"));
814        assert_eq!(P::constraint("x").name.as_deref(), Some("constraint"));
815        assert_eq!(P::format("x").name.as_deref(), Some("format"));
816        assert_eq!(P::example("x", "y").name.as_deref(), Some("example"));
817        assert_eq!(P::text("x").name, None);
818        assert_eq!(P::context("x").name.as_deref(), Some("context"));
819        assert_eq!(P::persona("x").name.as_deref(), Some("persona"));
820        assert_eq!(P::guidelines(&["x"]).name.as_deref(), Some("guidelines"));
821        assert_eq!(P::section("foo", "bar").name.as_deref(), Some("foo"));
822        assert_eq!(P::scaffolded(&["x"]).name.as_deref(), Some("scaffolded"));
823        assert_eq!(P::compress().name.as_deref(), Some("compress"));
824    }
825}