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};
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#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_real_handler_default() {
359        let handler = RealAppEffectHandler::default();
360        assert!(handler.workspace_root.is_none());
361    }
362
363    #[test]
364    fn test_real_handler_with_workspace_root() {
365        let root = PathBuf::from("/some/path");
366        let handler = RealAppEffectHandler::with_workspace_root(root.clone());
367        assert_eq!(handler.workspace_root, Some(root));
368    }
369
370    #[test]
371    fn test_resolve_path_absolute() {
372        let handler = RealAppEffectHandler::with_workspace_root(PathBuf::from("/workspace"));
373        let absolute = PathBuf::from("/absolute/path");
374        assert_eq!(handler.resolve_path(&absolute), absolute);
375    }
376
377    #[test]
378    fn test_resolve_path_relative_with_root() {
379        let handler = RealAppEffectHandler::with_workspace_root(PathBuf::from("/workspace"));
380        let relative = PathBuf::from("relative/path");
381        assert_eq!(
382            handler.resolve_path(&relative),
383            PathBuf::from("/workspace/relative/path")
384        );
385    }
386
387    #[test]
388    fn test_resolve_path_relative_without_root() {
389        let handler = RealAppEffectHandler::new();
390        let relative = PathBuf::from("relative/path");
391        assert_eq!(handler.resolve_path(&relative), relative);
392    }
393
394    #[test]
395    fn test_path_exists_effect() {
396        let mut handler = RealAppEffectHandler::new();
397        // Test with a path that definitely exists (this file's directory)
398        let result = handler.execute(AppEffect::PathExists {
399            path: PathBuf::from("."),
400        });
401        assert!(matches!(result, AppEffectResult::Bool(true)));
402    }
403
404    #[test]
405    fn test_path_not_exists_effect() {
406        let mut handler = RealAppEffectHandler::new();
407        let result = handler.execute(AppEffect::PathExists {
408            path: PathBuf::from("/nonexistent/path/that/should/not/exist/12345"),
409        });
410        assert!(matches!(result, AppEffectResult::Bool(false)));
411    }
412
413    #[test]
414    fn test_get_env_var_effect() {
415        let mut handler = RealAppEffectHandler::new();
416        // PATH should always be set
417        let result = handler.execute(AppEffect::GetEnvVar {
418            name: "PATH".to_string(),
419        });
420        assert!(matches!(result, AppEffectResult::String(_)));
421    }
422
423    #[test]
424    fn test_get_env_var_not_set() {
425        let mut handler = RealAppEffectHandler::new();
426        let result = handler.execute(AppEffect::GetEnvVar {
427            name: "DEFINITELY_NOT_SET_ENV_VAR_12345".to_string(),
428        });
429        assert!(matches!(result, AppEffectResult::Error(_)));
430    }
431
432    #[test]
433    fn test_set_env_var_effect() {
434        let mut handler = RealAppEffectHandler::new();
435        let var_name = "TEST_RALPH_ENV_VAR_12345";
436
437        // Set the variable
438        let result = handler.execute(AppEffect::SetEnvVar {
439            name: var_name.to_string(),
440            value: "test_value".to_string(),
441        });
442        assert!(matches!(result, AppEffectResult::Ok));
443
444        // Verify it was set
445        assert_eq!(std::env::var(var_name).ok(), Some("test_value".to_string()));
446
447        // Clean up
448        std::env::remove_var(var_name);
449    }
450
451    #[test]
452    fn test_logging_effects_are_noops() {
453        let mut handler = RealAppEffectHandler::new();
454
455        let effects = vec![
456            AppEffect::LogInfo {
457                message: "test".to_string(),
458            },
459            AppEffect::LogSuccess {
460                message: "test".to_string(),
461            },
462            AppEffect::LogWarn {
463                message: "test".to_string(),
464            },
465            AppEffect::LogError {
466                message: "test".to_string(),
467            },
468        ];
469
470        for effect in effects {
471            let result = handler.execute(effect);
472            assert!(
473                matches!(result, AppEffectResult::Ok),
474                "Logging effect should return Ok"
475            );
476        }
477    }
478
479    #[test]
480    fn test_rebase_effects_require_executor() {
481        let mut handler = RealAppEffectHandler::new();
482
483        let result = handler.execute(AppEffect::GitRebaseOnto {
484            upstream_branch: "main".to_string(),
485        });
486        assert!(matches!(result, AppEffectResult::Error(_)));
487
488        let result = handler.execute(AppEffect::GitContinueRebase);
489        assert!(matches!(result, AppEffectResult::Error(_)));
490
491        let result = handler.execute(AppEffect::GitAbortRebase);
492        assert!(matches!(result, AppEffectResult::Error(_)));
493    }
494}