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