Skip to main content

ralph_workflow/app/
effect_handler.rs

1//! Real implementation of AppEffectHandler.
2//!
3//! This handler executes actual side effects for production use.
4//! It provides concrete implementations for all [`AppEffect`] variants
5//! by delegating to the appropriate system calls or internal modules.
6
7use super::effect::{AppEffect, AppEffectHandler, AppEffectResult, CommitResult, RebaseResult};
8use std::path::{Path, PathBuf};
9
10/// Real effect handler that executes actual side effects.
11///
12/// This implementation performs real I/O operations including:
13/// - Filesystem operations via `std::fs`
14/// - Git operations via `crate::git_helpers`
15/// - Environment variable access via `std::env`
16/// - Working directory changes via `std::env::set_current_dir`
17///
18/// # Example
19///
20/// ```ignore
21/// use ralph_workflow::app::effect_handler::RealAppEffectHandler;
22/// use ralph_workflow::app::effect::{AppEffect, AppEffectHandler};
23///
24/// let mut handler = RealAppEffectHandler::new();
25/// let result = handler.execute(AppEffect::PathExists {
26///     path: PathBuf::from("Cargo.toml"),
27/// });
28/// ```
29pub struct RealAppEffectHandler {
30    /// Optional workspace root for relative path resolution.
31    ///
32    /// When set, relative paths in effects are resolved against this root.
33    /// When `None`, paths are used as-is (relative to current working directory).
34    workspace_root: Option<PathBuf>,
35}
36
37impl RealAppEffectHandler {
38    /// Create a new handler without a workspace root.
39    ///
40    /// Paths will be used as-is, relative to the current working directory.
41    pub fn new() -> Self {
42        Self {
43            workspace_root: None,
44        }
45    }
46
47    /// Create a new handler with a specific workspace root.
48    ///
49    /// All relative paths in effects will be resolved against this root.
50    ///
51    /// # Arguments
52    ///
53    /// * `root` - The workspace root directory for path resolution.
54    pub fn with_workspace_root(root: PathBuf) -> Self {
55        Self {
56            workspace_root: Some(root),
57        }
58    }
59
60    /// Resolve a path against the workspace root if set.
61    ///
62    /// If the path is absolute, it is returned as-is.
63    /// If the path is relative and a workspace root is set, the path is
64    /// joined to the workspace root.
65    /// If the path is relative and no workspace root is set, the path is
66    /// returned as-is.
67    fn resolve_path(&self, path: &Path) -> PathBuf {
68        if path.is_absolute() {
69            path.to_path_buf()
70        } else if let Some(ref root) = self.workspace_root {
71            root.join(path)
72        } else {
73            path.to_path_buf()
74        }
75    }
76}
77
78impl Default for RealAppEffectHandler {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl AppEffectHandler for RealAppEffectHandler {
85    fn execute(&mut self, effect: AppEffect) -> AppEffectResult {
86        match effect {
87            // =========================================================================
88            // Working Directory Effects
89            // =========================================================================
90            AppEffect::SetCurrentDir { path } => {
91                let resolved = self.resolve_path(&path);
92                match std::env::set_current_dir(&resolved) {
93                    Ok(()) => AppEffectResult::Ok,
94                    Err(e) => AppEffectResult::Error(format!(
95                        "Failed to set current directory to '{}': {}",
96                        resolved.display(),
97                        e
98                    )),
99                }
100            }
101
102            // =========================================================================
103            // Filesystem Effects
104            // =========================================================================
105            AppEffect::WriteFile { path, content } => {
106                let resolved = self.resolve_path(&path);
107                // Ensure parent directories exist
108                if let Some(parent) = resolved.parent() {
109                    if let Err(e) = std::fs::create_dir_all(parent) {
110                        return AppEffectResult::Error(format!(
111                            "Failed to create parent directories for '{}': {}",
112                            resolved.display(),
113                            e
114                        ));
115                    }
116                }
117                match std::fs::write(&resolved, content) {
118                    Ok(()) => AppEffectResult::Ok,
119                    Err(e) => AppEffectResult::Error(format!(
120                        "Failed to write file '{}': {}",
121                        resolved.display(),
122                        e
123                    )),
124                }
125            }
126
127            AppEffect::ReadFile { path } => {
128                let resolved = self.resolve_path(&path);
129                match std::fs::read_to_string(&resolved) {
130                    Ok(content) => AppEffectResult::String(content),
131                    Err(e) => AppEffectResult::Error(format!(
132                        "Failed to read file '{}': {}",
133                        resolved.display(),
134                        e
135                    )),
136                }
137            }
138
139            AppEffect::DeleteFile { path } => {
140                let resolved = self.resolve_path(&path);
141                match std::fs::remove_file(&resolved) {
142                    Ok(()) => AppEffectResult::Ok,
143                    Err(e) => AppEffectResult::Error(format!(
144                        "Failed to delete file '{}': {}",
145                        resolved.display(),
146                        e
147                    )),
148                }
149            }
150
151            AppEffect::CreateDir { path } => {
152                let resolved = self.resolve_path(&path);
153                match std::fs::create_dir_all(&resolved) {
154                    Ok(()) => AppEffectResult::Ok,
155                    Err(e) => AppEffectResult::Error(format!(
156                        "Failed to create directory '{}': {}",
157                        resolved.display(),
158                        e
159                    )),
160                }
161            }
162
163            AppEffect::PathExists { path } => {
164                let resolved = self.resolve_path(&path);
165                AppEffectResult::Bool(resolved.exists())
166            }
167
168            AppEffect::SetReadOnly { path, readonly } => {
169                let resolved = self.resolve_path(&path);
170                match std::fs::metadata(&resolved) {
171                    Ok(metadata) => {
172                        let mut permissions = metadata.permissions();
173                        permissions.set_readonly(readonly);
174                        match std::fs::set_permissions(&resolved, permissions) {
175                            Ok(()) => AppEffectResult::Ok,
176                            Err(e) => AppEffectResult::Error(format!(
177                                "Failed to set permissions on '{}': {}",
178                                resolved.display(),
179                                e
180                            )),
181                        }
182                    }
183                    Err(e) => AppEffectResult::Error(format!(
184                        "Failed to get metadata for '{}': {}",
185                        resolved.display(),
186                        e
187                    )),
188                }
189            }
190
191            // =========================================================================
192            // Git Effects
193            // =========================================================================
194            AppEffect::GitRequireRepo => match crate::git_helpers::require_git_repo() {
195                Ok(()) => AppEffectResult::Ok,
196                Err(e) => AppEffectResult::Error(format!("Not in a git repository: {}", e)),
197            },
198
199            AppEffect::GitGetRepoRoot => match crate::git_helpers::get_repo_root() {
200                Ok(root) => AppEffectResult::Path(root),
201                Err(e) => AppEffectResult::Error(format!("Failed to get repository root: {}", e)),
202            },
203
204            AppEffect::GitGetHeadOid => match crate::git_helpers::get_current_head_oid() {
205                Ok(oid) => AppEffectResult::String(oid),
206                Err(e) => AppEffectResult::Error(format!("Failed to get HEAD OID: {}", e)),
207            },
208
209            AppEffect::GitDiff => match crate::git_helpers::git_diff() {
210                Ok(diff) => AppEffectResult::String(diff),
211                Err(e) => AppEffectResult::Error(format!("Failed to get git diff: {}", e)),
212            },
213
214            #[cfg(any(test, feature = "test-utils"))]
215            AppEffect::GitDiffFrom { start_oid } => {
216                match crate::git_helpers::git_diff_from(&start_oid) {
217                    Ok(diff) => AppEffectResult::String(diff),
218                    Err(e) => AppEffectResult::Error(format!(
219                        "Failed to get git diff from '{}': {}",
220                        start_oid, e
221                    )),
222                }
223            }
224
225            #[cfg(not(any(test, feature = "test-utils")))]
226            AppEffect::GitDiffFrom { start_oid: _ } => {
227                AppEffectResult::Error("GitDiffFrom requires test-utils feature".to_string())
228            }
229
230            #[cfg(any(test, feature = "test-utils"))]
231            AppEffect::GitDiffFromStart => match crate::git_helpers::get_git_diff_from_start() {
232                Ok(diff) => AppEffectResult::String(diff),
233                Err(e) => {
234                    AppEffectResult::Error(format!("Failed to get diff from start commit: {}", e))
235                }
236            },
237
238            #[cfg(not(any(test, feature = "test-utils")))]
239            AppEffect::GitDiffFromStart => {
240                AppEffectResult::Error("GitDiffFromStart requires test-utils feature".to_string())
241            }
242
243            AppEffect::GitSnapshot => match crate::git_helpers::git_snapshot() {
244                Ok(snapshot) => AppEffectResult::String(snapshot),
245                Err(e) => AppEffectResult::Error(format!("Failed to create git snapshot: {}", e)),
246            },
247
248            AppEffect::GitAddAll => match crate::git_helpers::git_add_all() {
249                Ok(staged) => AppEffectResult::Bool(staged),
250                Err(e) => AppEffectResult::Error(format!("Failed to stage all changes: {}", e)),
251            },
252
253            AppEffect::GitCommit {
254                message,
255                user_name,
256                user_email,
257            } => {
258                match crate::git_helpers::git_commit(
259                    &message,
260                    user_name.as_deref(),
261                    user_email.as_deref(),
262                    None, // No executor needed for basic commit
263                ) {
264                    Ok(Some(oid)) => {
265                        AppEffectResult::Commit(CommitResult::Success(oid.to_string()))
266                    }
267                    Ok(None) => AppEffectResult::Commit(CommitResult::NoChanges),
268                    Err(e) => AppEffectResult::Error(format!("Failed to create commit: {}", e)),
269                }
270            }
271
272            AppEffect::GitSaveStartCommit => match crate::git_helpers::save_start_commit() {
273                Ok(()) => AppEffectResult::Ok,
274                Err(e) => AppEffectResult::Error(format!("Failed to save start commit: {}", e)),
275            },
276
277            AppEffect::GitResetStartCommit => match crate::git_helpers::reset_start_commit() {
278                Ok(result) => AppEffectResult::String(result.oid),
279                Err(e) => AppEffectResult::Error(format!("Failed to reset start commit: {}", e)),
280            },
281
282            AppEffect::GitRebaseOnto { upstream_branch: _ } => {
283                // Rebase operations require a process executor which we don't have here.
284                // This effect should be handled by a higher-level component that has
285                // access to a ProcessExecutor.
286                AppEffectResult::Error(
287                    "GitRebaseOnto requires executor injection - use pipeline runner".to_string(),
288                )
289            }
290
291            AppEffect::GitGetConflictedFiles => match crate::git_helpers::get_conflicted_files() {
292                Ok(files) => AppEffectResult::StringList(files),
293                Err(e) => AppEffectResult::Error(format!("Failed to get conflicted files: {}", e)),
294            },
295
296            AppEffect::GitContinueRebase => {
297                // Continue rebase requires a process executor.
298                AppEffectResult::Error(
299                    "GitContinueRebase requires executor injection - use pipeline runner"
300                        .to_string(),
301                )
302            }
303
304            AppEffect::GitAbortRebase => {
305                // Abort rebase requires a process executor.
306                AppEffectResult::Error(
307                    "GitAbortRebase requires executor injection - use pipeline runner".to_string(),
308                )
309            }
310
311            AppEffect::GitGetDefaultBranch => match crate::git_helpers::get_default_branch() {
312                Ok(branch) => AppEffectResult::String(branch),
313                Err(e) => AppEffectResult::Error(format!("Failed to get default branch: {}", e)),
314            },
315
316            AppEffect::GitIsMainBranch => match crate::git_helpers::is_main_or_master_branch() {
317                Ok(is_main) => AppEffectResult::Bool(is_main),
318                Err(e) => AppEffectResult::Error(format!("Failed to check branch: {}", e)),
319            },
320
321            // =========================================================================
322            // Environment Effects
323            // =========================================================================
324            AppEffect::GetEnvVar { name } => match std::env::var(&name) {
325                Ok(value) => AppEffectResult::String(value),
326                Err(std::env::VarError::NotPresent) => {
327                    AppEffectResult::Error(format!("Environment variable '{}' not set", name))
328                }
329                Err(std::env::VarError::NotUnicode(_)) => AppEffectResult::Error(format!(
330                    "Environment variable '{}' contains invalid Unicode",
331                    name
332                )),
333            },
334
335            AppEffect::SetEnvVar { name, value } => {
336                std::env::set_var(&name, &value);
337                AppEffectResult::Ok
338            }
339
340            // =========================================================================
341            // Logging Effects
342            // =========================================================================
343            // Logging is handled elsewhere (by the logger), so these are no-ops.
344            // The effect system captures them for testing/recording purposes.
345            AppEffect::LogInfo { message: _ }
346            | AppEffect::LogSuccess { message: _ }
347            | AppEffect::LogWarn { message: _ }
348            | AppEffect::LogError { message: _ } => AppEffectResult::Ok,
349        }
350    }
351}
352
353/// A variant of `RealAppEffectHandler` that can execute rebase operations.
354///
355/// This handler extends `RealAppEffectHandler` with the ability to execute
356/// git rebase operations that require a process executor.
357pub struct RealAppEffectHandlerWithExecutor<'a> {
358    /// The underlying handler for non-executor effects.
359    inner: RealAppEffectHandler,
360    /// Process executor for operations that require spawning processes.
361    executor: &'a dyn crate::executor::ProcessExecutor,
362}
363
364impl<'a> RealAppEffectHandlerWithExecutor<'a> {
365    /// Create a new handler with a process executor.
366    ///
367    /// # Arguments
368    ///
369    /// * `executor` - The process executor for rebase operations.
370    pub fn new(executor: &'a dyn crate::executor::ProcessExecutor) -> Self {
371        Self {
372            inner: RealAppEffectHandler::new(),
373            executor,
374        }
375    }
376
377    /// Create a new handler with a workspace root and process executor.
378    ///
379    /// # Arguments
380    ///
381    /// * `root` - The workspace root directory for path resolution.
382    /// * `executor` - The process executor for rebase operations.
383    pub fn with_workspace_root(
384        root: PathBuf,
385        executor: &'a dyn crate::executor::ProcessExecutor,
386    ) -> Self {
387        Self {
388            inner: RealAppEffectHandler::with_workspace_root(root),
389            executor,
390        }
391    }
392}
393
394impl AppEffectHandler for RealAppEffectHandlerWithExecutor<'_> {
395    fn execute(&mut self, effect: AppEffect) -> AppEffectResult {
396        match &effect {
397            AppEffect::GitRebaseOnto { upstream_branch } => {
398                match crate::git_helpers::rebase_onto(upstream_branch, self.executor) {
399                    Ok(result) => match result {
400                        crate::git_helpers::RebaseResult::Success => {
401                            AppEffectResult::Rebase(RebaseResult::Success)
402                        }
403                        crate::git_helpers::RebaseResult::Conflicts(files) => {
404                            AppEffectResult::Rebase(RebaseResult::Conflicts(files))
405                        }
406                        crate::git_helpers::RebaseResult::NoOp { reason } => {
407                            AppEffectResult::Rebase(RebaseResult::NoOp { reason })
408                        }
409                        crate::git_helpers::RebaseResult::Failed(kind) => {
410                            AppEffectResult::Rebase(RebaseResult::Failed(kind.to_string()))
411                        }
412                    },
413                    Err(e) => AppEffectResult::Error(format!("Rebase failed: {}", e)),
414                }
415            }
416
417            AppEffect::GitContinueRebase => {
418                match crate::git_helpers::continue_rebase(self.executor) {
419                    Ok(()) => AppEffectResult::Ok,
420                    Err(e) => AppEffectResult::Error(format!("Failed to continue rebase: {}", e)),
421                }
422            }
423
424            AppEffect::GitAbortRebase => match crate::git_helpers::abort_rebase(self.executor) {
425                Ok(()) => AppEffectResult::Ok,
426                Err(e) => AppEffectResult::Error(format!("Failed to abort rebase: {}", e)),
427            },
428
429            // Delegate all other effects to the inner handler
430            _ => self.inner.execute(effect),
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_real_handler_default() {
441        let handler = RealAppEffectHandler::default();
442        assert!(handler.workspace_root.is_none());
443    }
444
445    #[test]
446    fn test_real_handler_with_workspace_root() {
447        let root = PathBuf::from("/some/path");
448        let handler = RealAppEffectHandler::with_workspace_root(root.clone());
449        assert_eq!(handler.workspace_root, Some(root));
450    }
451
452    #[test]
453    fn test_resolve_path_absolute() {
454        let handler = RealAppEffectHandler::with_workspace_root(PathBuf::from("/workspace"));
455        let absolute = PathBuf::from("/absolute/path");
456        assert_eq!(handler.resolve_path(&absolute), absolute);
457    }
458
459    #[test]
460    fn test_resolve_path_relative_with_root() {
461        let handler = RealAppEffectHandler::with_workspace_root(PathBuf::from("/workspace"));
462        let relative = PathBuf::from("relative/path");
463        assert_eq!(
464            handler.resolve_path(&relative),
465            PathBuf::from("/workspace/relative/path")
466        );
467    }
468
469    #[test]
470    fn test_resolve_path_relative_without_root() {
471        let handler = RealAppEffectHandler::new();
472        let relative = PathBuf::from("relative/path");
473        assert_eq!(handler.resolve_path(&relative), relative);
474    }
475
476    #[test]
477    fn test_path_exists_effect() {
478        let mut handler = RealAppEffectHandler::new();
479        // Test with a path that definitely exists (this file's directory)
480        let result = handler.execute(AppEffect::PathExists {
481            path: PathBuf::from("."),
482        });
483        assert!(matches!(result, AppEffectResult::Bool(true)));
484    }
485
486    #[test]
487    fn test_path_not_exists_effect() {
488        let mut handler = RealAppEffectHandler::new();
489        let result = handler.execute(AppEffect::PathExists {
490            path: PathBuf::from("/nonexistent/path/that/should/not/exist/12345"),
491        });
492        assert!(matches!(result, AppEffectResult::Bool(false)));
493    }
494
495    #[test]
496    fn test_get_env_var_effect() {
497        let mut handler = RealAppEffectHandler::new();
498        // PATH should always be set
499        let result = handler.execute(AppEffect::GetEnvVar {
500            name: "PATH".to_string(),
501        });
502        assert!(matches!(result, AppEffectResult::String(_)));
503    }
504
505    #[test]
506    fn test_get_env_var_not_set() {
507        let mut handler = RealAppEffectHandler::new();
508        let result = handler.execute(AppEffect::GetEnvVar {
509            name: "DEFINITELY_NOT_SET_ENV_VAR_12345".to_string(),
510        });
511        assert!(matches!(result, AppEffectResult::Error(_)));
512    }
513
514    #[test]
515    fn test_set_env_var_effect() {
516        let mut handler = RealAppEffectHandler::new();
517        let var_name = "TEST_RALPH_ENV_VAR_12345";
518
519        // Set the variable
520        let result = handler.execute(AppEffect::SetEnvVar {
521            name: var_name.to_string(),
522            value: "test_value".to_string(),
523        });
524        assert!(matches!(result, AppEffectResult::Ok));
525
526        // Verify it was set
527        assert_eq!(std::env::var(var_name).ok(), Some("test_value".to_string()));
528
529        // Clean up
530        std::env::remove_var(var_name);
531    }
532
533    #[test]
534    fn test_logging_effects_are_noops() {
535        let mut handler = RealAppEffectHandler::new();
536
537        let effects = vec![
538            AppEffect::LogInfo {
539                message: "test".to_string(),
540            },
541            AppEffect::LogSuccess {
542                message: "test".to_string(),
543            },
544            AppEffect::LogWarn {
545                message: "test".to_string(),
546            },
547            AppEffect::LogError {
548                message: "test".to_string(),
549            },
550        ];
551
552        for effect in effects {
553            let result = handler.execute(effect);
554            assert!(
555                matches!(result, AppEffectResult::Ok),
556                "Logging effect should return Ok"
557            );
558        }
559    }
560
561    #[test]
562    fn test_rebase_effects_require_executor() {
563        let mut handler = RealAppEffectHandler::new();
564
565        let result = handler.execute(AppEffect::GitRebaseOnto {
566            upstream_branch: "main".to_string(),
567        });
568        assert!(matches!(result, AppEffectResult::Error(_)));
569
570        let result = handler.execute(AppEffect::GitContinueRebase);
571        assert!(matches!(result, AppEffectResult::Error(_)));
572
573        let result = handler.execute(AppEffect::GitAbortRebase);
574        assert!(matches!(result, AppEffectResult::Error(_)));
575    }
576}