use std::path::Path;
use serde::Deserialize;
use crate::error::Result;
use crate::providers::Provider;
#[derive(Debug, Deserialize, Default)]
pub struct Config {
pub system_extra: Option<String>,
pub context: Option<String>,
pub defaults: Option<Defaults>,
}
#[derive(Debug, Deserialize, Default)]
pub struct Defaults {
pub model: Option<String>,
pub max_tokens: Option<u32>,
pub repo: Option<String>,
pub provider: Option<Provider>,
pub base_url: Option<String>,
pub emoji: Option<bool>,
pub verify_links: Option<bool>,
pub match_style: Option<bool>,
}
const TEMPLATE: &str = r#"# Extra instructions appended to the system prompt.
# Use this to customize tone, style, or project-specific conventions.
#system_extra = ""
# Extra context included in every user prompt.
# Useful for project descriptions or recurring context.
#context = ""
[defaults]
#model = "claude-opus-4-7"
#max_tokens = 4096
#repo = "owner/repo"
#provider = "anthropic"
#base_url = ""
#emoji = true
#verify_links = true
#match_style = true
"#;
impl Config {
pub fn load(repo_root: &Path) -> Result<Option<Config>> {
let path = repo_root.join("communique.toml");
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Option<Config>> {
if !path.exists() {
return Ok(None);
}
let contents = xx::file::read_to_string(path)?;
let config: Config = toml::from_str(&contents).map_err(|e| {
let span = e.span().map(|s| s.into()).unwrap_or((0, 0).into());
crate::error::Error::Toml {
message: e.message().to_string(),
src: miette::NamedSource::new(path.display().to_string(), contents.clone()),
span,
}
})?;
config.validate()?;
Ok(Some(config))
}
fn validate(&self) -> Result<()> {
if let Some(defaults) = &self.defaults {
if let Some(max_tokens) = defaults.max_tokens
&& max_tokens == 0
{
return Err(crate::error::Error::Config(
"max_tokens must be greater than 0".into(),
));
}
if let Some(repo) = &defaults.repo
&& (!repo.contains('/') || repo.starts_with('/') || repo.ends_with('/'))
{
return Err(crate::error::Error::Config(format!(
"invalid repo '{repo}' (expected 'owner/repo' format)"
)));
}
}
Ok(())
}
pub fn template() -> &'static str {
TEMPLATE
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_missing_file() {
let dir = tempfile::tempdir().unwrap();
let result = Config::load(dir.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_load_valid_config() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("communique.toml"),
r#"
system_extra = "Be concise"
context = "A CLI tool"
[defaults]
model = "gpt-4"
emoji = false
"#,
)
.unwrap();
let config = Config::load(dir.path()).unwrap().unwrap();
assert_eq!(config.system_extra.as_deref(), Some("Be concise"));
assert_eq!(config.context.as_deref(), Some("A CLI tool"));
let defaults = config.defaults.unwrap();
assert_eq!(defaults.model.as_deref(), Some("gpt-4"));
assert_eq!(defaults.emoji, Some(false));
}
#[test]
fn test_load_invalid_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("communique.toml"), "invalid {{{{").unwrap();
let err = Config::load(dir.path());
assert!(err.is_err());
}
#[test]
fn test_template_is_valid_toml() {
let config: Config = toml::from_str(Config::template()).unwrap();
assert!(config.system_extra.is_none());
}
#[test]
fn test_validate_max_tokens_zero() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("communique.toml"),
"[defaults]\nmax_tokens = 0\n",
)
.unwrap();
let err = Config::load(dir.path()).unwrap_err();
assert!(err.to_string().contains("max_tokens"));
}
#[test]
fn test_validate_invalid_provider() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("communique.toml"),
"[defaults]\nprovider = \"gemini\"\n",
)
.unwrap();
let err = Config::load(dir.path()).unwrap_err();
assert!(err.to_string().contains("gemini"));
}
#[test]
fn test_validate_invalid_repo() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("communique.toml"),
"[defaults]\nrepo = \"noslash\"\n",
)
.unwrap();
let err = Config::load(dir.path()).unwrap_err();
assert!(err.to_string().contains("noslash"));
}
#[test]
fn test_validate_valid_defaults() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("communique.toml"),
"[defaults]\nmax_tokens = 4096\nprovider = \"openai\"\nrepo = \"jdx/communique\"\n",
)
.unwrap();
let config = Config::load(dir.path()).unwrap().unwrap();
let defaults = config.defaults.unwrap();
assert_eq!(defaults.max_tokens, Some(4096));
assert_eq!(defaults.provider, Some(crate::providers::Provider::OpenAI));
assert_eq!(defaults.repo.as_deref(), Some("jdx/communique"));
}
}