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 body = render_body(instructions, byte_budget)?;
52
53 let lead = if replace {
54 format!("{REPLACE_BANNER}\n{PREAMBLE}")
55 } else {
56 PREAMBLE.to_string()
57 };
58 let text = format!("<system-reminder>\n{lead}\n\n{body}\n</system-reminder>");
59 Some(user_reminder(text))
60}
61
62#[must_use]
64pub fn removal_message() -> Message {
65 user_reminder(format!(
66 "<system-reminder>\n{REMOVAL_NOTICE}\n</system-reminder>"
67 ))
68}
69
70#[must_use]
76pub fn instructions_hash(instructions: &ProjectInstructions) -> Option<u64> {
77 if instructions.entries.is_empty() {
78 return None;
79 }
80 let mut hasher = std::collections::hash_map::DefaultHasher::new();
81 for entry in &instructions.entries {
82 entry.source_path.hash(&mut hasher);
83 entry.content.hash(&mut hasher);
84 }
85 Some(hasher.finish())
86}
87
88#[must_use]
91pub fn render_body(instructions: &ProjectInstructions, byte_budget: usize) -> Option<String> {
92 if instructions.entries.is_empty() {
93 return None;
94 }
95 let sections: Vec<String> = instructions
96 .entries
97 .iter()
98 .map(|entry| {
99 format!(
100 "## From: {}\n{}",
101 entry.source_path.display(),
102 entry.content.trim_end()
103 )
104 })
105 .collect();
106 Some(truncate_on_char_boundary(
107 sections.join("\n\n"),
108 byte_budget,
109 ))
110}
111
112#[must_use]
122pub fn already_delivered(history: &[Message], body: &str) -> bool {
123 history.iter().rev().any(|m| {
124 m.role == Role::User
125 && m.content.iter().any(|b| match b {
126 ContentBlock::Text { text } => text.contains(PREAMBLE) && text.contains(body),
127 _ => false,
128 })
129 })
130}
131
132#[must_use]
138pub fn any_delivered(history: &[Message]) -> bool {
139 history.iter().any(|m| {
140 m.role == Role::User
141 && m.content.iter().any(|b| match b {
142 ContentBlock::Text { text } => {
143 text.contains(PREAMBLE) || text.contains(REMOVAL_NOTICE)
144 }
145 _ => false,
146 })
147 })
148}
149
150#[must_use]
153pub fn removal_delivered(history: &[Message]) -> bool {
154 history
155 .iter()
156 .rev()
157 .find_map(|m| {
158 if m.role != Role::User {
159 return None;
160 }
161 m.content.iter().find_map(|b| match b {
162 ContentBlock::Text { text }
163 if text.contains(PREAMBLE) || text.contains(REMOVAL_NOTICE) =>
164 {
165 Some(text.as_str())
166 }
167 _ => None,
168 })
169 })
170 .is_some_and(|text| text.contains(REMOVAL_NOTICE))
171}
172
173fn user_reminder(text: String) -> Message {
175 Message {
176 role: Role::User,
177 content: vec![ContentBlock::Text { text }],
178 }
179}
180
181fn truncate_on_char_boundary(mut s: String, budget: usize) -> String {
184 if budget == 0 || s.len() <= budget {
185 return s;
186 }
187 let mut end = budget;
188 while end > 0 && !s.is_char_boundary(end) {
189 end -= 1;
190 }
191 s.truncate(end);
192 s.push_str(TRUNCATION_MARKER);
193 s
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::load::InstructionEntry;
200 use std::path::PathBuf;
201
202 fn instr(entries: &[(&str, &str)]) -> ProjectInstructions {
203 ProjectInstructions {
204 entries: entries
205 .iter()
206 .map(|(p, c)| InstructionEntry {
207 source_path: PathBuf::from(p),
208 content: (*c).to_string(),
209 })
210 .collect(),
211 }
212 }
213
214 #[test]
215 fn renders_exact_envelope() {
216 let msg = render_instructions(&instr(&[("/p1", "a\n"), ("/p2", "b")]), 0, false).unwrap();
217 let ContentBlock::Text { text } = &msg.content[0] else {
218 panic!("expected text block");
219 };
220 let expected = "<system-reminder>\n\
221 As you answer the user's questions, you can use the project instructions below \
222 (deeper directories take precedence on conflict). They are context, not a \
223 message to answer.\n\
224 \n\
225 ## From: /p1\n\
226 a\n\
227 \n\
228 ## From: /p2\n\
229 b\n\
230 </system-reminder>";
231 assert_eq!(text, expected);
232 }
233
234 #[test]
235 fn empty_renders_none() {
236 assert!(render_instructions(&instr(&[]), 0, false).is_none());
237 }
238
239 #[test]
240 fn injected_message_is_user_text() {
241 let msg = render_instructions(&instr(&[("/p", "x")]), 0, false).unwrap();
242 assert_eq!(msg.role, Role::User);
243 assert_eq!(msg.content.len(), 1);
244 assert!(matches!(msg.content[0], ContentBlock::Text { .. }));
245 }
246
247 #[test]
248 fn truncates_at_budget_on_char_boundary_with_marker() {
249 let big = "é".repeat(200); let msg = render_instructions(&instr(&[("/p", &big)]), 64, false).unwrap();
252 let ContentBlock::Text { text } = &msg.content[0] else {
253 panic!();
254 };
255 assert!(
256 text.contains("…[project instructions truncated]…"),
257 "marker present"
258 );
259 let body_start = text.find("## From:").unwrap();
262 let body_end = text.find(TRUNCATION_MARKER).unwrap();
263 assert!(body_end - body_start <= 64, "body ≤ budget before marker");
264 }
267
268 #[test]
269 fn zero_budget_is_unbounded() {
270 let big = "x".repeat(10_000);
271 let msg = render_instructions(&instr(&[("/p", &big)]), 0, false).unwrap();
272 let ContentBlock::Text { text } = &msg.content[0] else {
273 panic!();
274 };
275 assert!(!text.contains("truncated"));
276 assert!(text.len() > 10_000);
277 }
278
279 #[test]
280 fn replace_flag_leads_with_banner() {
281 let msg = render_instructions(&instr(&[("/p", "x")]), 0, true).unwrap();
282 let ContentBlock::Text { text } = &msg.content[0] else {
283 panic!();
284 };
285 assert!(
286 text.starts_with(
287 "<system-reminder>\nThese instructions replace all previously provided \
288 project instructions.\n"
289 ),
290 "{text}"
291 );
292 assert!(text.contains("## From: /p"));
293 }
294
295 #[test]
296 fn removal_message_is_user_notice() {
297 let msg = removal_message();
298 assert_eq!(msg.role, Role::User);
299 let ContentBlock::Text { text } = &msg.content[0] else {
300 panic!();
301 };
302 assert_eq!(
303 text,
304 "<system-reminder>\nThe previously provided project instructions no longer \
305 apply.\n</system-reminder>"
306 );
307 }
308
309 #[test]
310 fn hash_is_none_empty_stable_and_content_sensitive() {
311 assert_eq!(instructions_hash(&instr(&[])), None);
312 let a = instructions_hash(&instr(&[("/p", "x")]));
313 let a2 = instructions_hash(&instr(&[("/p", "x")]));
314 let b = instructions_hash(&instr(&[("/p", "y")]));
315 assert!(a.is_some());
316 assert_eq!(a, a2, "stable for identical content");
317 assert_ne!(a, b, "changes when content changes");
318 }
319}