1use crate::context::ToolContext;
7use crate::path::AllowedPathResolver;
8
9struct ContextEntry {
11 name: &'static str,
12 context: &'static str,
13}
14
15#[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 #[inline]
71 pub fn new() -> Self {
72 Self::default()
73 }
74
75 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 #[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 #[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 #[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 #[inline]
227 pub fn allowed_paths(mut self, resolver: &AllowedPathResolver) -> Self {
228 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#[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 pub fn build(self) -> String {
260 const ENV_HEADER_SIZE: usize = 50;
263 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 if !has_tools && !has_supplemental && !has_system_prompt && !has_env_content {
306 return String::new();
307 }
308
309 if let Some(ref prompt) = self.system_prompt {
311 output.push_str(prompt);
312 output.push_str(section_separator(prompt));
314 }
315
316 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 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 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
384pub trait Substitute {
402 fn substitute(self, key: &str, value: &str) -> String;
407
408 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 assert!(
511 preamble.contains("Mock tool context.\n## `Other` Tool"),
512 "Expected single newline between tool sections.\nGot:\n{preamble}"
513 );
514
515 assert!(
517 preamble.contains("## `Mock` Tool\nMock tool context."),
518 "Expected single newline after tool header.\nGot:\n{preamble}"
519 );
520
521 assert!(
523 preamble.contains("# Tool Usage Guidelines\n\n## `Mock` Tool"),
524 "Expected blank line after section header.\nGot:\n{preamble}"
525 );
526
527 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 assert!(
544 preamble.contains("Mock tool context.\n## `Other` Tool"),
545 "Expected single newline between tool sections.\nGot:\n{preamble}"
546 );
547
548 assert!(
550 preamble.contains("## `Mock` Tool\nMock tool context."),
551 "Expected single newline after tool header.\nGot:\n{preamble}"
552 );
553
554 assert!(
556 preamble.contains("# Environment\n\nWorking directory:"),
557 "Expected blank line after Environment header.\nGot:\n{preamble}"
558 );
559
560 assert!(
562 preamble.contains("# Tool Usage Guidelines\n\n## `Mock` Tool"),
563 "Expected blank line after section header.\nGot:\n{preamble}"
564 );
565
566 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
1268 preamble.contains("- /home/user/project"),
1269 "Missing project path"
1270 );
1271 assert!(preamble.contains("- /tmp"), "Missing tmp path");
1272 }
1273}