Skip to main content

balls/
config.rs

1use crate::error::{BallError, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct PluginEntry {
9    #[serde(default)]
10    pub enabled: bool,
11    #[serde(default)]
12    pub sync_on_change: bool,
13    pub config_file: String,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Config {
18    pub version: u32,
19    pub id_length: usize,
20    pub stale_threshold_seconds: u64,
21    #[serde(default = "default_true")]
22    pub auto_fetch_on_ready: bool,
23    pub worktree_dir: String,
24    #[serde(default)]
25    pub protected_main: bool,
26    #[serde(default)]
27    pub plugins: BTreeMap<String, PluginEntry>,
28}
29
30fn default_true() -> bool {
31    true
32}
33
34impl Default for Config {
35    fn default() -> Self {
36        Config {
37            version: CONFIG_SCHEMA_VERSION,
38            id_length: 4,
39            stale_threshold_seconds: 60,
40            auto_fetch_on_ready: true,
41            worktree_dir: ".balls-worktrees".to_string(),
42            protected_main: false,
43            plugins: BTreeMap::new(),
44        }
45    }
46}
47
48pub const ID_LENGTH_MIN: usize = 4;
49pub const ID_LENGTH_MAX: usize = 32;
50
51/// Current on-disk schema version for `.balls/config.json`. Bump this
52/// when a config change requires migration logic. Older clients
53/// reading a config written with a higher version refuse to load
54/// with a clear "your bl is too old" error rather than silently
55/// losing fields. Lower-or-equal versions load normally because the
56/// struct definition is backward-compatible by design (new fields
57/// carry serde defaults).
58pub const CONFIG_SCHEMA_VERSION: u32 = 1;
59
60impl Config {
61    pub fn load(path: &Path) -> Result<Self> {
62        let s = fs::read_to_string(path).map_err(|e| match e.kind() {
63            std::io::ErrorKind::NotFound => BallError::NotInitialized,
64            _ => BallError::Io(e),
65        })?;
66        let mut c: Config = serde_json::from_str(&s)?;
67        c.sanitize();
68        c.validate()?;
69        Ok(c)
70    }
71
72    /// Clamp `id_length` into the supported range, warning on clamp.
73    /// id_length = 0 would otherwise infinite-loop id generation; very large
74    /// values waste hex space without colliding any less.
75    fn sanitize(&mut self) {
76        if !(ID_LENGTH_MIN..=ID_LENGTH_MAX).contains(&self.id_length) {
77            let original = self.id_length;
78            self.id_length = self.id_length.clamp(ID_LENGTH_MIN, ID_LENGTH_MAX);
79            eprintln!(
80                "warning: id_length {} out of range [{}, {}]; clamped to {}",
81                original, ID_LENGTH_MIN, ID_LENGTH_MAX, self.id_length
82            );
83        }
84    }
85
86    /// Reject `worktree_dir` values that would escape the repo root,
87    /// and refuse configs written with a schema version newer than
88    /// this binary understands.
89    fn validate(&self) -> Result<()> {
90        if self.version > CONFIG_SCHEMA_VERSION {
91            return Err(BallError::Other(format!(
92                "config schema version {} is newer than this bl (supports up to {}); \
93                 upgrade bl to read this repo's config",
94                self.version, CONFIG_SCHEMA_VERSION
95            )));
96        }
97        if self.worktree_dir.starts_with('/') || self.worktree_dir.contains("..") {
98            return Err(BallError::Other(format!(
99                "invalid config: worktree_dir {:?} must be a relative path with no '..' segments",
100                self.worktree_dir
101            )));
102        }
103        Ok(())
104    }
105
106    pub fn save(&self, path: &Path) -> Result<()> {
107        let s = serde_json::to_string_pretty(self)?;
108        if let Some(parent) = path.parent() {
109            fs::create_dir_all(parent)?;
110        }
111        fs::write(path, s + "\n")?;
112        Ok(())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use tempfile::TempDir;
120
121    #[test]
122    fn default_roundtrip() {
123        let dir = TempDir::new().unwrap();
124        let path = dir.path().join("nested/config.json");
125        let cfg = Config::default();
126        cfg.save(&path).unwrap();
127        let loaded = Config::load(&path).unwrap();
128        assert_eq!(loaded.version, 1);
129        assert_eq!(loaded.id_length, 4);
130        assert!(loaded.auto_fetch_on_ready);
131        assert!(!loaded.protected_main);
132        assert!(loaded.plugins.is_empty());
133    }
134
135    #[test]
136    fn load_missing_returns_not_initialized() {
137        let dir = TempDir::new().unwrap();
138        let path = dir.path().join("missing.json");
139        let err = Config::load(&path).unwrap_err();
140        assert!(matches!(err, BallError::NotInitialized));
141    }
142
143    #[test]
144    fn load_bad_json_returns_json_error() {
145        let dir = TempDir::new().unwrap();
146        let path = dir.path().join("bad.json");
147        std::fs::write(&path, "not json").unwrap();
148        let err = Config::load(&path).unwrap_err();
149        assert!(matches!(err, BallError::Json(_)));
150    }
151
152    #[test]
153    fn default_true_fills_in_missing_field() {
154        // Omit auto_fetch_on_ready — serde default must be true
155        let s = r#"{
156            "version": 1,
157            "id_length": 4,
158            "stale_threshold_seconds": 60,
159            "worktree_dir": ".balls-worktrees"
160        }"#;
161        let dir = TempDir::new().unwrap();
162        let path = dir.path().join("c.json");
163        std::fs::write(&path, s).unwrap();
164        let cfg = Config::load(&path).unwrap();
165        assert!(cfg.auto_fetch_on_ready);
166    }
167
168    #[test]
169    fn load_non_notfound_io_error() {
170        // A directory at the config path yields an IO error that's not NotFound.
171        let dir = TempDir::new().unwrap();
172        let path = dir.path().join("sub");
173        std::fs::create_dir_all(&path).unwrap();
174        let err = Config::load(&path).unwrap_err();
175        assert!(matches!(err, BallError::Io(_)));
176    }
177
178    fn write_cfg(dir: &TempDir, body: &str) -> std::path::PathBuf {
179        let path = dir.path().join("c.json");
180        std::fs::write(&path, body).unwrap();
181        path
182    }
183
184    #[test]
185    fn id_length_clamped_low() {
186        let dir = TempDir::new().unwrap();
187        let p = write_cfg(
188            &dir,
189            r#"{"version":1,"id_length":0,"stale_threshold_seconds":60,"worktree_dir":".balls-worktrees"}"#,
190        );
191        let cfg = Config::load(&p).unwrap();
192        assert_eq!(cfg.id_length, ID_LENGTH_MIN);
193    }
194
195    #[test]
196    fn id_length_clamped_high() {
197        let dir = TempDir::new().unwrap();
198        let p = write_cfg(
199            &dir,
200            r#"{"version":1,"id_length":99,"stale_threshold_seconds":60,"worktree_dir":".balls-worktrees"}"#,
201        );
202        let cfg = Config::load(&p).unwrap();
203        assert_eq!(cfg.id_length, ID_LENGTH_MAX);
204    }
205
206    #[test]
207    fn worktree_dir_rejects_absolute_path() {
208        let dir = TempDir::new().unwrap();
209        let p = write_cfg(
210            &dir,
211            r#"{"version":1,"id_length":4,"stale_threshold_seconds":60,"worktree_dir":"/tmp/evil"}"#,
212        );
213        let err = Config::load(&p).unwrap_err();
214        assert!(matches!(err, BallError::Other(ref s) if s.contains("worktree_dir")));
215    }
216
217    #[test]
218    fn worktree_dir_rejects_parent_segment() {
219        let dir = TempDir::new().unwrap();
220        let p = write_cfg(
221            &dir,
222            r#"{"version":1,"id_length":4,"stale_threshold_seconds":60,"worktree_dir":"../escape"}"#,
223        );
224        let err = Config::load(&p).unwrap_err();
225        assert!(matches!(err, BallError::Other(ref s) if s.contains("worktree_dir")));
226    }
227
228    #[test]
229    fn load_rejects_future_schema_version() {
230        let dir = TempDir::new().unwrap();
231        let future = CONFIG_SCHEMA_VERSION + 1;
232        let p = write_cfg(
233            &dir,
234            &format!(
235                r#"{{"version":{future},"id_length":4,"stale_threshold_seconds":60,"worktree_dir":".balls-worktrees"}}"#
236            ),
237        );
238        let err = Config::load(&p).unwrap_err();
239        assert!(
240            matches!(err, BallError::Other(ref s) if s.contains("schema version") && s.contains("upgrade bl")),
241            "expected schema-version error, got: {err:?}",
242        );
243    }
244
245    #[test]
246    fn load_accepts_current_schema_version() {
247        let dir = TempDir::new().unwrap();
248        let p = write_cfg(
249            &dir,
250            &format!(
251                r#"{{"version":{CONFIG_SCHEMA_VERSION},"id_length":4,"stale_threshold_seconds":60,"worktree_dir":".balls-worktrees"}}"#
252            ),
253        );
254        let cfg = Config::load(&p).unwrap();
255        assert_eq!(cfg.version, CONFIG_SCHEMA_VERSION);
256    }
257
258    #[test]
259    fn plugin_entry_serde() {
260        let mut cfg = Config::default();
261        cfg.plugins.insert(
262            "jira".to_string(),
263            PluginEntry {
264                enabled: true,
265                sync_on_change: true,
266                config_file: ".balls/plugins/jira.json".into(),
267            },
268        );
269        let s = serde_json::to_string(&cfg).unwrap();
270        assert!(s.contains("jira"));
271        let back: Config = serde_json::from_str(&s).unwrap();
272        assert_eq!(back.plugins.len(), 1);
273    }
274}