Skip to main content

ralph_workflow/prompts/
content_reference.rs

1//! Content reference types for prompt templates.
2//!
3//! When prompt content (PROMPT, DIFF, PLAN) exceeds size limits, we reference
4//! the content by file path instead of embedding it inline. This prevents
5//! CLI argument limits from being exceeded while still providing agents with
6//! access to all necessary information.
7
8use std::path::{Path, PathBuf};
9
10/// Maximum size in bytes for inline content embedding.
11/// Content larger than this should be referenced by file path.
12///
13/// Set to 100KB which is well below:
14/// - macOS ARG_MAX limit (~1MB)
15/// - Linux per-argument limit (~128KB)
16///
17/// This conservative limit ensures safety across platforms.
18pub const MAX_INLINE_CONTENT_SIZE: usize = 100 * 1024; // 100KB
19
20/// Represents content that can be either inline or referenced by path.
21///
22/// When content is small enough, it's embedded directly in the prompt.
23/// When content exceeds [`MAX_INLINE_CONTENT_SIZE`], instructions are
24/// provided to the agent to read the content from a file.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum PromptContentReference {
27    /// Content is small enough to embed inline in the prompt.
28    Inline(String),
29    /// Content is too large; agent should read from this absolute path.
30    FilePath {
31        /// Absolute path to the backup file containing the content.
32        path: PathBuf,
33        /// Human-readable description of what the content contains.
34        description: String,
35    },
36}
37
38impl PromptContentReference {
39    /// Create a content reference, choosing inline vs path based on size.
40    ///
41    /// If `content.len() <= MAX_INLINE_CONTENT_SIZE`, the content is stored inline.
42    /// Otherwise, a file path reference is created.
43    ///
44    /// # Arguments
45    ///
46    /// * `content` - The content to reference
47    /// * `backup_path` - Path where the content can be read if too large
48    /// * `description` - Description of the content for agent instructions
49    pub fn from_content(content: String, backup_path: &Path, description: &str) -> Self {
50        if content.len() <= MAX_INLINE_CONTENT_SIZE {
51            Self::Inline(content)
52        } else {
53            Self::FilePath {
54                path: backup_path.to_path_buf(),
55                description: description.to_string(),
56            }
57        }
58    }
59
60    /// Create an inline reference (for small content).
61    pub fn inline(content: String) -> Self {
62        Self::Inline(content)
63    }
64
65    /// Create a file path reference (for large content).
66    pub fn file_path(path: PathBuf, description: &str) -> Self {
67        Self::FilePath {
68            path,
69            description: description.to_string(),
70        }
71    }
72
73    /// Returns true if this is an inline reference.
74    pub fn is_inline(&self) -> bool {
75        matches!(self, Self::Inline(_))
76    }
77
78    /// Get the content for template rendering.
79    ///
80    /// For inline: returns the content directly.
81    /// For file path: returns instructions to read from the file.
82    pub fn render_for_template(&self) -> String {
83        match self {
84            Self::Inline(content) => content.clone(),
85            Self::FilePath { path, description } => {
86                format!(
87                    "[Content too large to embed - Read from: {}]\n\
88                     Description: {}\n\
89                     Use your file reading tools to access this file.",
90                    path.display(),
91                    description
92                )
93            }
94        }
95    }
96}
97
98/// Specialized reference for DIFF content.
99///
100/// When DIFF is too large, instructs the agent to use `git diff` command
101/// instead of embedding the diff content.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum DiffContentReference {
104    /// DIFF is small enough to embed inline.
105    Inline(String),
106    /// DIFF is too large; agent should run git diff from start_commit.
107    UseGitDiff {
108        /// The commit hash to diff from (usually the start of the session).
109        start_commit: String,
110        /// Description of why git diff is needed.
111        description: String,
112    },
113}
114
115impl DiffContentReference {
116    /// Create a diff reference, choosing inline vs git command based on size.
117    ///
118    /// If `diff_content.len() <= MAX_INLINE_CONTENT_SIZE`, the diff is stored inline.
119    /// Otherwise, instructions to use `git diff` are provided.
120    ///
121    /// # Arguments
122    ///
123    /// * `diff_content` - The diff content
124    /// * `start_commit` - The commit hash to diff from
125    pub fn from_diff(diff_content: String, start_commit: &str) -> Self {
126        if diff_content.len() <= MAX_INLINE_CONTENT_SIZE {
127            Self::Inline(diff_content)
128        } else {
129            Self::UseGitDiff {
130                start_commit: start_commit.to_string(),
131                description: format!(
132                    "Diff is {} bytes (exceeds {} limit)",
133                    diff_content.len(),
134                    MAX_INLINE_CONTENT_SIZE
135                ),
136            }
137        }
138    }
139
140    /// Get the content for template rendering.
141    ///
142    /// For inline: returns the diff content directly.
143    /// For git diff: returns instructions to run the git command.
144    pub fn render_for_template(&self) -> String {
145        match self {
146            Self::Inline(content) => content.clone(),
147            Self::UseGitDiff {
148                start_commit,
149                description,
150            } => {
151                format!(
152                    "[DIFF too large to embed - Use git diff instead]\n\
153                     {}\n\n\
154                     To see the changes, run:\n\
155                     git diff {}..HEAD\n\n\
156                     This shows all changes since the start of this session.",
157                    description, start_commit
158                )
159            }
160        }
161    }
162
163    /// Returns true if this is an inline reference.
164    pub fn is_inline(&self) -> bool {
165        matches!(self, Self::Inline(_))
166    }
167}
168
169/// Specialized reference for PLAN content.
170///
171/// When PLAN is too large, instructs the agent to read from PLAN.md
172/// with optional fallback to the XML plan file.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum PlanContentReference {
175    /// PLAN is small enough to embed inline.
176    Inline(String),
177    /// PLAN is too large; agent should read from file.
178    ReadFromFile {
179        /// Primary path to the plan file (usually .agent/PLAN.md).
180        primary_path: PathBuf,
181        /// Optional fallback path if primary is missing (usually .agent/tmp/plan.xml).
182        fallback_path: Option<PathBuf>,
183        /// Description of why file reading is needed.
184        description: String,
185    },
186}
187
188impl PlanContentReference {
189    /// Create a plan reference, choosing inline vs file path based on size.
190    ///
191    /// If `plan_content.len() <= MAX_INLINE_CONTENT_SIZE`, the plan is stored inline.
192    /// Otherwise, instructions to read from file are provided.
193    ///
194    /// # Arguments
195    ///
196    /// * `plan_content` - The plan content
197    /// * `plan_path` - Path to the primary plan file
198    /// * `xml_fallback_path` - Optional path to XML fallback
199    pub fn from_plan(
200        plan_content: String,
201        plan_path: &Path,
202        xml_fallback_path: Option<&Path>,
203    ) -> Self {
204        if plan_content.len() <= MAX_INLINE_CONTENT_SIZE {
205            Self::Inline(plan_content)
206        } else {
207            Self::ReadFromFile {
208                primary_path: plan_path.to_path_buf(),
209                fallback_path: xml_fallback_path.map(|p| p.to_path_buf()),
210                description: format!(
211                    "Plan is {} bytes (exceeds {} limit)",
212                    plan_content.len(),
213                    MAX_INLINE_CONTENT_SIZE
214                ),
215            }
216        }
217    }
218
219    /// Get the content for template rendering.
220    ///
221    /// For inline: returns the plan content directly.
222    /// For file path: returns instructions to read from the file.
223    pub fn render_for_template(&self) -> String {
224        match self {
225            Self::Inline(content) => content.clone(),
226            Self::ReadFromFile {
227                primary_path,
228                fallback_path,
229                description,
230            } => {
231                let fallback_msg = fallback_path.as_ref().map_or(String::new(), |p| {
232                    format!(
233                        "\nIf {} is missing or empty, try reading: {}",
234                        primary_path.display(),
235                        p.display()
236                    )
237                });
238                format!(
239                    "[PLAN too large to embed - Read from file]\n\
240                     {}\n\n\
241                     Read the implementation plan from: {}{}\n\n\
242                     Use your file reading tools to access the plan.",
243                    description,
244                    primary_path.display(),
245                    fallback_msg
246                )
247            }
248        }
249    }
250
251    /// Returns true if this is an inline reference.
252    pub fn is_inline(&self) -> bool {
253        matches!(self, Self::Inline(_))
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    // =========================================================================
262    // PromptContentReference tests
263    // =========================================================================
264
265    #[test]
266    fn test_small_content_is_inline() {
267        let content = "Small content".to_string();
268        let reference = PromptContentReference::from_content(
269            content.clone(),
270            Path::new("/backup/path"),
271            "test",
272        );
273        assert!(reference.is_inline());
274        assert_eq!(reference.render_for_template(), content);
275    }
276
277    #[test]
278    fn test_large_content_becomes_file_path() {
279        let content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
280        let reference = PromptContentReference::from_content(
281            content,
282            Path::new("/backup/prompt.md"),
283            "User requirements",
284        );
285        assert!(!reference.is_inline());
286        let rendered = reference.render_for_template();
287        assert!(rendered.contains("/backup/prompt.md"));
288        assert!(rendered.contains("User requirements"));
289    }
290
291    #[test]
292    fn test_exactly_max_size_is_inline() {
293        let content = "x".repeat(MAX_INLINE_CONTENT_SIZE);
294        let reference = PromptContentReference::from_content(
295            content.clone(),
296            Path::new("/backup/path"),
297            "test",
298        );
299        assert!(reference.is_inline());
300    }
301
302    #[test]
303    fn test_empty_content_is_inline() {
304        let reference =
305            PromptContentReference::from_content(String::new(), Path::new("/backup"), "test");
306        assert!(reference.is_inline());
307        assert_eq!(reference.render_for_template(), "");
308    }
309
310    #[test]
311    fn test_unicode_content_size_in_bytes() {
312        // Unicode characters take multiple bytes
313        // 🎉 is 4 bytes in UTF-8
314        let emoji = "🎉".repeat(MAX_INLINE_CONTENT_SIZE / 4 + 1);
315        let reference = PromptContentReference::from_content(emoji, Path::new("/backup"), "test");
316        // Should exceed limit due to multi-byte characters
317        assert!(!reference.is_inline());
318    }
319
320    #[test]
321    fn test_prompt_inline_constructor() {
322        let content = "Direct content".to_string();
323        let reference = PromptContentReference::inline(content.clone());
324        assert!(reference.is_inline());
325        assert_eq!(reference.render_for_template(), content);
326    }
327
328    #[test]
329    fn test_prompt_file_path_constructor() {
330        let path = PathBuf::from("/path/to/file.md");
331        let reference = PromptContentReference::file_path(path.clone(), "Description");
332        assert!(!reference.is_inline());
333        let rendered = reference.render_for_template();
334        assert!(rendered.contains("/path/to/file.md"));
335        assert!(rendered.contains("Description"));
336    }
337
338    // =========================================================================
339    // DiffContentReference tests
340    // =========================================================================
341
342    #[test]
343    fn test_small_diff_is_inline() {
344        let diff = "+added line\n-removed line".to_string();
345        let reference = DiffContentReference::from_diff(diff.clone(), "abc123");
346        assert!(reference.is_inline());
347        assert_eq!(reference.render_for_template(), diff);
348    }
349
350    #[test]
351    fn test_large_diff_uses_git_command() {
352        let diff = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
353        let reference = DiffContentReference::from_diff(diff, "abc123");
354        assert!(!reference.is_inline());
355        let rendered = reference.render_for_template();
356        assert!(rendered.contains("git diff abc123..HEAD"));
357    }
358
359    #[test]
360    fn test_diff_with_empty_start_commit() {
361        let reference =
362            DiffContentReference::from_diff("x".repeat(MAX_INLINE_CONTENT_SIZE + 1), "");
363        let rendered = reference.render_for_template();
364        assert!(rendered.contains("git diff ..HEAD"));
365    }
366
367    #[test]
368    fn test_diff_exactly_max_size_is_inline() {
369        let diff = "d".repeat(MAX_INLINE_CONTENT_SIZE);
370        let reference = DiffContentReference::from_diff(diff.clone(), "abc");
371        assert!(reference.is_inline());
372        assert_eq!(reference.render_for_template(), diff);
373    }
374
375    // =========================================================================
376    // PlanContentReference tests
377    // =========================================================================
378
379    #[test]
380    fn test_small_plan_is_inline() {
381        let plan = "# Plan\n\n1. Do thing".to_string();
382        let reference =
383            PlanContentReference::from_plan(plan.clone(), Path::new(".agent/PLAN.md"), None);
384        assert!(reference.is_inline());
385        assert_eq!(reference.render_for_template(), plan);
386    }
387
388    #[test]
389    fn test_large_plan_reads_from_file() {
390        let plan = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
391        let reference = PlanContentReference::from_plan(
392            plan,
393            Path::new(".agent/PLAN.md"),
394            Some(Path::new(".agent/tmp/plan.xml")),
395        );
396        assert!(!reference.is_inline());
397        let rendered = reference.render_for_template();
398        assert!(rendered.contains(".agent/PLAN.md"));
399        assert!(rendered.contains("plan.xml"));
400    }
401
402    #[test]
403    fn test_plan_without_xml_fallback() {
404        let reference = PlanContentReference::from_plan(
405            "x".repeat(MAX_INLINE_CONTENT_SIZE + 1),
406            Path::new(".agent/PLAN.md"),
407            None,
408        );
409        let rendered = reference.render_for_template();
410        assert!(rendered.contains(".agent/PLAN.md"));
411        assert!(!rendered.contains("plan.xml"));
412    }
413
414    #[test]
415    fn test_plan_exactly_max_size_is_inline() {
416        let plan = "p".repeat(MAX_INLINE_CONTENT_SIZE);
417        let reference =
418            PlanContentReference::from_plan(plan.clone(), Path::new(".agent/PLAN.md"), None);
419        assert!(reference.is_inline());
420        assert_eq!(reference.render_for_template(), plan);
421    }
422}