1pub fn default_config_yaml() -> &'static str {
8 r#"version: "2.0"
9
10boards:
11 - name: development
12 preset: nautical
13 path: kanban-work/
14 scan_paths:
15 - "kanban-work/expeditions/"
16 - "kanban-work/voyages/"
17 - "kanban-work/chores/"
18 wip_exempt_types:
19 - voyage
20 wip_limits:
21 in_progress: 4
22 review: 3
23 states:
24 - backlog
25 - in_progress
26 - review
27 - done
28
29 - name: research
30 preset: hdd
31 path: research/
32 scan_paths:
33 - "research/"
34 states:
35 - draft
36 - captured
37 - planned
38 - outline
39 - active
40 - writing
41 - running
42 - review
43 - formalized
44 - complete
45 - abandoned
46 - retired
47 type_states:
48 hypothesis:
49 - draft
50 - active
51 - retired
52 measure:
53 - draft
54 - active
55 - retired
56 paper:
57 - draft
58 - outline
59 - writing
60 - review
61 - complete
62 - abandoned
63 experiment:
64 - planned
65 - running
66 - complete
67 - abandoned
68 literature:
69 - draft
70 - active
71 - complete
72 idea:
73 - captured
74 - formalized
75 - abandoned
76"#
77}
78
79use serde::Deserialize;
80use std::collections::HashMap;
81use std::path::Path;
82
83#[derive(Debug, thiserror::Error)]
85pub enum ConfigError {
86 #[error("IO error: {0}")]
87 Io(#[from] std::io::Error),
88
89 #[error("YAML parse error: {0}")]
90 Yaml(#[from] serde_yaml::Error),
91
92 #[error("Board not found: {0}")]
93 BoardNotFound(String),
94
95 #[error("Invalid config: {0}")]
96 Invalid(String),
97}
98
99pub type Result<T> = std::result::Result<T, ConfigError>;
100
101#[derive(Debug, Clone, Deserialize)]
103pub struct ConfigFile {
104 pub version: String,
105 pub boards: Vec<BoardConfig>,
106 pub namespace: Option<String>,
107 pub default_board: Option<String>,
108 pub relationships: Option<HashMap<String, RelationshipConfig>>,
109 pub critical_path: Option<CriticalPathConfig>,
110}
111
112#[derive(Debug, Clone, Deserialize)]
114pub struct BoardConfig {
115 pub name: String,
116 pub preset: String,
117 pub path: String,
118 pub scan_paths: Vec<String>,
119 #[serde(default)]
120 pub ignore: Vec<String>,
121 #[serde(default)]
122 pub wip_exempt_types: Vec<String>,
123 #[serde(default)]
124 pub wip_limits: HashMap<String, u32>,
125 pub states: Vec<String>,
126 #[serde(default)]
127 pub phases: Vec<String>,
128 #[serde(default)]
132 pub type_states: HashMap<String, Vec<String>>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
137pub struct RelationshipConfig {
138 pub from_board: String,
139 pub to_board: String,
140 pub predicate: String,
141}
142
143#[derive(Debug, Clone, Deserialize)]
145pub struct CriticalPathConfig {
146 pub enabled: bool,
147 #[serde(default)]
148 pub boost_priority: bool,
149}
150
151impl ConfigFile {
152 pub fn from_path(path: &Path) -> Result<Self> {
154 let contents = std::fs::read_to_string(path)?;
155 Self::from_yaml(&contents)
156 }
157
158 pub fn from_yaml(yaml: &str) -> Result<Self> {
160 let config: ConfigFile = serde_yaml::from_str(yaml)?;
161 config.validate()?;
162 Ok(config)
163 }
164
165 pub fn board(&self, name: &str) -> Result<&BoardConfig> {
167 self.boards
168 .iter()
169 .find(|b| b.name == name)
170 .ok_or_else(|| ConfigError::BoardNotFound(name.to_string()))
171 }
172
173 pub fn default_board(&self) -> Result<&BoardConfig> {
175 let name = self.default_board.as_deref().unwrap_or("development");
176 self.board(name)
177 }
178
179 fn validate(&self) -> Result<()> {
181 if self.boards.is_empty() {
182 return Err(ConfigError::Invalid("No boards defined".to_string()));
183 }
184 for board in &self.boards {
185 if board.states.is_empty() {
186 return Err(ConfigError::Invalid(format!(
187 "Board '{}' has no states defined",
188 board.name
189 )));
190 }
191 if board.scan_paths.is_empty() {
192 return Err(ConfigError::Invalid(format!(
193 "Board '{}' has no scan_paths defined",
194 board.name
195 )));
196 }
197 }
198 Ok(())
199 }
200}
201
202impl BoardConfig {
203 pub fn wip_limit(&self, category: &str) -> Option<u32> {
205 self.wip_limits.get(category).copied()
206 }
207
208 pub fn is_wip_exempt(&self, item_type: &str) -> bool {
210 self.wip_exempt_types
211 .iter()
212 .any(|t| t.eq_ignore_ascii_case(item_type))
213 }
214
215 pub fn is_valid_state(&self, state: &str) -> bool {
217 self.states.iter().any(|s| s == state)
218 }
219
220 pub fn is_valid_state_for_type(&self, state: &str, item_type: &str) -> bool {
223 if let Some(type_states) = self.type_states.get(item_type) {
224 type_states.iter().any(|s| s == state)
225 } else {
226 self.is_valid_state(state)
227 }
228 }
229
230 pub fn states_for_type(&self, item_type: &str) -> &[String] {
233 if let Some(type_states) = self.type_states.get(item_type) {
234 type_states
235 } else {
236 &self.states
237 }
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 const SAMPLE_CONFIG: &str = r#"
246version: "2.0"
247
248boards:
249 - name: development
250 preset: nautical
251 path: kanban-work/
252 scan_paths:
253 - "kanban-work/expeditions/"
254 - "kanban-work/voyages/"
255 - "kanban-work/chores/"
256 ignore:
257 - "**/archive/**"
258 wip_exempt_types:
259 - voyage
260 wip_limits:
261 provisioning: 50
262 underway: 4
263 approaching: 3
264 states:
265 - backlog
266 - planning
267 - ready
268 - in_progress
269 - review
270 - done
271
272 - name: research
273 preset: hdd
274 path: research/
275 scan_paths:
276 - "research/hypotheses/"
277 - "research/experiments/"
278 - "research/papers/"
279 wip_limits:
280 active: 5
281 states:
282 - draft
283 - active
284 - complete
285 - abandoned
286 phases:
287 - discovery
288 - design
289 - execution
290 - analysis
291 - writing
292
293namespace: "https://nusy.dev/"
294default_board: development
295
296relationships:
297 implements:
298 from_board: development
299 to_board: research
300 predicate: "expr:implements"
301 spawns:
302 from_board: research
303 to_board: development
304 predicate: "expr:spawns"
305
306critical_path:
307 enabled: true
308 boost_priority: true
309"#;
310
311 #[test]
312 fn test_parse_config() {
313 let config = ConfigFile::from_yaml(SAMPLE_CONFIG).unwrap();
314 assert_eq!(config.version, "2.0");
315 assert_eq!(config.boards.len(), 2);
316 assert_eq!(config.default_board.as_deref(), Some("development"));
317 }
318
319 #[test]
320 fn test_board_lookup() {
321 let config = ConfigFile::from_yaml(SAMPLE_CONFIG).unwrap();
322 let dev = config.board("development").unwrap();
323 assert_eq!(dev.preset, "nautical");
324 assert_eq!(dev.states.len(), 6);
325
326 let research = config.board("research").unwrap();
327 assert_eq!(research.preset, "hdd");
328 assert_eq!(research.states.len(), 4);
329 assert_eq!(research.phases.len(), 5);
330 }
331
332 #[test]
333 fn test_default_board() {
334 let config = ConfigFile::from_yaml(SAMPLE_CONFIG).unwrap();
335 let default = config.default_board().unwrap();
336 assert_eq!(default.name, "development");
337 }
338
339 #[test]
340 fn test_board_not_found() {
341 let config = ConfigFile::from_yaml(SAMPLE_CONFIG).unwrap();
342 assert!(config.board("nonexistent").is_err());
343 }
344
345 #[test]
346 fn test_wip_limits() {
347 let config = ConfigFile::from_yaml(SAMPLE_CONFIG).unwrap();
348 let dev = config.board("development").unwrap();
349 assert_eq!(dev.wip_limit("underway"), Some(4));
350 assert_eq!(dev.wip_limit("approaching"), Some(3));
351 assert_eq!(dev.wip_limit("nonexistent"), None);
352 }
353
354 #[test]
355 fn test_wip_exempt() {
356 let config = ConfigFile::from_yaml(SAMPLE_CONFIG).unwrap();
357 let dev = config.board("development").unwrap();
358 assert!(dev.is_wip_exempt("voyage"));
359 assert!(dev.is_wip_exempt("Voyage")); assert!(!dev.is_wip_exempt("expedition"));
361 }
362
363 #[test]
364 fn test_valid_states() {
365 let config = ConfigFile::from_yaml(SAMPLE_CONFIG).unwrap();
366 let dev = config.board("development").unwrap();
367 assert!(dev.is_valid_state("backlog"));
368 assert!(dev.is_valid_state("in_progress"));
369 assert!(!dev.is_valid_state("archived"));
370 }
371
372 #[test]
373 fn test_relationships() {
374 let config = ConfigFile::from_yaml(SAMPLE_CONFIG).unwrap();
375 let rels = config.relationships.as_ref().unwrap();
376 assert_eq!(rels.len(), 2);
377 let implements = &rels["implements"];
378 assert_eq!(implements.from_board, "development");
379 assert_eq!(implements.to_board, "research");
380 }
381
382 #[test]
383 fn test_critical_path() {
384 let config = ConfigFile::from_yaml(SAMPLE_CONFIG).unwrap();
385 let cp = config.critical_path.as_ref().unwrap();
386 assert!(cp.enabled);
387 assert!(cp.boost_priority);
388 }
389
390 #[test]
391 fn test_loads_real_config() {
392 let config_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
393 .join("../../.yurtle-kanban/config.yaml");
394 if config_path.exists() {
395 let config = ConfigFile::from_path(&config_path).unwrap();
396 assert_eq!(config.boards.len(), 2);
397 assert!(config.board("development").is_ok());
398 assert!(config.board("research").is_ok());
399 }
400 }
401
402 #[test]
403 fn test_type_states() {
404 let config = ConfigFile::from_yaml(SAMPLE_CONFIG_WITH_TYPE_STATES).unwrap();
405 let research = config.board("research").unwrap();
406
407 assert!(research.is_valid_state("draft"));
409 assert!(research.is_valid_state("active"));
410
411 assert!(research.is_valid_state_for_type("planned", "experiment"));
413 assert!(research.is_valid_state_for_type("running", "experiment"));
414 assert!(!research.is_valid_state_for_type("outline", "experiment")); assert!(research.is_valid_state_for_type("outline", "paper"));
417 assert!(research.is_valid_state_for_type("writing", "paper"));
418 assert!(!research.is_valid_state_for_type("running", "paper")); assert!(research.is_valid_state_for_type("captured", "idea"));
421 assert!(research.is_valid_state_for_type("formalized", "idea"));
422
423 assert!(research.is_valid_state_for_type("retired", "hypothesis"));
425 assert!(research.is_valid_state_for_type("retired", "measure"));
426 assert!(!research.is_valid_state_for_type("complete", "hypothesis")); assert!(!research.is_valid_state_for_type("complete", "measure")); assert!(research.is_valid_state_for_type("draft", "unknown_type"));
431 assert!(research.is_valid_state_for_type("active", "unknown_type"));
432 }
433
434 #[test]
435 fn test_states_for_type() {
436 let config = ConfigFile::from_yaml(SAMPLE_CONFIG_WITH_TYPE_STATES).unwrap();
437 let research = config.board("research").unwrap();
438
439 let exp_states = research.states_for_type("experiment");
440 assert_eq!(exp_states, &["planned", "running", "complete", "abandoned"]);
441
442 let hyp_states = research.states_for_type("hypothesis");
443 assert_eq!(hyp_states, &["draft", "active", "retired"]);
444
445 let unknown_states = research.states_for_type("widget");
447 assert_eq!(unknown_states, research.states.as_slice());
448 }
449
450 const SAMPLE_CONFIG_WITH_TYPE_STATES: &str = r#"
451version: "2.0"
452
453boards:
454 - name: development
455 preset: nautical
456 path: kanban-work/
457 scan_paths:
458 - "kanban-work/expeditions/"
459 states:
460 - backlog
461 - in_progress
462 - review
463 - done
464
465 - name: research
466 preset: hdd
467 path: research/
468 scan_paths:
469 - "research/"
470 states:
471 - draft
472 - captured
473 - planned
474 - outline
475 - active
476 - writing
477 - running
478 - review
479 - formalized
480 - complete
481 - abandoned
482 - retired
483 type_states:
484 hypothesis:
485 - draft
486 - active
487 - retired
488 measure:
489 - draft
490 - active
491 - retired
492 paper:
493 - draft
494 - outline
495 - writing
496 - review
497 - complete
498 - abandoned
499 experiment:
500 - planned
501 - running
502 - complete
503 - abandoned
504 literature:
505 - draft
506 - active
507 - complete
508 idea:
509 - captured
510 - formalized
511 - abandoned
512"#;
513
514 #[test]
515 fn test_invalid_config_no_boards() {
516 let yaml = r#"
517version: "1.0"
518boards: []
519"#;
520 assert!(ConfigFile::from_yaml(yaml).is_err());
521 }
522
523 #[test]
524 fn test_invalid_config_no_states() {
525 let yaml = r#"
526version: "1.0"
527boards:
528 - name: test
529 preset: nautical
530 path: test/
531 scan_paths: ["test/"]
532 states: []
533"#;
534 assert!(ConfigFile::from_yaml(yaml).is_err());
535 }
536}