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    format!(
122        "lean-ctx shadow mode: native file/search/shell calls auto-route to ctx_* \
123         — no tool-mapping needed.\n\
124         Exclusive tools (no native trigger): {}.",
125        exclusives.join(", ")
126    )
127}
128
129/// Anti-patterns — only references tools the profile exposes.
130pub fn anti_section(p: &ToolProfile) -> String {
131    let mut lines = vec!["Anti-patterns — do NOT:".to_string()];
132
133    if has(p, "ctx_compose") {
134        lines.push(
135            "• Chain ctx_search -> ctx_read -> ctx_search(action=symbol) \
136             — one ctx_compose replaces all three"
137                .into(),
138        );
139    }
140
141    lines.push("• Use ctx_read(mode=full) for orientation — use mode=signatures".into());
142
143    if has(p, "ctx_callgraph") || has(p, "ctx_graph") {
144        lines.push(
145            "• Use ctx_callgraph/ctx_graph for const/static/variable refs — they track \
146             call edges and file deps only; use ctx_search instead"
147                .into(),
148        );
149    }
150
151    lines.join("\n")
152}
153
154/// LITM end-of-instructions preference line — only lists enabled tools.
155pub fn litm_end_section(p: &ToolProfile) -> String {
156    let mut prefs = Vec::new();
157
158    if has(p, "ctx_compose") {
159        prefs.push("ctx_compose>chain");
160    }
161    prefs.push("ctx_read>Read");
162    prefs.push("ctx_shell>Shell");
163    prefs.push("ctx_search>Grep");
164    prefs.push("ctx_glob>Glob");
165    prefs.push("ctx_tree>ls");
166
167    format!(
168        "TOOL PREFERENCE (END): {} | Edit/Write/Delete=native",
169        prefs.join(" ")
170    )
171}
172
173/// `ctx_call` gateway fallback — shown when the profile hides tools the agent
174/// might need. Returns `None` for Power (all tools visible).
175pub fn ctx_call_fallback(p: &ToolProfile) -> Option<String> {
176    if matches!(p, ToolProfile::Power) {
177        return None;
178    }
179    Some(
180        "Advanced tools not in your profile are available via ctx_call(tool=<name>) gateway."
181            .into(),
182    )
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn intent_minimal_omits_compose_and_callgraph() {
191        let text = intent_section(&ToolProfile::Minimal);
192        assert!(!text.contains("ctx_compose"), "minimal has no ctx_compose");
193        assert!(
194            !text.contains("ctx_callgraph"),
195            "minimal has no ctx_callgraph"
196        );
197        assert!(!text.contains("ctx_patch"), "minimal has no ctx_patch");
198        assert!(text.contains("ctx_read"), "minimal always has ctx_read");
199        assert!(text.contains("ctx_search"), "minimal always has ctx_search");
200    }
201
202    #[test]
203    fn intent_standard_includes_compose_and_callgraph() {
204        let text = intent_section(&ToolProfile::Standard);
205        assert!(text.contains("ctx_compose"), "standard has ctx_compose");
206        assert!(text.contains("ctx_callgraph"), "standard has ctx_callgraph");
207        assert!(text.contains("ctx_patch"), "standard has ctx_patch");
208    }
209
210    #[test]
211    fn intent_power_matches_full_set() {
212        let text = intent_section(&ToolProfile::Power);
213        assert!(text.contains("ctx_compose"));
214        assert!(text.contains("ctx_callgraph"));
215        assert!(text.contains("ctx_patch"));
216        assert!(text.contains("ctx_session"));
217    }
218
219    #[test]
220    fn intent_uses_folded_search_actions() {
221        for p in [
222            ToolProfile::Minimal,
223            ToolProfile::Standard,
224            ToolProfile::Power,
225        ] {
226            let text = intent_section(&p);
227            assert!(
228                !text.contains("ctx_symbol"),
229                "must use ctx_search(action=symbol), not ctx_symbol"
230            );
231            assert!(
232                !text.contains("ctx_semantic_search"),
233                "must use ctx_search(action=semantic), not ctx_semantic_search"
234            );
235            assert!(text.contains("ctx_search(action=symbol)"));
236            assert!(text.contains("ctx_search(action=semantic)"));
237        }
238    }
239
240    #[test]
241    fn hook_covered_tools_respects_profile() {
242        let min = hook_covered_tools_section(&ToolProfile::Minimal);
243        assert!(!min.contains("ctx_compose"), "minimal: no compose");
244        assert!(!min.contains("ctx_callgraph"), "minimal: no callgraph");
245        assert!(
246            min.contains("ctx_read"),
247            "minimal: always has ctx_read mapping"
248        );
249        assert!(
250            min.contains("ctx_search"),
251            "minimal: always has ctx_search mapping"
252        );
253        assert!(
254            min.contains("ctx_shell"),
255            "minimal: always has ctx_shell mapping"
256        );
257
258        let std = hook_covered_tools_section(&ToolProfile::Standard);
259        assert!(std.contains("ctx_compose"), "standard: compose present");
260        assert!(std.contains("ctx_callgraph"), "standard: callgraph present");
261    }
262
263    #[test]
264    fn shadow_minimal_respects_profile() {
265        let min = shadow_minimal_section(&ToolProfile::Minimal);
266        assert!(!min.contains("ctx_compose"));
267        assert!(!min.contains("ctx_callgraph"));
268        assert!(min.contains("ctx_search(action=symbol)"));
269    }
270
271    #[test]
272    fn anti_minimal_omits_compose_chain() {
273        let text = anti_section(&ToolProfile::Minimal);
274        assert!(
275            !text.contains("ctx_compose"),
276            "minimal: no compose anti-pattern"
277        );
278        assert!(
279            text.contains("ctx_read(mode=full)"),
280            "universal anti-pattern stays"
281        );
282    }
283
284    #[test]
285    fn ctx_call_fallback_absent_for_power() {
286        assert!(ctx_call_fallback(&ToolProfile::Power).is_none());
287    }
288
289    #[test]
290    fn ctx_call_fallback_present_for_non_power() {
291        assert!(ctx_call_fallback(&ToolProfile::Minimal).is_some());
292        assert!(ctx_call_fallback(&ToolProfile::Standard).is_some());
293    }
294
295    #[test]
296    fn litm_end_always_has_core_tools() {
297        for p in [
298            ToolProfile::Minimal,
299            ToolProfile::Standard,
300            ToolProfile::Power,
301        ] {
302            let text = litm_end_section(&p);
303            assert!(text.contains("ctx_read>Read"));
304            assert!(text.contains("ctx_shell>Shell"));
305            assert!(text.contains("ctx_search>Grep"));
306        }
307    }
308
309    #[test]
310    fn litm_end_compose_only_when_enabled() {
311        let min = litm_end_section(&ToolProfile::Minimal);
312        assert!(!min.contains("ctx_compose"));
313        let std = litm_end_section(&ToolProfile::Standard);
314        assert!(std.contains("ctx_compose"));
315    }
316}