1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3use crate::error::{GitGardenerError, Result};
4
5#[derive(Debug, Serialize, Deserialize, Clone)]
6pub struct Config {
7 #[serde(default = "default_version")]
8 pub version: String,
9
10 #[serde(default)]
11 pub defaults: DefaultConfig,
12
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub hooks: Option<Hooks>,
15}
16
17#[derive(Debug, Serialize, Deserialize, Clone, Default)]
18pub struct DefaultConfig {
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub root_dir: Option<String>,
21}
22
23#[derive(Debug, Serialize, Deserialize, Clone)]
24pub struct Hooks {
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub post_create: Option<Vec<Hook>>,
27}
28
29#[derive(Debug, Serialize, Deserialize, Clone)]
30pub struct Hook {
31 #[serde(rename = "type")]
32 pub hook_type: HookType,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub from: Option<String>,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub to: Option<String>,
39
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub command: Option<String>,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub env: Option<std::collections::HashMap<String, String>>,
45}
46
47#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
48#[serde(rename_all = "lowercase")]
49pub enum HookType {
50 Copy,
51 Command,
52}
53
54impl Default for Config {
55 fn default() -> Self {
56 Self {
57 version: default_version(),
58 defaults: DefaultConfig::default(),
59 hooks: None,
60 }
61 }
62}
63
64fn default_version() -> String {
65 "1.0".to_string()
66}
67
68impl Config {
69 pub fn load_from_file(path: &Path) -> Result<Self> {
70 if !path.exists() {
71 return Err(GitGardenerError::ConfigNotFound {
72 path: path.display().to_string(),
73 });
74 }
75
76 let contents = std::fs::read_to_string(path)?;
77 let config: Config = serde_yaml::from_str(&contents)
78 .map_err(|e| GitGardenerError::Custom(format!("Failed to parse YAML config: {}", e)))?;
79 Ok(config)
80 }
81
82 pub fn save_to_file(&self, path: &Path) -> Result<()> {
83 let contents = serde_yaml::to_string(self)
84 .map_err(|e| GitGardenerError::Custom(format!("Failed to serialize config: {}", e)))?;
85
86 if let Some(parent) = path.parent() {
87 std::fs::create_dir_all(parent)?;
88 }
89
90 std::fs::write(path, contents)?;
91 Ok(())
92 }
93
94 pub fn get_config_path(repo_path: &Path) -> PathBuf {
95 repo_path.join(".gardener.yml")
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use tempfile::tempdir;
103
104 #[test]
105 fn test_default_config() {
106 let config = Config::default();
107 assert_eq!(config.version, "1.0");
108 assert!(config.defaults.root_dir.is_none());
109 assert!(config.hooks.is_none());
110 }
111
112 #[test]
113 fn test_save_and_load_config() {
114 let temp_dir = tempdir().unwrap();
115 let config_path = temp_dir.path().join("test_config.yml");
116
117 let config = Config::default();
118 config.save_to_file(&config_path).unwrap();
119
120 let loaded_config = Config::load_from_file(&config_path).unwrap();
121 assert_eq!(loaded_config.version, config.version);
122 }
123
124 #[test]
125 fn test_config_load_fails_for_nonexistent_file() {
126 let temp_dir = tempdir().unwrap();
128 let config_path = temp_dir.path().join("nonexistent.yml");
129
130 let result = Config::load_from_file(&config_path);
131
132 assert!(result.is_err());
133 assert!(matches!(
134 result.unwrap_err(),
135 GitGardenerError::ConfigNotFound { .. }
136 ));
137 }
138
139 #[test]
140 fn test_config_with_hooks_serialization() {
141 use std::collections::HashMap;
143
144 let mut env = HashMap::new();
145 env.insert("NODE_ENV".to_string(), "development".to_string());
146
147 let hook = Hook {
148 hook_type: HookType::Command,
149 from: None,
150 to: None,
151 command: Some("npm install".to_string()),
152 env: Some(env),
153 };
154
155 let hooks = Hooks {
156 post_create: Some(vec![hook]),
157 };
158
159 let config = Config {
160 version: "1.0".to_string(),
161 defaults: DefaultConfig {
162 root_dir: Some(".gardener".to_string()),
163 },
164 hooks: Some(hooks),
165 };
166
167 let temp_dir = tempdir().unwrap();
168 let config_path = temp_dir.path().join("hooks_config.yml");
169
170 config.save_to_file(&config_path).unwrap();
171 let loaded_config = Config::load_from_file(&config_path).unwrap();
172
173 assert_eq!(loaded_config.version, config.version);
174 assert!(loaded_config.hooks.is_some());
175
176 let loaded_hooks = loaded_config.hooks.unwrap();
177 assert!(loaded_hooks.post_create.is_some());
178
179 let hooks_vec = loaded_hooks.post_create.unwrap();
180 assert_eq!(hooks_vec.len(), 1);
181 assert_eq!(hooks_vec[0].hook_type, HookType::Command);
182 assert_eq!(hooks_vec[0].command, Some("npm install".to_string()));
183 }
184
185 #[test]
186 fn test_config_with_copy_hook_serialization() {
187 let hook = Hook {
189 hook_type: HookType::Copy,
190 from: Some("README.md".to_string()),
191 to: Some("README.md".to_string()),
192 command: None,
193 env: None,
194 };
195
196 let hooks = Hooks {
197 post_create: Some(vec![hook]),
198 };
199
200 let config = Config {
201 version: "1.0".to_string(),
202 defaults: DefaultConfig::default(),
203 hooks: Some(hooks),
204 };
205
206 let temp_dir = tempdir().unwrap();
207 let config_path = temp_dir.path().join("copy_config.yml");
208
209 config.save_to_file(&config_path).unwrap();
210 let loaded_config = Config::load_from_file(&config_path).unwrap();
211
212 let loaded_hooks = loaded_config.hooks.unwrap();
213 let hooks_vec = loaded_hooks.post_create.unwrap();
214
215 assert_eq!(hooks_vec[0].hook_type, HookType::Copy);
216 assert_eq!(hooks_vec[0].from, Some("README.md".to_string()));
217 assert_eq!(hooks_vec[0].to, Some("README.md".to_string()));
218 assert!(hooks_vec[0].command.is_none());
219 }
220
221 #[test]
222 fn test_config_fails_with_invalid_yaml() {
223 let temp_dir = tempdir().unwrap();
225 let config_path = temp_dir.path().join("invalid.yml");
226
227 std::fs::write(&config_path, "invalid: yaml: content: [").unwrap();
229
230 let result = Config::load_from_file(&config_path);
231
232 assert!(result.is_err());
233 assert!(matches!(result.unwrap_err(), GitGardenerError::Custom(_)));
234 }
235}