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, 4000);
170/// assert!(config.cross_file);
171/// ```
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ReviewConfig {
174    /// Maximum number of comments per review (default: 5).
175    #[serde(default = "default_max_comments")]
176    pub max_comments: usize,
177    /// Minimum LLM confidence to include a comment (default: 90.0).
178    #[serde(default = "default_min_confidence")]
179    pub min_confidence: f64,
180    /// Only show comments at these severity levels.
181    #[serde(default = "default_severity_filter")]
182    pub severity_filter: Vec<Severity>,
183    /// Additional glob patterns to skip before sending to LLM.
184    #[serde(default)]
185    pub skip_patterns: Vec<String>,
186    /// Additional file extensions to skip before sending to LLM.
187    #[serde(default)]
188    pub skip_extensions: Vec<String>,
189    /// Token threshold for splitting diff into per-file LLM calls (default: 4000).
190    #[serde(default = "default_max_diff_tokens")]
191    pub max_diff_tokens: usize,
192    /// Include suggestion-level comments (default: false).
193    #[serde(default)]
194    pub include_suggestions: bool,
195    /// Group related files for cross-file analysis when splitting diffs (default: true).
196    #[serde(default = "default_cross_file")]
197    pub cross_file: bool,
198}
199
200fn default_max_comments() -> usize {
201    5
202}
203
204fn default_min_confidence() -> f64 {
205    90.0
206}
207
208fn default_severity_filter() -> Vec<Severity> {
209    vec![Severity::Bug, Severity::Warning]
210}
211
212fn default_max_diff_tokens() -> usize {
213    4000
214}
215
216fn default_cross_file() -> bool {
217    true
218}
219
220impl Default for ReviewConfig {
221    fn default() -> Self {
222        Self {
223            max_comments: default_max_comments(),
224            min_confidence: default_min_confidence(),
225            severity_filter: default_severity_filter(),
226            skip_patterns: Vec::new(),
227            skip_extensions: Vec::new(),
228            max_diff_tokens: default_max_diff_tokens(),
229            include_suggestions: false,
230            cross_file: default_cross_file(),
231        }
232    }
233}
234
235/// Per-path configuration for monorepo support.
236///
237/// # Examples
238///
239/// ```
240/// use argus_core::PathConfig;
241///
242/// let config = PathConfig {
243///     instructions: Some("Focus on auth flows".into()),
244///     context_boundary: true,
245/// };
246/// assert!(config.context_boundary);
247/// ```
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct PathConfig {
250    /// Custom review instructions for this path.
251    pub instructions: Option<String>,
252    /// When `true`, prevent cross-boundary context leaking.
253    #[serde(default)]
254    pub context_boundary: bool,
255}
256
257/// Configuration for embedding providers used by semantic search.
258///
259/// # Examples
260///
261/// ```
262/// use argus_core::EmbeddingConfig;
263///
264/// let config = EmbeddingConfig::default();
265/// assert_eq!(config.provider, "voyage");
266/// assert_eq!(config.model, "voyage-code-3");
267/// assert_eq!(config.dimensions, 1024);
268/// ```
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct EmbeddingConfig {
271    /// Embedding provider (default: `"voyage"`).
272    #[serde(default = "default_embedding_provider")]
273    pub provider: String,
274    /// API key for the embedding provider.
275    pub api_key: Option<String>,
276    /// Model name (default: `"voyage-code-3"`).
277    #[serde(default = "default_embedding_model")]
278    pub model: String,
279    /// Embedding dimensions (default: 1024).
280    #[serde(default = "default_embedding_dimensions")]
281    pub dimensions: usize,
282}
283
284fn default_embedding_provider() -> String {
285    "voyage".into()
286}
287
288fn default_embedding_model() -> String {
289    "voyage-code-3".into()
290}
291
292fn default_embedding_dimensions() -> usize {
293    1024
294}
295
296impl Default for EmbeddingConfig {
297    fn default() -> Self {
298        Self {
299            provider: default_embedding_provider(),
300            api_key: None,
301            model: default_embedding_model(),
302            dimensions: default_embedding_dimensions(),
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn default_config_has_expected_values() {
313        let config = ArgusConfig::default();
314        assert_eq!(config.review.max_comments, 5);
315        assert_eq!(config.review.min_confidence, 90.0);
316        assert_eq!(config.review.max_diff_tokens, 4000);
317        assert!(!config.review.include_suggestions);
318        assert!(config.review.skip_patterns.is_empty());
319        assert!(config.review.skip_extensions.is_empty());
320        assert_eq!(config.llm.provider, "openai");
321        assert_eq!(config.llm.model, "gpt-4o");
322        assert_eq!(config.embedding.provider, "voyage");
323        assert_eq!(config.embedding.model, "voyage-code-3");
324        assert_eq!(config.embedding.dimensions, 1024);
325        assert!(config.paths.is_empty());
326    }
327
328    #[test]
329    fn parse_minimal_toml() {
330        let toml = r#"
331[review]
332max_comments = 10
333min_confidence = 85.0
334"#;
335        let config = ArgusConfig::from_toml(toml).unwrap();
336        assert_eq!(config.review.max_comments, 10);
337        assert_eq!(config.review.min_confidence, 85.0);
338    }
339
340    #[test]
341    fn parse_full_toml() {
342        let toml = r#"
343[llm]
344provider = "anthropic"
345model = "claude-sonnet-4-20250514"
346base_url = "https://api.anthropic.com"
347max_input_tokens = 50000
348
349[review]
350max_comments = 3
351min_confidence = 95.0
352severity_filter = ["bug"]
353
354[paths."packages/auth"]
355instructions = "Focus on authentication flows"
356context_boundary = true
357"#;
358        let config = ArgusConfig::from_toml(toml).unwrap();
359        assert_eq!(config.llm.provider, "anthropic");
360        assert_eq!(config.llm.max_input_tokens, Some(50000));
361        assert_eq!(config.review.max_comments, 3);
362        assert_eq!(config.review.severity_filter, vec![Severity::Bug]);
363
364        let auth_path = &config.paths["packages/auth"];
365        assert!(auth_path.context_boundary);
366        assert_eq!(
367            auth_path.instructions.as_deref(),
368            Some("Focus on authentication flows")
369        );
370    }
371
372    #[test]
373    fn empty_toml_gives_defaults() {
374        let config = ArgusConfig::from_toml("").unwrap();
375        assert_eq!(config.review.max_comments, 5);
376        assert_eq!(config.llm.model, "gpt-4o");
377    }
378
379    #[test]
380    fn invalid_toml_returns_error() {
381        let result = ArgusConfig::from_toml("{{invalid}}");
382        assert!(result.is_err());
383    }
384
385    #[test]
386    fn parse_noise_reduction_config() {
387        let toml = r#"
388[review]
389max_comments = 3
390skip_patterns = ["*.test.ts", "fixtures/**"]
391skip_extensions = ["snap", "lock"]
392max_diff_tokens = 8000
393include_suggestions = true
394"#;
395        let config = ArgusConfig::from_toml(toml).unwrap();
396        assert_eq!(config.review.max_comments, 3);
397        assert_eq!(
398            config.review.skip_patterns,
399            vec!["*.test.ts", "fixtures/**"]
400        );
401        assert_eq!(config.review.skip_extensions, vec!["snap", "lock"]);
402        assert_eq!(config.review.max_diff_tokens, 8000);
403        assert!(config.review.include_suggestions);
404    }
405
406    #[test]
407    fn noise_reduction_defaults_when_omitted() {
408        let toml = r#"
409[review]
410max_comments = 10
411"#;
412        let config = ArgusConfig::from_toml(toml).unwrap();
413        assert!(config.review.skip_patterns.is_empty());
414        assert!(config.review.skip_extensions.is_empty());
415        assert_eq!(config.review.max_diff_tokens, 4000);
416        assert!(!config.review.include_suggestions);
417    }
418
419    #[test]
420    fn parse_rules_from_toml() {
421        let toml = r#"
422[[rules]]
423name = "no-unwrap"
424severity = "warning"
425description = "Do not use .unwrap() in production code"
426
427[[rules]]
428name = "no-todo"
429severity = "suggestion"
430description = "Remove TODO comments before merging"
431"#;
432        let config = ArgusConfig::from_toml(toml).unwrap();
433        assert_eq!(config.rules.len(), 2);
434        assert_eq!(config.rules[0].name, "no-unwrap");
435        assert_eq!(config.rules[0].severity, "warning");
436        assert_eq!(
437            config.rules[0].description,
438            "Do not use .unwrap() in production code"
439        );
440        assert_eq!(config.rules[1].name, "no-todo");
441        assert_eq!(config.rules[1].severity, "suggestion");
442    }
443
444    #[test]
445    fn empty_rules_by_default() {
446        assert!(ArgusConfig::default().rules.is_empty());
447    }
448}