use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct Config {
#[serde(default)]
pub branch: BranchConfig,
#[serde(default)]
pub github: GithubConfig,
#[serde(default)]
pub hooks: HooksConfig,
#[serde(default)]
pub commands: CommandsConfig,
#[serde(default)]
pub skills: SkillsConfig,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct HooksConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_pre_tool_use_hooks")]
pub pre_tool_use: Vec<Hook>,
#[serde(default = "default_post_tool_use_hooks")]
pub post_tool_use: Vec<Hook>,
}
fn default_pre_tool_use_hooks() -> Vec<Hook> {
vec![Hook::default_require_action_node()]
}
fn default_post_tool_use_hooks() -> Vec<Hook> {
vec![Hook::default_post_commit_reminder()]
}
impl Default for HooksConfig {
fn default() -> Self {
Self {
enabled: true,
pre_tool_use: default_pre_tool_use_hooks(),
post_tool_use: default_post_tool_use_hooks(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Hook {
pub name: String,
#[serde(default)]
pub description: String,
pub matcher: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub script: Option<String>,
#[serde(default)]
pub script_path: Option<String>,
}
impl Hook {
pub fn default_require_action_node() -> Self {
Self {
name: "require-action-node".to_string(),
description: "Blocks Edit/Write if no recent action/goal node exists".to_string(),
matcher: "Edit|Write".to_string(),
enabled: true,
script: None, script_path: None,
}
}
pub fn default_post_commit_reminder() -> Self {
Self {
name: "post-commit-reminder".to_string(),
description: "Reminds to link commits to the decision graph".to_string(),
matcher: "Bash".to_string(),
enabled: true,
script: None, script_path: None,
}
}
pub fn uses_builtin(&self) -> bool {
self.script.is_none() && self.script_path.is_none()
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct CommandsConfig {
#[serde(default = "default_true")]
pub install_defaults: bool,
#[serde(default)]
pub custom: Vec<String>,
}
impl Default for CommandsConfig {
fn default() -> Self {
Self {
install_defaults: true,
custom: vec![],
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct SkillsConfig {
#[serde(default = "default_true")]
pub install_defaults: bool,
#[serde(default)]
pub custom: Vec<String>,
}
impl Default for SkillsConfig {
fn default() -> Self {
Self {
install_defaults: true,
custom: vec![],
}
}
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct GithubConfig {
#[serde(default)]
pub commit_repo: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct BranchConfig {
#[serde(default = "default_main_branches")]
pub main_branches: Vec<String>,
#[serde(default = "default_true")]
pub auto_detect: bool,
}
fn default_main_branches() -> Vec<String> {
vec!["main".to_string(), "master".to_string()]
}
fn default_true() -> bool {
true
}
impl Default for BranchConfig {
fn default() -> Self {
Self {
main_branches: default_main_branches(),
auto_detect: true,
}
}
}
impl Config {
pub fn load() -> Self {
if let Some(path) = Self::find_config_path() {
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(config) = toml::from_str(&contents) {
return config;
}
}
}
Self::default()
}
fn find_config_path() -> Option<PathBuf> {
Self::find_deciduous_dir().map(|d| d.join("config.toml"))
}
pub fn find_deciduous_dir() -> Option<PathBuf> {
let current_dir = std::env::current_dir().ok()?;
let mut dir = current_dir.as_path();
loop {
let deciduous_dir = dir.join(".deciduous");
if deciduous_dir.exists() {
return Some(deciduous_dir);
}
match dir.parent() {
Some(parent) => dir = parent,
None => break,
}
}
None
}
pub fn find_project_root() -> Option<PathBuf> {
Self::find_deciduous_dir().and_then(|d| d.parent().map(|p| p.to_path_buf()))
}
pub fn is_main_branch(&self, branch: &str) -> bool {
self.branch.main_branches.iter().any(|b| b == branch)
}
pub fn enabled_pre_hooks(&self) -> Vec<&Hook> {
if !self.hooks.enabled {
return vec![];
}
self.hooks
.pre_tool_use
.iter()
.filter(|h| h.enabled)
.collect()
}
pub fn enabled_post_hooks(&self) -> Vec<&Hook> {
if !self.hooks.enabled {
return vec![];
}
self.hooks
.post_tool_use
.iter()
.filter(|h| h.enabled)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.is_main_branch("main"));
assert!(config.is_main_branch("master"));
assert!(!config.is_main_branch("feature-x"));
assert!(config.branch.auto_detect);
}
#[test]
fn test_parse_config() {
let toml = r#"
[branch]
main_branches = ["main", "master", "develop"]
auto_detect = true
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.is_main_branch("develop"));
assert!(!config.is_main_branch("feature-x"));
}
#[test]
fn test_default_hooks() {
let config = Config::default();
assert!(config.hooks.enabled);
assert_eq!(config.hooks.pre_tool_use.len(), 1);
assert_eq!(config.hooks.pre_tool_use[0].name, "require-action-node");
assert_eq!(config.hooks.pre_tool_use[0].matcher, "Edit|Write");
assert!(config.hooks.pre_tool_use[0].enabled);
assert_eq!(config.hooks.post_tool_use.len(), 1);
assert_eq!(config.hooks.post_tool_use[0].name, "post-commit-reminder");
assert_eq!(config.hooks.post_tool_use[0].matcher, "Bash");
assert!(config.hooks.post_tool_use[0].enabled);
}
#[test]
fn test_parse_hooks_config() {
let toml = r#"
[hooks]
enabled = true
[[hooks.pre_tool_use]]
name = "my-custom-hook"
description = "A custom pre-edit hook"
matcher = "Edit"
enabled = true
script = "echo 'hello'"
[[hooks.post_tool_use]]
name = "my-post-hook"
description = "A custom post hook"
matcher = "Bash"
enabled = false
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.hooks.enabled);
assert_eq!(config.hooks.pre_tool_use.len(), 1);
assert_eq!(config.hooks.pre_tool_use[0].name, "my-custom-hook");
assert_eq!(
config.hooks.pre_tool_use[0].script,
Some("echo 'hello'".to_string())
);
assert_eq!(config.enabled_post_hooks().len(), 0);
}
#[test]
fn test_hooks_disabled() {
let toml = r#"
[hooks]
enabled = false
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(!config.hooks.enabled);
assert_eq!(config.enabled_pre_hooks().len(), 0);
assert_eq!(config.enabled_post_hooks().len(), 0);
}
#[test]
fn test_hook_uses_builtin() {
let hook = Hook::default_require_action_node();
assert!(hook.uses_builtin());
let custom_hook = Hook {
name: "custom".to_string(),
description: "".to_string(),
matcher: "Edit".to_string(),
enabled: true,
script: Some("echo hi".to_string()),
script_path: None,
};
assert!(!custom_hook.uses_builtin());
}
}