Skip to main content

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}