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 read from a file (with git diff fallback).
107    ReadFromFile {
108        /// Absolute path to the diff file containing the content.
109        path: PathBuf,
110        /// The commit hash to diff from (fallback if file is missing).
111        start_commit: String,
112        /// Description of why file reading is needed.
113        description: String,
114    },
115}
116
117impl DiffContentReference {
118    /// Create a diff reference, choosing inline vs git command based on size.
119    ///
120    /// If `diff_content.len() <= MAX_INLINE_CONTENT_SIZE`, the diff is stored inline.
121    /// Otherwise, instructions to read from a file are provided (with git diff fallback).
122    ///
123    /// # Arguments
124    ///
125    /// * `diff_content` - The diff content
126    /// * `start_commit` - The commit hash to diff from
127    pub fn from_diff(diff_content: String, start_commit: &str, diff_path: &Path) -> Self {
128        if diff_content.len() <= MAX_INLINE_CONTENT_SIZE {
129            Self::Inline(diff_content)
130        } else {
131            Self::ReadFromFile {
132                path: diff_path.to_path_buf(),
133                start_commit: start_commit.to_string(),
134                description: format!(
135                    "Diff is {} bytes (exceeds {} limit)",
136                    diff_content.len(),
137                    MAX_INLINE_CONTENT_SIZE
138                ),
139            }
140        }
141    }
142
143    /// Get the content for template rendering.
144    ///
145    /// For inline: returns the diff content directly.
146    /// For git diff: returns instructions to run the git command.
147    pub fn render_for_template(&self) -> String {
148        match self {
149            Self::Inline(content) => content.clone(),
150            Self::ReadFromFile {
151                path,
152                start_commit,
153                description,
154            } => {
155                format!(
156                    "[DIFF too large to embed - Read from file]\n\
157                     {}\n\n\
158                     Read the diff from: {}\n\
159                     If this file is missing or unavailable, run:\n\
160                     git diff {}..HEAD\n\n\
161                     This shows all changes since the start of this session.",
162                    description,
163                    path.display(),
164                    start_commit
165                )
166            }
167        }
168    }
169
170    /// Returns true if this is an inline reference.
171    pub fn is_inline(&self) -> bool {
172        matches!(self, Self::Inline(_))
173    }
174}
175
176/// Specialized reference for PLAN content.
177///
178/// When PLAN is too large, instructs the agent to read from PLAN.md
179/// with optional fallback to the XML plan file.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum PlanContentReference {
182    /// PLAN is small enough to embed inline.
183    Inline(String),
184    /// PLAN is too large; agent should read from file.
185    ReadFromFile {
186        /// Primary path to the plan file (usually .agent/PLAN.md).
187        primary_path: PathBuf,
188        /// Optional fallback path if primary is missing (usually .agent/tmp/plan.xml).
189        fallback_path: Option<PathBuf>,
190        /// Description of why file reading is needed.
191        description: String,
192    },
193}
194
195impl PlanContentReference {
196    /// Create a plan reference, choosing inline vs file path based on size.
197    ///
198    /// If `plan_content.len() <= MAX_INLINE_CONTENT_SIZE`, the plan is stored inline.
199    /// Otherwise, instructions to read from file are provided.
200    ///
201    /// # Arguments
202    ///
203    /// * `plan_content` - The plan content
204    /// * `plan_path` - Path to the primary plan file
205    /// * `xml_fallback_path` - Optional path to XML fallback
206    pub fn from_plan(
207        plan_content: String,
208        plan_path: &Path,
209        xml_fallback_path: Option<&Path>,
210    ) -> Self {
211        if plan_content.len() <= MAX_INLINE_CONTENT_SIZE {
212            Self::Inline(plan_content)
213        } else {
214            Self::ReadFromFile {
215                primary_path: plan_path.to_path_buf(),
216                fallback_path: xml_fallback_path.map(|p| p.to_path_buf()),
217                description: format!(
218                    "Plan is {} bytes (exceeds {} limit)",
219                    plan_content.len(),
220                    MAX_INLINE_CONTENT_SIZE
221                ),
222            }
223        }
224    }
225
226    /// Get the content for template rendering.
227    ///
228    /// For inline: returns the plan content directly.
229    /// For file path: returns instructions to read from the file.
230    pub fn render_for_template(&self) -> String {
231        match self {
232            Self::Inline(content) => content.clone(),
233            Self::ReadFromFile {
234                primary_path,
235                fallback_path,
236                description,
237            } => {
238                let fallback_msg = fallback_path.as_ref().map_or(String::new(), |p| {
239                    format!(
240                        "\nIf {} is missing or empty, try reading: {}",
241                        primary_path.display(),
242                        p.display()
243                    )
244                });
245                format!(
246                    "[PLAN too large to embed - Read from file]\n\
247                     {}\n\n\
248                     Read the implementation plan from: {}{}\n\n\
249                     Use your file reading tools to access the plan.",
250                    description,
251                    primary_path.display(),
252                    fallback_msg
253                )
254            }
255        }
256    }
257
258    /// Returns true if this is an inline reference.
259    pub fn is_inline(&self) -> bool {
260        matches!(self, Self::Inline(_))
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    // =========================================================================
269    // PromptContentReference tests
270    // =========================================================================
271
272    #[test]
273    fn test_small_content_is_inline() {
274        let content = "Small content".to_string();
275        let reference = PromptContentReference::from_content(
276            content.clone(),
277            Path::new("/backup/path"),
278            "test",
279        );
280        assert!(reference.is_inline());
281        assert_eq!(reference.render_for_template(), content);
282    }
283
284    #[test]
285    fn test_large_content_becomes_file_path() {
286        let content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
287        let reference = PromptContentReference::from_content(
288            content,
289            Path::new("/backup/prompt.md"),
290            "User requirements",
291        );
292        assert!(!reference.is_inline());
293        let rendered = reference.render_for_template();
294        assert!(rendered.contains("/backup/prompt.md"));
295        assert!(rendered.contains("User requirements"));
296    }
297
298    #[test]
299    fn test_exactly_max_size_is_inline() {
300        let content = "x".repeat(MAX_INLINE_CONTENT_SIZE);
301        let reference = PromptContentReference::from_content(
302            content.clone(),
303            Path::new("/backup/path"),
304            "test",
305        );
306        assert!(reference.is_inline());
307    }
308
309    #[test]
310    fn test_empty_content_is_inline() {
311        let reference =
312            PromptContentReference::from_content(String::new(), Path::new("/backup"), "test");
313        assert!(reference.is_inline());
314        assert_eq!(reference.render_for_template(), "");
315    }
316
317    #[test]
318    fn test_unicode_content_size_in_bytes() {
319        // Unicode characters take multiple bytes
320        // 🎉 is 4 bytes in UTF-8
321        let emoji = "🎉".repeat(MAX_INLINE_CONTENT_SIZE / 4 + 1);
322        let reference = PromptContentReference::from_content(emoji, Path::new("/backup"), "test");
323        // Should exceed limit due to multi-byte characters
324        assert!(!reference.is_inline());
325    }
326
327    #[test]
328    fn test_prompt_inline_constructor() {
329        let content = "Direct content".to_string();
330        let reference = PromptContentReference::inline(content.clone());
331        assert!(reference.is_inline());
332        assert_eq!(reference.render_for_template(), content);
333    }
334
335    #[test]
336    fn test_prompt_file_path_constructor() {
337        let path = PathBuf::from("/path/to/file.md");
338        let reference = PromptContentReference::file_path(path.clone(), "Description");
339        assert!(!reference.is_inline());
340        let rendered = reference.render_for_template();
341        assert!(rendered.contains("/path/to/file.md"));
342        assert!(rendered.contains("Description"));
343    }
344
345    // =========================================================================
346    // DiffContentReference tests
347    // =========================================================================
348
349    #[test]
350    fn test_small_diff_is_inline() {
351        let diff = "+added line\n-removed line".to_string();
352        let reference =
353            DiffContentReference::from_diff(diff.clone(), "abc123", Path::new("/backup/diff.txt"));
354        assert!(reference.is_inline());
355        assert_eq!(reference.render_for_template(), diff);
356    }
357
358    #[test]
359    fn test_large_diff_reads_from_file_with_git_fallback() {
360        let diff = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
361        let reference =
362            DiffContentReference::from_diff(diff, "abc123", Path::new("/backup/diff.txt"));
363        assert!(!reference.is_inline());
364        let rendered = reference.render_for_template();
365        assert!(rendered.contains("/backup/diff.txt"));
366        assert!(rendered.contains("git diff abc123..HEAD"));
367    }
368
369    #[test]
370    fn test_diff_with_empty_start_commit() {
371        let reference = DiffContentReference::from_diff(
372            "x".repeat(MAX_INLINE_CONTENT_SIZE + 1),
373            "",
374            Path::new("/backup/diff.txt"),
375        );
376        let rendered = reference.render_for_template();
377        assert!(rendered.contains("git diff ..HEAD"));
378    }
379
380    #[test]
381    fn test_diff_exactly_max_size_is_inline() {
382        let diff = "d".repeat(MAX_INLINE_CONTENT_SIZE);
383        let reference =
384            DiffContentReference::from_diff(diff.clone(), "abc", Path::new("/backup/diff.txt"));
385        assert!(reference.is_inline());
386        assert_eq!(reference.render_for_template(), diff);
387    }
388
389    // =========================================================================
390    // PlanContentReference tests
391    // =========================================================================
392
393    #[test]
394    fn test_small_plan_is_inline() {
395        let plan = "# Plan\n\n1. Do thing".to_string();
396        let reference =
397            PlanContentReference::from_plan(plan.clone(), Path::new(".agent/PLAN.md"), None);
398        assert!(reference.is_inline());
399        assert_eq!(reference.render_for_template(), plan);
400    }
401
402    #[test]
403    fn test_large_plan_reads_from_file() {
404        let plan = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
405        let reference = PlanContentReference::from_plan(
406            plan,
407            Path::new(".agent/PLAN.md"),
408            Some(Path::new(".agent/tmp/plan.xml")),
409        );
410        assert!(!reference.is_inline());
411        let rendered = reference.render_for_template();
412        assert!(rendered.contains(".agent/PLAN.md"));
413        assert!(rendered.contains("plan.xml"));
414    }
415
416    #[test]
417    fn test_plan_without_xml_fallback() {
418        let reference = PlanContentReference::from_plan(
419            "x".repeat(MAX_INLINE_CONTENT_SIZE + 1),
420            Path::new(".agent/PLAN.md"),
421            None,
422        );
423        let rendered = reference.render_for_template();
424        assert!(rendered.contains(".agent/PLAN.md"));
425        assert!(!rendered.contains("plan.xml"));
426    }
427
428    #[test]
429    fn test_plan_exactly_max_size_is_inline() {
430        let plan = "p".repeat(MAX_INLINE_CONTENT_SIZE);
431        let reference =
432            PlanContentReference::from_plan(plan.clone(), Path::new(".agent/PLAN.md"), None);
433        assert!(reference.is_inline());
434        assert_eq!(reference.render_for_template(), plan);
435    }
436}