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