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