use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{Error, Result};
pub const TOKEN_ENV: &str = "LINEAR_API_KEY";
pub const FILE_NAME: &str = "linear.toml";
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub linear: LinearConfig,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct LinearConfig {
pub team: Option<String>,
pub states: BTreeMap<String, String>,
pub push_on_move: bool,
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
let cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
cfg.validate()?;
Ok(cfg)
}
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) => Self::parse(&text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(Error::Config(format!("{}: {e}", path.display()))),
}
}
pub fn load_default() -> Result<Self> {
Self::load(&default_path())
}
fn validate(&self) -> Result<()> {
for key in self.linear.states.keys() {
if !cliban_core::schema::ISSUE_STATUSES.contains(&key.as_str()) {
return Err(Error::Config(format!(
"[linear.states] has key {key:?}, which is not a cliban status \
(expected one of: {})",
cliban_core::schema::ISSUE_STATUSES.join(", ")
)));
}
}
Ok(())
}
}
pub fn default_path() -> PathBuf {
cliban_core::paths::config_dir().join(FILE_NAME)
}
pub fn token() -> Result<String> {
token_from(std::env::var(TOKEN_ENV).ok())
}
pub fn token_from(raw: Option<String>) -> Result<String> {
raw.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.ok_or(Error::MissingToken(TOKEN_ENV))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_config_is_valid_and_all_defaults() {
let cfg = Config::parse("").unwrap();
assert_eq!(cfg, Config::default());
assert!(cfg.linear.team.is_none());
assert!(cfg.linear.states.is_empty());
}
#[test]
fn parses_team_and_state_overrides() {
let cfg = Config::parse(
r#"
[linear]
team = "ENG"
[linear.states]
in-review = "Code Review"
"#,
)
.unwrap();
assert_eq!(cfg.linear.team.as_deref(), Some("ENG"));
assert_eq!(
cfg.linear.states.get("in-review").map(String::as_str),
Some("Code Review")
);
}
#[test]
fn push_on_move_defaults_to_off() {
assert!(!Config::parse("").unwrap().linear.push_on_move);
assert!(
!Config::parse("[linear]\nteam = \"ENG\"\n")
.unwrap()
.linear
.push_on_move
);
}
#[test]
fn parses_push_on_move() {
let cfg = Config::parse("[linear]\npush_on_move = true\n").unwrap();
assert!(cfg.linear.push_on_move);
}
#[test]
fn rejects_a_state_key_that_is_not_a_cliban_status() {
let err = Config::parse(
r#"
[linear.states]
in_review = "Code Review"
"#,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("in_review"), "{msg}");
assert!(
msg.contains("in-review"),
"should list the valid set: {msg}"
);
}
#[test]
fn rejects_unknown_keys_rather_than_ignoring_them() {
assert!(Config::parse("[linear]\nteem = \"ENG\"\n").is_err());
}
#[test]
fn a_missing_file_is_defaults_not_an_error() {
let cfg = Config::load(Path::new("/nonexistent/cliban/linear.toml")).unwrap();
assert_eq!(cfg, Config::default());
}
#[test]
fn token_treats_blank_and_absent_alike() {
assert!(token_from(None).is_err());
assert!(token_from(Some(String::new())).is_err());
assert!(token_from(Some(" ".into())).is_err());
assert_eq!(
token_from(Some(" lin_api_xyz ".into())).unwrap(),
"lin_api_xyz"
);
}
#[test]
fn missing_token_error_names_the_variable_and_where_to_get_one() {
let msg = token_from(None).unwrap_err().to_string();
assert!(msg.contains(TOKEN_ENV), "{msg}");
assert!(msg.contains("linear.app/settings/api"), "{msg}");
}
}