lean_ctx/core/
instruction_compiler.rs1use 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 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 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 #[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}