1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::ArgusError;
7use crate::types::Severity;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Rule {
28 pub name: String,
30 pub severity: String,
32 pub description: String,
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct ArgusConfig {
50 #[serde(default)]
52 pub llm: LlmConfig,
53 #[serde(default)]
55 pub review: ReviewConfig,
56 #[serde(default)]
58 pub embedding: EmbeddingConfig,
59 #[serde(default)]
61 pub paths: HashMap<String, PathConfig>,
62 #[serde(default)]
64 pub rules: Vec<Rule>,
65}
66
67impl ArgusConfig {
68 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 pub fn from_toml(content: &str) -> Result<Self, ArgusError> {
107 let config: Self = toml::from_str(content)?;
108 Ok(config)
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct LlmConfig {
124 #[serde(default = "default_provider")]
126 pub provider: String,
127 #[serde(default = "default_model")]
129 pub model: String,
130 pub api_key: Option<String>,
132 pub base_url: Option<String>,
134 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#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ReviewConfig {
174 #[serde(default = "default_max_comments")]
176 pub max_comments: usize,
177 #[serde(default = "default_min_confidence")]
179 pub min_confidence: f64,
180 #[serde(default = "default_severity_filter")]
182 pub severity_filter: Vec<Severity>,
183 #[serde(default)]
185 pub skip_patterns: Vec<String>,
186 #[serde(default)]
188 pub skip_extensions: Vec<String>,
189 #[serde(default = "default_max_diff_tokens")]
191 pub max_diff_tokens: usize,
192 #[serde(default)]
194 pub include_suggestions: bool,
195 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct PathConfig {
250 pub instructions: Option<String>,
252 #[serde(default)]
254 pub context_boundary: bool,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct EmbeddingConfig {
271 #[serde(default = "default_embedding_provider")]
273 pub provider: String,
274 pub api_key: Option<String>,
276 #[serde(default = "default_embedding_model")]
278 pub model: String,
279 #[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}