1use gonzalo_domain::StateCategory;
10use gonzalo_ticket::{StateMapping, StateSignal, TicketSource};
11use gonzalo_ticket_github::GitHubProjectSource;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::path::Path;
15
16pub type NamedSource = (String, Box<dyn TicketSource>);
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Config {
25 #[serde(rename = "connection", default)]
26 pub connections: Vec<Connection>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Connection {
32 pub name: String,
33 pub provider: String,
35 pub org: String,
36 pub project: u32,
37 pub token_env: String,
39 #[serde(default)]
42 pub state_map: BTreeMap<String, String>,
43 #[serde(default)]
47 pub set_targets: BTreeMap<String, String>,
48}
49
50#[derive(Debug, thiserror::Error)]
52pub enum ConfigError {
53 #[error("reading config {0}: {1}")]
54 Read(String, String),
55 #[error("parsing config: {0}")]
56 Parse(String),
57 #[error("connection {conn}: env var {var} is not set")]
58 MissingEnv { conn: String, var: String },
59 #[error("connection {conn}: unknown provider {provider}")]
60 UnknownProvider { conn: String, provider: String },
61 #[error("connection {conn}: unknown state category {value:?}")]
62 BadCategory { conn: String, value: String },
63 #[error("building source: {0}")]
64 Source(String),
65}
66
67pub fn load(path: &Path) -> Result<Config, ConfigError> {
69 let text = std::fs::read_to_string(path)
70 .map_err(|e| ConfigError::Read(path.display().to_string(), e.to_string()))?;
71 parse(&text)
72}
73
74pub fn parse(text: &str) -> Result<Config, ConfigError> {
76 toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))
77}
78
79impl Config {
80 pub fn load(path: &Path) -> Result<Self, ConfigError> {
82 load(path)
83 }
84
85 pub fn sources(&self) -> Result<Vec<NamedSource>, ConfigError> {
87 self.connections
88 .iter()
89 .map(|c| Ok((c.name.clone(), build_source(c)?)))
90 .collect()
91 }
92}
93
94pub fn build_source(conn: &Connection) -> Result<Box<dyn TicketSource>, ConfigError> {
96 let token = std::env::var(&conn.token_env).map_err(|_| ConfigError::MissingEnv {
97 conn: conn.name.clone(),
98 var: conn.token_env.clone(),
99 })?;
100 match conn.provider.as_str() {
101 "github-projects" => {
102 let mapping = state_mapping(conn)?;
103 let targets = write_targets(conn)?;
104 let src = GitHubProjectSource::new(&conn.org, conn.project, token, mapping)
105 .map_err(|e| ConfigError::Source(e.to_string()))?
106 .with_write_targets(targets);
107 Ok(Box::new(src))
108 }
109 other => Err(ConfigError::UnknownProvider {
110 conn: conn.name.clone(),
111 provider: other.to_string(),
112 }),
113 }
114}
115
116fn state_mapping(conn: &Connection) -> Result<StateMapping, ConfigError> {
117 let mut by_value = BTreeMap::new();
118 let mut default = StateCategory::Open;
119 for (k, v) in &conn.state_map {
120 let cat = parse_category(v).ok_or_else(|| ConfigError::BadCategory {
121 conn: conn.name.clone(),
122 value: v.clone(),
123 })?;
124 if k == "default" {
125 default = cat;
126 } else {
127 by_value.insert(k.clone(), cat);
128 }
129 }
130 Ok(StateMapping {
131 signal: StateSignal::NativeStatus,
132 by_value,
133 default,
134 })
135}
136
137pub fn write_targets(conn: &Connection) -> Result<BTreeMap<StateCategory, String>, ConfigError> {
140 let mut out = BTreeMap::new();
141 for (cat, column) in &conn.set_targets {
142 let parsed = parse_category(cat).ok_or_else(|| ConfigError::BadCategory {
143 conn: conn.name.clone(),
144 value: cat.clone(),
145 })?;
146 out.insert(parsed, column.clone());
147 }
148 Ok(out)
149}
150
151pub fn parse_category(s: &str) -> Option<StateCategory> {
155 Some(match s {
156 "triage" => StateCategory::Triage,
157 "backlog" => StateCategory::Backlog,
158 "open" => StateCategory::Open,
159 "in_progress" => StateCategory::InProgress,
160 "pending" => StateCategory::Pending,
161 "done" => StateCategory::Done,
162 "canceled" => StateCategory::Canceled,
163 _ => return None,
164 })
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use std::sync::Mutex;
171
172 static ENV_LOCK: Mutex<()> = Mutex::new(());
175
176 const SAMPLE: &str = r#"
177[[connection]]
178name = "caliban-ai-board"
179provider = "github-projects"
180org = "caliban-ai"
181project = 1
182token_env = "TEST_TICKET_TOKEN"
183
184[connection.state_map]
185default = "open"
186"Todo" = "open"
187"In Progress" = "in_progress"
188"Done" = "done"
189"#;
190
191 #[test]
192 fn parses_a_connection() {
193 let cfg = parse(SAMPLE).unwrap();
194 assert_eq!(cfg.connections.len(), 1);
195 let c = &cfg.connections[0];
196 assert_eq!(c.provider, "github-projects");
197 assert_eq!(c.org, "caliban-ai");
198 assert_eq!(c.project, 1);
199 assert_eq!(
200 c.state_map.get("In Progress").map(String::as_str),
201 Some("in_progress")
202 );
203 }
204
205 #[test]
206 fn state_mapping_pulls_out_default_and_entries() {
207 let cfg = parse(SAMPLE).unwrap();
208 let m = state_mapping(&cfg.connections[0]).unwrap();
209 assert_eq!(m.signal, StateSignal::NativeStatus);
210 assert_eq!(m.default, StateCategory::Open);
211 assert_eq!(m.category_of("In Progress"), StateCategory::InProgress);
212 assert_eq!(m.category_of("Done"), StateCategory::Done);
213 assert_eq!(m.category_of("Nonexistent"), StateCategory::Open);
214 }
215
216 #[test]
217 fn missing_env_var_is_reported() {
218 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
219 #[allow(unsafe_code)]
220 unsafe {
221 std::env::remove_var("TEST_TICKET_TOKEN")
222 };
223 let cfg = parse(SAMPLE).unwrap();
224 let err = build_source(&cfg.connections[0]).err().unwrap();
225 assert!(matches!(err, ConfigError::MissingEnv { .. }));
226 }
227
228 #[test]
229 fn unknown_provider_is_reported() {
230 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
231 let text = SAMPLE.replace("github-projects", "bogus-tracker");
232 let cfg = parse(&text).unwrap();
233 #[allow(unsafe_code)]
234 unsafe {
235 std::env::set_var("TEST_TICKET_TOKEN", "x")
236 };
237 let err = build_source(&cfg.connections[0]).err().unwrap();
238 assert!(matches!(err, ConfigError::UnknownProvider { .. }));
239 #[allow(unsafe_code)]
240 unsafe {
241 std::env::remove_var("TEST_TICKET_TOKEN")
242 };
243 }
244
245 #[test]
246 fn parses_and_builds_multiple_connections() {
247 const TWO: &str = r#"
248[[connection]]
249name = "board-a"
250provider = "github-projects"
251org = "org-a"
252project = 1
253token_env = "MULTI_TEST_TOKEN_A"
254[connection.state_map]
255default = "open"
256
257[[connection]]
258name = "board-b"
259provider = "github-projects"
260org = "org-b"
261project = 2
262token_env = "MULTI_TEST_TOKEN_B"
263[connection.state_map]
264default = "open"
265"#;
266 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
267 let cfg = parse(TWO).unwrap();
268 assert_eq!(cfg.connections.len(), 2);
269 assert_eq!(cfg.connections[0].name, "board-a");
270 assert_eq!(cfg.connections[1].name, "board-b");
271
272 #[allow(unsafe_code)]
273 unsafe {
274 std::env::set_var("MULTI_TEST_TOKEN_A", "x");
275 std::env::set_var("MULTI_TEST_TOKEN_B", "y");
276 }
277 let sources = cfg.sources().unwrap();
278 assert_eq!(sources.len(), 2);
279 assert_eq!(sources[0].0, "board-a");
280 assert_eq!(sources[1].0, "board-b");
281 #[allow(unsafe_code)]
282 unsafe {
283 std::env::remove_var("MULTI_TEST_TOKEN_A");
284 std::env::remove_var("MULTI_TEST_TOKEN_B");
285 }
286 }
287
288 const WITH_TARGETS: &str = r#"
289[[connection]]
290name = "caliban-ai-board"
291provider = "github-projects"
292org = "caliban-ai"
293project = 1
294token_env = "TEST_TICKET_TOKEN"
295
296[connection.state_map]
297default = "open"
298"Done" = "done"
299"Shipped" = "done"
300
301[connection.set_targets]
302done = "Shipped"
303"#;
304
305 #[test]
306 fn parses_set_targets_into_categories() {
307 let cfg = parse(WITH_TARGETS).unwrap();
308 let c = &cfg.connections[0];
309 let targets = write_targets(c).unwrap();
310 assert_eq!(
311 targets.get(&StateCategory::Done).map(String::as_str),
312 Some("Shipped")
313 );
314 }
315
316 #[test]
317 fn set_targets_with_bad_category_errors() {
318 let text = WITH_TARGETS.replace("done = \"Shipped\"", "finished = \"Shipped\"");
319 let cfg = parse(&text).unwrap();
320 assert!(matches!(
321 write_targets(&cfg.connections[0]),
322 Err(ConfigError::BadCategory { .. })
323 ));
324 }
325
326 #[test]
327 fn bad_category_is_reported() {
328 let text = SAMPLE.replace(r#""Done" = "done""#, r#""Done" = "finished""#);
329 let cfg = parse(&text).unwrap();
330 let err = state_mapping(&cfg.connections[0]).unwrap_err();
331 assert!(matches!(err, ConfigError::BadCategory { .. }));
332 }
333}