use serde::Deserialize;
#[derive(Debug, Deserialize, Default, PartialEq)]
pub struct Config {
#[serde(default)]
pub github: GithubConfig,
#[serde(default)]
pub bitbucket: BitbucketConfig,
#[serde(default)]
pub display: DisplayConfig,
#[serde(default)]
pub jira: JiraConfig,
#[serde(default)]
pub profiles: Vec<ProfileConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GithubConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub watch_repos: Vec<String>,
#[serde(default)]
pub exclude_repos: Vec<String>,
}
impl Default for GithubConfig {
fn default() -> Self {
Self {
enabled: true,
watch_repos: vec![],
exclude_repos: vec![],
}
}
}
impl PartialEq for GithubConfig {
fn eq(&self, other: &Self) -> bool {
self.enabled == other.enabled
&& self.watch_repos == other.watch_repos
&& self.exclude_repos == other.exclude_repos
}
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct BitbucketConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub workspace: Option<String>,
#[serde(default)]
pub watch_repos: Vec<String>,
#[serde(default)]
pub token: Option<String>, #[serde(default)]
pub base_url: Option<String>, #[serde(default)]
pub username: Option<String>, }
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct JiraConfig {
#[serde(default)]
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct FilterGroup {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub org: Option<String>,
#[serde(default)]
pub repo: Option<String>,
#[serde(default)]
pub status: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct ProfileConfig {
pub name: String,
#[serde(default)]
pub filters: Vec<FilterGroup>,
}
#[derive(Debug, Deserialize)]
pub struct DisplayConfig {
#[serde(default = "default_date_format")]
pub date_format: String,
}
impl Default for DisplayConfig {
fn default() -> Self {
Self {
date_format: "relative".to_string(),
}
}
}
impl PartialEq for DisplayConfig {
fn eq(&self, other: &Self) -> bool {
self.date_format == other.date_format
}
}
#[allow(dead_code)]
fn default_true() -> bool {
true
}
#[allow(dead_code)]
fn default_date_format() -> String {
"relative".to_string()
}
pub(crate) fn load_config_from(path: std::path::PathBuf) -> anyhow::Result<Config> {
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!("No config at {:?}, using defaults", path);
return Ok(Config::default());
}
Err(e) => {
return Err(anyhow::anyhow!("Cannot read config at {:?}: {}", path, e));
}
};
let config: Config = toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("Config parse error in {:?}: {}", path, e))?;
tracing::debug!("Loaded config from {:?}", path);
Ok(config)
}
pub fn load_config() -> anyhow::Result<Config> {
let path = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!(
"Cannot determine config directory — set XDG_CONFIG_HOME or HOME"
))?
.join("prlens")
.join("config.toml");
load_config_from(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jira_config_parse() {
let cfg: Config = toml::from_str("[jira]\nbase_url = \"https://acme.atlassian.net\"\n")
.unwrap();
assert_eq!(cfg.jira.base_url, Some("https://acme.atlassian.net".to_string()));
}
#[test]
fn jira_config_default() {
assert_eq!(JiraConfig::default().base_url, None);
}
#[test]
fn profile_config_parse() {
let toml_str = "[[profiles]]\nname = \"work\"\n[[profiles.filters]]\norg = \"acme\"\n[[profiles]]\nname = \"oss\"\n";
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.profiles.len(), 2);
assert_eq!(cfg.profiles[0].name, "work");
assert_eq!(cfg.profiles[0].filters[0].org, Some("acme".to_string()));
assert_eq!(cfg.profiles[1].name, "oss");
assert_eq!(cfg.profiles[1].filters.len(), 0);
}
#[test]
fn config_default_when_missing() {
let result = load_config_from(
std::env::temp_dir().join("prlens-test-nonexistent-config.toml"),
);
assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
assert_eq!(result.unwrap(), Config::default());
}
#[test]
fn config_parse_partial() {
let cfg: Config = toml::from_str("[github]\nenabled = false\n").unwrap();
assert!(!cfg.github.enabled);
assert!(!cfg.bitbucket.enabled);
assert_eq!(cfg.display.date_format, "relative");
}
#[test]
fn config_parse_bitbucket_new_fields() {
let cfg: Config = toml::from_str(
"[bitbucket]\nenabled = true\ntoken = \"mytoken\"\nbase_url = \"https://bb.example.com\"\nusername = \"user@example.com\"\n"
).unwrap();
assert!(cfg.bitbucket.enabled);
assert_eq!(cfg.bitbucket.token.as_deref(), Some("mytoken"));
assert_eq!(cfg.bitbucket.base_url.as_deref(), Some("https://bb.example.com"));
assert_eq!(cfg.bitbucket.username.as_deref(), Some("user@example.com"));
}
#[test]
fn bitbucket_config_default_new_fields_are_none() {
let cfg = BitbucketConfig::default();
assert_eq!(cfg.token, None);
assert_eq!(cfg.base_url, None);
assert_eq!(cfg.username, None);
}
#[test]
fn bitbucket_config_clone() {
let cfg = BitbucketConfig {
enabled: true,
token: Some("tok".to_string()),
base_url: Some("https://example.com".to_string()),
username: Some("user@example.com".to_string()),
workspace: None,
watch_repos: vec![],
};
let cloned = cfg.clone();
assert_eq!(cfg, cloned);
}
#[test]
fn config_unknown_keys_ignored() {
let result = toml::from_str::<Config>("[github]\nunknown_field = \"hi\"\n");
match result {
Ok(_) => {
}
Err(_) => {
}
}
}
}