use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub default_backend: Option<String>,
#[serde(default)]
pub backends: HashMap<String, BackendConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "backend")]
#[serde(rename_all = "snake_case")]
pub enum BackendConfig {
Github(GithubConfig),
Jira(JiraConfig),
Linear(LinearConfig),
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct GithubConfig {
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub repo: Option<String>,
#[serde(default)]
pub gh_cli_user: Option<String>,
#[serde(default)]
pub gh_cli_host: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct JiraConfig {
#[serde(default)]
pub server: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub api_token: Option<String>,
#[serde(default)]
pub project_key: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct LinearConfig {
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub team_key: Option<String>,
#[serde(default)]
pub team_id: Option<String>,
}
impl GithubConfig {
pub fn with_env(mut self) -> Self {
self.token = self.token.or_else(|| std::env::var("GITHUB_TOKEN").ok());
self.owner = self.owner.or_else(|| std::env::var("GITHUB_OWNER").ok());
self.repo = self.repo.or_else(|| std::env::var("GITHUB_REPO").ok());
self
}
}
impl JiraConfig {
pub fn with_env(mut self) -> Self {
self.server = self.server.or_else(|| std::env::var("JIRA_SERVER").ok());
self.email = self.email.or_else(|| std::env::var("JIRA_EMAIL").ok());
self.api_token = self
.api_token
.or_else(|| std::env::var("JIRA_API_TOKEN").ok());
self.project_key = self
.project_key
.or_else(|| std::env::var("JIRA_PROJECT_KEY").ok());
self
}
}
impl LinearConfig {
pub fn with_env(mut self) -> Self {
self.api_key = self
.api_key
.or_else(|| std::env::var("LINEAR_API_KEY").ok());
self.team_key = self
.team_key
.or_else(|| std::env::var("LINEAR_TEAM_KEY").ok());
self.team_id = self
.team_id
.or_else(|| std::env::var("LINEAR_TEAM_ID").ok());
self
}
}
impl Config {
pub fn load() -> Result<Self> {
let mut cfg = Self::load_from_disk()?;
for v in cfg.backends.values_mut() {
match v {
BackendConfig::Github(g) => *g = std::mem::take(g).with_env(),
BackendConfig::Jira(j) => *j = std::mem::take(j).with_env(),
BackendConfig::Linear(l) => *l = std::mem::take(l).with_env(),
}
}
cfg.auto_register_from_env();
if cfg.default_backend.is_none() {
cfg.default_backend = std::env::var("TICKETS_BACKEND").ok();
}
Ok(cfg)
}
fn load_from_disk() -> Result<Self> {
let cwd_toml = PathBuf::from(".trusty-tickets/config.toml");
if cwd_toml.exists() {
let s = std::fs::read_to_string(&cwd_toml)
.with_context(|| format!("read {}", cwd_toml.display()))?;
return toml::from_str(&s).with_context(|| format!("parse {}", cwd_toml.display()));
}
let legacy = PathBuf::from(".mcp-ticketer/config.json");
if legacy.exists() {
let s = std::fs::read_to_string(&legacy)
.with_context(|| format!("read {}", legacy.display()))?;
return serde_json::from_str(&s)
.with_context(|| format!("parse legacy {}", legacy.display()));
}
if let Some(home) = dirs::home_dir() {
let home_toml = home.join(".trusty-tickets/config.toml");
if home_toml.exists() {
let s = std::fs::read_to_string(&home_toml)
.with_context(|| format!("read {}", home_toml.display()))?;
return toml::from_str(&s)
.with_context(|| format!("parse {}", home_toml.display()));
}
}
Ok(Self::default())
}
fn auto_register_from_env(&mut self) {
if !self.backends.contains_key("github")
&& std::env::var("GITHUB_TOKEN").is_ok()
&& std::env::var("GITHUB_OWNER").is_ok()
&& std::env::var("GITHUB_REPO").is_ok()
{
self.backends.insert(
"github".into(),
BackendConfig::Github(GithubConfig::default().with_env()),
);
}
if !self.backends.contains_key("jira")
&& std::env::var("JIRA_SERVER").is_ok()
&& std::env::var("JIRA_API_TOKEN").is_ok()
{
self.backends.insert(
"jira".into(),
BackendConfig::Jira(JiraConfig::default().with_env()),
);
}
if !self.backends.contains_key("linear") && std::env::var("LINEAR_API_KEY").is_ok() {
self.backends.insert(
"linear".into(),
BackendConfig::Linear(LinearConfig::default().with_env()),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_toml() {
let toml_src = r#"
default_backend = "github"
[backends.github]
backend = "github"
owner = "octocat"
repo = "hello"
"#;
let cfg: Config = toml::from_str(toml_src).expect("parse");
assert_eq!(cfg.default_backend.as_deref(), Some("github"));
match cfg.backends.get("github").unwrap() {
BackendConfig::Github(g) => assert_eq!(g.owner.as_deref(), Some("octocat")),
_ => panic!("wrong variant"),
}
}
#[test]
fn parse_linear_toml() {
let toml_src = r#"
[backends.linear]
backend = "linear"
api_key = "lin_api_..." # pragma: allowlist secret
team_key = "ENG"
"#;
let cfg: Config = toml::from_str(toml_src).expect("parse");
match cfg.backends.get("linear").unwrap() {
BackendConfig::Linear(l) => assert_eq!(l.team_key.as_deref(), Some("ENG")),
_ => panic!("wrong variant"),
}
}
}