Skip to main content

twin_cli/
hooks.rs

1//! フック実行システムモジュール
2//!
3//! このモジュールの役割:
4//! - pre_create/post_create/pre_remove/post_remove フックの実行
5//! - フック実行時のエラーハンドリングと継続/中断制御
6//! - フック実行ログの表示
7//! - 環境変数の設定と引数の展開
8
9#![allow(dead_code)]
10use crate::core::{HookCommand, TwinError, TwinResult};
11use log::{debug, error, info, warn};
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::process::{Command, Output};
15
16/// フックのタイプ
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum HookType {
19    /// 環境作成前に実行
20    PreCreate,
21    /// 環境作成後に実行
22    PostCreate,
23    /// 環境削除前に実行
24    PreRemove,
25    /// 環境削除後に実行
26    PostRemove,
27}
28
29impl HookType {
30    /// フックタイプを文字列として取得
31    pub fn as_str(&self) -> &str {
32        match self {
33            HookType::PreCreate => "pre_create",
34            HookType::PostCreate => "post_create",
35            HookType::PreRemove => "pre_remove",
36            HookType::PostRemove => "post_remove",
37        }
38    }
39}
40
41/// フック実行の結果
42#[derive(Debug)]
43pub struct HookResult {
44    /// フックタイプ
45    pub hook_type: HookType,
46    /// コマンド文字列
47    pub command: String,
48    /// 実行成功したか
49    pub success: bool,
50    /// 終了コード
51    pub exit_code: Option<i32>,
52    /// 標準出力
53    pub stdout: String,
54    /// 標準エラー出力
55    pub stderr: String,
56    /// 実行時間(ミリ秒)
57    pub duration_ms: u128,
58}
59
60/// フック実行のコンテキスト情報
61#[derive(Debug, Clone)]
62pub struct HookContext {
63    /// エージェント名
64    pub agent_name: String,
65    /// ワークツリーのパス
66    pub worktree_path: PathBuf,
67    /// ブランチ名
68    pub branch: String,
69    /// プロジェクトルートパス
70    pub project_root: PathBuf,
71    /// 追加の環境変数
72    pub env_vars: HashMap<String, String>,
73}
74
75impl HookContext {
76    /// 新しいコンテキストを作成
77    pub fn new(
78        agent_name: impl Into<String>,
79        worktree_path: impl Into<PathBuf>,
80        branch: impl Into<String>,
81        project_root: impl Into<PathBuf>,
82    ) -> Self {
83        Self {
84            agent_name: agent_name.into(),
85            worktree_path: worktree_path.into(),
86            branch: branch.into(),
87            project_root: project_root.into(),
88            env_vars: HashMap::new(),
89        }
90    }
91
92    /// 環境変数を追加
93    pub fn add_env_var(&mut self, key: impl Into<String>, value: impl Into<String>) {
94        self.env_vars.insert(key.into(), value.into());
95    }
96
97    /// コンテキストを環境変数として取得
98    pub fn as_env_vars(&self) -> HashMap<String, String> {
99        let mut vars = self.env_vars.clone();
100
101        // 標準のコンテキスト変数を追加
102        vars.insert("TWIN_AGENT_NAME".to_string(), self.agent_name.clone());
103        vars.insert(
104            "TWIN_WORKTREE_PATH".to_string(),
105            self.worktree_path.display().to_string(),
106        );
107        vars.insert("TWIN_BRANCH".to_string(), self.branch.clone());
108        vars.insert(
109            "TWIN_PROJECT_ROOT".to_string(),
110            self.project_root.display().to_string(),
111        );
112
113        vars
114    }
115}
116
117/// フック実行マネージャー
118pub struct HookExecutor {
119    /// ドライランモード
120    dry_run: bool,
121    /// タイムアウト(秒)
122    timeout_seconds: u64,
123    /// エラー時に続行するか
124    continue_on_error: bool,
125}
126
127impl HookExecutor {
128    /// 新しいフック実行マネージャーを作成
129    pub fn new() -> Self {
130        Self {
131            dry_run: false,
132            timeout_seconds: 30,
133            continue_on_error: false,
134        }
135    }
136
137    /// ドライランモードを設定
138    pub fn set_dry_run(&mut self, dry_run: bool) {
139        self.dry_run = dry_run;
140    }
141
142    /// タイムアウトを設定
143    pub fn set_timeout(&mut self, seconds: u64) {
144        self.timeout_seconds = seconds;
145    }
146
147    /// エラー時の継続設定
148    pub fn set_continue_on_error(&mut self, continue_on_error: bool) {
149        self.continue_on_error = continue_on_error;
150    }
151
152    /// フックを実行
153    pub fn execute(
154        &self,
155        hook_type: HookType,
156        hook: &HookCommand,
157        context: &HookContext,
158    ) -> TwinResult<HookResult> {
159        info!("Executing {} hook: {}", hook_type.as_str(), hook.command);
160
161        // コマンドと引数を展開
162        let expanded_command = self.expand_command(&hook.command, context);
163        let expanded_args = if hook.args.is_empty() {
164            None
165        } else {
166            Some(
167                hook.args
168                    .iter()
169                    .map(|arg| self.expand_command(arg, context))
170                    .collect::<Vec<_>>(),
171            )
172        };
173
174        if self.dry_run {
175            info!("[DRY RUN] Would execute: {expanded_command}");
176            if let Some(args) = &expanded_args {
177                info!("[DRY RUN] With args: {args:?}");
178            }
179
180            return Ok(HookResult {
181                hook_type,
182                command: expanded_command,
183                success: true,
184                exit_code: Some(0),
185                stdout: "[DRY RUN]".to_string(),
186                stderr: String::new(),
187                duration_ms: 0,
188            });
189        }
190
191        // 実際にコマンドを実行
192        let start_time = std::time::Instant::now();
193        let result = self.execute_command(&expanded_command, expanded_args.as_deref(), context)?;
194        let duration_ms = start_time.elapsed().as_millis();
195
196        let hook_result = HookResult {
197            hook_type,
198            command: expanded_command.clone(),
199            success: result.status.success(),
200            exit_code: result.status.code(),
201            stdout: String::from_utf8_lossy(&result.stdout).to_string(),
202            stderr: String::from_utf8_lossy(&result.stderr).to_string(),
203            duration_ms,
204        };
205
206        // ログ出力
207        if hook_result.success {
208            info!(
209                "{} hook completed successfully in {}ms",
210                hook_type.as_str(),
211                duration_ms
212            );
213            if !hook_result.stdout.is_empty() {
214                debug!("Hook stdout: {}", hook_result.stdout);
215            }
216        } else {
217            error!(
218                "{} hook failed with exit code {:?}",
219                hook_type.as_str(),
220                hook_result.exit_code
221            );
222            if !hook_result.stderr.is_empty() {
223                error!("Hook stderr: {}", hook_result.stderr);
224            }
225
226            // エラー時の処理
227            if !hook.continue_on_error {
228                return Err(TwinError::hook(
229                    format!("{} hook failed: {}", hook_type.as_str(), expanded_command),
230                    hook_type.as_str().to_string(),
231                    hook_result.exit_code,
232                ));
233            } else {
234                warn!("Continuing despite hook failure (continue_on_error=true)");
235            }
236        }
237
238        Ok(hook_result)
239    }
240
241    /// 複数のフックを順次実行
242    pub fn execute_hooks(
243        &self,
244        hook_type: HookType,
245        hooks: &[HookCommand],
246        context: &HookContext,
247    ) -> TwinResult<Vec<HookResult>> {
248        let mut results = Vec::new();
249
250        for hook in hooks {
251            match self.execute(hook_type, hook, context) {
252                Ok(result) => {
253                    let should_stop = !result.success && !hook.continue_on_error;
254                    results.push(result);
255
256                    if should_stop {
257                        break;
258                    }
259                }
260                Err(e) => {
261                    if !hook.continue_on_error {
262                        return Err(e);
263                    }
264                    warn!("Hook execution error (continuing): {e}");
265                }
266            }
267        }
268
269        Ok(results)
270    }
271
272    /// コマンド内の変数を展開
273    fn expand_command(&self, command: &str, context: &HookContext) -> String {
274        let mut result = command.to_string();
275
276        // 基本的な変数展開
277        result = result.replace("${AGENT_NAME}", &context.agent_name);
278        result = result.replace(
279            "${WORKTREE_PATH}",
280            &context.worktree_path.display().to_string(),
281        );
282        result = result.replace("${BRANCH}", &context.branch);
283        result = result.replace(
284            "${PROJECT_ROOT}",
285            &context.project_root.display().to_string(),
286        );
287
288        // 追加の環境変数を展開
289        for (key, value) in &context.env_vars {
290            result = result.replace(&format!("${{{key}}}"), value);
291        }
292
293        result
294    }
295
296    /// 実際にコマンドを実行
297    fn execute_command(
298        &self,
299        command: &str,
300        args: Option<&[String]>,
301        context: &HookContext,
302    ) -> TwinResult<Output> {
303        let mut cmd = if cfg!(windows) {
304            let mut c = Command::new("cmd");
305            c.arg("/C");
306            c
307        } else {
308            let mut c = Command::new("sh");
309            c.arg("-c");
310            c
311        };
312
313        // コマンド文字列を構築
314        let full_command = if let Some(args) = args {
315            format!("{} {}", command, args.join(" "))
316        } else {
317            command.to_string()
318        };
319
320        cmd.arg(&full_command);
321
322        // 作業ディレクトリを設定
323        // pre_createやpre_removeの場合、worktreeがまだ存在しない可能性があるため、
324        // 存在しない場合はプロジェクトルートを使用
325        let work_dir = if context.worktree_path.exists() {
326            &context.worktree_path
327        } else {
328            &context.project_root
329        };
330        cmd.current_dir(work_dir);
331
332        // 環境変数を設定
333        for (key, value) in context.as_env_vars() {
334            cmd.env(key, value);
335        }
336
337        debug!("Executing command: {full_command}");
338        debug!("Working directory: {:?}", context.worktree_path);
339
340        // タイムアウトを考慮した実行
341        let output = if self.timeout_seconds > 0 {
342            // タイムアウト付き実行(簡易実装)
343            // 実際のプロダクションコードではtokio::time::timeoutなどを使用
344            cmd.output().map_err(|e| {
345                TwinError::hook(
346                    format!("Failed to execute hook command: {e}"),
347                    command.to_string(),
348                    None,
349                )
350            })?
351        } else {
352            cmd.output().map_err(|e| {
353                TwinError::hook(
354                    format!("Failed to execute hook command: {e}"),
355                    command.to_string(),
356                    None,
357                )
358            })?
359        };
360
361        Ok(output)
362    }
363}
364
365/// デフォルトのフック実行マネージャーを作成
366impl Default for HookExecutor {
367    fn default() -> Self {
368        Self::new()
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_hook_type_string() {
378        assert_eq!(HookType::PreCreate.as_str(), "pre_create");
379        assert_eq!(HookType::PostCreate.as_str(), "post_create");
380        assert_eq!(HookType::PreRemove.as_str(), "pre_remove");
381        assert_eq!(HookType::PostRemove.as_str(), "post_remove");
382    }
383
384    #[test]
385    fn test_context_env_vars() {
386        let mut context = HookContext::new(
387            "test-agent",
388            "/path/to/worktree",
389            "feature/test",
390            "/path/to/project",
391        );
392        context.add_env_var("CUSTOM_VAR", "custom_value");
393
394        let env_vars = context.as_env_vars();
395        assert_eq!(env_vars.get("TWIN_AGENT_NAME").unwrap(), "test-agent");
396        assert_eq!(env_vars.get("TWIN_BRANCH").unwrap(), "feature/test");
397        assert_eq!(env_vars.get("CUSTOM_VAR").unwrap(), "custom_value");
398    }
399
400    #[test]
401    fn test_command_expansion() {
402        let context = HookContext::new(
403            "my-agent",
404            "/workspace/my-agent",
405            "feature/my-agent",
406            "/workspace",
407        );
408
409        let executor = HookExecutor::new();
410        let expanded = executor.expand_command(
411            "echo 'Working on ${AGENT_NAME} in ${WORKTREE_PATH}'",
412            &context,
413        );
414
415        assert_eq!(
416            expanded,
417            "echo 'Working on my-agent in /workspace/my-agent'"
418        );
419    }
420
421    #[test]
422    fn test_dry_run_execution() {
423        let mut executor = HookExecutor::new();
424        executor.set_dry_run(true);
425
426        let context = HookContext::new("test", "/test", "test", "/test");
427
428        let hook = HookCommand {
429            command: "echo test".to_string(),
430            args: vec![],
431            env: HashMap::new(),
432            timeout: 60,
433            continue_on_error: false,
434        };
435
436        let result = executor
437            .execute(HookType::PreCreate, &hook, &context)
438            .unwrap();
439        assert!(result.success);
440        assert_eq!(result.stdout, "[DRY RUN]");
441    }
442}