Skip to main content

lean_ctx/core/
instruction_compiler.rs

1use crate::core::client_constraints;
2use crate::core::profiles;
3use crate::core::protocol::CrpMode;
4
5#[derive(Debug, Clone, serde::Serialize)]
6#[serde(rename_all = "camelCase")]
7pub struct CompiledRuleFile {
8    pub path: String,
9    pub content: String,
10}
11
12#[derive(Debug, Clone, serde::Serialize)]
13#[serde(rename_all = "camelCase")]
14pub struct CompiledInstructions {
15    pub schema_version: u32,
16    pub client: String,
17    pub profile: String,
18    pub crp_mode: String,
19    pub unified_tool_mode: bool,
20    pub mcp_instructions: String,
21    pub rules_files: Vec<CompiledRuleFile>,
22}
23
24#[derive(Debug, Clone, Copy, Default)]
25pub struct CompileOptions {
26    pub unified: bool,
27    pub include_rules_files: bool,
28    pub crp_mode_override: Option<CrpMode>,
29}
30
31pub fn compile(
32    client_id: &str,
33    profile_name: &str,
34    opts: CompileOptions,
35) -> Result<CompiledInstructions, String> {
36    let client_id = client_id.trim();
37    let profile_name = profile_name.trim();
38    if client_id.is_empty() {
39        return Err("missing client id".to_string());
40    }
41    if profile_name.is_empty() {
42        return Err("missing profile name".to_string());
43    }
44
45    let constraints = client_constraints::by_client_id(client_id);
46    if constraints.is_none() {
47        return Err(format!(
48            "unknown client '{client_id}' (use 'lean-ctx instructions --list-clients')"
49        ));
50    }
51
52    let profile = profiles::load_profile(profile_name)
53        .ok_or_else(|| format!("unknown profile '{profile_name}'"))?;
54    // Same resolver the MCP server and every tool use (#1890): the Engine seam
55    // projects `CrpMode::resolve` for this profile.
56    let crp_mode = opts.crp_mode_override.unwrap_or_else(|| {
57        CrpMode::parse(profile.engine_config().compression.crp_mode).unwrap_or(CrpMode::Off)
58    });
59
60    let mcp_instructions = crate::instructions::build_instructions_with_client_for_compiler(
61        crp_mode,
62        client_id,
63        opts.unified,
64    );
65
66    let constraints = constraints.expect("client id validated above");
67    if let Some(cap) = constraints.mcp_instructions_max_chars
68        && mcp_instructions.len() > cap
69    {
70        return Err(format!(
71            "compiled MCP instructions exceed cap for {client_id}: {} > {cap}",
72            mcp_instructions.len()
73        ));
74    }
75
76    let mut rules_files = Vec::new();
77    if opts.include_rules_files && (client_id == "claude-code" || client_id == "codebuddy") {
78        let config_dir = crate::instructions::claude_config_dir_display();
79        rules_files.push(CompiledRuleFile {
80            path: format!("{config_dir}/rules/lean-ctx.md"),
81            content: crate::rules_inject::rules_dedicated_markdown(),
82        });
83    }
84
85    Ok(CompiledInstructions {
86        schema_version: 1,
87        client: client_id.to_string(),
88        profile: profile.profile.name,
89        crp_mode: crp_mode.as_str().to_string(),
90        unified_tool_mode: opts.unified,
91        mcp_instructions,
92        rules_files,
93    })
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn compiled_instructions_are_deterministic() {
102        // The guidance reads env + Config::load(); parallel env-mutating tests
103        // would otherwise change the text between the two compiles.
104        let _lock = crate::core::data_dir::test_env_lock();
105        let a = compile(
106            "cursor",
107            "exploration",
108            CompileOptions {
109                unified: false,
110                include_rules_files: false,
111                crp_mode_override: Some(CrpMode::Tdd),
112            },
113        )
114        .unwrap();
115        let b = compile(
116            "cursor",
117            "exploration",
118            CompileOptions {
119                unified: false,
120                include_rules_files: false,
121                crp_mode_override: Some(CrpMode::Tdd),
122            },
123        )
124        .unwrap();
125        assert_eq!(a.mcp_instructions, b.mcp_instructions);
126    }
127
128    #[test]
129    fn compiles_for_all_known_clients() {
130        for c in crate::core::client_constraints::ALL_CLIENTS {
131            let out = compile(
132                c.id,
133                "exploration",
134                CompileOptions {
135                    unified: false,
136                    include_rules_files: false,
137                    crp_mode_override: Some(CrpMode::Tdd),
138                },
139            )
140            .unwrap();
141            assert!(
142                !out.mcp_instructions.trim().is_empty(),
143                "empty instructions for client {}",
144                c.id
145            );
146        }
147    }
148
149    /// #1890: `lean-ctx instructions` and the MCP server/tool layer must agree
150    /// on the CRP mode for every built-in profile (default config included).
151    #[test]
152    fn compiled_crp_mode_matches_tool_resolver() {
153        let _lock = crate::core::data_dir::test_env_lock();
154        for name in ["coder", "exploration", "bugfix", "review", "passthrough"] {
155            let out = compile("claude-code", name, CompileOptions::default()).unwrap();
156            let profile = profiles::load_profile(name).unwrap();
157            let tools = CrpMode::resolve(profile.compression.crp_mode.as_deref());
158            assert_eq!(out.crp_mode, tools.as_str(), "profile {name}");
159            let expected = crate::instructions::build_instructions_with_client_for_compiler(
160                tools,
161                "claude-code",
162                false,
163            );
164            assert_eq!(out.mcp_instructions, expected, "profile {name}");
165        }
166    }
167
168    #[test]
169    fn default_coder_profile_does_not_force_tdd() {
170        let coder = profiles::load_profile("coder").unwrap();
171        assert_eq!(coder.compression.crp_mode, None);
172    }
173
174    #[test]
175    fn claude_mcp_instructions_respect_cap() {
176        let out = compile(
177            "claude-code",
178            "exploration",
179            CompileOptions {
180                unified: false,
181                include_rules_files: false,
182                crp_mode_override: Some(CrpMode::Tdd),
183            },
184        )
185        .unwrap();
186        assert!(out.mcp_instructions.len() <= 2048);
187    }
188}