Skip to main content

gonzalo_ticket_config/
lib.rs

1//! Multi-connection ticket config and the provider registry (ADR 0010).
2//!
3//! A `tickets.toml` holds an array of `[[connection]]` tables. This crate parses
4//! it and is the **registry** that turns each connection into a
5//! `Box<dyn TicketSource>` — it sits *above* the connector crates, which would
6//! otherwise be a dependency cycle (they depend on `gonzalo-ticket` for the
7//! trait). Secrets are referenced by env-var name, never stored in the file.
8
9use 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
16/// A named, live ticket source built from a connection: `(connection name, source)`.
17///
18/// The first tuple element (`.0`) is the connection's name; the second is its
19/// live source.
20pub type NamedSource = (String, Box<dyn TicketSource>);
21
22/// Top-level config: a list of connections.
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Config {
25    #[serde(rename = "connection", default)]
26    pub connections: Vec<Connection>,
27}
28
29/// One ticket connection.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Connection {
32    pub name: String,
33    /// Provider key in the registry, e.g. `"github-projects"`.
34    pub provider: String,
35    pub org: String,
36    pub project: u32,
37    /// Name of the env var holding the access token (never the token itself).
38    pub token_env: String,
39    /// Status-name → category map. The reserved key `"default"` sets the
40    /// fallback category; all other keys are board column names.
41    #[serde(default)]
42    pub state_map: BTreeMap<String, String>,
43    /// Optional category→column overrides for write-back (`set_state`), for
44    /// boards where two columns map to the same category. Keys are category
45    /// names (same vocabulary as `state_map` values).
46    #[serde(default)]
47    pub set_targets: BTreeMap<String, String>,
48}
49
50/// Config / registry failures.
51#[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
67/// Load and parse a `tickets.toml` from disk.
68pub 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
74/// Parse config from a TOML string.
75pub 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    /// Convenience: load and parse from a path.
81    pub fn load(path: &Path) -> Result<Self, ConfigError> {
82        load(path)
83    }
84
85    /// Build a live `TicketSource` for each connection.
86    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
94/// The registry: map a connection's `provider` to a constructed source.
95pub 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
137/// Parse a connection's `set_targets` (category-name → column) into typed
138/// categories.
139pub 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
151/// Parse a normalized state-category name (`triage`, `backlog`, `open`,
152/// `in_progress`, `pending`, `done`, `canceled`) into a [`StateCategory`].
153/// Returns `None` for any other string. Shared by config parsing and the CLI.
154pub 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    /// Serializes the tests that mutate the shared `TEST_TICKET_TOKEN` env var,
173    /// which would otherwise race under `cargo test`'s default parallelism.
174    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}