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 pub push_on_move: bool,
48}
49
50impl Config {
51 pub fn parse(text: &str) -> Result<Self> {
52 let cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
53 cfg.validate()?;
54 Ok(cfg)
55 }
56
57 pub fn load(path: &Path) -> Result<Self> {
61 match std::fs::read_to_string(path) {
62 Ok(text) => Self::parse(&text),
63 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
64 Err(e) => Err(Error::Config(format!("{}: {e}", path.display()))),
65 }
66 }
67
68 pub fn load_default() -> Result<Self> {
70 Self::load(&default_path())
71 }
72
73 fn validate(&self) -> Result<()> {
76 for key in self.linear.states.keys() {
77 if !cliban_core::schema::ISSUE_STATUSES.contains(&key.as_str()) {
78 return Err(Error::Config(format!(
79 "[linear.states] has key {key:?}, which is not a cliban status \
80 (expected one of: {})",
81 cliban_core::schema::ISSUE_STATUSES.join(", ")
82 )));
83 }
84 }
85 Ok(())
86 }
87}
88
89pub fn default_path() -> PathBuf {
91 cliban_core::paths::config_dir().join(FILE_NAME)
92}
93
94pub fn token() -> Result<String> {
97 token_from(std::env::var(TOKEN_ENV).ok())
98}
99
100pub fn token_from(raw: Option<String>) -> Result<String> {
104 raw.map(|v| v.trim().to_string())
105 .filter(|v| !v.is_empty())
106 .ok_or(Error::MissingToken(TOKEN_ENV))
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn empty_config_is_valid_and_all_defaults() {
115 let cfg = Config::parse("").unwrap();
116 assert_eq!(cfg, Config::default());
117 assert!(cfg.linear.team.is_none());
118 assert!(cfg.linear.states.is_empty());
119 }
120
121 #[test]
122 fn parses_team_and_state_overrides() {
123 let cfg = Config::parse(
124 r#"
125 [linear]
126 team = "ENG"
127 [linear.states]
128 in-review = "Code Review"
129 "#,
130 )
131 .unwrap();
132 assert_eq!(cfg.linear.team.as_deref(), Some("ENG"));
133 assert_eq!(
134 cfg.linear.states.get("in-review").map(String::as_str),
135 Some("Code Review")
136 );
137 }
138
139 #[test]
140 fn push_on_move_defaults_to_off() {
141 assert!(!Config::parse("").unwrap().linear.push_on_move);
144 assert!(
145 !Config::parse("[linear]\nteam = \"ENG\"\n")
146 .unwrap()
147 .linear
148 .push_on_move
149 );
150 }
151
152 #[test]
153 fn parses_push_on_move() {
154 let cfg = Config::parse("[linear]\npush_on_move = true\n").unwrap();
155 assert!(cfg.linear.push_on_move);
156 }
157
158 #[test]
159 fn rejects_a_state_key_that_is_not_a_cliban_status() {
160 let err = Config::parse(
161 r#"
162 [linear.states]
163 in_review = "Code Review"
164 "#,
165 )
166 .unwrap_err();
167 let msg = err.to_string();
168 assert!(msg.contains("in_review"), "{msg}");
169 assert!(
170 msg.contains("in-review"),
171 "should list the valid set: {msg}"
172 );
173 }
174
175 #[test]
176 fn rejects_unknown_keys_rather_than_ignoring_them() {
177 assert!(Config::parse("[linear]\nteem = \"ENG\"\n").is_err());
180 }
181
182 #[test]
183 fn a_missing_file_is_defaults_not_an_error() {
184 let cfg = Config::load(Path::new("/nonexistent/cliban/linear.toml")).unwrap();
185 assert_eq!(cfg, Config::default());
186 }
187
188 #[test]
189 fn token_treats_blank_and_absent_alike() {
190 assert!(token_from(None).is_err());
191 assert!(token_from(Some(String::new())).is_err());
192 assert!(token_from(Some(" ".into())).is_err());
193 assert_eq!(
194 token_from(Some(" lin_api_xyz ".into())).unwrap(),
195 "lin_api_xyz"
196 );
197 }
198
199 #[test]
200 fn missing_token_error_names_the_variable_and_where_to_get_one() {
201 let msg = token_from(None).unwrap_err().to_string();
202 assert!(msg.contains(TOKEN_ENV), "{msg}");
203 assert!(msg.contains("linear.app/settings/api"), "{msg}");
204 }
205}