llm_coding_tools_core/
system_prompt.rs

1//! System prompt generation for LLM agents.
2//!
3//! Provides [`SystemPromptBuilder`] for tracking tools and generating formatted
4//! system prompts containing tool usage context.
5
6use crate::context::ToolContext;
7use crate::path::AllowedPathResolver;
8
9/// Entry storing tool name and context string.
10struct ContextEntry {
11    name: &'static str,
12    context: &'static str,
13}
14
15/// Builder that tracks tools and generates formatted system prompts.
16///
17/// The environment section is always included and appears before tool listings.
18///
19/// # Example
20///
21/// ```no_run
22/// use llm_coding_tools_core::context::{ToolContext, READ_ABSOLUTE};
23/// use llm_coding_tools_core::SystemPromptBuilder;
24///
25/// struct ReadTool;
26///
27/// impl ToolContext for ReadTool {
28///     const NAME: &'static str = "read";
29///
30///     fn context(&self) -> &'static str {
31///         READ_ABSOLUTE
32///     }
33/// }
34///
35/// let mut pb = SystemPromptBuilder::new()
36///     .working_directory(std::env::current_dir().unwrap().display().to_string());
37///
38/// pb.track(ReadTool);
39///
40/// let _prompt = pb.build();
41/// ```
42///
43/// # Output
44///
45/// The generated system prompt is Markdown. For example, with two tools:
46///
47/// ```text
48/// # Environment
49///
50/// Working directory: /home/user/project
51///
52/// # Tool Usage Guidelines
53///
54/// ## `Read` Tool
55/// Reads files from disk.
56/// ## `Bash` Tool
57/// Executes shell commands.
58/// ```
59#[derive(Default)]
60pub struct SystemPromptBuilder {
61    entries: Vec<ContextEntry>,
62    working_directory: Option<String>,
63    allowed_paths: Option<Vec<String>>,
64    supplemental: Vec<(&'static str, &'static str)>,
65    system_prompt: Option<String>,
66}
67
68impl SystemPromptBuilder {
69    /// Creates a new system prompt builder.
70    #[inline]
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Records context and returns tool unchanged.
76    ///
77    /// Use this to wrap tools before registering them with your tool collection:
78    /// ```no_run
79    /// use llm_coding_tools_core::context::{ToolContext, READ_ABSOLUTE};
80    /// use llm_coding_tools_core::SystemPromptBuilder;
81    ///
82    /// struct MyTool;
83    ///
84    /// impl ToolContext for MyTool {
85    ///     const NAME: &'static str = "read";
86    ///
87    ///     fn context(&self) -> &'static str {
88    ///         READ_ABSOLUTE
89    ///     }
90    /// }
91    ///
92    /// let mut pb = SystemPromptBuilder::new();
93    /// let _my_tool = pb.track(MyTool);
94    /// // register _my_tool with your tool collection
95    /// ```
96    ///
97    /// For example, if working with rig's agent builder:
98    /// ```text
99    /// let mut pb = SystemPromptBuilder::new();
100    /// let agent = client
101    ///     .agent("gpt-4o")
102    ///     .tool(pb.track(ReadTool::new()))
103    ///     .system prompt(&pb.build())
104    ///     .build();
105    /// ```
106    pub fn track<T: ToolContext>(&mut self, tool: T) -> T {
107        self.entries.push(ContextEntry {
108            name: T::NAME,
109            context: tool.context(),
110        });
111        tool
112    }
113
114    /// Adds supplemental context to the system prompt.
115    ///
116    /// Supplemental context appears in a separate "Supplemental Context" section
117    /// after tool usage guidelines. Use this for guidance that isn't inherent
118    /// to a specific tool, such as git workflows or GitHub CLI patterns.
119    ///
120    /// # Arguments
121    ///
122    /// * `name` - Section header (e.g., "Git Workflow", "GitHub CLI")
123    /// * `context` - Context string content (e.g., [`GIT_WORKFLOW`](crate::context::GIT_WORKFLOW))
124    ///
125    /// # Examples
126    ///
127    /// Adding both git and GitHub CLI context:
128    ///
129    /// ```rust
130    /// use llm_coding_tools_core::{SystemPromptBuilder, context};
131    ///
132    /// let pb = SystemPromptBuilder::new()
133    ///     .add_context("Git Workflow", context::GIT_WORKFLOW)
134    ///     .add_context("GitHub CLI", context::GITHUB_CLI);
135    ///
136    /// let prompt = pb.build();
137    /// assert!(prompt.contains("# Supplemental Context"));
138    /// assert!(prompt.contains("## Git Workflow"));
139    /// ```
140    ///
141    /// Selective inclusion - adding only Git Workflow when not using GitHub features:
142    ///
143    /// ```rust
144    /// use llm_coding_tools_core::{SystemPromptBuilder, context};
145    ///
146    /// // Only include git workflow for agents that use git but not GitHub
147    /// let pb = SystemPromptBuilder::new()
148    ///     .add_context("Git Workflow", context::GIT_WORKFLOW);
149    ///
150    /// let prompt = pb.build();
151    /// assert!(prompt.contains("## Git Workflow"));
152    /// assert!(!prompt.contains("## GitHub CLI"));
153    /// ```
154    #[inline]
155    pub fn add_context(mut self, name: &'static str, context: &'static str) -> Self {
156        self.supplemental.push((name, context));
157        self
158    }
159
160    /// Sets a custom system prompt that appears first in the generated system prompt.
161    ///
162    /// The provided prompt is prepended before all other sections (environment,
163    /// tools, supplemental context). User provides exactly what they want,
164    /// including any markdown headers - no auto-modification is applied.
165    ///
166    /// # Example
167    ///
168    /// ```rust
169    /// use llm_coding_tools_core::SystemPromptBuilder;
170    ///
171    /// let pb = SystemPromptBuilder::new()
172    ///     .system_prompt("# System Instructions\n\nYou are a helpful assistant.");
173    ///
174    /// let prompt = pb.build();
175    /// assert!(prompt.starts_with("# System Instructions"));
176    /// ```
177    #[inline]
178    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
179        self.system_prompt = Some(prompt.into());
180        self
181    }
182}
183
184impl SystemPromptBuilder {
185    /// Sets the working directory to display in the environment section.
186    ///
187    /// Accepts any type that can be converted to String, including:
188    /// - `&str`
189    /// - `String`
190    /// - `PathBuf` or `&Path` (via `.display().to_string()`)
191    ///
192    /// # Example
193    ///
194    /// ```no_run
195    /// use llm_coding_tools_core::SystemPromptBuilder;
196    ///
197    /// let _pb = SystemPromptBuilder::new()
198    ///     .working_directory("/home/user/project");
199    ///
200    /// // With runtime-computed path
201    /// let _pb = SystemPromptBuilder::new()
202    ///     .working_directory(std::env::current_dir().unwrap().display().to_string());
203    /// ```
204    #[inline]
205    pub fn working_directory(mut self, path: impl Into<String>) -> Self {
206        self.working_directory = Some(path.into());
207        self
208    }
209
210    /// Sets the allowed directories to display in the environment section.
211    ///
212    /// Takes an [`AllowedPathResolver`] reference and extracts its allowed paths
213    /// for display. Paths are already canonicalized (absolute, symlinks resolved)
214    /// by the resolver during construction.
215    ///
216    /// # Example
217    ///
218    /// ```no_run
219    /// use llm_coding_tools_core::{AllowedPathResolver, SystemPromptBuilder};
220    ///
221    /// let resolver = AllowedPathResolver::new(vec!["/home/user/project", "/tmp"]).unwrap();
222    /// let _pb = SystemPromptBuilder::new()
223    ///     .working_directory("/home/user/project")
224    ///     .allowed_paths(&resolver);
225    /// ```
226    #[inline]
227    pub fn allowed_paths(mut self, resolver: &AllowedPathResolver) -> Self {
228        // AllowedPathResolver::allowed_paths() returns &[PathBuf] where paths
229        // are already canonicalized (absolute, symlinks resolved) during
230        // AllowedPathResolver::new() construction.
231        self.allowed_paths = Some(
232            resolver
233                .allowed_paths()
234                .iter()
235                .map(|p| p.display().to_string())
236                .collect(),
237        );
238        self
239    }
240}
241
242/// Returns the separator needed to ensure exactly `\n\n` between content and next section.
243///
244/// Given a string, determines how many newlines to append so that the result
245/// ends with exactly `\n\n` (one blank line). Does not modify the user's content.
246#[inline]
247fn section_separator(s: &str) -> &'static str {
248    if s.ends_with("\n\n") {
249        ""
250    } else if s.ends_with('\n') {
251        "\n"
252    } else {
253        "\n\n"
254    }
255}
256
257impl SystemPromptBuilder {
258    /// Generates the system prompt string with environment section.
259    pub fn build(self) -> String {
260        // Environment section size: ~50 bytes header + path length
261        // "# Environment\n\nWorking directory: \n\n" = ~38 bytes
262        const ENV_HEADER_SIZE: usize = 50;
263        // "Allowed directories:\n- " per path + path length
264        const ALLOWED_DIR_PER_ITEM: usize = 25;
265
266        let system_prompt_size = self.system_prompt.as_ref().map_or(0, |p| p.len() + 2);
267
268        let env_size = if self.working_directory.is_some() || self.allowed_paths.is_some() {
269            ENV_HEADER_SIZE + self.working_directory.as_ref().map_or(0, |d| d.len())
270        } else if self.system_prompt.is_some()
271            || !self.entries.is_empty()
272            || !self.supplemental.is_empty()
273        {
274            ENV_HEADER_SIZE
275        } else {
276            0
277        };
278
279        let allowed_size = self.allowed_paths.as_ref().map_or(0, |paths| {
280            paths.iter().map(|p| p.len() + ALLOWED_DIR_PER_ITEM).sum()
281        });
282
283        let tools_size: usize = self
284            .entries
285            .iter()
286            .map(|e| e.context.len() + e.name.len() + 20)
287            .sum();
288
289        let supplemental_size: usize = self
290            .supplemental
291            .iter()
292            .map(|(n, c)| c.len() + n.len() + 20)
293            .sum();
294
295        let has_tools = !self.entries.is_empty();
296        let has_supplemental = !self.supplemental.is_empty();
297        let has_system_prompt = self.system_prompt.is_some();
298        let has_env_content = self.working_directory.is_some() || self.allowed_paths.is_some();
299
300        let total_size =
301            system_prompt_size + env_size + allowed_size + tools_size + supplemental_size + 90;
302        let mut output = String::with_capacity(total_size);
303
304        // Return empty if nothing to output
305        if !has_tools && !has_supplemental && !has_system_prompt && !has_env_content {
306            return String::new();
307        }
308
309        // System prompt (first)
310        if let Some(ref prompt) = self.system_prompt {
311            output.push_str(prompt);
312            // Smart separator: ensure exactly one blank line before next section
313            output.push_str(section_separator(prompt));
314        }
315
316        // Environment section
317        if has_env_content || has_system_prompt || has_tools || has_supplemental {
318            output.push_str("# Environment\n\n");
319
320            if let Some(ref dir) = self.working_directory {
321                output.push_str("Working directory: ");
322                output.push_str(dir);
323                output.push('\n');
324            }
325
326            if let Some(ref paths) = self.allowed_paths {
327                output.push_str("Allowed directories:\n");
328                for path in paths {
329                    output.push_str("- ");
330                    output.push_str(path);
331                    output.push('\n');
332                }
333            }
334
335            if (has_tools || has_supplemental) && has_env_content {
336                if !output.ends_with('\n') {
337                    output.push('\n');
338                }
339                output.push('\n');
340            }
341        }
342
343        // Tool section
344        if has_tools {
345            output.push_str("# Tool Usage Guidelines\n\n");
346
347            for entry in self.entries {
348                output.push_str("## `");
349                let mut chars = entry.name.chars();
350                if let Some(first) = chars.next() {
351                    output.push(first.to_ascii_uppercase());
352                    output.push_str(chars.as_str());
353                } else {
354                    output.push_str(entry.name);
355                }
356                output.push_str("` Tool\n");
357                output.push_str(entry.context);
358                if !entry.context.ends_with('\n') {
359                    output.push('\n');
360                }
361            }
362        }
363
364        // Supplemental context section
365        if has_supplemental {
366            output.push_str("\n# Supplemental Context\n");
367
368            for (name, context) in self.supplemental {
369                output.push_str("## ");
370                output.push_str(name);
371                output.push('\n');
372                output.push_str(context);
373                if !context.ends_with('\n') {
374                    output.push('\n');
375                }
376            }
377        }
378
379        output.truncate(output.trim_end().len());
380        output
381    }
382}
383
384/// Extension trait for placeholder substitution on system prompt strings.
385///
386/// Provides simple `{key}` placeholder replacement after building a system prompt.
387/// Unmatched placeholders are left as-is.
388///
389/// # Example
390///
391/// ```rust
392/// use llm_coding_tools_core::Substitute;
393///
394/// let text = "Available agents: {agents}".to_string();
395/// let result = text
396///     .substitute("agents", "code-review, research")
397///     .substitute("missing", "ignored");
398///
399/// assert_eq!(result, "Available agents: code-review, research");
400/// ```
401pub trait Substitute {
402    /// Replaces `{key}` placeholder with the given value.
403    ///
404    /// Returns a new String with the substitution applied.
405    /// If the placeholder is not found, returns the string unchanged.
406    fn substitute(self, key: &str, value: &str) -> String;
407
408    /// Replaces multiple `{key}` placeholders with their values.
409    ///
410    /// Accepts an iterator of (key, value) pairs.
411    fn substitute_all<'a>(
412        self,
413        substitutions: impl IntoIterator<Item = (&'a str, &'a str)>,
414    ) -> String;
415}
416
417impl Substitute for String {
418    #[inline]
419    fn substitute(self, key: &str, value: &str) -> String {
420        let placeholder = format!("{{{}}}", key);
421        self.replace(&placeholder, value)
422    }
423
424    fn substitute_all<'a>(
425        mut self,
426        substitutions: impl IntoIterator<Item = (&'a str, &'a str)>,
427    ) -> String {
428        for (key, value) in substitutions {
429            let placeholder = format!("{{{}}}", key);
430            self = self.replace(&placeholder, value);
431        }
432        self
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    struct MockTool {
441        id: u32,
442    }
443
444    impl ToolContext for MockTool {
445        const NAME: &'static str = "mock";
446        fn context(&self) -> &'static str {
447            "Mock tool context."
448        }
449    }
450
451    struct OtherTool;
452
453    impl ToolContext for OtherTool {
454        const NAME: &'static str = "other";
455        fn context(&self) -> &'static str {
456            "Other context."
457        }
458    }
459
460    #[test]
461    fn empty_builder_returns_empty_string() {
462        let preamble = SystemPromptBuilder::new().build();
463        assert!(preamble.is_empty());
464    }
465
466    #[test]
467    fn track_returns_tool_unchanged() {
468        let mut pb = SystemPromptBuilder::new();
469        let tool = MockTool { id: 42 };
470        let returned = pb.track(tool);
471        assert_eq!(returned.id, 42);
472    }
473
474    #[test]
475    fn single_tool_formats_correctly() {
476        let mut pb = SystemPromptBuilder::new().working_directory("/home/user");
477        let _ = pb.track(MockTool { id: 1 });
478        let preamble = pb.build();
479
480        assert!(preamble.contains("# Environment"));
481        assert!(preamble.contains("Working directory: /home/user"));
482        assert!(preamble.contains("# Tool Usage Guidelines"));
483        assert!(preamble.contains("## `Mock` Tool"));
484        assert!(preamble.contains("Mock tool context."));
485    }
486
487    #[test]
488    fn multiple_tools_preserve_order() {
489        let mut pb = SystemPromptBuilder::new().working_directory("/home/user");
490        let _ = pb.track(MockTool { id: 1 });
491        let _ = pb.track(OtherTool);
492        let preamble = pb.build();
493
494        let mock_pos = preamble.find("## `Mock` Tool").unwrap();
495        let other_pos = preamble.find("## `Other` Tool").unwrap();
496        assert!(
497            mock_pos < other_pos,
498            "Tools should appear in insertion order"
499        );
500    }
501
502    #[test]
503    fn multiple_tools_have_single_newline_between() {
504        let mut pb = SystemPromptBuilder::new().working_directory("/home/user");
505        let _ = pb.track(MockTool { id: 1 });
506        let _ = pb.track(OtherTool);
507        let preamble = pb.build();
508
509        // Verify exact transition: context ends, then next tool header
510        assert!(
511            preamble.contains("Mock tool context.\n## `Other` Tool"),
512            "Expected single newline between tool sections.\nGot:\n{preamble}"
513        );
514
515        // Verify single newline after tool header
516        assert!(
517            preamble.contains("## `Mock` Tool\nMock tool context."),
518            "Expected single newline after tool header.\nGot:\n{preamble}"
519        );
520
521        // Verify blank line after section header
522        assert!(
523            preamble.contains("# Tool Usage Guidelines\n\n## `Mock` Tool"),
524            "Expected blank line after section header.\nGot:\n{preamble}"
525        );
526
527        // Verify no trailing whitespace at end of preamble
528        assert_eq!(
529            preamble,
530            preamble.trim_end(),
531            "Preamble has trailing whitespace"
532        );
533    }
534
535    #[test]
536    fn multiple_tools_with_working_dir_have_single_newline_between() {
537        let mut pb = SystemPromptBuilder::new().working_directory("/test");
538        let _ = pb.track(MockTool { id: 1 });
539        let _ = pb.track(OtherTool);
540        let preamble = pb.build();
541
542        // Verify exact transition: context ends, then next tool header
543        assert!(
544            preamble.contains("Mock tool context.\n## `Other` Tool"),
545            "Expected single newline between tool sections.\nGot:\n{preamble}"
546        );
547
548        // Verify single newline after tool header
549        assert!(
550            preamble.contains("## `Mock` Tool\nMock tool context."),
551            "Expected single newline after tool header.\nGot:\n{preamble}"
552        );
553
554        // Verify blank line after Environment header
555        assert!(
556            preamble.contains("# Environment\n\nWorking directory:"),
557            "Expected blank line after Environment header.\nGot:\n{preamble}"
558        );
559
560        // Verify blank line after Tool Usage Guidelines header
561        assert!(
562            preamble.contains("# Tool Usage Guidelines\n\n## `Mock` Tool"),
563            "Expected blank line after section header.\nGot:\n{preamble}"
564        );
565
566        // Verify no trailing whitespace at end of preamble
567        assert_eq!(
568            preamble,
569            preamble.trim_end(),
570            "Preamble has trailing whitespace"
571        );
572    }
573
574    #[test]
575    fn builder_includes_environment_section() {
576        let mut pb = SystemPromptBuilder::new().working_directory("/home/user/project");
577        let _ = pb.track(MockTool { id: 1 });
578        let preamble = pb.build();
579
580        assert!(preamble.contains("# Environment"));
581        assert!(preamble.contains("Working directory: /home/user/project"));
582        // Environment should come before tools
583        let env_pos = preamble.find("# Environment").unwrap();
584        let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
585        assert!(env_pos < tools_pos);
586    }
587
588    #[test]
589    fn builder_without_env_data_and_tools_returns_empty() {
590        let pb = SystemPromptBuilder::new();
591        let preamble = pb.build();
592        assert!(preamble.is_empty());
593    }
594
595    #[test]
596    fn builder_with_working_dir_but_no_tools() {
597        // Environment section should render even without tools tracked
598        let pb = SystemPromptBuilder::new().working_directory("/home/user/project");
599        let preamble = pb.build();
600
601        assert!(preamble.contains("# Environment"));
602        assert!(preamble.contains("Working directory: /home/user/project"));
603        assert!(!preamble.contains("# Tool Usage Guidelines"));
604    }
605
606    #[test]
607    fn working_directory_accepts_runtime_string() {
608        // Simulates std::env::current_dir().unwrap().display().to_string()
609        let runtime_path = String::from("/runtime/computed/path");
610        let pb = SystemPromptBuilder::new().working_directory(runtime_path);
611        let preamble = pb.build();
612
613        assert!(preamble.contains("Working directory: /runtime/computed/path"));
614    }
615
616    #[test]
617    fn working_directory_accepts_str() {
618        let pb = SystemPromptBuilder::new().working_directory("/static/path");
619        let preamble = pb.build();
620
621        assert!(preamble.contains("Working directory: /static/path"));
622    }
623
624    #[test]
625    fn substitute_replaces_single_placeholder() {
626        use super::Substitute;
627
628        let text = "Hello {name}!".to_string();
629        let result = text.substitute("name", "World");
630        assert_eq!(result, "Hello World!");
631    }
632
633    #[test]
634    fn substitute_leaves_unmatched_placeholders() {
635        use super::Substitute;
636
637        let text = "Hello {name}, welcome to {place}!".to_string();
638        let result = text.substitute("name", "Alice");
639        assert_eq!(result, "Hello Alice, welcome to {place}!");
640    }
641
642    #[test]
643    fn substitute_handles_empty_value() {
644        use super::Substitute;
645
646        let text = "Prefix{middle}Suffix".to_string();
647        let result = text.substitute("middle", "");
648        assert_eq!(result, "PrefixSuffix");
649    }
650
651    #[test]
652    fn substitute_all_replaces_multiple() {
653        use super::Substitute;
654
655        let text = "Hello {name}, welcome to {place}!".to_string();
656        let result = text.substitute_all([("name", "Alice"), ("place", "Wonderland")]);
657        assert_eq!(result, "Hello Alice, welcome to Wonderland!");
658    }
659
660    #[test]
661    fn substitute_no_placeholder_returns_unchanged() {
662        use super::Substitute;
663
664        let text = "No placeholders here".to_string();
665        let result = text.substitute("missing", "value");
666        assert_eq!(result, "No placeholders here");
667    }
668
669    #[test]
670    fn default_builder_compiles() {
671        let _pb_default: SystemPromptBuilder = SystemPromptBuilder::new();
672    }
673
674    #[test]
675    fn backwards_compatibility_existing_api() {
676        // Existing code should work unchanged
677        let mut pb = SystemPromptBuilder::new();
678        let _ = pb.track(MockTool { id: 1 });
679        let preamble = pb.build();
680
681        assert!(preamble.contains("# Tool Usage Guidelines"));
682        assert!(preamble.contains("## `Mock` Tool"));
683    }
684
685    #[test]
686    fn builder_with_allowed_paths_shows_paths() {
687        use tempfile::TempDir;
688
689        let dir = TempDir::new().unwrap();
690        let resolver = AllowedPathResolver::new(vec![dir.path()]).unwrap();
691
692        let pb = SystemPromptBuilder::new()
693            .working_directory("/home/user")
694            .allowed_paths(&resolver);
695        let preamble = pb.build();
696
697        assert!(preamble.contains("# Environment"));
698        assert!(preamble.contains("Working directory: /home/user"));
699        assert!(preamble.contains("Allowed directories:"));
700        // Check that the temp dir path appears (canonicalized)
701        assert!(preamble.contains(&dir.path().canonicalize().unwrap().display().to_string()));
702    }
703
704    #[test]
705    fn builder_with_only_allowed_paths_no_working_dir() {
706        use tempfile::TempDir;
707
708        let dir = TempDir::new().unwrap();
709        let resolver = AllowedPathResolver::new(vec![dir.path()]).unwrap();
710
711        let pb = SystemPromptBuilder::new().allowed_paths(&resolver);
712        let preamble = pb.build();
713
714        assert!(preamble.contains("# Environment"));
715        assert!(!preamble.contains("Working directory:"));
716        assert!(preamble.contains("Allowed directories:"));
717    }
718
719    #[test]
720    fn allowed_paths_format_is_bulleted_absolute_paths() {
721        use std::path::Path;
722        use tempfile::TempDir;
723
724        let dir1 = TempDir::new().unwrap();
725        let dir2 = TempDir::new().unwrap();
726        let resolver = AllowedPathResolver::new(vec![dir1.path(), dir2.path()]).unwrap();
727
728        let pb = SystemPromptBuilder::new().allowed_paths(&resolver);
729        let preamble = pb.build();
730
731        // Check format: "- <absolute_path>" (cross-platform)
732        let lines: Vec<&str> = preamble.lines().collect();
733        let allowed_idx = lines
734            .iter()
735            .position(|l| l.contains("Allowed directories"))
736            .unwrap();
737
738        for i in 1..=2 {
739            let line = lines[allowed_idx + i];
740            assert!(
741                line.starts_with("- "),
742                "Line should start with '- ': {}",
743                line
744            );
745            let path_str = line.strip_prefix("- ").unwrap();
746            assert!(
747                Path::new(path_str).is_absolute(),
748                "Path should be absolute: {}",
749                path_str
750            );
751        }
752    }
753
754    #[test]
755    fn allowed_paths_appears_after_working_directory() {
756        use tempfile::TempDir;
757
758        let dir = TempDir::new().unwrap();
759        let resolver = AllowedPathResolver::new(vec![dir.path()]).unwrap();
760
761        let pb = SystemPromptBuilder::new()
762            .working_directory("/home/user")
763            .allowed_paths(&resolver);
764        let preamble = pb.build();
765
766        let working_dir_pos = preamble.find("Working directory:").unwrap();
767        let allowed_pos = preamble.find("Allowed directories:").unwrap();
768        assert!(
769            working_dir_pos < allowed_pos,
770            "Working directory should appear before allowed paths"
771        );
772    }
773
774    #[test]
775    fn builder_with_only_working_dir_no_allowed_paths() {
776        // Only working_directory() should not render "Allowed directories:" section
777        let pb = SystemPromptBuilder::new().working_directory("/home/user/project");
778        let preamble = pb.build();
779
780        assert!(preamble.contains("# Environment"));
781        assert!(preamble.contains("Working directory: /home/user/project"));
782        assert!(
783            !preamble.contains("Allowed directories:"),
784            "Should not render Allowed directories when not explicitly set"
785        );
786    }
787
788    #[test]
789    fn add_context_includes_supplemental_section() {
790        let pb = SystemPromptBuilder::new()
791            .working_directory("/home/user")
792            .add_context("Git Workflow", "Git guidance content.");
793
794        let preamble = pb.build();
795
796        assert!(preamble.contains("# Supplemental Context"));
797        assert!(preamble.contains("## Git Workflow"));
798        assert!(preamble.contains("Git guidance content."));
799    }
800
801    #[test]
802    fn add_context_appears_after_tools() {
803        let mut pb = SystemPromptBuilder::new().add_context("Git Workflow", "Git guidance.");
804        let _ = pb.track(MockTool { id: 1 });
805
806        let preamble = pb.build();
807
808        let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
809        let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
810        assert!(
811            tools_pos < supplemental_pos,
812            "Tools should appear before supplemental context"
813        );
814    }
815
816    #[test]
817    fn add_context_multiple_sections_preserve_order() {
818        let pb = SystemPromptBuilder::new()
819            .working_directory("/home/user")
820            .add_context("Git Workflow", "Git content.")
821            .add_context("GitHub CLI", "GitHub content.");
822
823        let preamble = pb.build();
824
825        let git_pos = preamble.find("## Git Workflow").unwrap();
826        let github_pos = preamble.find("## GitHub CLI").unwrap();
827        assert!(
828            git_pos < github_pos,
829            "Contexts should appear in insertion order"
830        );
831    }
832
833    #[test]
834    fn add_context_only_no_tools() {
835        let pb = SystemPromptBuilder::new()
836            .working_directory("/home/user")
837            .add_context("Git Workflow", "Git guidance.");
838
839        let preamble = pb.build();
840
841        assert!(!preamble.contains("# Tool Usage Guidelines"));
842        assert!(preamble.contains("# Supplemental Context"));
843        assert!(preamble.contains("## Git Workflow"));
844    }
845
846    #[test]
847    fn add_context_with_env_section() {
848        let pb = SystemPromptBuilder::new()
849            .working_directory("/home/user")
850            .add_context("Git Workflow", "Git guidance.");
851
852        let preamble = pb.build();
853
854        let env_pos = preamble.find("# Environment").unwrap();
855        let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
856        assert!(env_pos < supplemental_pos);
857    }
858
859    #[test]
860    fn add_context_with_env_and_tools() {
861        let mut pb = SystemPromptBuilder::new()
862            .working_directory("/home/user")
863            .add_context("Git Workflow", "Git guidance.");
864        let _ = pb.track(MockTool { id: 1 });
865
866        let preamble = pb.build();
867
868        let env_pos = preamble.find("# Environment").unwrap();
869        let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
870        let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
871
872        assert!(env_pos < tools_pos);
873        assert!(tools_pos < supplemental_pos);
874    }
875
876    #[test]
877    fn add_context_no_triple_newlines() {
878        let mut pb = SystemPromptBuilder::new()
879            .working_directory("/home/user")
880            .add_context("Git Workflow", "Git guidance.\n");
881        let _ = pb.track(MockTool { id: 1 });
882
883        let preamble = pb.build();
884
885        assert!(
886            !preamble.contains("\n\n\n"),
887            "Found triple newline in preamble.\nGot:\n{preamble}"
888        );
889    }
890
891    #[test]
892    fn add_context_chains_fluently() {
893        // Verify fluent chaining works
894        let pb = SystemPromptBuilder::new()
895            .add_context("A", "a")
896            .add_context("B", "b")
897            .add_context("C", "c");
898
899        let preamble = pb.build();
900
901        assert!(preamble.contains("## A"));
902        assert!(preamble.contains("## B"));
903        assert!(preamble.contains("## C"));
904    }
905
906    #[test]
907    fn add_context_with_actual_git_workflow_constant() {
908        use crate::context::GIT_WORKFLOW;
909
910        let pb = SystemPromptBuilder::new()
911            .working_directory("/home/user")
912            .add_context("Git Workflow", GIT_WORKFLOW);
913
914        let preamble = pb.build();
915
916        assert!(preamble.contains("# Supplemental Context"));
917        assert!(preamble.contains("## Git Workflow"));
918        // Verify actual content from git_workflow.txt is included
919        assert!(
920            preamble.contains("Only create commits when requested"),
921            "Should contain git commit workflow content"
922        );
923        assert!(
924            preamble.contains("Git Safety Protocol"),
925            "Should contain safety protocol section"
926        );
927    }
928
929    #[test]
930    fn add_context_with_actual_github_cli_constant() {
931        use crate::context::GITHUB_CLI;
932
933        let pb = SystemPromptBuilder::new()
934            .working_directory("/home/user")
935            .add_context("GitHub CLI", GITHUB_CLI);
936
937        let preamble = pb.build();
938
939        assert!(preamble.contains("# Supplemental Context"));
940        assert!(preamble.contains("## GitHub CLI"));
941        // Verify actual content from github_cli.txt is included
942        assert!(
943            preamble.contains("gh pr create"),
944            "Should contain gh pr create example"
945        );
946    }
947
948    #[test]
949    fn add_context_selective_inclusion_git_only() {
950        use crate::context::{GITHUB_CLI, GIT_WORKFLOW};
951
952        // Only include git workflow (not GitHub CLI)
953        let pb = SystemPromptBuilder::new()
954            .working_directory("/home/user")
955            .add_context("Git Workflow", GIT_WORKFLOW);
956
957        let preamble = pb.build();
958
959        assert!(preamble.contains("## Git Workflow"));
960        assert!(!preamble.contains("## GitHub CLI"));
961        assert!(!preamble.contains(GITHUB_CLI));
962    }
963
964    #[test]
965    fn add_context_both_git_and_github() {
966        use crate::context::{GITHUB_CLI, GIT_WORKFLOW};
967
968        let pb = SystemPromptBuilder::new()
969            .working_directory("/home/user")
970            .add_context("Git Workflow", GIT_WORKFLOW)
971            .add_context("GitHub CLI", GITHUB_CLI);
972
973        let preamble = pb.build();
974
975        assert!(preamble.contains("## Git Workflow"));
976        assert!(preamble.contains("## GitHub CLI"));
977        // Verify order preserved
978        let git_pos = preamble.find("## Git Workflow").unwrap();
979        let github_pos = preamble.find("## GitHub CLI").unwrap();
980        assert!(
981            git_pos < github_pos,
982            "Git Workflow should appear before GitHub CLI"
983        );
984    }
985
986    #[test]
987    fn system_prompt_appears_first() {
988        let pb = SystemPromptBuilder::new()
989            .system_prompt("# System Instructions\n\nYou are a helpful assistant.")
990            .working_directory("/home/user");
991
992        let preamble = pb.build();
993
994        assert!(
995            preamble.starts_with("# System Instructions"),
996            "System prompt should appear first.\nGot:\n{preamble}"
997        );
998
999        let system_pos = preamble.find("# System Instructions").unwrap();
1000        let env_pos = preamble.find("# Environment").unwrap();
1001        assert!(
1002            system_pos < env_pos,
1003            "System prompt should appear before environment section"
1004        );
1005    }
1006
1007    #[test]
1008    fn system_prompt_appears_before_tools() {
1009        let mut pb =
1010            SystemPromptBuilder::new().system_prompt("# Custom Header\n\nMy custom instructions.");
1011        let _ = pb.track(MockTool { id: 1 });
1012
1013        let preamble = pb.build();
1014
1015        let system_pos = preamble.find("# Custom Header").unwrap();
1016        let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
1017        assert!(
1018            system_pos < tools_pos,
1019            "System prompt should appear before tools section"
1020        );
1021    }
1022
1023    #[test]
1024    fn system_prompt_no_modification() {
1025        // User provides exact content, no auto-header added
1026        let custom = "My custom content without header";
1027        let pb = SystemPromptBuilder::new().system_prompt(custom);
1028
1029        let preamble = pb.build();
1030
1031        assert!(
1032            preamble.starts_with("My custom content without header"),
1033            "System prompt should not be modified.\nGot:\n{preamble}"
1034        );
1035    }
1036
1037    #[test]
1038    fn system_prompt_optional_default_behavior() {
1039        // Without system_prompt, existing behavior preserved
1040        let mut pb = SystemPromptBuilder::new();
1041        let _ = pb.track(MockTool { id: 1 });
1042
1043        let preamble = pb.build();
1044
1045        assert!(
1046            preamble.starts_with("# Environment"),
1047            "Without system prompt, should start with Environment.\nGot:\n{preamble}"
1048        );
1049    }
1050
1051    #[test]
1052    fn system_prompt_only_produces_output() {
1053        let pb = SystemPromptBuilder::new()
1054            .system_prompt("# Just Instructions\n\nOnly system prompt, no tools.");
1055
1056        let preamble = pb.build();
1057
1058        assert!(!preamble.is_empty());
1059        assert!(preamble.contains("# Just Instructions"));
1060        assert!(!preamble.contains("# Tool Usage Guidelines"));
1061    }
1062
1063    #[test]
1064    fn system_prompt_with_env_and_tools_and_supplemental() {
1065        let mut pb = SystemPromptBuilder::new()
1066            .system_prompt("# System\n\nInstructions.")
1067            .working_directory("/home/user")
1068            .add_context("Git Workflow", "Git guidance.");
1069        let _ = pb.track(MockTool { id: 1 });
1070
1071        let preamble = pb.build();
1072
1073        let system_pos = preamble.find("# System").unwrap();
1074        let env_pos = preamble.find("# Environment").unwrap();
1075        let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
1076        let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
1077
1078        assert!(system_pos < env_pos);
1079        assert!(env_pos < tools_pos);
1080        assert!(tools_pos < supplemental_pos);
1081    }
1082
1083    #[test]
1084    fn system_prompt_no_trailing_newline_gets_separator() {
1085        // System prompt without trailing newline should get "\n\n" separator
1086        let mut pb = SystemPromptBuilder::new().system_prompt("# System\n\nNo trailing newline");
1087        let _ = pb.track(MockTool { id: 1 });
1088
1089        let preamble = pb.build();
1090
1091        // Should have exactly one blank line between system prompt and environment
1092        assert!(
1093            preamble.contains("No trailing newline\n\n# Environment"),
1094            "Expected one blank line after system prompt.\nGot:\n{preamble}"
1095        );
1096        assert!(
1097            !preamble.contains("\n\n\n"),
1098            "Found triple newline in preamble.\nGot:\n{preamble}"
1099        );
1100    }
1101
1102    #[test]
1103    fn system_prompt_single_trailing_newline_gets_one_more() {
1104        // System prompt ending with \n should get "\n" to make "\n\n"
1105        let mut pb =
1106            SystemPromptBuilder::new().system_prompt("# System\n\nEnds with single newline\n");
1107        let _ = pb.track(MockTool { id: 1 });
1108
1109        let preamble = pb.build();
1110
1111        // Should have exactly one blank line between system prompt and environment
1112        assert!(
1113            preamble.contains("Ends with single newline\n\n# Environment"),
1114            "Expected one blank line after system prompt.\nGot:\n{preamble}"
1115        );
1116        assert!(
1117            !preamble.contains("\n\n\n"),
1118            "Found triple newline in preamble.\nGot:\n{preamble}"
1119        );
1120    }
1121
1122    #[test]
1123    fn system_prompt_double_trailing_newline_no_extra() {
1124        // System prompt ending with \n\n should get no extra separator
1125        let mut pb =
1126            SystemPromptBuilder::new().system_prompt("# System\n\nEnds with double newline\n\n");
1127        let _ = pb.track(MockTool { id: 1 });
1128
1129        let preamble = pb.build();
1130
1131        // Should have exactly one blank line between system prompt and environment
1132        assert!(
1133            preamble.contains("Ends with double newline\n\n# Environment"),
1134            "Expected one blank line after system prompt.\nGot:\n{preamble}"
1135        );
1136        assert!(
1137            !preamble.contains("\n\n\n"),
1138            "Found triple newline in preamble.\nGot:\n{preamble}"
1139        );
1140    }
1141
1142    #[test]
1143    fn system_prompt_trailing_newlines_with_environment() {
1144        let pb = SystemPromptBuilder::new()
1145            .system_prompt("# System\n\nEnds with single newline\n")
1146            .working_directory("/home/user");
1147
1148        let preamble = pb.build();
1149
1150        assert!(
1151            preamble.contains("Ends with single newline\n\n# Environment"),
1152            "Expected one blank line after system prompt.\nGot:\n{preamble}"
1153        );
1154        assert!(
1155            !preamble.contains("\n\n\n"),
1156            "Found triple newline in preamble.\nGot:\n{preamble}"
1157        );
1158    }
1159
1160    #[test]
1161    fn system_prompt_chains_fluently() {
1162        // Verify fluent chaining with other methods
1163        let pb = SystemPromptBuilder::new()
1164            .system_prompt("# System\n\nContent.")
1165            .working_directory("/home/user")
1166            .add_context("A", "a");
1167
1168        let preamble = pb.build();
1169
1170        assert!(preamble.contains("# System"));
1171        assert!(preamble.contains("# Environment"));
1172        assert!(preamble.contains("# Supplemental Context"));
1173    }
1174
1175    #[test]
1176    fn section_separator_returns_correct_suffix() {
1177        // Direct unit test for section_separator helper
1178        assert_eq!(section_separator("no newline"), "\n\n");
1179        assert_eq!(section_separator("single newline\n"), "\n");
1180        assert_eq!(section_separator("double newline\n\n"), "");
1181        assert_eq!(section_separator("triple newline\n\n\n"), "");
1182        assert_eq!(section_separator(""), "\n\n");
1183    }
1184
1185    #[test]
1186    fn preamble_preview_structure_has_correct_section_order() {
1187        // Mirrors the example binary to verify structure
1188        let resolver = AllowedPathResolver::from_canonical(["/home/user/project", "/tmp"]);
1189
1190        let mut pb = SystemPromptBuilder::new()
1191            .system_prompt("# System Instructions\n\nYou are helpful.")
1192            .working_directory("/home/user/project")
1193            .allowed_paths(&resolver)
1194            .add_context("Git Workflow", "Git guidance content.")
1195            .add_context("GitHub CLI", "GitHub guidance content.");
1196
1197        let _ = pb.track(MockTool { id: 1 });
1198        let _ = pb.track(OtherTool);
1199
1200        let preamble = pb.build();
1201
1202        // Verify all sections present
1203        assert!(
1204            preamble.contains("# System Instructions"),
1205            "Missing system prompt"
1206        );
1207        assert!(
1208            preamble.contains("# Environment"),
1209            "Missing environment section"
1210        );
1211        assert!(
1212            preamble.contains("Working directory:"),
1213            "Missing working directory"
1214        );
1215        assert!(
1216            preamble.contains("Allowed directories:"),
1217            "Missing allowed directories"
1218        );
1219        assert!(
1220            preamble.contains("# Tool Usage Guidelines"),
1221            "Missing tools section"
1222        );
1223        assert!(
1224            preamble.contains("# Supplemental Context"),
1225            "Missing supplemental section"
1226        );
1227
1228        // Verify section order: system -> env -> tools -> supplemental
1229        let system_pos = preamble.find("# System Instructions").unwrap();
1230        let env_pos = preamble.find("# Environment").unwrap();
1231        let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
1232        let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
1233
1234        assert!(
1235            system_pos < env_pos,
1236            "System prompt should come before environment"
1237        );
1238        assert!(env_pos < tools_pos, "Environment should come before tools");
1239        assert!(
1240            tools_pos < supplemental_pos,
1241            "Tools should come before supplemental"
1242        );
1243
1244        // Verify no formatting issues
1245        assert!(
1246            !preamble.contains("\n\n\n"),
1247            "Found triple newline (double blank line)"
1248        );
1249        assert_eq!(
1250            preamble,
1251            preamble.trim_end(),
1252            "Preamble has trailing whitespace"
1253        );
1254    }
1255
1256    #[test]
1257    fn preamble_preview_allowed_paths_rendered_correctly() {
1258        let resolver = AllowedPathResolver::from_canonical(["/home/user/project", "/tmp"]);
1259
1260        let pb = SystemPromptBuilder::new()
1261            .working_directory("/home/user/project")
1262            .allowed_paths(&resolver);
1263
1264        let preamble = pb.build();
1265
1266        // Verify both paths appear as bullet points
1267        assert!(
1268            preamble.contains("- /home/user/project"),
1269            "Missing project path"
1270        );
1271        assert!(preamble.contains("- /tmp"), "Missing tmp path");
1272    }
1273}