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 {
368    use super::*;
369    use crate::app::mock_effect_handler::MockAppEffectHandler;
370
371    #[test]
372    fn test_reset_start_commit_emits_correct_effects() {
373        let mut handler = MockAppEffectHandler::new();
374
375        let result = handle_reset_start_commit(&mut handler, None);
376
377        assert!(result.is_ok());
378        let captured = handler.captured();
379        assert!(
380            captured
381                .iter()
382                .any(|e| matches!(e, AppEffect::GitRequireRepo)),
383            "should emit GitRequireRepo"
384        );
385        assert!(
386            captured
387                .iter()
388                .any(|e| matches!(e, AppEffect::GitGetRepoRoot)),
389            "should emit GitGetRepoRoot"
390        );
391        assert!(
392            captured
393                .iter()
394                .any(|e| matches!(e, AppEffect::GitResetStartCommit)),
395            "should emit GitResetStartCommit"
396        );
397    }
398
399    #[test]
400    fn test_reset_start_commit_with_working_dir() {
401        let mut handler = MockAppEffectHandler::new();
402        let dir = PathBuf::from("/test/dir");
403
404        let result = handle_reset_start_commit(&mut handler, Some(&dir));
405
406        assert!(result.is_ok());
407        let captured = handler.captured();
408        assert!(
409            captured
410                .iter()
411                .any(|e| matches!(e, AppEffect::SetCurrentDir { path } if path == &dir)),
412            "should emit SetCurrentDir with the override path"
413        );
414    }
415
416    #[test]
417    fn test_reset_start_commit_fails_without_repo() {
418        let mut handler = MockAppEffectHandler::new().without_repo();
419
420        let result = handle_reset_start_commit(&mut handler, None);
421
422        assert!(result.is_err());
423        assert!(result.unwrap_err().contains("git repository"));
424    }
425
426    #[test]
427    fn test_save_start_commit_returns_oid() {
428        let expected_oid = "abc123def456";
429        let mut handler = MockAppEffectHandler::new().with_head_oid(expected_oid);
430
431        let result = save_start_commit(&mut handler);
432
433        assert!(result.is_ok());
434        assert_eq!(result.unwrap(), expected_oid);
435    }
436
437    #[test]
438    fn test_is_on_main_branch_true() {
439        let mut handler = MockAppEffectHandler::new().on_main_branch();
440
441        let result = is_on_main_branch(&mut handler);
442
443        assert!(result.is_ok());
444        assert!(result.unwrap());
445    }
446
447    #[test]
448    fn test_is_on_main_branch_false() {
449        let mut handler = MockAppEffectHandler::new(); // default is not on main
450
451        let result = is_on_main_branch(&mut handler);
452
453        assert!(result.is_ok());
454        assert!(!result.unwrap());
455    }
456
457    #[test]
458    fn test_get_head_oid() {
459        let expected = "1234567890abcdef1234567890abcdef12345678";
460        let mut handler = MockAppEffectHandler::new().with_head_oid(expected);
461
462        let result = get_head_oid(&mut handler);
463
464        assert!(result.is_ok());
465        assert_eq!(result.unwrap(), expected);
466    }
467
468    #[test]
469    fn test_require_repo_success() {
470        let mut handler = MockAppEffectHandler::new();
471
472        let result = require_repo(&mut handler);
473
474        assert!(result.is_ok());
475    }
476
477    #[test]
478    fn test_require_repo_failure() {
479        let mut handler = MockAppEffectHandler::new().without_repo();
480
481        let result = require_repo(&mut handler);
482
483        assert!(result.is_err());
484    }
485
486    #[test]
487    fn test_get_repo_root() {
488        let mut handler = MockAppEffectHandler::new();
489
490        let result = get_repo_root(&mut handler);
491
492        assert!(result.is_ok());
493        // Default mock CWD is "/"
494        assert_eq!(result.unwrap(), PathBuf::from("/"));
495    }
496
497    #[test]
498    fn test_ensure_files_creates_directories() {
499        let mut handler = MockAppEffectHandler::new();
500
501        let result = ensure_files_effectful(&mut handler, true);
502
503        assert!(result.is_ok());
504
505        // Verify directories were created via effects
506        let captured = handler.captured();
507        assert!(
508            captured.iter().any(
509                |e| matches!(e, AppEffect::CreateDir { path } if path.ends_with(".agent/logs"))
510            ),
511            "should create .agent/logs directory"
512        );
513        assert!(
514            captured.iter().any(
515                |e| matches!(e, AppEffect::CreateDir { path } if path.ends_with(".agent/tmp"))
516            ),
517            "should create .agent/tmp directory"
518        );
519    }
520
521    #[test]
522    fn test_ensure_files_writes_xsd_schemas() {
523        let mut handler = MockAppEffectHandler::new();
524
525        let result = ensure_files_effectful(&mut handler, true);
526
527        assert!(result.is_ok());
528
529        // Verify XSD schemas were written via effects
530        let captured = handler.captured();
531        assert!(
532            captured.iter().any(
533                |e| matches!(e, AppEffect::WriteFile { path, .. } if path.ends_with("plan.xsd"))
534            ),
535            "should write plan.xsd"
536        );
537        assert!(
538            captured.iter().any(
539                |e| matches!(e, AppEffect::WriteFile { path, .. } if path.ends_with("issues.xsd"))
540            ),
541            "should write issues.xsd"
542        );
543    }
544
545    #[test]
546    fn test_ensure_files_non_isolation_creates_context_files() {
547        let mut handler = MockAppEffectHandler::new();
548
549        // isolation_mode = false should create STATUS.md, NOTES.md, ISSUES.md
550        let result = ensure_files_effectful(&mut handler, false);
551
552        assert!(result.is_ok());
553
554        let captured = handler.captured();
555        assert!(
556            captured.iter().any(
557                |e| matches!(e, AppEffect::WriteFile { path, .. } if path.ends_with("STATUS.md"))
558            ),
559            "should create STATUS.md in non-isolation mode"
560        );
561        assert!(
562            captured.iter().any(
563                |e| matches!(e, AppEffect::WriteFile { path, .. } if path.ends_with("NOTES.md"))
564            ),
565            "should create NOTES.md in non-isolation mode"
566        );
567        assert!(
568            captured.iter().any(
569                |e| matches!(e, AppEffect::WriteFile { path, .. } if path.ends_with("ISSUES.md"))
570            ),
571            "should create ISSUES.md in non-isolation mode"
572        );
573    }
574
575    #[test]
576    fn test_ensure_files_isolation_skips_context_files() {
577        let mut handler = MockAppEffectHandler::new();
578
579        // isolation_mode = true should NOT create STATUS.md, NOTES.md, ISSUES.md
580        let result = ensure_files_effectful(&mut handler, true);
581
582        assert!(result.is_ok());
583
584        let captured = handler.captured();
585        assert!(
586            !captured.iter().any(
587                |e| matches!(e, AppEffect::WriteFile { path, .. } if path.ends_with("STATUS.md"))
588            ),
589            "should NOT create STATUS.md in isolation mode"
590        );
591        assert!(
592            !captured.iter().any(
593                |e| matches!(e, AppEffect::WriteFile { path, .. } if path.ends_with("NOTES.md"))
594            ),
595            "should NOT create NOTES.md in isolation mode"
596        );
597        assert!(
598            !captured.iter().any(
599                |e| matches!(e, AppEffect::WriteFile { path, .. } if path.ends_with("ISSUES.md"))
600            ),
601            "should NOT create ISSUES.md in isolation mode"
602        );
603    }
604
605    #[test]
606    fn test_reset_context_for_isolation_deletes_existing_files() {
607        // Files exist - should emit delete effects
608        let mut handler = MockAppEffectHandler::new()
609            .with_file(".agent/STATUS.md", "old status")
610            .with_file(".agent/NOTES.md", "old notes")
611            .with_file(".agent/ISSUES.md", "old issues");
612
613        let result = reset_context_for_isolation_effectful(&mut handler);
614
615        assert!(result.is_ok());
616
617        let captured = handler.captured();
618        assert!(
619            captured.iter().any(
620                |e| matches!(e, AppEffect::DeleteFile { path } if path.ends_with("STATUS.md"))
621            ),
622            "should delete STATUS.md"
623        );
624        assert!(
625            captured
626                .iter()
627                .any(|e| matches!(e, AppEffect::DeleteFile { path } if path.ends_with("NOTES.md"))),
628            "should delete NOTES.md"
629        );
630        assert!(
631            captured.iter().any(
632                |e| matches!(e, AppEffect::DeleteFile { path } if path.ends_with("ISSUES.md"))
633            ),
634            "should delete ISSUES.md"
635        );
636    }
637
638    #[test]
639    fn test_reset_context_for_isolation_skips_nonexistent_files() {
640        // No files exist - should check PathExists but not emit DeleteFile
641        let mut handler = MockAppEffectHandler::new();
642
643        let result = reset_context_for_isolation_effectful(&mut handler);
644
645        assert!(result.is_ok());
646
647        let captured = handler.captured();
648        // Should check if files exist
649        assert!(
650            captured.iter().any(
651                |e| matches!(e, AppEffect::PathExists { path } if path.ends_with("STATUS.md"))
652            ),
653            "should check if STATUS.md exists"
654        );
655        // Should NOT try to delete non-existent files
656        assert!(
657            !captured.iter().any(
658                |e| matches!(e, AppEffect::DeleteFile { path } if path.ends_with("STATUS.md"))
659            ),
660            "should NOT delete non-existent STATUS.md"
661        );
662    }
663
664    #[test]
665    fn test_check_prompt_exists_returns_true_when_file_exists() {
666        let mut handler = MockAppEffectHandler::new().with_file("PROMPT.md", "# Goal\nTest");
667
668        let result = check_prompt_exists_effectful(&mut handler);
669
670        assert!(result.is_ok());
671        assert!(result.unwrap(), "should return true when PROMPT.md exists");
672    }
673
674    #[test]
675    fn test_check_prompt_exists_returns_false_when_file_missing() {
676        let mut handler = MockAppEffectHandler::new();
677
678        let result = check_prompt_exists_effectful(&mut handler);
679
680        assert!(result.is_ok());
681        assert!(
682            !result.unwrap(),
683            "should return false when PROMPT.md doesn't exist"
684        );
685    }
686}