Skip to main content

ralph_workflow/prompts/
content_builder.rs

1//! Builder for assembling prompt content with size-aware references.
2//!
3//! This builder checks content sizes and creates appropriate references
4//! (inline vs file path) for each piece of content. This prevents CLI
5//! argument limits from being exceeded while still providing agents with
6//! access to all necessary information.
7
8use std::path::Path;
9
10use super::content_reference::{
11    DiffContentReference, PlanContentReference, PromptContentReference,
12};
13use crate::workspace::Workspace;
14
15/// Builder for constructing prompt content with size-aware references.
16///
17/// This builder encapsulates the logic for determining whether content
18/// should be embedded inline or referenced by file path.
19pub struct PromptContentBuilder<'a> {
20    workspace: &'a dyn Workspace,
21    prompt_ref: Option<PromptContentReference>,
22    plan_ref: Option<PlanContentReference>,
23    diff_ref: Option<DiffContentReference>,
24}
25
26impl<'a> PromptContentBuilder<'a> {
27    /// Create a new builder with a workspace reference.
28    pub fn new(workspace: &'a dyn Workspace) -> Self {
29        Self {
30            workspace,
31            prompt_ref: None,
32            plan_ref: None,
33            diff_ref: None,
34        }
35    }
36
37    /// Add PROMPT content with automatic size checking.
38    ///
39    /// If the content exceeds [`MAX_INLINE_CONTENT_SIZE`], the builder will
40    /// create a reference to the backup file instead of embedding inline.
41    pub fn with_prompt(mut self, content: String) -> Self {
42        let backup_path = self.workspace.prompt_backup();
43        self.prompt_ref = Some(PromptContentReference::from_content(
44            content,
45            &backup_path,
46            "Original user requirements from PROMPT.md",
47        ));
48        self
49    }
50
51    /// Add PLAN content with automatic size checking.
52    ///
53    /// If the content exceeds [`MAX_INLINE_CONTENT_SIZE`], the builder will
54    /// create instructions to read from .agent/PLAN.md with optional XML fallback.
55    pub fn with_plan(mut self, content: String) -> Self {
56        let plan_path = Path::new(".agent/PLAN.md");
57        let xml_fallback = Path::new(".agent/tmp/plan.xml");
58        self.plan_ref = Some(PlanContentReference::from_plan(
59            content,
60            plan_path,
61            Some(xml_fallback),
62        ));
63        self
64    }
65
66    /// Add DIFF content with automatic size checking.
67    ///
68    /// If the content exceeds [`MAX_INLINE_CONTENT_SIZE`], the builder will
69    /// create instructions to use `git diff` instead of embedding inline.
70    pub fn with_diff(mut self, content: String, start_commit: &str) -> Self {
71        self.diff_ref = Some(DiffContentReference::from_diff(content, start_commit));
72        self
73    }
74
75    /// Build the references.
76    ///
77    /// Note: Backup files should be created before calling build() if needed.
78    /// This builder only determines how content should be referenced.
79    pub fn build(self) -> PromptContentReferences {
80        PromptContentReferences {
81            prompt: self.prompt_ref,
82            plan: self.plan_ref,
83            diff: self.diff_ref,
84        }
85    }
86
87    /// Check if any content exceeds the inline size limit.
88    ///
89    /// This is useful for logging or debugging to see when content
90    /// will be referenced by file path instead of embedded inline.
91    pub fn has_oversize_content(&self) -> bool {
92        let prompt_oversize = self.prompt_ref.as_ref().is_some_and(|r| !r.is_inline());
93        let plan_oversize = self.plan_ref.as_ref().is_some_and(|r| !r.is_inline());
94        let diff_oversize = self.diff_ref.as_ref().is_some_and(|r| !r.is_inline());
95
96        prompt_oversize || plan_oversize || diff_oversize
97    }
98}
99
100/// Container for all content references.
101///
102/// This struct holds the resolved references for PROMPT, PLAN, and DIFF
103/// content. Each reference may be inline or a file path reference.
104pub struct PromptContentReferences {
105    /// Reference to PROMPT.md content.
106    pub prompt: Option<PromptContentReference>,
107    /// Reference to PLAN.md content.
108    pub plan: Option<PlanContentReference>,
109    /// Reference to diff content.
110    pub diff: Option<DiffContentReference>,
111}
112
113impl PromptContentReferences {
114    /// Get the PROMPT content for template rendering.
115    ///
116    /// Returns the content directly if inline, or instructions to read from file.
117    pub fn prompt_for_template(&self) -> String {
118        self.prompt
119            .as_ref()
120            .map(|r| r.render_for_template())
121            .unwrap_or_default()
122    }
123
124    /// Get the PLAN content for template rendering.
125    ///
126    /// Returns the content directly if inline, or instructions to read from file.
127    pub fn plan_for_template(&self) -> String {
128        self.plan
129            .as_ref()
130            .map(|r| r.render_for_template())
131            .unwrap_or_default()
132    }
133
134    /// Get the DIFF content for template rendering.
135    ///
136    /// Returns the content directly if inline, or instructions to use git diff.
137    pub fn diff_for_template(&self) -> String {
138        self.diff
139            .as_ref()
140            .map(|r| r.render_for_template())
141            .unwrap_or_default()
142    }
143
144    /// Check if the PROMPT reference is inline.
145    pub fn prompt_is_inline(&self) -> bool {
146        self.prompt.as_ref().is_some_and(|r| r.is_inline())
147    }
148
149    /// Check if the PLAN reference is inline.
150    pub fn plan_is_inline(&self) -> bool {
151        self.plan.as_ref().is_some_and(|r| r.is_inline())
152    }
153
154    /// Check if the DIFF reference is inline.
155    pub fn diff_is_inline(&self) -> bool {
156        self.diff.as_ref().is_some_and(|r| r.is_inline())
157    }
158}
159
160#[cfg(all(test, feature = "test-utils"))]
161mod tests {
162    use super::*;
163    use crate::prompts::MAX_INLINE_CONTENT_SIZE;
164    use crate::workspace::MemoryWorkspace;
165
166    #[test]
167    fn test_builder_small_content() {
168        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "test");
169
170        let builder = PromptContentBuilder::new(&workspace)
171            .with_prompt("Small prompt".to_string())
172            .with_plan("Small plan".to_string());
173
174        assert!(!builder.has_oversize_content());
175
176        let refs = builder.build();
177        assert_eq!(refs.prompt_for_template(), "Small prompt");
178        assert_eq!(refs.plan_for_template(), "Small plan");
179    }
180
181    #[test]
182    fn test_builder_large_prompt() {
183        let workspace = MemoryWorkspace::new_test().with_file(".agent/PROMPT.md.backup", "backup");
184
185        let large_content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
186        let builder = PromptContentBuilder::new(&workspace).with_prompt(large_content);
187
188        assert!(builder.has_oversize_content());
189
190        let refs = builder.build();
191        let rendered = refs.prompt_for_template();
192        assert!(rendered.contains("PROMPT.md.backup"));
193        assert!(!refs.prompt_is_inline());
194    }
195
196    #[test]
197    fn test_builder_large_plan() {
198        let workspace = MemoryWorkspace::new_test().with_file(".agent/PLAN.md", "plan");
199
200        let large_content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
201        let builder = PromptContentBuilder::new(&workspace).with_plan(large_content);
202
203        assert!(builder.has_oversize_content());
204
205        let refs = builder.build();
206        let rendered = refs.plan_for_template();
207        assert!(rendered.contains(".agent/PLAN.md"));
208        assert!(rendered.contains("plan.xml"));
209        assert!(!refs.plan_is_inline());
210    }
211
212    #[test]
213    fn test_builder_large_diff() {
214        let workspace = MemoryWorkspace::new_test();
215
216        let large_content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
217        let builder = PromptContentBuilder::new(&workspace).with_diff(large_content, "abc123def");
218
219        assert!(builder.has_oversize_content());
220
221        let refs = builder.build();
222        let rendered = refs.diff_for_template();
223        assert!(rendered.contains("git diff abc123def..HEAD"));
224        assert!(!refs.diff_is_inline());
225    }
226
227    #[test]
228    fn test_builder_no_oversize_when_all_small() {
229        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "test");
230
231        let builder = PromptContentBuilder::new(&workspace)
232            .with_prompt("Small prompt".to_string())
233            .with_plan("Small plan".to_string())
234            .with_diff("Small diff".to_string(), "abc123");
235
236        assert!(!builder.has_oversize_content());
237
238        let refs = builder.build();
239        assert!(refs.prompt_is_inline());
240        assert!(refs.plan_is_inline());
241        assert!(refs.diff_is_inline());
242    }
243
244    #[test]
245    fn test_builder_partial_oversize() {
246        let workspace = MemoryWorkspace::new_test().with_file(".agent/PROMPT.md.backup", "backup");
247
248        // Only prompt is oversized
249        let large_prompt = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
250        let builder = PromptContentBuilder::new(&workspace)
251            .with_prompt(large_prompt)
252            .with_plan("Small plan".to_string())
253            .with_diff("Small diff".to_string(), "abc123");
254
255        assert!(builder.has_oversize_content());
256
257        let refs = builder.build();
258        assert!(!refs.prompt_is_inline());
259        assert!(refs.plan_is_inline());
260        assert!(refs.diff_is_inline());
261    }
262
263    #[test]
264    fn test_builder_empty_content() {
265        let workspace = MemoryWorkspace::new_test();
266
267        let refs = PromptContentBuilder::new(&workspace).build();
268
269        assert_eq!(refs.prompt_for_template(), "");
270        assert_eq!(refs.plan_for_template(), "");
271        assert_eq!(refs.diff_for_template(), "");
272    }
273
274    #[test]
275    fn test_refs_inline_checks_with_none() {
276        let refs = PromptContentReferences {
277            prompt: None,
278            plan: None,
279            diff: None,
280        };
281
282        // None should not be considered inline
283        assert!(!refs.prompt_is_inline());
284        assert!(!refs.plan_is_inline());
285        assert!(!refs.diff_is_inline());
286    }
287}