1use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8#[derive(Debug, Deserialize, Serialize, Default, Clone)]
10pub struct Config {
11 #[serde(default)]
13 pub branch: BranchConfig,
14
15 #[serde(default)]
17 pub github: GithubConfig,
18
19 #[serde(default)]
21 pub hooks: HooksConfig,
22
23 #[serde(default)]
25 pub commands: CommandsConfig,
26
27 #[serde(default)]
29 pub skills: SkillsConfig,
30}
31
32#[derive(Debug, Deserialize, Serialize, Clone)]
34pub struct HooksConfig {
35 #[serde(default = "default_true")]
37 pub enabled: bool,
38
39 #[serde(default = "default_pre_tool_use_hooks")]
41 pub pre_tool_use: Vec<Hook>,
42
43 #[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#[derive(Debug, Deserialize, Serialize, Clone)]
68pub struct Hook {
69 pub name: String,
71
72 #[serde(default)]
74 pub description: String,
75
76 pub matcher: String,
78
79 #[serde(default = "default_true")]
81 pub enabled: bool,
82
83 #[serde(default)]
85 pub script: Option<String>,
86
87 #[serde(default)]
89 pub script_path: Option<String>,
90}
91
92impl Hook {
93 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, script_path: None,
102 }
103 }
104
105 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, script_path: None,
114 }
115 }
116
117 pub fn uses_builtin(&self) -> bool {
119 self.script.is_none() && self.script_path.is_none()
120 }
121}
122
123#[derive(Debug, Deserialize, Serialize, Clone)]
125pub struct CommandsConfig {
126 #[serde(default = "default_true")]
128 pub install_defaults: bool,
129
130 #[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#[derive(Debug, Deserialize, Serialize, Clone)]
146pub struct SkillsConfig {
147 #[serde(default = "default_true")]
149 pub install_defaults: bool,
150
151 #[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#[derive(Debug, Deserialize, Serialize, Default, Clone)]
167pub struct GithubConfig {
168 #[serde(default)]
172 pub commit_repo: Option<String>,
173}
174
175#[derive(Debug, Deserialize, Serialize, Clone)]
177pub struct BranchConfig {
178 #[serde(default = "default_main_branches")]
181 pub main_branches: Vec<String>,
182
183 #[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 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 fn find_config_path() -> Option<PathBuf> {
222 Self::find_deciduous_dir().map(|d| d.join("config.toml"))
223 }
224
225 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 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 pub fn is_main_branch(&self, branch: &str) -> bool {
251 self.branch.main_branches.iter().any(|b| b == branch)
252 }
253
254 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 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 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 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 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 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}