Skip to main content

locode_instructions/
render.rs

1//! Rendering discovered project instructions into a conversation message (ADR-0023 §2).
2//!
3//! The loader in [`crate::load`] produces a neutral [`ProjectInstructions`]; this turns
4//! it into **one `User`-role `<system-reminder>` message** and injects it (Slice 3). This is
5//! the "engine renders / injects the neutral value" half of ADR-0023 — a single format for
6//! every pack (fidelity is bounded to tools + prompt, ADR-0023 §1).
7//!
8//! Why `User` and not `Developer`: injected framing is authored as `User` from the start so
9//! the conversation ⇄ payload conversion stays losslessly bidirectional (ADR-0013 amendment
10//! 2026-07-23) — a `Developer`-rendered-to-user fallback cannot be reversed.
11
12use std::hash::{Hash, Hasher};
13
14use crate::load::ProjectInstructions;
15use locode_protocol::{ContentBlock, Message, Role};
16
17/// The authority preamble + relevance disclaimer (Claude's framing —
18/// `claude-code: api.ts:461-473`).
19const 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
23/// Leads the envelope when the instructions changed mid-session (ADR-0023 Refresh, the
24/// Codex/opencode replace-banner pattern — `codex: context/world_state/agents_md.rs:9-11`).
25const REPLACE_BANNER: &str =
26    "These instructions replace all previously provided project instructions.";
27
28/// The whole body when the instructions vanished mid-session.
29const REMOVAL_NOTICE: &str = "The previously provided project instructions no longer apply.";
30
31/// Appended when the body is truncated to the byte budget.
32const TRUNCATION_MARKER: &str = "\n\n…[project instructions truncated]…";
33
34/// Render project instructions into one `User` `<system-reminder>` message, or `None` when
35/// there is nothing to inject.
36///
37/// Each entry becomes a `## From: <source_path>` section (root→cwd order, so the deepest
38/// file is last). The **sections body** is capped at `byte_budget` bytes (UTF-8-boundary
39/// safe) with a truncation marker; `byte_budget == 0` means unbounded. When `replace` is set
40/// (the content changed mid-session), a replace banner leads the envelope.
41#[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/// The removal banner message, emitted when previously-applied instructions vanish.
74#[must_use]
75pub fn removal_message() -> Message {
76    user_reminder(format!(
77        "<system-reminder>\n{REMOVAL_NOTICE}\n</system-reminder>"
78    ))
79}
80
81/// A stable hash of the discovered instructions, or `None` when nothing was discovered.
82/// Drives the per-turn diff: an unchanged hash means "don't re-inject" (ADR-0023 Refresh).
83#[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
96/// A `User`-role `<system-reminder>` message wrapping `text`.
97fn user_reminder(text: String) -> Message {
98    Message {
99        role: Role::User,
100        content: vec![ContentBlock::Text { text }],
101    }
102}
103
104/// Truncate `s` to at most `budget` bytes at a UTF-8 char boundary, appending a marker when
105/// anything was dropped. `budget == 0` ⇒ unbounded (returned unchanged).
106fn 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        // A body well over a tiny budget, containing multi-byte chars near the cut.
173        let big = "é".repeat(200); // 400 bytes
174        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        // The section body was capped at 64 bytes before the marker; the envelope adds the
183        // preamble + tags, but the point is the body did not blow past the budget.
184        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        // The cut landed on a char boundary — else `String::truncate` in the renderer
188        // would have panicked before we got here, splitting an `é` mid-sequence.
189    }
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}