Skip to main content

lean_ctx/core/
rules_sections.rs

1//! Profile-aware rules section builders (#756).
2//!
3//! Each function generates the text for one rules section, filtering tool
4//! references by the active [`ToolProfile`]. Power profile produces output
5//! identical to the previous static constants; Minimal/Standard/Custom omit
6//! tools the agent cannot call.
7//!
8//! Folded-tool alignment (#509): `ctx_symbol` → `ctx_search(action=symbol)`,
9//! `ctx_semantic_search` → `ctx_search(action=semantic)`.
10
11use super::tool_profiles::ToolProfile;
12
13fn has(p: &ToolProfile, name: &str) -> bool {
14    p.is_tool_enabled(name)
15}
16
17/// Intent-to-tool playbook — only advertises tools the profile exposes.
18pub fn intent_section(p: &ToolProfile) -> String {
19    let mut lines = vec!["Tool selection by intent:".to_string()];
20
21    if has(p, "ctx_compose") {
22        lines.push("• Orient / understand code (call FIRST) -> ctx_compose".into());
23    }
24
25    let read_line = if has(p, "ctx_patch") {
26        "• Read a file -> ctx_read(path, mode=signatures|map|full); edit after reading -> ctx_patch"
27    } else {
28        "• Read a file -> ctx_read(path, mode=signatures|map|full)"
29    };
30    lines.push(read_line.into());
31
32    // #509: ctx_symbol and ctx_semantic_search are folded into ctx_search actions.
33    let mut search_parts = vec!["• Exact symbol -> ctx_search(action=symbol)"];
34    search_parts.push("pattern -> ctx_search");
35    search_parts.push("by meaning -> ctx_search(action=semantic)");
36    lines.push(search_parts.join("; "));
37
38    let mut nav = String::from("• Files by glob -> ctx_glob; structure -> ctx_tree");
39    if has(p, "ctx_callgraph") {
40        nav.push_str("; callers/impact -> ctx_callgraph");
41    }
42    lines.push(nav);
43
44    let mut verify = String::from("• Verify after edits -> ctx_shell(test/build)");
45    if has(p, "ctx_session") || has(p, "ctx_knowledge") {
46        verify.push_str("; memory -> ctx_session / ctx_knowledge");
47    }
48    lines.push(verify);
49
50    lines.push(
51        "Semantic questions -> search tools, not whole-file reads: \
52         reading more ≠ understanding more."
53            .into(),
54    );
55    lines.join("\n")
56}
57
58/// Mandatory tool mapping on hook-covered hosts (Cursor with hooks).
59/// v8: strict mode — always prefer ctx_* even when hooks would cover native tools.
60pub fn hook_covered_tools_section(p: &ToolProfile) -> String {
61    let mut lines = vec!["MANDATORY MAPPING (always use ctx_* instead of native):".to_string()];
62
63    lines.push("• Read/cat -> ctx_read(path, mode) — cached, 10 modes, re-reads ~13 tokens".into());
64    lines.push(
65        "• Grep/search -> ctx_search(pattern, path) — also action=symbol|semantic \
66         for definitions/meaning"
67            .into(),
68    );
69    lines.push("• Shell/bash -> ctx_shell(command) — 95+ compression patterns".into());
70
71    if has(p, "ctx_compose") {
72        lines.push(
73            "• ctx_compose — orient in code FIRST (bundles search + read + symbols) \
74             — call before editing/debugging"
75                .into(),
76        );
77    }
78
79    if has(p, "ctx_callgraph") {
80        lines.push(
81            "• ctx_callgraph — callers, callees, blast radius — use instead of manual \
82             file reading"
83                .into(),
84        );
85    }
86
87    if has(p, "ctx_session") || has(p, "ctx_knowledge") {
88        lines.push(
89            "• ctx_session / ctx_knowledge — persistent memory — record decisions & \
90             progress after milestones"
91                .into(),
92        );
93    }
94
95    if has(p, "ctx_expand") {
96        lines.push("• ctx_expand — recover full text from [Archived]/compressed output".into());
97    }
98
99    lines.join("\n")
100}
101
102/// Shadow-mode exclusive tools (no native trigger to intercept).
103pub fn shadow_minimal_section(p: &ToolProfile) -> String {
104    let mut exclusives = Vec::new();
105
106    if has(p, "ctx_compose") {
107        exclusives.push("ctx_compose (understand code, call first)");
108    }
109
110    // #509: always ctx_search actions, not standalone tools
111    exclusives.push("ctx_search(action=symbol) (exact symbol)");
112    exclusives.push("ctx_search(action=semantic) (by meaning)");
113
114    if has(p, "ctx_callgraph") {
115        exclusives.push("ctx_callgraph (callers)");
116    }
117    if has(p, "ctx_knowledge") || has(p, "ctx_session") {
118        exclusives.push("ctx_knowledge / ctx_session (memory)");
119    }
120
121    let edit_line = if has(p, "ctx_patch") {
122        "File editing → native Edit/StrReplace (lean-ctx only handles reads); if denied, use ctx_patch.\n"
123    } else {
124        "File editing → native Edit/StrReplace (lean-ctx only handles reads).\n"
125    };
126
127    format!(
128        "lean-ctx shadow mode: native read/search/shell calls auto-route to ctx_* \
129         — no tool-mapping needed.\n\
130         {edit_line}\
131         Exclusive tools (no native trigger): {}.",
132        exclusives.join(", ")
133    )
134}
135
136/// Anti-patterns — only references tools the profile exposes.
137pub fn anti_section(p: &ToolProfile) -> String {
138    let mut lines = vec!["Anti-patterns — do NOT:".to_string()];
139
140    if has(p, "ctx_compose") {
141        lines.push(
142            "• Chain ctx_search -> ctx_read -> ctx_search(action=symbol) \
143             — one ctx_compose replaces all three"
144                .into(),
145        );
146    }
147
148    lines.push("• Use ctx_read(mode=full) for orientation — use mode=signatures".into());
149
150    if has(p, "ctx_callgraph") || has(p, "ctx_graph") {
151        lines.push(
152            "• Use ctx_callgraph/ctx_graph for const/static/variable refs — they track \
153             call edges and file deps only; use ctx_search instead"
154                .into(),
155        );
156    }
157
158    lines.join("\n")
159}
160
161/// LITM end-of-instructions preference line — only lists enabled tools.
162pub fn litm_end_section(p: &ToolProfile) -> String {
163    let mut prefs = Vec::new();
164
165    if has(p, "ctx_compose") {
166        prefs.push("ctx_compose>chain");
167    }
168    prefs.push("ctx_read>Read");
169    prefs.push("ctx_shell>Shell");
170    prefs.push("ctx_search>Grep");
171    prefs.push("ctx_glob>Glob");
172    prefs.push("ctx_tree>ls");
173
174    format!(
175        "TOOL PREFERENCE (END): {} | Edit/Write/Delete=native",
176        prefs.join(" ")
177    )
178}
179
180/// `ctx_call` gateway fallback — shown when the profile hides tools the agent
181/// might need. Returns `None` for Power (all tools visible).
182pub fn ctx_call_fallback(p: &ToolProfile) -> Option<String> {
183    if matches!(p, ToolProfile::Power) {
184        return None;
185    }
186    Some(
187        "Advanced tools not in your profile are available via ctx_call(tool=<name>) gateway."
188            .into(),
189    )
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn intent_minimal_omits_compose_and_callgraph() {
198        let text = intent_section(&ToolProfile::Minimal);
199        assert!(!text.contains("ctx_compose"), "minimal has no ctx_compose");
200        assert!(
201            !text.contains("ctx_callgraph"),
202            "minimal has no ctx_callgraph"
203        );
204        assert!(!text.contains("ctx_patch"), "minimal has no ctx_patch");
205        assert!(text.contains("ctx_read"), "minimal always has ctx_read");
206        assert!(text.contains("ctx_search"), "minimal always has ctx_search");
207    }
208
209    #[test]
210    fn intent_standard_includes_compose_and_callgraph() {
211        let text = intent_section(&ToolProfile::Standard);
212        assert!(text.contains("ctx_compose"), "standard has ctx_compose");
213        assert!(text.contains("ctx_callgraph"), "standard has ctx_callgraph");
214        assert!(text.contains("ctx_patch"), "standard has ctx_patch");
215    }
216
217    #[test]
218    fn intent_power_matches_full_set() {
219        let text = intent_section(&ToolProfile::Power);
220        assert!(text.contains("ctx_compose"));
221        assert!(text.contains("ctx_callgraph"));
222        assert!(text.contains("ctx_patch"));
223        assert!(text.contains("ctx_session"));
224    }
225
226    #[test]
227    fn intent_uses_folded_search_actions() {
228        for p in [
229            ToolProfile::Minimal,
230            ToolProfile::Standard,
231            ToolProfile::Power,
232        ] {
233            let text = intent_section(&p);
234            assert!(
235                !text.contains("ctx_symbol"),
236                "must use ctx_search(action=symbol), not ctx_symbol"
237            );
238            assert!(
239                !text.contains("ctx_semantic_search"),
240                "must use ctx_search(action=semantic), not ctx_semantic_search"
241            );
242            assert!(text.contains("ctx_search(action=symbol)"));
243            assert!(text.contains("ctx_search(action=semantic)"));
244        }
245    }
246
247    #[test]
248    fn hook_covered_tools_respects_profile() {
249        let min = hook_covered_tools_section(&ToolProfile::Minimal);
250        assert!(!min.contains("ctx_compose"), "minimal: no compose");
251        assert!(!min.contains("ctx_callgraph"), "minimal: no callgraph");
252        assert!(
253            min.contains("ctx_read"),
254            "minimal: always has ctx_read mapping"
255        );
256        assert!(
257            min.contains("ctx_search"),
258            "minimal: always has ctx_search mapping"
259        );
260        assert!(
261            min.contains("ctx_shell"),
262            "minimal: always has ctx_shell mapping"
263        );
264
265        let std = hook_covered_tools_section(&ToolProfile::Standard);
266        assert!(std.contains("ctx_compose"), "standard: compose present");
267        assert!(std.contains("ctx_callgraph"), "standard: callgraph present");
268    }
269
270    #[test]
271    fn shadow_minimal_respects_profile() {
272        let min = shadow_minimal_section(&ToolProfile::Minimal);
273        assert!(!min.contains("ctx_compose"));
274        assert!(!min.contains("ctx_callgraph"));
275        assert!(min.contains("ctx_search(action=symbol)"));
276        assert!(!min.contains("ctx_patch"));
277
278        let power = shadow_minimal_section(&ToolProfile::Power);
279        assert!(power.contains("ctx_patch"));
280    }
281
282    #[test]
283    fn anti_minimal_omits_compose_chain() {
284        let text = anti_section(&ToolProfile::Minimal);
285        assert!(
286            !text.contains("ctx_compose"),
287            "minimal: no compose anti-pattern"
288        );
289        assert!(
290            text.contains("ctx_read(mode=full)"),
291            "universal anti-pattern stays"
292        );
293    }
294
295    #[test]
296    fn ctx_call_fallback_absent_for_power() {
297        assert!(ctx_call_fallback(&ToolProfile::Power).is_none());
298    }
299
300    #[test]
301    fn ctx_call_fallback_present_for_non_power() {
302        assert!(ctx_call_fallback(&ToolProfile::Minimal).is_some());
303        assert!(ctx_call_fallback(&ToolProfile::Standard).is_some());
304    }
305
306    #[test]
307    fn litm_end_always_has_core_tools() {
308        for p in [
309            ToolProfile::Minimal,
310            ToolProfile::Standard,
311            ToolProfile::Power,
312        ] {
313            let text = litm_end_section(&p);
314            assert!(text.contains("ctx_read>Read"));
315            assert!(text.contains("ctx_shell>Shell"));
316            assert!(text.contains("ctx_search>Grep"));
317        }
318    }
319
320    #[test]
321    fn litm_end_compose_only_when_enabled() {
322        let min = litm_end_section(&ToolProfile::Minimal);
323        assert!(!min.contains("ctx_compose"));
324        let std = litm_end_section(&ToolProfile::Standard);
325        assert!(std.contains("ctx_compose"));
326    }
327}