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
51pub 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 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 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 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 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}