use crate::engine::Severity;
use crate::engine::bundling::types::BundlingConfig;
use crate::engine::rules::types::RulesConfig;
use crate::error::CoraError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub provider: ProviderConfig,
pub focus: Vec<String>,
pub rules: Vec<String>,
pub ignore: IgnoreConfig,
pub hook: HookConfig,
pub quality_gate: crate::engine::quality_gate::QualityGateConfig,
pub output: OutputConfig,
pub response_format: String,
pub review_system_prompt_override: Option<String>,
pub review_system_prompt_file: Option<String>,
pub scan_system_prompt_override: Option<String>,
pub scan_system_prompt_file: Option<String>,
pub temperature: f32,
pub max_tokens: u32,
pub max_tokens_param: String,
pub timeout: u64,
pub cache_ttl: u64,
pub static_analysis: StaticAnalysisConfig,
pub sanitize_comments: bool,
pub rules_config: RulesConfig,
pub context_chain: crate::engine::context::types::ContextConfig,
pub bundling: BundlingConfig,
pub debt: crate::engine::debt_tracker::DebtConfig,
pub profile: Option<crate::engine::profiles::Profile>,
#[serde(default, skip_serializing_if = "is_default")]
pub analysis: AnalysisConfig,
#[serde(default, skip_serializing_if = "is_default")]
pub brain: BrainConfig,
#[serde(default = "default_update_check", skip_serializing_if = "is_true")]
pub update_check: bool,
}
fn default_update_check() -> bool {
true
}
fn is_true(b: &bool) -> bool {
*b
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub provider: String,
pub model: String,
pub base_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IgnoreConfig {
pub files: Vec<String>,
pub rules: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
pub mode: String,
pub min_severity: String,
pub max_diff_size: usize,
pub on_violation: String, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
pub format: String,
pub color: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
provider: ProviderConfig {
provider: "openai".to_string(),
model: "gpt-4o-mini".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
},
focus: vec![
"security".into(),
"performance".into(),
"bugs".into(),
"best_practice".into(),
],
rules: vec![],
ignore: IgnoreConfig {
files: vec![
"node_modules/**".into(),
"dist/**".into(),
"target/**".into(),
".git/**".into(),
],
rules: vec![],
},
hook: HookConfig {
mode: "warn".to_string(),
min_severity: "major".to_string(),
max_diff_size: 5 * 1024 * 1024,
on_violation: "warn".to_string(),
},
quality_gate: crate::engine::quality_gate::QualityGateConfig::default(),
output: OutputConfig {
format: "pretty".to_string(),
color: true,
},
response_format: "none".to_string(),
review_system_prompt_override: None,
review_system_prompt_file: None,
sanitize_comments: false,
scan_system_prompt_override: None,
scan_system_prompt_file: None,
temperature: 0.0,
max_tokens: 8192, max_tokens_param: "auto".to_string(),
timeout: 600,
cache_ttl: 1440, static_analysis: StaticAnalysisConfig::default(),
rules_config: RulesConfig::default(),
context_chain: crate::engine::context::types::ContextConfig::default(),
bundling: BundlingConfig::default(),
debt: crate::engine::debt_tracker::DebtConfig::default(),
profile: None,
analysis: AnalysisConfig::default(),
brain: BrainConfig::default(),
update_check: true,
}
}
}
pub fn check_base_url(url: &str) -> std::result::Result<(), String> {
let url = url.trim();
if url.is_empty() {
return Ok(());
}
if url.starts_with("https://") {
return Ok(());
}
if let Some(rest) = url.strip_prefix("http://") {
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let hostport = authority.rsplit('@').next().unwrap_or("");
let host = if let Some(v6) = hostport.strip_prefix('[') {
v6.split(']').next().unwrap_or("")
} else {
hostport.split(':').next().unwrap_or("")
};
if matches!(
host.to_ascii_lowercase().as_str(),
"localhost" | "127.0.0.1" | "::1"
) && !authority.contains('@')
{
return Ok(());
}
return Err(format!(
"plain http:// is only allowed for loopback hosts (localhost, 127.0.0.1, [::1]); use https:// (got: {url})"
));
}
Err(format!(
"must be an https:// URL (http:// allowed for loopback only), got: {url}"
))
}
impl HookConfig {
pub fn min_severity_level(&self) -> Severity {
Severity::from_str_lossy(&self.min_severity)
}
}
impl Config {
pub fn validate(&self) -> std::result::Result<(), CoraError> {
let mut errs: Vec<String> = Vec::new();
if self.provider.provider.trim().is_empty() {
errs.push("provider.provider must not be empty".into());
}
if let Err(e) = check_base_url(&self.provider.base_url) {
errs.push(format!("provider.base_url: {e}"));
}
if !(0.0..=2.0).contains(&self.temperature) {
errs.push(format!(
"llm.temperature must be between 0.0 and 2.0, got: {}",
self.temperature
));
}
if self.max_tokens == 0 {
errs.push("llm.max_tokens must be at least 1".into());
}
if self.timeout == 0 {
errs.push("llm.timeout must be at least 1 second".into());
}
const VALID_TOKEN_PARAMS: &[&str] = &[
"auto",
"max_tokens",
"max_output_tokens",
"max_completion_tokens",
];
if !VALID_TOKEN_PARAMS.contains(&self.max_tokens_param.as_str()) {
errs.push(format!(
"llm.max_tokens_param must be one of {:?}, got: {}",
VALID_TOKEN_PARAMS, self.max_tokens_param
));
}
const VALID_RESPONSE_FORMATS: &[&str] = &["none", "json_object"];
if !VALID_RESPONSE_FORMATS.contains(&self.response_format.as_str()) {
errs.push(format!(
"review.response_format must be one of {:?}, got: {}",
VALID_RESPONSE_FORMATS, self.response_format
));
}
const VALID_OUTPUT_FORMATS: &[&str] = &["pretty", "json", "compact", "sarif"];
if !VALID_OUTPUT_FORMATS.contains(&self.output.format.as_str()) {
errs.push(format!(
"output.format must be one of {:?}, got: {}",
VALID_OUTPUT_FORMATS, self.output.format
));
}
const VALID_HOOK_MODES: &[&str] = &["warn", "block"];
if !VALID_HOOK_MODES.contains(&self.hook.mode.as_str()) {
errs.push(format!(
"hook.mode must be one of {:?}, got: {}",
VALID_HOOK_MODES, self.hook.mode
));
}
const VALID_VIOLATION: &[&str] = &["warn", "disallow"];
if !VALID_VIOLATION.contains(&self.hook.on_violation.as_str()) {
errs.push(format!(
"hook.on_violation must be one of {:?}, got: {}",
VALID_VIOLATION, self.hook.on_violation
));
}
const VALID_SEVERITIES: &[&str] = &["critical", "major", "minor", "info"];
if !VALID_SEVERITIES.contains(&self.hook.min_severity.to_lowercase().as_str()) {
errs.push(format!(
"hook.min_severity must be one of {:?}, got: {}",
VALID_SEVERITIES, self.hook.min_severity
));
}
if let Some(profile) = &self.profile {
if let Err(pe) = profile.validate() {
errs.push(pe);
}
}
if errs.is_empty() {
Ok(())
} else {
Err(CoraError::ConfigParse(format!(
"invalid configuration:\n - {}",
errs.join("\n - ")
)))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CoraFile {
#[serde(
default,
deserialize_with = "deserialize_provider_section",
skip_serializing_if = "Option::is_none"
)]
pub provider: Option<ProviderSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub focus: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ignore: Option<IgnoreSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hook: Option<HookSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_gate: Option<crate::engine::quality_gate::QualityGateConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<OutputSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub review: Option<ReviewSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scan: Option<ScanSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rules_engine: Option<RulesSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bundling: Option<BundlingSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debt: Option<crate::engine::debt_tracker::DebtConfig>,
#[serde(default)]
pub profile: Option<crate::engine::profiles::ProfileRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub analysis: Option<AnalysisConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub brain: Option<BrainSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ProviderSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
}
fn deserialize_provider_section<'de, D>(
deserializer: D,
) -> std::result::Result<Option<ProviderSection>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum ProviderField {
Section(ProviderSection),
Name(String),
}
match Option::<ProviderField>::deserialize(deserializer)? {
Some(ProviderField::Section(section)) => Ok(Some(section)),
Some(ProviderField::Name(provider)) => Ok(Some(ProviderSection {
provider: Some(provider),
..Default::default()
})),
None => Ok(None),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct IgnoreSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub files: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct HookSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_diff_size: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_violation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct OutputSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ReviewSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub static_analysis: Option<StaticAnalysisConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sanitize_comments: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_chain: Option<crate::engine::context::types::ContextConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StaticAnalysisConfig {
#[serde(default, skip_serializing_if = "is_default")]
pub auto_clippy: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub clippy_output_file: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct AnalysisConfig {
#[serde(default, skip_serializing_if = "is_default")]
pub entry_point_patterns: Vec<String>,
}
fn default_vector_store() -> String {
"usearch".to_string()
}
fn default_vector_bits() -> String {
"residual".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BrainConfig {
#[serde(default = "default_vector_store")]
pub vector_store: String,
#[serde(default = "default_vector_bits")]
pub vector_bits: String,
#[serde(default, skip_serializing_if = "is_default")]
pub embedding: BrainEmbeddingMode,
}
impl Default for BrainConfig {
fn default() -> Self {
Self {
vector_store: default_vector_store(),
vector_bits: default_vector_bits(),
embedding: BrainEmbeddingMode::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BrainSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub vector_store: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vector_bits: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedding: Option<BrainEmbeddingMode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BrainEmbeddingMode {
#[default]
Auto,
Hashing,
Pretrained,
}
impl std::fmt::Display for BrainEmbeddingMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Auto => write!(f, "auto"),
Self::Hashing => write!(f, "hashing"),
Self::Pretrained => write!(f, "pretrained"),
}
}
}
impl std::str::FromStr for BrainEmbeddingMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"hashing" => Ok(Self::Hashing),
"pretrained" => Ok(Self::Pretrained),
other => Err(format!(
"unknown brain.embedding value '{other}' — expected auto, hashing, or pretrained"
)),
}
}
}
fn is_default<T: Default + PartialEq>(val: &T) -> bool {
*val == T::default()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ScanSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt_file: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct LlmSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens_param: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<u64>,
}
fn default_rules_enabled() -> bool {
true
}
fn default_max_findings() -> usize {
5
}
fn deserialize_max_findings<'de, D>(d: D) -> std::result::Result<usize, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<usize>::deserialize(d)?.unwrap_or(0))
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct RulesSection {
#[serde(default = "default_rules_enabled", skip_serializing_if = "is_true")]
pub enabled: bool,
#[serde(
default = "default_max_findings",
deserialize_with = "deserialize_max_findings"
)]
pub max_findings: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub custom: Vec<crate::engine::rules::types::CustomRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub index_skip_files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct BundlingSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_chars_per_group: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_files_per_group: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strategy: Option<crate::engine::bundling::GroupingStrategy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coalesce_by_directory: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coalesce_by_language: Option<bool>,
}
impl CoraFile {
pub fn from_str(content: &str) -> std::result::Result<Self, CoraError> {
serde_yaml_ng::from_str(content).map_err(|e| CoraError::ConfigParse(e.to_string()))
}
#[allow(clippy::assigning_clones)]
pub fn merge_into(&self, config: &mut Config) -> Result<(), CoraError> {
if let Some(v) = &self.model {
config.provider.model.clone_from(v);
}
if let Some(v) = &self.base_url {
config.provider.base_url.clone_from(v);
}
if let Some(p) = &self.provider {
if let Some(v) = &p.provider {
config.provider.provider.clone_from(v);
if let Some(preset) = crate::config::providers::PRESETS
.iter()
.find(|pr| pr.name == v)
{
if p.base_url.is_none() && self.base_url.is_none() {
config.provider.base_url = preset.default_base_url.to_string();
}
if p.model.is_none() && self.model.is_none() {
config.provider.model = preset.default_model.to_string();
}
}
}
if let Some(v) = &p.model {
config.provider.model.clone_from(v);
}
if let Some(v) = &p.base_url {
config.provider.base_url.clone_from(v);
}
}
if let Some(v) = &self.focus {
config.focus.clone_from(v);
}
if let Some(v) = &self.rules {
config.rules.clone_from(v);
}
if let Some(ig) = &self.ignore {
if let Some(v) = &ig.files {
for f in v {
if !config.ignore.files.contains(f) {
config.ignore.files.push(f.clone());
}
}
}
if let Some(v) = &ig.rules {
config.ignore.rules.clone_from(v);
}
}
if let Some(h) = &self.hook {
if let Some(v) = &h.mode {
config.hook.mode.clone_from(v);
}
if let Some(v) = &h.min_severity {
config.hook.min_severity.clone_from(v);
}
if let Some(v) = h.max_diff_size {
config.hook.max_diff_size = v;
}
if let Some(v) = &h.on_violation {
config.hook.on_violation.clone_from(v);
}
}
if let Some(qg) = &self.quality_gate {
config.quality_gate = qg.clone();
}
if let Some(o) = &self.output {
if let Some(v) = &o.format {
config.output.format.clone_from(v);
}
if let Some(v) = o.color {
config.output.color = v;
}
}
if let Some(r) = &self.review {
if let Some(v) = &r.response_format {
config.response_format.clone_from(v);
}
if let Some(v) = &r.system_prompt {
config.review_system_prompt_override = Some(v.clone());
}
if let Some(v) = &r.system_prompt_file {
config.review_system_prompt_file = Some(v.clone());
}
if let Some(sa) = &r.static_analysis {
config.static_analysis.clone_from(sa);
}
if let Some(v) = r.sanitize_comments {
config.sanitize_comments = v;
}
if let Some(cc) = &r.context_chain {
config.context_chain.clone_from(cc);
}
}
if let Some(s) = &self.scan {
if let Some(v) = &s.system_prompt {
config.scan_system_prompt_override = Some(v.clone());
}
if let Some(v) = &s.system_prompt_file {
config.scan_system_prompt_file = Some(v.clone());
}
}
if let Some(llm) = &self.llm {
if let Some(v) = llm.temperature {
config.temperature = v;
}
if let Some(v) = llm.max_tokens {
config.max_tokens = v;
}
if let Some(v) = llm.timeout {
config.timeout = v;
}
if let Some(v) = llm.cache_ttl {
config.cache_ttl = v;
}
if let Some(ref v) = llm.max_tokens_param {
config.max_tokens_param = v.clone();
}
}
if let Some(re) = &self.rules_engine {
config.rules_config.enabled = re.enabled;
config.rules_config.max_findings = re.max_findings;
if !re.custom.is_empty() {
config.rules_config.custom_rules = re.custom.clone();
}
if !re.index_skip_files.is_empty() {
for f in &re.index_skip_files {
if !config.rules_config.index_skip_files.contains(f) {
config.rules_config.index_skip_files.push(f.clone());
}
}
}
}
if let Some(b) = &self.bundling {
if let Some(v) = b.max_chars_per_group {
config.bundling.max_chars_per_group = v;
}
if let Some(v) = b.max_files_per_group {
config.bundling.max_files_per_group = v;
}
if let Some(v) = b.strategy {
config.bundling.strategy = v;
}
if let Some(v) = b.coalesce_by_directory {
config.bundling.coalesce_by_directory = v;
}
if let Some(v) = b.coalesce_by_language {
config.bundling.coalesce_by_language = v;
}
}
if let Some(debt) = &self.debt {
config.debt.enabled = debt.enabled;
if debt.history_dir.is_some() {
config.debt.history_dir.clone_from(&debt.history_dir);
}
config.debt.retention_days = debt.retention_days;
}
if let Some(profile_ref) = &self.profile {
match crate::engine::profiles::resolve_profile(profile_ref) {
Ok(profile) => {
config.profile = Some(profile);
}
Err(e) => {
return Err(CoraError::ConfigParse(format!(
"invalid profile in config: {e}"
)));
}
}
}
if let Some(analysis) = &self.analysis {
if !analysis.entry_point_patterns.is_empty() {
config
.analysis
.entry_point_patterns
.clone_from(&analysis.entry_point_patterns);
}
}
if let Some(brain) = &self.brain {
if let Some(v) = &brain.vector_store {
config.brain.vector_store.clone_from(v);
}
if let Some(v) = &brain.vector_bits {
config.brain.vector_bits.clone_from(v);
}
if let Some(v) = &brain.embedding {
config.brain.embedding = v.clone();
}
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
#[test]
fn config_default_provider() {
let cfg = Config::default();
assert_eq!(cfg.provider.provider, "openai");
assert_eq!(cfg.provider.model, "gpt-4o-mini");
assert_eq!(cfg.provider.base_url, "https://api.openai.com/v1");
}
#[test]
fn config_default_focus() {
let cfg = Config::default();
assert_eq!(
cfg.focus,
vec!["security", "performance", "bugs", "best_practice"]
);
}
#[test]
fn config_default_rules_empty() {
let cfg = Config::default();
assert!(cfg.rules.is_empty());
}
#[test]
fn config_default_ignore_files() {
let cfg = Config::default();
assert!(cfg.ignore.files.contains(&"node_modules/**".to_string()));
assert!(cfg.ignore.files.contains(&"dist/**".to_string()));
assert!(cfg.ignore.files.contains(&"target/**".to_string()));
assert!(cfg.ignore.files.contains(&".git/**".to_string()));
}
#[test]
fn config_default_hook() {
let cfg = Config::default();
assert_eq!(cfg.hook.mode, "warn");
assert_eq!(cfg.hook.min_severity, "major");
assert_eq!(cfg.hook.max_diff_size, 5 * 1024 * 1024);
assert_eq!(cfg.hook.on_violation, "warn");
}
#[test]
fn config_default_output() {
let cfg = Config::default();
assert_eq!(cfg.output.format, "pretty");
assert!(cfg.output.color);
}
#[test]
fn merge_empty_cora_file_leaves_defaults() {
let mut cfg = Config::default();
let cora = CoraFile::default();
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.provider.provider, "openai");
assert_eq!(cfg.provider.model, "gpt-4o-mini");
assert_eq!(cfg.output.format, "pretty");
}
#[test]
fn brain_vector_bits_defaults_to_residual() {
let cfg = Config::default();
assert_eq!(cfg.brain.vector_bits, "residual");
assert_eq!(cfg.brain.vector_store, "usearch");
}
#[test]
fn merge_brain_vector_store_and_bits() {
let mut cfg = Config::default();
let cora = CoraFile::from_str(
r"
brain:
vector_store: vecq
vector_bits: '6'
embedding: hashing
",
)
.unwrap();
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.brain.vector_store, "vecq");
assert_eq!(cfg.brain.vector_bits, "6");
}
#[test]
fn merge_brain_vector_bits_absent_keeps_default() {
let mut cfg = Config::default();
let cora = CoraFile::from_str(
r"
brain:
vector_store: vecq
",
)
.unwrap();
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.brain.vector_bits, "residual");
}
#[test]
fn merge_provider_overrides() {
let mut cfg = Config::default();
let cora = CoraFile {
provider: Some(ProviderSection {
provider: Some("anthropic".to_string()),
model: Some("claude-3-haiku".to_string()),
base_url: Some("https://api.anthropic.com/v1".to_string()),
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.provider.provider, "anthropic");
assert_eq!(cfg.provider.model, "claude-3-haiku");
assert_eq!(cfg.provider.base_url, "https://api.anthropic.com/v1");
}
#[test]
fn merge_top_level_provider_shortcuts() {
let mut cfg = Config::default();
let cora = CoraFile::from_str(
r"
provider: openai
model: glm-5.1
base_url: https://api.z.ai/api/coding/paas/v4
",
)
.unwrap();
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.provider.provider, "openai");
assert_eq!(cfg.provider.model, "glm-5.1");
assert_eq!(cfg.provider.base_url, "https://api.z.ai/api/coding/paas/v4");
}
#[test]
fn merge_partial_provider() {
let mut cfg = Config::default();
let cora = CoraFile {
provider: Some(ProviderSection {
provider: Some("ollama".to_string()),
model: None,
base_url: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.provider.provider, "ollama");
assert_eq!(cfg.provider.model, "llama3.1"); assert_eq!(cfg.provider.base_url, "http://localhost:11434/v1"); }
#[test]
fn merge_shortcut_provider_resolves_preset() {
let mut cfg = Config::default();
let cora = CoraFile::from_str(
r"
provider: zai
",
)
.unwrap();
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.provider.provider, "zai");
assert_eq!(cfg.provider.model, "glm-5.1"); assert_eq!(cfg.provider.base_url, "https://api.z.ai/api/coding/paas/v4"); }
#[test]
fn merge_focus() {
let mut cfg = Config::default();
let cora = CoraFile {
focus: Some(vec!["security".to_string(), "bugs".to_string()]),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.focus, vec!["security", "bugs"]);
}
#[test]
fn merge_rules() {
let mut cfg = Config::default();
let cora = CoraFile {
rules: Some(vec!["no unwrap".to_string()]),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.rules, vec!["no unwrap"]);
}
#[test]
fn merge_ignore() {
let mut cfg = Config::default();
let cora = CoraFile {
ignore: Some(IgnoreSection {
files: Some(vec!["vendor/**".to_string()]),
rules: Some(vec!["skip-rule-1".to_string()]),
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert!(
cfg.ignore.files.contains(&"vendor/**".to_string()),
"user ignore file should be merged in"
);
assert!(
cfg.ignore.files.contains(&"node_modules/**".to_string()),
"default node_modules/** must be preserved"
);
assert!(
cfg.ignore.files.contains(&"target/**".to_string()),
"default target/** must be preserved"
);
assert!(
cfg.ignore.files.contains(&"dist/**".to_string()),
"default dist/** must be preserved"
);
assert!(
cfg.ignore.files.contains(&".git/**".to_string()),
"default .git/** must be preserved"
);
assert_eq!(cfg.ignore.rules, vec!["skip-rule-1"]);
}
#[test]
fn merge_ignore_files_dedup() {
let mut cfg = Config::default();
let cora = CoraFile {
ignore: Some(IgnoreSection {
files: Some(vec![
"node_modules/**".to_string(), "my-vendor/**".to_string(),
]),
rules: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
let nm_count = cfg
.ignore
.files
.iter()
.filter(|f| *f == "node_modules/**")
.count();
assert_eq!(nm_count, 1, "duplicate entry should be deduplicated");
}
#[test]
fn rules_engine_section_without_enabled_keeps_rules_on() {
let cora: CoraFile = serde_yaml_ng::from_str("rules_engine:\n max_findings: 0\n").unwrap();
let mut cfg = Config::default();
cora.merge_into(&mut cfg).unwrap();
assert!(cfg.rules_config.enabled);
assert_eq!(cfg.rules_config.max_findings, 0);
}
#[test]
fn rules_engine_enabled_false_still_disables_and_round_trips() {
let cora: CoraFile =
serde_yaml_ng::from_str("rules_engine:\n enabled: false\n max_findings: 7\n")
.unwrap();
let mut cfg = Config::default();
cora.merge_into(&mut cfg).unwrap();
assert!(!cfg.rules_config.enabled);
let out = serde_yaml_ng::to_string(&cora).unwrap();
let again: CoraFile = serde_yaml_ng::from_str(&out).unwrap();
assert!(
!again.rules_engine.unwrap().enabled,
"false must survive a round trip"
);
}
#[test]
fn merge_index_skip_files_preserves_defaults() {
let mut cfg = Config::default();
let cora = CoraFile {
rules_engine: Some(RulesSection {
enabled: true,
max_findings: 10,
custom: Vec::new(),
index_skip_files: vec!["my-generated/**".to_string()],
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert!(
cfg.rules_config
.index_skip_files
.contains(&"my-generated/**".to_string()),
"user skip pattern should be merged in"
);
assert!(
cfg.rules_config
.index_skip_files
.iter()
.any(|f| f == "*.config.ts"),
"default *.config.ts must be preserved"
);
assert!(
cfg.rules_config
.index_skip_files
.iter()
.any(|f| f == "*.config.js"),
"default *.config.js must be preserved"
);
}
#[test]
fn merge_hook() {
let mut cfg = Config::default();
let cora = CoraFile {
hook: Some(HookSection {
mode: Some("block".to_string()),
min_severity: Some("critical".to_string()),
max_diff_size: Some(1024),
on_violation: Some("disallow".to_string()),
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.hook.mode, "block");
assert_eq!(cfg.hook.min_severity, "critical");
assert_eq!(cfg.hook.max_diff_size, 1024);
assert_eq!(cfg.hook.on_violation, "disallow");
}
#[test]
fn merge_output() {
let mut cfg = Config::default();
let cora = CoraFile {
output: Some(OutputSection {
format: Some("json".to_string()),
color: Some(false),
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.output.format, "json");
assert!(!cfg.output.color);
}
#[test]
fn parse_cora_file_empty() {
let cora = CoraFile::from_str("").unwrap();
assert!(cora.provider.is_none());
assert!(cora.focus.is_none());
}
#[test]
fn parse_cora_file_full() {
let yaml = r"
provider:
provider: anthropic
model: claude-3-haiku
base_url: https://api.anthropic.com/v1
focus:
- security
- bugs
rules:
- no unwrap
ignore:
files:
- vendor/**
hook:
mode: block
min_severity: critical
output:
format: json
color: false
";
let cora = CoraFile::from_str(yaml).unwrap();
assert_eq!(
cora.provider.as_ref().unwrap().provider.as_deref(),
Some("anthropic")
);
assert_eq!(cora.focus.as_ref().unwrap().len(), 2);
assert_eq!(cora.rules.as_ref().unwrap().len(), 1);
assert_eq!(
cora.output.as_ref().unwrap().format.as_deref(),
Some("json")
);
assert_eq!(cora.output.as_ref().unwrap().color, Some(false));
}
#[test]
fn hook_min_severity_level() {
let cfg = HookConfig {
mode: "warn".to_string(),
min_severity: "critical".to_string(),
max_diff_size: 1024,
on_violation: "warn".to_string(),
};
assert_eq!(cfg.min_severity_level(), Severity::Critical);
}
#[test]
fn hook_min_severity_level_unknown() {
let cfg = HookConfig {
mode: "warn".to_string(),
min_severity: "whatever".to_string(),
max_diff_size: 1024,
on_violation: "warn".to_string(),
};
assert_eq!(cfg.min_severity_level(), Severity::Info);
}
#[test]
fn cora_file_yaml_roundtrip() {
let cora = CoraFile {
provider: Some(ProviderSection {
provider: Some("ollama".to_string()),
model: Some("llama3".to_string()),
base_url: Some("http://localhost:11434".to_string()),
}),
focus: Some(vec!["security".to_string()]),
..Default::default()
};
let yaml = serde_yaml_ng::to_string(&cora).unwrap();
let back: CoraFile = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(
back.provider.as_ref().unwrap().provider.as_deref(),
Some("ollama")
);
assert_eq!(back.focus.as_ref().unwrap().len(), 1);
}
#[test]
fn config_json_roundtrip() {
let cfg = Config::default();
let json = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back.provider.provider, cfg.provider.provider);
assert_eq!(back.output.format, cfg.output.format);
}
#[test]
fn config_default_response_format_none() {
let cfg = Config::default();
assert_eq!(cfg.response_format, "none");
}
#[test]
fn config_default_system_prompt_overrides_none() {
let cfg = Config::default();
assert!(cfg.review_system_prompt_override.is_none());
assert!(cfg.review_system_prompt_file.is_none());
assert!(cfg.scan_system_prompt_override.is_none());
assert!(cfg.scan_system_prompt_file.is_none());
}
#[test]
fn parse_review_section_with_response_format() {
let yaml = r"
review:
response_format: json_object
";
let cora = CoraFile::from_str(yaml).unwrap();
assert_eq!(
cora.review.as_ref().unwrap().response_format.as_deref(),
Some("json_object")
);
}
#[test]
fn parse_review_section_with_system_prompt() {
let yaml = r"
review:
system_prompt: |
You are a security-focused reviewer.
system_prompt_file: .cora/prompts/review.md
";
let cora = CoraFile::from_str(yaml).unwrap();
assert_eq!(
cora.review.as_ref().unwrap().system_prompt.as_deref(),
Some("You are a security-focused reviewer.\n")
);
assert_eq!(
cora.review.as_ref().unwrap().system_prompt_file.as_deref(),
Some(".cora/prompts/review.md")
);
}
#[test]
fn merge_review_response_format() {
let mut cfg = Config::default();
let cora = CoraFile {
review: Some(ReviewSection {
response_format: Some("json_object".to_string()),
system_prompt: None,
system_prompt_file: None,
static_analysis: None,
sanitize_comments: None,
context_chain: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.response_format, "json_object");
}
#[test]
fn merge_review_system_prompt() {
let mut cfg = Config::default();
let cora = CoraFile {
review: Some(ReviewSection {
response_format: None,
system_prompt: Some("Custom prompt here.".to_string()),
system_prompt_file: None,
static_analysis: None,
sanitize_comments: None,
context_chain: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(
cfg.review_system_prompt_override.as_deref(),
Some("Custom prompt here.")
);
}
#[test]
fn merge_review_system_prompt_file() {
let mut cfg = Config::default();
let cora = CoraFile {
review: Some(ReviewSection {
response_format: None,
system_prompt: None,
system_prompt_file: Some("prompts/review.md".to_string()),
static_analysis: None,
sanitize_comments: None,
context_chain: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(
cfg.review_system_prompt_file.as_deref(),
Some("prompts/review.md")
);
}
#[test]
fn parse_scan_section_with_system_prompt() {
let yaml = r"
scan:
system_prompt: |
You are a performance-focused scanner.
";
let cora = CoraFile::from_str(yaml).unwrap();
assert_eq!(
cora.scan.as_ref().unwrap().system_prompt.as_deref(),
Some("You are a performance-focused scanner.\n")
);
}
#[test]
fn merge_scan_system_prompt() {
let mut cfg = Config::default();
let cora = CoraFile {
scan: Some(ScanSection {
system_prompt: Some("Performance only.".to_string()),
system_prompt_file: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(
cfg.scan_system_prompt_override.as_deref(),
Some("Performance only.")
);
}
#[test]
fn parse_cora_file_with_review_and_scan() {
let yaml = r"
review:
response_format: json_object
system_prompt: |
Security only.
scan:
system_prompt: |
Performance only.
system_prompt_file: scan.md
";
let cora = CoraFile::from_str(yaml).unwrap();
let review = cora.review.unwrap();
assert_eq!(review.response_format.as_deref(), Some("json_object"));
assert_eq!(review.system_prompt.as_deref(), Some("Security only.\n"));
let scan = cora.scan.unwrap();
assert_eq!(scan.system_prompt.as_deref(), Some("Performance only.\n"));
assert_eq!(scan.system_prompt_file.as_deref(), Some("scan.md"));
}
#[test]
fn config_default_temperature_is_zero() {
let cfg = Config::default();
assert_eq!(cfg.temperature, 0.0);
}
#[test]
fn config_default_max_tokens() {
let cfg = Config::default();
assert_eq!(cfg.max_tokens, 8192);
}
#[test]
fn config_default_timeout() {
let cfg = Config::default();
assert_eq!(cfg.timeout, 600);
}
#[test]
fn config_default_cache_ttl() {
let cfg = Config::default();
assert_eq!(cfg.cache_ttl, 1440);
}
#[test]
fn parse_llm_section() {
let yaml = r"
llm:
temperature: 0.5
max_tokens: 8192
timeout: 60
cache_ttl: 720
";
let cora = CoraFile::from_str(yaml).unwrap();
let llm = cora.llm.unwrap();
assert_eq!(llm.temperature, Some(0.5));
assert_eq!(llm.max_tokens, Some(8192));
assert_eq!(llm.timeout, Some(60));
assert_eq!(llm.cache_ttl, Some(720));
}
#[test]
fn parse_llm_section_partial() {
let yaml = r"
llm:
temperature: 0.3
";
let cora = CoraFile::from_str(yaml).unwrap();
let llm = cora.llm.unwrap();
assert_eq!(llm.temperature, Some(0.3));
assert_eq!(llm.max_tokens, None);
assert_eq!(llm.timeout, None);
assert_eq!(llm.cache_ttl, None);
}
#[test]
fn merge_llm_temperature() {
let mut cfg = Config::default();
let cora = CoraFile {
llm: Some(LlmSection {
temperature: Some(0.7),
max_tokens: None,
max_tokens_param: None,
timeout: None,
cache_ttl: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.temperature, 0.7);
assert_eq!(cfg.max_tokens, 8192);
assert_eq!(cfg.timeout, 600);
assert_eq!(cfg.cache_ttl, 1440);
}
#[test]
fn merge_llm_max_tokens() {
let mut cfg = Config::default();
let cora = CoraFile {
llm: Some(LlmSection {
temperature: None,
max_tokens: Some(2048),
max_tokens_param: None,
timeout: None,
cache_ttl: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.max_tokens, 2048);
}
#[test]
fn merge_llm_timeout() {
let mut cfg = Config::default();
let cora = CoraFile {
llm: Some(LlmSection {
temperature: None,
max_tokens: None,
max_tokens_param: None,
timeout: Some(300),
cache_ttl: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.timeout, 300);
}
#[test]
fn merge_llm_all_fields() {
let mut cfg = Config::default();
let cora = CoraFile {
llm: Some(LlmSection {
temperature: Some(1.0),
max_tokens: Some(16384),
max_tokens_param: None,
timeout: Some(240),
cache_ttl: Some(2880),
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.temperature, 1.0);
assert_eq!(cfg.max_tokens, 16384);
assert_eq!(cfg.timeout, 240);
assert_eq!(cfg.cache_ttl, 2880);
}
#[test]
fn cora_file_malformed_yaml_returns_error() {
let yaml = r"
provider:
provider: openai
model: gpt-4
this is not valid yaml: [
";
let result = CoraFile::from_str(yaml);
assert!(result.is_err(), "malformed YAML should return an error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("config parse error"),
"error message should mention parse failure: {err}"
);
}
#[test]
fn cora_file_empty_yaml_is_ok() {
let cora = CoraFile::from_str("").unwrap();
assert!(cora.llm.is_none());
assert!(cora.provider.is_none());
}
#[test]
fn cora_file_yaml_roundtrip_with_llm() {
let cora = CoraFile {
llm: Some(LlmSection {
temperature: Some(0.5),
max_tokens: Some(8192),
max_tokens_param: None,
timeout: Some(60),
cache_ttl: None,
}),
..Default::default()
};
let yaml = serde_yaml_ng::to_string(&cora).unwrap();
let back: CoraFile = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(back.llm.as_ref().unwrap().temperature, Some(0.5));
assert_eq!(back.llm.as_ref().unwrap().max_tokens, Some(8192));
}
#[test]
fn config_default_bundling() {
let cfg = Config::default();
assert_eq!(cfg.bundling.max_chars_per_group, 60_000);
assert_eq!(cfg.bundling.max_files_per_group, 20);
assert_eq!(
cfg.bundling.strategy,
crate::engine::bundling::GroupingStrategy::Smart
);
assert!(cfg.bundling.coalesce_by_directory);
assert!(cfg.bundling.coalesce_by_language);
}
#[test]
fn parse_bundling_section_full() {
let yaml = r"
bundling:
max_chars_per_group: 30000
max_files_per_group: 10
strategy: flat
coalesce_by_directory: false
coalesce_by_language: false
";
let cora = CoraFile::from_str(yaml).unwrap();
let b = cora.bundling.unwrap();
assert_eq!(b.max_chars_per_group, Some(30_000));
assert_eq!(b.max_files_per_group, Some(10));
assert_eq!(
b.strategy,
Some(crate::engine::bundling::GroupingStrategy::Flat)
);
assert_eq!(b.coalesce_by_directory, Some(false));
assert_eq!(b.coalesce_by_language, Some(false));
}
#[test]
fn parse_bundling_section_partial() {
let yaml = r"
bundling:
max_chars_per_group: 40000
strategy: flat
";
let cora = CoraFile::from_str(yaml).unwrap();
let b = cora.bundling.unwrap();
assert_eq!(b.max_chars_per_group, Some(40_000));
assert_eq!(b.max_files_per_group, None);
assert_eq!(
b.strategy,
Some(crate::engine::bundling::GroupingStrategy::Flat)
);
assert_eq!(b.coalesce_by_directory, None);
assert_eq!(b.coalesce_by_language, None);
}
#[test]
fn merge_bundling_all_fields() {
let mut cfg = Config::default();
let cora = CoraFile {
bundling: Some(BundlingSection {
max_chars_per_group: Some(30_000),
max_files_per_group: Some(10),
strategy: Some(crate::engine::bundling::GroupingStrategy::Flat),
coalesce_by_directory: Some(false),
coalesce_by_language: Some(false),
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.bundling.max_chars_per_group, 30_000);
assert_eq!(cfg.bundling.max_files_per_group, 10);
assert_eq!(
cfg.bundling.strategy,
crate::engine::bundling::GroupingStrategy::Flat
);
assert!(!cfg.bundling.coalesce_by_directory);
assert!(!cfg.bundling.coalesce_by_language);
}
#[test]
fn merge_bundling_partial() {
let mut cfg = Config::default();
let cora = CoraFile {
bundling: Some(BundlingSection {
max_chars_per_group: Some(40_000),
max_files_per_group: None,
strategy: None,
coalesce_by_directory: None,
coalesce_by_language: Some(false),
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.bundling.max_chars_per_group, 40_000);
assert_eq!(cfg.bundling.max_files_per_group, 20); assert_eq!(
cfg.bundling.strategy,
crate::engine::bundling::GroupingStrategy::Smart
); assert!(cfg.bundling.coalesce_by_directory); assert!(!cfg.bundling.coalesce_by_language);
}
#[test]
fn merge_bundling_absent_leaves_defaults() {
let mut cfg = Config::default();
let cora = CoraFile {
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.bundling.max_chars_per_group, 60_000);
assert_eq!(cfg.bundling.max_files_per_group, 20);
}
#[test]
fn bundling_section_yaml_roundtrip() {
let section = BundlingSection {
max_chars_per_group: Some(50_000),
max_files_per_group: Some(15),
strategy: Some(crate::engine::bundling::GroupingStrategy::Smart),
coalesce_by_directory: Some(true),
coalesce_by_language: Some(false),
};
let yaml = serde_yaml_ng::to_string(§ion).unwrap();
let back: BundlingSection = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(back.max_chars_per_group, Some(50_000));
assert_eq!(back.max_files_per_group, Some(15));
assert_eq!(
back.strategy,
Some(crate::engine::bundling::GroupingStrategy::Smart)
);
}
#[test]
fn config_default_max_tokens_param() {
let cfg = Config::default();
assert_eq!(cfg.max_tokens_param, "auto");
}
#[test]
fn merge_llm_max_tokens_param_explicit() {
let mut cfg = Config::default();
let cora = CoraFile {
llm: Some(LlmSection {
max_tokens_param: Some("max_output_tokens".to_string()),
..Default::default()
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.max_tokens_param, "max_output_tokens");
}
#[test]
fn merge_llm_max_tokens_param_absent_leaves_default() {
let mut cfg = Config::default();
let cora = CoraFile {
llm: Some(LlmSection {
temperature: Some(0.5),
..Default::default()
}),
..Default::default()
};
cora.merge_into(&mut cfg).unwrap();
assert_eq!(cfg.max_tokens_param, "auto");
}
#[test]
fn llm_section_yaml_roundtrip_with_max_tokens_param() {
let section = LlmSection {
temperature: Some(0.7),
max_tokens: Some(8192),
max_tokens_param: Some("max_output_tokens".to_string()),
timeout: Some(300),
cache_ttl: Some(60),
};
let yaml = serde_yaml_ng::to_string(§ion).unwrap();
let back: LlmSection = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(back.max_tokens_param, Some("max_output_tokens".to_string()));
assert_eq!(back.max_tokens, Some(8192));
}
#[test]
fn validate_accepts_default_config() {
assert!(Config::default().validate().is_ok());
}
#[test]
fn validate_rejects_out_of_range_temperature() {
let cfg = Config {
temperature: 5.0,
..Default::default()
};
let err = cfg.validate().unwrap_err().to_string();
assert!(err.contains("temperature"), "err: {err}");
}
#[test]
fn validate_rejects_invalid_output_format() {
let mut cfg = Config::default();
cfg.output.format = "prety".to_string(); let err = cfg.validate().unwrap_err().to_string();
assert!(err.contains("output.format"), "err: {err}");
}
#[test]
fn validate_rejects_invalid_max_tokens_param() {
let cfg = Config {
max_tokens_param: "tokens".to_string(),
..Default::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn validate_rejects_invalid_base_url() {
let mut cfg = Config::default();
cfg.provider.base_url = "api.openai.com".to_string(); let err = cfg.validate().unwrap_err().to_string();
assert!(err.contains("base_url"), "err: {err}");
}
#[test]
fn base_url_requires_https_except_loopback() {
for ok in [
"",
"https://api.openai.com/v1",
"http://localhost:11434/v1",
"http://127.0.0.1:8080",
"http://[::1]:8080/v1",
] {
assert!(check_base_url(ok).is_ok(), "should accept {ok}");
}
for bad in [
"http://evil.example.com/v1",
"http://localhost@evil.example.com/v1",
"http://localhost.evil.example.com",
"ws://localhost:1",
"unix:/tmp/x.sock",
"api.openai.com",
] {
assert!(check_base_url(bad).is_err(), "should reject {bad}");
}
let mut cfg = Config::default();
cfg.provider.base_url = "http://evil.example.com/v1".to_string();
assert!(cfg.validate().is_err());
}
#[test]
fn validate_aggregates_multiple_errors() {
let cfg = Config {
temperature: 9.0,
timeout: 0,
..Default::default()
};
let err = cfg.validate().unwrap_err().to_string();
assert!(err.contains("temperature"), "err: {err}");
assert!(err.contains("timeout"), "err: {err}");
}
#[test]
fn deny_unknown_fields_rejects_top_level_typo() {
let yaml = "\nquailty_gate:\n enabled: true\n";
let result = CoraFile::from_str(yaml);
assert!(result.is_err(), "misspelled top-level key must be rejected");
}
#[test]
fn deny_unknown_fields_rejects_section_typo() {
let yaml = "\nllm:\n temprature: 0.5\n";
let result = CoraFile::from_str(yaml);
assert!(result.is_err(), "misspelled section key must be rejected");
}
#[test]
fn deny_unknown_fields_allows_valid_keys() {
let yaml = "\nllm:\n temperature: 0.5\n max_tokens: 8192\n";
assert!(CoraFile::from_str(yaml).is_ok());
}
}