ralph_workflow/files/io/
agent_files.rs

1//! Agent file management for the `.agent/` directory.
2//!
3//! This module handles creation, modification, and cleanup of files
4//! in the `.agent/` directory that are used during pipeline execution.
5
6use std::fs;
7use std::io::{self, BufRead};
8use std::path::Path;
9
10use super::{
11    context::overwrite_one_liner, context::VAGUE_ISSUES_LINE, context::VAGUE_NOTES_LINE,
12    context::VAGUE_STATUS_LINE, integrity, recovery,
13};
14
15/// Files that Ralph generates during a run and should clean up.
16pub const GENERATED_FILES: &[&str] = &[
17    ".no_agent_commit",
18    ".agent/PLAN.md",
19    ".agent/commit-message.txt",
20    ".agent/checkpoint.json.tmp",
21];
22
23/// Check if a file contains a specific marker string.
24///
25/// Useful for detecting specific content patterns in files without
26/// loading the entire file into memory.
27///
28/// # Arguments
29///
30/// * `file_path` - Path to the file to check
31/// * `marker` - String to search for
32///
33/// # Returns
34///
35/// `Ok(true)` if the marker is found, `Ok(false)` if not found or file doesn't exist.
36pub fn file_contains_marker(file_path: &Path, marker: &str) -> io::Result<bool> {
37    if !file_path.exists() {
38        return Ok(false);
39    }
40
41    let file = fs::File::open(file_path)?;
42    let reader = io::BufReader::new(file);
43
44    for line in reader.lines().map_while(Result::ok) {
45        if line.contains(marker) {
46            return Ok(true);
47        }
48    }
49
50    Ok(false)
51}
52
53/// Ensure required files and directories exist.
54///
55/// Creates the `.agent/logs` directory if it doesn't exist.
56///
57/// When `isolation_mode` is true (the default), STATUS.md, NOTES.md and ISSUES.md
58/// are NOT created. This prevents context contamination from previous runs.
59pub fn ensure_files(isolation_mode: bool) -> io::Result<()> {
60    let agent_dir = Path::new(".agent");
61
62    // Best-effort state repair before we start touching `.agent/` contents.
63    // If the state is unrecoverable, fail early with a clear error.
64    if let recovery::RecoveryStatus::Unrecoverable(msg) = recovery::auto_repair(agent_dir)? {
65        return Err(io::Error::other(format!(
66            "Failed to repair .agent state: {msg}"
67        )));
68    }
69
70    integrity::check_filesystem_ready(agent_dir)?;
71    fs::create_dir_all(".agent/logs")?;
72
73    // Only create STATUS.md, NOTES.md and ISSUES.md when NOT in isolation mode
74    if !isolation_mode {
75        // Always overwrite/truncate these files to a single vague sentence to
76        // avoid detailed context persisting across runs.
77        overwrite_one_liner(Path::new(".agent/STATUS.md"), VAGUE_STATUS_LINE)?;
78        overwrite_one_liner(Path::new(".agent/NOTES.md"), VAGUE_NOTES_LINE)?;
79        overwrite_one_liner(Path::new(".agent/ISSUES.md"), VAGUE_ISSUES_LINE)?;
80    }
81
82    Ok(())
83}
84
85/// Delete the PLAN.md file after integration.
86///
87/// Called after the plan has been integrated into the codebase.
88/// Silently succeeds if the file doesn't exist.
89pub fn delete_plan_file() -> io::Result<()> {
90    let plan_path = Path::new(".agent/PLAN.md");
91    if plan_path.exists() {
92        fs::remove_file(plan_path)?;
93    }
94    Ok(())
95}
96
97/// Delete the commit-message.txt file after committing.
98///
99/// Called after a successful git commit to clean up the temporary
100/// commit message file. Silently succeeds if the file doesn't exist.
101pub fn delete_commit_message_file() -> io::Result<()> {
102    let msg_path = Path::new(".agent/commit-message.txt");
103    if msg_path.exists() {
104        fs::remove_file(msg_path)?;
105    }
106    Ok(())
107}
108
109/// Read commit message from file; fails if missing or empty.
110///
111/// # Errors
112///
113/// Returns an error if the file doesn't exist, cannot be read, or is empty.
114pub fn read_commit_message_file() -> io::Result<String> {
115    let msg_path = Path::new(".agent/commit-message.txt");
116    if msg_path.exists() && !integrity::verify_file_not_corrupted(msg_path)? {
117        return Err(io::Error::new(
118            io::ErrorKind::InvalidData,
119            ".agent/commit-message.txt appears corrupted",
120        ));
121    }
122    let content = fs::read_to_string(msg_path).map_err(|e| {
123        io::Error::new(
124            e.kind(),
125            format!("Failed to read .agent/commit-message.txt: {e}"),
126        )
127    })?;
128    let trimmed = content.trim();
129    if trimmed.is_empty() {
130        return Err(io::Error::new(
131            io::ErrorKind::InvalidData,
132            ".agent/commit-message.txt is empty",
133        ));
134    }
135    Ok(trimmed.to_string())
136}
137
138/// Write commit message to file.
139///
140/// Creates the .agent directory if it doesn't exist and writes the
141/// commit message to .agent/commit-message.txt.
142///
143/// # Arguments
144///
145/// * `message` - The commit message to write
146///
147/// # Errors
148///
149/// Returns an error if the file cannot be created or written.
150pub fn write_commit_message_file(message: &str) -> io::Result<()> {
151    let msg_path = Path::new(".agent/commit-message.txt");
152    if let Some(parent) = msg_path.parent() {
153        fs::create_dir_all(parent)?;
154    }
155    integrity::write_file_atomic(msg_path, message)?;
156    Ok(())
157}
158
159/// Clean up all generated files.
160///
161/// Removes temporary files that may have been left behind by an interrupted
162/// pipeline run. This includes PLAN.md, commit-message.txt, and other
163/// artifacts listed in [`GENERATED_FILES`].
164///
165/// This function is best-effort: individual file deletion failures are
166/// silently ignored since we're in a cleanup context.
167pub fn cleanup_generated_files() {
168    for file in GENERATED_FILES {
169        let _ = fs::remove_file(file);
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use tempfile::TempDir;
177    use test_helpers::with_temp_cwd;
178
179    #[test]
180    fn test_file_contains_marker() {
181        let dir = TempDir::new().unwrap();
182        let file_path = dir.path().join("test.txt");
183        fs::write(&file_path, "line1\nMARKER_TEST\nline3").unwrap();
184
185        assert!(file_contains_marker(&file_path, "MARKER_TEST").unwrap());
186        assert!(!file_contains_marker(&file_path, "NONEXISTENT").unwrap());
187    }
188
189    #[test]
190    fn test_file_contains_marker_missing() {
191        let result = file_contains_marker(Path::new("/nonexistent/file.txt"), "MARKER");
192        assert!(!result.unwrap());
193    }
194
195    #[test]
196    fn test_delete_plan_file() {
197        let dir = TempDir::new().unwrap();
198        let agent_dir = dir.path().join(".agent");
199        fs::create_dir_all(&agent_dir).unwrap();
200        let plan_path = agent_dir.join("PLAN.md");
201        fs::write(&plan_path, "test plan").unwrap();
202        assert!(plan_path.exists());
203
204        // Simulating delete_plan_file logic
205        fs::remove_file(&plan_path).unwrap();
206        assert!(!plan_path.exists());
207    }
208
209    #[test]
210    fn test_read_commit_message_file() {
211        with_temp_cwd(|_dir| {
212            fs::create_dir_all(".agent").unwrap();
213            fs::write(".agent/commit-message.txt", "feat: test commit\n").unwrap();
214
215            let msg = read_commit_message_file().unwrap();
216            assert_eq!(msg, "feat: test commit");
217        });
218    }
219
220    #[test]
221    fn test_read_commit_message_file_empty() {
222        with_temp_cwd(|_dir| {
223            fs::create_dir_all(".agent").unwrap();
224            fs::write(".agent/commit-message.txt", "   \n").unwrap();
225            assert!(read_commit_message_file().is_err());
226        });
227    }
228
229    #[test]
230    fn test_ensure_files_isolation_mode() {
231        with_temp_cwd(|_dir| {
232            ensure_files(true).unwrap();
233
234            // Should not create PROMPT.md (creation is an explicit user action)
235            assert!(!Path::new("PROMPT.md").exists());
236
237            // Should NOT create STATUS.md, NOTES.md and ISSUES.md in isolation mode
238            assert!(!Path::new(".agent/STATUS.md").exists());
239            assert!(!Path::new(".agent/NOTES.md").exists());
240            assert!(!Path::new(".agent/ISSUES.md").exists());
241        });
242    }
243
244    #[test]
245    fn test_ensure_files_non_isolation_mode() {
246        with_temp_cwd(|_dir| {
247            ensure_files(false).unwrap();
248
249            // Should not create PROMPT.md (creation is an explicit user action)
250            assert!(!Path::new("PROMPT.md").exists());
251            assert!(Path::new(".agent/STATUS.md").exists());
252            assert!(Path::new(".agent/NOTES.md").exists());
253            assert!(Path::new(".agent/ISSUES.md").exists());
254        });
255    }
256}