1use clap::Args;
60use ggen_utils::error::Result;
61use serde::{Deserialize, Serialize};
62use std::collections::HashMap;
63
64#[derive(Args, Debug)]
65pub struct CreateArgs {
66 pub name: String,
68
69 #[arg(short = 't', long)]
71 pub trigger: String,
72
73 #[arg(long)]
75 pub template: String,
76
77 #[arg(long)]
79 pub schedule: Option<String>,
80
81 #[arg(long)]
83 pub path: Option<String>,
84
85 #[arg(short = 'v', long = "var")]
87 pub vars: Vec<String>,
88
89 #[arg(long, default_value = "true")]
91 pub enabled: bool,
92
93 #[arg(long)]
95 pub dry_run: bool,
96
97 #[arg(long)]
99 pub json: bool,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct HookConfig {
104 pub name: String,
105 pub trigger: HookTrigger,
106 pub template: String,
107 pub vars: HashMap<String, String>,
108 pub enabled: bool,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(tag = "type", rename_all = "kebab-case")]
113pub enum HookTrigger {
114 #[serde(rename = "git-pre-commit")]
115 GitPreCommit,
116 #[serde(rename = "git-post-merge")]
117 GitPostMerge,
118 #[serde(rename = "git-post-checkout")]
119 GitPostCheckout,
120 #[serde(rename = "file-watch")]
121 FileWatch { path: String },
122 #[serde(rename = "cron")]
123 Cron { schedule: String },
124 #[serde(rename = "manual")]
125 Manual,
126}
127
128fn parse_vars(vars: &[String]) -> Result<HashMap<String, String>> {
130 let mut map = HashMap::new();
131 for var in vars {
132 let parts: Vec<&str> = var.splitn(2, '=').collect();
133 if parts.len() != 2 {
134 return Err(ggen_utils::error::Error::new_fmt(format_args!(
135 "Invalid variable format: '{}'. Expected 'key=value'",
136 var
137 )));
138 }
139 map.insert(parts[0].to_string(), parts[1].to_string());
140 }
141 Ok(map)
142}
143
144fn validate_create_args(args: &CreateArgs) -> Result<()> {
146 if args.name.trim().is_empty() {
148 return Err(ggen_utils::error::Error::new("Hook name cannot be empty"));
149 }
150
151 if args.name.len() > 100 {
152 return Err(ggen_utils::error::Error::new(
153 "Hook name too long (max 100 characters)",
154 ));
155 }
156
157 match args.trigger.as_str() {
159 "cron" => {
160 if args.schedule.is_none() {
161 return Err(ggen_utils::error::Error::new(
162 "Cron trigger requires --schedule parameter",
163 ));
164 }
165 }
166 "file-watch" => {
167 if args.path.is_none() {
168 return Err(ggen_utils::error::Error::new(
169 "File-watch trigger requires --path parameter",
170 ));
171 }
172 }
173 "git-pre-commit" | "git-post-merge" | "git-post-checkout" | "manual" => {
174 }
176 _ => {
177 return Err(ggen_utils::error::Error::new_fmt(format_args!(
178 "Invalid trigger type: '{}'. Must be one of: git-pre-commit, git-post-merge, git-post-checkout, file-watch, cron, manual",
179 args.trigger
180 )));
181 }
182 }
183
184 if args.template.trim().is_empty() {
186 return Err(ggen_utils::error::Error::new(
187 "Template reference cannot be empty",
188 ));
189 }
190
191 Ok(())
192}
193
194fn parse_trigger(args: &CreateArgs) -> Result<HookTrigger> {
196 match args.trigger.as_str() {
197 "git-pre-commit" => Ok(HookTrigger::GitPreCommit),
198 "git-post-merge" => Ok(HookTrigger::GitPostMerge),
199 "git-post-checkout" => Ok(HookTrigger::GitPostCheckout),
200 "file-watch" => Ok(HookTrigger::FileWatch {
201 path: args
202 .path
203 .clone()
204 .expect("file-watch requires --path (validated earlier)"),
205 }),
206 "cron" => Ok(HookTrigger::Cron {
207 schedule: args
208 .schedule
209 .clone()
210 .expect("cron requires --schedule (validated earlier)"),
211 }),
212 "manual" => Ok(HookTrigger::Manual),
213 _ => Err(ggen_utils::error::Error::new_fmt(format_args!(
214 "Unknown trigger type: {}",
215 args.trigger
216 ))),
217 }
218}
219
220pub async fn run(args: &CreateArgs) -> Result<()> {
222 validate_create_args(args)?;
224
225 println!("đ¨ Creating knowledge hook '{}'...", args.name);
226
227 let vars = parse_vars(&args.vars)?;
229
230 let trigger = parse_trigger(args)?;
232
233 let hook_config = HookConfig {
235 name: args.name.clone(),
236 trigger,
237 template: args.template.clone(),
238 vars,
239 enabled: args.enabled,
240 };
241
242 if args.json {
244 let json = serde_json::to_string_pretty(&hook_config).map_err(|e| {
245 ggen_utils::error::Error::new_fmt(format_args!("JSON serialization failed: {}", e))
246 })?;
247 println!("{}", json);
248 return Ok(());
249 }
250
251 if args.dry_run {
252 println!(" Dry run enabled - hook will not be installed");
253 println!("\nHook Configuration:");
254 println!(" Name: {}", hook_config.name);
255 println!(" Trigger: {:?}", hook_config.trigger);
256 println!(" Template: {}", hook_config.template);
257 println!(" Enabled: {}", hook_config.enabled);
258 if !hook_config.vars.is_empty() {
259 println!(" Variables:");
260 for (key, value) in &hook_config.vars {
261 println!(" {} = {}", key, value);
262 }
263 }
264 return Ok(());
265 }
266
267 println!(" Hook trigger: {:?}", hook_config.trigger);
275 println!(" Template: {}", hook_config.template);
276 println!(
277 " Status: {}",
278 if hook_config.enabled {
279 "enabled"
280 } else {
281 "disabled"
282 }
283 );
284 println!("\nâ
Hook '{}' created successfully!", args.name);
285 println!("\nNext steps:");
286 println!(" - Run hook manually: ggen hook run '{}'", args.name);
287 println!(
288 " - Validate configuration: ggen hook validate '{}'",
289 args.name
290 );
291 println!(" - List all hooks: ggen hook list");
292
293 Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_validate_create_args_valid() {
302 let args = CreateArgs {
303 name: "test-hook".to_string(),
304 trigger: "git-pre-commit".to_string(),
305 template: "graph.tmpl".to_string(),
306 schedule: None,
307 path: None,
308 vars: vec![],
309 enabled: true,
310 dry_run: false,
311 json: false,
312 };
313 assert!(validate_create_args(&args).is_ok());
314 }
315
316 #[test]
317 fn test_validate_create_args_empty_name() {
318 let args = CreateArgs {
319 name: "".to_string(),
320 trigger: "git-pre-commit".to_string(),
321 template: "graph.tmpl".to_string(),
322 schedule: None,
323 path: None,
324 vars: vec![],
325 enabled: true,
326 dry_run: false,
327 json: false,
328 };
329 assert!(validate_create_args(&args).is_err());
330 }
331
332 #[test]
333 fn test_validate_create_args_cron_without_schedule() {
334 let args = CreateArgs {
335 name: "nightly".to_string(),
336 trigger: "cron".to_string(),
337 template: "graph.tmpl".to_string(),
338 schedule: None, path: None,
340 vars: vec![],
341 enabled: true,
342 dry_run: false,
343 json: false,
344 };
345 assert!(validate_create_args(&args).is_err());
346 }
347
348 #[test]
349 fn test_validate_create_args_file_watch_without_path() {
350 let args = CreateArgs {
351 name: "watcher".to_string(),
352 trigger: "file-watch".to_string(),
353 template: "graph.tmpl".to_string(),
354 schedule: None,
355 path: None, vars: vec![],
357 enabled: true,
358 dry_run: false,
359 json: false,
360 };
361 assert!(validate_create_args(&args).is_err());
362 }
363
364 #[test]
365 fn test_parse_vars_valid() {
366 let vars = vec!["name=value".to_string(), "key=data".to_string()];
367 let result = parse_vars(&vars).unwrap();
368 assert_eq!(result.get("name"), Some(&"value".to_string()));
369 assert_eq!(result.get("key"), Some(&"data".to_string()));
370 }
371
372 #[test]
373 fn test_parse_trigger_git_pre_commit() {
374 let args = CreateArgs {
375 name: "test".to_string(),
376 trigger: "git-pre-commit".to_string(),
377 template: "graph.tmpl".to_string(),
378 schedule: None,
379 path: None,
380 vars: vec![],
381 enabled: true,
382 dry_run: false,
383 json: false,
384 };
385 let trigger = parse_trigger(&args).unwrap();
386 matches!(trigger, HookTrigger::GitPreCommit);
387 }
388
389 #[test]
390 fn test_parse_trigger_cron() {
391 let args = CreateArgs {
392 name: "test".to_string(),
393 trigger: "cron".to_string(),
394 template: "graph.tmpl".to_string(),
395 schedule: Some("0 2 * * *".to_string()),
396 path: None,
397 vars: vec![],
398 enabled: true,
399 dry_run: false,
400 json: false,
401 };
402 let trigger = parse_trigger(&args).unwrap();
403 if let HookTrigger::Cron { schedule } = trigger {
404 assert_eq!(schedule, "0 2 * * *");
405 } else {
406 panic!("Expected Cron trigger");
407 }
408 }
409}