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                // NOTE: The pipeline prefers writing the full diff to `path` so agents don't
156                // need git. The git commands below are a *last resort* fallback.
157                //
158                // We want the diff from `start_commit` to the current state on disk,
159                // including staged + unstaged changes (and untracked, if any).
160                // There is no single git command that covers all of that in every case,
161                // so we provide the minimal set.
162                if start_commit.is_empty() {
163                    format!(
164                        "[DIFF too large to embed - Read from file]\n\
165                         {}\n\n\
166                         Read the diff from: {}\n\
167                         If this file is missing or unavailable, regenerate it with git (last resort):\n\
168                         - Unstaged changes: git diff\n\
169                         - Staged changes:   git diff --cached\n\
170                         - Untracked files:  git ls-files --others --exclude-standard\n",
171                        description,
172                        path.display(),
173                    )
174                } else {
175                    format!(
176                        "[DIFF too large to embed - Read from file]\n\
177                         {}\n\n\
178                         Read the diff from: {}\n\
179                         If this file is missing or unavailable, regenerate it with git (last resort):\n\
180                         - Unstaged changes: git diff {}\n\
181                         - Staged changes:   git diff --cached {}\n\
182                         - Untracked files:  git ls-files --others --exclude-standard\n",
183                        description,
184                        path.display(),
185                        start_commit,
186                        start_commit,
187                    )
188                }
189            }
190        }
191    }
192
193    /// Returns true if this is an inline reference.
194    pub fn is_inline(&self) -> bool {
195        matches!(self, Self::Inline(_))
196    }
197}
198
199/// Specialized reference for PLAN content.
200///
201/// When PLAN is too large, instructs the agent to read from PLAN.md
202/// with optional fallback to the XML plan file.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum PlanContentReference {
205    /// PLAN is small enough to embed inline.
206    Inline(String),
207    /// PLAN is too large; agent should read from file.
208    ReadFromFile {
209        /// Primary path to the plan file (usually .agent/PLAN.md).
210        primary_path: PathBuf,
211        /// Optional fallback path if primary is missing (usually .agent/tmp/plan.xml).
212        fallback_path: Option<PathBuf>,
213        /// Description of why file reading is needed.
214        description: String,
215    },
216}
217
218impl PlanContentReference {
219    /// Create a plan reference, choosing inline vs file path based on size.
220    ///
221    /// If `plan_content.len() <= MAX_INLINE_CONTENT_SIZE`, the plan is stored inline.
222    /// Otherwise, instructions to read from file are provided.
223    ///
224    /// # Arguments
225    ///
226    /// * `plan_content` - The plan content
227    /// * `plan_path` - Path to the primary plan file
228    /// * `xml_fallback_path` - Optional path to XML fallback
229    pub fn from_plan(
230        plan_content: String,
231        plan_path: &Path,
232        xml_fallback_path: Option<&Path>,
233    ) -> Self {
234        if plan_content.len() <= MAX_INLINE_CONTENT_SIZE {
235            Self::Inline(plan_content)
236        } else {
237            Self::ReadFromFile {
238                primary_path: plan_path.to_path_buf(),
239                fallback_path: xml_fallback_path.map(|p| p.to_path_buf()),
240                description: format!(
241                    "Plan is {} bytes (exceeds {} limit)",
242                    plan_content.len(),
243                    MAX_INLINE_CONTENT_SIZE
244                ),
245            }
246        }
247    }
248
249    /// Get the content for template rendering.
250    ///
251    /// For inline: returns the plan content directly.
252    /// For file path: returns instructions to read from the file.
253    pub fn render_for_template(&self) -> String {
254        match self {
255            Self::Inline(content) => content.clone(),
256            Self::ReadFromFile {
257                primary_path,
258                fallback_path,
259                description,
260            } => {
261                let fallback_msg = fallback_path.as_ref().map_or(String::new(), |p| {
262                    format!(
263                        "\nIf {} is missing or empty, try reading: {}",
264                        primary_path.display(),
265                        p.display()
266                    )
267                });
268                format!(
269                    "[PLAN too large to embed - Read from file]\n\
270                     {}\n\n\
271                     Read the implementation plan from: {}{}\n\n\
272                     Use your file reading tools to access the plan.",
273                    description,
274                    primary_path.display(),
275                    fallback_msg
276                )
277            }
278        }
279    }
280
281    /// Returns true if this is an inline reference.
282    pub fn is_inline(&self) -> bool {
283        matches!(self, Self::Inline(_))
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    // =========================================================================
292    // PromptContentReference tests
293    // =========================================================================
294
295    #[test]
296    fn test_small_content_is_inline() {
297        let content = "Small content".to_string();
298        let reference = PromptContentReference::from_content(
299            content.clone(),
300            Path::new("/backup/path"),
301            "test",
302        );
303        assert!(reference.is_inline());
304        assert_eq!(reference.render_for_template(), content);
305    }
306
307    #[test]
308    fn test_large_content_becomes_file_path() {
309        let content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
310        let reference = PromptContentReference::from_content(
311            content,
312            Path::new("/backup/prompt.md"),
313            "User requirements",
314        );
315        assert!(!reference.is_inline());
316        let rendered = reference.render_for_template();
317        assert!(rendered.contains("/backup/prompt.md"));
318        assert!(rendered.contains("User requirements"));
319    }
320
321    #[test]
322    fn test_exactly_max_size_is_inline() {
323        let content = "x".repeat(MAX_INLINE_CONTENT_SIZE);
324        let reference = PromptContentReference::from_content(
325            content.clone(),
326            Path::new("/backup/path"),
327            "test",
328        );
329        assert!(reference.is_inline());
330    }
331
332    #[test]
333    fn test_empty_content_is_inline() {
334        let reference =
335            PromptContentReference::from_content(String::new(), Path::new("/backup"), "test");
336        assert!(reference.is_inline());
337        assert_eq!(reference.render_for_template(), "");
338    }
339
340    #[test]
341    fn test_unicode_content_size_in_bytes() {
342        // Unicode characters take multiple bytes
343        // 🎉 is 4 bytes in UTF-8
344        let emoji = "🎉".repeat(MAX_INLINE_CONTENT_SIZE / 4 + 1);
345        let reference = PromptContentReference::from_content(emoji, Path::new("/backup"), "test");
346        // Should exceed limit due to multi-byte characters
347        assert!(!reference.is_inline());
348    }
349
350    #[test]
351    fn test_prompt_inline_constructor() {
352        let content = "Direct content".to_string();
353        let reference = PromptContentReference::inline(content.clone());
354        assert!(reference.is_inline());
355        assert_eq!(reference.render_for_template(), content);
356    }
357
358    #[test]
359    fn test_prompt_file_path_constructor() {
360        let path = PathBuf::from("/path/to/file.md");
361        let reference = PromptContentReference::file_path(path.clone(), "Description");
362        assert!(!reference.is_inline());
363        let rendered = reference.render_for_template();
364        assert!(rendered.contains("/path/to/file.md"));
365        assert!(rendered.contains("Description"));
366    }
367
368    // =========================================================================
369    // DiffContentReference tests
370    // =========================================================================
371
372    #[test]
373    fn test_small_diff_is_inline() {
374        let diff = "+added line\n-removed line".to_string();
375        let reference =
376            DiffContentReference::from_diff(diff.clone(), "abc123", Path::new("/backup/diff.txt"));
377        assert!(reference.is_inline());
378        assert_eq!(reference.render_for_template(), diff);
379    }
380
381    #[test]
382    fn test_large_diff_reads_from_file_with_git_fallback() {
383        let diff = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
384        let reference =
385            DiffContentReference::from_diff(diff, "abc123", Path::new("/backup/diff.txt"));
386        assert!(!reference.is_inline());
387        let rendered = reference.render_for_template();
388        assert!(rendered.contains("/backup/diff.txt"));
389        assert!(rendered.contains("git diff abc123"));
390        assert!(rendered.contains("git diff --cached abc123"));
391    }
392
393    #[test]
394    fn test_diff_with_empty_start_commit() {
395        let reference = DiffContentReference::from_diff(
396            "x".repeat(MAX_INLINE_CONTENT_SIZE + 1),
397            "",
398            Path::new("/backup/diff.txt"),
399        );
400        let rendered = reference.render_for_template();
401        assert!(rendered.contains("Unstaged changes: git diff"));
402        assert!(rendered.contains("Staged changes:   git diff --cached"));
403    }
404
405    #[test]
406    fn test_diff_exactly_max_size_is_inline() {
407        let diff = "d".repeat(MAX_INLINE_CONTENT_SIZE);
408        let reference =
409            DiffContentReference::from_diff(diff.clone(), "abc", Path::new("/backup/diff.txt"));
410        assert!(reference.is_inline());
411        assert_eq!(reference.render_for_template(), diff);
412    }
413
414    // =========================================================================
415    // PlanContentReference tests
416    // =========================================================================
417
418    #[test]
419    fn test_small_plan_is_inline() {
420        let plan = "# Plan\n\n1. Do thing".to_string();
421        let reference =
422            PlanContentReference::from_plan(plan.clone(), Path::new(".agent/PLAN.md"), None);
423        assert!(reference.is_inline());
424        assert_eq!(reference.render_for_template(), plan);
425    }
426
427    #[test]
428    fn test_large_plan_reads_from_file() {
429        let plan = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
430        let reference = PlanContentReference::from_plan(
431            plan,
432            Path::new(".agent/PLAN.md"),
433            Some(Path::new(".agent/tmp/plan.xml")),
434        );
435        assert!(!reference.is_inline());
436        let rendered = reference.render_for_template();
437        assert!(rendered.contains(".agent/PLAN.md"));
438        assert!(rendered.contains("plan.xml"));
439    }
440
441    #[test]
442    fn test_plan_without_xml_fallback() {
443        let reference = PlanContentReference::from_plan(
444            "x".repeat(MAX_INLINE_CONTENT_SIZE + 1),
445            Path::new(".agent/PLAN.md"),
446            None,
447        );
448        let rendered = reference.render_for_template();
449        assert!(rendered.contains(".agent/PLAN.md"));
450        assert!(!rendered.contains("plan.xml"));
451    }
452
453    #[test]
454    fn test_plan_exactly_max_size_is_inline() {
455        let plan = "p".repeat(MAX_INLINE_CONTENT_SIZE);
456        let reference =
457            PlanContentReference::from_plan(plan.clone(), Path::new(".agent/PLAN.md"), None);
458        assert!(reference.is_inline());
459        assert_eq!(reference.render_for_template(), plan);
460    }
461}