Skip to main content

argus_core/
config.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::ArgusError;
7use crate::types::Severity;
8
9/// A custom review rule defined in `.argus.toml`.
10///
11/// Rules are injected into the LLM system prompt so the reviewer
12/// checks for project-specific patterns.
13///
14/// # Examples
15///
16/// ```
17/// use argus_core::Rule;
18///
19/// let rule = Rule {
20///     name: "no-unwrap".into(),
21///     severity: "warning".into(),
22///     description: "Do not use .unwrap() in production code".into(),
23/// };
24/// assert_eq!(rule.name, "no-unwrap");
25/// ```
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Rule {
28    /// Short identifier for the rule (used in output).
29    pub name: String,
30    /// Severity level: "bug", "warning", or "suggestion".
31    pub severity: String,
32    /// Natural language instruction for the LLM.
33    pub description: String,
34}
35
36/// Top-level configuration loaded from `.argus.toml`.
37///
38/// Supports layered resolution: CLI flags > env vars > local config > defaults.
39///
40/// # Examples
41///
42/// ```
43/// use argus_core::ArgusConfig;
44///
45/// let config = ArgusConfig::default();
46/// assert_eq!(config.review.max_comments, 5);
47/// ```
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct ArgusConfig {
50    /// LLM provider settings.
51    #[serde(default)]
52    pub llm: LlmConfig,
53    /// Review behavior settings.
54    #[serde(default)]
55    pub review: ReviewConfig,
56    /// Embedding provider settings for semantic search.
57    #[serde(default)]
58    pub embedding: EmbeddingConfig,
59    /// Per-path overrides for monorepo support.
60    #[serde(default)]
61    pub paths: HashMap<String, PathConfig>,
62    /// Custom review rules injected into the LLM prompt.
63    #[serde(default)]
64    pub rules: Vec<Rule>,
65}
66
67impl ArgusConfig {
68    /// Load configuration from a TOML file at `path`.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`ArgusError::Io`] if the file cannot be read, or
73    /// [`ArgusError::Toml`] if the content is not valid TOML.
74    ///
75    /// # Examples
76    ///
77    /// ```no_run
78    /// use argus_core::ArgusConfig;
79    /// use std::path::Path;
80    ///
81    /// let config = ArgusConfig::from_file(Path::new(".argus.toml")).unwrap();
82    /// ```
83    pub fn from_file(path: &Path) -> Result<Self, ArgusError> {
84        let content = std::fs::read_to_string(path)?;
85        Self::from_toml(&content)
86    }
87
88    /// Parse configuration from a TOML string.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`ArgusError::Toml`] if parsing fails.
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use argus_core::ArgusConfig;
98    ///
99    /// let toml = r#"
100    /// [review]
101    /// max_comments = 10
102    /// "#;
103    /// let config = ArgusConfig::from_toml(toml).unwrap();
104    /// assert_eq!(config.review.max_comments, 10);
105    /// ```
106    pub fn from_toml(content: &str) -> Result<Self, ArgusError> {
107        let config: Self = toml::from_str(content)?;
108        Ok(config)
109    }
110}
111
112/// LLM provider configuration.
113///
114/// # Examples
115///
116/// ```
117/// use argus_core::LlmConfig;
118///
119/// let config = LlmConfig::default();
120/// assert_eq!(config.model, "gpt-4o");
121/// ```
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct LlmConfig {
124    /// Provider name (e.g. `"openai"`, `"anthropic"`, `"ollama"`).
125    #[serde(default = "default_provider")]
126    pub provider: String,
127    /// Model identifier.
128    #[serde(default = "default_model")]
129    pub model: String,
130    /// API key for the provider.
131    pub api_key: Option<String>,
132    /// Custom base URL for API requests.
133    pub base_url: Option<String>,
134    /// Maximum input tokens to send per request.
135    pub max_input_tokens: Option<usize>,
136}
137
138fn default_provider() -> String {
139    "openai".into()
140}
141
142fn default_model() -> String {
143    "gpt-4o".into()
144}
145
146impl Default for LlmConfig {
147    fn default() -> Self {
148        Self {
149            provider: default_provider(),
150            model: default_model(),
151            api_key: None,
152            base_url: None,
153            max_input_tokens: None,
154        }
155    }
156}
157
158/// Review behavior configuration.
159///
160/// # Examples
161///
162/// ```
163/// use argus_core::ReviewConfig;
164///
165/// let config = ReviewConfig::default();
166/// assert_eq!(config.min_confidence, 90.0);
167/// assert_eq!(config.max_comments, 5);
168/// assert!(!config.include_suggestions);
169/// assert_eq!(config.max_diff_tokens, 64000);
170/// assert!(config.cross_file);
171/// assert!(config.self_reflection);
172/// assert_eq!(config.self_reflection_score_threshold, 7);
173/// ```
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ReviewConfig {
176    /// Maximum number of comments per review (default: 5).
177    #[serde(default = "default_max_comments")]
178    pub max_comments: usize,
179    /// Minimum LLM confidence to include a comment (default: 90.0).
180    #[serde(default = "default_min_confidence")]
181    pub min_confidence: f64,
182    /// Only show comments at these severity levels.
183    #[serde(default = "default_severity_filter")]
184    pub severity_filter: Vec<Severity>,
185    /// Additional glob patterns to skip before sending to LLM.
186    #[serde(default)]
187    pub skip_patterns: Vec<String>,
188    /// Additional file extensions to skip before sending to LLM.
189    #[serde(default)]
190    pub skip_extensions: Vec<String>,
191    /// Token threshold for splitting diff into per-file LLM calls (default: 4000).
192    #[serde(default = "default_max_diff_tokens")]
193    pub max_diff_tokens: usize,
194    /// Include suggestion-level comments (default: false).
195    #[serde(default)]
196    pub include_suggestions: bool,
197    /// Group related files for cross-file analysis when splitting diffs (default: true).
198    #[serde(default = "default_cross_file")]
199    pub cross_file: bool,
200    /// Enable self-reflection pass to filter false positives (default: true).
201    ///
202    /// When enabled, a second LLM call evaluates the initial review comments
203    /// and filters out low-quality ones (style nits, speculative issues, etc.).
204    #[serde(default = "default_self_reflection")]
205    pub self_reflection: bool,
206    /// Minimum score (1-10) a comment must receive during self-reflection to be kept (default: 7).
207    #[serde(default = "default_self_reflection_score_threshold")]
208    pub self_reflection_score_threshold: u8,
209}
210
211fn default_max_comments() -> usize {
212    5
213}
214
215fn default_min_confidence() -> f64 {
216    90.0
217}
218
219fn default_severity_filter() -> Vec<Severity> {
220    vec![Severity::Bug, Severity::Warning]
221}
222
223fn default_max_diff_tokens() -> usize {
224    64000
225}
226
227fn default_cross_file() -> bool {
228    true
229}
230
231fn default_self_reflection() -> bool {
232    true
233}
234
235fn default_self_reflection_score_threshold() -> u8 {
236    7
237}
238
239impl Default for ReviewConfig {
240    fn default() -> Self {
241        Self {
242            max_comments: default_max_comments(),
243            min_confidence: default_min_confidence(),
244            severity_filter: default_severity_filter(),
245            skip_patterns: Vec::new(),
246            skip_extensions: Vec::new(),
247            max_diff_tokens: default_max_diff_tokens(),
248            include_suggestions: false,
249            cross_file: default_cross_file(),
250            self_reflection: default_self_reflection(),
251            self_reflection_score_threshold: default_self_reflection_score_threshold(),
252        }
253    }
254}
255
256/// Per-path configuration for monorepo support.
257///
258/// # Examples
259///
260/// ```
261/// use argus_core::PathConfig;
262///
263/// let config = PathConfig {
264///     instructions: Some("Focus on auth flows".into()),
265///     context_boundary: true,
266/// };
267/// assert!(config.context_boundary);
268/// ```
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct PathConfig {
271    /// Custom review instructions for this path.
272    pub instructions: Option<String>,
273    /// When `true`, prevent cross-boundary context leaking.
274    #[serde(default)]
275    pub context_boundary: bool,
276}
277
278/// Configuration for embedding providers used by semantic search.
279///
280/// # Examples
281///
282/// ```
283/// use argus_core::EmbeddingConfig;
284///
285/// let config = EmbeddingConfig::default();
286/// assert_eq!(config.provider, "voyage");
287/// assert_eq!(config.model, "voyage-code-3");
288/// assert_eq!(config.dimensions, 1024);
289/// ```
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct EmbeddingConfig {
292    /// Embedding provider (default: `"voyage"`).
293    #[serde(default = "default_embedding_provider")]
294    pub provider: String,
295    /// API key for the embedding provider.
296    pub api_key: Option<String>,
297    /// Model name (default: `"voyage-code-3"`).
298    #[serde(default = "default_embedding_model")]
299    pub model: String,
300    /// Embedding dimensions (default: 1024).
301    #[serde(default = "default_embedding_dimensions")]
302    pub dimensions: usize,
303}
304
305fn default_embedding_provider() -> String {
306    "voyage".into()
307}
308
309fn default_embedding_model() -> String {
310    "voyage-code-3".into()
311}
312
313fn default_embedding_dimensions() -> usize {
314    1024
315}
316
317impl Default for EmbeddingConfig {
318    fn default() -> Self {
319        Self {
320            provider: default_embedding_provider(),
321            api_key: None,
322            model: default_embedding_model(),
323            dimensions: default_embedding_dimensions(),
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn default_config_has_expected_values() {
334        let config = ArgusConfig::default();
335        assert_eq!(config.review.max_comments, 5);
336        assert_eq!(config.review.min_confidence, 90.0);
337        assert_eq!(config.review.max_diff_tokens, 64000);
338        assert!(!config.review.include_suggestions);
339        assert!(config.review.skip_patterns.is_empty());
340        assert!(config.review.skip_extensions.is_empty());
341        assert_eq!(config.llm.provider, "openai");
342        assert_eq!(config.llm.model, "gpt-4o");
343        assert_eq!(config.embedding.provider, "voyage");
344        assert_eq!(config.embedding.model, "voyage-code-3");
345        assert_eq!(config.embedding.dimensions, 1024);
346        assert!(config.paths.is_empty());
347        assert!(config.review.self_reflection);
348        assert_eq!(config.review.self_reflection_score_threshold, 7);
349    }
350
351    #[test]
352    fn parse_minimal_toml() {
353        let toml = r#"
354[review]
355max_comments = 10
356min_confidence = 85.0
357"#;
358        let config = ArgusConfig::from_toml(toml).unwrap();
359        assert_eq!(config.review.max_comments, 10);
360        assert_eq!(config.review.min_confidence, 85.0);
361    }
362
363    #[test]
364    fn parse_full_toml() {
365        let toml = r#"
366[llm]
367provider = "anthropic"
368model = "claude-sonnet-4-20250514"
369base_url = "https://api.anthropic.com"
370max_input_tokens = 50000
371
372[review]
373max_comments = 3
374min_confidence = 95.0
375severity_filter = ["bug"]
376
377[paths."packages/auth"]
378instructions = "Focus on authentication flows"
379context_boundary = true
380"#;
381        let config = ArgusConfig::from_toml(toml).unwrap();
382        assert_eq!(config.llm.provider, "anthropic");
383        assert_eq!(config.llm.max_input_tokens, Some(50000));
384        assert_eq!(config.review.max_comments, 3);
385        assert_eq!(config.review.severity_filter, vec![Severity::Bug]);
386
387        let auth_path = &config.paths["packages/auth"];
388        assert!(auth_path.context_boundary);
389        assert_eq!(
390            auth_path.instructions.as_deref(),
391            Some("Focus on authentication flows")
392        );
393    }
394
395    #[test]
396    fn empty_toml_gives_defaults() {
397        let config = ArgusConfig::from_toml("").unwrap();
398        assert_eq!(config.review.max_comments, 5);
399        assert_eq!(config.llm.model, "gpt-4o");
400    }
401
402    #[test]
403    fn invalid_toml_returns_error() {
404        let result = ArgusConfig::from_toml("{{invalid}}");
405        assert!(result.is_err());
406    }
407
408    #[test]
409    fn parse_noise_reduction_config() {
410        let toml = r#"
411[review]
412max_comments = 3
413skip_patterns = ["*.test.ts", "fixtures/**"]
414skip_extensions = ["snap", "lock"]
415max_diff_tokens = 8000
416include_suggestions = true
417"#;
418        let config = ArgusConfig::from_toml(toml).unwrap();
419        assert_eq!(config.review.max_comments, 3);
420        assert_eq!(
421            config.review.skip_patterns,
422            vec!["*.test.ts", "fixtures/**"]
423        );
424        assert_eq!(config.review.skip_extensions, vec!["snap", "lock"]);
425        assert_eq!(config.review.max_diff_tokens, 8000);
426        assert!(config.review.include_suggestions);
427    }
428
429    #[test]
430    fn noise_reduction_defaults_when_omitted() {
431        let toml = r#"
432[review]
433max_comments = 10
434"#;
435        let config = ArgusConfig::from_toml(toml).unwrap();
436        assert!(config.review.skip_patterns.is_empty());
437        assert!(config.review.skip_extensions.is_empty());
438        assert_eq!(config.review.max_diff_tokens, 64000);
439        assert!(!config.review.include_suggestions);
440    }
441
442    #[test]
443    fn parse_rules_from_toml() {
444        let toml = r#"
445[[rules]]
446name = "no-unwrap"
447severity = "warning"
448description = "Do not use .unwrap() in production code"
449
450[[rules]]
451name = "no-todo"
452severity = "suggestion"
453description = "Remove TODO comments before merging"
454"#;
455        let config = ArgusConfig::from_toml(toml).unwrap();
456        assert_eq!(config.rules.len(), 2);
457        assert_eq!(config.rules[0].name, "no-unwrap");
458        assert_eq!(config.rules[0].severity, "warning");
459        assert_eq!(
460            config.rules[0].description,
461            "Do not use .unwrap() in production code"
462        );
463        assert_eq!(config.rules[1].name, "no-todo");
464        assert_eq!(config.rules[1].severity, "suggestion");
465    }
466
467    #[test]
468    fn empty_rules_by_default() {
469        assert!(ArgusConfig::default().rules.is_empty());
470    }
471}