Skip to main content

arrow_kanban/
config.rs

1//! Board configuration — parse `.yurtle-kanban/config.yaml`.
2//!
3//! Supports two boards (development/nautical, research/hdd) with
4//! configurable item types, state graphs, WIP limits, and scan paths.
5
6/// Default config YAML for `nusy-kanban init`.
7pub 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/// Errors from config parsing.
84#[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/// Top-level config file structure.
102#[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/// A single board's configuration.
113#[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    /// Per-type state overrides. If a type has an entry here, its valid states
129    /// are these instead of the board-level `states`. This enables research
130    /// items (measures, hypotheses, papers, etc.) to have distinct lifecycles.
131    #[serde(default)]
132    pub type_states: HashMap<String, Vec<String>>,
133}
134
135/// Cross-board relationship config.
136#[derive(Debug, Clone, Deserialize)]
137pub struct RelationshipConfig {
138    pub from_board: String,
139    pub to_board: String,
140    pub predicate: String,
141}
142
143/// Critical path tracking config.
144#[derive(Debug, Clone, Deserialize)]
145pub struct CriticalPathConfig {
146    pub enabled: bool,
147    #[serde(default)]
148    pub boost_priority: bool,
149}
150
151impl ConfigFile {
152    /// Load config from a YAML file path.
153    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    /// Parse config from a YAML string.
159    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    /// Get a board config by name.
166    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    /// Get the default board config.
174    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    /// Validate the config for internal consistency.
180    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    /// Get the WIP limit for a given state category, or None if unlimited.
204    pub fn wip_limit(&self, category: &str) -> Option<u32> {
205        self.wip_limits.get(category).copied()
206    }
207
208    /// Check if a type is WIP-exempt (e.g., voyages).
209    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    /// Check if a state is valid for this board (board-level states).
216    pub fn is_valid_state(&self, state: &str) -> bool {
217        self.states.iter().any(|s| s == state)
218    }
219
220    /// Check if a state is valid for a specific item type on this board.
221    /// Falls back to board-level states if no type-specific override exists.
222    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    /// Get the valid states for a specific item type.
231    /// Returns type-specific states if defined, otherwise board-level states.
232    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")); // case insensitive
360        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        // Board-level states
408        assert!(research.is_valid_state("draft"));
409        assert!(research.is_valid_state("active"));
410
411        // Type-specific states
412        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")); // outline is paper-only
415
416        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")); // running is experiment-only
419
420        assert!(research.is_valid_state_for_type("captured", "idea"));
421        assert!(research.is_valid_state_for_type("formalized", "idea"));
422
423        // Hypothesis and measure: draft → active → retired
424        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")); // hypotheses don't "complete"
427        assert!(!research.is_valid_state_for_type("complete", "measure")); // measures don't "complete"
428
429        // Fallback: unknown type uses board-level states
430        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        // Unknown type falls back to board states
446        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}