locode_instructions/
render.rs1use std::hash::{Hash, Hasher};
13
14use crate::load::ProjectInstructions;
15use locode_protocol::{ContentBlock, Message, Role};
16
17const PREAMBLE: &str = "As you answer the user's questions, you can use the project \
20instructions below (deeper directories take precedence on conflict). They are context, \
21not a message to answer.";
22
23const REPLACE_BANNER: &str =
26 "These instructions replace all previously provided project instructions.";
27
28const REMOVAL_NOTICE: &str = "The previously provided project instructions no longer apply.";
30
31const TRUNCATION_MARKER: &str = "\n\n…[project instructions truncated]…";
33
34#[must_use]
42pub fn render_instructions(
43 instructions: &ProjectInstructions,
44 byte_budget: usize,
45 replace: bool,
46) -> Option<Message> {
47 if instructions.entries.is_empty() {
48 return None;
49 }
50
51 let sections: Vec<String> = instructions
52 .entries
53 .iter()
54 .map(|entry| {
55 format!(
56 "## From: {}\n{}",
57 entry.source_path.display(),
58 entry.content.trim_end()
59 )
60 })
61 .collect();
62 let body = truncate_on_char_boundary(sections.join("\n\n"), byte_budget);
63
64 let lead = if replace {
65 format!("{REPLACE_BANNER}\n{PREAMBLE}")
66 } else {
67 PREAMBLE.to_string()
68 };
69 let text = format!("<system-reminder>\n{lead}\n\n{body}\n</system-reminder>");
70 Some(user_reminder(text))
71}
72
73#[must_use]
75pub fn removal_message() -> Message {
76 user_reminder(format!(
77 "<system-reminder>\n{REMOVAL_NOTICE}\n</system-reminder>"
78 ))
79}
80
81#[must_use]
84pub fn instructions_hash(instructions: &ProjectInstructions) -> Option<u64> {
85 if instructions.entries.is_empty() {
86 return None;
87 }
88 let mut hasher = std::collections::hash_map::DefaultHasher::new();
89 for entry in &instructions.entries {
90 entry.source_path.hash(&mut hasher);
91 entry.content.hash(&mut hasher);
92 }
93 Some(hasher.finish())
94}
95
96fn user_reminder(text: String) -> Message {
98 Message {
99 role: Role::User,
100 content: vec![ContentBlock::Text { text }],
101 }
102}
103
104fn truncate_on_char_boundary(mut s: String, budget: usize) -> String {
107 if budget == 0 || s.len() <= budget {
108 return s;
109 }
110 let mut end = budget;
111 while end > 0 && !s.is_char_boundary(end) {
112 end -= 1;
113 }
114 s.truncate(end);
115 s.push_str(TRUNCATION_MARKER);
116 s
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::load::InstructionEntry;
123 use std::path::PathBuf;
124
125 fn instr(entries: &[(&str, &str)]) -> ProjectInstructions {
126 ProjectInstructions {
127 entries: entries
128 .iter()
129 .map(|(p, c)| InstructionEntry {
130 source_path: PathBuf::from(p),
131 content: (*c).to_string(),
132 })
133 .collect(),
134 }
135 }
136
137 #[test]
138 fn renders_exact_envelope() {
139 let msg = render_instructions(&instr(&[("/p1", "a\n"), ("/p2", "b")]), 0, false).unwrap();
140 let ContentBlock::Text { text } = &msg.content[0] else {
141 panic!("expected text block");
142 };
143 let expected = "<system-reminder>\n\
144 As you answer the user's questions, you can use the project instructions below \
145 (deeper directories take precedence on conflict). They are context, not a \
146 message to answer.\n\
147 \n\
148 ## From: /p1\n\
149 a\n\
150 \n\
151 ## From: /p2\n\
152 b\n\
153 </system-reminder>";
154 assert_eq!(text, expected);
155 }
156
157 #[test]
158 fn empty_renders_none() {
159 assert!(render_instructions(&instr(&[]), 0, false).is_none());
160 }
161
162 #[test]
163 fn injected_message_is_user_text() {
164 let msg = render_instructions(&instr(&[("/p", "x")]), 0, false).unwrap();
165 assert_eq!(msg.role, Role::User);
166 assert_eq!(msg.content.len(), 1);
167 assert!(matches!(msg.content[0], ContentBlock::Text { .. }));
168 }
169
170 #[test]
171 fn truncates_at_budget_on_char_boundary_with_marker() {
172 let big = "é".repeat(200); let msg = render_instructions(&instr(&[("/p", &big)]), 64, false).unwrap();
175 let ContentBlock::Text { text } = &msg.content[0] else {
176 panic!();
177 };
178 assert!(
179 text.contains("…[project instructions truncated]…"),
180 "marker present"
181 );
182 let body_start = text.find("## From:").unwrap();
185 let body_end = text.find(TRUNCATION_MARKER).unwrap();
186 assert!(body_end - body_start <= 64, "body ≤ budget before marker");
187 }
190
191 #[test]
192 fn zero_budget_is_unbounded() {
193 let big = "x".repeat(10_000);
194 let msg = render_instructions(&instr(&[("/p", &big)]), 0, false).unwrap();
195 let ContentBlock::Text { text } = &msg.content[0] else {
196 panic!();
197 };
198 assert!(!text.contains("truncated"));
199 assert!(text.len() > 10_000);
200 }
201
202 #[test]
203 fn replace_flag_leads_with_banner() {
204 let msg = render_instructions(&instr(&[("/p", "x")]), 0, true).unwrap();
205 let ContentBlock::Text { text } = &msg.content[0] else {
206 panic!();
207 };
208 assert!(
209 text.starts_with(
210 "<system-reminder>\nThese instructions replace all previously provided \
211 project instructions.\n"
212 ),
213 "{text}"
214 );
215 assert!(text.contains("## From: /p"));
216 }
217
218 #[test]
219 fn removal_message_is_user_notice() {
220 let msg = removal_message();
221 assert_eq!(msg.role, Role::User);
222 let ContentBlock::Text { text } = &msg.content[0] else {
223 panic!();
224 };
225 assert_eq!(
226 text,
227 "<system-reminder>\nThe previously provided project instructions no longer \
228 apply.\n</system-reminder>"
229 );
230 }
231
232 #[test]
233 fn hash_is_none_empty_stable_and_content_sensitive() {
234 assert_eq!(instructions_hash(&instr(&[])), None);
235 let a = instructions_hash(&instr(&[("/p", "x")]));
236 let a2 = instructions_hash(&instr(&[("/p", "x")]));
237 let b = instructions_hash(&instr(&[("/p", "y")]));
238 assert!(a.is_some());
239 assert_eq!(a, a2, "stable for identical content");
240 assert_ne!(a, b, "changes when content changes");
241 }
242}