use std::path::Path;
use serde::Deserialize;
use crate::batch::DEFAULT_MAX_LINES;
use crate::error::ReviewError;
use crate::prompt::DEFAULT_SYSTEM_PROMPT;
pub const DEFAULT_MAX_CONCURRENT: usize = 4;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ConfigFile {
pub review: Option<ReviewSection>,
pub azure: Option<AzureSection>,
pub platform: Option<PlatformSection>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ReviewSection {
pub model: Option<String>,
pub fallback_model: Option<String>,
pub backend: Option<String>,
pub max_lines_per_batch: Option<usize>,
pub system_prompt: Option<String>,
pub instructions: Option<String>,
pub extensions: Option<Vec<String>>,
pub parallel: Option<String>,
pub max_concurrent: Option<usize>,
pub semantic: Option<bool>,
pub cache: Option<CacheSection>,
pub cost: Option<CostSection>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CacheSection {
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CostSection {
pub report: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AzureSection {
pub endpoint: Option<String>,
pub credential_source: Option<String>,
pub api_key_encrypted: Option<String>,
pub vault_url: Option<String>,
pub vault_secret_name: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PlatformSection {
#[serde(rename = "type")]
pub platform_type: Option<String>,
pub org_url: Option<String>,
pub project: Option<String>,
}
pub const DEFAULT_CONFIG_FILENAME: &str = "panoptico.toml";
impl ConfigFile {
pub fn template() -> &'static str {
r#"# AI Code Reviewer configuration file.
# See README.md for full documentation of each option.
[review]
# Model deployment name.
model = "claude-sonnet-4-5"
# Fallback model for rate-limit retries (optional).
# fallback_model = "claude-haiku-4-5"
# Backend: "azure", "anthropic", "aws-bedrock", or "claude-code".
backend = "claude-code"
# Maximum lines per review batch.
max_lines_per_batch = 500
# Path to a text file with a custom system prompt (optional).
# Overrides the built-in default prompt when set.
# system_prompt = "system-prompt.txt"
# Path to custom review instructions file (optional).
# Appended to the review request as additional context.
# instructions = "review-instructions.md"
# File extension glob patterns to include in the review.
# Leave empty to include all changed files.
extensions = [
"*.c", "*.cpp", "*.h", "*.hpp",
"*.rs",
"*.py",
"*.js", "*.jsx", "*.ts", "*.tsx",
"*.cs", "*.java", "*.kt", "*.go", "*.swift", "*.dart",
"*.rb", "*.php",
"*.sh", "*.ps1",
"*.sql", "*.tf",
"*.yml", "*.yaml", "*.toml", "*.md",
]
# Batch parallelization mode: "none", "hybrid", or "full".
parallel = "none"
# Maximum concurrent API calls in parallel/hybrid modes.
max_concurrent = 4
[review.cache]
# Enable prompt caching for batches 2+ (Azure and Anthropic backends).
enabled = false
[review.cost]
# Print cost report (token usage and estimated cost) after review.
report = false
[azure]
# Azure AI Foundry endpoint URL (or set AZURE_AI_ENDPOINT env var).
# endpoint = "https://your-resource.services.ai.azure.com/anthropic/"
# Credential source: "env", "keyring", "encrypted", or "vault".
credential_source = "env"
# Base64-encoded encrypted API key (when credential_source = "encrypted").
# Generate with: panoptico config encrypt-key --password <pwd> --api-key <key>
# api_key_encrypted = ""
# Azure Key Vault settings (when credential_source = "vault").
# vault_url = "https://your-vault.vault.azure.net"
# vault_secret_name = "panoptico-api-key"
[platform]
# Target platform for posting review comments.
type = "azure-devops"
# Organization URL.
# org_url = "https://dev.azure.com/YourOrg"
# Project name.
# project = "YourProject"
"#
}
pub fn from_file(path: &Path) -> Result<Self, ReviewError> {
let content =
std::fs::read_to_string(path).map_err(|e| ReviewError::Config(e.to_string()))?;
Self::parse(&content)
}
pub fn parse(content: &str) -> Result<Self, ReviewError> {
toml::from_str(content).map_err(|e| ReviewError::Config(e.to_string()))
}
pub fn into_review_config(self) -> Result<ReviewConfig, ReviewError> {
let review = self.review.unwrap_or_default();
let azure = self.azure.unwrap_or_default();
let platform = self.platform.unwrap_or_default();
let cache = review.cache.unwrap_or_default();
let cost = review.cost.unwrap_or_default();
let max_lines_per_batch = review.max_lines_per_batch.unwrap_or(DEFAULT_MAX_LINES);
if max_lines_per_batch == 0 {
return Err(ReviewError::Config(
"max_lines_per_batch must be at least 1".to_string(),
));
}
let max_concurrent = review.max_concurrent.unwrap_or(DEFAULT_MAX_CONCURRENT);
if max_concurrent == 0 {
return Err(ReviewError::Config(
"max_concurrent must be at least 1".to_string(),
));
}
let credential_source =
parse_credential_source(azure.credential_source.as_deref().unwrap_or("env"))?;
Ok(ReviewConfig {
backend: parse_backend(review.backend.as_deref().unwrap_or("azure"))?,
model: review
.model
.unwrap_or_else(|| "claude-sonnet-4-5".to_string()),
fallback_model: review.fallback_model,
endpoint: azure.endpoint,
base_ref: "origin/main".to_string(),
target_ref: "HEAD".to_string(),
extensions: review.extensions.unwrap_or_default(),
max_lines_per_batch,
system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
system_prompt_path: review.system_prompt,
instructions_path: review.instructions,
json_output: false,
output_path: None,
cache_enabled: cache.enabled.unwrap_or(false),
cost_report: cost.report.unwrap_or(false),
platform_type: platform.platform_type,
org_url: platform.org_url,
project: platform.project,
parallel: parse_parallel(review.parallel.as_deref().unwrap_or("none"))?,
max_concurrent,
credential_source,
api_key_encrypted: azure.api_key_encrypted,
vault_url: azure.vault_url,
vault_secret_name: azure.vault_secret_name,
key_password: None,
semantic: review.semantic.unwrap_or(true),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendType {
AzureAiFoundry,
Anthropic,
AwsBedrock,
ClaudeCode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParallelMode {
Sequential,
Hybrid,
Full,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialSourceType {
Env,
Keyring,
Encrypted,
Vault,
}
#[derive(Debug, Clone)]
pub struct ReviewConfig {
pub backend: BackendType,
pub model: String,
pub fallback_model: Option<String>,
pub endpoint: Option<String>,
pub base_ref: String,
pub target_ref: String,
pub extensions: Vec<String>,
pub max_lines_per_batch: usize,
pub system_prompt: String,
pub system_prompt_path: Option<String>,
pub instructions_path: Option<String>,
pub json_output: bool,
pub output_path: Option<String>,
pub cache_enabled: bool,
pub cost_report: bool,
pub platform_type: Option<String>,
pub org_url: Option<String>,
pub project: Option<String>,
pub parallel: ParallelMode,
pub max_concurrent: usize,
pub credential_source: CredentialSourceType,
pub api_key_encrypted: Option<String>,
pub vault_url: Option<String>,
pub vault_secret_name: Option<String>,
pub key_password: Option<String>,
pub semantic: bool,
}
impl Default for ReviewConfig {
fn default() -> Self {
Self {
backend: BackendType::AzureAiFoundry,
model: "claude-sonnet-4-5".to_string(),
fallback_model: None,
endpoint: None,
base_ref: "origin/main".to_string(),
target_ref: "HEAD".to_string(),
extensions: vec![],
max_lines_per_batch: DEFAULT_MAX_LINES,
system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
system_prompt_path: None,
instructions_path: None,
json_output: false,
output_path: None,
cache_enabled: false,
cost_report: false,
platform_type: None,
org_url: None,
project: None,
parallel: ParallelMode::Sequential,
max_concurrent: DEFAULT_MAX_CONCURRENT,
credential_source: CredentialSourceType::Env,
api_key_encrypted: None,
vault_url: None,
vault_secret_name: None,
key_password: None,
semantic: true,
}
}
}
impl ReviewConfig {
pub fn effective_parallel(&self) -> ParallelMode {
if self.backend == BackendType::ClaudeCode && self.parallel == ParallelMode::Hybrid {
ParallelMode::Full
} else {
self.parallel
}
}
}
pub fn parse_backend(value: &str) -> Result<BackendType, ReviewError> {
match value {
"azure" => Ok(BackendType::AzureAiFoundry),
"anthropic" => Ok(BackendType::Anthropic),
"aws-bedrock" => Ok(BackendType::AwsBedrock),
"claude-code" => Ok(BackendType::ClaudeCode),
other => Err(ReviewError::Config(format!(
"Unknown backend '{}'. Valid options: azure, anthropic, aws-bedrock, claude-code",
other
))),
}
}
pub fn parse_parallel(value: &str) -> Result<ParallelMode, ReviewError> {
match value {
"none" => Ok(ParallelMode::Sequential),
"hybrid" => Ok(ParallelMode::Hybrid),
"full" => Ok(ParallelMode::Full),
other => Err(ReviewError::Config(format!(
"Unknown parallel mode '{}'. Valid options: none, hybrid, full",
other
))),
}
}
pub fn parse_credential_source(value: &str) -> Result<CredentialSourceType, ReviewError> {
match value {
"env" => Ok(CredentialSourceType::Env),
"keyring" => Ok(CredentialSourceType::Keyring),
"encrypted" => Ok(CredentialSourceType::Encrypted),
"vault" => Ok(CredentialSourceType::Vault),
other => Err(ReviewError::Config(format!(
"Unknown credential_source '{}'. Valid options: env, keyring, encrypted, vault",
other
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_path(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
fn sample_toml() -> &'static str {
r#"
[review]
model = "claude-sonnet-4-5"
fallback_model = "claude-haiku-4-5"
backend = "azure"
max_lines_per_batch = 500
instructions = "review-instructions.md"
extensions = ["*.c", "*.cpp", "*.h", "*.py", "*.rs"]
parallel = "hybrid"
max_concurrent = 4
[review.cache]
enabled = true
[review.cost]
report = true
[azure]
endpoint = "https://your-resource.services.ai.azure.com/anthropic/"
[platform]
type = "azure-devops"
org_url = "https://dev.azure.com/your-organization"
project = "your-project"
"#
}
#[test]
fn parse_full_toml() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let review = file.review.unwrap();
assert_eq!(review.model.unwrap(), "claude-sonnet-4-5");
assert_eq!(review.fallback_model.unwrap(), "claude-haiku-4-5");
assert_eq!(review.backend.unwrap(), "azure");
assert_eq!(review.max_lines_per_batch.unwrap(), 500);
assert_eq!(review.instructions.unwrap(), "review-instructions.md");
assert_eq!(review.extensions.unwrap().len(), 5);
assert_eq!(review.max_concurrent.unwrap(), 4);
}
#[test]
fn parse_cache_section() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let cache = file.review.unwrap().cache.unwrap();
assert!(cache.enabled.unwrap());
}
#[test]
fn parse_cost_section() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let cost = file.review.unwrap().cost.unwrap();
assert!(cost.report.unwrap());
}
#[test]
fn parse_azure_section() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let azure = file.azure.unwrap();
assert!(azure.endpoint.unwrap().contains("your-resource"));
}
#[test]
fn parse_platform_section() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let platform = file.platform.unwrap();
assert_eq!(platform.platform_type.unwrap(), "azure-devops");
assert!(platform.org_url.unwrap().contains("your-organization"));
assert_eq!(platform.project.unwrap(), "your-project");
}
#[test]
fn parse_minimal_toml_only_model() {
let toml = r#"
[review]
model = "claude-sonnet-4-5"
"#;
let file = ConfigFile::parse(toml).unwrap();
let review = file.review.unwrap();
assert_eq!(review.model.unwrap(), "claude-sonnet-4-5");
assert!(review.fallback_model.is_none());
assert!(review.backend.is_none());
assert!(review.extensions.is_none());
assert!(review.cache.is_none());
assert!(review.cost.is_none());
assert!(file.azure.is_none());
assert!(file.platform.is_none());
}
#[test]
fn parse_empty_toml() {
let file = ConfigFile::parse("").unwrap();
assert!(file.review.is_none());
assert!(file.azure.is_none());
assert!(file.platform.is_none());
}
#[test]
fn parse_invalid_toml_returns_error() {
let result = ConfigFile::parse("{{not valid toml");
assert!(result.is_err(), "Invalid TOML should return an error");
}
#[test]
fn from_file_loads_sample_config() {
let path = fixture_path("sample_config.toml");
let file = ConfigFile::from_file(&path).unwrap();
let review = file.review.unwrap();
assert_eq!(review.model.unwrap(), "claude-sonnet-4-5");
}
#[test]
fn from_file_loads_minimal_config() {
let path = fixture_path("minimal_config.toml");
let file = ConfigFile::from_file(&path).unwrap();
let review = file.review.unwrap();
assert_eq!(review.model.unwrap(), "claude-sonnet-4-5");
assert!(file.azure.is_none());
}
#[test]
fn from_file_loads_dev_config_claude_code_backend() {
let path = fixture_path("dev_config.toml");
let file = ConfigFile::from_file(&path).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(
config.backend,
BackendType::ClaudeCode,
"dev_config.toml should select claude-code backend"
);
assert_eq!(config.max_lines_per_batch, 300);
assert_eq!(config.extensions.len(), 3);
assert!(!config.cache_enabled);
assert!(config.cost_report);
assert!(
config.endpoint.is_none(),
"claude-code backend needs no endpoint"
);
}
#[test]
fn from_file_nonexistent_returns_error() {
let path = fixture_path("does_not_exist.toml");
let result = ConfigFile::from_file(&path);
assert!(result.is_err(), "Missing file should return an error");
}
#[test]
fn into_review_config_maps_all_fields() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.model, "claude-sonnet-4-5");
assert_eq!(config.fallback_model.as_deref(), Some("claude-haiku-4-5"));
assert_eq!(config.backend, BackendType::AzureAiFoundry);
assert_eq!(config.max_lines_per_batch, 500);
assert_eq!(
config.instructions_path.as_deref(),
Some("review-instructions.md")
);
assert_eq!(config.extensions.len(), 5);
assert!(config.cache_enabled);
assert!(config.cost_report);
assert!(config.endpoint.unwrap().contains("your-resource"));
assert_eq!(config.platform_type.as_deref(), Some("azure-devops"));
assert!(config.org_url.unwrap().contains("your-organization"));
assert_eq!(config.project.as_deref(), Some("your-project"));
assert_eq!(config.parallel, ParallelMode::Hybrid);
assert_eq!(config.max_concurrent, 4);
}
#[test]
fn into_review_config_claude_code_backend() {
let toml = r#"
[review]
backend = "claude-code"
"#;
let file = ConfigFile::parse(toml).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.backend, BackendType::ClaudeCode);
}
#[test]
fn into_review_config_defaults_for_empty() {
let file = ConfigFile::parse("").unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.model, "claude-sonnet-4-5");
assert_eq!(config.backend, BackendType::AzureAiFoundry);
assert_eq!(config.max_lines_per_batch, DEFAULT_MAX_LINES);
assert!(!config.cache_enabled);
assert!(!config.cost_report);
assert!(config.endpoint.is_none());
assert!(config.system_prompt_path.is_none());
assert!(config.instructions_path.is_none());
assert!(config.extensions.is_empty());
}
#[test]
fn into_review_config_preserves_system_prompt_default() {
let file = ConfigFile::parse("").unwrap();
let config = file.into_review_config().unwrap();
assert!(
!config.system_prompt.is_empty(),
"System prompt should have the default value"
);
}
#[test]
fn into_review_config_system_prompt_path_none_by_default() {
let file = ConfigFile::parse("").unwrap();
let config = file.into_review_config().unwrap();
assert!(
config.system_prompt_path.is_none(),
"system_prompt_path should be None when not set in TOML"
);
}
#[test]
fn parse_system_prompt_path_from_toml() {
let toml = r#"
[review]
system_prompt = "my-prompt.txt"
"#;
let file = ConfigFile::parse(toml).unwrap();
let review = file.review.unwrap();
assert_eq!(review.system_prompt.unwrap(), "my-prompt.txt");
}
#[test]
fn into_review_config_maps_system_prompt_path() {
let toml = r#"
[review]
system_prompt = "custom-system-prompt.txt"
"#;
let file = ConfigFile::parse(toml).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(
config.system_prompt_path.as_deref(),
Some("custom-system-prompt.txt"),
"TOML system_prompt should map to system_prompt_path"
);
assert!(
!config.system_prompt.is_empty(),
"Default system_prompt text should still be set"
);
}
#[test]
fn into_review_config_json_output_defaults_false() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let config = file.into_review_config().unwrap();
assert!(
!config.json_output,
"TOML does not set json_output; it should default to false"
);
}
#[test]
fn into_review_config_output_path_defaults_none() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let config = file.into_review_config().unwrap();
assert!(
config.output_path.is_none(),
"TOML does not set output_path; it should default to None"
);
}
#[test]
fn into_review_config_base_ref_defaults_origin_main() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(
config.base_ref, "origin/main",
"base_ref is a CLI concern; TOML default should be origin/main"
);
}
#[test]
fn into_review_config_target_ref_defaults_head() {
let file = ConfigFile::parse(sample_toml()).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(
config.target_ref, "HEAD",
"target_ref is a CLI concern; TOML default should be HEAD"
);
}
#[test]
fn default_backend_is_azure() {
let config = ReviewConfig::default();
assert_eq!(config.backend, BackendType::AzureAiFoundry);
}
#[test]
fn default_model_is_sonnet() {
let config = ReviewConfig::default();
assert_eq!(config.model, "claude-sonnet-4-5");
}
#[test]
fn default_base_ref_is_origin_main() {
let config = ReviewConfig::default();
assert_eq!(config.base_ref, "origin/main");
}
#[test]
fn default_target_ref_is_head() {
let config = ReviewConfig::default();
assert_eq!(config.target_ref, "HEAD");
}
#[test]
fn default_max_lines_matches_constant() {
let config = ReviewConfig::default();
assert_eq!(config.max_lines_per_batch, DEFAULT_MAX_LINES);
}
#[test]
fn default_endpoint_is_none() {
let config = ReviewConfig::default();
assert!(config.endpoint.is_none());
}
#[test]
fn default_extensions_is_empty() {
let config = ReviewConfig::default();
assert!(config.extensions.is_empty());
}
#[test]
fn default_instructions_path_is_none() {
let config = ReviewConfig::default();
assert!(config.instructions_path.is_none());
}
#[test]
fn default_json_output_is_false() {
let config = ReviewConfig::default();
assert!(!config.json_output);
}
#[test]
fn default_output_path_is_none() {
let config = ReviewConfig::default();
assert!(config.output_path.is_none());
}
#[test]
fn default_cache_disabled() {
let config = ReviewConfig::default();
assert!(!config.cache_enabled);
}
#[test]
fn default_cost_report_disabled() {
let config = ReviewConfig::default();
assert!(!config.cost_report);
}
#[test]
fn default_fallback_model_is_none() {
let config = ReviewConfig::default();
assert!(config.fallback_model.is_none());
}
#[test]
fn default_platform_fields_are_none() {
let config = ReviewConfig::default();
assert!(config.platform_type.is_none());
assert!(config.org_url.is_none());
assert!(config.project.is_none());
}
#[test]
fn default_system_prompt_is_not_empty() {
let config = ReviewConfig::default();
assert!(!config.system_prompt.is_empty());
}
#[test]
fn default_system_prompt_path_is_none() {
let config = ReviewConfig::default();
assert!(
config.system_prompt_path.is_none(),
"system_prompt_path should default to None"
);
}
#[test]
fn backend_type_equality() {
assert_eq!(BackendType::AzureAiFoundry, BackendType::AzureAiFoundry);
assert_eq!(BackendType::Anthropic, BackendType::Anthropic);
assert_eq!(BackendType::AwsBedrock, BackendType::AwsBedrock);
assert_eq!(BackendType::ClaudeCode, BackendType::ClaudeCode);
assert_ne!(BackendType::AzureAiFoundry, BackendType::Anthropic);
assert_ne!(BackendType::AzureAiFoundry, BackendType::ClaudeCode);
assert_ne!(BackendType::Anthropic, BackendType::AwsBedrock);
}
#[test]
fn parse_backend_azure() {
assert_eq!(parse_backend("azure").unwrap(), BackendType::AzureAiFoundry);
}
#[test]
fn parse_backend_anthropic() {
assert_eq!(parse_backend("anthropic").unwrap(), BackendType::Anthropic);
}
#[test]
fn parse_backend_aws_bedrock() {
assert_eq!(
parse_backend("aws-bedrock").unwrap(),
BackendType::AwsBedrock
);
}
#[test]
fn parse_backend_claude_code() {
assert_eq!(
parse_backend("claude-code").unwrap(),
BackendType::ClaudeCode
);
}
#[test]
fn parse_backend_unknown_returns_error() {
let result = parse_backend("unknown");
assert!(result.is_err(), "Unknown backend should return error");
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("Unknown backend 'unknown'"));
}
#[test]
fn parallel_mode_equality() {
assert_eq!(ParallelMode::Sequential, ParallelMode::Sequential);
assert_eq!(ParallelMode::Hybrid, ParallelMode::Hybrid);
assert_eq!(ParallelMode::Full, ParallelMode::Full);
assert_ne!(ParallelMode::Sequential, ParallelMode::Hybrid);
assert_ne!(ParallelMode::Hybrid, ParallelMode::Full);
}
#[test]
fn parse_parallel_none() {
assert_eq!(parse_parallel("none").unwrap(), ParallelMode::Sequential);
}
#[test]
fn parse_parallel_hybrid() {
assert_eq!(parse_parallel("hybrid").unwrap(), ParallelMode::Hybrid);
}
#[test]
fn parse_parallel_full() {
assert_eq!(parse_parallel("full").unwrap(), ParallelMode::Full);
}
#[test]
fn parse_parallel_unknown_returns_error() {
let result = parse_parallel("unknown");
assert!(result.is_err(), "Unknown parallel mode should return error");
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("Unknown parallel mode 'unknown'"));
}
#[test]
fn default_parallel_is_none() {
let config = ReviewConfig::default();
assert_eq!(config.parallel, ParallelMode::Sequential);
}
#[test]
fn into_review_config_parallel_hybrid() {
let toml = r#"
[review]
parallel = "hybrid"
"#;
let file = ConfigFile::parse(toml).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.parallel, ParallelMode::Hybrid);
}
#[test]
fn into_review_config_parallel_full() {
let toml = r#"
[review]
parallel = "full"
"#;
let file = ConfigFile::parse(toml).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.parallel, ParallelMode::Full);
}
#[test]
fn into_review_config_parallel_defaults_none() {
let file = ConfigFile::parse("").unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.parallel, ParallelMode::Sequential);
}
#[test]
fn config_with_overrides() {
let config = ReviewConfig {
model: "claude-haiku-4-5".to_string(),
backend: BackendType::ClaudeCode,
json_output: true,
output_path: Some("report.json".to_string()),
..Default::default()
};
assert_eq!(config.model, "claude-haiku-4-5");
assert_eq!(config.backend, BackendType::ClaudeCode);
assert!(config.json_output);
assert_eq!(config.output_path.as_deref(), Some("report.json"));
assert_eq!(config.base_ref, "origin/main");
}
#[test]
fn default_max_concurrent() {
let config = ReviewConfig::default();
assert_eq!(config.max_concurrent, DEFAULT_MAX_CONCURRENT);
}
#[test]
fn into_review_config_max_concurrent_from_toml() {
let toml = r#"
[review]
max_concurrent = 8
"#;
let file = ConfigFile::parse(toml).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.max_concurrent, 8);
}
#[test]
fn into_review_config_max_concurrent_defaults() {
let file = ConfigFile::parse("").unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.max_concurrent, DEFAULT_MAX_CONCURRENT);
}
#[test]
fn effective_parallel_claude_code_hybrid_becomes_full() {
let config = ReviewConfig {
backend: BackendType::ClaudeCode,
parallel: ParallelMode::Hybrid,
..Default::default()
};
assert_eq!(config.effective_parallel(), ParallelMode::Full);
}
#[test]
fn effective_parallel_claude_code_none_stays_none() {
let config = ReviewConfig {
backend: BackendType::ClaudeCode,
parallel: ParallelMode::Sequential,
..Default::default()
};
assert_eq!(config.effective_parallel(), ParallelMode::Sequential);
}
#[test]
fn effective_parallel_claude_code_full_stays_full() {
let config = ReviewConfig {
backend: BackendType::ClaudeCode,
parallel: ParallelMode::Full,
..Default::default()
};
assert_eq!(config.effective_parallel(), ParallelMode::Full);
}
#[test]
fn effective_parallel_azure_hybrid_stays_hybrid() {
let config = ReviewConfig {
backend: BackendType::AzureAiFoundry,
parallel: ParallelMode::Hybrid,
..Default::default()
};
assert_eq!(config.effective_parallel(), ParallelMode::Hybrid);
}
#[test]
fn default_credential_source_is_env() {
let config = ReviewConfig::default();
assert_eq!(config.credential_source, CredentialSourceType::Env);
}
#[test]
fn default_credential_optional_fields_are_none() {
let config = ReviewConfig::default();
assert!(config.api_key_encrypted.is_none());
assert!(config.vault_url.is_none());
assert!(config.vault_secret_name.is_none());
}
#[test]
fn parse_azure_credential_source() {
let toml = r#"
[azure]
endpoint = "https://example.com/anthropic/"
credential_source = "encrypted"
api_key_encrypted = "c2FsdC4uLm5vbmNlLi4u"
"#;
let file = ConfigFile::parse(toml).unwrap();
let azure = file.azure.unwrap();
assert_eq!(azure.credential_source.unwrap(), "encrypted");
assert_eq!(azure.api_key_encrypted.unwrap(), "c2FsdC4uLm5vbmNlLi4u");
}
#[test]
fn parse_azure_vault_fields() {
let toml = r#"
[azure]
endpoint = "https://example.com/anthropic/"
credential_source = "vault"
vault_url = "https://myvault.vault.azure.net"
vault_secret_name = "panoptico-api-key"
"#;
let file = ConfigFile::parse(toml).unwrap();
let azure = file.azure.unwrap();
assert_eq!(azure.credential_source.unwrap(), "vault");
assert_eq!(azure.vault_url.unwrap(), "https://myvault.vault.azure.net");
assert_eq!(azure.vault_secret_name.unwrap(), "panoptico-api-key");
}
#[test]
fn parse_azure_no_credential_fields_defaults_none() {
let toml = r#"
[azure]
endpoint = "https://example.com/anthropic/"
"#;
let file = ConfigFile::parse(toml).unwrap();
let azure = file.azure.unwrap();
assert!(azure.credential_source.is_none());
assert!(azure.api_key_encrypted.is_none());
assert!(azure.vault_url.is_none());
assert!(azure.vault_secret_name.is_none());
}
#[test]
fn into_review_config_maps_credential_fields() {
let toml = r#"
[azure]
credential_source = "encrypted"
api_key_encrypted = "base64blob=="
vault_url = "https://vault.example.com"
vault_secret_name = "my-secret"
"#;
let file = ConfigFile::parse(toml).unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.credential_source, CredentialSourceType::Encrypted);
assert_eq!(config.api_key_encrypted.as_deref(), Some("base64blob=="));
assert_eq!(
config.vault_url.as_deref(),
Some("https://vault.example.com")
);
assert_eq!(config.vault_secret_name.as_deref(), Some("my-secret"));
}
#[test]
fn into_review_config_credential_defaults_to_env() {
let file = ConfigFile::parse("").unwrap();
let config = file.into_review_config().unwrap();
assert_eq!(config.credential_source, CredentialSourceType::Env);
assert!(config.api_key_encrypted.is_none());
assert!(config.vault_url.is_none());
assert!(config.vault_secret_name.is_none());
}
#[test]
fn template_is_valid_toml() {
let template = ConfigFile::template();
let file: ConfigFile = toml::from_str(template)
.expect("template() should produce valid TOML parseable as ConfigFile");
assert!(
file.review.is_some(),
"Template should contain a [review] section"
);
assert!(
file.azure.is_some(),
"Template should contain an [azure] section"
);
assert!(
file.platform.is_some(),
"Template should contain a [platform] section"
);
}
#[test]
fn template_defaults_are_sensible() {
let template = ConfigFile::template();
let config = ConfigFile::parse(template)
.unwrap()
.into_review_config()
.unwrap();
assert_eq!(config.model, "claude-sonnet-4-5");
assert_eq!(config.backend, BackendType::ClaudeCode);
assert_eq!(config.max_lines_per_batch, DEFAULT_MAX_LINES);
assert_eq!(config.max_concurrent, DEFAULT_MAX_CONCURRENT);
assert_eq!(config.parallel, ParallelMode::Sequential);
assert!(!config.cache_enabled);
assert!(!config.cost_report);
assert_eq!(config.credential_source, CredentialSourceType::Env);
assert!(
config.extensions.len() >= 14,
"Template should include at least 14 extension patterns, got {}",
config.extensions.len()
);
}
#[test]
fn into_review_config_rejects_zero_max_lines_per_batch() {
let toml = r#"
[review]
max_lines_per_batch = 0
"#;
let file = ConfigFile::parse(toml).unwrap();
let result = file.into_review_config();
assert!(result.is_err(), "max_lines_per_batch=0 should be rejected");
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("max_lines_per_batch must be at least 1"));
}
#[test]
fn into_review_config_rejects_zero_max_concurrent() {
let toml = r#"
[review]
max_concurrent = 0
"#;
let file = ConfigFile::parse(toml).unwrap();
let result = file.into_review_config();
assert!(result.is_err(), "max_concurrent=0 should be rejected");
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("max_concurrent must be at least 1"));
}
#[test]
fn parse_credential_source_env() {
assert_eq!(
parse_credential_source("env").unwrap(),
CredentialSourceType::Env
);
}
#[test]
fn parse_credential_source_keyring() {
assert_eq!(
parse_credential_source("keyring").unwrap(),
CredentialSourceType::Keyring
);
}
#[test]
fn parse_credential_source_encrypted() {
assert_eq!(
parse_credential_source("encrypted").unwrap(),
CredentialSourceType::Encrypted
);
}
#[test]
fn parse_credential_source_vault() {
assert_eq!(
parse_credential_source("vault").unwrap(),
CredentialSourceType::Vault
);
}
#[test]
fn parse_credential_source_unknown_returns_error() {
let result = parse_credential_source("unknown");
assert!(
result.is_err(),
"Unknown credential_source should be rejected"
);
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("Unknown credential_source 'unknown'"));
}
#[test]
fn into_review_config_rejects_unknown_credential_source() {
let toml = r#"
[azure]
credential_source = "magic"
"#;
let file = ConfigFile::parse(toml).unwrap();
let result = file.into_review_config();
assert!(
result.is_err(),
"Unknown credential_source should be rejected"
);
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("Unknown credential_source 'magic'"));
}
#[test]
fn semantic_defaults_to_true() {
let config = ReviewConfig::default();
assert!(config.semantic, "semantic should default to true");
}
#[test]
fn semantic_from_toml_true() {
let toml = r#"
[review]
semantic = true
"#;
let file = ConfigFile::parse(toml).unwrap();
let config = file.into_review_config().unwrap();
assert!(
config.semantic,
"semantic = true in TOML should be parsed correctly"
);
}
#[test]
fn semantic_from_toml_false_overrides_default() {
let toml = r#"
[review]
semantic = false
"#;
let file = ConfigFile::parse(toml).unwrap();
let config = file.into_review_config().unwrap();
assert!(
!config.semantic,
"semantic = false in TOML should override the default"
);
}
#[test]
fn semantic_omitted_from_toml_defaults_to_true() {
let file = ConfigFile::parse("").unwrap();
let config = file.into_review_config().unwrap();
assert!(
config.semantic,
"Omitting semantic from TOML should default to true"
);
}
}