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 workspace-relative path.
30    FilePath {
31        /// Workspace-relative 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    #[must_use]
50    pub fn from_content(content: String, backup_path: &Path, description: &str) -> Self {
51        if content.len() <= MAX_INLINE_CONTENT_SIZE {
52            Self::Inline(content)
53        } else {
54            Self::FilePath {
55                path: backup_path.to_path_buf(),
56                description: description.to_string(),
57            }
58        }
59    }
60
61    /// Create an inline reference (for small content).
62    #[must_use]
63    pub const fn inline(content: String) -> Self {
64        Self::Inline(content)
65    }
66
67    /// Create a file path reference (for large content).
68    #[must_use]
69    pub fn file_path(path: PathBuf, description: &str) -> Self {
70        Self::FilePath {
71            path,
72            description: description.to_string(),
73        }
74    }
75
76    /// Returns true if this is an inline reference.
77    #[must_use]
78    pub const fn is_inline(&self) -> bool {
79        matches!(self, Self::Inline(_))
80    }
81
82    /// Get the content for template rendering.
83    ///
84    /// For inline: returns the content directly.
85    /// For file path: returns instructions to read from the file.
86    #[must_use]
87    pub fn render_for_template(&self) -> String {
88        match self {
89            Self::Inline(content) => content.clone(),
90            Self::FilePath { path, description } => {
91                format!(
92                    "[Content too large to embed - Read from: {}]\n\
93                     Description: {}\n\
94                     Use your file reading tools to access this file.",
95                    path.display(),
96                    description
97                )
98            }
99        }
100    }
101}
102
103/// Specialized reference for DIFF content.
104///
105/// When DIFF is too large, the pipeline prefers writing the full diff to a file so
106/// agents can read it without invoking git. Some prompts (e.g., review) may include
107/// git-based fallback instructions as a last resort.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum DiffContentReference {
110    /// DIFF is small enough to embed inline.
111    Inline(String),
112    /// DIFF is too large; agent should read from a file (with optional git fallback).
113    ReadFromFile {
114        /// Workspace-relative path to the diff file containing the content.
115        path: PathBuf,
116        /// The commit hash to diff from (fallback if file is missing).
117        start_commit: String,
118        /// Description of why file reading is needed.
119        description: String,
120    },
121}
122
123impl DiffContentReference {
124    /// Create a diff reference, choosing inline vs file reference based on size.
125    ///
126    /// If `diff_content.len() <= MAX_INLINE_CONTENT_SIZE`, the diff is stored inline.
127    /// Otherwise, instructions to read from a file are provided.
128    ///
129    /// # Arguments
130    ///
131    /// * `diff_content` - The diff content
132    /// * `start_commit` - The commit hash to diff from
133    #[must_use]
134    pub fn from_diff(diff_content: String, start_commit: &str, diff_path: &Path) -> Self {
135        if diff_content.len() <= MAX_INLINE_CONTENT_SIZE {
136            Self::Inline(diff_content)
137        } else {
138            Self::ReadFromFile {
139                path: diff_path.to_path_buf(),
140                start_commit: start_commit.to_string(),
141                description: format!(
142                    "Diff is {} bytes (exceeds {} limit)",
143                    diff_content.len(),
144                    MAX_INLINE_CONTENT_SIZE
145                ),
146            }
147        }
148    }
149
150    /// Get the content for template rendering.
151    ///
152    /// For inline: returns the diff content directly.
153    /// For file reference: returns instructions to read from the provided path,
154    /// plus optional git fallback commands.
155    #[must_use]
156    pub fn render_for_template(&self) -> String {
157        match self {
158            Self::Inline(content) => content.clone(),
159            Self::ReadFromFile {
160                path,
161                start_commit,
162                description,
163            } => {
164                if start_commit.is_empty() {
165                    format!(
166                        "[DIFF too large to embed - Read from file]\n\
167                         {}\n\n\
168                         Read the diff from: {}\n\
169                         If this file is missing or unavailable, regenerate it with git (last resort):\n\
170                         - Unstaged changes: git diff\n\
171                         - Staged changes:   git diff --cached\n\
172                         - Untracked files:  git ls-files --others --exclude-standard\n",
173                        description,
174                        path.display(),
175                    )
176                } else {
177                    format!(
178                        "[DIFF too large to embed - Read from file]\n\
179                         {}\n\n\
180                         Read the diff from: {}\n\
181                         If this file is missing or unavailable, regenerate it with git (last resort):\n\
182                         - Unstaged changes: git diff {}\n\
183                         - Staged changes:   git diff --cached {}\n\
184                         - Untracked files:  git ls-files --others --exclude-standard\n",
185                        description,
186                        path.display(),
187                        start_commit,
188                        start_commit,
189                    )
190                }
191            }
192        }
193    }
194
195    /// Returns true if this is an inline reference.
196    #[must_use]
197    pub const fn is_inline(&self) -> bool {
198        matches!(self, Self::Inline(_))
199    }
200}
201
202/// Specialized reference for PLAN content.
203///
204/// When PLAN is too large, instructs the agent to read from PLAN.md
205/// with optional fallback to the XML plan file.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum PlanContentReference {
208    /// PLAN is small enough to embed inline.
209    Inline(String),
210    /// PLAN is too large; agent should read from file.
211    ReadFromFile {
212        /// Primary path to the plan file (usually .agent/PLAN.md), workspace-relative.
213        primary_path: PathBuf,
214        /// Optional fallback path if primary is missing (usually .agent/tmp/plan.xml), workspace-relative.
215        fallback_path: Option<PathBuf>,
216        /// Description of why file reading is needed.
217        description: String,
218    },
219}
220
221impl PlanContentReference {
222    /// Create a plan reference, choosing inline vs file path based on size.
223    ///
224    /// If `plan_content.len() <= MAX_INLINE_CONTENT_SIZE`, the plan is stored inline.
225    /// Otherwise, instructions to read from file are provided.
226    ///
227    /// # Arguments
228    ///
229    /// * `plan_content` - The plan content
230    /// * `plan_path` - Path to the primary plan file
231    /// * `xml_fallback_path` - Optional path to XML fallback
232    #[must_use]
233    pub fn from_plan(
234        plan_content: String,
235        plan_path: &Path,
236        xml_fallback_path: Option<&Path>,
237    ) -> Self {
238        if plan_content.len() <= MAX_INLINE_CONTENT_SIZE {
239            Self::Inline(plan_content)
240        } else {
241            Self::ReadFromFile {
242                primary_path: plan_path.to_path_buf(),
243                fallback_path: xml_fallback_path.map(std::path::Path::to_path_buf),
244                description: format!(
245                    "Plan is {} bytes (exceeds {} limit)",
246                    plan_content.len(),
247                    MAX_INLINE_CONTENT_SIZE
248                ),
249            }
250        }
251    }
252
253    /// Get the content for template rendering.
254    ///
255    /// For inline: returns the plan content directly.
256    /// For file path: returns instructions to read from the file.
257    #[must_use]
258    pub fn render_for_template(&self) -> String {
259        match self {
260            Self::Inline(content) => content.clone(),
261            Self::ReadFromFile {
262                primary_path,
263                fallback_path,
264                description,
265            } => {
266                let fallback_msg = fallback_path.as_ref().map_or(String::new(), |p| {
267                    format!(
268                        "\nIf {} is missing or empty, try reading: {}",
269                        primary_path.display(),
270                        p.display()
271                    )
272                });
273                format!(
274                    "[PLAN too large to embed - Read from file]\n\
275                     {}\n\n\
276                     Read the implementation plan from: {}{}\n\n\
277                     Use your file reading tools to access the plan.",
278                    description,
279                    primary_path.display(),
280                    fallback_msg
281                )
282            }
283        }
284    }
285
286    /// Returns true if this is an inline reference.
287    #[must_use]
288    pub const fn is_inline(&self) -> bool {
289        matches!(self, Self::Inline(_))
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    // =========================================================================
298    // PromptContentReference tests
299    // =========================================================================
300
301    #[test]
302    fn test_small_content_is_inline() {
303        let content = "Small content".to_string();
304        let reference = PromptContentReference::from_content(
305            content.clone(),
306            Path::new("/backup/path"),
307            "test",
308        );
309        assert!(reference.is_inline());
310        assert_eq!(reference.render_for_template(), content);
311    }
312
313    #[test]
314    fn test_large_content_becomes_file_path() {
315        let content = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
316        let reference = PromptContentReference::from_content(
317            content,
318            Path::new("/backup/prompt.md"),
319            "User requirements",
320        );
321        assert!(!reference.is_inline());
322        let rendered = reference.render_for_template();
323        assert!(rendered.contains("/backup/prompt.md"));
324        assert!(rendered.contains("User requirements"));
325    }
326
327    #[test]
328    fn test_exactly_max_size_is_inline() {
329        let content = "x".repeat(MAX_INLINE_CONTENT_SIZE);
330        let reference =
331            PromptContentReference::from_content(content, Path::new("/backup/path"), "test");
332        assert!(reference.is_inline());
333    }
334
335    #[test]
336    fn test_empty_content_is_inline() {
337        let reference =
338            PromptContentReference::from_content(String::new(), Path::new("/backup"), "test");
339        assert!(reference.is_inline());
340        assert_eq!(reference.render_for_template(), "");
341    }
342
343    #[test]
344    fn test_unicode_content_size_in_bytes() {
345        // Unicode characters take multiple bytes
346        // 🎉 is 4 bytes in UTF-8
347        let emoji = "🎉".repeat(MAX_INLINE_CONTENT_SIZE / 4 + 1);
348        let reference = PromptContentReference::from_content(emoji, Path::new("/backup"), "test");
349        // Should exceed limit due to multi-byte characters
350        assert!(!reference.is_inline());
351    }
352
353    #[test]
354    fn test_prompt_inline_constructor() {
355        let content = "Direct content".to_string();
356        let reference = PromptContentReference::inline(content.clone());
357        assert!(reference.is_inline());
358        assert_eq!(reference.render_for_template(), content);
359    }
360
361    #[test]
362    fn test_prompt_file_path_constructor() {
363        let path = PathBuf::from("/path/to/file.md");
364        let reference = PromptContentReference::file_path(path, "Description");
365        assert!(!reference.is_inline());
366        let rendered = reference.render_for_template();
367        assert!(rendered.contains("/path/to/file.md"));
368        assert!(rendered.contains("Description"));
369    }
370
371    // =========================================================================
372    // DiffContentReference tests
373    // =========================================================================
374
375    #[test]
376    fn test_small_diff_is_inline() {
377        let diff = "+added line\n-removed line".to_string();
378        let reference =
379            DiffContentReference::from_diff(diff.clone(), "abc123", Path::new("/backup/diff.txt"));
380        assert!(reference.is_inline());
381        assert_eq!(reference.render_for_template(), diff);
382    }
383
384    #[test]
385    fn test_large_diff_reads_from_file() {
386        let diff = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
387        let reference =
388            DiffContentReference::from_diff(diff, "abc123", Path::new("/backup/diff.txt"));
389        assert!(!reference.is_inline());
390        let rendered = reference.render_for_template();
391        assert!(rendered.contains("/backup/diff.txt"));
392        assert!(rendered.contains("git diff"));
393    }
394
395    #[test]
396    fn test_diff_with_empty_start_commit_includes_git_fallback() {
397        let reference = DiffContentReference::from_diff(
398            "x".repeat(MAX_INLINE_CONTENT_SIZE + 1),
399            "",
400            Path::new("/backup/diff.txt"),
401        );
402        let rendered = reference.render_for_template();
403        assert!(rendered.contains("/backup/diff.txt"));
404        assert!(rendered.contains("Unstaged changes: git diff"));
405        assert!(rendered.contains("Staged changes:   git diff --cached"));
406    }
407
408    #[test]
409    fn test_diff_exactly_max_size_is_inline() {
410        let diff = "d".repeat(MAX_INLINE_CONTENT_SIZE);
411        let reference =
412            DiffContentReference::from_diff(diff.clone(), "abc", Path::new("/backup/diff.txt"));
413        assert!(reference.is_inline());
414        assert_eq!(reference.render_for_template(), diff);
415    }
416
417    // =========================================================================
418    // PlanContentReference tests
419    // =========================================================================
420
421    #[test]
422    fn test_small_plan_is_inline() {
423        let plan = "# Plan\n\n1. Do thing".to_string();
424        let reference =
425            PlanContentReference::from_plan(plan.clone(), Path::new(".agent/PLAN.md"), None);
426        assert!(reference.is_inline());
427        assert_eq!(reference.render_for_template(), plan);
428    }
429
430    #[test]
431    fn test_large_plan_reads_from_file() {
432        let plan = "x".repeat(MAX_INLINE_CONTENT_SIZE + 1);
433        let reference = PlanContentReference::from_plan(
434            plan,
435            Path::new(".agent/PLAN.md"),
436            Some(Path::new(".agent/tmp/plan.xml")),
437        );
438        assert!(!reference.is_inline());
439        let rendered = reference.render_for_template();
440        assert!(rendered.contains(".agent/PLAN.md"));
441        assert!(rendered.contains("plan.xml"));
442    }
443
444    #[test]
445    fn test_plan_without_xml_fallback() {
446        let reference = PlanContentReference::from_plan(
447            "x".repeat(MAX_INLINE_CONTENT_SIZE + 1),
448            Path::new(".agent/PLAN.md"),
449            None,
450        );
451        let rendered = reference.render_for_template();
452        assert!(rendered.contains(".agent/PLAN.md"));
453        assert!(!rendered.contains("plan.xml"));
454    }
455
456    #[test]
457    fn test_plan_exactly_max_size_is_inline() {
458        let plan = "p".repeat(MAX_INLINE_CONTENT_SIZE);
459        let reference =
460            PlanContentReference::from_plan(plan.clone(), Path::new(".agent/PLAN.md"), None);
461        assert!(reference.is_inline());
462        assert_eq!(reference.render_for_template(), plan);
463    }
464}