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,
#[serde(default)]
pub remote: crate::remote::RemoteConfig,
}
#[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()
}
pub fn save_remote(&self) -> std::io::Result<PathBuf> {
let dir = Self::find_deciduous_dir().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no .deciduous directory found; run `deciduous init` first",
)
})?;
let path = dir.join("config.toml");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc = existing.parse::<toml_edit::DocumentMut>().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("config.toml is not valid TOML: {e}"),
)
})?;
let table = doc["remote"].or_insert(toml_edit::table());
if let Some(url) = &self.remote.url {
table["url"] = toml_edit::value(url.clone());
}
match &self.remote.workspace {
Some(ws) => table["workspace"] = toml_edit::value(ws.clone()),
None => {
if let Some(t) = table.as_table_mut() {
t.remove("workspace");
}
}
}
std::fs::write(&path, doc.to_string())?;
Ok(path)
}
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());
}
}
#[cfg(test)]
mod remote_config_tests {
use super::*;
#[test]
fn writing_the_remote_table_keeps_comments_and_formatting() {
let dir = std::env::temp_dir().join(format!("deciduous-cfg-{}", std::process::id()));
let deciduous = dir.join(".deciduous");
std::fs::create_dir_all(&deciduous).unwrap();
let path = deciduous.join("config.toml");
let original = "# Deciduous Configuration\n\
# This file controls branch detection\n\
\n\
[branch]\n\
main_branches = [\"main\", \"master\"]\n\
auto_detect = true\n";
std::fs::write(&path, original).unwrap();
let mut doc = std::fs::read_to_string(&path)
.unwrap()
.parse::<toml_edit::DocumentMut>()
.unwrap();
doc["remote"].or_insert(toml_edit::table())["url"] =
toml_edit::value("https://example.com/mcp");
std::fs::write(&path, doc.to_string()).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(
after.contains("# Deciduous Configuration"),
"comment dropped:\n{after}"
);
assert!(after.contains("# This file controls branch detection"));
assert!(
after.contains("main_branches = [\"main\", \"master\"]"),
"array was reflowed:\n{after}"
);
assert!(after.contains("[remote]"));
assert!(after.contains("https://example.com/mcp"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_config_with_no_remote_is_not_configured() {
assert!(!Config::default().remote.is_configured());
}
}