ggen_cli_lib/cmds/hook/
create.rs

1//! Create knowledge hooks for automatic graph regeneration.
2//!
3//! This module implements Pattern 021: Knowledge Hooks from the ggen cookbook.
4//! Hooks trigger automatic graph regeneration in response to system events,
5//! transforming ggen from a manual tool into a reactive autonomic system.
6//!
7//! # Hook Types
8//!
9//! ## Git Hooks (Local automation)
10//! - `pre-commit`: Regenerate before committing
11//! - `post-merge`: Rebuild after merging branches
12//! - `post-checkout`: Update when switching branches
13//!
14//! ## File Watch (Continuous updates)
15//! - `file-watch`: Trigger on filesystem changes using inotify/fswatch
16//!
17//! ## Scheduled (Temporal patterns)
18//! - `cron`: Run at scheduled intervals
19//!
20//! ## Manual (CLI trigger)
21//! - Manually invoked via `ggen hook run`
22//!
23//! # Examples
24//!
25//! ```bash
26//! # Git pre-commit hook
27//! ggen hook create "pre-commit" \
28//!   --trigger git-pre-commit \
29//!   --template graph-gen.tmpl
30//!
31//! # Nightly full rebuild (cron)
32//! ggen hook create "nightly-rebuild" \
33//!   --trigger cron \
34//!   --schedule "0 2 * * *" \
35//!   --template full-graph.tmpl
36//!
37//! # File watcher for incremental updates
38//! ggen hook create "rust-watcher" \
39//!   --trigger file-watch \
40//!   --path "src/**/*.rs" \
41//!   --template incremental.tmpl
42//!
43//! # Dry run (test without installing)
44//! ggen hook create "test-hook" \
45//!   --trigger git-pre-commit \
46//!   --template test.tmpl \
47//!   --dry-run
48//! ```
49//!
50//! # Errors
51//!
52//! Returns errors if:
53//! - Hook name is invalid or already exists
54//! - Template reference is invalid
55//! - Trigger type requires missing parameters (e.g., cron without schedule)
56//! - Git hook installation fails
57//! - File permissions prevent hook creation
58
59use clap::Args;
60use ggen_utils::error::Result;
61use serde::{Deserialize, Serialize};
62use std::collections::HashMap;
63
64#[derive(Args, Debug)]
65pub struct CreateArgs {
66    /// Hook name (unique identifier)
67    pub name: String,
68
69    /// Trigger type: git-pre-commit, git-post-merge, git-post-checkout, file-watch, cron, manual
70    #[arg(short = 't', long)]
71    pub trigger: String,
72
73    /// Template reference to execute when hook triggers
74    #[arg(long)]
75    pub template: String,
76
77    /// Cron schedule (required for cron trigger, e.g., "0 2 * * *")
78    #[arg(long)]
79    pub schedule: Option<String>,
80
81    /// File path pattern for file-watch trigger (e.g., "src/**/*.rs")
82    #[arg(long)]
83    pub path: Option<String>,
84
85    /// Variables to pass to the template (key=value format)
86    #[arg(short = 'v', long = "var")]
87    pub vars: Vec<String>,
88
89    /// Enable the hook immediately after creation
90    #[arg(long, default_value = "true")]
91    pub enabled: bool,
92
93    /// Perform dry-run without installing the hook
94    #[arg(long)]
95    pub dry_run: bool,
96
97    /// Output hook configuration as JSON
98    #[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
128/// Parse key=value pairs into HashMap
129fn 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
144/// Validate hook creation arguments
145fn validate_create_args(args: &CreateArgs) -> Result<()> {
146    // Validate hook name
147    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    // Validate trigger-specific requirements
158    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            // These triggers don't require additional parameters
175        }
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    // Validate template reference
185    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
194/// Parse trigger type and parameters into HookTrigger enum
195fn 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
220/// Main entry point for `ggen hook create`
221pub async fn run(args: &CreateArgs) -> Result<()> {
222    // Validate input
223    validate_create_args(args)?;
224
225    println!("🔨 Creating knowledge hook '{}'...", args.name);
226
227    // Parse variables
228    let vars = parse_vars(&args.vars)?;
229
230    // Parse trigger
231    let trigger = parse_trigger(args)?;
232
233    // Build hook configuration
234    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    // Output as JSON if requested
243    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    // TODO: Implement actual hook installation
268    // This will involve:
269    // 1. Creating hook config file in .ggen/hooks/
270    // 2. Installing git hooks if trigger is git-*
271    // 3. Setting up file watcher if trigger is file-watch
272    // 4. Configuring cron job if trigger is cron
273
274    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, // Missing schedule
339            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, // Missing path
356            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}