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    /// When true, `issue mv` on a Linear-linked issue pushes state + the
44    /// living progress comment after the move commits locally. Opt-in and
45    /// best-effort: a failed push warns and records board activity, never
46    /// fails the move.
47    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    /// Load from `path`, or return defaults when the file is absent. A file
58    /// that exists but cannot be read or parsed is an error — silently falling
59    /// back to defaults there would apply the wrong state map without saying so.
60    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    /// Load from the default location.
69    pub fn load_default() -> Result<Self> {
70        Self::load(&default_path())
71    }
72
73    /// Reject state overrides keyed on something that is not a cliban status —
74    /// a typo like `in_review` would otherwise sit there doing nothing.
75    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
89/// `$XDG_CONFIG_HOME/cliban/linear.toml`, falling back to `~/.config/...`.
90pub fn default_path() -> PathBuf {
91    cliban_core::paths::config_dir().join(FILE_NAME)
92}
93
94/// The API token from the environment. Blank counts as unset — an exported but
95/// empty variable is a mistake, not a credential.
96pub fn token() -> Result<String> {
97    token_from(std::env::var(TOKEN_ENV).ok())
98}
99
100/// The pure half of [`token`]. Split out so the blank/absent rules can be
101/// tested without mutating process-wide environment, which races with every
102/// other test in the binary.
103pub 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        // The flag is opt-in: a config file that predates it (or no file at
142        // all) must never start pushing on every move.
143        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        // deny_unknown_fields: a misspelled key that silently did nothing
178        // would be worse than a loud parse failure.
179        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}