#[cfg(test)]
mod tests;
use std::path::{Path, PathBuf};
use mentra::{BuiltinProvider, ModelSelector};
use serde::Deserialize;
use thiserror::Error;
use crate::{
context::{ContextConfig, ContextScope},
event::ContextFile,
expand::expand,
provider,
run::Effort,
};
pub const DEFAULT_WORKSPACE_CONFIG_FILE: &str = ".basis/config.json";
pub const DEFAULT_GLOBAL_CONFIG_FILE: &str = "config.json";
pub const CONFIG_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Setting<T> {
pub value: T,
pub path: PathBuf,
pub scope: ContextScope,
}
impl<T> Setting<T> {
fn new(value: T, path: &Path, scope: ContextScope) -> Self {
Self {
value,
path: path.to_path_buf(),
scope,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Config {
pub provider: Option<Setting<BuiltinProvider>>,
pub model: Option<Setting<String>>,
pub effort: Option<Setting<Effort>>,
pub base_url: Option<Setting<String>>,
pub files: Vec<ContextFile>,
}
impl Config {
pub fn discover(workspace: &Path, global_dir: Option<&Path>) -> Result<Self, ConfigError> {
Self::discover_with(workspace, global_dir, &|name| std::env::var(name).ok())
}
pub fn discover_default(workspace: &Path) -> Result<Self, ConfigError> {
Self::discover(workspace, ContextConfig::default().global_dir.as_deref())
}
pub(crate) fn discover_with(
workspace: &Path,
global_dir: Option<&Path>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<Self, ConfigError> {
let mut sources = Vec::new();
let workspace_file = workspace.join(DEFAULT_WORKSPACE_CONFIG_FILE);
if workspace_file.is_file() {
sources.push(read(workspace_file, ContextScope::Workspace, lookup)?);
}
if let Some(global) = global_dir {
let global_file = global.join(DEFAULT_GLOBAL_CONFIG_FILE);
if global_file.is_file()
&& !sources
.iter()
.any(|(path, _)| crate::paths::same_dir(path, &global_file))
{
sources.push(read(global_file, ContextScope::Global, lookup)?);
}
}
Ok(layer(sources))
}
pub fn is_empty(&self) -> bool {
self.provider.is_none()
&& self.model.is_none()
&& self.effort.is_none()
&& self.base_url.is_none()
}
pub fn model_selector(&self) -> Option<ModelSelector> {
self.model
.as_ref()
.map(|model| ModelSelector::Id(model.value.clone()))
}
}
#[cfg_attr(feature = "mcp", doc = "[`McpError`](crate::mcp::McpError)'s rule")]
#[cfg_attr(not(feature = "mcp"), doc = "`McpError`'s rule")]
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path} is not a valid basis config: {problem} at line {line}, column {column}")]
Parse {
path: PathBuf,
problem: &'static str,
line: usize,
column: usize,
},
#[error("{path} declares no `schema`; this basis understands {CONFIG_SCHEMA_VERSION}")]
NoSchema { path: PathBuf },
#[error(
"{path} declares config schema {schema}, but this basis understands {CONFIG_SCHEMA_VERSION}"
)]
UnsupportedSchema { path: PathBuf, schema: u32 },
#[error("{path}: `{key}` {reason}")]
Invalid {
path: PathBuf,
key: &'static str,
reason: String,
},
#[error(
"{path} sets `base_url`, which basis honors only from your own \
{DEFAULT_GLOBAL_CONFIG_FILE}: a file a repository ships must not be able to point the \
model's traffic — and the API key on it — at a host you did not choose"
)]
WorkspaceBaseUrl { path: PathBuf },
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigFile {
schema: Option<u32>,
provider: Option<String>,
model: Option<String>,
effort: Option<EffortName>,
base_url: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
enum EffortName {
Low,
Medium,
High,
XHigh,
Max,
}
impl From<EffortName> for Effort {
fn from(effort: EffortName) -> Self {
match effort {
EffortName::Low => Self::Low,
EffortName::Medium => Self::Medium,
EffortName::High => Self::High,
EffortName::XHigh => Self::XHigh,
EffortName::Max => Self::Max,
}
}
}
struct Read {
provider: Option<BuiltinProvider>,
model: Option<String>,
effort: Option<Effort>,
base_url: Option<String>,
scope: ContextScope,
}
fn read(
path: PathBuf,
scope: ContextScope,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<(PathBuf, Read), ConfigError> {
let text = std::fs::read_to_string(&path).map_err(|source| ConfigError::Read {
path: path.clone(),
source,
})?;
let parsed = parse(&path, &text, scope.clone(), lookup)?;
Ok((path, parsed))
}
fn parse(
path: &Path,
text: &str,
scope: ContextScope,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<Read, ConfigError> {
let file: ConfigFile = serde_json::from_str(text).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
problem: match source.classify() {
serde_json::error::Category::Syntax => "a syntax error",
serde_json::error::Category::Data => "an unknown key or a value of the wrong type",
serde_json::error::Category::Eof => "an unexpected end of input",
serde_json::error::Category::Io => "a read error",
},
line: source.line(),
column: source.column(),
})?;
match file.schema {
None => {
return Err(ConfigError::NoSchema {
path: path.to_path_buf(),
});
}
Some(schema) if schema != CONFIG_SCHEMA_VERSION => {
return Err(ConfigError::UnsupportedSchema {
path: path.to_path_buf(),
schema,
});
}
Some(_) => {}
}
if file.base_url.is_some() && scope == ContextScope::Workspace {
return Err(ConfigError::WorkspaceBaseUrl {
path: path.to_path_buf(),
});
}
let expanded = |key: &'static str, raw: &str| -> Result<String, ConfigError> {
expand(raw, lookup).map_err(|reason| ConfigError::Invalid {
path: path.to_path_buf(),
key,
reason,
})
};
let provider = file
.provider
.as_deref()
.map(|name| {
let name = expanded("provider", name)?;
provider::parse(&name).map_err(|error| ConfigError::Invalid {
path: path.to_path_buf(),
key: "provider",
reason: error.to_string(),
})
})
.transpose()?;
let model = file
.model
.as_deref()
.map(|model| expanded("model", model))
.transpose()?
.map(|model| model.trim().to_string())
.map(|model| {
if model.is_empty() {
Err(ConfigError::Invalid {
path: path.to_path_buf(),
key: "model",
reason: "is empty; remove the key to take the provider's newest".to_string(),
})
} else {
Ok(model)
}
})
.transpose()?;
let base_url = file
.base_url
.as_deref()
.map(|url| {
let url = expanded("base_url", url)?;
provider::normalize_base_url(&url).map_err(|error| ConfigError::Invalid {
path: path.to_path_buf(),
key: "base_url",
reason: error.to_string(),
})
})
.transpose()?;
Ok(Read {
provider,
model,
effort: file.effort.map(Effort::from),
base_url,
scope,
})
}
fn layer(sources: Vec<(PathBuf, Read)>) -> Config {
let mut config = Config::default();
for (path, read) in sources {
config.files.push(ContextFile {
path: path.clone(),
scope: read.scope.label(),
});
if config.provider.is_none()
&& let Some(provider) = read.provider
{
config.provider = Some(Setting::new(provider, &path, read.scope.clone()));
}
if config.model.is_none()
&& let Some(model) = read.model
{
config.model = Some(Setting::new(model, &path, read.scope.clone()));
}
if config.effort.is_none()
&& let Some(effort) = read.effort
{
config.effort = Some(Setting::new(effort, &path, read.scope.clone()));
}
if config.base_url.is_none()
&& let Some(base_url) = read.base_url
{
config.base_url = Some(Setting::new(base_url, &path, read.scope));
}
}
config
}