use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub doctrine: DoctrineConfig,
#[serde(default)]
pub axiom: AxiomConfig,
#[serde(default)]
pub llm: LlmConfig,
#[serde(default)]
pub mcp: McpConfig,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DoctrineConfig {
#[serde(default = "default_doctrine_source")]
pub source: String,
#[serde(default = "default_doctrine_fallback")]
pub fallback_repo: String,
#[serde(default = "default_pin_commit")]
pub pin_commit: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AxiomConfig {
#[serde(default = "default_axiom_source")]
pub source: String,
#[serde(default = "default_algorithm_latest")]
pub algorithm_latest: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LlmConfig {
#[serde(default = "default_llm_provider")]
pub provider: String,
#[serde(default)]
pub ollama: OllamaConfig,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OllamaConfig {
#[serde(default = "default_ollama_base_url")]
pub base_url: String,
#[serde(default = "default_ollama_model")]
pub model: String,
#[serde(default = "default_ollama_temperature")]
pub temperature: f32,
#[serde(default = "default_ollama_num_ctx")]
pub num_ctx: u32,
}
fn default_ollama_base_url() -> String {
"http://localhost:11434".into()
}
fn default_ollama_model() -> String {
"qwen2.5-coder:14b".into()
}
const fn default_ollama_temperature() -> f32 {
0.1
}
const fn default_ollama_num_ctx() -> u32 {
8192
}
impl Default for OllamaConfig {
fn default() -> Self {
Self {
base_url: default_ollama_base_url(),
model: default_ollama_model(),
temperature: default_ollama_temperature(),
num_ctx: default_ollama_num_ctx(),
}
}
}
impl cordance_llm::OllamaSettings for OllamaConfig {
fn base_url(&self) -> &str {
&self.base_url
}
fn model(&self) -> &str {
&self.model
}
fn temperature(&self) -> f32 {
self.temperature
}
fn num_ctx(&self) -> u32 {
self.num_ctx
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct McpConfig {
#[serde(default)]
pub allowed_roots: Vec<String>,
}
fn default_doctrine_source() -> String {
"../engineering-doctrine".into()
}
fn default_doctrine_fallback() -> String {
"https://github.com/0ryant/engineering-doctrine".into()
}
fn default_pin_commit() -> String {
"auto".into()
}
fn default_axiom_source() -> String {
"../pai-axiom".into()
}
fn default_algorithm_latest() -> String {
"auto".into()
}
fn default_llm_provider() -> String {
"none".into()
}
impl Default for DoctrineConfig {
fn default() -> Self {
Self {
source: default_doctrine_source(),
fallback_repo: default_doctrine_fallback(),
pin_commit: default_pin_commit(),
}
}
}
impl Default for AxiomConfig {
fn default() -> Self {
Self {
source: default_axiom_source(),
algorithm_latest: default_algorithm_latest(),
}
}
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
provider: default_llm_provider(),
ollama: OllamaConfig::default(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("io error reading {path}: {source}")]
Io {
path: Utf8PathBuf,
#[source]
source: std::io::Error,
},
#[error("toml parse error in {path}: {source}")]
Parse {
path: Utf8PathBuf,
#[source]
source: Box<toml::de::Error>,
},
#[error("invalid URL in cordance.toml: {0}")]
InvalidUrl(String),
}
impl Config {
pub fn load_strict(target: &Utf8PathBuf) -> Result<Self, ConfigError> {
let path = target.join("cordance.toml");
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io {
path: path.clone(),
source,
})?;
let mut cfg: Self = toml::from_str(&content).map_err(|source| ConfigError::Parse {
path: path.clone(),
source: Box::new(source),
})?;
validate_llm_endpoints(&mut cfg)?;
Ok(cfg)
}
pub fn doctrine_root(&self, target: &Utf8PathBuf) -> Utf8PathBuf {
resolve_path(&self.doctrine.source, target)
}
pub fn axiom_root(&self, target: &Utf8PathBuf) -> Utf8PathBuf {
resolve_path(&self.axiom.source, target)
}
pub fn axiom_version(&self, target: &Utf8PathBuf) -> String {
if self.axiom.algorithm_latest != "auto" {
return self.axiom.algorithm_latest.clone();
}
let axiom = self.axiom_root(target);
let candidates = [
axiom.join("PAI/Algorithm/LATEST"),
axiom.join("axiom/Algorithm/LATEST"),
];
for candidate in &candidates {
if let Ok(content) = std::fs::read_to_string(candidate) {
let v = content.trim().to_string();
if !v.is_empty() {
return v;
}
}
}
"v3.1.1-axiom".into()
}
}
fn resolve_path(s: &str, base: &Utf8PathBuf) -> Utf8PathBuf {
let p = Utf8PathBuf::from(s);
if p.is_absolute() {
p
} else {
base.join(s)
}
}
fn allow_remote_llm_from_env() -> bool {
std::env::var("CORDANCE_ALLOW_REMOTE_LLM").as_deref() == Ok("1")
}
fn validate_llm_endpoints(cfg: &mut Config) -> Result<(), ConfigError> {
validate_llm_endpoints_with(cfg, allow_remote_llm_from_env())
}
fn validate_llm_endpoints_with(
cfg: &mut Config,
allow_remote: bool,
) -> Result<(), ConfigError> {
if allow_remote {
return Ok(());
}
let url_str = &cfg.llm.ollama.base_url;
let parsed = url::Url::parse(url_str)
.map_err(|e| ConfigError::InvalidUrl(format!("[llm.ollama].base_url: {e}")))?;
match parsed.host_str() {
Some("localhost" | "127.0.0.1" | "::1" | "[::1]") => Ok(()),
other => {
tracing::warn!(
host = ?other,
"Refusing non-loopback Ollama base_url from cordance.toml; \
set CORDANCE_ALLOW_REMOTE_LLM=1 to override"
);
cfg.llm.ollama.base_url = default_ollama_base_url();
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_cfg(content: &str) -> (tempfile::TempDir, Utf8PathBuf) {
let dir = tempfile::tempdir().expect("create tempdir");
let path = dir.path().join("cordance.toml");
std::fs::write(&path, content).expect("write cordance.toml");
let utf8 = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.expect("tempdir is utf8");
(dir, utf8)
}
fn parse_cfg(content: &str) -> Config {
toml::from_str(content).expect("parse test cordance.toml")
}
#[test]
fn non_loopback_ollama_url_resets_to_default() {
let mut cfg = parse_cfg(
r#"
[llm.ollama]
base_url = "https://evil.com"
"#,
);
validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
assert_eq!(
cfg.llm.ollama.base_url,
"http://localhost:11434",
"non-loopback host must be reset to the default loopback URL"
);
}
#[test]
fn loopback_ollama_url_preserved() {
let mut cfg = parse_cfg(
r#"
[llm.ollama]
base_url = "http://127.0.0.1:11434"
"#,
);
validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
assert_eq!(cfg.llm.ollama.base_url, "http://127.0.0.1:11434");
}
#[test]
fn localhost_hostname_preserved() {
let mut cfg = parse_cfg(
r#"
[llm.ollama]
base_url = "http://localhost:11434"
"#,
);
validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
}
#[test]
fn ipv6_loopback_preserved() {
let mut cfg = parse_cfg(
r#"
[llm.ollama]
base_url = "http://[::1]:11434"
"#,
);
validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
assert_eq!(cfg.llm.ollama.base_url, "http://[::1]:11434");
}
#[test]
fn env_var_overrides_loopback_check() {
let mut cfg = parse_cfg(
r#"
[llm.ollama]
base_url = "https://evil.com"
"#,
);
validate_llm_endpoints_with(&mut cfg, true).expect("validate ok");
assert_eq!(
cfg.llm.ollama.base_url, "https://evil.com",
"env override must preserve the configured URL"
);
}
#[test]
fn malformed_url_returns_invalid_url_err() {
let mut cfg = parse_cfg(
r#"
[llm.ollama]
base_url = "not a url"
"#,
);
let err = validate_llm_endpoints_with(&mut cfg, false)
.expect_err("expect InvalidUrl error");
assert!(
matches!(err, ConfigError::InvalidUrl(_)),
"expected ConfigError::InvalidUrl, got {err:?}"
);
}
#[test]
fn toml_parse_error_returns_err() {
let (_dir, target) = write_cfg("not = = valid");
let err = Config::load_strict(&target).expect_err("expect Parse error");
assert!(
matches!(err, ConfigError::Parse { .. }),
"expected ConfigError::Parse, got {err:?}"
);
}
#[test]
fn missing_config_returns_default() {
let dir = tempfile::tempdir().expect("tempdir");
let utf8 = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.expect("utf8 tempdir");
let cfg = Config::load_strict(&utf8).expect("default on missing file");
assert_eq!(cfg.llm.provider, "none");
assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
}
#[test]
fn load_strict_unwrap_or_default_recovers_from_parse_error() {
let (_dir, target) = write_cfg("not = = valid");
let cfg = Config::load_strict(&target).unwrap_or_default();
assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
}
#[test]
fn load_strict_resets_remote_url_unless_env_set() {
if allow_remote_llm_from_env() {
return;
}
let (_dir, target) = write_cfg(
r#"
[llm.ollama]
base_url = "https://evil.com"
"#,
);
let cfg = Config::load_strict(&target).expect("load_strict ok");
assert_eq!(
cfg.llm.ollama.base_url, "http://localhost:11434",
"remote URL must be reset to loopback default"
);
}
}