Skip to main content

ralph_workflow/files/
backup.rs

1//! Backup management for PROMPT.md.
2//!
3//! This module handles creation and rotation of PROMPT.md backups to protect
4//! against accidental deletion or modification.
5
6use std::path::Path;
7
8use crate::workspace::Workspace;
9
10// ============================================================================
11// Workspace-based backup functions
12// ============================================================================
13
14/// Create a backup of PROMPT.md using the Workspace abstraction.
15///
16/// This function mirrors `create_prompt_backup_at` but uses the `Workspace` trait
17/// for all file operations, allowing tests to use `MemoryWorkspace` instead of
18/// real filesystem access.
19///
20/// With backup rotation enabled, this maintains up to 3 backup versions:
21/// `.agent/PROMPT.md.backup`, `.agent/PROMPT.md.backup.1`, and `.agent/PROMPT.md.backup.2`.
22///
23/// # Behavior
24///
25/// - If PROMPT.md doesn't exist, returns `Ok(None)` (nothing to backup)
26/// - Creates the `.agent` directory if it doesn't exist
27/// - Rotates existing backups: backup.2 → deleted, backup.1 → backup.2, backup → backup.1
28/// - Sets all backup files to read-only (best-effort; failures don't error)
29/// - Returns a warning string in the Ok variant if read-only setting fails
30///
31/// # Returns
32///
33/// Returns `io::Result<Option<String>>` where:
34/// - `Ok(None)` - backup created and read-only set successfully
35/// - `Ok(Some(warning))` - backup created but read-only couldn't be set
36/// - `Err(e)` - if the backup cannot be created
37///
38/// # Errors
39///
40/// Returns error if the operation fails.
41pub fn create_prompt_backup_with_workspace(
42    workspace: &dyn Workspace,
43) -> std::io::Result<Option<String>> {
44    let prompt_path = Path::new("PROMPT.md");
45
46    // If PROMPT.md doesn't exist, that's fine - nothing to backup
47    if !workspace.exists(prompt_path) {
48        return Ok(None);
49    }
50
51    // Ensure .agent directory exists
52    let agent_dir = Path::new(".agent");
53    let backup_base = Path::new(".agent/PROMPT.md.backup");
54    let backup_1 = Path::new(".agent/PROMPT.md.backup.1");
55    let backup_2 = Path::new(".agent/PROMPT.md.backup.2");
56
57    workspace.create_dir_all(agent_dir)?;
58
59    // Read PROMPT.md content
60    let content = workspace.read(prompt_path).map_err(|e| {
61        std::io::Error::new(
62            e.kind(),
63            format!("Failed to read PROMPT.md for backup: {e}"),
64        )
65    })?;
66
67    // Backup rotation: .2 → deleted, .1 → .2, .backup → .1
68    // Delete oldest backup if it exists
69    let _ = workspace.remove_if_exists(backup_2);
70
71    // Rotate .1 → .2
72    if workspace.exists(backup_1) {
73        let _ = workspace.rename(backup_1, backup_2);
74    }
75
76    // Rotate .backup → .1
77    if workspace.exists(backup_base) {
78        let _ = workspace.rename(backup_base, backup_1);
79    }
80
81    // Write new backup atomically to prevent corruption
82    workspace.write_atomic(backup_base, &content).map_err(|e| {
83        std::io::Error::new(e.kind(), format!("Failed to write PROMPT.md backup: {e}"))
84    })?;
85
86    // Set read-only permissions on all backups (best-effort)
87    let readonly_warning = [backup_base, backup_1, backup_2]
88        .iter()
89        .filter(|backup_path| workspace.exists(backup_path))
90        .find_map(|backup_path| {
91            workspace
92                .set_readonly(backup_path)
93                .err()
94                .map(|e| e.to_string())
95        });
96
97    Ok(readonly_warning)
98}
99
100/// Make PROMPT.md read-only using the Workspace abstraction.
101///
102/// This function mirrors `make_prompt_read_only_at` but uses the `Workspace` trait
103/// for all file operations.
104///
105/// # Returns
106///
107/// Returns `Option<String>` where:
108/// - `None` - permissions set successfully or file doesn't exist
109/// - `Some(warning)` - couldn't set read-only permissions
110pub fn make_prompt_read_only_with_workspace(workspace: &dyn Workspace) -> Option<String> {
111    let prompt_path = Path::new("PROMPT.md");
112
113    // If PROMPT.md doesn't exist, that's fine - nothing to protect
114    if !workspace.exists(prompt_path) {
115        return None;
116    }
117
118    // Try to set read-only permissions
119    match workspace.set_readonly(prompt_path) {
120        Ok(()) => None,
121        Err(e) => Some(format!(
122            "Failed to set read-only permissions on PROMPT.md: {e}"
123        )),
124    }
125}
126
127/// Make PROMPT.md writable again using the Workspace abstraction.
128///
129/// This function mirrors `make_prompt_writable_at` but uses the `Workspace` trait
130/// for all file operations.
131///
132/// # Returns
133///
134/// Returns `Option<String>` where:
135/// - `None` - permissions restored successfully or file doesn't exist
136/// - `Some(warning)` - couldn't restore write permissions
137pub fn make_prompt_writable_with_workspace(workspace: &dyn Workspace) -> Option<String> {
138    let prompt_path = Path::new("PROMPT.md");
139
140    // If PROMPT.md doesn't exist, that's fine - nothing to modify
141    if !workspace.exists(prompt_path) {
142        return None;
143    }
144
145    // Try to restore write permissions
146    match workspace.set_writable(prompt_path) {
147        Ok(()) => None,
148        Err(e) => Some(format!("Failed to set write permissions on PROMPT.md: {e}")),
149    }
150}
151
152// ============================================================================
153// Diff backup functions for oversized content
154// ============================================================================
155
156/// Path for diff backup file.
157const DIFF_BACKUP_PATH: &str = ".agent/DIFF.backup";
158
159/// Write oversized diff content to a backup file.
160///
161/// When a diff exceeds the inline size limit, this function writes it
162/// to `.agent/DIFF.backup` so agents can read it if needed.
163///
164/// # Arguments
165///
166/// * `workspace` - Workspace for file operations
167/// * `diff_content` - The diff content to write
168///
169/// # Returns
170///
171/// Returns `Ok(PathBuf)` with the backup path on success, or an error.
172///
173/// # Errors
174///
175/// Returns error if the operation fails.
176pub fn write_diff_backup_with_workspace(
177    workspace: &dyn Workspace,
178    diff_content: &str,
179) -> std::io::Result<std::path::PathBuf> {
180    let backup_path = Path::new(DIFF_BACKUP_PATH);
181
182    // Ensure .agent directory exists
183    workspace.create_dir_all(Path::new(".agent"))?;
184
185    // Write the diff content
186    workspace.write(backup_path, diff_content)?;
187
188    Ok(backup_path.to_path_buf())
189}
190
191// Note: Old tests using with_temp_cwd have been removed since production
192// code now uses workspace-based functions (_with_workspace variants).
193// The non-workspace functions have been removed. See workspace_tests module
194// below for the active tests covering current behavior.
195
196/// Tests for workspace-based backup functions
197#[cfg(all(test, feature = "test-utils"))]
198mod workspace_tests {
199    use super::*;
200    use crate::workspace::{MemoryWorkspace, Workspace};
201
202    #[test]
203    fn test_create_prompt_backup_with_workspace_creates_file() {
204        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test Content\n");
205
206        let result = create_prompt_backup_with_workspace(&workspace);
207        assert!(result.is_ok());
208
209        // Backup should exist with same content
210        assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup")));
211        assert_eq!(
212            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
213            "# Test Content\n"
214        );
215    }
216
217    #[test]
218    fn test_create_prompt_backup_with_workspace_missing_prompt() {
219        let workspace = MemoryWorkspace::new_test();
220        // No PROMPT.md exists
221
222        let result = create_prompt_backup_with_workspace(&workspace);
223        assert!(result.is_ok());
224        assert!(result.unwrap().is_none()); // No warning
225
226        // No backup should be created
227        assert!(!workspace.exists(Path::new(".agent/PROMPT.md.backup")));
228    }
229
230    #[test]
231    fn test_create_prompt_backup_with_workspace_rotation() {
232        let workspace = MemoryWorkspace::new_test()
233            .with_file("PROMPT.md", "# Version 1\n")
234            .with_dir(".agent");
235
236        // First backup
237        create_prompt_backup_with_workspace(&workspace).unwrap();
238        assert_eq!(
239            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
240            "# Version 1\n"
241        );
242
243        // Update PROMPT.md and create second backup
244        workspace
245            .write(Path::new("PROMPT.md"), "# Version 2\n")
246            .unwrap();
247        create_prompt_backup_with_workspace(&workspace).unwrap();
248
249        // Check rotation: .backup has v2, .backup.1 has v1
250        assert_eq!(
251            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
252            "# Version 2\n"
253        );
254        assert_eq!(
255            workspace.get_file(".agent/PROMPT.md.backup.1").unwrap(),
256            "# Version 1\n"
257        );
258
259        // Third backup
260        workspace
261            .write(Path::new("PROMPT.md"), "# Version 3\n")
262            .unwrap();
263        create_prompt_backup_with_workspace(&workspace).unwrap();
264
265        // Check: .backup=v3, .backup.1=v2, .backup.2=v1
266        assert_eq!(
267            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
268            "# Version 3\n"
269        );
270        assert_eq!(
271            workspace.get_file(".agent/PROMPT.md.backup.1").unwrap(),
272            "# Version 2\n"
273        );
274        assert_eq!(
275            workspace.get_file(".agent/PROMPT.md.backup.2").unwrap(),
276            "# Version 1\n"
277        );
278    }
279
280    #[test]
281    fn test_create_prompt_backup_with_workspace_deletes_oldest() {
282        let workspace = MemoryWorkspace::new_test().with_dir(".agent");
283
284        // Create 4 backups - oldest (v1) should be deleted
285        for i in 1..=4 {
286            workspace
287                .write(Path::new("PROMPT.md"), &format!("# Version {i}\n"))
288                .unwrap();
289            create_prompt_backup_with_workspace(&workspace).unwrap();
290        }
291
292        // Only 3 backups should exist
293        assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup")));
294        assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup.1")));
295        assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup.2")));
296
297        // Content: .backup=v4, .backup.1=v3, .backup.2=v2 (v1 deleted)
298        assert_eq!(
299            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
300            "# Version 4\n"
301        );
302        assert_eq!(
303            workspace.get_file(".agent/PROMPT.md.backup.1").unwrap(),
304            "# Version 3\n"
305        );
306        assert_eq!(
307            workspace.get_file(".agent/PROMPT.md.backup.2").unwrap(),
308            "# Version 2\n"
309        );
310    }
311
312    #[test]
313    fn test_make_prompt_read_only_with_workspace() {
314        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test\n");
315
316        // Should succeed (no-op for in-memory workspace, but function exists)
317        let result = make_prompt_read_only_with_workspace(&workspace);
318        assert!(result.is_none());
319    }
320
321    #[test]
322    fn test_make_prompt_read_only_with_workspace_missing() {
323        let workspace = MemoryWorkspace::new_test();
324        // No PROMPT.md
325
326        let result = make_prompt_read_only_with_workspace(&workspace);
327        assert!(result.is_none()); // No warning when file doesn't exist
328    }
329
330    #[test]
331    fn test_make_prompt_writable_with_workspace() {
332        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test\n");
333
334        let result = make_prompt_writable_with_workspace(&workspace);
335        assert!(result.is_none());
336    }
337
338    #[test]
339    fn test_write_diff_backup_with_workspace() {
340        let workspace = MemoryWorkspace::new_test();
341        let diff = "+added\n-removed";
342
343        let result = write_diff_backup_with_workspace(&workspace, diff);
344        assert!(result.is_ok());
345
346        let path = result.unwrap();
347        assert_eq!(path, Path::new(".agent/DIFF.backup"));
348        assert_eq!(workspace.get_file(".agent/DIFF.backup").unwrap(), diff);
349    }
350
351    #[test]
352    fn test_write_diff_backup_creates_agent_dir() {
353        let workspace = MemoryWorkspace::new_test();
354        // No .agent directory exists
355
356        let diff = "some diff content";
357        let result = write_diff_backup_with_workspace(&workspace, diff);
358        assert!(result.is_ok());
359
360        // Verify .agent directory was created and file exists
361        assert!(workspace.exists(Path::new(".agent")));
362        assert!(workspace.exists(Path::new(".agent/DIFF.backup")));
363        assert_eq!(workspace.get_file(".agent/DIFF.backup").unwrap(), diff);
364    }
365
366    #[test]
367    fn test_write_diff_backup_overwrites_existing() {
368        let workspace = MemoryWorkspace::new_test().with_file(".agent/DIFF.backup", "old content");
369
370        let new_diff = "new diff content";
371        let result = write_diff_backup_with_workspace(&workspace, new_diff);
372        assert!(result.is_ok());
373
374        // Should have overwritten the old content
375        assert_eq!(workspace.get_file(".agent/DIFF.backup").unwrap(), new_diff);
376    }
377}