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            AppEffect::GitDiffFrom { start_oid } => {
215                match crate::git_helpers::git_diff_from(&start_oid) {
216                    Ok(diff) => AppEffectResult::String(diff),
217                    Err(e) => AppEffectResult::Error(format!(
218                        "Failed to get git diff from '{}': {}",
219                        start_oid, e
220                    )),
221                }
222            }
223
224            AppEffect::GitDiffFromStart => match crate::git_helpers::get_git_diff_from_start() {
225                Ok(diff) => AppEffectResult::String(diff),
226                Err(e) => {
227                    AppEffectResult::Error(format!("Failed to get diff from start commit: {}", e))
228                }
229            },
230
231            AppEffect::GitSnapshot => match crate::git_helpers::git_snapshot() {
232                Ok(snapshot) => AppEffectResult::String(snapshot),
233                Err(e) => AppEffectResult::Error(format!("Failed to create git snapshot: {}", e)),
234            },
235
236            AppEffect::GitAddAll => match crate::git_helpers::git_add_all() {
237                Ok(staged) => AppEffectResult::Bool(staged),
238                Err(e) => AppEffectResult::Error(format!("Failed to stage all changes: {}", e)),
239            },
240
241            AppEffect::GitCommit {
242                message,
243                user_name,
244                user_email,
245            } => {
246                match crate::git_helpers::git_commit(
247                    &message,
248                    user_name.as_deref(),
249                    user_email.as_deref(),
250                    None, // No executor needed for basic commit
251                ) {
252                    Ok(Some(oid)) => {
253                        AppEffectResult::Commit(CommitResult::Success(oid.to_string()))
254                    }
255                    Ok(None) => AppEffectResult::Commit(CommitResult::NoChanges),
256                    Err(e) => AppEffectResult::Error(format!("Failed to create commit: {}", e)),
257                }
258            }
259
260            AppEffect::GitSaveStartCommit => match crate::git_helpers::save_start_commit() {
261                Ok(()) => AppEffectResult::Ok,
262                Err(e) => AppEffectResult::Error(format!("Failed to save start commit: {}", e)),
263            },
264
265            AppEffect::GitResetStartCommit => match crate::git_helpers::reset_start_commit() {
266                Ok(result) => AppEffectResult::String(result.oid),
267                Err(e) => AppEffectResult::Error(format!("Failed to reset start commit: {}", e)),
268            },
269
270            AppEffect::GitRebaseOnto { upstream_branch: _ } => {
271                // Rebase operations require a process executor which we don't have here.
272                // This effect should be handled by a higher-level component that has
273                // access to a ProcessExecutor.
274                AppEffectResult::Error(
275                    "GitRebaseOnto requires executor injection - use pipeline runner".to_string(),
276                )
277            }
278
279            AppEffect::GitGetConflictedFiles => match crate::git_helpers::get_conflicted_files() {
280                Ok(files) => AppEffectResult::StringList(files),
281                Err(e) => AppEffectResult::Error(format!("Failed to get conflicted files: {}", e)),
282            },
283
284            AppEffect::GitContinueRebase => {
285                // Continue rebase requires a process executor.
286                AppEffectResult::Error(
287                    "GitContinueRebase requires executor injection - use pipeline runner"
288                        .to_string(),
289                )
290            }
291
292            AppEffect::GitAbortRebase => {
293                // Abort rebase requires a process executor.
294                AppEffectResult::Error(
295                    "GitAbortRebase requires executor injection - use pipeline runner".to_string(),
296                )
297            }
298
299            AppEffect::GitGetDefaultBranch => match crate::git_helpers::get_default_branch() {
300                Ok(branch) => AppEffectResult::String(branch),
301                Err(e) => AppEffectResult::Error(format!("Failed to get default branch: {}", e)),
302            },
303
304            AppEffect::GitIsMainBranch => match crate::git_helpers::is_main_or_master_branch() {
305                Ok(is_main) => AppEffectResult::Bool(is_main),
306                Err(e) => AppEffectResult::Error(format!("Failed to check branch: {}", e)),
307            },
308
309            // =========================================================================
310            // Environment Effects
311            // =========================================================================
312            AppEffect::GetEnvVar { name } => match std::env::var(&name) {
313                Ok(value) => AppEffectResult::String(value),
314                Err(std::env::VarError::NotPresent) => {
315                    AppEffectResult::Error(format!("Environment variable '{}' not set", name))
316                }
317                Err(std::env::VarError::NotUnicode(_)) => AppEffectResult::Error(format!(
318                    "Environment variable '{}' contains invalid Unicode",
319                    name
320                )),
321            },
322
323            AppEffect::SetEnvVar { name, value } => {
324                std::env::set_var(&name, &value);
325                AppEffectResult::Ok
326            }
327
328            // =========================================================================
329            // Logging Effects
330            // =========================================================================
331            // Logging is handled elsewhere (by the logger), so these are no-ops.
332            // The effect system captures them for testing/recording purposes.
333            AppEffect::LogInfo { message: _ }
334            | AppEffect::LogSuccess { message: _ }
335            | AppEffect::LogWarn { message: _ }
336            | AppEffect::LogError { message: _ } => AppEffectResult::Ok,
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_real_handler_default() {
347        let handler = RealAppEffectHandler::default();
348        assert!(handler.workspace_root.is_none());
349    }
350
351    #[test]
352    fn test_real_handler_with_workspace_root() {
353        let root = PathBuf::from("/some/path");
354        let handler = RealAppEffectHandler::with_workspace_root(root.clone());
355        assert_eq!(handler.workspace_root, Some(root));
356    }
357
358    #[test]
359    fn test_resolve_path_absolute() {
360        let handler = RealAppEffectHandler::with_workspace_root(PathBuf::from("/workspace"));
361        let absolute = PathBuf::from("/absolute/path");
362        assert_eq!(handler.resolve_path(&absolute), absolute);
363    }
364
365    #[test]
366    fn test_resolve_path_relative_with_root() {
367        let handler = RealAppEffectHandler::with_workspace_root(PathBuf::from("/workspace"));
368        let relative = PathBuf::from("relative/path");
369        assert_eq!(
370            handler.resolve_path(&relative),
371            PathBuf::from("/workspace/relative/path")
372        );
373    }
374
375    #[test]
376    fn test_resolve_path_relative_without_root() {
377        let handler = RealAppEffectHandler::new();
378        let relative = PathBuf::from("relative/path");
379        assert_eq!(handler.resolve_path(&relative), relative);
380    }
381
382    #[test]
383    fn test_path_exists_effect() {
384        let mut handler = RealAppEffectHandler::new();
385        // Test with a path that definitely exists (this file's directory)
386        let result = handler.execute(AppEffect::PathExists {
387            path: PathBuf::from("."),
388        });
389        assert!(matches!(result, AppEffectResult::Bool(true)));
390    }
391
392    #[test]
393    fn test_path_not_exists_effect() {
394        let mut handler = RealAppEffectHandler::new();
395        let result = handler.execute(AppEffect::PathExists {
396            path: PathBuf::from("/nonexistent/path/that/should/not/exist/12345"),
397        });
398        assert!(matches!(result, AppEffectResult::Bool(false)));
399    }
400
401    #[test]
402    fn test_get_env_var_effect() {
403        let mut handler = RealAppEffectHandler::new();
404        // PATH should always be set
405        let result = handler.execute(AppEffect::GetEnvVar {
406            name: "PATH".to_string(),
407        });
408        assert!(matches!(result, AppEffectResult::String(_)));
409    }
410
411    #[test]
412    fn test_get_env_var_not_set() {
413        let mut handler = RealAppEffectHandler::new();
414        let result = handler.execute(AppEffect::GetEnvVar {
415            name: "DEFINITELY_NOT_SET_ENV_VAR_12345".to_string(),
416        });
417        assert!(matches!(result, AppEffectResult::Error(_)));
418    }
419
420    #[test]
421    fn test_set_env_var_effect() {
422        let mut handler = RealAppEffectHandler::new();
423        let var_name = "TEST_RALPH_ENV_VAR_12345";
424
425        // Set the variable
426        let result = handler.execute(AppEffect::SetEnvVar {
427            name: var_name.to_string(),
428            value: "test_value".to_string(),
429        });
430        assert!(matches!(result, AppEffectResult::Ok));
431
432        // Verify it was set
433        assert_eq!(std::env::var(var_name).ok(), Some("test_value".to_string()));
434
435        // Clean up
436        std::env::remove_var(var_name);
437    }
438
439    #[test]
440    fn test_logging_effects_are_noops() {
441        let mut handler = RealAppEffectHandler::new();
442
443        let effects = vec![
444            AppEffect::LogInfo {
445                message: "test".to_string(),
446            },
447            AppEffect::LogSuccess {
448                message: "test".to_string(),
449            },
450            AppEffect::LogWarn {
451                message: "test".to_string(),
452            },
453            AppEffect::LogError {
454                message: "test".to_string(),
455            },
456        ];
457
458        for effect in effects {
459            let result = handler.execute(effect);
460            assert!(
461                matches!(result, AppEffectResult::Ok),
462                "Logging effect should return Ok"
463            );
464        }
465    }
466
467    #[test]
468    fn test_rebase_effects_require_executor() {
469        let mut handler = RealAppEffectHandler::new();
470
471        let result = handler.execute(AppEffect::GitRebaseOnto {
472            upstream_branch: "main".to_string(),
473        });
474        assert!(matches!(result, AppEffectResult::Error(_)));
475
476        let result = handler.execute(AppEffect::GitContinueRebase);
477        assert!(matches!(result, AppEffectResult::Error(_)));
478
479        let result = handler.execute(AppEffect::GitAbortRebase);
480        assert!(matches!(result, AppEffectResult::Error(_)));
481    }
482}