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/// MUR-owned run directory for ast-grep: `<mur_home>/runtime/ast-grep`. The
37/// MCP tool writes its empty `sgconfig.yml` here on every call, so an agent
38/// running `ast_grep_search` needs read+write on it. One definition so the
39/// tool, `mur code-nav setup` (which grants it) and doctor cannot drift.
40pub fn ast_grep_runtime_dir(mur_home: &std::path::Path) -> PathBuf {
41    mur_home
42        .join(AST_GREP_RUNTIME_PARENT)
43        .join(AST_GREP_TOOL_DIR)
44}
45const AST_GREP_RUNTIME_PARENT: &str = "runtime";
46
47/// Default number of matches returned per call.
48pub const AST_GREP_DEFAULT_MAX_RESULTS: u32 = 200;
49/// Default context lines around each match (ast-grep `--context`).
50pub const AST_GREP_DEFAULT_MAX_CONTEXT_LINES: u32 = 3;
51/// Default wall-clock budget for one ast-grep run.
52pub const AST_GREP_DEFAULT_TIMEOUT_SECS: u64 = 30;
53/// Default cumulative stdout bytes read before the child is killed.
54pub const AST_GREP_DEFAULT_MAX_OUTPUT_BYTES: u64 = 1024 * 1024;
55/// Default bytes kept of each match's `lines` text before truncation.
56pub const AST_GREP_DEFAULT_MAX_MATCH_BYTES: u64 = 2 * 1024;
57
58/// Ceiling on `max_results`, whatever the agent or config asks for.
59pub const AST_GREP_HARD_MAX_RESULTS: u32 = 2_000;
60/// Ceiling on `max_context_lines`.
61pub const AST_GREP_HARD_MAX_CONTEXT_LINES: u32 = 20;
62/// Ceiling on `timeout_secs`.
63pub const AST_GREP_HARD_MAX_TIMEOUT_SECS: u64 = 300;
64/// Ceiling on `max_output_bytes`. Required, not defensive: see module docs.
65pub const AST_GREP_HARD_MAX_OUTPUT_BYTES: u64 = 16 * 1024 * 1024;
66/// Ceiling on `max_match_bytes`.
67pub const AST_GREP_HARD_MAX_MATCH_BYTES: u64 = 64 * 1024;
68
69#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
70pub struct SearchConfig {
71    #[serde(default)]
72    pub ast_grep: AstGrepConfig,
73}
74
75/// User-tunable bounds for `ast_grep_search`. Each field defaults on its own,
76/// so a partial `search.ast_grep:` block keeps the defaults it omits.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct AstGrepConfig {
79    #[serde(default = "ast_grep_default_max_results")]
80    pub max_results: u32,
81    #[serde(default = "ast_grep_default_max_context_lines")]
82    pub max_context_lines: u32,
83    #[serde(default = "ast_grep_default_timeout_secs")]
84    pub timeout_secs: u64,
85    #[serde(default = "ast_grep_default_max_output_bytes")]
86    pub max_output_bytes: u64,
87    #[serde(default = "ast_grep_default_max_match_bytes")]
88    pub max_match_bytes: u64,
89}
90
91fn ast_grep_default_max_results() -> u32 {
92    AST_GREP_DEFAULT_MAX_RESULTS
93}
94fn ast_grep_default_max_context_lines() -> u32 {
95    AST_GREP_DEFAULT_MAX_CONTEXT_LINES
96}
97fn ast_grep_default_timeout_secs() -> u64 {
98    AST_GREP_DEFAULT_TIMEOUT_SECS
99}
100fn ast_grep_default_max_output_bytes() -> u64 {
101    AST_GREP_DEFAULT_MAX_OUTPUT_BYTES
102}
103fn ast_grep_default_max_match_bytes() -> u64 {
104    AST_GREP_DEFAULT_MAX_MATCH_BYTES
105}
106
107impl Default for AstGrepConfig {
108    fn default() -> Self {
109        Self {
110            max_results: AST_GREP_DEFAULT_MAX_RESULTS,
111            max_context_lines: AST_GREP_DEFAULT_MAX_CONTEXT_LINES,
112            timeout_secs: AST_GREP_DEFAULT_TIMEOUT_SECS,
113            max_output_bytes: AST_GREP_DEFAULT_MAX_OUTPUT_BYTES,
114            max_match_bytes: AST_GREP_DEFAULT_MAX_MATCH_BYTES,
115        }
116    }
117}
118
119/// The bounds one `ast_grep_search` call actually runs under.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct AstGrepLimits {
122    pub max_results: u32,
123    pub context_lines: u32,
124    pub timeout_secs: u64,
125    pub max_output_bytes: u64,
126    pub max_match_bytes: u64,
127}
128
129/// `min(requested, configured, hard)`, floored at `floor`. A missing request
130/// means "use the configured value". The floor keeps a zero in config from
131/// turning the tool into a silent no-op (zero results, zero timeout).
132fn clamp<T: Ord + Copy>(requested: Option<T>, configured: T, hard: T, floor: T) -> T {
133    let cap = configured.min(hard);
134    requested.map_or(cap, |r| r.min(cap)).max(floor)
135}
136
137impl AstGrepConfig {
138    /// Resolve the per-call bounds. Only `max_results` and `context` are
139    /// agent-selectable; the time and byte caps are config-and-ceiling only,
140    /// because they are the guards against the agent's own input.
141    pub fn resolve(&self, max_results: Option<u32>, context: Option<u32>) -> AstGrepLimits {
142        AstGrepLimits {
143            max_results: clamp(max_results, self.max_results, AST_GREP_HARD_MAX_RESULTS, 1),
144            context_lines: clamp(
145                context,
146                self.max_context_lines,
147                AST_GREP_HARD_MAX_CONTEXT_LINES,
148                0,
149            ),
150            timeout_secs: clamp(None, self.timeout_secs, AST_GREP_HARD_MAX_TIMEOUT_SECS, 1),
151            max_output_bytes: clamp(
152                None,
153                self.max_output_bytes,
154                AST_GREP_HARD_MAX_OUTPUT_BYTES,
155                1,
156            ),
157            max_match_bytes: clamp(None, self.max_match_bytes, AST_GREP_HARD_MAX_MATCH_BYTES, 1),
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn config_without_search_block_uses_defaults() {
168        let c: Config = serde_yaml::from_str("llm: {}\n").unwrap();
169        assert_eq!(c.search.ast_grep, AstGrepConfig::default());
170    }
171
172    #[test]
173    fn partial_ast_grep_block_keeps_omitted_defaults() {
174        let c: Config =
175            serde_yaml::from_str("search:\n  ast_grep:\n    max_results: 50\n").unwrap();
176        let g = &c.search.ast_grep;
177        assert_eq!(g.max_results, 50);
178        assert_eq!(g.max_context_lines, AST_GREP_DEFAULT_MAX_CONTEXT_LINES);
179        assert_eq!(g.timeout_secs, AST_GREP_DEFAULT_TIMEOUT_SECS);
180        assert_eq!(g.max_output_bytes, AST_GREP_DEFAULT_MAX_OUTPUT_BYTES);
181        assert_eq!(g.max_match_bytes, AST_GREP_DEFAULT_MAX_MATCH_BYTES);
182    }
183
184    #[test]
185    fn defaults_sit_under_ceilings() {
186        let l = AstGrepConfig::default().resolve(None, None);
187        assert_eq!(l.max_results, AST_GREP_DEFAULT_MAX_RESULTS);
188        assert_eq!(l.context_lines, AST_GREP_DEFAULT_MAX_CONTEXT_LINES);
189        assert_eq!(l.timeout_secs, AST_GREP_DEFAULT_TIMEOUT_SECS);
190        assert_eq!(l.max_output_bytes, AST_GREP_DEFAULT_MAX_OUTPUT_BYTES);
191        assert_eq!(l.max_match_bytes, AST_GREP_DEFAULT_MAX_MATCH_BYTES);
192    }
193
194    #[test]
195    fn agent_request_cannot_exceed_config() {
196        let l = AstGrepConfig::default().resolve(Some(u32::MAX), Some(u32::MAX));
197        assert_eq!(l.max_results, AST_GREP_DEFAULT_MAX_RESULTS);
198        assert_eq!(l.context_lines, AST_GREP_DEFAULT_MAX_CONTEXT_LINES);
199    }
200
201    #[test]
202    fn agent_request_below_config_wins() {
203        let l = AstGrepConfig::default().resolve(Some(5), Some(0));
204        assert_eq!((l.max_results, l.context_lines), (5, 0));
205    }
206
207    #[test]
208    fn config_cannot_exceed_hard_ceiling() {
209        let c = AstGrepConfig {
210            max_results: u32::MAX,
211            max_context_lines: u32::MAX,
212            timeout_secs: u64::MAX,
213            max_output_bytes: u64::MAX,
214            max_match_bytes: u64::MAX,
215        };
216        let l = c.resolve(None, None);
217        assert_eq!(l.max_results, AST_GREP_HARD_MAX_RESULTS);
218        assert_eq!(l.context_lines, AST_GREP_HARD_MAX_CONTEXT_LINES);
219        assert_eq!(l.timeout_secs, AST_GREP_HARD_MAX_TIMEOUT_SECS);
220        assert_eq!(l.max_output_bytes, AST_GREP_HARD_MAX_OUTPUT_BYTES);
221        assert_eq!(l.max_match_bytes, AST_GREP_HARD_MAX_MATCH_BYTES);
222    }
223
224    #[test]
225    fn zero_config_is_floored_not_a_silent_noop() {
226        let c = AstGrepConfig {
227            max_results: 0,
228            max_context_lines: 0,
229            timeout_secs: 0,
230            max_output_bytes: 0,
231            max_match_bytes: 0,
232        };
233        let l = c.resolve(Some(0), None);
234        assert_eq!(l.max_results, 1);
235        assert_eq!(l.context_lines, 0);
236        assert_eq!(l.timeout_secs, 1);
237        assert_eq!(l.max_output_bytes, 1);
238        assert_eq!(l.max_match_bytes, 1);
239    }
240}