Skip to main content

ralph_workflow/app/
effectful.rs

1//! Effectful app operations that use `AppEffect` handlers.
2//!
3//! This module provides functions that execute CLI operations via an
4//! [`AppEffectHandler`], enabling testing without real side effects.
5//!
6//! # Architecture
7//!
8//! Each function in this module:
9//! 1. Takes an `AppEffectHandler` reference
10//! 2. Executes effects through the handler
11//! 3. Returns strongly-typed results
12//!
13//! In production, use `RealAppEffectHandler` for actual I/O.
14//! In tests, use `MockAppEffectHandler` to verify behavior without side effects.
15//!
16//! # Example
17//!
18//! ```ignore
19//! use ralph_workflow::app::effectful::handle_reset_start_commit;
20//! use ralph_workflow::app::mock_effect_handler::MockAppEffectHandler;
21//!
22//! // Test without real git or filesystem
23//! let mut handler = MockAppEffectHandler::new()
24//!     .with_head_oid("abc123");
25//!
26//! let result = handle_reset_start_commit(&mut handler, None);
27//! assert!(result.is_ok());
28//! ```
29
30use super::effect::{AppEffect, AppEffectHandler, AppEffectResult};
31use std::path::PathBuf;
32use thiserror::Error;
33
34/// XSD schemas for XML validation - included at compile time.
35const PLAN_XSD_SCHEMA: &str = include_str!("../files/llm_output_extraction/plan.xsd");
36const DEVELOPMENT_RESULT_XSD_SCHEMA: &str =
37    include_str!("../files/llm_output_extraction/development_result.xsd");
38const ISSUES_XSD_SCHEMA: &str = include_str!("../files/llm_output_extraction/issues.xsd");
39const FIX_RESULT_XSD_SCHEMA: &str = include_str!("../files/llm_output_extraction/fix_result.xsd");
40const COMMIT_MESSAGE_XSD_SCHEMA: &str =
41    include_str!("../files/llm_output_extraction/commit_message.xsd");
42
43// Re-use the canonical vague line constants from context module
44use crate::files::context::{VAGUE_ISSUES_LINE, VAGUE_NOTES_LINE, VAGUE_STATUS_LINE};
45
46#[derive(Debug, Error)]
47pub enum AppEffectError {
48    #[error("effect {effect:?} handler failed: {message}")]
49    Handler { effect: AppEffect, message: String },
50    #[error("effect {effect:?} returned unexpected result: {result:?}")]
51    UnexpectedResult {
52        effect: AppEffect,
53        result: AppEffectResult,
54    },
55}
56
57fn execute_expect_ok<H: AppEffectHandler>(
58    handler: &mut H,
59    effect: AppEffect,
60) -> Result<(), AppEffectError> {
61    match handler.execute(effect.clone()) {
62        AppEffectResult::Ok => Ok(()),
63        AppEffectResult::Error(message) => Err(AppEffectError::Handler { effect, message }),
64        other => Err(AppEffectError::UnexpectedResult {
65            effect,
66            result: other,
67        }),
68    }
69}
70
71fn execute_expect_string<H: AppEffectHandler>(
72    handler: &mut H,
73    effect: AppEffect,
74) -> Result<String, AppEffectError> {
75    match handler.execute(effect.clone()) {
76        AppEffectResult::String(value) => Ok(value),
77        AppEffectResult::Error(message) => Err(AppEffectError::Handler { effect, message }),
78        other => Err(AppEffectError::UnexpectedResult {
79            effect,
80            result: other,
81        }),
82    }
83}
84
85fn execute_expect_path<H: AppEffectHandler>(
86    handler: &mut H,
87    effect: AppEffect,
88) -> Result<PathBuf, AppEffectError> {
89    match handler.execute(effect.clone()) {
90        AppEffectResult::Path(path) => Ok(path),
91        AppEffectResult::Error(message) => Err(AppEffectError::Handler { effect, message }),
92        other => Err(AppEffectError::UnexpectedResult {
93            effect,
94            result: other,
95        }),
96    }
97}
98
99fn execute_expect_bool<H: AppEffectHandler>(
100    handler: &mut H,
101    effect: AppEffect,
102) -> Result<bool, AppEffectError> {
103    match handler.execute(effect.clone()) {
104        AppEffectResult::Bool(value) => Ok(value),
105        AppEffectResult::Error(message) => Err(AppEffectError::Handler { effect, message }),
106        other => Err(AppEffectError::UnexpectedResult {
107            effect,
108            result: other,
109        }),
110    }
111}
112
113/// Handle the `--reset-start-commit` command using effects.
114///
115/// This function resets the `.agent/start_commit` file to track the
116/// merge-base with the default branch (or HEAD if on main/master).
117///
118/// # Arguments
119///
120/// * `handler` - The effect handler to execute operations through
121/// * `working_dir_override` - Optional directory override (for testing)
122///
123/// # Returns
124///
125/// Returns the OID that was written to the `start_commit` file, or an error.
126///
127/// # Effects Emitted
128///
129/// 1. `SetCurrentDir` - If `working_dir_override` is provided
130/// 2. `GitRequireRepo` - Validates git repository exists
131/// 3. `GitGetRepoRoot` - Gets the repository root path
132/// 4. `SetCurrentDir` - Changes to repo root (if no override)
133/// 5. `GitResetStartCommit` - Resets the start commit reference
134///
135/// # Errors
136///
137/// Returns error if the operation fails.
138pub fn handle_reset_start_commit<H: AppEffectHandler>(
139    handler: &mut H,
140    working_dir_override: Option<&PathBuf>,
141) -> Result<String, AppEffectError> {
142    if let Some(dir) = working_dir_override {
143        execute_expect_ok(handler, AppEffect::SetCurrentDir { path: dir.clone() })?;
144    }
145
146    execute_expect_ok(handler, AppEffect::GitRequireRepo)?;
147
148    let repo_root = execute_expect_path(handler, AppEffect::GitGetRepoRoot)?;
149
150    if working_dir_override.is_none() {
151        execute_expect_ok(
152            handler,
153            AppEffect::SetCurrentDir {
154                path: repo_root.clone(),
155            },
156        )?;
157    }
158
159    execute_expect_string(handler, AppEffect::GitResetStartCommit)
160}
161
162/// Save the starting commit at pipeline start using effects.
163///
164/// This records the current HEAD (or merge-base on feature branches) to
165/// `.agent/start_commit` for incremental diff generation.
166///
167/// # Returns
168///
169/// Returns the OID that was saved, or an error.
170///
171/// # Errors
172///
173/// Returns error if the operation fails.
174pub fn save_start_commit<H: AppEffectHandler>(handler: &mut H) -> Result<String, AppEffectError> {
175    execute_expect_string(handler, AppEffect::GitSaveStartCommit)
176}
177
178/// Check if the current branch is main/master using effects.
179///
180/// # Returns
181///
182/// Returns `true` if on main or master branch, `false` otherwise.
183///
184/// # Errors
185///
186/// Returns error if the operation fails.
187pub fn is_on_main_branch<H: AppEffectHandler>(handler: &mut H) -> Result<bool, AppEffectError> {
188    execute_expect_bool(handler, AppEffect::GitIsMainBranch)
189}
190
191/// Get the current HEAD OID using effects.
192///
193/// # Returns
194///
195/// Returns the 40-character hex OID of HEAD, or an error.
196///
197/// # Errors
198///
199/// Returns error if the operation fails.
200pub fn get_head_oid<H: AppEffectHandler>(handler: &mut H) -> Result<String, AppEffectError> {
201    execute_expect_string(handler, AppEffect::GitGetHeadOid)
202}
203
204/// Validate that we're in a git repository using effects.
205///
206/// # Returns
207///
208/// Returns `Ok(())` if in a git repo, error otherwise.
209///
210/// # Errors
211///
212/// Returns error if the operation fails.
213pub fn require_repo<H: AppEffectHandler>(handler: &mut H) -> Result<(), AppEffectError> {
214    execute_expect_ok(handler, AppEffect::GitRequireRepo)
215}
216
217/// Get the repository root path using effects.
218///
219/// # Returns
220///
221/// Returns the absolute path to the repository root.
222///
223/// # Errors
224///
225/// Returns error if the operation fails.
226pub fn get_repo_root<H: AppEffectHandler>(handler: &mut H) -> Result<PathBuf, AppEffectError> {
227    execute_expect_path(handler, AppEffect::GitGetRepoRoot)
228}
229
230/// Ensure required files and directories exist using effects.
231///
232/// Creates the `.agent/logs` and `.agent/tmp` directories if they don't exist.
233/// Also writes XSD schemas to `.agent/tmp/` for agent self-validation.
234///
235/// When `isolation_mode` is true (the default), STATUS.md, NOTES.md and ISSUES.md
236/// are NOT created. This prevents context contamination from previous runs.
237///
238/// # Arguments
239///
240/// * `handler` - The effect handler to execute operations through
241/// * `isolation_mode` - If true, skip creating STATUS.md, NOTES.md, ISSUES.md
242///
243/// # Returns
244///
245/// Returns `Ok(())` on success or `AppEffectError` when an effect fails.
246///
247/// # Effects Emitted
248///
249/// 1. `CreateDir` - Creates `.agent/logs` directory
250/// 2. `CreateDir` - Creates `.agent/tmp` directory
251/// 3. `WriteFile` - Writes XSD schemas to `.agent/tmp/`
252/// 4. `WriteFile` - Creates STATUS.md, NOTES.md, ISSUES.md (if not isolation mode)
253///
254/// # Errors
255///
256/// Returns error if the operation fails.
257pub fn ensure_files_effectful<H: AppEffectHandler>(
258    handler: &mut H,
259    isolation_mode: bool,
260) -> Result<(), AppEffectError> {
261    execute_expect_ok(
262        handler,
263        AppEffect::CreateDir {
264            path: PathBuf::from(".agent/logs"),
265        },
266    )?;
267
268    execute_expect_ok(
269        handler,
270        AppEffect::CreateDir {
271            path: PathBuf::from(".agent/tmp"),
272        },
273    )?;
274
275    let schemas = [
276        (".agent/tmp/plan.xsd", PLAN_XSD_SCHEMA),
277        (
278            ".agent/tmp/development_result.xsd",
279            DEVELOPMENT_RESULT_XSD_SCHEMA,
280        ),
281        (".agent/tmp/issues.xsd", ISSUES_XSD_SCHEMA),
282        (".agent/tmp/fix_result.xsd", FIX_RESULT_XSD_SCHEMA),
283        (".agent/tmp/commit_message.xsd", COMMIT_MESSAGE_XSD_SCHEMA),
284    ];
285
286    schemas
287        .iter()
288        .map(|(path, content)| {
289            execute_expect_ok(
290                handler,
291                AppEffect::WriteFile {
292                    path: PathBuf::from(*path),
293                    content: content.to_string(),
294                },
295            )
296        })
297        .collect::<Result<Vec<_>, _>>()?;
298
299    if !isolation_mode {
300        let context_files = [
301            (".agent/STATUS.md", VAGUE_STATUS_LINE),
302            (".agent/NOTES.md", VAGUE_NOTES_LINE),
303            (".agent/ISSUES.md", VAGUE_ISSUES_LINE),
304        ];
305
306        context_files
307            .iter()
308            .map(|(path, line)| {
309                let content = format!("{}\n", line.lines().next().unwrap_or_default().trim());
310                execute_expect_ok(
311                    handler,
312                    AppEffect::WriteFile {
313                        path: PathBuf::from(*path),
314                        content,
315                    },
316                )
317            })
318            .collect::<Result<Vec<_>, _>>()?;
319    }
320
321    Ok(())
322}
323
324/// Reset context for isolation mode by deleting STATUS.md, NOTES.md, ISSUES.md.
325///
326/// This function is called at the start of each Ralph run when isolation mode
327/// is enabled (the default). It prevents context contamination by removing
328/// any stale status, notes, or issues from previous runs.
329///
330/// # Arguments
331///
332/// * `handler` - The effect handler to execute operations through
333///
334/// # Returns
335///
336/// Returns `Ok(())` on success or an error message.
337///
338/// # Effects Emitted
339///
340/// 1. `PathExists` - Checks if each context file exists
341/// 2. `DeleteFile` - Deletes each existing context file
342///
343/// # Errors
344///
345/// Returns error if the operation fails.
346pub fn reset_context_for_isolation_effectful<H: AppEffectHandler>(
347    handler: &mut H,
348) -> Result<(), AppEffectError> {
349    let context_files = [
350        PathBuf::from(".agent/STATUS.md"),
351        PathBuf::from(".agent/NOTES.md"),
352        PathBuf::from(".agent/ISSUES.md"),
353    ];
354
355    context_files
356        .iter()
357        .map(|path| -> Result<(), AppEffectError> {
358            let exists =
359                execute_expect_bool(handler, AppEffect::PathExists { path: path.clone() })?;
360            if exists {
361                execute_expect_ok(handler, AppEffect::DeleteFile { path: path.clone() })?;
362            }
363            Ok(())
364        })
365        .collect::<Result<Vec<_>, _>>()?;
366
367    Ok(())
368}
369
370/// Check if PROMPT.md exists using effects.
371///
372/// # Arguments
373///
374/// * `handler` - The effect handler to execute operations through
375///
376/// # Returns
377///
378/// Returns `Ok(true)` if PROMPT.md exists, `Ok(false)` otherwise.
379///
380/// # Effects Emitted
381///
382/// 1. `PathExists` - Checks if PROMPT.md exists
383///
384/// # Errors
385///
386/// Returns error if the operation fails.
387pub fn check_prompt_exists_effectful<H: AppEffectHandler>(
388    handler: &mut H,
389) -> Result<bool, AppEffectError> {
390    execute_expect_bool(
391        handler,
392        AppEffect::PathExists {
393            path: PathBuf::from("PROMPT.md"),
394        },
395    )
396}
397
398#[cfg(test)]
399mod tests;