Skip to main content

cliban_sync/
config.rs

1//! `~/.config/cliban/linear.toml` — the optional bits of Linear setup.
2//!
3//! Optional is the point: with no config file at all, `import` works and
4//! `push` works on any already-linked issue, because the status map falls back
5//! to Linear's own workflow-state *types* (see [`crate::linear::states`]). The
6//! file exists for the two things that cannot be inferred — which team new
7//! issues go to, and a state name that does not match cliban's vocabulary.
8//!
9//! The API token is deliberately **not** a config field. It lives in
10//! `$LINEAR_API_KEY` and nowhere else, so there is no cliban-owned file on disk
11//! that is worth stealing and no path by which a token reaches a log, a
12//! `--json` payload, or a git repo full of dotfiles.
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16
17use serde::Deserialize;
18
19use crate::error::{Error, Result};
20
21/// Environment variable holding the Linear API key.
22pub const TOKEN_ENV: &str = "LINEAR_API_KEY";
23
24/// Config file name inside [`cliban_core::paths::config_dir`].
25pub 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    /// Team key (e.g. `ENG`) new issues are created in when `push --create` is
37    /// used without `--team`.
38    pub team: Option<String>,
39    /// cliban status → exact Linear workflow-state name. Overrides the
40    /// name-then-type inference for the statuses listed; anything absent still
41    /// infers.
42    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    /// Load from `path`, or return defaults when the file is absent. A file
53    /// that exists but cannot be read or parsed is an error — silently falling
54    /// back to defaults there would apply the wrong state map without saying so.
55    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    /// Load from the default location.
64    pub fn load_default() -> Result<Self> {
65        Self::load(&default_path())
66    }
67
68    /// Reject state overrides keyed on something that is not a cliban status —
69    /// a typo like `in_review` would otherwise sit there doing nothing.
70    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
84/// `$XDG_CONFIG_HOME/cliban/linear.toml`, falling back to `~/.config/...`.
85pub fn default_path() -> PathBuf {
86    cliban_core::paths::config_dir().join(FILE_NAME)
87}
88
89/// The API token from the environment. Blank counts as unset — an exported but
90/// empty variable is a mistake, not a credential.
91pub fn token() -> Result<String> {
92    token_from(std::env::var(TOKEN_ENV).ok())
93}
94
95/// The pure half of [`token`]. Split out so the blank/absent rules can be
96/// tested without mutating process-wide environment, which races with every
97/// other test in the binary.
98pub 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        // deny_unknown_fields: a misspelled key that silently did nothing
154        // would be worse than a loud parse failure.
155        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}