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    #[must_use]
42    pub const fn new() -> Self {
43        Self {
44            workspace_root: None,
45        }
46    }
47
48    /// Create a new handler with a specific workspace root.
49    ///
50    /// All relative paths in effects will be resolved against this root.
51    ///
52    /// # Arguments
53    ///
54    /// * `root` - The workspace root directory for path resolution.
55    #[must_use]
56    pub const fn with_workspace_root(root: PathBuf) -> Self {
57        Self {
58            workspace_root: Some(root),
59        }
60    }
61
62    /// Resolve a path against the workspace root if set.
63    ///
64    /// If the path is absolute, it is returned as-is.
65    /// If the path is relative and a workspace root is set, the path is
66    /// joined to the workspace root.
67    /// If the path is relative and no workspace root is set, the path is
68    /// returned as-is.
69    fn resolve_path(&self, path: &Path) -> PathBuf {
70        if path.is_absolute() {
71            path.to_path_buf()
72        } else if let Some(ref root) = self.workspace_root {
73            root.join(path)
74        } else {
75            path.to_path_buf()
76        }
77    }
78
79    fn execute_set_current_dir(&self, path: &Path) -> AppEffectResult {
80        let resolved = self.resolve_path(path);
81        match std::env::set_current_dir(&resolved) {
82            Ok(()) => AppEffectResult::Ok,
83            Err(error) => AppEffectResult::Error(format!(
84                "Failed to set current directory to '{}': {}",
85                resolved.display(),
86                error
87            )),
88        }
89    }
90
91    fn execute_write_file(&self, path: &Path, content: String) -> AppEffectResult {
92        let resolved = self.resolve_path(path);
93        if let Some(parent) = resolved.parent() {
94            if let Err(error) = std::fs::create_dir_all(parent) {
95                return AppEffectResult::Error(format!(
96                    "Failed to create parent directories for '{}': {}",
97                    resolved.display(),
98                    error
99                ));
100            }
101        }
102
103        match std::fs::write(&resolved, content) {
104            Ok(()) => AppEffectResult::Ok,
105            Err(error) => AppEffectResult::Error(format!(
106                "Failed to write file '{}': {}",
107                resolved.display(),
108                error
109            )),
110        }
111    }
112
113    fn execute_read_file(&self, path: &Path) -> AppEffectResult {
114        let resolved = self.resolve_path(path);
115        match std::fs::read_to_string(&resolved) {
116            Ok(content) => AppEffectResult::String(content),
117            Err(error) => AppEffectResult::Error(format!(
118                "Failed to read file '{}': {}",
119                resolved.display(),
120                error
121            )),
122        }
123    }
124
125    fn execute_delete_file(&self, path: &Path) -> AppEffectResult {
126        let resolved = self.resolve_path(path);
127        match std::fs::remove_file(&resolved) {
128            Ok(()) => AppEffectResult::Ok,
129            Err(error) => AppEffectResult::Error(format!(
130                "Failed to delete file '{}': {}",
131                resolved.display(),
132                error
133            )),
134        }
135    }
136
137    fn execute_create_dir(&self, path: &Path) -> AppEffectResult {
138        let resolved = self.resolve_path(path);
139        match std::fs::create_dir_all(&resolved) {
140            Ok(()) => AppEffectResult::Ok,
141            Err(error) => AppEffectResult::Error(format!(
142                "Failed to create directory '{}': {}",
143                resolved.display(),
144                error
145            )),
146        }
147    }
148
149    fn execute_path_exists(&self, path: &Path) -> AppEffectResult {
150        let resolved = self.resolve_path(path);
151        AppEffectResult::Bool(resolved.exists())
152    }
153
154    fn execute_set_read_only(&self, path: &Path, readonly: bool) -> AppEffectResult {
155        let resolved = self.resolve_path(path);
156        match std::fs::metadata(&resolved) {
157            Ok(metadata) => {
158                let mut permissions = metadata.permissions();
159                permissions.set_readonly(readonly);
160                match std::fs::set_permissions(&resolved, permissions) {
161                    Ok(()) => AppEffectResult::Ok,
162                    Err(error) => AppEffectResult::Error(format!(
163                        "Failed to set permissions on '{}': {}",
164                        resolved.display(),
165                        error
166                    )),
167                }
168            }
169            Err(error) => AppEffectResult::Error(format!(
170                "Failed to get metadata for '{}': {}",
171                resolved.display(),
172                error
173            )),
174        }
175    }
176
177    fn execute_git_require_repo() -> AppEffectResult {
178        match crate::git_helpers::require_git_repo() {
179            Ok(()) => AppEffectResult::Ok,
180            Err(error) => AppEffectResult::Error(format!("Not in a git repository: {error}")),
181        }
182    }
183
184    fn execute_git_get_repo_root() -> AppEffectResult {
185        match crate::git_helpers::get_repo_root() {
186            Ok(root) => AppEffectResult::Path(root),
187            Err(error) => AppEffectResult::Error(format!("Failed to get repository root: {error}")),
188        }
189    }
190
191    fn execute_git_get_head_oid() -> AppEffectResult {
192        match crate::git_helpers::get_current_head_oid() {
193            Ok(oid) => AppEffectResult::String(oid),
194            Err(error) => AppEffectResult::Error(format!("Failed to get HEAD OID: {error}")),
195        }
196    }
197
198    fn execute_git_diff() -> AppEffectResult {
199        match crate::git_helpers::git_diff() {
200            Ok(diff) => AppEffectResult::String(diff),
201            Err(error) => AppEffectResult::Error(format!("Failed to get git diff: {error}")),
202        }
203    }
204
205    fn execute_git_diff_from(start_oid: &str) -> AppEffectResult {
206        match crate::git_helpers::git_diff_from(start_oid) {
207            Ok(diff) => AppEffectResult::String(diff),
208            Err(error) => AppEffectResult::Error(format!(
209                "Failed to get git diff from '{start_oid}': {error}"
210            )),
211        }
212    }
213
214    fn execute_git_diff_from_start() -> AppEffectResult {
215        match crate::git_helpers::get_git_diff_from_start() {
216            Ok(diff) => AppEffectResult::String(diff),
217            Err(error) => {
218                AppEffectResult::Error(format!("Failed to get diff from start commit: {error}"))
219            }
220        }
221    }
222
223    fn execute_git_snapshot() -> AppEffectResult {
224        match crate::git_helpers::git_snapshot() {
225            Ok(snapshot) => AppEffectResult::String(snapshot),
226            Err(error) => AppEffectResult::Error(format!("Failed to create git snapshot: {error}")),
227        }
228    }
229
230    fn execute_git_add_all() -> AppEffectResult {
231        match crate::git_helpers::git_add_all() {
232            Ok(staged) => AppEffectResult::Bool(staged),
233            Err(error) => AppEffectResult::Error(format!("Failed to stage all changes: {error}")),
234        }
235    }
236
237    fn execute_git_commit(
238        message: &str,
239        user_name: Option<&str>,
240        user_email: Option<&str>,
241    ) -> AppEffectResult {
242        match crate::git_helpers::git_commit(message, user_name, user_email, None) {
243            Ok(Some(oid)) => AppEffectResult::Commit(CommitResult::Success(oid.to_string())),
244            Ok(None) => AppEffectResult::Commit(CommitResult::NoChanges),
245            Err(error) => AppEffectResult::Error(format!("Failed to create commit: {error}")),
246        }
247    }
248
249    fn execute_git_save_start_commit() -> AppEffectResult {
250        match crate::git_helpers::save_start_commit() {
251            Ok(()) => AppEffectResult::Ok,
252            Err(error) => AppEffectResult::Error(format!("Failed to save start commit: {error}")),
253        }
254    }
255
256    fn execute_git_reset_start_commit() -> AppEffectResult {
257        match crate::git_helpers::reset_start_commit() {
258            Ok(result) => AppEffectResult::String(result.oid),
259            Err(error) => AppEffectResult::Error(format!("Failed to reset start commit: {error}")),
260        }
261    }
262
263    fn execute_git_rebase_onto(_upstream_branch: String) -> AppEffectResult {
264        AppEffectResult::Error(
265            "GitRebaseOnto requires executor injection - use pipeline runner".to_string(),
266        )
267    }
268
269    fn execute_git_get_conflicted_files() -> AppEffectResult {
270        match crate::git_helpers::get_conflicted_files() {
271            Ok(files) => AppEffectResult::StringList(files),
272            Err(error) => {
273                AppEffectResult::Error(format!("Failed to get conflicted files: {error}"))
274            }
275        }
276    }
277
278    fn execute_git_continue_rebase() -> AppEffectResult {
279        AppEffectResult::Error(
280            "GitContinueRebase requires executor injection - use pipeline runner".to_string(),
281        )
282    }
283
284    fn execute_git_abort_rebase() -> AppEffectResult {
285        AppEffectResult::Error(
286            "GitAbortRebase requires executor injection - use pipeline runner".to_string(),
287        )
288    }
289
290    fn execute_git_get_default_branch() -> AppEffectResult {
291        match crate::git_helpers::get_default_branch() {
292            Ok(branch) => AppEffectResult::String(branch),
293            Err(error) => AppEffectResult::Error(format!("Failed to get default branch: {error}")),
294        }
295    }
296
297    fn execute_git_is_main_branch() -> AppEffectResult {
298        match crate::git_helpers::is_main_or_master_branch() {
299            Ok(is_main) => AppEffectResult::Bool(is_main),
300            Err(error) => AppEffectResult::Error(format!("Failed to check branch: {error}")),
301        }
302    }
303
304    fn execute_get_env_var(name: &str) -> AppEffectResult {
305        match std::env::var(name) {
306            Ok(value) => AppEffectResult::String(value),
307            Err(std::env::VarError::NotPresent) => {
308                AppEffectResult::Error(format!("Environment variable '{name}' not set"))
309            }
310            Err(std::env::VarError::NotUnicode(_)) => AppEffectResult::Error(format!(
311                "Environment variable '{name}' contains invalid Unicode"
312            )),
313        }
314    }
315
316    fn execute_set_env_var(name: &str, value: &str) -> AppEffectResult {
317        std::env::set_var(name, value);
318        AppEffectResult::Ok
319    }
320}
321
322impl Default for RealAppEffectHandler {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328impl AppEffectHandler for RealAppEffectHandler {
329    fn execute(&mut self, effect: AppEffect) -> AppEffectResult {
330        match effect {
331            AppEffect::SetCurrentDir { path } => self.execute_set_current_dir(&path),
332            AppEffect::WriteFile { path, content } => self.execute_write_file(&path, content),
333            AppEffect::ReadFile { path } => self.execute_read_file(&path),
334            AppEffect::DeleteFile { path } => self.execute_delete_file(&path),
335            AppEffect::CreateDir { path } => self.execute_create_dir(&path),
336            AppEffect::PathExists { path } => self.execute_path_exists(&path),
337            AppEffect::SetReadOnly { path, readonly } => {
338                self.execute_set_read_only(&path, readonly)
339            }
340            AppEffect::GitRequireRepo => Self::execute_git_require_repo(),
341            AppEffect::GitGetRepoRoot => Self::execute_git_get_repo_root(),
342            AppEffect::GitGetHeadOid => Self::execute_git_get_head_oid(),
343            AppEffect::GitDiff => Self::execute_git_diff(),
344            AppEffect::GitDiffFrom { start_oid } => Self::execute_git_diff_from(&start_oid),
345            AppEffect::GitDiffFromStart => Self::execute_git_diff_from_start(),
346            AppEffect::GitSnapshot => Self::execute_git_snapshot(),
347            AppEffect::GitAddAll => Self::execute_git_add_all(),
348            AppEffect::GitCommit {
349                message,
350                user_name,
351                user_email,
352            } => Self::execute_git_commit(&message, user_name.as_deref(), user_email.as_deref()),
353            AppEffect::GitSaveStartCommit => Self::execute_git_save_start_commit(),
354            AppEffect::GitResetStartCommit => Self::execute_git_reset_start_commit(),
355            AppEffect::GitRebaseOnto { upstream_branch } => {
356                Self::execute_git_rebase_onto(upstream_branch)
357            }
358            AppEffect::GitGetConflictedFiles => Self::execute_git_get_conflicted_files(),
359            AppEffect::GitContinueRebase => Self::execute_git_continue_rebase(),
360            AppEffect::GitAbortRebase => Self::execute_git_abort_rebase(),
361            AppEffect::GitGetDefaultBranch => Self::execute_git_get_default_branch(),
362            AppEffect::GitIsMainBranch => Self::execute_git_is_main_branch(),
363            AppEffect::GetEnvVar { name } => Self::execute_get_env_var(&name),
364            AppEffect::SetEnvVar { name, value } => Self::execute_set_env_var(&name, &value),
365            AppEffect::LogInfo { message: _ }
366            | AppEffect::LogSuccess { message: _ }
367            | AppEffect::LogWarn { message: _ }
368            | AppEffect::LogError { message: _ } => AppEffectResult::Ok,
369        }
370    }
371}
372
373#[cfg(test)]
374mod tests;