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