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