xchecker_config/config/builder.rs
1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::error::XCheckerError;
5
6use super::{
7 Config, ConfigSource, Defaults, HooksConfig, LlmConfig, PhasesConfig, RunnerConfig,
8 SecurityConfig, Selectors,
9};
10
11impl Config {
12 /// Create a builder for programmatic configuration.
13 ///
14 /// Use this when you need to configure xchecker programmatically without
15 /// relying on environment variables or config files. This is the recommended
16 /// approach for embedding xchecker in other applications.
17 ///
18 /// # Example
19 ///
20 /// ```rust,no_run
21 /// use xchecker_config::Config;
22 /// use std::time::Duration;
23 ///
24 /// let config = Config::builder()
25 /// .state_dir("/custom/path")
26 /// .packet_max_bytes(32768)
27 /// .packet_max_lines(600)
28 /// .phase_timeout(Duration::from_secs(300))
29 /// .build()
30 /// .expect("Failed to build config");
31 /// ```
32 #[must_use]
33 pub fn builder() -> ConfigBuilder {
34 ConfigBuilder::new()
35 }
36}
37
38/// Builder for programmatic configuration of xchecker.
39///
40/// `ConfigBuilder` provides a fluent API for constructing `Config` instances
41/// without relying on environment variables or config files. This is useful
42/// for embedding xchecker in other applications where deterministic behavior
43/// is required.
44///
45/// # Example
46///
47/// ```rust,no_run
48/// use xchecker_config::Config;
49/// use std::time::Duration;
50///
51/// let config = Config::builder()
52/// .state_dir("/custom/state")
53/// .packet_max_bytes(65536)
54/// .packet_max_lines(1200)
55/// .phase_timeout(Duration::from_secs(600))
56/// .runner_mode("native")
57/// .build()
58/// .expect("Failed to build config");
59/// ```
60///
61/// # Source Attribution
62///
63/// All values set via the builder are attributed to `ConfigSource::Programmatic`
64/// in the resulting `Config`'s source attribution map.
65#[derive(Debug, Clone)]
66pub struct ConfigBuilder {
67 state_dir: Option<PathBuf>,
68 packet_max_bytes: Option<usize>,
69 packet_max_lines: Option<usize>,
70 phase_timeout: Option<std::time::Duration>,
71 runner_mode: Option<String>,
72 model: Option<String>,
73 max_turns: Option<u32>,
74 verbose: Option<bool>,
75 llm_provider: Option<String>,
76 execution_strategy: Option<String>,
77 extra_secret_patterns: Vec<String>,
78 ignore_secret_patterns: Vec<String>,
79}
80
81impl Default for ConfigBuilder {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl ConfigBuilder {
88 /// Create a new `ConfigBuilder` with no values set.
89 ///
90 /// All configuration values will use their defaults unless explicitly set.
91 #[must_use]
92 pub fn new() -> Self {
93 Self {
94 state_dir: None,
95 packet_max_bytes: None,
96 packet_max_lines: None,
97 phase_timeout: None,
98 runner_mode: None,
99 model: None,
100 max_turns: None,
101 verbose: None,
102 llm_provider: None,
103 execution_strategy: None,
104 extra_secret_patterns: Vec::new(),
105 ignore_secret_patterns: Vec::new(),
106 }
107 }
108
109 /// Set the state directory for xchecker operations.
110 ///
111 /// This overrides the default state directory discovery (XCHECKER_HOME,
112 /// upward search for `.xchecker/`).
113 ///
114 /// # Arguments
115 ///
116 /// * `path` - Path to the state directory
117 #[must_use]
118 pub fn state_dir(mut self, path: impl Into<PathBuf>) -> Self {
119 self.state_dir = Some(path.into());
120 self
121 }
122
123 /// Set the maximum packet size in bytes.
124 ///
125 /// This limits the size of context packets sent to the LLM.
126 /// Default: 65536 bytes.
127 ///
128 /// # Arguments
129 ///
130 /// * `bytes` - Maximum packet size in bytes (must be > 0 and <= 10MB)
131 #[must_use]
132 pub fn packet_max_bytes(mut self, bytes: usize) -> Self {
133 self.packet_max_bytes = Some(bytes);
134 self
135 }
136
137 /// Set the maximum packet size in lines.
138 ///
139 /// This limits the number of lines in context packets sent to the LLM.
140 /// Default: 1200 lines.
141 ///
142 /// # Arguments
143 ///
144 /// * `lines` - Maximum packet size in lines (must be > 0 and <= 100,000)
145 #[must_use]
146 pub fn packet_max_lines(mut self, lines: usize) -> Self {
147 self.packet_max_lines = Some(lines);
148 self
149 }
150
151 /// Set the phase execution timeout.
152 ///
153 /// This limits how long a single phase can run before timing out.
154 /// Default: 600 seconds (10 minutes).
155 ///
156 /// # Arguments
157 ///
158 /// * `timeout` - Phase timeout duration (must be >= 5 seconds and <= 2 hours)
159 #[must_use]
160 pub fn phase_timeout(mut self, timeout: std::time::Duration) -> Self {
161 self.phase_timeout = Some(timeout);
162 self
163 }
164
165 /// Set the runner mode for process execution.
166 ///
167 /// Valid values: "auto", "native", "wsl"
168 /// Default: "auto"
169 ///
170 /// # Arguments
171 ///
172 /// * `mode` - Runner mode string
173 #[must_use]
174 pub fn runner_mode(mut self, mode: impl Into<String>) -> Self {
175 self.runner_mode = Some(mode.into());
176 self
177 }
178
179 /// Set the model to use for LLM operations.
180 ///
181 /// Valid values: "haiku", "sonnet", "opus", or specific model versions.
182 /// Default: "haiku" (for testing/development)
183 ///
184 /// # Arguments
185 ///
186 /// * `model` - Model name or alias
187 #[must_use]
188 pub fn model(mut self, model: impl Into<String>) -> Self {
189 self.model = Some(model.into());
190 self
191 }
192
193 /// Set the maximum number of turns for LLM interactions.
194 ///
195 /// Default: 6 turns.
196 ///
197 /// # Arguments
198 ///
199 /// * `turns` - Maximum number of turns (must be > 0 and <= 50)
200 #[must_use]
201 pub fn max_turns(mut self, turns: u32) -> Self {
202 self.max_turns = Some(turns);
203 self
204 }
205
206 /// Set verbose output mode.
207 ///
208 /// Default: false
209 ///
210 /// # Arguments
211 ///
212 /// * `verbose` - Whether to enable verbose output
213 #[must_use]
214 pub fn verbose(mut self, verbose: bool) -> Self {
215 self.verbose = Some(verbose);
216 self
217 }
218
219 /// Set the LLM provider.
220 ///
221 /// Valid values: "claude-cli", "gemini-cli", "openrouter", "anthropic"
222 /// Default: "claude-cli"
223 ///
224 /// # Arguments
225 ///
226 /// * `provider` - LLM provider name
227 #[must_use]
228 pub fn llm_provider(mut self, provider: impl Into<String>) -> Self {
229 self.llm_provider = Some(provider.into());
230 self
231 }
232
233 /// Set the execution strategy.
234 ///
235 /// Currently only "controlled" is supported.
236 /// Default: "controlled"
237 ///
238 /// # Arguments
239 ///
240 /// * `strategy` - Execution strategy name
241 #[must_use]
242 pub fn execution_strategy(mut self, strategy: impl Into<String>) -> Self {
243 self.execution_strategy = Some(strategy.into());
244 self
245 }
246
247 /// Add extra secret patterns for detection.
248 ///
249 /// These patterns are added to the built-in patterns and will cause
250 /// secret detection to trigger if matched.
251 ///
252 /// # Arguments
253 ///
254 /// * `patterns` - Vector of regex patterns to add
255 ///
256 /// # Example
257 ///
258 /// ```rust,no_run
259 /// use xchecker_config::Config;
260 ///
261 /// let config = Config::builder()
262 /// .extra_secret_patterns(vec![
263 /// "SECRET_[A-Z0-9]{32}".to_string(),
264 /// "API_KEY_[A-Za-z0-9]{40}".to_string(),
265 /// ])
266 /// .build()
267 /// .expect("Failed to build config");
268 /// ```
269 #[must_use]
270 pub fn extra_secret_patterns(mut self, patterns: Vec<String>) -> Self {
271 self.extra_secret_patterns = patterns;
272 self
273 }
274
275 /// Add a single extra secret pattern for detection.
276 ///
277 /// This pattern is added to the built-in patterns and will cause
278 /// secret detection to trigger if matched.
279 ///
280 /// # Arguments
281 ///
282 /// * `pattern` - Regex pattern to add
283 #[must_use]
284 pub fn add_extra_secret_pattern(mut self, pattern: impl Into<String>) -> Self {
285 self.extra_secret_patterns.push(pattern.into());
286 self
287 }
288
289 /// Set patterns to ignore during secret detection.
290 ///
291 /// Pattern IDs listed here will be ignored during secret scanning.
292 /// Use this to suppress false positives for known-safe patterns.
293 ///
294 /// **Warning:** Suppressing patterns reduces security. Only suppress
295 /// patterns if you're certain they won't match real secrets.
296 ///
297 /// # Arguments
298 ///
299 /// * `patterns` - Vector of pattern IDs to ignore
300 ///
301 /// # Example
302 ///
303 /// ```rust,no_run
304 /// use xchecker_config::Config;
305 ///
306 /// let config = Config::builder()
307 /// .ignore_secret_patterns(vec!["test_token".to_string()])
308 /// .build()
309 /// .expect("Failed to build config");
310 /// ```
311 #[must_use]
312 pub fn ignore_secret_patterns(mut self, patterns: Vec<String>) -> Self {
313 self.ignore_secret_patterns = patterns;
314 self
315 }
316
317 /// Add a single pattern to ignore during secret detection.
318 ///
319 /// **Warning:** Suppressing patterns reduces security. Only suppress
320 /// patterns if you're certain they won't match real secrets.
321 ///
322 /// # Arguments
323 ///
324 /// * `pattern` - Pattern ID to ignore
325 #[must_use]
326 pub fn add_ignore_secret_pattern(mut self, pattern: impl Into<String>) -> Self {
327 self.ignore_secret_patterns.push(pattern.into());
328 self
329 }
330
331 /// Build the `Config` from the builder values.
332 ///
333 /// This creates a `Config` using the values set on the builder, with
334 /// defaults applied for any unset values. The resulting config is
335 /// validated before being returned.
336 ///
337 /// # Errors
338 ///
339 /// Returns an error if:
340 /// - Any configuration value is invalid (e.g., packet_max_bytes = 0)
341 /// - Validation fails for the resulting configuration
342 ///
343 /// # Returns
344 ///
345 /// A fully configured and validated `Config` instance.
346 pub fn build(self) -> Result<Config, XCheckerError> {
347 let mut source_attribution = HashMap::new();
348
349 // Start with defaults
350 let mut defaults = Defaults::default();
351 let selectors = Selectors::default();
352 let mut runner = RunnerConfig::default();
353 let mut llm = LlmConfig {
354 provider: None,
355 fallback_provider: None,
356 claude: None,
357 gemini: None,
358 openrouter: None,
359 anthropic: None,
360 execution_strategy: None,
361 prompt_template: None,
362 };
363 let phases = PhasesConfig::default();
364 let hooks = HooksConfig::default();
365
366 // Track default sources before any programmatic overrides.
367 for key in [
368 "max_turns",
369 "packet_max_bytes",
370 "packet_max_lines",
371 "output_format",
372 "verbose",
373 "runner_mode",
374 "phase_timeout",
375 "stdout_cap_bytes",
376 "stderr_cap_bytes",
377 "lock_ttl_seconds",
378 "debug_packet",
379 "allow_links",
380 "llm_provider",
381 "execution_strategy",
382 ] {
383 source_attribution.insert(key.to_string(), ConfigSource::Default);
384 }
385
386 // Seed LLM defaults before applying overrides.
387 llm.provider = Some("claude-cli".to_string());
388 llm.execution_strategy = Some("controlled".to_string());
389
390 // Apply builder values (all attributed to Programmatic source).
391 if let Some(bytes) = self.packet_max_bytes {
392 defaults.packet_max_bytes = Some(bytes);
393 source_attribution.insert("packet_max_bytes".to_string(), ConfigSource::Programmatic);
394 }
395
396 if let Some(lines) = self.packet_max_lines {
397 defaults.packet_max_lines = Some(lines);
398 source_attribution.insert("packet_max_lines".to_string(), ConfigSource::Programmatic);
399 }
400
401 if let Some(timeout) = self.phase_timeout {
402 defaults.phase_timeout = Some(timeout.as_secs());
403 source_attribution.insert("phase_timeout".to_string(), ConfigSource::Programmatic);
404 }
405
406 if let Some(mode) = self.runner_mode {
407 runner.mode = Some(mode);
408 source_attribution.insert("runner_mode".to_string(), ConfigSource::Programmatic);
409 }
410
411 if let Some(model) = self.model {
412 defaults.model = Some(model);
413 source_attribution.insert("model".to_string(), ConfigSource::Programmatic);
414 }
415
416 if let Some(turns) = self.max_turns {
417 defaults.max_turns = Some(turns);
418 source_attribution.insert("max_turns".to_string(), ConfigSource::Programmatic);
419 }
420
421 if let Some(verbose) = self.verbose {
422 defaults.verbose = Some(verbose);
423 source_attribution.insert("verbose".to_string(), ConfigSource::Programmatic);
424 }
425
426 // Apply LLM provider.
427 if let Some(provider) = self.llm_provider {
428 llm.provider = Some(provider);
429 source_attribution.insert("llm_provider".to_string(), ConfigSource::Programmatic);
430 }
431
432 // Apply execution strategy.
433 if let Some(strategy) = self.execution_strategy {
434 llm.execution_strategy = Some(strategy);
435 source_attribution.insert("execution_strategy".to_string(), ConfigSource::Programmatic);
436 }
437
438 // Note: state_dir is stored but not directly used in Config struct
439 // It would be used by OrchestratorHandle when creating the orchestrator
440 // For now, we store it in a way that can be retrieved if needed
441 if self.state_dir.is_some() {
442 source_attribution.insert("state_dir".to_string(), ConfigSource::Programmatic);
443 }
444
445 // Build security config from builder values
446 let security = SecurityConfig {
447 extra_secret_patterns: self.extra_secret_patterns,
448 ignore_secret_patterns: self.ignore_secret_patterns,
449 };
450 if !security.extra_secret_patterns.is_empty() || !security.ignore_secret_patterns.is_empty()
451 {
452 source_attribution.insert("security".to_string(), ConfigSource::Programmatic);
453 }
454
455 let config = Config {
456 defaults,
457 selectors,
458 runner,
459 llm,
460 phases,
461 hooks,
462 security,
463 source_attribution,
464 };
465
466 // Validate the configuration
467 config.validate()?;
468
469 Ok(config)
470 }
471}