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, MAX_INLINE_CONTENT_SIZE,
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        // For oversize diffs, write the diff to .agent/tmp/diff.txt so agents can read it
72        // without relying on git being available.
73        let is_oversize = content.len() > MAX_INLINE_CONTENT_SIZE;
74        if is_oversize {
75            let tmp_dir = Path::new(".agent/tmp");
76            let diff_rel = tmp_dir.join("diff.txt");
77            if self.workspace.create_dir_all(tmp_dir).is_ok() {
78                let _ = self.workspace.write(&diff_rel, &content);
79            }
80        }
81
82        let diff_abs = self.workspace.absolute(Path::new(".agent/tmp/diff.txt"));
83        self.diff_ref = Some(DiffContentReference::from_diff(
84            content,
85            start_commit,
86            &diff_abs,
87        ));
88        self
89    }
90
91    /// Build the references.
92    ///
93    /// Note: Backup files should be created before calling build() if needed.
94    /// This builder only determines how content should be referenced.
95    pub fn build(self) -> PromptContentReferences {
96        PromptContentReferences {
97            prompt: self.prompt_ref,
98            plan: self.plan_ref,
99            diff: self.diff_ref,
100        }
101    }
102
103    /// Check if any content exceeds the inline size limit.
104    ///
105    /// This is useful for logging or debugging to see when content
106    /// will be referenced by file path instead of embedded inline.
107    pub fn has_oversize_content(&self) -> bool {
108        let prompt_oversize = self.prompt_ref.as_ref().is_some_and(|r| !r.is_inline());
109        let plan_oversize = self.plan_ref.as_ref().is_some_and(|r| !r.is_inline());
110        let diff_oversize = self.diff_ref.as_ref().is_some_and(|r| !r.is_inline());
111
112        prompt_oversize || plan_oversize || diff_oversize
113    }
114}
115
116/// Container for all content references.
117///
118/// This struct holds the resolved references for PROMPT, PLAN, and DIFF
119/// content. Each reference may be inline or a file path reference.
120pub struct PromptContentReferences {
121    /// Reference to PROMPT.md content.
122    pub prompt: Option<PromptContentReference>,
123    /// Reference to PLAN.md content.
124    pub plan: Option<PlanContentReference>,
125    /// Reference to diff content.
126    pub diff: Option<DiffContentReference>,
127}
128
129impl PromptContentReferences {
130    /// Get the PROMPT content for template rendering.
131    ///
132    /// Returns the content directly if inline, or instructions to read from file.
133    pub fn prompt_for_template(&self) -> String {
134        self.prompt
135            .as_ref()
136            .map(|r| r.render_for_template())
137            .unwrap_or_default()
138    }
139
140    /// Get the PLAN content for template rendering.
141    ///
142    /// Returns the content directly if inline, or instructions to read from file.
143    pub fn plan_for_template(&self) -> String {
144        self.plan
145            .as_ref()
146            .map(|r| r.render_for_template())
147            .unwrap_or_default()
148    }
149
150    /// Get the DIFF content for template rendering.
151    ///
152    /// Returns the content directly if inline, or instructions to use git diff.
153    pub fn diff_for_template(&self) -> String {
154        self.diff
155            .as_ref()
156            .map(|r| r.render_for_template())
157            .unwrap_or_default()
158    }
159
160    /// Check if the PROMPT reference is inline.
161    pub fn prompt_is_inline(&self) -> bool {
162        self.prompt.as_ref().is_some_and(|r| r.is_inline())
163    }
164
165    /// Check if the PLAN reference is inline.
166    pub fn plan_is_inline(&self) -> bool {
167        self.plan.as_ref().is_some_and(|r| r.is_inline())
168    }
169
170    /// Check if the DIFF reference is inline.
171    pub fn diff_is_inline(&self) -> bool {
172        self.diff.as_ref().is_some_and(|r| r.is_inline())
173    }
174}
175
176#[cfg(all(test, feature = "test-utils"))]
177mod tests {
178    use super::*;
179    use crate::prompts::MAX_INLINE_CONTENT_SIZE;
180    use crate::workspace::MemoryWorkspace;
181
182    #[test]
183    fn test_builder_small_content() {
184        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "test");
185
186        let builder = PromptContentBuilder::new(&workspace)
187            .with_prompt("Small prompt".to_string())
188            .with_plan("Small plan".to_string());
189
190        assert!(!builder.has_oversize_content());
191
192        let refs = builder.build();
193        assert_eq!(refs.prompt_for_template(), "Small prompt");
194        assert_eq!(refs.plan_for_template(), "Small plan");
195    }
196
197    #[test]
198    fn test_builder_large_prompt() {
199        let workspace = MemoryWorkspace::new_test().with_file(".agent/PROMPT.md.backup", "backup");
200
201        let large_content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
202        let builder = PromptContentBuilder::new(&workspace).with_prompt(large_content);
203
204        assert!(builder.has_oversize_content());
205
206        let refs = builder.build();
207        let rendered = refs.prompt_for_template();
208        assert!(rendered.contains("PROMPT.md.backup"));
209        assert!(!refs.prompt_is_inline());
210    }
211
212    #[test]
213    fn test_builder_large_plan() {
214        let workspace = MemoryWorkspace::new_test().with_file(".agent/PLAN.md", "plan");
215
216        let large_content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
217        let builder = PromptContentBuilder::new(&workspace).with_plan(large_content);
218
219        assert!(builder.has_oversize_content());
220
221        let refs = builder.build();
222        let rendered = refs.plan_for_template();
223        assert!(rendered.contains(".agent/PLAN.md"));
224        assert!(rendered.contains("plan.xml"));
225        assert!(!refs.plan_is_inline());
226    }
227
228    #[test]
229    fn test_builder_large_diff() {
230        let workspace = MemoryWorkspace::new_test();
231
232        let large_content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
233        let builder = PromptContentBuilder::new(&workspace).with_diff(large_content, "abc123def");
234
235        assert!(builder.has_oversize_content());
236
237        let refs = builder.build();
238        let rendered = refs.diff_for_template();
239        assert!(
240            rendered.contains(".agent/tmp/diff.txt"),
241            "Oversize diff should reference .agent/tmp/diff.txt: {}",
242            &rendered[..rendered.len().min(200)]
243        );
244        // Diff should be written for file-based fallback
245        assert!(workspace.was_written(".agent/tmp/diff.txt"));
246        assert!(!refs.diff_is_inline());
247    }
248
249    #[test]
250    fn test_builder_no_oversize_when_all_small() {
251        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "test");
252
253        let builder = PromptContentBuilder::new(&workspace)
254            .with_prompt("Small prompt".to_string())
255            .with_plan("Small plan".to_string())
256            .with_diff("Small diff".to_string(), "abc123");
257
258        assert!(!builder.has_oversize_content());
259
260        let refs = builder.build();
261        assert!(refs.prompt_is_inline());
262        assert!(refs.plan_is_inline());
263        assert!(refs.diff_is_inline());
264    }
265
266    #[test]
267    fn test_builder_partial_oversize() {
268        let workspace = MemoryWorkspace::new_test().with_file(".agent/PROMPT.md.backup", "backup");
269
270        // Only prompt is oversized
271        let large_prompt = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
272        let builder = PromptContentBuilder::new(&workspace)
273            .with_prompt(large_prompt)
274            .with_plan("Small plan".to_string())
275            .with_diff("Small diff".to_string(), "abc123");
276
277        assert!(builder.has_oversize_content());
278
279        let refs = builder.build();
280        assert!(!refs.prompt_is_inline());
281        assert!(refs.plan_is_inline());
282        assert!(refs.diff_is_inline());
283    }
284
285    #[test]
286    fn test_builder_empty_content() {
287        let workspace = MemoryWorkspace::new_test();
288
289        let refs = PromptContentBuilder::new(&workspace).build();
290
291        assert_eq!(refs.prompt_for_template(), "");
292        assert_eq!(refs.plan_for_template(), "");
293        assert_eq!(refs.diff_for_template(), "");
294    }
295
296    #[test]
297    fn test_refs_inline_checks_with_none() {
298        let refs = PromptContentReferences {
299            prompt: None,
300            plan: None,
301            diff: None,
302        };
303
304        // None should not be considered inline
305        assert!(!refs.prompt_is_inline());
306        assert!(!refs.plan_is_inline());
307        assert!(!refs.diff_is_inline());
308    }
309}