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(¬es_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}