1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16
17use serde::Deserialize;
18
19use crate::error::{Error, Result};
20
21pub const TOKEN_ENV: &str = "LINEAR_API_KEY";
23
24pub const FILE_NAME: &str = "linear.toml";
26
27#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
28#[serde(default, deny_unknown_fields)]
29pub struct Config {
30 pub linear: LinearConfig,
31}
32
33#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
34#[serde(default, deny_unknown_fields)]
35pub struct LinearConfig {
36 pub team: Option<String>,
39 pub states: BTreeMap<String, String>,
43}
44
45impl Config {
46 pub fn parse(text: &str) -> Result<Self> {
47 let cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
48 cfg.validate()?;
49 Ok(cfg)
50 }
51
52 pub fn load(path: &Path) -> Result<Self> {
56 match std::fs::read_to_string(path) {
57 Ok(text) => Self::parse(&text),
58 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
59 Err(e) => Err(Error::Config(format!("{}: {e}", path.display()))),
60 }
61 }
62
63 pub fn load_default() -> Result<Self> {
65 Self::load(&default_path())
66 }
67
68 fn validate(&self) -> Result<()> {
71 for key in self.linear.states.keys() {
72 if !cliban_core::schema::ISSUE_STATUSES.contains(&key.as_str()) {
73 return Err(Error::Config(format!(
74 "[linear.states] has key {key:?}, which is not a cliban status \
75 (expected one of: {})",
76 cliban_core::schema::ISSUE_STATUSES.join(", ")
77 )));
78 }
79 }
80 Ok(())
81 }
82}
83
84pub fn default_path() -> PathBuf {
86 cliban_core::paths::config_dir().join(FILE_NAME)
87}
88
89pub fn token() -> Result<String> {
92 token_from(std::env::var(TOKEN_ENV).ok())
93}
94
95pub fn token_from(raw: Option<String>) -> Result<String> {
99 raw.map(|v| v.trim().to_string())
100 .filter(|v| !v.is_empty())
101 .ok_or(Error::MissingToken(TOKEN_ENV))
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn empty_config_is_valid_and_all_defaults() {
110 let cfg = Config::parse("").unwrap();
111 assert_eq!(cfg, Config::default());
112 assert!(cfg.linear.team.is_none());
113 assert!(cfg.linear.states.is_empty());
114 }
115
116 #[test]
117 fn parses_team_and_state_overrides() {
118 let cfg = Config::parse(
119 r#"
120 [linear]
121 team = "ENG"
122 [linear.states]
123 in-review = "Code Review"
124 "#,
125 )
126 .unwrap();
127 assert_eq!(cfg.linear.team.as_deref(), Some("ENG"));
128 assert_eq!(
129 cfg.linear.states.get("in-review").map(String::as_str),
130 Some("Code Review")
131 );
132 }
133
134 #[test]
135 fn rejects_a_state_key_that_is_not_a_cliban_status() {
136 let err = Config::parse(
137 r#"
138 [linear.states]
139 in_review = "Code Review"
140 "#,
141 )
142 .unwrap_err();
143 let msg = err.to_string();
144 assert!(msg.contains("in_review"), "{msg}");
145 assert!(
146 msg.contains("in-review"),
147 "should list the valid set: {msg}"
148 );
149 }
150
151 #[test]
152 fn rejects_unknown_keys_rather_than_ignoring_them() {
153 assert!(Config::parse("[linear]\nteem = \"ENG\"\n").is_err());
156 }
157
158 #[test]
159 fn a_missing_file_is_defaults_not_an_error() {
160 let cfg = Config::load(Path::new("/nonexistent/cliban/linear.toml")).unwrap();
161 assert_eq!(cfg, Config::default());
162 }
163
164 #[test]
165 fn token_treats_blank_and_absent_alike() {
166 assert!(token_from(None).is_err());
167 assert!(token_from(Some(String::new())).is_err());
168 assert!(token_from(Some(" ".into())).is_err());
169 assert_eq!(
170 token_from(Some(" lin_api_xyz ".into())).unwrap(),
171 "lin_api_xyz"
172 );
173 }
174
175 #[test]
176 fn missing_token_error_names_the_variable_and_where_to_get_one() {
177 let msg = token_from(None).unwrap_err().to_string();
178 assert!(msg.contains(TOKEN_ENV), "{msg}");
179 assert!(msg.contains("linear.app/settings/api"), "{msg}");
180 }
181}