Skip to main content

ralph_workflow/files/io/
context.rs

1//! Context file management for Ralph's agent files.
2//!
3//! This module handles operations on context files (STATUS.md, NOTES.md, ISSUES.md)
4//! in the `.agent/` directory, including cleanup for isolation mode and fresh eyes
5//! for the reviewer phase.
6use crate::logger::Logger;
7use crate::workspace::Workspace;
8use std::fs;
9use std::io;
10use std::path::Path;
11
12use super::integrity;
13
14// Vague status line constants (for isolation mode)
15pub const VAGUE_STATUS_LINE: &str = "In progress.";
16pub const VAGUE_NOTES_LINE: &str = "Notes.";
17pub const VAGUE_ISSUES_LINE: &str = "No issues recorded.";
18
19/// Overwrite a file with a single-line content.
20///
21/// Enforces "1 sentence, 1 line" semantics by taking only the first line.
22pub fn overwrite_one_liner(path: &Path, line: &str) -> io::Result<()> {
23    let first_line = line.lines().next().unwrap_or_default().trim();
24    let content = if first_line.is_empty() {
25        "\n".to_string()
26    } else {
27        format!("{first_line}\n")
28    };
29    integrity::write_file_atomic(path, &content)
30}
31
32/// Clean context before reviewer phase.
33///
34/// When `isolation_mode` is true (the default), this function does nothing
35/// since STATUS.md, NOTES.md and ISSUES.md should not exist in isolation mode.
36///
37/// In non-isolation mode, this overwrites the context files with vague
38/// one-liners to give the reviewer "fresh eyes" without context from
39/// the development phase.
40///
41/// **Note:** This function uses the current working directory for paths.
42/// For explicit path control, use [`clean_context_for_reviewer_at`] instead.
43pub fn clean_context_for_reviewer(logger: &Logger, isolation_mode: bool) -> io::Result<()> {
44    clean_context_for_reviewer_at(Path::new("."), logger, isolation_mode)
45}
46
47/// Clean context before reviewer phase at a specific repository path.
48///
49/// # Arguments
50///
51/// * `repo_root` - Path to the repository root
52/// * `logger` - Logger for output
53/// * `isolation_mode` - If true, skip cleanup since files don't exist
54pub fn clean_context_for_reviewer_at(
55    repo_root: &Path,
56    logger: &Logger,
57    isolation_mode: bool,
58) -> io::Result<()> {
59    if isolation_mode {
60        // In isolation mode, these files don't exist, so nothing to clean
61        logger.info("Isolation mode: skipping context cleanup (files don't exist)");
62        return Ok(());
63    }
64
65    logger.info("Cleaning context for reviewer (fresh eyes)...");
66
67    let agent_dir = repo_root.join(".agent");
68
69    // Remove any archived context; preserving it defeats the "fresh eyes" intent.
70    let archive_dir = agent_dir.join("archive");
71    if archive_dir.exists() {
72        // Best-effort: if this fails, proceed with overwriting the live files.
73        let _ = fs::remove_dir_all(&archive_dir);
74    }
75
76    // Overwrite live context files with intentionally vague one-liners.
77    overwrite_one_liner(&agent_dir.join("STATUS.md"), VAGUE_STATUS_LINE)?;
78    overwrite_one_liner(&agent_dir.join("NOTES.md"), VAGUE_NOTES_LINE)?;
79    overwrite_one_liner(&agent_dir.join("ISSUES.md"), VAGUE_ISSUES_LINE)?;
80
81    logger.success("Context cleaned for reviewer");
82    Ok(())
83}
84
85/// Delete STATUS.md, NOTES.md and ISSUES.md for isolation mode.
86///
87/// This function is called at the start of each Ralph run when isolation mode
88/// is enabled (the default). It prevents context contamination by removing
89/// any stale status, notes, or issues from previous runs.
90///
91/// Unlike `clean_context_for_reviewer()`, this does NOT archive the files -
92/// in isolation mode, the goal is to operate without these files entirely,
93/// so there's no value in preserving them.
94///
95/// **Note:** This function uses the current working directory for paths.
96/// For explicit path control, use [`reset_context_for_isolation_at`] instead.
97pub fn reset_context_for_isolation(logger: &Logger) -> io::Result<()> {
98    reset_context_for_isolation_at(Path::new("."), logger)
99}
100
101/// Delete STATUS.md, NOTES.md and ISSUES.md for isolation mode at a specific repository path.
102///
103/// # Arguments
104///
105/// * `repo_root` - Path to the repository root
106/// * `logger` - Logger for output
107pub fn reset_context_for_isolation_at(repo_root: &Path, logger: &Logger) -> io::Result<()> {
108    logger.info("Isolation mode: removing STATUS.md, NOTES.md and ISSUES.md...");
109
110    let agent_dir = repo_root.join(".agent");
111    let status_path = agent_dir.join("STATUS.md");
112    let notes_path = agent_dir.join("NOTES.md");
113    let issues_path = agent_dir.join("ISSUES.md");
114
115    if status_path.exists() {
116        fs::remove_file(&status_path)?;
117        logger.info("Deleted .agent/STATUS.md");
118    }
119
120    if notes_path.exists() {
121        fs::remove_file(&notes_path)?;
122        logger.info("Deleted .agent/NOTES.md");
123    }
124
125    if issues_path.exists() {
126        fs::remove_file(&issues_path)?;
127        logger.info("Deleted .agent/ISSUES.md");
128    }
129
130    logger.success("Context reset for isolation mode");
131    Ok(())
132}
133
134/// Delete ISSUES.md after the final fix iteration completes in isolation mode.
135///
136/// This function is called at the end of the review-fix cycle when isolation mode
137/// is enabled. Between Review and Fix phases, ISSUES.md must persist so the Fix
138/// agent knows what to fix. But after all cycles complete, ISSUES.md should be
139/// deleted to prevent context contamination for subsequent runs.
140///
141/// **Note:** This function uses the current working directory for paths.
142/// For explicit path control, use [`delete_issues_file_for_isolation_at`] instead.
143pub fn delete_issues_file_for_isolation(logger: &Logger) -> io::Result<()> {
144    delete_issues_file_for_isolation_at(Path::new("."), logger)
145}
146
147/// Delete ISSUES.md after the final fix iteration completes in isolation mode at a specific repository path.
148///
149/// # Arguments
150///
151/// * `repo_root` - Path to the repository root
152/// * `logger` - Logger for output
153pub fn delete_issues_file_for_isolation_at(repo_root: &Path, logger: &Logger) -> io::Result<()> {
154    let issues_path = repo_root.join(".agent/ISSUES.md");
155
156    if issues_path.exists() {
157        fs::remove_file(&issues_path)?;
158        logger.info("Isolation mode: deleted .agent/ISSUES.md after final fix");
159    }
160
161    Ok(())
162}
163
164/// Delete ISSUES.md after the final fix iteration completes using workspace.
165///
166/// This version uses the [`Workspace`] trait for file operations, allowing tests
167/// to use [`MemoryWorkspace`] instead of the real filesystem.
168///
169/// # Arguments
170///
171/// * `workspace` - The workspace for file operations
172/// * `logger` - Logger for output
173pub fn delete_issues_file_for_isolation_with_workspace(
174    workspace: &dyn Workspace,
175    logger: &Logger,
176) -> io::Result<()> {
177    let issues_path = Path::new(".agent/ISSUES.md");
178
179    if workspace.exists(issues_path) {
180        workspace.remove(issues_path)?;
181        logger.info("Isolation mode: deleted .agent/ISSUES.md after final fix");
182    }
183
184    Ok(())
185}
186
187/// Overwrite a file with a single-line content using workspace.
188///
189/// Enforces "1 sentence, 1 line" semantics by taking only the first line.
190/// Uses atomic write to ensure file integrity.
191fn overwrite_one_liner_with_workspace(
192    workspace: &dyn Workspace,
193    path: &Path,
194    line: &str,
195) -> io::Result<()> {
196    let first_line = line.lines().next().unwrap_or_default().trim();
197    let content = if first_line.is_empty() {
198        "\n".to_string()
199    } else {
200        format!("{first_line}\n")
201    };
202    workspace.write_atomic(path, &content)
203}
204
205/// Clean context before reviewer phase using workspace.
206///
207/// This version uses the [`Workspace`] trait for file operations, allowing tests
208/// to use [`MemoryWorkspace`] instead of the real filesystem.
209///
210/// When `isolation_mode` is true (the default), this function does nothing
211/// since STATUS.md, NOTES.md and ISSUES.md should not exist in isolation mode.
212///
213/// In non-isolation mode, this overwrites the context files with vague
214/// one-liners to give the reviewer "fresh eyes" without context from
215/// the development phase.
216///
217/// # Arguments
218///
219/// * `workspace` - The workspace for file operations
220/// * `logger` - Logger for output
221/// * `isolation_mode` - If true, skip cleanup since files don't exist
222pub fn clean_context_for_reviewer_with_workspace(
223    workspace: &dyn Workspace,
224    logger: &Logger,
225    isolation_mode: bool,
226) -> io::Result<()> {
227    if isolation_mode {
228        // In isolation mode, these files don't exist, so nothing to clean
229        logger.info("Isolation mode: skipping context cleanup (files don't exist)");
230        return Ok(());
231    }
232
233    logger.info("Cleaning context for reviewer (fresh eyes)...");
234
235    // Remove any archived context; preserving it defeats the "fresh eyes" intent.
236    let archive_dir = Path::new(".agent/archive");
237    // Best-effort: if this fails, proceed with overwriting the live files.
238    let _ = workspace.remove_dir_all_if_exists(archive_dir);
239
240    // Overwrite live context files with intentionally vague one-liners.
241    overwrite_one_liner_with_workspace(
242        workspace,
243        Path::new(".agent/STATUS.md"),
244        VAGUE_STATUS_LINE,
245    )?;
246    overwrite_one_liner_with_workspace(workspace, Path::new(".agent/NOTES.md"), VAGUE_NOTES_LINE)?;
247    overwrite_one_liner_with_workspace(
248        workspace,
249        Path::new(".agent/ISSUES.md"),
250        VAGUE_ISSUES_LINE,
251    )?;
252
253    logger.success("Context cleaned for reviewer");
254    Ok(())
255}
256
257/// Update the status file with minimal, vague content.
258///
259/// Status is intentionally kept to 1 sentence to prevent context contamination.
260/// The content should encourage discovery rather than tracking detailed progress.
261///
262/// When `isolation_mode` is true (the default), this function does nothing
263/// since STATUS.md should not exist in isolation mode.
264///
265/// **Note:** This function uses the current working directory for paths.
266/// For explicit path control, use [`update_status_at`] instead.
267pub fn update_status(_status: &str, isolation_mode: bool) -> io::Result<()> {
268    update_status_at(Path::new("."), _status, isolation_mode)
269}
270
271/// Update the status file with minimal, vague content at a specific repository path.
272///
273/// # Arguments
274///
275/// * `repo_root` - Path to the repository root
276/// * `_status` - Status string (unused, always writes vague status)
277/// * `isolation_mode` - If true, do nothing since STATUS.md should not exist
278pub fn update_status_at(repo_root: &Path, _status: &str, isolation_mode: bool) -> io::Result<()> {
279    if isolation_mode {
280        // In isolation mode, STATUS.md should not exist
281        return Ok(());
282    }
283    overwrite_one_liner(&repo_root.join(".agent/STATUS.md"), VAGUE_STATUS_LINE)
284}
285
286/// Update the status file with minimal, vague content using workspace.
287///
288/// This version uses the [`Workspace`] trait for file operations, allowing tests
289/// to use [`MemoryWorkspace`] instead of the real filesystem.
290///
291/// Status is intentionally kept to 1 sentence to prevent context contamination.
292/// When `isolation_mode` is true (the default), this function does nothing
293/// since STATUS.md should not exist in isolation mode.
294///
295/// # Arguments
296///
297/// * `workspace` - The workspace for file operations
298/// * `_status` - Status string (unused, always writes vague status)
299/// * `isolation_mode` - If true, do nothing since STATUS.md should not exist
300pub fn update_status_with_workspace(
301    workspace: &dyn Workspace,
302    _status: &str,
303    isolation_mode: bool,
304) -> io::Result<()> {
305    if isolation_mode {
306        // In isolation mode, STATUS.md should not exist
307        return Ok(());
308    }
309    overwrite_one_liner_with_workspace(workspace, Path::new(".agent/STATUS.md"), VAGUE_STATUS_LINE)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::logger::Colors;
316    use crate::workspace::{MemoryWorkspace, Workspace};
317
318    // =========================================================================
319    // Workspace-based tests (for testability without real filesystem)
320    // =========================================================================
321    //
322    // Note: Old tests using with_temp_cwd have been removed since production
323    // code now uses workspace-based functions. The old std::fs functions
324    // are kept for backward compatibility but are not tested here.
325
326    #[test]
327    fn test_delete_issues_file_for_isolation_with_workspace() {
328        let workspace = MemoryWorkspace::new_test()
329            .with_dir(".agent")
330            .with_file(".agent/ISSUES.md", "some issues");
331
332        let colors = Colors { enabled: false };
333        let logger = Logger::new(colors);
334
335        delete_issues_file_for_isolation_with_workspace(&workspace, &logger).unwrap();
336
337        assert!(
338            !workspace.exists(Path::new(".agent/ISSUES.md")),
339            "ISSUES.md should be deleted via workspace"
340        );
341    }
342
343    #[test]
344    fn test_delete_issues_file_for_isolation_with_workspace_nonexistent() {
345        // File doesn't exist - should succeed silently
346        let workspace = MemoryWorkspace::new_test().with_dir(".agent");
347
348        let colors = Colors { enabled: false };
349        let logger = Logger::new(colors);
350
351        let result = delete_issues_file_for_isolation_with_workspace(&workspace, &logger);
352        assert!(result.is_ok(), "Should succeed when file doesn't exist");
353    }
354
355    #[test]
356    fn test_clean_context_for_reviewer_with_workspace_non_isolation() {
357        let workspace = MemoryWorkspace::new_test()
358            .with_dir(".agent")
359            .with_file(".agent/STATUS.md", "old status")
360            .with_file(".agent/NOTES.md", "old notes")
361            .with_file(".agent/ISSUES.md", "old issues")
362            .with_dir(".agent/archive")
363            .with_file(".agent/archive/old.txt", "archived");
364
365        let colors = Colors { enabled: false };
366        let logger = Logger::new(colors);
367
368        // Non-isolation mode should overwrite context files with vague content
369        clean_context_for_reviewer_with_workspace(&workspace, &logger, false).unwrap();
370
371        // Files should be overwritten with vague one-liners
372        assert_eq!(
373            workspace.read(Path::new(".agent/STATUS.md")).unwrap(),
374            "In progress.\n"
375        );
376        assert_eq!(
377            workspace.read(Path::new(".agent/NOTES.md")).unwrap(),
378            "Notes.\n"
379        );
380        assert_eq!(
381            workspace.read(Path::new(".agent/ISSUES.md")).unwrap(),
382            "No issues recorded.\n"
383        );
384        // Archive directory should be removed
385        assert!(
386            !workspace.exists(Path::new(".agent/archive")),
387            "Archive should be removed"
388        );
389    }
390
391    #[test]
392    fn test_clean_context_for_reviewer_with_workspace_isolation_mode() {
393        let workspace = MemoryWorkspace::new_test().with_dir(".agent");
394
395        let colors = Colors { enabled: false };
396        let logger = Logger::new(colors);
397
398        // Isolation mode should do nothing
399        clean_context_for_reviewer_with_workspace(&workspace, &logger, true).unwrap();
400
401        // No files should be created
402        assert!(
403            !workspace.exists(Path::new(".agent/STATUS.md")),
404            "STATUS.md should not be created in isolation mode"
405        );
406    }
407
408    #[test]
409    fn test_update_status_with_workspace_non_isolation() {
410        let workspace = MemoryWorkspace::new_test().with_dir(".agent");
411
412        // Non-isolation mode should write vague status
413        update_status_with_workspace(&workspace, "In progress.", false).unwrap();
414
415        let content = workspace.read(Path::new(".agent/STATUS.md")).unwrap();
416        assert_eq!(content, "In progress.\n");
417    }
418
419    #[test]
420    fn test_update_status_with_workspace_isolation_mode() {
421        let workspace = MemoryWorkspace::new_test().with_dir(".agent");
422
423        // Isolation mode should do nothing
424        update_status_with_workspace(&workspace, "In progress.", true).unwrap();
425
426        // STATUS.md should NOT be created
427        assert!(
428            !workspace.exists(Path::new(".agent/STATUS.md")),
429            "STATUS.md should not be created in isolation mode"
430        );
431    }
432}