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;