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