Skip to main content

mur_common/config/
search.rs

1//! Code-search tool tuning (`search:`). Today only `search.ast_grep`, which
2//! bounds the `ast_grep_search` MCP tool (plan 2026-10-03 code-nav, phase 1).
3//!
4//! Every limit resolves in three layers: the value the agent asks for in a
5//! call, the user's config value, and a compile-time `HARD_MAX_*` ceiling.
6//! The effective value is the smallest of the three, so neither an agent nor
7//! a config file can lift a limit past the ceiling. The ceilings exist
8//! because ast-grep's JSON output is quadratic on long lines (one minified
9//! line with 5k matches emits ~127 MB), so an unbounded cap is a memory bomb.
10
11use super::*;
12
13/// The only ast-grep version MUR runs. Phase 0 findings (flag spelling,
14/// exit 79 = config error, `--json=stream` shape) were verified against it.
15/// Shared by the MCP tool (resolver) and `mur code-nav setup` (installer).
16pub const AST_GREP_PINNED_VERSION: &str = "0.45.3";
17/// Managed-tools directory under mur home. Setup installs here and the
18/// resolver reads only from here.
19pub const MUR_TOOLS_DIR: &str = "tools";
20const AST_GREP_TOOL_DIR: &str = "ast-grep";
21const AST_GREP_BIN_STEM: &str = "ast-grep";
22
23/// Where the pinned ast-grep must live:
24/// `<mur_home>/tools/ast-grep/<ver>/ast-grep[.exe]`. One definition so the
25/// installer (`mur code-nav setup`) and the resolver (MCP tool) cannot drift.
26pub fn ast_grep_binary_path(mur_home: &std::path::Path) -> PathBuf {
27    mur_home
28        .join(MUR_TOOLS_DIR)
29        .join(AST_GREP_TOOL_DIR)
30        .join(AST_GREP_PINNED_VERSION)
31        .join(format!(
32            "{AST_GREP_BIN_STEM}{}",
33            std::env::consts::EXE_SUFFIX
34        ))
35}
36/// Default number of matches returned per call.
37pub const AST_GREP_DEFAULT_MAX_RESULTS: u32 = 200;
38/// Default context lines around each match (ast-grep `--context`).
39pub const AST_GREP_DEFAULT_MAX_CONTEXT_LINES: u32 = 3;
40/// Default wall-clock budget for one ast-grep run.
41pub const AST_GREP_DEFAULT_TIMEOUT_SECS: u64 = 30;
42/// Default cumulative stdout bytes read before the child is killed.
43pub const AST_GREP_DEFAULT_MAX_OUTPUT_BYTES: u64 = 1024 * 1024;
44/// Default bytes kept of each match's `lines` text before truncation.
45pub const AST_GREP_DEFAULT_MAX_MATCH_BYTES: u64 = 2 * 1024;
46
47/// Ceiling on `max_results`, whatever the agent or config asks for.
48pub const AST_GREP_HARD_MAX_RESULTS: u32 = 2_000;
49/// Ceiling on `max_context_lines`.
50pub const AST_GREP_HARD_MAX_CONTEXT_LINES: u32 = 20;
51/// Ceiling on `timeout_secs`.
52pub const AST_GREP_HARD_MAX_TIMEOUT_SECS: u64 = 300;
53/// Ceiling on `max_output_bytes`. Required, not defensive: see module docs.
54pub const AST_GREP_HARD_MAX_OUTPUT_BYTES: u64 = 16 * 1024 * 1024;
55/// Ceiling on `max_match_bytes`.
56pub const AST_GREP_HARD_MAX_MATCH_BYTES: u64 = 64 * 1024;
57
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct SearchConfig {
60    #[serde(default)]
61    pub ast_grep: AstGrepConfig,
62}
63
64/// User-tunable bounds for `ast_grep_search`. Each field defaults on its own,
65/// so a partial `search.ast_grep:` block keeps the defaults it omits.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct AstGrepConfig {
68    #[serde(default = "ast_grep_default_max_results")]
69    pub max_results: u32,
70    #[serde(default = "ast_grep_default_max_context_lines")]
71    pub max_context_lines: u32,
72    #[serde(default = "ast_grep_default_timeout_secs")]
73    pub timeout_secs: u64,
74    #[serde(default = "ast_grep_default_max_output_bytes")]
75    pub max_output_bytes: u64,
76    #[serde(default = "ast_grep_default_max_match_bytes")]
77    pub max_match_bytes: u64,
78}
79
80fn ast_grep_default_max_results() -> u32 {
81    AST_GREP_DEFAULT_MAX_RESULTS
82}
83fn ast_grep_default_max_context_lines() -> u32 {
84    AST_GREP_DEFAULT_MAX_CONTEXT_LINES
85}
86fn ast_grep_default_timeout_secs() -> u64 {
87    AST_GREP_DEFAULT_TIMEOUT_SECS
88}
89fn ast_grep_default_max_output_bytes() -> u64 {
90    AST_GREP_DEFAULT_MAX_OUTPUT_BYTES
91}
92fn ast_grep_default_max_match_bytes() -> u64 {
93    AST_GREP_DEFAULT_MAX_MATCH_BYTES
94}
95
96impl Default for AstGrepConfig {
97    fn default() -> Self {
98        Self {
99            max_results: AST_GREP_DEFAULT_MAX_RESULTS,
100            max_context_lines: AST_GREP_DEFAULT_MAX_CONTEXT_LINES,
101            timeout_secs: AST_GREP_DEFAULT_TIMEOUT_SECS,
102            max_output_bytes: AST_GREP_DEFAULT_MAX_OUTPUT_BYTES,
103            max_match_bytes: AST_GREP_DEFAULT_MAX_MATCH_BYTES,
104        }
105    }
106}
107
108/// The bounds one `ast_grep_search` call actually runs under.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct AstGrepLimits {
111    pub max_results: u32,
112    pub context_lines: u32,
113    pub timeout_secs: u64,
114    pub max_output_bytes: u64,
115    pub max_match_bytes: u64,
116}
117
118/// `min(requested, configured, hard)`, floored at `floor`. A missing request
119/// means "use the configured value". The floor keeps a zero in config from
120/// turning the tool into a silent no-op (zero results, zero timeout).
121fn clamp<T: Ord + Copy>(requested: Option<T>, configured: T, hard: T, floor: T) -> T {
122    let cap = configured.min(hard);
123    requested.map_or(cap, |r| r.min(cap)).max(floor)
124}
125
126impl AstGrepConfig {
127    /// Resolve the per-call bounds. Only `max_results` and `context` are
128    /// agent-selectable; the time and byte caps are config-and-ceiling only,
129    /// because they are the guards against the agent's own input.
130    pub fn resolve(&self, max_results: Option<u32>, context: Option<u32>) -> AstGrepLimits {
131        AstGrepLimits {
132            max_results: clamp(max_results, self.max_results, AST_GREP_HARD_MAX_RESULTS, 1),
133            context_lines: clamp(
134                context,
135                self.max_context_lines,
136                AST_GREP_HARD_MAX_CONTEXT_LINES,
137                0,
138            ),
139            timeout_secs: clamp(None, self.timeout_secs, AST_GREP_HARD_MAX_TIMEOUT_SECS, 1),
140            max_output_bytes: clamp(
141                None,
142                self.max_output_bytes,
143                AST_GREP_HARD_MAX_OUTPUT_BYTES,
144                1,
145            ),
146            max_match_bytes: clamp(None, self.max_match_bytes, AST_GREP_HARD_MAX_MATCH_BYTES, 1),
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn config_without_search_block_uses_defaults() {
157        let c: Config = serde_yaml::from_str("llm: {}\n").unwrap();
158        assert_eq!(c.search.ast_grep, AstGrepConfig::default());
159    }
160
161    #[test]
162    fn partial_ast_grep_block_keeps_omitted_defaults() {
163        let c: Config =
164            serde_yaml::from_str("search:\n  ast_grep:\n    max_results: 50\n").unwrap();
165        let g = &c.search.ast_grep;
166        assert_eq!(g.max_results, 50);
167        assert_eq!(g.max_context_lines, AST_GREP_DEFAULT_MAX_CONTEXT_LINES);
168        assert_eq!(g.timeout_secs, AST_GREP_DEFAULT_TIMEOUT_SECS);
169        assert_eq!(g.max_output_bytes, AST_GREP_DEFAULT_MAX_OUTPUT_BYTES);
170        assert_eq!(g.max_match_bytes, AST_GREP_DEFAULT_MAX_MATCH_BYTES);
171    }
172
173    #[test]
174    fn defaults_sit_under_ceilings() {
175        let l = AstGrepConfig::default().resolve(None, None);
176        assert_eq!(l.max_results, AST_GREP_DEFAULT_MAX_RESULTS);
177        assert_eq!(l.context_lines, AST_GREP_DEFAULT_MAX_CONTEXT_LINES);
178        assert_eq!(l.timeout_secs, AST_GREP_DEFAULT_TIMEOUT_SECS);
179        assert_eq!(l.max_output_bytes, AST_GREP_DEFAULT_MAX_OUTPUT_BYTES);
180        assert_eq!(l.max_match_bytes, AST_GREP_DEFAULT_MAX_MATCH_BYTES);
181    }
182
183    #[test]
184    fn agent_request_cannot_exceed_config() {
185        let l = AstGrepConfig::default().resolve(Some(u32::MAX), Some(u32::MAX));
186        assert_eq!(l.max_results, AST_GREP_DEFAULT_MAX_RESULTS);
187        assert_eq!(l.context_lines, AST_GREP_DEFAULT_MAX_CONTEXT_LINES);
188    }
189
190    #[test]
191    fn agent_request_below_config_wins() {
192        let l = AstGrepConfig::default().resolve(Some(5), Some(0));
193        assert_eq!((l.max_results, l.context_lines), (5, 0));
194    }
195
196    #[test]
197    fn config_cannot_exceed_hard_ceiling() {
198        let c = AstGrepConfig {
199            max_results: u32::MAX,
200            max_context_lines: u32::MAX,
201            timeout_secs: u64::MAX,
202            max_output_bytes: u64::MAX,
203            max_match_bytes: u64::MAX,
204        };
205        let l = c.resolve(None, None);
206        assert_eq!(l.max_results, AST_GREP_HARD_MAX_RESULTS);
207        assert_eq!(l.context_lines, AST_GREP_HARD_MAX_CONTEXT_LINES);
208        assert_eq!(l.timeout_secs, AST_GREP_HARD_MAX_TIMEOUT_SECS);
209        assert_eq!(l.max_output_bytes, AST_GREP_HARD_MAX_OUTPUT_BYTES);
210        assert_eq!(l.max_match_bytes, AST_GREP_HARD_MAX_MATCH_BYTES);
211    }
212
213    #[test]
214    fn zero_config_is_floored_not_a_silent_noop() {
215        let c = AstGrepConfig {
216            max_results: 0,
217            max_context_lines: 0,
218            timeout_secs: 0,
219            max_output_bytes: 0,
220            max_match_bytes: 0,
221        };
222        let l = c.resolve(Some(0), None);
223        assert_eq!(l.max_results, 1);
224        assert_eq!(l.context_lines, 0);
225        assert_eq!(l.timeout_secs, 1);
226        assert_eq!(l.max_output_bytes, 1);
227        assert_eq!(l.max_match_bytes, 1);
228    }
229}