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