Skip to main content

deciduous/
config.rs

1//! Configuration file support for deciduous
2//!
3//! Reads from .deciduous/config.toml
4
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8/// Configuration structure
9#[derive(Debug, Deserialize, Serialize, Default, Clone)]
10pub struct Config {
11    /// Branch settings
12    #[serde(default)]
13    pub branch: BranchConfig,
14
15    /// GitHub settings for external repository references
16    #[serde(default)]
17    pub github: GithubConfig,
18
19    /// Claude Code hooks configuration
20    #[serde(default)]
21    pub hooks: HooksConfig,
22
23    /// Claude Code commands configuration
24    #[serde(default)]
25    pub commands: CommandsConfig,
26
27    /// Claude Code skills configuration
28    #[serde(default)]
29    pub skills: SkillsConfig,
30}
31
32/// Hooks configuration for Claude Code integration
33#[derive(Debug, Deserialize, Serialize, Clone)]
34pub struct HooksConfig {
35    /// Whether hooks are enabled
36    #[serde(default = "default_true")]
37    pub enabled: bool,
38
39    /// Pre-tool-use hooks (run before Edit/Write/etc)
40    #[serde(default = "default_pre_tool_use_hooks")]
41    pub pre_tool_use: Vec<Hook>,
42
43    /// Post-tool-use hooks (run after Bash/etc)
44    #[serde(default = "default_post_tool_use_hooks")]
45    pub post_tool_use: Vec<Hook>,
46}
47
48fn default_pre_tool_use_hooks() -> Vec<Hook> {
49    vec![Hook::default_require_action_node()]
50}
51
52fn default_post_tool_use_hooks() -> Vec<Hook> {
53    vec![Hook::default_post_commit_reminder()]
54}
55
56impl Default for HooksConfig {
57    fn default() -> Self {
58        Self {
59            enabled: true,
60            pre_tool_use: default_pre_tool_use_hooks(),
61            post_tool_use: default_post_tool_use_hooks(),
62        }
63    }
64}
65
66/// A single hook definition
67#[derive(Debug, Deserialize, Serialize, Clone)]
68pub struct Hook {
69    /// Hook name (used for filename)
70    pub name: String,
71
72    /// Description of what this hook does
73    #[serde(default)]
74    pub description: String,
75
76    /// Regex pattern for matching tools (e.g., "Edit|Write" or "Bash")
77    pub matcher: String,
78
79    /// Whether this hook is enabled
80    #[serde(default = "default_true")]
81    pub enabled: bool,
82
83    /// Script content (if inline) - mutually exclusive with script_path
84    #[serde(default)]
85    pub script: Option<String>,
86
87    /// Path to script file (relative to .deciduous/) - mutually exclusive with script
88    #[serde(default)]
89    pub script_path: Option<String>,
90}
91
92impl Hook {
93    /// Default pre-edit hook that requires an action node
94    pub fn default_require_action_node() -> Self {
95        Self {
96            name: "require-action-node".to_string(),
97            description: "Blocks Edit/Write if no recent action/goal node exists".to_string(),
98            matcher: "Edit|Write".to_string(),
99            enabled: true,
100            script: None, // Uses built-in template
101            script_path: None,
102        }
103    }
104
105    /// Default post-commit hook that reminds to link commits
106    pub fn default_post_commit_reminder() -> Self {
107        Self {
108            name: "post-commit-reminder".to_string(),
109            description: "Reminds to link commits to the decision graph".to_string(),
110            matcher: "Bash".to_string(),
111            enabled: true,
112            script: None, // Uses built-in template
113            script_path: None,
114        }
115    }
116
117    /// Check if this hook uses a built-in script
118    pub fn uses_builtin(&self) -> bool {
119        self.script.is_none() && self.script_path.is_none()
120    }
121}
122
123/// Commands configuration for Claude Code slash commands
124#[derive(Debug, Deserialize, Serialize, Clone)]
125pub struct CommandsConfig {
126    /// Whether to install default commands
127    #[serde(default = "default_true")]
128    pub install_defaults: bool,
129
130    /// Custom commands to install (paths relative to .deciduous/commands/)
131    #[serde(default)]
132    pub custom: Vec<String>,
133}
134
135impl Default for CommandsConfig {
136    fn default() -> Self {
137        Self {
138            install_defaults: true,
139            custom: vec![],
140        }
141    }
142}
143
144/// Skills configuration for Claude Code skills
145#[derive(Debug, Deserialize, Serialize, Clone)]
146pub struct SkillsConfig {
147    /// Whether to install default skills
148    #[serde(default = "default_true")]
149    pub install_defaults: bool,
150
151    /// Custom skills to install (paths relative to .deciduous/skills/)
152    #[serde(default)]
153    pub custom: Vec<String>,
154}
155
156impl Default for SkillsConfig {
157    fn default() -> Self {
158        Self {
159            install_defaults: true,
160            custom: vec![],
161        }
162    }
163}
164
165/// GitHub-related configuration for commit/PR links
166#[derive(Debug, Deserialize, Serialize, Default, Clone)]
167pub struct GithubConfig {
168    /// External repository for commit links (e.g., "phoenixframework/phoenix")
169    /// When set, commit hashes in nodes will link to this repo instead of the local one.
170    /// Format: "owner/repo"
171    #[serde(default)]
172    pub commit_repo: Option<String>,
173}
174
175/// Branch-related configuration
176#[derive(Debug, Deserialize, Serialize, Clone)]
177pub struct BranchConfig {
178    /// Main/default branch names (nodes on these branches won't trigger special grouping)
179    /// Default: ["main", "master"]
180    #[serde(default = "default_main_branches")]
181    pub main_branches: Vec<String>,
182
183    /// Whether to auto-detect and store branch on node creation
184    /// Default: true
185    #[serde(default = "default_true")]
186    pub auto_detect: bool,
187}
188
189fn default_main_branches() -> Vec<String> {
190    vec!["main".to_string(), "master".to_string()]
191}
192
193fn default_true() -> bool {
194    true
195}
196
197impl Default for BranchConfig {
198    fn default() -> Self {
199        Self {
200            main_branches: default_main_branches(),
201            auto_detect: true,
202        }
203    }
204}
205
206impl Config {
207    /// Load config from .deciduous/config.toml
208    /// Returns default config if file doesn't exist
209    pub fn load() -> Self {
210        if let Some(path) = Self::find_config_path() {
211            if let Ok(contents) = std::fs::read_to_string(&path) {
212                if let Ok(config) = toml::from_str(&contents) {
213                    return config;
214                }
215            }
216        }
217        Self::default()
218    }
219
220    /// Find config.toml by walking up directory tree
221    fn find_config_path() -> Option<PathBuf> {
222        Self::find_deciduous_dir().map(|d| d.join("config.toml"))
223    }
224
225    /// Find .deciduous directory by walking up directory tree
226    pub fn find_deciduous_dir() -> Option<PathBuf> {
227        let current_dir = std::env::current_dir().ok()?;
228        let mut dir = current_dir.as_path();
229
230        loop {
231            let deciduous_dir = dir.join(".deciduous");
232            if deciduous_dir.exists() {
233                return Some(deciduous_dir);
234            }
235
236            match dir.parent() {
237                Some(parent) => dir = parent,
238                None => break,
239            }
240        }
241        None
242    }
243
244    /// Find the project root (parent of .deciduous)
245    pub fn find_project_root() -> Option<PathBuf> {
246        Self::find_deciduous_dir().and_then(|d| d.parent().map(|p| p.to_path_buf()))
247    }
248
249    /// Check if a branch is considered a "main" branch
250    pub fn is_main_branch(&self, branch: &str) -> bool {
251        self.branch.main_branches.iter().any(|b| b == branch)
252    }
253
254    /// Get all enabled pre-tool-use hooks
255    pub fn enabled_pre_hooks(&self) -> Vec<&Hook> {
256        if !self.hooks.enabled {
257            return vec![];
258        }
259        self.hooks
260            .pre_tool_use
261            .iter()
262            .filter(|h| h.enabled)
263            .collect()
264    }
265
266    /// Get all enabled post-tool-use hooks
267    pub fn enabled_post_hooks(&self) -> Vec<&Hook> {
268        if !self.hooks.enabled {
269            return vec![];
270        }
271        self.hooks
272            .post_tool_use
273            .iter()
274            .filter(|h| h.enabled)
275            .collect()
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_default_config() {
285        let config = Config::default();
286        assert!(config.is_main_branch("main"));
287        assert!(config.is_main_branch("master"));
288        assert!(!config.is_main_branch("feature-x"));
289        assert!(config.branch.auto_detect);
290    }
291
292    #[test]
293    fn test_parse_config() {
294        let toml = r#"
295[branch]
296main_branches = ["main", "master", "develop"]
297auto_detect = true
298"#;
299        let config: Config = toml::from_str(toml).unwrap();
300        assert!(config.is_main_branch("develop"));
301        assert!(!config.is_main_branch("feature-x"));
302    }
303
304    #[test]
305    fn test_default_hooks() {
306        let config = Config::default();
307        assert!(config.hooks.enabled);
308
309        // Should have default pre-tool-use hook
310        assert_eq!(config.hooks.pre_tool_use.len(), 1);
311        assert_eq!(config.hooks.pre_tool_use[0].name, "require-action-node");
312        assert_eq!(config.hooks.pre_tool_use[0].matcher, "Edit|Write");
313        assert!(config.hooks.pre_tool_use[0].enabled);
314
315        // Should have default post-tool-use hook
316        assert_eq!(config.hooks.post_tool_use.len(), 1);
317        assert_eq!(config.hooks.post_tool_use[0].name, "post-commit-reminder");
318        assert_eq!(config.hooks.post_tool_use[0].matcher, "Bash");
319        assert!(config.hooks.post_tool_use[0].enabled);
320    }
321
322    #[test]
323    fn test_parse_hooks_config() {
324        let toml = r#"
325[hooks]
326enabled = true
327
328[[hooks.pre_tool_use]]
329name = "my-custom-hook"
330description = "A custom pre-edit hook"
331matcher = "Edit"
332enabled = true
333script = "echo 'hello'"
334
335[[hooks.post_tool_use]]
336name = "my-post-hook"
337description = "A custom post hook"
338matcher = "Bash"
339enabled = false
340"#;
341        let config: Config = toml::from_str(toml).unwrap();
342        assert!(config.hooks.enabled);
343        assert_eq!(config.hooks.pre_tool_use.len(), 1);
344        assert_eq!(config.hooks.pre_tool_use[0].name, "my-custom-hook");
345        assert_eq!(
346            config.hooks.pre_tool_use[0].script,
347            Some("echo 'hello'".to_string())
348        );
349
350        // Check enabled_post_hooks filters correctly
351        assert_eq!(config.enabled_post_hooks().len(), 0);
352    }
353
354    #[test]
355    fn test_hooks_disabled() {
356        let toml = r#"
357[hooks]
358enabled = false
359"#;
360        let config: Config = toml::from_str(toml).unwrap();
361        assert!(!config.hooks.enabled);
362        // Even with default hooks, enabled_*_hooks should return empty
363        assert_eq!(config.enabled_pre_hooks().len(), 0);
364        assert_eq!(config.enabled_post_hooks().len(), 0);
365    }
366
367    #[test]
368    fn test_hook_uses_builtin() {
369        let hook = Hook::default_require_action_node();
370        assert!(hook.uses_builtin());
371
372        let custom_hook = Hook {
373            name: "custom".to_string(),
374            description: "".to_string(),
375            matcher: "Edit".to_string(),
376            enabled: true,
377            script: Some("echo hi".to_string()),
378            script_path: None,
379        };
380        assert!(!custom_hook.uses_builtin());
381    }
382}